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:
kemal committed 2026-09-30 08:01:30 +01:00
1 parent aea1d459d9
commit c71f1b6333
12 files changed
+222 -106

No files matched your search

+8
View File
@@ -1,5 +1,13 @@
# Changelog # 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) ## 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. The Python package is 2.0.0. The optional Rust kernels are unchanged and stay `simurg_core` 1.1.0.
+3
View File
@@ -1,5 +1,7 @@
# SimurgArray # SimurgArray
<p align="center"><img src="branding/templates/readme_banner_1280x320.png" alt="SimurgArray: Thirty birds, one picture." width="100%"></p>
Personal research toolkit for Illumina Infinium genotyping BeadChip data Personal research toolkit for Illumina Infinium genotyping BeadChip data
(IDAT → normalization → genotype calling → BAF/LRR → QC → VCF/PLINK; CN/LOH/mosaicism, PGx, cluster training, (IDAT → normalization → genotype calling → BAF/LRR → QC → VCF/PLINK; CN/LOH/mosaicism, PGx, cluster training,
methylation control QC). methylation control QC).
@@ -7,6 +9,7 @@ methylation control QC).
- **SIMURGARRAY_MASTER_PLAN.md** — architecture, algorithms, roadmap - **SIMURGARRAY_MASTER_PLAN.md** — architecture, algorithms, roadmap
- **SIMURGARRAY_FUNCTIONAL_SPEC.md** — 416 itemised requirements (CLI, inputs, outputs, QC, CN, PGx, methylation, edge cases) - **SIMURGARRAY_FUNCTIONAL_SPEC.md** — 416 itemised requirements (CLI, inputs, outputs, QC, CN, PGx, methylation, edge cases)
- `docs/validation/` — per-phase validation reports · `docs/known_unknowns.md` — findings log · `docs/research/` — background - `docs/validation/` — per-phase validation reports · `docs/known_unknowns.md` — findings log · `docs/research/` — background
- `branding/` — logo, icons, templates and the brand guide · `blog/` — five posts about the project
## Status ## Status
+2
View File
@@ -19,6 +19,8 @@ used only as external subprocess oracles and are not copied or linked.
| [PharmVar](https://www.pharmvar.org) haplotype definitions (Gaedigk et al. 2018 CPT 103:399) | PharmVar terms of use (attribution) | per-gene zips downloaded by `scripts/pgx/fetch_sources.sh` into `data/` (never committed); `simurg pgx db build` derives core-allele definitions for genes CPIC does not define | | [PharmVar](https://www.pharmvar.org) haplotype definitions (Gaedigk et al. 2018 CPT 103:399) | PharmVar terms of use (attribution) | per-gene zips downloaded by `scripts/pgx/fetch_sources.sh` into `data/` (never committed); `simurg pgx db build` derives core-allele definitions for genes CPIC does not define |
| [GeT-RM](https://www.cdc.gov/lab-quality/php/get-rm/reference-materials.html) consolidated PGx/HLA table (CDC) | US government work | validation truth only (`scripts/pgx/getrm_truth.py`), not redistributed | | [GeT-RM](https://www.cdc.gov/lab-quality/php/get-rm/reference-materials.html) consolidated PGx/HLA table (CDC) | US government work | validation truth only (`scripts/pgx/getrm_truth.py`), not redistributed |
| [ClinPGx](https://www.clinpgx.org) (formerly PharmGKB) clinical-annotation variants | CC BY-SA 4.0 (attribution: ClinPGx, https://www.clinpgx.org/page/citingClinpgx) | `clinicalVariants.zip` downloaded by `scripts/pgx/fetch_sources.sh` into `data/` (never committed); rsIDs become genotype-only variant-based genes in the local PGx DB | | [ClinPGx](https://www.clinpgx.org) (formerly PharmGKB) clinical-annotation variants | CC BY-SA 4.0 (attribution: ClinPGx, https://www.clinpgx.org/page/citingClinpgx) | `clinicalVariants.zip` downloaded by `scripts/pgx/fetch_sources.sh` into `data/` (never committed); rsIDs become genotype-only variant-based genes in the local PGx DB |
| [Noto Sans, Noto Sans Mono](https://fonts.google.com/noto) — Copyright 2022 The Noto Project Authors | SIL Open Font License 1.1 | Glyph outlines of the wordmark in `branding/logo/` (converted to SVG paths by `scripts/brand/make_brand.py`); named in report CSS but never bundled |
| Okabe and Ito colour-universal-design palette (Okabe & Ito 2008, *Color Universal Design*) | free to use (cited) | Chart colours in `simurg/core/brand.py` and the HTML reports |
## BSD-2-Clause (BeadArrayFiles, verbatim from its README) ## BSD-2-Clause (BeadArrayFiles, verbatim from its README)
+8 -11
View File
@@ -1,6 +1,6 @@
# Branding brief for SimurgArray # Branding brief for SimurgArray
Copy everything below the line into the branding agent. Copy everything below the line into a branding agent. The identity it asks for now exists in `branding/`; this brief is kept for future refinements.
________________________________________________________________________________ ________________________________________________________________________________
@@ -26,16 +26,13 @@ Precise, calm, honest, a little dry, quietly clever. Think of a well made scient
## Existing visual direction (keep and refine it, do not replace it) ## Existing visual direction (keep and refine it, do not replace it)
* **Core motif:** a bird in flight drawn only with round dots on a square grid, like beads on a BeadChip. The current bird faces right, wings raised and swept back, with a small crest, a hooked beak and three long tail plumes ending in round "peacock eye" dots. The identity already exists in `branding/` (guide: `branding/README.md`). Treat it as the starting point.
* **Channel colors:** the scanner has a red and a green channel. Dots on the outline of the bird are red and green; the inner dots, where both channels "overlap", are gold.
* **Palette (starting point, you may tune values for contrast):** * **Core motif:** a simurg seen from the front, wings raised in a V with three feathers each, a three dot crown, drawn only with round dots on a square grid like beads on a BeadChip. Three drawings: detailed (31 x 27 dots), simple (11 x 10) and micro (7 x 7).
* Channel red #C8453A * **Channel colours:** the body, wings and crown are gold, "where the two channels meet". Three tail plumes: red on the left (red channel), gold in the middle, green on the right (green channel), each ending in a round peacock eye.
* Channel green #2F8F5B * **Palette:** night indigo #1E2240, paper #F7F5EF, simurg gold #D4A017, gold deep #A87A00 (the bird on light backgrounds), gold ink #8C6500 (accent text on light), channel red #C8453A, channel green #2F8F5B, muted #A3A7C9 on dark and #5A5E7E on light. Charts use the Okabe and Ito colours with shapes.
* Simurg gold #D4A017 * **Type:** Noto Sans (wordmark in SemiBold, "Array" in the accent colour) and Noto Sans Mono.
* Night indigo #1E2240 (backgrounds; most of the work happened at night) * The five blog featured images, the README banner, the social preview, the avatar and the HTML report header band already use it.
* Paper #F7F5EF (documents, light backgrounds)
* **Type so far:** Noto Sans for text, Noto Sans Mono for anything a user might paste into a terminal.
* Five blog featured images already exist in this style (1200 x 630, indigo background, faint bead grid, bird on the right, title on the left, footer with three colored dots, the name and the tagline). Stay consistent with them.
## Deliverables ## Deliverables
+2
View File
@@ -1,3 +1,5 @@
<img src="../branding/logo/png/lockup_horizontal_light.png" alt="SimurgArray" width="360">
# SimurgArray user guide (v2.0) # SimurgArray user guide (v2.0)
SimurgArray turns Illumina Infinium genotyping BeadChip scans (IDAT files) into genotype calls, QC tables and SimurgArray turns Illumina Infinium genotyping BeadChip scans (IDAT files) into genotype calls, QC tables and
+16 -78
View File
@@ -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, Loaded straight from the file, so these scripts run with any Python that has Pillow, without installing SimurgArray.
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.
""" """
from __future__ import annotations from __future__ import annotations
import math import importlib.util
from typing import Any 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: COLOURS = _brand.COLOURS
dx, dy = b[0] - a[0], b[1] - a[1] MICRO = _brand.MICRO
t = max(0.0, min(1.0, ((px - a[0]) * dx + (py - a[1]) * dy) / ((dx * dx + dy * dy) or 1.0))) SIMPLE = _brand.SIMPLE
return math.hypot(px - a[0] - t * dx, py - a[1] - t * dy) Grid = _brand.Grid
art = _brand.art
detailed = _brand.detailed
__all__ = ["COLOURS", "MICRO", "SIMPLE", "Grid", "art", "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)
+144
View File
@@ -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
+11 -3
View File
@@ -7,6 +7,7 @@ A and B are two files or two folders; folders are paired by relative path. Outpu
from __future__ import annotations from __future__ import annotations
import gzip import gzip
import hashlib
import html import html
import io import io
import json import json
@@ -228,7 +229,8 @@ DASH_CSS = """
:root{--bg:#fafafa;--fg:#222;--muted:#666;--line:#ddd;--ok:#d6e6f5;--near:#e8f2d6;--bad:#f9d3ac;--unk:#e6e6e6} :root{--bg:#fafafa;--fg:#222;--muted:#666;--line:#ddd;--ok:#d6e6f5;--near:#e8f2d6;--bad:#f9d3ac;--unk:#e6e6e6}
@media (prefers-color-scheme: dark){:root{--bg:#1d1f21;--fg:#e6e6e6;--muted:#aaa;--line:#444; @media (prefers-color-scheme: dark){:root{--bg:#1d1f21;--fg:#e6e6e6;--muted:#aaa;--line:#444;
--ok:#23405c;--near:#35451f;--bad:#6b3d10;--unk:#3a3a3a}} --ok:#23405c;--near:#35451f;--bad:#6b3d10;--unk:#3a3a3a}}
body{margin:0;padding:16px;background:var(--bg);color:var(--fg);font:14px/1.4 system-ui,sans-serif} body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.4 "Noto Sans",system-ui,sans-serif}
.sa-main{padding:16px}
table{border-collapse:collapse;margin:6px 0} table{border-collapse:collapse;margin:6px 0}
td,th{border:1px solid var(--line);padding:3px 8px;text-align:right} td,th{border:1px solid var(--line);padding:3px 8px;text-align:right}
td:first-child,th:first-child{text-align:left} td:first-child,th:first-child{text-align:left}
@@ -294,12 +296,18 @@ def render_dashboard(summary: dict[str, Any], examples: dict[str, list[dict[str,
+ _table(["pair", "locus", "A", "B"], body) + _table(["pair", "locus", "A", "B"], body)
) )
parts.append('<p class="muted">Every discrepancy and every unmatched record is listed in discrepancies.tsv.gz.</p>') parts.append('<p class="muted">Every discrepancy and every unmatched record is listed in discrepancies.tsv.gz.</p>')
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 ( return (
'<!doctype html><html lang="en"><head><meta charset="utf-8">' '<!doctype html><html lang="en"><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">' '<meta name="viewport" content="width=device-width, initial-scale=1">'
f"<title>{title} — {PRODUCT_NAME}</title><style>{DASH_CSS}</style></head><body>" f"<title>{title} · {PRODUCT_NAME}</title>{favicon_link()}<style>{DASH_CSS}{BAR_CSS}</style></head><body>"
+ bar
+ '<div class="sa-main">'
+ "\n".join(parts) + "\n".join(parts)
+ "</body></html>\n" + "</div></body></html>\n"
) )
+16 -8
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import datetime as _dt import datetime as _dt
import gzip import gzip
import hashlib
import html import html
import json import json
import math import math
@@ -390,22 +391,29 @@ def _write_html(
th_rows = "".join( th_rows = "".join(
f"<tr><td>{m.column}</td><td>{m.key}</td><td>{_cell(th.get(m.key).value)}</td></tr>" for m in metrics f"<tr><td>{m.column}</td><td>{m.key}</td><td>{_cell(th.get(m.key).value)}</td></tr>" for m in metrics
) )
from simurg import __version__
from simurg.core.brand import BAR_CSS, CHART, FONT_STACK, favicon_link, report_bar
generated = _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%d %H:%M UTC") generated = _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%d %H:%M UTC")
checksum = hashlib.sha256(json.dumps(rows, sort_keys=True, default=str).encode("utf-8")).hexdigest()
bar = report_bar("Methylation control QC", __version__, "samples sha256", checksum)
page = f"""<!doctype html><html lang="en"><head><meta charset="utf-8"> page = f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>Methylation QC</title> <meta name="viewport" content="width=device-width, initial-scale=1"><title>Methylation QC · SimurgArray</title>
<style>body{{font:14px system-ui,sans-serif;margin:16px;color:#222;background:#fff}}table{{border-collapse:collapse}} {favicon_link()}
<style>body{{font:14px {FONT_STACK};margin:0;color:#222;background:#fff}}.sa-main{{padding:16px}}
table{{border-collapse:collapse}}
td,th{{border:1px solid #ccc;padding:3px 6px;text-align:left}}.plots{{display:flex;flex-wrap:wrap;gap:16px}} td,th{{border:1px solid #ccc;padding:3px 6px;text-align:left}}.plots{{display:flex;flex-wrap:wrap;gap:16px}}
.plot{{width:min(560px,100%);height:380px}}.note{{color:#666}}</style> .plot{{width:min(560px,100%);height:380px}}.note{{color:#666}}{BAR_CSS}</style>
<script>{_plotly()}</script></head><body> <script>{_plotly()}</script></head><body>
<h1>Methylation control QC</h1> {bar}<div class="sa-main">
<p class="note">{len(rows)} samples · manifest {html.escape(Path(man.path).name)} <p class="note">{len(rows)} samples · manifest {html.escape(Path(man.path).name)}
· profile {html.escape(th.profile or "none")} · generated {generated} · SimurgArray</p> · profile {html.escape(th.profile or "none")} · generated {generated}</p>
<div class="plots"><div id="hist" class="plot"></div><div id="pca" class="plot"></div></div> <div class="plots"><div id="hist" class="plot"></div><div id="pca" class="plot"></div></div>
<h2>Samples</h2><table><tr>{"".join(f"<th>{c}</th>" for c in head)}</tr>{trs}</table> <h2>Samples</h2><table><tr>{"".join(f"<th>{c}</th>" for c in head)}</tr>{trs}</table>
<h2>Thresholds</h2><table><tr><th>metric</th><th>key</th><th>threshold</th></tr>{th_rows}</table> <h2>Thresholds</h2><table><tr><th>metric</th><th>key</th><th>threshold</th></tr>{th_rows}</table>
<script>const D={json.dumps(data, allow_nan=False, default=_json_num)}; <script>const D={json.dumps(data, allow_nan=False, default=_json_num)};
Plotly.newPlot("hist",[{{x:D.hist.pass,type:"histogram",name:"pass",marker:{{color:"#4c78a8"}}}}, Plotly.newPlot("hist",[{{x:D.hist.pass,type:"histogram",name:"pass",marker:{{color:"{CHART["BB"]}"}}}},
{{x:D.hist.fail,type:"histogram",name:"fail",marker:{{color:"#e45756"}}}}], {{x:D.hist.fail,type:"histogram",name:"fail",marker:{{color:"{CHART["AA"]}"}}}}],
{{title:"Probes passing detection",barmode:"overlay",xaxis:{{title:"proportion"}},yaxis:{{title:"samples"}}}}); {{title:"Probes passing detection",barmode:"overlay",xaxis:{{title:"proportion"}},yaxis:{{title:"samples"}}}});
if(D.pcs.length){{const g=[...new Set(D.pcs.map(p=>p.sample_group))]; if(D.pcs.length){{const g=[...new Set(D.pcs.map(p=>p.sample_group))];
Plotly.newPlot("pca",g.map(k=>({{x:D.pcs.filter(p=>p.sample_group===k).map(p=>p.pc1), Plotly.newPlot("pca",g.map(k=>({{x:D.pcs.filter(p=>p.sample_group===k).map(p=>p.pc1),
@@ -413,7 +421,7 @@ y:D.pcs.filter(p=>p.sample_group===k).map(p=>p.pc2),text:D.pcs.filter(p=>p.sampl
mode:"markers",type:"scatter",name:k}})),{{title:"PCA of beta ("+D.n_pca_probes+" candidate probes)", mode:"markers",type:"scatter",name:k}})),{{title:"PCA of beta ("+D.n_pca_probes+" candidate probes)",
xaxis:{{title:"PC1"}},yaxis:{{title:"PC2"}}}});}} xaxis:{{title:"PC1"}},yaxis:{{title:"PC2"}}}});}}
else{{document.getElementById("pca").textContent="No features, PCA skipped";}} else{{document.getElementById("pca").textContent="No features, PCA skipped";}}
</script></body></html> </script></div></body></html>
""" """
tmp = path.with_suffix(".html.part") tmp = path.with_suffix(".html.part")
tmp.write_text(page, encoding="utf-8") tmp.write_text(page, encoding="utf-8")
+7 -2
View File
@@ -536,9 +536,14 @@ def payload(datasets: Sequence[Dataset], th: Thresholds, generated: str, metadat
def render_html(data: Mapping[str, Any]) -> str: def render_html(data: Mapping[str, Any]) -> str:
blob = json.dumps(data, sort_keys=True, ensure_ascii=False, separators=(",", ":"), allow_nan=False) blob = json.dumps(data, sort_keys=True, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
packed = base64.b64encode(gzip.compress(blob.encode("utf-8"), compresslevel=9, mtime=0)).decode("ascii") packed = base64.b64encode(gzip.compress(blob.encode("utf-8"), compresslevel=9, mtime=0)).decode("ascii")
page = _asset("template.html").replace("__TITLE__", f"{PRODUCT_NAME} QC Report").replace("__DATA__", packed) from simurg.core.brand import BAR_CSS, favicon_link, report_bar
checksum = hashlib.sha256(blob.encode("utf-8")).hexdigest()
bar = report_bar("QC Report", __version__, "data sha256", checksum)
page = (_asset("template.html").replace("__TITLE__", f"{PRODUCT_NAME} QC Report").replace("__DATA__", packed)
.replace("__FAVICON__", favicon_link()).replace("__BRANDBAR__", bar)) # fmt: skip
for marker, text in ( for marker, text in (
("/*__STYLE__*/", _asset("report.css")), ("/*__STYLE__*/", _asset("report.css") + "\n" + BAR_CSS),
("/*__APP__*/", _asset("report.js")), ("/*__APP__*/", _asset("report.js")),
("/*__PLOTLY__*/", _plotly().replace("</script", "<\\/script")), ("/*__PLOTLY__*/", _plotly().replace("</script", "<\\/script")),
): ):
+3 -3
View File
@@ -1,14 +1,14 @@
:root { --blue:#3B75AF; --orange:#E8871E; --amber:#F2C14E; --grey:#9A9A9A; --bg:#fafafa; --line:#ddd; } :root { --blue:#3B75AF; --orange:#E8871E; --amber:#F2C14E; --grey:#9A9A9A; --bg:#fafafa; --line:#ddd; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin:0; font:14px/1.4 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; color:#222; background:var(--bg); } body { margin:0; font:14px/1.4 "Noto Sans",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; color:#222; background:var(--bg); }
header { padding:10px 16px; background:#fff; border-bottom:1px solid var(--line); } header { padding:10px 16px; background:#fff; border-bottom:1px solid var(--line); }
header .title { font-size:20px; font-weight:600; } header .title { font-size:20px; font-weight:600; }
.muted { color:#666; font-size:13px; } .muted { color:#666; font-size:13px; }
.notice { margin-top:6px; padding:6px 10px; background:#FFF6DA; border:1px solid var(--amber); border-radius:4px; font-size:13px; } .notice { margin-top:6px; padding:6px 10px; background:#FFF6DA; border:1px solid var(--amber); border-radius:4px; font-size:13px; }
#layout { display:flex; min-height:calc(100vh - 70px); } #layout { display:flex; min-height:calc(100vh - 110px); }
nav { width:190px; flex:none; background:#fff; border-right:1px solid var(--line); padding:8px; } nav { width:190px; flex:none; background:#fff; border-right:1px solid var(--line); padding:8px; }
nav button { display:block; width:100%; text-align:left; margin:2px 0; padding:8px 10px; border:0; background:none; border-radius:4px; cursor:pointer; font-size:14px; } nav button { display:block; width:100%; text-align:left; margin:2px 0; padding:8px 10px; border:0; background:none; border-radius:4px; cursor:pointer; font-size:14px; }
nav button.active { background:#E3EDF7; font-weight:600; } nav button.active { background:#E3EDF7; font-weight:600; box-shadow:inset 3px 0 0 #D4A017; }
nav button:hover { background:#eef3f8; } nav button:hover { background:#eef3f8; }
main { flex:1; min-width:0; padding:12px 16px; } main { flex:1; min-width:0; padding:12px 16px; }
.controls { display:flex; flex-wrap:wrap; gap:12px; align-items:end; margin-bottom:10px; } .controls { display:flex; flex-wrap:wrap; gap:12px; align-items:end; margin-bottom:10px; }
+2 -1
View File
@@ -4,11 +4,12 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>__TITLE__</title> <title>__TITLE__</title>
__FAVICON__
<style>/*__STYLE__*/</style> <style>/*__STYLE__*/</style>
</head> </head>
<body> <body>
__BRANDBAR__
<header id="hdr"> <header id="hdr">
<div class="title">__TITLE__</div>
<div id="hdrinfo" class="muted">loading…</div> <div id="hdrinfo" class="muted">loading…</div>
<div id="metanotice" class="notice" hidden>This report embeds sample-sheet metadata. Check it before sharing <div id="metanotice" class="notice" hidden>This report embeds sample-sheet metadata. Check it before sharing
(use <code>--no-metadata</code>, <code>--drop-metadata</code> or <code>--keep-metadata</code> to remove columns).</div> (use <code>--no-metadata</code>, <code>--drop-metadata</code> or <code>--keep-metadata</code> to remove columns).</div>