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.
Files changed (267) hide show
  1. package/CHANGELOG.md +395 -0
  2. package/CONTRIBUTING.md +105 -0
  3. package/README.md +113 -49
  4. package/README.zh-CN.md +39 -12
  5. package/SKILL.md +15 -5
  6. package/assets/readme/landing-tour.gif +0 -0
  7. package/assets/readme/studio-tour.gif +0 -0
  8. package/benchmarks/evidence-library.json +277 -1
  9. package/bin/eduevidence.js +2 -1
  10. package/docs/architecture.md +325 -46
  11. package/docs/demo-workplace-ai.md +1 -1
  12. package/docs/install-guide.md +1 -1
  13. package/docs/j-ev-experimental.md +250 -0
  14. package/docs/orchestration-role-model.md +1 -1
  15. package/docs/release-closeout/README.md +1 -1
  16. package/docs/reproducibility.md +138 -0
  17. package/docs/sciverse-api.md +125 -0
  18. package/domains/_neutral/copy/few_shots.json +21 -0
  19. package/domains/_neutral/copy/framing_lexicon.json +19 -0
  20. package/domains/_neutral/copy/module_labels.json +5 -0
  21. package/domains/_neutral/copy/module_labels_footer.json +102 -0
  22. package/domains/_neutral/copy/module_labels_modules.json +204 -0
  23. package/domains/_neutral/copy/module_labels_nav.json +126 -0
  24. package/domains/_neutral/copy/module_labels_summary.json +98 -0
  25. package/domains/_neutral/copy/module_labels_tables.json +164 -0
  26. package/domains/_neutral/copy/module_labels_v2.json +90 -0
  27. package/domains/_neutral/copy/risk_constructs.json +20 -0
  28. package/domains/_neutral/copy/section_titles.json +66 -0
  29. package/domains/_neutral/copy/terminology.json +11 -0
  30. package/domains/check_copy_packs.py +103 -0
  31. package/domains/education/copy/few_shots.json +22 -0
  32. package/domains/education/copy/framing_enums.json +167 -0
  33. package/domains/education/copy/framing_lexicon.json +166 -0
  34. package/domains/education/copy/module_labels.json +169 -0
  35. package/domains/education/copy/risk_constructs.json +48 -0
  36. package/domains/education/copy/section_titles.json +186 -0
  37. package/domains/education/copy/terminology.json +70 -0
  38. package/domains/education/manifest.json +1 -1
  39. package/domains/education/outcome_taxonomy.json +2 -2
  40. package/domains/manifest.json +1 -1
  41. package/domains/policy/copy/few_shots.json +22 -0
  42. package/domains/policy/copy/framing_enums.json +94 -0
  43. package/domains/policy/copy/framing_lexicon.json +174 -0
  44. package/domains/policy/copy/module_labels.json +168 -0
  45. package/domains/policy/copy/risk_constructs.json +33 -0
  46. package/domains/policy/copy/section_titles.json +186 -0
  47. package/domains/policy/copy/terminology.json +64 -0
  48. package/eduevidence_cli.py +10 -0
  49. package/engine/capabilities.py +57 -5
  50. package/engine/decision_policy.py +167 -0
  51. package/engine/evidence_graph.py +14 -10
  52. package/engine/gaps.py +42 -22
  53. package/engine/ids.py +2 -0
  54. package/engine/library.py +6 -2
  55. package/engine/library_builtin.py +7 -4
  56. package/engine/living.py +34 -4
  57. package/engine/migration.py +88 -3
  58. package/engine/orchestration.py +5 -5
  59. package/engine/paths.py +2 -0
  60. package/engine/pilot.py +34 -32
  61. package/engine/taxonomy.py +211 -0
  62. package/engine/tribunal.py +49 -43
  63. package/engine/versions.py +1 -1
  64. package/examples/ai-coding-assistant-evidence/EduEvidence_Report.html +1361 -147
  65. package/examples/ai-coding-assistant-evidence/artifact_manifest.json +3 -3
  66. package/examples/ai-coding-assistant-evidence/citation_check.json +1 -1
  67. package/examples/ai-coding-assistant-evidence/final_verdict.json +107 -0
  68. package/examples/ai-coding-assistant-evidence/gate_report.json +101 -0
  69. package/examples/ai-coding-assistant-evidence/report_spec.json +23 -12
  70. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_academic.html +448 -128
  71. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_claude.html +448 -128
  72. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_datalab-dark.html +448 -128
  73. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_datalab.html +448 -128
  74. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_presentation.html +448 -128
  75. package/examples/ai-coding-assistant-evidence/reports-5themes/report_academic.html +1360 -146
  76. package/examples/ai-coding-assistant-evidence/reports-5themes/report_claude.html +1360 -146
  77. package/examples/ai-coding-assistant-evidence/reports-5themes/report_datalab-dark.html +1360 -146
  78. package/examples/ai-coding-assistant-evidence/reports-5themes/report_datalab.html +1360 -146
  79. package/examples/ai-coding-assistant-evidence/reports-5themes/report_presentation.html +1360 -146
  80. package/examples/ai-coding-assistant-evidence/result.json +13 -9
  81. package/examples/ai-coding-assistant-evidence/result.zh.json +45 -41
  82. package/examples/ai-coding-assistant-evidence/skeptic.json +72 -0
  83. package/examples/ai-coding-assistant-evidence/verdict.json +6 -2
  84. package/examples/spaced-retrieval-practice/EduEvidence_Report.html +2728 -0
  85. package/examples/spaced-retrieval-practice/applicability.json +14 -0
  86. package/examples/spaced-retrieval-practice/artifact_manifest.json +15 -0
  87. package/examples/spaced-retrieval-practice/claims.jsonl +3 -0
  88. package/examples/spaced-retrieval-practice/evidence.jsonl +6 -0
  89. package/examples/spaced-retrieval-practice/final_verdict.json +93 -0
  90. package/examples/spaced-retrieval-practice/frame.json +58 -0
  91. package/examples/spaced-retrieval-practice/gate_report.json +101 -0
  92. package/examples/spaced-retrieval-practice/methodology.json +78 -0
  93. package/examples/spaced-retrieval-practice/report.html +2522 -0
  94. package/examples/spaced-retrieval-practice/report_spec.json +212 -0
  95. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_academic.html +2728 -0
  96. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_claude.html +2728 -0
  97. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_datalab-dark.html +2728 -0
  98. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_datalab.html +2728 -0
  99. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_presentation.html +2728 -0
  100. package/examples/spaced-retrieval-practice/reports-5themes/report_academic.html +2728 -0
  101. package/examples/spaced-retrieval-practice/reports-5themes/report_claude.html +2728 -0
  102. package/examples/spaced-retrieval-practice/reports-5themes/report_datalab-dark.html +2728 -0
  103. package/examples/spaced-retrieval-practice/reports-5themes/report_datalab.html +2728 -0
  104. package/examples/spaced-retrieval-practice/reports-5themes/report_presentation.html +2728 -0
  105. package/examples/spaced-retrieval-practice/result.json +942 -0
  106. package/examples/spaced-retrieval-practice/result.zh.json +942 -0
  107. package/examples/spaced-retrieval-practice/skeptic.json +70 -0
  108. package/examples/spaced-retrieval-practice/sources.jsonl +7 -0
  109. package/examples/spaced-retrieval-practice/verdict.json +93 -0
  110. package/examples/workplace-ai-assistant/EduEvidence_Report.html +2814 -0
  111. package/examples/workplace-ai-assistant/artifact_manifest.json +15 -0
  112. package/examples/workplace-ai-assistant/claims.jsonl +4 -4
  113. package/examples/workplace-ai-assistant/evidence.jsonl +4 -4
  114. package/examples/workplace-ai-assistant/evidence_graph.json +15 -15
  115. package/examples/workplace-ai-assistant/final_verdict.json +78 -0
  116. package/examples/workplace-ai-assistant/gate_report.json +101 -0
  117. package/examples/workplace-ai-assistant/report_spec.json +209 -40
  118. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_academic.html +449 -119
  119. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_claude.html +449 -119
  120. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_datalab-dark.html +449 -119
  121. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_datalab.html +449 -119
  122. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_presentation.html +449 -119
  123. package/examples/workplace-ai-assistant/reports-5themes/report_academic.html +2814 -0
  124. package/examples/workplace-ai-assistant/reports-5themes/report_claude.html +2814 -0
  125. package/examples/workplace-ai-assistant/reports-5themes/report_datalab-dark.html +2814 -0
  126. package/examples/workplace-ai-assistant/reports-5themes/report_datalab.html +2814 -0
  127. package/examples/workplace-ai-assistant/reports-5themes/report_presentation.html +2814 -0
  128. package/examples/workplace-ai-assistant/result.json +82 -20
  129. package/examples/workplace-ai-assistant/result.zh.json +82 -20
  130. package/examples/workplace-ai-assistant/skeptic.json +72 -0
  131. package/examples/workplace-ai-assistant/verdict.json +36 -10
  132. package/integrations/agent_mcp.py +2 -2
  133. package/integrations/jev/__init__.py +115 -0
  134. package/integrations/jev/approval.py +212 -0
  135. package/integrations/jev/cli.py +84 -0
  136. package/integrations/jev/config.py +112 -0
  137. package/integrations/jev/gateway.py +128 -0
  138. package/integrations/jev/modes.py +38 -0
  139. package/integrations/jev/tools_classify.py +88 -0
  140. package/integrations/jev/tools_extract.py +111 -0
  141. package/integrations/jev/tools_rerank.py +71 -0
  142. package/integrations/jev/tools_screen.py +87 -0
  143. package/integrations/jev/tools_verify.py +95 -0
  144. package/integrations/jev_mcp.py +22 -0
  145. package/integrations/semantic_decide.py +286 -0
  146. package/integrations/semdecide_cli.py +55 -0
  147. package/package.json +19 -2
  148. package/pyproject.toml +4 -3
  149. package/references/report-copy-style.md +107 -0
  150. package/references/retrieval-compliance.md +75 -0
  151. package/references/retrieval-protocol.md +20 -0
  152. package/retrieval/audit.py +27 -3
  153. package/retrieval/fetch.py +96 -0
  154. package/retrieval/sciverse.py +398 -0
  155. package/retrieval/search.py +47 -7
  156. package/schemas/applicability.schema.json +94 -0
  157. package/schemas/chart-spec.schema.json +10 -3
  158. package/schemas/evidence.schema.json +316 -43
  159. package/schemas/fetch-result.schema.json +2 -1
  160. package/schemas/report-result.schema.json +3 -3
  161. package/schemas/report-spec.schema.json +98 -100
  162. package/schemas/skeptic.schema.json +86 -0
  163. package/schemas/source.schema.json +21 -2
  164. package/schemas/v2/decision-snapshot.schema.json +20 -9
  165. package/schemas/v2/finding.schema.json +5 -1
  166. package/schemas/v2/intake.schema.json +191 -0
  167. package/schemas/v2/methodology-audit.schema.json +5 -1
  168. package/schemas/v2/outcome.schema.json +28 -5
  169. package/schemas/v2/study.schema.json +5 -1
  170. package/schemas/vNext/autoevolve-session.schema.json +34 -1
  171. package/schemas/vNext/eval-snapshot.schema.json +77 -1
  172. package/schemas/vNext/execution-plan.schema.json +50 -1
  173. package/schemas/vNext/gap-priority.schema.json +54 -1
  174. package/schemas/vNext/negative-search-record.schema.json +68 -1
  175. package/schemas/vNext/research-iteration.schema.json +87 -1
  176. package/schemas/vNext/research-strategy.schema.json +62 -1
  177. package/schemas/vNext/skill-experiment.schema.json +90 -1
  178. package/schemas/vNext/task-spec.schema.json +156 -1
  179. package/schemas/vNext/worker-result.schema.json +60 -1
  180. package/schemas/verdict.schema.json +164 -28
  181. package/scripts/build_evidence_library.py +15 -5
  182. package/scripts/build_report_variants.py +18 -2
  183. package/scripts/build_result.py +74 -9
  184. package/scripts/check_package_parity.py +85 -0
  185. package/scripts/check_protocol_alignment.py +375 -0
  186. package/scripts/check_versioned_schemas.py +254 -0
  187. package/scripts/claim_audit.py +13 -8
  188. package/scripts/compute_confidence.py +10 -0
  189. package/scripts/dashboard_server.py +13 -2
  190. package/scripts/did_regression.py +12 -2
  191. package/scripts/evidence_score.py +5 -2
  192. package/scripts/intake/__init__.py +31 -0
  193. package/scripts/intake/__main__.py +18 -0
  194. package/scripts/intake/background.py +78 -0
  195. package/scripts/intake/browser.py +79 -0
  196. package/scripts/intake/cli.py +57 -0
  197. package/scripts/intake/constants.py +57 -0
  198. package/scripts/intake/depth.py +53 -0
  199. package/scripts/intake/enhancements.py +106 -0
  200. package/scripts/intake/hooks.py +90 -0
  201. package/scripts/intake/prefs.py +76 -0
  202. package/scripts/intake/prompts.py +85 -0
  203. package/scripts/intake/session.py +152 -0
  204. package/scripts/lint_file_layers.py +126 -0
  205. package/scripts/orchestrator.py +187 -40
  206. package/scripts/pre_verdict_gate.py +241 -29
  207. package/scripts/quickstart.py +18 -2
  208. package/scripts/run_workspace.py +7 -1
  209. package/scripts/skill_lint.py +11 -1
  210. package/scripts/skill_payload.py +6 -3
  211. package/scripts/test_adversarial_empirical.py +96 -25
  212. package/scripts/validate_schema.py +31 -1
  213. package/skill/agents/evaluation-designer.md +20 -4
  214. package/skill/agents/evidence-analyst.md +19 -3
  215. package/skill/agents/evidence-judge.md +98 -8
  216. package/skill/agents/evidence-retriever.md +20 -3
  217. package/skill/agents/intervention-designer.md +20 -4
  218. package/skill/agents/method-reviewer.md +18 -2
  219. package/skill/agents/{education-planner.md → research-planner.md} +19 -3
  220. package/skill/agents/skeptic.md +18 -2
  221. package/skill/roles/registry.yaml +11 -11
  222. package/skill/sub-skills/aihot-trend-analysis/SKILL.md +28 -9
  223. package/skill/sub-skills/contradiction-analysis/SKILL.md +31 -11
  224. package/skill/sub-skills/data-analysis/SKILL.md +34 -15
  225. package/skill/sub-skills/ethics-review/SKILL.md +33 -10
  226. package/skill/sub-skills/evidence-extraction/SKILL.md +29 -11
  227. package/skill/sub-skills/evidence-review/SKILL.md +31 -12
  228. package/skill/sub-skills/gap-analysis/SKILL.md +31 -9
  229. package/skill/sub-skills/literature-review/SKILL.md +35 -14
  230. package/skill/sub-skills/methodology-audit/SKILL.md +29 -12
  231. package/skill/sub-skills/report-generation/SKILL.md +28 -0
  232. package/skill/sub-skills/research-planning/SKILL.md +41 -14
  233. package/skill/sub-skills/study-design/SKILL.md +30 -9
  234. package/skill/task-briefs/adjudicate.md +32 -7
  235. package/skill/task-briefs/applicability.md +37 -2
  236. package/skill/task-briefs/audit.md +32 -7
  237. package/skill/task-briefs/challenge.md +34 -5
  238. package/skill/task-briefs/evaluate.md +30 -5
  239. package/skill/task-briefs/extract.md +31 -8
  240. package/skill/task-briefs/frame.md +39 -10
  241. package/skill/task-briefs/intervene.md +32 -6
  242. package/skill/task-briefs/present.md +32 -8
  243. package/skill/task-briefs/projection.md +36 -2
  244. package/skill/task-briefs/retrieve.md +36 -6
  245. package/skill/workflows/decision-and-pilot.md +76 -1
  246. package/skill/workflows/evaluate-and-update.md +83 -0
  247. package/skill/workflows/evidence-review.md +104 -0
  248. package/skill/workflows/experimental-jev.md +170 -0
  249. package/skill/workflows/intake.md +120 -0
  250. package/visualization/eduevidence-report/scripts/build_figures.py +25 -3
  251. package/visualization/eduevidence-report/scripts/build_infographics.py +37 -15
  252. package/visualization/eduevidence-report/scripts/build_report.py +435 -575
  253. package/visualization/eduevidence-report/scripts/charts_data.py +2 -0
  254. package/visualization/eduevidence-report/scripts/lieflat_engine.py +349 -38
  255. package/visualization/eduevidence-report/scripts/report_copy_pack.py +296 -0
  256. package/visualization/eduevidence-report/scripts/report_copy_policy_guard.py +47 -0
  257. package/visualization/eduevidence-report/scripts/zh_labels.py +141 -1
  258. package/web/architecture.html +14885 -0
  259. package/web/studio/assets/index-B8tkF44Q.css +1 -0
  260. package/web/studio/index.html +2 -2
  261. package/scripts/build_esl_artifacts.py +0 -1921
  262. package/scripts/build_killer_demo.py +0 -295
  263. package/scripts/enrich_projects_human_and_lieflat.py +0 -315
  264. package/scripts/generate_new_projects.py +0 -686
  265. package/scripts/sync_killer_demo_report.py +0 -270
  266. package/web/studio/assets/index-CzXocaGv.css +0 -1
  267. /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("&lt;", "<").replace("&gt;", ">")
99
+ .replace("&quot;", '"').replace("&#39;", "'").replace("&#x27;", "'")
100
+ .replace("&amp;", "&").replace("&nbsp;", " "))
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
- w, h = 540, 300
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
- out.append(f'<text x="{x:.0f}" y="76" fill="{p["text"]}" font-size="9" font-weight="600" text-anchor="middle" {lf_fade(j * STAGGER_BAR + 40)}>{esc(str(d).replace("_", " ")[:14])}</text>')
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 = {n} studies, significant = {c.get("sig", 0)}</title></circle>')
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
- w, h = 540, 300
452
- out = _svg_open(p, w, h, meta.get("title", "paired rungs"))
453
- colors = _dir_colors(p)
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
- out.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>')
459
- out.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>')
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
- out.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>')
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
- out.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>')
479
- out.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>')
480
- out.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>')
481
- out.append(f'<line x1="46" y1="{y_base}" x2="512" y2="{y_base}" stroke="{p["text"]}" stroke-width="1.2" {lf_draw(120)}/>')
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="278" font-size="8" font-weight="600" fill="{p["muted"]}" {lf_fade(400)}>{esc(bundle.get("derived", ""))[:64]}</text>')
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
- def P(c, r, k):
604
- return 236 + (c - r) * 17, 244 + (c + r) * 8.4 - k * 52
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
- out.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)}/>')
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
- out.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>')
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
- out.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)}/>')
618
- out.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>')
619
- out.append(f'<text x="30" y="294" font-size="7.5" font-weight="600" fill="{p["muted"]}" {lf_fade(500)}>{esc(str(bundle.get("derived", ""))[:70])}</text>')
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
- return entry["renderer"](bundle, theme, meta or {}, audit)
1085
+ svg = entry["renderer"](bundle, theme, meta or {}, audit)
1086
+ check_geometry(svg, fig_type)
1087
+ return svg