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
3ff38e6009
commit
bda36b09dc
12 files changed
+222
-106
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user