diff --git a/CHANGELOG.md b/CHANGELOG.md index 19d0740..182ec3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +**Branding.** A visual identity (`branding/`, guide in `branding/README.md`): a dotted simurg mark in detailed, simple +and micro drawings, wordmark and lockups, icons, README banner, social preview and featured-image templates, with +contrast and colour-vision checks. HTML reports (QC report, compare dashboard, methylation QC) get a favicon and a +header band with the mark, the software version and a checksum of the data they show. The mark geometry lives in +`simurg/core/brand.py`. Blog posts and featured images in `blog/`. + ## 2.0.0 — 2026-09-30 (v2 modules) The Python package is 2.0.0. The optional Rust kernels are unchanged and stay `simurg_core` 1.1.0. diff --git a/README.md b/README.md index 8d22623..179ceaf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # SimurgArray +

+
# SimurgArray user guide (v2.0)
SimurgArray turns Illumina Infinium genotyping BeadChip scans (IDAT files) into genotype calls, QC tables and
diff --git a/scripts/brand/simurg_mark.py b/scripts/brand/simurg_mark.py
index efb2ee1..bba28f1 100644
--- a/scripts/brand/simurg_mark.py
+++ b/scripts/brand/simurg_mark.py
@@ -1,86 +1,24 @@
-"""Geometry of the SimurgArray mark on its dot grid (no dependencies beyond the standard library).
+"""The mark geometry for the image scripts; the single source is ``src/simurg/core/brand.py``.
-A frontal simurg with raised wings (three feathers each), a three-dot crown and three tail plumes: red channel,
-gold, green channel, each ending in a peacock eye. Shapes are capsules, circles and an ellipse in grid units; a cell
-belongs to the first shape that contains its centre. ``SIMPLE`` and ``MICRO`` are hand-drawn small versions.
+Loaded straight from the file, so these scripts run with any Python that has Pillow, without installing SimurgArray.
"""
from __future__ import annotations
-import math
-from typing import Any
+import importlib.util
+from pathlib import Path
+_path = Path(__file__).resolve().parents[2] / "src" / "simurg" / "core" / "brand.py"
+_spec = importlib.util.spec_from_file_location("simurg_brand", _path)
+assert _spec is not None and _spec.loader is not None
+_brand = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(_brand)
-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)
+COLOURS = _brand.COLOURS
+MICRO = _brand.MICRO
+SIMPLE = _brand.SIMPLE
+Grid = _brand.Grid
+art = _brand.art
+detailed = _brand.detailed
-
-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 math.hypot(x - p[0], y - p[1]) <= p[2]
- if kind == "cap":
- pts, r = p
- return min(_seg(x, y, pts[i], pts[i + 1]) for i in range(len(pts) - 1)) <= r
- cx, cy, rx, ry = p
- return ((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)
+__all__ = ["COLOURS", "MICRO", "SIMPLE", "Grid", "art", "detailed"]
diff --git a/src/simurg/core/brand.py b/src/simurg/core/brand.py
new file mode 100644
index 0000000..363960b
--- /dev/null
+++ b/src/simurg/core/brand.py
@@ -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'Every discrepancy and every unmatched record is listed in discrepancies.tsv.gz.
') + from simurg.core.brand import BAR_CSS, favicon_link, report_bar + + checksum = hashlib.sha256(json.dumps(summary, sort_keys=True, default=str).encode("utf-8")).hexdigest() + bar = report_bar("Differential test", __version__, "summary sha256", checksum) return ( '' '' - f"