eduevidence 6.0.0 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +395 -0
- package/CONTRIBUTING.md +105 -0
- package/README.md +113 -49
- package/README.zh-CN.md +39 -12
- package/SKILL.md +15 -5
- package/assets/readme/landing-tour.gif +0 -0
- package/assets/readme/studio-tour.gif +0 -0
- package/benchmarks/evidence-library.json +277 -1
- package/bin/eduevidence.js +2 -1
- package/docs/architecture.md +325 -46
- package/docs/demo-workplace-ai.md +1 -1
- package/docs/install-guide.md +1 -1
- package/docs/j-ev-experimental.md +250 -0
- package/docs/orchestration-role-model.md +1 -1
- package/docs/release-closeout/README.md +1 -1
- package/docs/reproducibility.md +138 -0
- package/docs/sciverse-api.md +125 -0
- package/domains/_neutral/copy/few_shots.json +21 -0
- package/domains/_neutral/copy/framing_lexicon.json +19 -0
- package/domains/_neutral/copy/module_labels.json +5 -0
- package/domains/_neutral/copy/module_labels_footer.json +102 -0
- package/domains/_neutral/copy/module_labels_modules.json +204 -0
- package/domains/_neutral/copy/module_labels_nav.json +126 -0
- package/domains/_neutral/copy/module_labels_summary.json +98 -0
- package/domains/_neutral/copy/module_labels_tables.json +164 -0
- package/domains/_neutral/copy/module_labels_v2.json +90 -0
- package/domains/_neutral/copy/risk_constructs.json +20 -0
- package/domains/_neutral/copy/section_titles.json +66 -0
- package/domains/_neutral/copy/terminology.json +11 -0
- package/domains/check_copy_packs.py +103 -0
- package/domains/education/copy/few_shots.json +22 -0
- package/domains/education/copy/framing_enums.json +167 -0
- package/domains/education/copy/framing_lexicon.json +166 -0
- package/domains/education/copy/module_labels.json +169 -0
- package/domains/education/copy/risk_constructs.json +48 -0
- package/domains/education/copy/section_titles.json +186 -0
- package/domains/education/copy/terminology.json +70 -0
- package/domains/education/manifest.json +1 -1
- package/domains/education/outcome_taxonomy.json +2 -2
- package/domains/manifest.json +1 -1
- package/domains/policy/copy/few_shots.json +22 -0
- package/domains/policy/copy/framing_enums.json +94 -0
- package/domains/policy/copy/framing_lexicon.json +174 -0
- package/domains/policy/copy/module_labels.json +168 -0
- package/domains/policy/copy/risk_constructs.json +33 -0
- package/domains/policy/copy/section_titles.json +186 -0
- package/domains/policy/copy/terminology.json +64 -0
- package/eduevidence_cli.py +10 -0
- package/engine/capabilities.py +57 -5
- package/engine/decision_policy.py +167 -0
- package/engine/evidence_graph.py +14 -10
- package/engine/gaps.py +42 -22
- package/engine/ids.py +2 -0
- package/engine/library.py +6 -2
- package/engine/library_builtin.py +7 -4
- package/engine/living.py +34 -4
- package/engine/migration.py +88 -3
- package/engine/orchestration.py +5 -5
- package/engine/paths.py +2 -0
- package/engine/pilot.py +34 -32
- package/engine/taxonomy.py +211 -0
- package/engine/tribunal.py +49 -43
- package/engine/versions.py +1 -1
- package/examples/ai-coding-assistant-evidence/EduEvidence_Report.html +1361 -147
- package/examples/ai-coding-assistant-evidence/artifact_manifest.json +3 -3
- package/examples/ai-coding-assistant-evidence/citation_check.json +1 -1
- package/examples/ai-coding-assistant-evidence/final_verdict.json +107 -0
- package/examples/ai-coding-assistant-evidence/gate_report.json +101 -0
- package/examples/ai-coding-assistant-evidence/report_spec.json +23 -12
- package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_academic.html +448 -128
- package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_claude.html +448 -128
- package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_datalab-dark.html +448 -128
- package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_datalab.html +448 -128
- package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_presentation.html +448 -128
- package/examples/ai-coding-assistant-evidence/reports-5themes/report_academic.html +1360 -146
- package/examples/ai-coding-assistant-evidence/reports-5themes/report_claude.html +1360 -146
- package/examples/ai-coding-assistant-evidence/reports-5themes/report_datalab-dark.html +1360 -146
- package/examples/ai-coding-assistant-evidence/reports-5themes/report_datalab.html +1360 -146
- package/examples/ai-coding-assistant-evidence/reports-5themes/report_presentation.html +1360 -146
- package/examples/ai-coding-assistant-evidence/result.json +13 -9
- package/examples/ai-coding-assistant-evidence/result.zh.json +45 -41
- package/examples/ai-coding-assistant-evidence/skeptic.json +72 -0
- package/examples/ai-coding-assistant-evidence/verdict.json +6 -2
- package/examples/spaced-retrieval-practice/EduEvidence_Report.html +2728 -0
- package/examples/spaced-retrieval-practice/applicability.json +14 -0
- package/examples/spaced-retrieval-practice/artifact_manifest.json +15 -0
- package/examples/spaced-retrieval-practice/claims.jsonl +3 -0
- package/examples/spaced-retrieval-practice/evidence.jsonl +6 -0
- package/examples/spaced-retrieval-practice/final_verdict.json +93 -0
- package/examples/spaced-retrieval-practice/frame.json +58 -0
- package/examples/spaced-retrieval-practice/gate_report.json +101 -0
- package/examples/spaced-retrieval-practice/methodology.json +78 -0
- package/examples/spaced-retrieval-practice/report.html +2522 -0
- package/examples/spaced-retrieval-practice/report_spec.json +212 -0
- package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_academic.html +2728 -0
- package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_claude.html +2728 -0
- package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_datalab-dark.html +2728 -0
- package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_datalab.html +2728 -0
- package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_presentation.html +2728 -0
- package/examples/spaced-retrieval-practice/reports-5themes/report_academic.html +2728 -0
- package/examples/spaced-retrieval-practice/reports-5themes/report_claude.html +2728 -0
- package/examples/spaced-retrieval-practice/reports-5themes/report_datalab-dark.html +2728 -0
- package/examples/spaced-retrieval-practice/reports-5themes/report_datalab.html +2728 -0
- package/examples/spaced-retrieval-practice/reports-5themes/report_presentation.html +2728 -0
- package/examples/spaced-retrieval-practice/result.json +942 -0
- package/examples/spaced-retrieval-practice/result.zh.json +942 -0
- package/examples/spaced-retrieval-practice/skeptic.json +70 -0
- package/examples/spaced-retrieval-practice/sources.jsonl +7 -0
- package/examples/spaced-retrieval-practice/verdict.json +93 -0
- package/examples/workplace-ai-assistant/EduEvidence_Report.html +2814 -0
- package/examples/workplace-ai-assistant/artifact_manifest.json +15 -0
- package/examples/workplace-ai-assistant/claims.jsonl +4 -4
- package/examples/workplace-ai-assistant/evidence.jsonl +4 -4
- package/examples/workplace-ai-assistant/evidence_graph.json +15 -15
- package/examples/workplace-ai-assistant/final_verdict.json +78 -0
- package/examples/workplace-ai-assistant/gate_report.json +101 -0
- package/examples/workplace-ai-assistant/report_spec.json +209 -40
- package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_academic.html +449 -119
- package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_claude.html +449 -119
- package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_datalab-dark.html +449 -119
- package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_datalab.html +449 -119
- package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_presentation.html +449 -119
- package/examples/workplace-ai-assistant/reports-5themes/report_academic.html +2814 -0
- package/examples/workplace-ai-assistant/reports-5themes/report_claude.html +2814 -0
- package/examples/workplace-ai-assistant/reports-5themes/report_datalab-dark.html +2814 -0
- package/examples/workplace-ai-assistant/reports-5themes/report_datalab.html +2814 -0
- package/examples/workplace-ai-assistant/reports-5themes/report_presentation.html +2814 -0
- package/examples/workplace-ai-assistant/result.json +82 -20
- package/examples/workplace-ai-assistant/result.zh.json +82 -20
- package/examples/workplace-ai-assistant/skeptic.json +72 -0
- package/examples/workplace-ai-assistant/verdict.json +36 -10
- package/integrations/agent_mcp.py +2 -2
- package/integrations/jev/__init__.py +115 -0
- package/integrations/jev/approval.py +212 -0
- package/integrations/jev/cli.py +84 -0
- package/integrations/jev/config.py +112 -0
- package/integrations/jev/gateway.py +128 -0
- package/integrations/jev/modes.py +38 -0
- package/integrations/jev/tools_classify.py +88 -0
- package/integrations/jev/tools_extract.py +111 -0
- package/integrations/jev/tools_rerank.py +71 -0
- package/integrations/jev/tools_screen.py +87 -0
- package/integrations/jev/tools_verify.py +95 -0
- package/integrations/jev_mcp.py +22 -0
- package/integrations/semantic_decide.py +286 -0
- package/integrations/semdecide_cli.py +55 -0
- package/package.json +19 -2
- package/pyproject.toml +4 -3
- package/references/report-copy-style.md +107 -0
- package/references/retrieval-compliance.md +75 -0
- package/references/retrieval-protocol.md +20 -0
- package/retrieval/audit.py +27 -3
- package/retrieval/fetch.py +96 -0
- package/retrieval/sciverse.py +398 -0
- package/retrieval/search.py +47 -7
- package/schemas/applicability.schema.json +94 -0
- package/schemas/chart-spec.schema.json +10 -3
- package/schemas/evidence.schema.json +316 -43
- package/schemas/fetch-result.schema.json +2 -1
- package/schemas/report-result.schema.json +3 -3
- package/schemas/report-spec.schema.json +98 -100
- package/schemas/skeptic.schema.json +86 -0
- package/schemas/source.schema.json +21 -2
- package/schemas/v2/decision-snapshot.schema.json +20 -9
- package/schemas/v2/finding.schema.json +5 -1
- package/schemas/v2/intake.schema.json +191 -0
- package/schemas/v2/methodology-audit.schema.json +5 -1
- package/schemas/v2/outcome.schema.json +28 -5
- package/schemas/v2/study.schema.json +5 -1
- package/schemas/vNext/autoevolve-session.schema.json +34 -1
- package/schemas/vNext/eval-snapshot.schema.json +77 -1
- package/schemas/vNext/execution-plan.schema.json +50 -1
- package/schemas/vNext/gap-priority.schema.json +54 -1
- package/schemas/vNext/negative-search-record.schema.json +68 -1
- package/schemas/vNext/research-iteration.schema.json +87 -1
- package/schemas/vNext/research-strategy.schema.json +62 -1
- package/schemas/vNext/skill-experiment.schema.json +90 -1
- package/schemas/vNext/task-spec.schema.json +156 -1
- package/schemas/vNext/worker-result.schema.json +60 -1
- package/schemas/verdict.schema.json +164 -28
- package/scripts/build_evidence_library.py +15 -5
- package/scripts/build_report_variants.py +18 -2
- package/scripts/build_result.py +74 -9
- package/scripts/check_package_parity.py +85 -0
- package/scripts/check_protocol_alignment.py +375 -0
- package/scripts/check_versioned_schemas.py +254 -0
- package/scripts/claim_audit.py +13 -8
- package/scripts/compute_confidence.py +10 -0
- package/scripts/dashboard_server.py +13 -2
- package/scripts/did_regression.py +12 -2
- package/scripts/evidence_score.py +5 -2
- package/scripts/intake/__init__.py +31 -0
- package/scripts/intake/__main__.py +18 -0
- package/scripts/intake/background.py +78 -0
- package/scripts/intake/browser.py +79 -0
- package/scripts/intake/cli.py +57 -0
- package/scripts/intake/constants.py +57 -0
- package/scripts/intake/depth.py +53 -0
- package/scripts/intake/enhancements.py +106 -0
- package/scripts/intake/hooks.py +90 -0
- package/scripts/intake/prefs.py +76 -0
- package/scripts/intake/prompts.py +85 -0
- package/scripts/intake/session.py +152 -0
- package/scripts/lint_file_layers.py +126 -0
- package/scripts/orchestrator.py +187 -40
- package/scripts/pre_verdict_gate.py +241 -29
- package/scripts/quickstart.py +18 -2
- package/scripts/run_workspace.py +7 -1
- package/scripts/skill_lint.py +11 -1
- package/scripts/skill_payload.py +6 -3
- package/scripts/test_adversarial_empirical.py +96 -25
- package/scripts/validate_schema.py +31 -1
- package/skill/agents/evaluation-designer.md +20 -4
- package/skill/agents/evidence-analyst.md +19 -3
- package/skill/agents/evidence-judge.md +98 -8
- package/skill/agents/evidence-retriever.md +20 -3
- package/skill/agents/intervention-designer.md +20 -4
- package/skill/agents/method-reviewer.md +18 -2
- package/skill/agents/{education-planner.md → research-planner.md} +19 -3
- package/skill/agents/skeptic.md +18 -2
- package/skill/roles/registry.yaml +11 -11
- package/skill/sub-skills/aihot-trend-analysis/SKILL.md +28 -9
- package/skill/sub-skills/contradiction-analysis/SKILL.md +31 -11
- package/skill/sub-skills/data-analysis/SKILL.md +34 -15
- package/skill/sub-skills/ethics-review/SKILL.md +33 -10
- package/skill/sub-skills/evidence-extraction/SKILL.md +29 -11
- package/skill/sub-skills/evidence-review/SKILL.md +31 -12
- package/skill/sub-skills/gap-analysis/SKILL.md +31 -9
- package/skill/sub-skills/literature-review/SKILL.md +35 -14
- package/skill/sub-skills/methodology-audit/SKILL.md +29 -12
- package/skill/sub-skills/report-generation/SKILL.md +28 -0
- package/skill/sub-skills/research-planning/SKILL.md +41 -14
- package/skill/sub-skills/study-design/SKILL.md +30 -9
- package/skill/task-briefs/adjudicate.md +32 -7
- package/skill/task-briefs/applicability.md +37 -2
- package/skill/task-briefs/audit.md +32 -7
- package/skill/task-briefs/challenge.md +34 -5
- package/skill/task-briefs/evaluate.md +30 -5
- package/skill/task-briefs/extract.md +31 -8
- package/skill/task-briefs/frame.md +39 -10
- package/skill/task-briefs/intervene.md +32 -6
- package/skill/task-briefs/present.md +32 -8
- package/skill/task-briefs/projection.md +36 -2
- package/skill/task-briefs/retrieve.md +36 -6
- package/skill/workflows/decision-and-pilot.md +76 -1
- package/skill/workflows/evaluate-and-update.md +83 -0
- package/skill/workflows/evidence-review.md +104 -0
- package/skill/workflows/experimental-jev.md +170 -0
- package/skill/workflows/intake.md +120 -0
- package/visualization/eduevidence-report/scripts/build_figures.py +25 -3
- package/visualization/eduevidence-report/scripts/build_infographics.py +37 -15
- package/visualization/eduevidence-report/scripts/build_report.py +435 -575
- package/visualization/eduevidence-report/scripts/charts_data.py +2 -0
- package/visualization/eduevidence-report/scripts/lieflat_engine.py +349 -38
- package/visualization/eduevidence-report/scripts/report_copy_pack.py +296 -0
- package/visualization/eduevidence-report/scripts/report_copy_policy_guard.py +47 -0
- package/visualization/eduevidence-report/scripts/zh_labels.py +141 -1
- package/web/architecture.html +14885 -0
- package/web/studio/assets/index-B8tkF44Q.css +1 -0
- package/web/studio/index.html +2 -2
- package/scripts/build_esl_artifacts.py +0 -1921
- package/scripts/build_killer_demo.py +0 -295
- package/scripts/enrich_projects_human_and_lieflat.py +0 -315
- package/scripts/generate_new_projects.py +0 -686
- package/scripts/sync_killer_demo_report.py +0 -270
- package/web/studio/assets/index-CzXocaGv.css +0 -1
- /package/web/studio/assets/{index-pa7jD7n4.js → index-CQ6Keoyc.js} +0 -0
|
@@ -224,6 +224,8 @@ def extract_year_x_dimension(result: dict, params: dict, lang: str = "en") -> tu
|
|
|
224
224
|
if len(grid) < 3:
|
|
225
225
|
return None, f"fewer than 3 populated year×dimension cells (got {len(grid)})"
|
|
226
226
|
return {"years": years, "dimensions": dims, "cells": grid,
|
|
227
|
+
"dimension_labels": [{"label": d, "label_zh": outcome_label(d, "zh"),
|
|
228
|
+
"label_en": outcome_label(d, "en")} for d in dims],
|
|
227
229
|
"unit": "1 study", "label_zh": "年份 × 维度文献年历", "label_en": "Year × dimension almanac"}, None
|
|
228
230
|
|
|
229
231
|
|
|
@@ -22,6 +22,7 @@ from __future__ import annotations
|
|
|
22
22
|
|
|
23
23
|
import html
|
|
24
24
|
import math
|
|
25
|
+
import re
|
|
25
26
|
from typing import Any, Dict, List, Optional, Tuple
|
|
26
27
|
|
|
27
28
|
import charts_data as CD
|
|
@@ -37,6 +38,232 @@ THEME_PALETTES = {
|
|
|
37
38
|
STAGGER_DOT = 12
|
|
38
39
|
STAGGER_BAR = 100
|
|
39
40
|
MIN_FONT = 6.5
|
|
41
|
+
LANE_MIN_H = 15.0 # a data-driven row lane never renders thinner than this
|
|
42
|
+
ROT45 = 0.7071067811865476 # cos/sin 45°, used by rotated tick labels
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Geometry guard — every renderer's output is measured against its own viewBox
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
#
|
|
49
|
+
# A fixed canvas plus data-driven row counts silently clips a figure: shapes get
|
|
50
|
+
# drawn outside the viewBox and the browser cuts them off. Renderers therefore
|
|
51
|
+
# size their canvas from the content (see _svg_open) and every figure is measured
|
|
52
|
+
# once more after rendering, so a renderer that forgets fails loudly instead of
|
|
53
|
+
# shipping a clipped figure into the HTML report and its PNG exports.
|
|
54
|
+
#
|
|
55
|
+
# The measurement is a pure-stdlib, deterministic parse of the emitted markup: no
|
|
56
|
+
# DOM, no font metrics, no I/O. Text boxes use a conservative advance-width model
|
|
57
|
+
# calibrated against the shipped UI stack ("Helvetica Neue", "PingFang SC",
|
|
58
|
+
# Arial) measured in Chrome via canvas measureText:
|
|
59
|
+
# CJK/full-width glyphs = 1.00 em (measured exactly 1.0)
|
|
60
|
+
# latin average ≈ 0.62 em ("Independent problem solving" at 11px)
|
|
61
|
+
# latin worst case ≈ 0.94 em (all-caps/like "WWWWWWWWWWWW" at 9px)
|
|
62
|
+
# so latin is charged at a cap-height upper bound rather than an average, making
|
|
63
|
+
# every measured box an over-estimate for ordinary labels.
|
|
64
|
+
|
|
65
|
+
CHAR_W = 0.75 # latin advance width, in em (cap-height upper bound)
|
|
66
|
+
CHAR_W_WIDE = 1.0 # CJK / full-width advance width, in em
|
|
67
|
+
LATIN_WIDE_ABOVE = 18 # font sizes above this are display numbers, not words
|
|
68
|
+
CHAR_W_BIG = 0.85 # wider cap-only glyphs at display sizes
|
|
69
|
+
ASCENT = 0.85 # baseline to glyph top, in em
|
|
70
|
+
DESCENT = 0.30 # baseline to glyph bottom, in em
|
|
71
|
+
|
|
72
|
+
_NUM_RE = re.compile(r"-?\d+(?:\.\d+)?")
|
|
73
|
+
_ATTR_RE = re.compile(r'([\w:-]+)="([^"]*)"')
|
|
74
|
+
_ELEM_RE = re.compile(
|
|
75
|
+
r"<(rect|circle|ellipse|line|polyline|polygon|path|text)\b([^>]*?)"
|
|
76
|
+
r"(?:/>|>(.*?)</\1>)",
|
|
77
|
+
re.S,
|
|
78
|
+
)
|
|
79
|
+
_TAG_RE = re.compile(r"<[^>]*>")
|
|
80
|
+
_TRANSLATE_RE = re.compile(r"translate\(([^)]*)\)")
|
|
81
|
+
_ROTATE_RE = re.compile(r"rotate\(([^)]*)\)")
|
|
82
|
+
_VIEWBOX_RE = re.compile(r'viewBox="([^"]+)"')
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class GeometryOverflowError(ValueError):
|
|
86
|
+
"""A renderer drew outside its own viewBox, so content would be clipped."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _enum(raw: str) -> List[float]:
|
|
90
|
+
return [float(x) for x in _NUM_RE.findall(raw or "")]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _pairs(values: List[float]) -> List[Tuple[float, float]]:
|
|
94
|
+
return list(zip(values[0::2], values[1::2]))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _unescape(raw: str) -> str:
|
|
98
|
+
return (raw.replace("<", "<").replace(">", ">")
|
|
99
|
+
.replace(""", '"').replace("'", "'").replace("'", "'")
|
|
100
|
+
.replace("&", "&").replace(" ", " "))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def text_width(text: str, font_size: float) -> float:
|
|
104
|
+
"""Conservative advance width of a label at a given font size."""
|
|
105
|
+
latin = CHAR_W_BIG if font_size >= LATIN_WIDE_ABOVE else CHAR_W
|
|
106
|
+
total = 0.0
|
|
107
|
+
for ch in _unescape(text):
|
|
108
|
+
total += CHAR_W_WIDE if ch > "\u2e7f" else latin
|
|
109
|
+
return total * font_size
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _element_boxes(svg: str):
|
|
113
|
+
"""Yield (tag, x0, y0, x1, y1) for every element that draws ink."""
|
|
114
|
+
for match in _ELEM_RE.finditer(svg):
|
|
115
|
+
tag, raw_attrs, raw_body = match.group(1), match.group(2), match.group(3)
|
|
116
|
+
a = dict(_ATTR_RE.findall(raw_attrs))
|
|
117
|
+
box = None
|
|
118
|
+
if tag == "rect":
|
|
119
|
+
x, y = float(a.get("x", 0)), float(a.get("y", 0))
|
|
120
|
+
w, h = float(a.get("width", 0)), float(a.get("height", 0))
|
|
121
|
+
box = (x, y, x + w, y + h)
|
|
122
|
+
elif tag == "circle":
|
|
123
|
+
cx, cy, r = float(a.get("cx", 0)), float(a.get("cy", 0)), float(a.get("r", 0))
|
|
124
|
+
box = (cx - r, cy - r, cx + r, cy + r)
|
|
125
|
+
elif tag == "ellipse":
|
|
126
|
+
cx, cy = float(a.get("cx", 0)), float(a.get("cy", 0))
|
|
127
|
+
rx, ry = float(a.get("rx", 0)), float(a.get("ry", 0))
|
|
128
|
+
box = (cx - rx, cy - ry, cx + rx, cy + ry)
|
|
129
|
+
elif tag == "line":
|
|
130
|
+
x1, y1 = float(a.get("x1", 0)), float(a.get("y1", 0))
|
|
131
|
+
x2, y2 = float(a.get("x2", 0)), float(a.get("y2", 0))
|
|
132
|
+
box = (min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
|
|
133
|
+
elif tag in ("polyline", "polygon"):
|
|
134
|
+
pts = _pairs(_enum(a.get("points", "")))
|
|
135
|
+
if pts:
|
|
136
|
+
box = (min(p[0] for p in pts), min(p[1] for p in pts),
|
|
137
|
+
max(p[0] for p in pts), max(p[1] for p in pts))
|
|
138
|
+
elif tag == "path":
|
|
139
|
+
# Control points over-approximate the ink box: exactly what a bound
|
|
140
|
+
# check wants, since a curve never leaves its control hull.
|
|
141
|
+
pts = []
|
|
142
|
+
for seg in re.finditer(r"([A-Za-z])([^A-Za-z]*)", a.get("d", "")):
|
|
143
|
+
if seg.group(1).upper() != "Z":
|
|
144
|
+
pts += _pairs(_enum(seg.group(2)))
|
|
145
|
+
if pts:
|
|
146
|
+
box = (min(p[0] for p in pts), min(p[1] for p in pts),
|
|
147
|
+
max(p[0] for p in pts), max(p[1] for p in pts))
|
|
148
|
+
elif tag == "text":
|
|
149
|
+
x, y = float(a.get("x", 0)), float(a.get("y", 0))
|
|
150
|
+
size = float(a.get("font-size", 10))
|
|
151
|
+
width = text_width(_TAG_RE.sub("", raw_body or ""), size)
|
|
152
|
+
anchor = a.get("text-anchor", "start")
|
|
153
|
+
x0 = x - (width if anchor == "end" else width / 2.0 if anchor == "middle" else 0.0)
|
|
154
|
+
box = (x0, y - size * ASCENT, x0 + width, y + size * DESCENT)
|
|
155
|
+
if box is None:
|
|
156
|
+
continue
|
|
157
|
+
tx0, ty0, tx1, ty1 = _apply_transform(a.get("transform", ""), box)
|
|
158
|
+
yield tag, tx0, ty0, tx1, ty1
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _transform_matrix(transform: str) -> Optional[Tuple[float, float, float, float, float, float]]:
|
|
162
|
+
"""Compose an SVG transform list into a 2x3 affine matrix (a b c d e f)."""
|
|
163
|
+
ops = re.findall(r"(translate|rotate|scale|matrix)\(([^)]*)\)", transform or "")
|
|
164
|
+
if not ops:
|
|
165
|
+
return None
|
|
166
|
+
m = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
|
|
167
|
+
|
|
168
|
+
def mul(n, by):
|
|
169
|
+
return (by[0] * n[0] + by[2] * n[1], by[1] * n[0] + by[3] * n[1],
|
|
170
|
+
by[0] * n[2] + by[2] * n[3], by[1] * n[2] + by[3] * n[3],
|
|
171
|
+
by[0] * n[4] + by[2] * n[5] + by[4],
|
|
172
|
+
by[1] * n[4] + by[3] * n[5] + by[5])
|
|
173
|
+
|
|
174
|
+
for name, raw in ops:
|
|
175
|
+
v = _enum(raw)
|
|
176
|
+
if name == "translate":
|
|
177
|
+
dx, dy = (v + [0.0, 0.0])[:2]
|
|
178
|
+
op = (1.0, 0.0, 0.0, 1.0, dx, dy)
|
|
179
|
+
elif name == "scale":
|
|
180
|
+
sx = v[0] if v else 1.0
|
|
181
|
+
sy = v[1] if len(v) > 1 else sx
|
|
182
|
+
op = (sx, 0.0, 0.0, sy, 0.0, 0.0)
|
|
183
|
+
elif name == "matrix" and len(v) >= 6:
|
|
184
|
+
op = tuple(v[:6]) # type: ignore[assignment]
|
|
185
|
+
else: # rotate(angle [cx cy])
|
|
186
|
+
angle = math.radians(v[0] if v else 0.0)
|
|
187
|
+
cos_a, sin_a = math.cos(angle), math.sin(angle)
|
|
188
|
+
rot = (cos_a, sin_a, -sin_a, cos_a, 0.0, 0.0)
|
|
189
|
+
ox, oy = (v[1], v[2]) if len(v) > 2 else (0.0, 0.0)
|
|
190
|
+
op = mul(rot, mul((1.0, 0.0, 0.0, 1.0, -ox, -oy), (1.0, 0.0, 0.0, 1.0, ox, oy)))
|
|
191
|
+
m = mul(op, m)
|
|
192
|
+
return m
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _apply_transform(transform: str, box) -> Tuple[float, float, float, float]:
|
|
196
|
+
"""Map an element box through its transform list (SVG user-space order)."""
|
|
197
|
+
m = _transform_matrix(transform)
|
|
198
|
+
if m is None:
|
|
199
|
+
return box
|
|
200
|
+
x0, y0, x1, y1 = box
|
|
201
|
+
a, b, c, d, e, f = m
|
|
202
|
+
corners = [(a * px + c * py + e, b * px + d * py + f)
|
|
203
|
+
for px, py in ((x0, y0), (x0, y1), (x1, y0), (x1, y1))]
|
|
204
|
+
return (min(p[0] for p in corners), min(p[1] for p in corners),
|
|
205
|
+
max(p[0] for p in corners), max(p[1] for p in corners))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def geometry_overflows(svg: str, tolerance: float = 0.5) -> List[Tuple[str, float, float, float]]:
|
|
209
|
+
"""Measure an emitted SVG against its own viewBox.
|
|
210
|
+
|
|
211
|
+
Returns one (tag, max_x, max_y, overflow) entry per offending element, in
|
|
212
|
+
viewBox coordinates. An empty list means every drawn element sits inside the
|
|
213
|
+
canvas within tolerance px, which absorbs sub-pixel label rounding.
|
|
214
|
+
"""
|
|
215
|
+
view = _VIEWBOX_RE.search(svg)
|
|
216
|
+
if view is None:
|
|
217
|
+
return [("svg", 0.0, 0.0, float("inf"))]
|
|
218
|
+
vx0, vy0, vw, vh = _enum(view.group(1))[:4]
|
|
219
|
+
right, bottom = vx0 + vw, vy0 + vh
|
|
220
|
+
out: List[Tuple[str, float, float, float]] = []
|
|
221
|
+
for tag, x0, y0, x1, y1 in _element_boxes(svg):
|
|
222
|
+
over = max(x1 - right, y1 - bottom, vx0 - x0, vy0 - y0)
|
|
223
|
+
if over > tolerance:
|
|
224
|
+
out.append((tag, round(max(x0, x1), 1), round(max(y0, y1), 1), round(over, 2)))
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def check_geometry(svg: str, fig_type: str, tolerance: float = 0.5) -> None:
|
|
229
|
+
"""Raise GeometryOverflowError if a figure escapes its own viewBox."""
|
|
230
|
+
bad = geometry_overflows(svg, tolerance)
|
|
231
|
+
if not bad:
|
|
232
|
+
return
|
|
233
|
+
tag, x, y, over = max(bad, key=lambda item: item[3])
|
|
234
|
+
raise GeometryOverflowError(
|
|
235
|
+
f"{fig_type}: drawing escapes its viewBox by {over}px "
|
|
236
|
+
f"(<{tag}> reaches x={x}, y={y}; {len(bad)} element(s) outside). "
|
|
237
|
+
f"Size the canvas from the content instead of a fixed w/h."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# Designed canvas minimums. A figure never shrinks below these, so charts whose
|
|
242
|
+
# data already fits keep their exact proportions and byte-identical output.
|
|
243
|
+
MIN_CANVAS_W = 540
|
|
244
|
+
MIN_CANVAS_H = 300
|
|
245
|
+
PAD_RIGHT = 14.0
|
|
246
|
+
PAD_BOTTOM = 16.0
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _fit_canvas(body: List[str], w_min: int = MIN_CANVAS_W, h_min: int = MIN_CANVAS_H,
|
|
250
|
+
pad_right: float = PAD_RIGHT,
|
|
251
|
+
pad_bottom: float = PAD_BOTTOM) -> Tuple[int, int]:
|
|
252
|
+
"""Size a canvas from the elements already drawn, never below the minimum.
|
|
253
|
+
|
|
254
|
+
A renderer whose geometry follows the data — row counts, axis counts, label
|
|
255
|
+
lengths, effect magnitudes — must not draw on a fixed canvas: whatever lands
|
|
256
|
+
past the edge is silently clipped by the browser. Measuring the emitted
|
|
257
|
+
elements against a provisional canvas and growing to fit keeps the figure
|
|
258
|
+
whole, while max() with the designed minimum preserves both proportions and
|
|
259
|
+
byte-identical output for the shapes that already fit.
|
|
260
|
+
"""
|
|
261
|
+
probe = "<svg>" + "\n".join(body) + "</svg>"
|
|
262
|
+
max_x = max_y = 0.0
|
|
263
|
+
for _tag, _x0, _y0, x1, y1 in _element_boxes(probe):
|
|
264
|
+
max_x, max_y = max(max_x, x1), max(max_y, y1)
|
|
265
|
+
return (int(math.ceil(max(max_x + pad_right, w_min))),
|
|
266
|
+
int(math.ceil(max(max_y + pad_bottom, h_min))))
|
|
40
267
|
|
|
41
268
|
|
|
42
269
|
def get_theme(theme: str = "claude") -> Dict[str, str]:
|
|
@@ -161,11 +388,20 @@ def render_dot_cascade(bundle: dict, theme: str, meta: dict, audit: Optional[lis
|
|
|
161
388
|
p = get_theme(theme)
|
|
162
389
|
A = Audit(audit)
|
|
163
390
|
studies = bundle.get("studies") or []
|
|
164
|
-
|
|
165
|
-
out = _svg_open(p, w, h, meta.get("title", "dot cascade"))
|
|
391
|
+
out_title = meta.get("title", "dot cascade")
|
|
166
392
|
n = len(studies)
|
|
167
393
|
x_start, x_end, y_base = 64, 516, 236
|
|
394
|
+
# The 45° study labels under the axis are as wide as they are tall, so a
|
|
395
|
+
# packed cascade can need a wider canvas than the 540px default. Positions
|
|
396
|
+
# are untouched; only the canvas grows, and never below the old minimum.
|
|
168
397
|
x_gap = (x_end - x_start) / max(1, n)
|
|
398
|
+
label_fs = MIN_FONT + 1.5
|
|
399
|
+
labels = [str(s["label"])[:12] for s in studies]
|
|
400
|
+
label_w = max((text_width(label, label_fs) for label in labels), default=0.0)
|
|
401
|
+
last_label_x = x_start + (n - 0.5) * x_gap
|
|
402
|
+
w = max(540, int(math.ceil(last_label_x + label_w * ROT45 + label_fs * ASCENT * ROT45)) + 16)
|
|
403
|
+
h = max(300, int(math.ceil(y_base + 18 + label_w * ROT45 + label_fs * DESCENT * ROT45)) + 10)
|
|
404
|
+
out = _svg_open(p, w, h, out_title)
|
|
169
405
|
gmax = max(abs(s["g"]) for s in studies) or 1.0
|
|
170
406
|
scale = 92 / gmax
|
|
171
407
|
for i, s in enumerate(studies):
|
|
@@ -198,19 +434,48 @@ def render_bubble_almanac(bundle: dict, theme: str, meta: dict, audit: Optional[
|
|
|
198
434
|
years = bundle.get("years") or []
|
|
199
435
|
dims = bundle.get("dimensions") or []
|
|
200
436
|
cells = bundle.get("cells") or []
|
|
201
|
-
w, h = 540, 300
|
|
202
|
-
out = _svg_open(p, w, h, meta.get("title", "bubble almanac"))
|
|
203
|
-
for y in range(70, 262, 7):
|
|
204
|
-
out.append(f'<line x1="44" y1="{y}" x2="520" y2="{y}" stroke="{p["grid"]}" stroke-width="0.5" {lf_fade((y - 70) * 2)}/>')
|
|
205
437
|
x_start, x_gap = 150, (520 - 150) / max(1, len(dims) - 1) if len(dims) > 1 else 0
|
|
206
438
|
if len(dims) == 1:
|
|
207
439
|
x_start = 335
|
|
208
440
|
y_start, y_gap = 92, 44
|
|
441
|
+
# Bubble radius mirrors the draw loop below (sqrt area encoding). Bubbles are
|
|
442
|
+
# area-encoded and are never rescaled (the sqrt encoding is a contract), so
|
|
443
|
+
# the largest one decides how far the grid reaches: the canvas clears it on
|
|
444
|
+
# the right and below, and shifts the grid down when it would cross the top.
|
|
445
|
+
def bubble_r(n: int) -> float:
|
|
446
|
+
return max(3.5, math.sqrt(max(1, n)) * 3.6)
|
|
447
|
+
|
|
448
|
+
max_r = max((bubble_r(int(c["n"])) for c in cells), default=0.0)
|
|
449
|
+
shift_y = max(0.0, 70.0 + max_r + 4 - y_start)
|
|
450
|
+
last_col = x_start + max(0, len(dims) - 1) * x_gap
|
|
451
|
+
# One row per publication year: the year axis is data-driven, so a fixed 300
|
|
452
|
+
# canvas clipped every row past the fifth. The grid band grows with the rows
|
|
453
|
+
# and the canvas keeps its original minimum and 540px width.
|
|
454
|
+
band_bottom = max(262, y_start + max(0, len(years) - 1) * y_gap + 22)
|
|
455
|
+
h = max(300, int(math.ceil(band_bottom + shift_y + max_r + 4)))
|
|
456
|
+
w = max(540, int(math.ceil(last_col + max_r + 4)))
|
|
457
|
+
out = _svg_open(p, w, h, meta.get("title", "bubble almanac"))
|
|
458
|
+
for y in range(70, band_bottom, 7):
|
|
459
|
+
out.append(f'<line x1="44" y1="{y + shift_y:.1f}" x2="520" y2="{y + shift_y:.1f}" stroke="{p["grid"]}" stroke-width="0.5" {lf_fade((y - 70) * 2)}/>')
|
|
460
|
+
dim_labels = {item["label"]: item for item in (bundle.get("dimension_labels") or [])}
|
|
461
|
+
is_zh = meta.get("lang", "en") == "zh"
|
|
209
462
|
for j, d in enumerate(dims):
|
|
210
463
|
x = x_start + j * x_gap
|
|
211
|
-
|
|
464
|
+
dim_item = dim_labels.get(d, {})
|
|
465
|
+
dim_text = (dim_item.get("label_zh" if is_zh else "label_en")
|
|
466
|
+
or str(d).replace("_", " "))
|
|
467
|
+
# The last column label is centred on the grid edge and would be clipped
|
|
468
|
+
# half-way; clamp it inside the canvas instead (label only, not the data).
|
|
469
|
+
dim_fs = 9
|
|
470
|
+
half = text_width(dim_text[:14], dim_fs) / 2
|
|
471
|
+
# Keep an 8px margin: the label must not sit flush against the canvas
|
|
472
|
+
# edge, where glyph rounding would clip the last stroke.
|
|
473
|
+
tx = min(max(x, 40 + half), w - half - 8)
|
|
474
|
+
# Untouched labels keep their exact old markup: only a clamp changes bytes.
|
|
475
|
+
x_attr = f"{x:.0f}" if abs(tx - x) < 0.05 else f"{tx:.1f}"
|
|
476
|
+
out.append(f'<text x="{x_attr}" y="76" fill="{p["text"]}" font-size="{dim_fs}" font-weight="600" text-anchor="middle" {lf_fade(j * STAGGER_BAR + 40)}>{esc(dim_text[:14])}</text>')
|
|
212
477
|
for i, y_str in enumerate(years):
|
|
213
|
-
y = y_start + i * y_gap
|
|
478
|
+
y = y_start + i * y_gap + shift_y
|
|
214
479
|
out.append(f'<text x="96" y="{y + 4}" fill="{p["subtext"]}" font-size="9" font-weight="800" text-anchor="end" {lf_fade(i * STAGGER_BAR + 80)}>{esc(y_str)}</text>')
|
|
215
480
|
for k, c in enumerate(cells):
|
|
216
481
|
j = dims.index(c["dim"]) if c["dim"] in dims else 0
|
|
@@ -220,11 +485,11 @@ def render_bubble_almanac(bundle: dict, theme: str, meta: dict, audit: Optional[
|
|
|
220
485
|
if c.get("sig") is not None:
|
|
221
486
|
A.log(f"cells[{k}].sig", int(c["sig"]))
|
|
222
487
|
bx = x_start + j * x_gap
|
|
223
|
-
by = y_start + i * y_gap
|
|
488
|
+
by = y_start + i * y_gap + shift_y
|
|
224
489
|
r = max(3.5, math.sqrt(n) * 3.6)
|
|
225
490
|
color = p["secondary"] if int(c.get("sig") or 0) > 0 else p["primary"]
|
|
226
491
|
out.append(f'<circle cx="{bx:.1f}" cy="{by:.1f}" r="{r:.1f}" fill="{color}" fill-opacity="0.22" stroke="{color}" stroke-width="1.2" {lf_pop(k * STAGGER_DOT + 100)}>'
|
|
227
|
-
f'<title>{esc(str(c["dim"]))} ({esc(str(c["year"]))}) — N =
|
|
492
|
+
f'<title>{esc(dim_labels.get(c["dim"], {}).get("label_zh" if is_zh else "label_en") or str(c["dim"]).replace("_", " "))} ({esc(str(c["year"]))}) — {"N = " + str(n) + " 篇研究" if is_zh else "N = " + str(n) + " studies"}, {"显著 = " if is_zh else "significant = "}{c.get("sig", 0)}</title></circle>')
|
|
228
493
|
if int(c.get("sig") or 0) > 0:
|
|
229
494
|
out.append(f'<circle cx="{bx:.1f}" cy="{by:.1f}" r="3.2" fill="{color}" {lf_pop(k * STAGGER_DOT + 160)}/>')
|
|
230
495
|
out.append("</svg>")
|
|
@@ -448,15 +713,18 @@ def render_paired_rungs(bundle: dict, theme: str, meta: dict, audit: Optional[li
|
|
|
448
713
|
p = get_theme(theme)
|
|
449
714
|
A = Audit(audit)
|
|
450
715
|
rows = bundle.get("rows") or []
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
716
|
+
# Group width follows the row count, so each centered pair column walks
|
|
717
|
+
# rightwards as outcomes are added: past eight rows the last group's value
|
|
718
|
+
# label hangs over the 540px edge. The 480px group band keeps the designed
|
|
719
|
+
# spacing when it fits, and the canvas grows with it when it does not.
|
|
454
720
|
group_w = 480 / max(1, len(rows))
|
|
721
|
+
colors = _dir_colors(p)
|
|
722
|
+
body: List[str] = []
|
|
455
723
|
col_w = 13
|
|
456
724
|
y_base = 238
|
|
457
725
|
lang = meta.get("lang", "en")
|
|
458
|
-
|
|
459
|
-
|
|
726
|
+
body.append(f'<text x="60" y="76" font-size="8" font-weight="700" fill="{colors["positive"]}" {lf_fade(40)}>{esc(lang == "zh" and "正向" or "POSITIVE")}</text>')
|
|
727
|
+
body.append(f'<text x="60" y="92" font-size="8" font-weight="700" fill="{colors["negative"]}" {lf_fade(80)}>{esc(lang == "zh" and "负向" or "NEGATIVE")}</text>')
|
|
460
728
|
for r_i, r in enumerate(rows):
|
|
461
729
|
cx = 60 + (r_i + 0.5) * group_w
|
|
462
730
|
label = r.get("label_" + lang, r.get("label_en", r["label"]))
|
|
@@ -471,14 +739,16 @@ def render_paired_rungs(bundle: dict, theme: str, meta: dict, audit: Optional[li
|
|
|
471
739
|
break
|
|
472
740
|
y -= 7.7
|
|
473
741
|
x = cx - col_w - 4 + col * (col_w + 8)
|
|
474
|
-
|
|
742
|
+
body.append(f'<rect x="{x:.1f}" y="{y:.1f}" width="{col_w}" height="5.5" rx="2.6" fill="{colors[key]}" {lf_fade(r_i * STAGGER_BAR + shown * STAGGER_DOT + 60)}><title>{esc(str(label))} — {key}</title></rect>')
|
|
475
743
|
shown += 1
|
|
476
744
|
last_y = min(last_y, y)
|
|
477
745
|
if shown < count:
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
746
|
+
body.append(f'<text x="{cx:.1f}" y="{y - 4:.1f}" text-anchor="middle" font-size="{MIN_FONT + 1.5}" font-weight="800" fill="{p["subtext"]}" {lf_fade(r_i * STAGGER_BAR + 500)}>+{count - shown}</text>')
|
|
747
|
+
body.append(f'<text x="{cx:.1f}" y="{y_base + 16}" text-anchor="middle" font-size="{MIN_FONT + 1.5}" font-weight="600" fill="{p["subtext"]}" {lf_fade(r_i * STAGGER_BAR + 100)}>{esc(str(label)[:10])}</text>')
|
|
748
|
+
body.append(f'<text x="{cx:.1f}" y="{last_y - 8:.1f}" text-anchor="middle" font-size="8" font-weight="800" fill="{p["text"]}" {lf_fade(r_i * STAGGER_BAR + 520)}>{r["positive"]} / {r["negative"]}</text>')
|
|
749
|
+
body.append(f'<line x1="46" y1="{y_base}" x2="512" y2="{y_base}" stroke="{p["text"]}" stroke-width="1.2" {lf_draw(120)}/>')
|
|
750
|
+
w, h = _fit_canvas(body)
|
|
751
|
+
out = _svg_open(p, w, h, meta.get("title", "paired rungs")) + body
|
|
482
752
|
out.append("</svg>")
|
|
483
753
|
return "\n".join(out)
|
|
484
754
|
|
|
@@ -487,13 +757,20 @@ def render_brand_spectrum(bundle: dict, theme: str, meta: dict, audit: Optional[
|
|
|
487
757
|
p = get_theme(theme)
|
|
488
758
|
A = Audit(audit)
|
|
489
759
|
axes = bundle.get("axes") or []
|
|
490
|
-
w, h = 540, 300
|
|
491
|
-
out = _svg_open(p, w, h, meta.get("title", "brand spectrum"))
|
|
492
760
|
lang = meta.get("lang", "en")
|
|
493
761
|
left_t = bundle.get("left_zh" if lang == "zh" else "left_en", "negative")
|
|
494
762
|
right_t = bundle.get("right_zh" if lang == "zh" else "right_en", "positive")
|
|
495
763
|
x0, x1 = 150, 400
|
|
496
764
|
y0, gap = 88, 46
|
|
765
|
+
# One row per bipolar axis: a fixed 300px canvas clipped the sixth row
|
|
766
|
+
# (y = 88 + 5·46 = 318, plus a 7.5px dot and its value label). The canvas
|
|
767
|
+
# grows with the row count and keeps the original minimum and top margin.
|
|
768
|
+
last_y = y0 + max(0, len(axes) - 1) * gap
|
|
769
|
+
# The derived-stat note keeps its old baseline until the rows grow into it.
|
|
770
|
+
caption_y = max(278, int(math.ceil(last_y + 7.5 + 12)))
|
|
771
|
+
h = max(300, caption_y + 22)
|
|
772
|
+
w = 540
|
|
773
|
+
out = _svg_open(p, w, h, meta.get("title", "brand spectrum"))
|
|
497
774
|
out.append(f'<text x="{x0 - 12}" y="62" text-anchor="end" font-size="9" font-weight="700" fill="{p["subtext"]}" {lf_fade(40)}>{esc(left_t)}</text>')
|
|
498
775
|
out.append(f'<text x="{x1 + 12}" y="62" font-size="9" font-weight="700" fill="{p["subtext"]}" {lf_fade(80)}>{esc(right_t)}</text>')
|
|
499
776
|
px = lambda t: x0 + t * (x1 - x0)
|
|
@@ -521,7 +798,7 @@ def render_brand_spectrum(bundle: dict, theme: str, meta: dict, audit: Optional[
|
|
|
521
798
|
net_txt = f"{net * 100:+.0f}%"
|
|
522
799
|
out.append(f'<circle cx="{ux:.1f}" cy="{y}" r="7.5" fill="{p["primary"] if net < 0 else p["secondary"]}" stroke="{p["card_bg"]}" stroke-width="1.8" {lf_pop(i * STAGGER_BAR + 160)}><title>{esc(str(label))} — {esc(left_t)}↔{esc(right_t)}: {net_txt} (pos {ax["positive"]} / neg {ax["negative"]} / null {ax["null"]})</title></circle>')
|
|
523
800
|
out.append(f'<text x="{ux:.1f}" y="{y - 11}" fill="{p["text"]}" font-size="8" font-weight="800" text-anchor="middle" {lf_fade(i * STAGGER_BAR + 220)}>{net_txt}</text>')
|
|
524
|
-
out.append(f'<text x="30" y="
|
|
801
|
+
out.append(f'<text x="30" y="{caption_y}" font-size="8" font-weight="600" fill="{p["muted"]}" {lf_fade(400)}>{esc(bundle.get("derived", ""))[:64]}</text>')
|
|
525
802
|
out.append("</svg>")
|
|
526
803
|
return "\n".join(out)
|
|
527
804
|
|
|
@@ -596,27 +873,53 @@ def render_dotty_matrix(bundle: dict, theme: str, meta: dict, audit: Optional[li
|
|
|
596
873
|
p = get_theme(theme)
|
|
597
874
|
A = Audit(audit)
|
|
598
875
|
layers = bundle.get("layers") or []
|
|
599
|
-
w, h = 540, 310
|
|
600
|
-
out = _svg_open(p, w, h, meta.get("title", "dotty matrix"))
|
|
601
876
|
shades = [p["text"], p["secondary"], p["accent"], p["muted"]]
|
|
602
877
|
|
|
603
|
-
|
|
604
|
-
|
|
878
|
+
# The layer plates are parallelograms stacked in depth. Each extra phase
|
|
879
|
+
# lifts the stack, so the front plate keeps its position while every plate
|
|
880
|
+
# behind it moves up: the canvas follows the projections the data actually
|
|
881
|
+
# draws. Cells are clamped into the 6x6 footprint, which is what keeps the
|
|
882
|
+
# plates a readable square.
|
|
883
|
+
top_margin = 24.0
|
|
884
|
+
n_layers = max(1, len(layers))
|
|
885
|
+
# Depth walks the stack upwards, so past the fifth phase the back plates
|
|
886
|
+
# cross y=0 and are clipped off the top. Shift the whole projection down by
|
|
887
|
+
# exactly what the deepest plate needs; figures that already clear the top
|
|
888
|
+
# margin keep shift 0 and their original geometry.
|
|
889
|
+
stack_top = 244 + (-1.2) * 8.4 - (n_layers - 1) * 52
|
|
890
|
+
shift_y = max(0.0, top_margin - stack_top)
|
|
605
891
|
|
|
892
|
+
def P(c, r, k):
|
|
893
|
+
return 236 + (c - r) * 17, 244 + (c + r) * 8.4 - k * 52 + shift_y
|
|
894
|
+
|
|
895
|
+
max_r = min(5, max((int(cell["r"]) for layer in layers
|
|
896
|
+
for cell in layer.get("cells") or []), default=5))
|
|
897
|
+
max_c = min(5, max((int(cell["c"]) for layer in layers
|
|
898
|
+
for cell in layer.get("cells") or []), default=5))
|
|
899
|
+
# The phase legend sits beside each plate and the derived note under the
|
|
900
|
+
# stack, so the canvas has to fit both the widest plate + its label and the
|
|
901
|
+
# lowest plate + the note.
|
|
902
|
+
depths = [P(max_c + 0.6, max_r + 0.6, k)[1] for k in range(n_layers)]
|
|
903
|
+
derived_y = max(294.0 + shift_y, max(depths) + 12)
|
|
904
|
+
body: List[str] = []
|
|
606
905
|
for k in range(len(layers)):
|
|
906
|
+
# The plate keeps the designed 6x6 footprint so sparse phases do not
|
|
907
|
+
# shrink the stack; only the dots follow the data.
|
|
607
908
|
cs = [P(-0.6, -0.6, k), P(5.6, -0.6, k), P(5.6, 5.6, k), P(-0.6, 5.6, k)]
|
|
608
909
|
poly_d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in cs) + " Z"
|
|
609
|
-
|
|
910
|
+
body.append(f'<path d="{poly_d}" fill="{p["card_bg"]}" fill-opacity="0.96" stroke="{p["border"]}" stroke-width="0.9" {lf_fade(k * STAGGER_BAR + 40)}/>')
|
|
610
911
|
layer = layers[k]
|
|
611
912
|
for ci, cell in enumerate(layer["cells"]):
|
|
612
913
|
A.log(f"layers[{k}].cells[{ci}].r", int(cell["r"]))
|
|
613
914
|
A.log(f"layers[{k}].cells[{ci}].c", int(cell["c"]))
|
|
614
|
-
x, y = P(cell["c"], cell["r"], k)
|
|
615
|
-
|
|
915
|
+
x, y = P(min(5, int(cell["c"])), min(5, int(cell["r"])), k)
|
|
916
|
+
body.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="1.5" fill="{shades[k % len(shades)]}" {lf_pop(min(k * STAGGER_BAR + ci * STAGGER_DOT, 1400))}><title>{esc(str(layer["label"]))} — activity #{ci + 1}</title></circle>')
|
|
616
917
|
cxr, cyr = P(5.6, -0.6, k)
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
918
|
+
body.append(f'<line x1="{cxr + 4:.1f}" y1="{cyr:.1f}" x2="{cxr + 20:.1f}" y2="{cyr:.1f}" stroke="{p["muted"]}" stroke-width="0.8" {lf_fade(k * STAGGER_BAR + 200)}/>')
|
|
919
|
+
body.append(f'<text x="{cxr + 24:.1f}" y="{cyr + 2.5:.1f}" font-size="8" font-weight="700" fill="{shades[k % len(shades)]}" {lf_fade(k * STAGGER_BAR + 220)}>{esc(str(layer["label"])[:18])}</text>')
|
|
920
|
+
body.append(f'<text x="30" y="{derived_y:.0f}" font-size="7.5" font-weight="600" fill="{p["muted"]}" {lf_fade(500)}>{esc(str(bundle.get("derived", ""))[:70])}</text>')
|
|
921
|
+
w, h = _fit_canvas(body, h_min=310)
|
|
922
|
+
out = _svg_open(p, w, h, meta.get("title", "dotty matrix")) + body
|
|
620
923
|
out.append("</svg>")
|
|
621
924
|
return "\n".join(out)
|
|
622
925
|
|
|
@@ -678,13 +981,19 @@ def render_matrix_heat(bundle: dict, theme: str, meta: dict, audit: Optional[lis
|
|
|
678
981
|
years = bundle.get("years") or []
|
|
679
982
|
outcomes = bundle.get("outcomes") or []
|
|
680
983
|
cells = bundle.get("cells") or []
|
|
681
|
-
w = 540
|
|
682
|
-
h = 96 + len(outcomes) * 40 + 24
|
|
683
|
-
out = _svg_open(p, w, h, meta.get("title", "matrix heat"))
|
|
684
984
|
lang = meta.get("lang", "en")
|
|
685
|
-
cell_w = 460 / max(1, len(years))
|
|
686
985
|
cell_h = 32
|
|
687
986
|
x0, y0 = 150, 78
|
|
987
|
+
n_years = max(1, len(years))
|
|
988
|
+
# Columns must fit the space between the row labels and the right margin.
|
|
989
|
+
# A fixed 460px budget overflowed the 540px canvas from four years up,
|
|
990
|
+
# which clipped the final column out of the rendered figure.
|
|
991
|
+
min_col = 56
|
|
992
|
+
right_margin = 20
|
|
993
|
+
w = max(540, x0 + n_years * min_col + right_margin)
|
|
994
|
+
cell_w = (w - x0 - right_margin) / n_years
|
|
995
|
+
h = 96 + len(outcomes) * 40 + 24
|
|
996
|
+
out = _svg_open(p, w, h, meta.get("title", "matrix heat"))
|
|
688
997
|
maxv = max((v for row in cells for v in row), default=1) or 1
|
|
689
998
|
for j, y in enumerate(years):
|
|
690
999
|
x = x0 + (j + 0.5) * cell_w
|
|
@@ -773,4 +1082,6 @@ def render_figure(fig_type: str, bundle: dict, theme: str, meta: dict,
|
|
|
773
1082
|
entry = REGISTRY.get(fig_type)
|
|
774
1083
|
if entry is None:
|
|
775
1084
|
raise ValueError(f"unregistered lieflat chart type: {fig_type!r}")
|
|
776
|
-
|
|
1085
|
+
svg = entry["renderer"](bundle, theme, meta or {}, audit)
|
|
1086
|
+
check_geometry(svg, fig_type)
|
|
1087
|
+
return svg
|