Apply the brand across the repo: reports, README, guide
* simurg/core/brand.py: single source of the mark geometry, colours, favicon and the report header band (scripts/brand now load it from there). * HTML reports (QC report, compare dashboard, methylation QC): offline SVG favicon, indigo header band with the mark, version and a sha256 of the data shown; Noto Sans font stack; methylation plots in Okabe-Ito colours. * README banner, guide logo, brand/blog links; branding brief updated to the final identity; Noto (OFL) and Okabe-Ito entries in THIRD_PARTY_NOTICES; CHANGELOG (Unreleased). Unit tests + P5 QC oracle: 285 passed; ruff and mypy clean.
This commit is contained in:
1 parent
deb8e263b2
commit
8ee0c46bba
12 files changed
+222
-106
No files matched your search
@@ -0,0 +1,144 @@
|
||||
"""SimurgArray visual identity: the dotted simurg mark, brand colours, and the header band used by every HTML report.
|
||||
|
||||
Pure and dependency free (no I/O). ``branding/README.md`` is the guide; ``scripts/brand/make_brand.py`` renders the
|
||||
logo files from the same geometry, so reports, icons and images always show the same dots [OURS].
|
||||
|
||||
The mark is a frontal simurg on a dot grid: gold body and wings, a three dot crown, and three tail plumes (red
|
||||
channel, gold, green channel) ending in peacock eyes. ``detailed()`` is drawn from shapes in grid units (a cell
|
||||
belongs to the first shape containing its centre); ``SIMPLE`` and ``MICRO`` are hand drawn for small sizes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import math
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
COLOURS = {
|
||||
"indigo": "#1E2240", "indigo_deep": "#12142A", "paper": "#F7F5EF", "red": "#C8453A", "green": "#2F8F5B",
|
||||
"gold": "#D4A017", "gold_deep": "#A87A00", "gold_ink": "#8C6500", "muted_dark": "#A3A7C9",
|
||||
"muted_light": "#5A5E7E",
|
||||
} # fmt: skip
|
||||
CHART = {"AA": "#D55E00", "AB": "#009E73", "BB": "#0072B2"} # Okabe and Ito; always paired with a shape or label
|
||||
TAGLINE = "Thirty birds, one picture."
|
||||
FONT_STACK = '"Noto Sans",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif'
|
||||
MONO_STACK = '"Noto Sans Mono",ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace'
|
||||
|
||||
|
||||
def _seg(px: float, py: float, a: tuple[float, float], b: tuple[float, float]) -> float:
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
t = max(0.0, min(1.0, ((px - a[0]) * dx + (py - a[1]) * dy) / ((dx * dx + dy * dy) or 1.0)))
|
||||
return math.hypot(px - a[0] - t * dx, py - a[1] - t * dy)
|
||||
|
||||
|
||||
CX = 15.5
|
||||
_WING = [([(13.6, 9.6), (8, 5.2), (1.8, 1.4)], 1.25), ([(13.4, 11), (7.5, 8.4), (1.2, 6.6)], 1.25),
|
||||
([(13.4, 12.6), (8, 12), (2.6, 11.8)], 1.2)] # fmt: skip
|
||||
SHAPES: list[tuple[str, Any, str]] = []
|
||||
for pts, r in _WING:
|
||||
SHAPES.append(("cap", (pts, r), "gold"))
|
||||
SHAPES.append(("cap", ([(2 * CX - x, y) for x, y in pts], r), "gold"))
|
||||
SHAPES += [
|
||||
("circ", (CX, 4.4, 2.3), "gold"), ("cap", ([(CX, 6), (CX, 8)], 1.3), "gold"), # head, neck
|
||||
("circ", (CX, 1.2, 0.62), "gold"), ("circ", (13.6, 1.9, 0.62), "gold"), ("circ", (17.4, 1.9, 0.62), "gold"),
|
||||
("ell", (CX, 12.5, 2.9, 5.4), "gold"), # body
|
||||
("cap", ([(14.8, 17), (12.6, 20.5), (10.4, 23.2)], 0.7), "red"), ("circ", (10, 24.3, 1.6), "red"),
|
||||
("cap", ([(CX, 17.5), (CX, 23.2)], 0.7), "gold"), ("circ", (CX, 24.6, 1.6), "gold"),
|
||||
("cap", ([(16.2, 17), (18.4, 20.5), (20.6, 23.2)], 0.7), "green"), ("circ", (21, 24.3, 1.6), "green"),
|
||||
] # fmt: skip
|
||||
DETAILED_SIZE = (31, 27)
|
||||
SIMPLE = """
|
||||
X.........X
|
||||
XX.......XX
|
||||
.XX..X..XX.
|
||||
..XXXXXXX..
|
||||
XXXXXXXXXXX
|
||||
...XXXXX...
|
||||
....XXX....
|
||||
...R.X.G...
|
||||
..R..X..G..
|
||||
.RR.XXX.GG.
|
||||
"""
|
||||
MICRO = """
|
||||
X.....X
|
||||
XX.X.XX
|
||||
.XXXXX.
|
||||
..XXX..
|
||||
...X...
|
||||
.R.X.G.
|
||||
R..X..G
|
||||
"""
|
||||
Grid = dict[tuple[int, int], str]
|
||||
|
||||
|
||||
def _inside(x: float, y: float, kind: str, p: Any) -> bool:
|
||||
if kind == "circ":
|
||||
return bool(math.hypot(x - p[0], y - p[1]) <= p[2])
|
||||
if kind == "cap":
|
||||
pts, r = p
|
||||
return bool(min(_seg(x, y, pts[i], pts[i + 1]) for i in range(len(pts) - 1)) <= r)
|
||||
cx, cy, rx, ry = p
|
||||
return bool(((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 <= 1)
|
||||
|
||||
|
||||
def detailed() -> tuple[Grid, int, int]:
|
||||
cols, rows = DETAILED_SIZE
|
||||
g: Grid = {}
|
||||
for r in range(rows):
|
||||
for c in range(cols):
|
||||
for kind, p, layer in SHAPES:
|
||||
if _inside(c + 0.5, r + 0.5, kind, p):
|
||||
g[(c, r)] = layer
|
||||
break
|
||||
return g, cols, rows
|
||||
|
||||
|
||||
def art(s: str) -> tuple[Grid, int, int]:
|
||||
lines = s.strip().splitlines()
|
||||
g = {(c, r): {"X": "gold", "R": "red", "G": "green"}[ch] for r, ln in enumerate(lines) for c, ch in enumerate(ln)
|
||||
if ch != "."} # fmt: skip
|
||||
return g, len(lines[0]), len(lines)
|
||||
|
||||
|
||||
def mark_svg(size: str = "simple", height: float = 24, *, scheme: str = "dark", title: str = "SimurgArray") -> str:
|
||||
"""Inline SVG of the mark (``size``: detailed, simple or micro); ``scheme`` dark (gold) or light (deep gold)."""
|
||||
g, cols, rows = detailed() if size == "detailed" else art(SIMPLE if size == "simple" else MICRO)
|
||||
gold = COLOURS["gold"] if scheme == "dark" else COLOURS["gold_deep"]
|
||||
fill = {"gold": gold, "red": COLOURS["red"], "green": COLOURS["green"]}
|
||||
width = height * (cols + 1) / (rows + 1)
|
||||
dots = "".join(f'<circle cx="{c + 1}" cy="{r + 1}" r="0.42" fill="{fill[layer]}"/>'
|
||||
for (c, r), layer in sorted(g.items(), key=lambda kv: (kv[0][1], kv[0][0]))) # fmt: skip
|
||||
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0.5 0.5 {cols + 1} {rows + 1}" '
|
||||
f'width="{width:.1f}" height="{height:.1f}" role="img" aria-label="{html.escape(title)}">'
|
||||
f"<title>{html.escape(title)}</title>{dots}</svg>") # fmt: skip
|
||||
|
||||
|
||||
def favicon_link() -> str:
|
||||
"""``<link rel="icon">`` with the micro mark on an indigo tile as an inline data URI (reports stay offline)."""
|
||||
g, cols, _ = art(MICRO)
|
||||
fill = {"gold": COLOURS["gold"], "red": COLOURS["red"], "green": COLOURS["green"]}
|
||||
n = cols + 1
|
||||
dots = "".join(f'<circle cx="{c + 1}" cy="{r + 1}" r="0.44" fill="{fill[layer]}"/>' for (c, r), layer in g.items())
|
||||
svg = (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {n + 1} {n + 1}">'
|
||||
f'<rect width="{n + 1}" height="{n + 1}" rx="1.5" fill="{COLOURS["indigo"]}"/>{dots}</svg>') # fmt: skip
|
||||
return f'<link rel="icon" href="data:image/svg+xml,{quote(svg, safe=" =:/,")}">'
|
||||
|
||||
|
||||
BAR_CSS = (
|
||||
f".sa-bar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:8px 16px;background:{COLOURS['indigo']};"
|
||||
f"color:{COLOURS['paper']};font-family:{FONT_STACK}}}"
|
||||
f".sa-bar .sa-name{{font-weight:600;letter-spacing:.2px}}"
|
||||
f".sa-bar .sa-name b{{color:{COLOURS['gold']};font-weight:600}}"
|
||||
f".sa-bar .sa-title{{font-size:17px;font-weight:600;margin-left:6px}}"
|
||||
f".sa-bar .sa-meta{{margin-left:auto;font:12px {MONO_STACK};color:{COLOURS['muted_dark']}}}"
|
||||
)
|
||||
|
||||
|
||||
def report_bar(title: str, version: str, checksum_label: str, checksum: str) -> str:
|
||||
"""Header band for HTML reports: the mark, the name, the report title, and on the right the software version
|
||||
and a checksum of what the report shows. The mark is only recognition; the checksum is what proves anything."""
|
||||
return (f'<div class="sa-bar">{mark_svg("simple", 22)}<span class="sa-name">Simurg<b>Array</b></span>'
|
||||
f'<span class="sa-title">{html.escape(title)}</span>'
|
||||
f'<span class="sa-meta">{html.escape(version)} · {html.escape(checksum_label)} '
|
||||
f"{html.escape(checksum[:12])}</span></div>") # fmt: skip
|
||||
Reference in new issue
Block a user