eduevidence 5.2.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/LICENSE +21 -0
- package/README.md +506 -0
- package/README.zh-CN.md +434 -0
- package/SKILL.md +195 -0
- package/bin/eduevidence.js +127 -0
- package/domains/education/manifest.json +183 -0
- package/domains/education/outcome_taxonomy.json +127 -0
- package/domains/manifest.json +26 -0
- package/domains/policy/frame.schema.json +234 -0
- package/domains/policy/manifest.json +10 -0
- package/domains/policy/methodology_checklist.json +109 -0
- package/domains/policy/outcome_taxonomy.json +53 -0
- package/domains/policy/references/causal-identification.md +45 -0
- package/domains/policy/references/cost-evidence.md +44 -0
- package/domains/policy/references/equity.md +42 -0
- package/domains/policy/references/evidence-hierarchy.md +41 -0
- package/domains/policy/references/implementation-evidence.md +47 -0
- package/eduevidence_cli.py +26 -0
- package/engine/__init__.py +11 -0
- package/engine/__pycache__/__init__.cpython-312.pyc +0 -0
- package/engine/__pycache__/analysis.cpython-312.pyc +0 -0
- package/engine/__pycache__/bias.cpython-312.pyc +0 -0
- package/engine/__pycache__/briefs.cpython-312.pyc +0 -0
- package/engine/__pycache__/capabilities.cpython-312.pyc +0 -0
- package/engine/__pycache__/citation_check.cpython-312.pyc +0 -0
- package/engine/__pycache__/contracts.cpython-312.pyc +0 -0
- package/engine/__pycache__/datasets.cpython-312.pyc +0 -0
- package/engine/__pycache__/events.cpython-312.pyc +0 -0
- package/engine/__pycache__/evidence_graph.cpython-312.pyc +0 -0
- package/engine/__pycache__/evidence_review.cpython-312.pyc +0 -0
- package/engine/__pycache__/evidencecore.cpython-312.pyc +0 -0
- package/engine/__pycache__/gap_lens.cpython-312.pyc +0 -0
- package/engine/__pycache__/gaps.cpython-312.pyc +0 -0
- package/engine/__pycache__/graph_store.cpython-312.pyc +0 -0
- package/engine/__pycache__/graph_validate.cpython-312.pyc +0 -0
- package/engine/__pycache__/ids.cpython-312.pyc +0 -0
- package/engine/__pycache__/library.cpython-312.pyc +0 -0
- package/engine/__pycache__/library_builtin.cpython-312.pyc +0 -0
- package/engine/__pycache__/living.cpython-312.pyc +0 -0
- package/engine/__pycache__/log.cpython-312.pyc +0 -0
- package/engine/__pycache__/meta_analysis.cpython-312.pyc +0 -0
- package/engine/__pycache__/meta_synthesis.cpython-312.pyc +0 -0
- package/engine/__pycache__/migration.cpython-312.pyc +0 -0
- package/engine/__pycache__/mode_router.cpython-312.pyc +0 -0
- package/engine/__pycache__/paths.cpython-312.pyc +0 -0
- package/engine/__pycache__/pilot.cpython-312.pyc +0 -0
- package/engine/__pycache__/planner.cpython-312.pyc +0 -0
- package/engine/__pycache__/project.cpython-312.pyc +0 -0
- package/engine/__pycache__/projections.cpython-312.pyc +0 -0
- package/engine/__pycache__/robustness.cpython-312.pyc +0 -0
- package/engine/__pycache__/run.cpython-312.pyc +0 -0
- package/engine/__pycache__/semantics.cpython-312.pyc +0 -0
- package/engine/__pycache__/study_design.cpython-312.pyc +0 -0
- package/engine/__pycache__/synthesis.cpython-312.pyc +0 -0
- package/engine/__pycache__/tribunal.cpython-312.pyc +0 -0
- package/engine/__pycache__/update.cpython-312.pyc +0 -0
- package/engine/__pycache__/versions.cpython-312.pyc +0 -0
- package/engine/analysis.py +308 -0
- package/engine/bias.py +178 -0
- package/engine/briefs.py +106 -0
- package/engine/capabilities.py +99 -0
- package/engine/citation_check.py +192 -0
- package/engine/contracts.py +117 -0
- package/engine/datasets.py +165 -0
- package/engine/events.py +67 -0
- package/engine/evidence_graph.py +571 -0
- package/engine/evidence_review.py +88 -0
- package/engine/evidencecore.py +182 -0
- package/engine/gap_lens.py +132 -0
- package/engine/gaps.py +169 -0
- package/engine/graph_store.py +335 -0
- package/engine/graph_validate.py +87 -0
- package/engine/ids.py +77 -0
- package/engine/library.py +268 -0
- package/engine/library_builtin.py +301 -0
- package/engine/living.py +671 -0
- package/engine/log.py +39 -0
- package/engine/meta_analysis.py +333 -0
- package/engine/meta_synthesis.py +111 -0
- package/engine/migration.py +397 -0
- package/engine/mode_router.py +72 -0
- package/engine/paths.py +15 -0
- package/engine/pilot.py +368 -0
- package/engine/planner.py +126 -0
- package/engine/project.py +118 -0
- package/engine/projections.py +240 -0
- package/engine/robustness.py +109 -0
- package/engine/run.py +85 -0
- package/engine/semantics.py +135 -0
- package/engine/study_design.py +87 -0
- package/engine/synthesis.py +187 -0
- package/engine/tribunal.py +408 -0
- package/engine/update.py +113 -0
- package/engine/versions.py +12 -0
- package/install.sh +510 -0
- package/integrations/__init__.py +1 -0
- package/integrations/__pycache__/__init__.cpython-312.pyc +0 -0
- package/integrations/__pycache__/agent_mcp.cpython-312.pyc +0 -0
- package/integrations/__pycache__/smart_web_fetch.cpython-312.pyc +0 -0
- package/integrations/agent_mcp.py +856 -0
- package/integrations/smart_web_fetch.py +59 -0
- package/package.json +50 -0
- package/pyproject.toml +55 -0
- package/references/applicability-policy.md +88 -0
- package/references/education-framing.md +132 -0
- package/references/effect_size_formulas.md +35 -0
- package/references/evaluation-design.md +111 -0
- package/references/evidence-quality.md +79 -0
- package/references/grade_framework.md +29 -0
- package/references/intervention-design.md +98 -0
- package/references/methodology-audit.md +103 -0
- package/references/outcome-taxonomy.md +106 -0
- package/references/retrieval-protocol.md +142 -0
- package/references/skeptic-protocol.md +93 -0
- package/references/social_science_pitfalls.md +48 -0
- package/references/source-validity.md +140 -0
- package/references/tribunal-policy.md +112 -0
- package/references/wwc_standards.md +29 -0
- package/retrieval/__init__.py +1 -0
- package/retrieval/__pycache__/__init__.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/corpus_store.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/dedupe.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/failures.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/fetch.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/search.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/source.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/validate.cpython-312.pyc +0 -0
- package/retrieval/corpus_store.py +181 -0
- package/retrieval/dedupe.py +127 -0
- package/retrieval/failures.py +90 -0
- package/retrieval/fetch.py +435 -0
- package/retrieval/search.py +493 -0
- package/retrieval/source.py +160 -0
- package/retrieval/validate.py +257 -0
- package/schemas/agent-mcp-approval.schema.json +57 -0
- package/schemas/chart-spec.schema.json +88 -0
- package/schemas/cross-model-review.schema.json +28 -0
- package/schemas/education-frame.schema.json +106 -0
- package/schemas/evaluation.schema.json +35 -0
- package/schemas/evidence.schema.json +81 -0
- package/schemas/fetch-result.schema.json +119 -0
- package/schemas/intervention.schema.json +46 -0
- package/schemas/methodology.schema.json +102 -0
- package/schemas/report-result.schema.json +381 -0
- package/schemas/report-spec.schema.json +130 -0
- package/schemas/source.schema.json +311 -0
- package/schemas/v2/analysis-plan.schema.json +28 -0
- package/schemas/v2/analysis-run.schema.json +33 -0
- package/schemas/v2/claim.schema.json +26 -0
- package/schemas/v2/dataset-asset.schema.json +40 -0
- package/schemas/v2/decision-snapshot.schema.json +53 -0
- package/schemas/v2/evidence-link.schema.json +38 -0
- package/schemas/v2/finding.schema.json +47 -0
- package/schemas/v2/graph-revision.schema.json +30 -0
- package/schemas/v2/knowledge-gap.schema.json +40 -0
- package/schemas/v2/methodology-audit.schema.json +30 -0
- package/schemas/v2/outcome.schema.json +18 -0
- package/schemas/v2/project.schema.json +31 -0
- package/schemas/v2/research-intent.schema.json +24 -0
- package/schemas/v2/run.schema.json +43 -0
- package/schemas/v2/source.schema.json +24 -0
- package/schemas/v2/study-design.schema.json +67 -0
- package/schemas/v2/study.schema.json +37 -0
- package/schemas/v3/pilot-outcome.schema.json +132 -0
- package/schemas/v3/run-manifest.schema.json +193 -0
- package/schemas/v3/synthesis.schema.json +86 -0
- package/schemas/v4/drift-report.schema.json +66 -0
- package/schemas/v4/evidence-library.schema.json +42 -0
- package/schemas/v4/living-subscription.schema.json +55 -0
- package/schemas/v4/meta-analysis.schema.json +152 -0
- package/schemas/verdict.schema.json +56 -0
- package/scripts/__init__.py +0 -0
- package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_evaluator.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_judge.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_routing.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_v2.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_v3.cpython-312.pyc +0 -0
- package/scripts/__pycache__/build_result.cpython-312.pyc +0 -0
- package/scripts/__pycache__/claim_audit.cpython-312.pyc +0 -0
- package/scripts/__pycache__/complexity_gate.cpython-312.pyc +0 -0
- package/scripts/__pycache__/compute_confidence.cpython-312.pyc +0 -0
- package/scripts/__pycache__/dashboard_server.cpython-312.pyc +0 -0
- package/scripts/__pycache__/did_regression.cpython-312.pyc +0 -0
- package/scripts/__pycache__/effect_calculator.cpython-312.pyc +0 -0
- package/scripts/__pycache__/evidence_matrix.cpython-312.pyc +0 -0
- package/scripts/__pycache__/evidence_score.cpython-312.pyc +0 -0
- package/scripts/__pycache__/evidence_semantics.cpython-312.pyc +0 -0
- package/scripts/__pycache__/fetch_benchmark.cpython-312.pyc +0 -0
- package/scripts/__pycache__/lint_report_layout.cpython-312.pyc +0 -0
- package/scripts/__pycache__/orchestrator.cpython-312.pyc +0 -0
- package/scripts/__pycache__/pre_verdict_gate.cpython-312.pyc +0 -0
- package/scripts/__pycache__/recompute_demo_quality.cpython-312.pyc +0 -0
- package/scripts/__pycache__/render_report.cpython-312.pyc +0 -0
- package/scripts/__pycache__/render_report_html.cpython-312.pyc +0 -0
- package/scripts/__pycache__/run_workspace.cpython-312.pyc +0 -0
- package/scripts/__pycache__/skill_lint.cpython-312.pyc +0 -0
- package/scripts/__pycache__/startup_probe.cpython-312.pyc +0 -0
- package/scripts/__pycache__/sync_killer_demo_report.cpython-312.pyc +0 -0
- package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.0.2.pyc +0 -0
- package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.1.1.pyc +0 -0
- package/scripts/__pycache__/validate_schema.cpython-312.pyc +0 -0
- package/scripts/audit_dois.py +292 -0
- package/scripts/bake_pack.sh +37 -0
- package/scripts/benchmark.py +183 -0
- package/scripts/benchmark_evaluator.py +371 -0
- package/scripts/benchmark_judge.py +535 -0
- package/scripts/benchmark_routing.py +120 -0
- package/scripts/benchmark_v2.py +304 -0
- package/scripts/benchmark_v3.py +552 -0
- package/scripts/build_esl_artifacts.py +1921 -0
- package/scripts/build_evidence_library.py +307 -0
- package/scripts/build_killer_demo.py +295 -0
- package/scripts/build_result.py +311 -0
- package/scripts/check_version_consistency.py +96 -0
- package/scripts/citation_check.py +123 -0
- package/scripts/claim_audit.py +157 -0
- package/scripts/complexity_gate.py +180 -0
- package/scripts/compute_confidence.py +176 -0
- package/scripts/dashboard_server.py +536 -0
- package/scripts/did_regression.py +315 -0
- package/scripts/effect_calculator.py +99 -0
- package/scripts/enrich_projects_human_and_lieflat.py +315 -0
- package/scripts/evidence_matrix.py +129 -0
- package/scripts/evidence_score.py +234 -0
- package/scripts/evidence_semantics.py +87 -0
- package/scripts/fetch_benchmark.py +177 -0
- package/scripts/generate_metrics.py +99 -0
- package/scripts/generate_new_projects.py +686 -0
- package/scripts/generate_promo.py +192 -0
- package/scripts/lint_report_layout.py +182 -0
- package/scripts/orchestrator.py +1456 -0
- package/scripts/pre_verdict_gate.py +513 -0
- package/scripts/quickstart.py +121 -0
- package/scripts/rebake_all_5themes.py +88 -0
- package/scripts/recompute_demo_quality.py +205 -0
- package/scripts/render_report.py +270 -0
- package/scripts/render_report_html.py +356 -0
- package/scripts/retraction_watch.py +110 -0
- package/scripts/run_workspace.py +337 -0
- package/scripts/serve_web.py +54 -0
- package/scripts/skill_lint.py +150 -0
- package/scripts/startup_probe.py +265 -0
- package/scripts/sync_killer_demo_report.py +270 -0
- package/scripts/test_adversarial_empirical.py +541 -0
- package/scripts/validate_schema.py +256 -0
- package/skill/agents/education-planner.md +80 -0
- package/skill/agents/evaluation-designer.md +74 -0
- package/skill/agents/evidence-analyst.md +106 -0
- package/skill/agents/evidence-judge.md +111 -0
- package/skill/agents/evidence-retriever.md +80 -0
- package/skill/agents/intervention-designer.md +82 -0
- package/skill/agents/method-reviewer.md +104 -0
- package/skill/agents/skeptic.md +89 -0
- package/skill/sub-skills/aihot-trend-analysis/SKILL.md +31 -0
- package/skill/sub-skills/contradiction-analysis/SKILL.md +17 -0
- package/skill/sub-skills/data-analysis/SKILL.md +23 -0
- package/skill/sub-skills/ethics-review/SKILL.md +25 -0
- package/skill/sub-skills/evidence-extraction/SKILL.md +19 -0
- package/skill/sub-skills/evidence-review/SKILL.md +18 -0
- package/skill/sub-skills/gap-analysis/SKILL.md +25 -0
- package/skill/sub-skills/literature-review/SKILL.md +21 -0
- package/skill/sub-skills/methodology-audit/SKILL.md +20 -0
- package/skill/sub-skills/report-generation/SKILL.md +51 -0
- package/skill/sub-skills/research-planning/SKILL.md +21 -0
- package/skill/sub-skills/study-design/SKILL.md +16 -0
- package/skill/task-briefs/adjudicate.md +17 -0
- package/skill/task-briefs/audit.md +15 -0
- package/skill/task-briefs/challenge.md +15 -0
- package/skill/task-briefs/evaluate.md +13 -0
- package/skill/task-briefs/extract.md +16 -0
- package/skill/task-briefs/frame.md +17 -0
- package/skill/task-briefs/intervene.md +14 -0
- package/skill/task-briefs/present.md +16 -0
- package/skill/task-briefs/retrieve.md +15 -0
- package/visualization/eduevidence-report/assets/base.css +337 -0
- package/visualization/eduevidence-report/motion/motion.css +157 -0
- package/visualization/eduevidence-report/motion/motion.js +107 -0
- package/visualization/eduevidence-report/references/bilingual-style.md +7 -0
- package/visualization/eduevidence-report/references/component-catalog.md +145 -0
- package/visualization/eduevidence-report/references/evidence-expansion.md +65 -0
- package/visualization/eduevidence-report/references/full-report-outline.md +86 -0
- package/visualization/eduevidence-report/references/layout-constraints.md +63 -0
- package/visualization/eduevidence-report/references/lieflat-composition.md +79 -0
- package/visualization/eduevidence-report/references/motion-system.md +31 -0
- package/visualization/eduevidence-report/schemas/adapter-envelope.schema.json +22 -0
- package/visualization/eduevidence-report/schemas/visual-layout.schema.json +87 -0
- package/visualization/eduevidence-report/scripts/__pycache__/adapter_contract.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_artifact_manifest.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_charts.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_figures.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_infographics.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_report.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/charts_data.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/lieflat_engine.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/zh_labels.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/adapter_contract.py +72 -0
- package/visualization/eduevidence-report/scripts/build_artifact_manifest.py +70 -0
- package/visualization/eduevidence-report/scripts/build_charts.py +283 -0
- package/visualization/eduevidence-report/scripts/build_figures.py +515 -0
- package/visualization/eduevidence-report/scripts/build_infographics.py +268 -0
- package/visualization/eduevidence-report/scripts/build_report.py +3211 -0
- package/visualization/eduevidence-report/scripts/charts_data.py +617 -0
- package/visualization/eduevidence-report/scripts/check_mobile_layout.js +220 -0
- package/visualization/eduevidence-report/scripts/lieflat_engine.py +852 -0
- package/visualization/eduevidence-report/scripts/zh_labels.py +245 -0
- package/visualization/eduevidence-report/themes/academic.css +94 -0
- package/visualization/eduevidence-report/themes/claude.css +96 -0
- package/visualization/eduevidence-report/themes/datalab-dark.css +147 -0
- package/visualization/eduevidence-report/themes/datalab.css +151 -0
- package/visualization/eduevidence-report/themes/presentation.css +140 -0
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""lieflat_engine.py — Data-driven, theme-aware Lieflat Charts pure-SVG renderers.
|
|
3
|
+
|
|
4
|
+
Every renderer receives a normalized data bundle produced by charts_data.py
|
|
5
|
+
(the ONLY numeric source) and emits a themed inline SVG. There is no demo or
|
|
6
|
+
hardcoded data anywhere in this module.
|
|
7
|
+
|
|
8
|
+
Contracts (per the Lieflat Charts codex / mono-tokens.js):
|
|
9
|
+
- No inline <style> blocks: elements carry lf-pop / lf-fade / lf-draw classes
|
|
10
|
+
plus an inline --motion-delay variable; motion/motion.css owns the
|
|
11
|
+
animation definitions (quarticOut-family fast-in fast-stop curves).
|
|
12
|
+
- Stagger: dot matrices 8–15 ms per dot (12 ms), bars 80–130 ms per bar
|
|
13
|
+
(100 ms), capped so large batches stay readable.
|
|
14
|
+
- Card chrome (conclusion title / legend subtitle / caption / uppercase
|
|
15
|
+
source line) lives in the HTML card; the SVG is the figure only.
|
|
16
|
+
- Min SVG font size 6.5 px; in-figure numeric values weight 800;
|
|
17
|
+
area encodings use sqrt.
|
|
18
|
+
- Every number drawn is logged through the audit list with its bundle
|
|
19
|
+
origin path so build_report can verify `lieflat_data_bound`.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import html
|
|
24
|
+
import math
|
|
25
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
26
|
+
|
|
27
|
+
import charts_data as CD
|
|
28
|
+
|
|
29
|
+
THEME_PALETTES = {
|
|
30
|
+
"claude": {"bg": "#FAF9F5", "card_bg": "#FFFFFF", "text": "#2A2925", "subtext": "#6A6963", "muted": "#8F8E88", "grid": "#E4E3DC", "border": "#DEDDD6", "primary": "#B8694A", "secondary": "#5E8A6A", "accent": "#C99A4A", "ribbon": "#F0EFEB"},
|
|
31
|
+
"academic": {"bg": "#FFFFFF", "card_bg": "#FAFAFA", "text": "#0F172A", "subtext": "#475569", "muted": "#94A3B8", "grid": "#E2E8F0", "border": "#CBD5E1", "primary": "#0F172A", "secondary": "#2563EB", "accent": "#059669", "ribbon": "#F1F5F9"},
|
|
32
|
+
"datalab": {"bg": "#F8FAFC", "card_bg": "#FFFFFF", "text": "#0F172A", "subtext": "#475569", "muted": "#94A3B8", "grid": "#E2E8F0", "border": "#CBD5E1", "primary": "#0284C7", "secondary": "#10B981", "accent": "#F59E0B", "ribbon": "#F0F9FF"},
|
|
33
|
+
"datalab-dark": {"bg": "#0B0F17", "card_bg": "#111827", "text": "#F8FAFC", "subtext": "#94A3B8", "muted": "#64748B", "grid": "#1E293B", "border": "#334155", "primary": "#38BDF8", "secondary": "#10B981", "accent": "#F59E0B", "ribbon": "#1E293B"},
|
|
34
|
+
"presentation": {"bg": "#140A08", "card_bg": "#1C0D0A", "text": "#FBBF24", "subtext": "#D97706", "muted": "#9A3412", "grid": "#2D120B", "border": "#4A2218", "primary": "#F59E0B", "secondary": "#F24D29", "accent": "#10B981", "ribbon": "#2A140E"},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
STAGGER_DOT = 12 # ms per dot (token range 8–15)
|
|
38
|
+
STAGGER_BAR = 100 # ms per bar (token range 80–130)
|
|
39
|
+
MIN_FONT = 6.5
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_theme(theme: str = "claude") -> Dict[str, str]:
|
|
43
|
+
t = theme.lower().replace("_", "-")
|
|
44
|
+
if "dark" in t:
|
|
45
|
+
return THEME_PALETTES["datalab-dark"]
|
|
46
|
+
if "acad" in t:
|
|
47
|
+
return THEME_PALETTES["academic"]
|
|
48
|
+
if "data" in t:
|
|
49
|
+
return THEME_PALETTES["datalab"]
|
|
50
|
+
if "pres" in t or "judge" in t:
|
|
51
|
+
return THEME_PALETTES["presentation"]
|
|
52
|
+
return THEME_PALETTES.get(t, THEME_PALETTES["claude"])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def esc(text: Any) -> str:
|
|
56
|
+
return html.escape(str(text if text is not None else ""))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _delay(ms: float) -> str:
|
|
60
|
+
return f"style=\"--motion-delay:{max(0, round(ms))}ms\""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def lf_pop(ms: float = 0) -> str:
|
|
64
|
+
return f'class="lf-pop" {_delay(ms)}'
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def lf_fade(ms: float = 0) -> str:
|
|
68
|
+
return f'class="lf-fade" {_delay(ms)}'
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def lf_draw(ms: float = 0) -> str:
|
|
72
|
+
return f'class="lf-draw" {_delay(ms)}'
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _fmt(v: float, digits: int = 2) -> str:
|
|
76
|
+
return f"{v:.{digits}f}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Audit:
|
|
80
|
+
"""Collects every displayed number as (origin, value) for the
|
|
81
|
+
lieflat_data_bound integrity gate."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, out: Optional[list] = None):
|
|
84
|
+
self.out: list[Tuple[str, Any]] = [] if out is None else out
|
|
85
|
+
|
|
86
|
+
def log(self, origin: str, value: Any) -> str:
|
|
87
|
+
self.out.append((origin, value))
|
|
88
|
+
if isinstance(value, float):
|
|
89
|
+
return _fmt(value)
|
|
90
|
+
return str(value)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _svg_open(p: Dict[str, str], w: int, h: int, aria: str) -> List[str]:
|
|
94
|
+
return [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" '
|
|
95
|
+
f'width="100%" height="100%" role="img" aria-label="{esc(aria)}" '
|
|
96
|
+
f'style="background:{p["card_bg"]};">']
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
100
|
+
# meta.forest — forest plot (Hedges' g + 95% CI)
|
|
101
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
102
|
+
|
|
103
|
+
def render_forest_plot(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
|
|
104
|
+
p = get_theme(theme)
|
|
105
|
+
A = Audit(audit)
|
|
106
|
+
studies = bundle.get("studies") or []
|
|
107
|
+
pooled = bundle.get("pooled")
|
|
108
|
+
w, h = 820, 96 + len(studies) * 27 + 66
|
|
109
|
+
span = max((abs(s["g"]) for s in studies), default=0.5)
|
|
110
|
+
for s in studies:
|
|
111
|
+
if s.get("ci_lower") is not None:
|
|
112
|
+
span = max(span, abs(s["ci_lower"]))
|
|
113
|
+
if s.get("ci_upper") is not None:
|
|
114
|
+
span = max(span, abs(s["ci_upper"]))
|
|
115
|
+
span = max(0.5, span * 1.15)
|
|
116
|
+
cx = w / 2
|
|
117
|
+
scale = (w * 0.34) / span
|
|
118
|
+
out = _svg_open(p, w, h, meta.get("title", "forest plot"))
|
|
119
|
+
head_y = 40
|
|
120
|
+
out.append(f'<text x="24" y="{head_y}" font-size="11" font-weight="700" fill="{p["subtext"]}" {lf_fade(0)}>Study</text>')
|
|
121
|
+
out.append(f'<text x="200" y="{head_y}" font-size="11" font-weight="700" fill="{p["subtext"]}" {lf_fade(30)}>Dimension</text>')
|
|
122
|
+
out.append(f'<text x="{cx:.0f}" y="{head_y}" text-anchor="middle" font-size="10" font-weight="700" fill="{p["subtext"]}" {lf_fade(60)}>Hedges' g [95% CI]</text>')
|
|
123
|
+
out.append(f'<text x="{w - 24}" y="{head_y}" text-anchor="end" font-size="10" font-weight="700" fill="{p["subtext"]}" {lf_fade(90)}>Effect [95% CI]</text>')
|
|
124
|
+
out.append(f'<line x1="20" y1="{head_y + 8}" x2="{w - 20}" y2="{head_y + 8}" stroke="{p["border"]}" stroke-width="1" {lf_draw(0)}/>')
|
|
125
|
+
# zero line
|
|
126
|
+
out.append(f'<line x1="{cx:.1f}" y1="{head_y + 8}" x2="{cx:.1f}" y2="{h - 56}" stroke="{p["muted"]}" stroke-dasharray="3,3" stroke-width="1" {lf_fade(120)}/>')
|
|
127
|
+
row_y = head_y + 26
|
|
128
|
+
for i, s in enumerate(studies):
|
|
129
|
+
g = float(s["g"])
|
|
130
|
+
ci_l = s.get("ci_lower")
|
|
131
|
+
ci_u = s.get("ci_upper")
|
|
132
|
+
color = p["secondary"] if g > 0.1 else (p["primary"] if g < -0.1 else p["accent"])
|
|
133
|
+
A.log(f"studies[{i}].g", round(g, 4))
|
|
134
|
+
x_pt = cx + g * scale
|
|
135
|
+
out.append(f'<text x="24" y="{row_y + 4}" font-size="10" font-weight="500" fill="{p["text"]}" {lf_fade(i * STAGGER_BAR + 60)}>{esc(str(s["label"])[:26])}</text>')
|
|
136
|
+
if s.get("n") is not None:
|
|
137
|
+
A.log(f"studies[{i}].n", round(float(s["n"]), 4))
|
|
138
|
+
dim = str(s.get("dimension") or "GENERAL").replace("_", " ").title()[:15]
|
|
139
|
+
out.append(f'<text x="200" y="{row_y + 4}" font-size="9" fill="{p["subtext"]}" {lf_fade(i * STAGGER_BAR + 60)}>{esc(dim)}</text>')
|
|
140
|
+
if ci_l is not None and ci_u is not None:
|
|
141
|
+
A.log(f"studies[{i}].ci_lower", round(float(ci_l), 4))
|
|
142
|
+
A.log(f"studies[{i}].ci_upper", round(float(ci_u), 4))
|
|
143
|
+
xl = cx + float(ci_l) * scale
|
|
144
|
+
xu = cx + float(ci_u) * scale
|
|
145
|
+
out.append(f'<line x1="{xl:.1f}" y1="{row_y}" x2="{xu:.1f}" y2="{row_y}" stroke="{color}" stroke-width="1.5" {lf_draw(i * STAGGER_BAR + 120)}/>')
|
|
146
|
+
out.append(f'<line x1="{xl:.1f}" y1="{row_y - 4}" x2="{xl:.1f}" y2="{row_y + 4}" stroke="{color}" stroke-width="1.5" {lf_fade(i * STAGGER_BAR + 140)}/>')
|
|
147
|
+
out.append(f'<line x1="{xu:.1f}" y1="{row_y - 4}" x2="{xu:.1f}" y2="{row_y + 4}" stroke="{color}" stroke-width="1.5" {lf_fade(i * STAGGER_BAR + 140)}/>')
|
|
148
|
+
out.append(f'<rect x="{x_pt - 3.5:.1f}" y="{row_y - 3.5:.1f}" width="7" height="7" fill="{color}" {lf_pop(i * STAGGER_BAR + 150)}><title>{esc(str(s["label"]))} — g = {g:+.2f}{f", N = {s["n"]:.0f}" if s.get("n") else ""}</title></rect>')
|
|
149
|
+
ci_txt = f'{g:+.2f} [{ci_l:+.2f}, {ci_u:+.2f}]' if ci_l is not None and ci_u is not None else f"{g:+.2f}"
|
|
150
|
+
out.append(f'<text x="{w - 24}" y="{row_y + 4}" text-anchor="end" font-size="10" font-weight="800" fill="{color}" {lf_fade(i * STAGGER_BAR + 200)}>{ci_txt}</text>')
|
|
151
|
+
row_y += 27
|
|
152
|
+
# axis
|
|
153
|
+
axis_y = h - 48
|
|
154
|
+
out.append(f'<line x1="{cx - 0.34 * w:.0f}" y1="{axis_y}" x2="{cx + 0.34 * w:.0f}" y2="{axis_y}" stroke="{p["text"]}" stroke-width="1" {lf_draw(200)}/>')
|
|
155
|
+
for tick in (-1.0, -0.5, 0.0, 0.5, 1.0):
|
|
156
|
+
tx = cx + tick * (w * 0.34)
|
|
157
|
+
out.append(f'<line x1="{tx:.1f}" y1="{axis_y}" x2="{tx:.1f}" y2="{axis_y + 5}" stroke="{p["text"]}" stroke-width="1" {lf_fade(220)}/>')
|
|
158
|
+
out.append(f'<text x="{tx:.1f}" y="{axis_y + 16}" text-anchor="middle" font-size="{MIN_FONT + 1.5}" font-weight="600" fill="{p["subtext"]}" {lf_fade(240)}>{tick:+.1f}</text>')
|
|
159
|
+
if pooled:
|
|
160
|
+
g = float(pooled["g"])
|
|
161
|
+
A.log("pooled.g", round(g, 4))
|
|
162
|
+
dx = cx + g * scale
|
|
163
|
+
out.append(f'<path d="M {dx - 7:.1f} {axis_y} L {dx:.1f} {axis_y - 9} L {dx + 7:.1f} {axis_y} L {dx:.1f} {axis_y + 9} Z" fill="{p["text"]}" {lf_pop(400)}><title>Pooled effect: {g:+.2f}</title></path>')
|
|
164
|
+
out.append("</svg>")
|
|
165
|
+
return "\n".join(out)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
169
|
+
# evidence.ranked_effects — L2 Dot Cascade
|
|
170
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
171
|
+
|
|
172
|
+
def render_dot_cascade(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
|
|
173
|
+
p = get_theme(theme)
|
|
174
|
+
A = Audit(audit)
|
|
175
|
+
studies = bundle.get("studies") or []
|
|
176
|
+
w, h = 540, 300
|
|
177
|
+
out = _svg_open(p, w, h, meta.get("title", "dot cascade"))
|
|
178
|
+
n = len(studies)
|
|
179
|
+
x_start, x_end, y_base = 64, 516, 236
|
|
180
|
+
x_gap = (x_end - x_start) / max(1, n)
|
|
181
|
+
gmax = max(abs(s["g"]) for s in studies) or 1.0
|
|
182
|
+
scale = 92 / gmax
|
|
183
|
+
for i, s in enumerate(studies):
|
|
184
|
+
g = float(s["g"])
|
|
185
|
+
A.log(f"studies[{i}].g", round(g, 4))
|
|
186
|
+
x = x_start + (i + 0.5) * x_gap
|
|
187
|
+
hgt = max(10, abs(g) * scale)
|
|
188
|
+
color = p["secondary"] if g >= 0 else p["primary"]
|
|
189
|
+
dots = int(hgt / 11)
|
|
190
|
+
for d in range(dots):
|
|
191
|
+
dy = y_base - (d + 1) * 11 if g >= 0 else y_base + (d + 1) * 11
|
|
192
|
+
out.append(f'<circle cx="{x:.1f}" cy="{dy:.1f}" r="1.8" fill="{color}" opacity="0.55" {lf_fade(d * STAGGER_DOT + i * STAGGER_BAR + 40)}/>')
|
|
193
|
+
head_y = y_base - hgt if g >= 0 else y_base + hgt
|
|
194
|
+
title = f'{esc(str(s["label"]))} — g = {g:+.2f}' + (f', N = {s["n"]:.0f}' if s.get("n") is not None else "")
|
|
195
|
+
if s.get("n") is not None:
|
|
196
|
+
A.log(f"studies[{i}].n", round(float(s["n"]), 4))
|
|
197
|
+
out.append(f'<circle cx="{x:.1f}" cy="{head_y:.1f}" r="4.8" fill="{color}" {lf_pop(i * STAGGER_BAR + 120)}><title>{title}</title></circle>')
|
|
198
|
+
badge_y = head_y - 9 if g >= 0 else head_y + 15
|
|
199
|
+
out.append(f'<text x="{x:.1f}" y="{badge_y:.1f}" fill="{p["text"]}" font-size="{MIN_FONT + 1.5}" font-weight="800" text-anchor="middle" {lf_fade(i * STAGGER_BAR + 180)}>{g:+.2f}</text>')
|
|
200
|
+
label = str(s["label"])[:12]
|
|
201
|
+
out.append(f'<text transform="translate({x:.1f},{y_base + 18}) rotate(45)" fill="{p["subtext"]}" font-size="{MIN_FONT + 1.5}" font-weight="600" {lf_fade(i * STAGGER_BAR + 220)}>{esc(label)}</text>')
|
|
202
|
+
out.append(f'<line x1="44" y1="{y_base}" x2="{x_end}" y2="{y_base}" stroke="{p["text"]}" stroke-width="1.2" {lf_draw(150)}/>')
|
|
203
|
+
out.append("</svg>")
|
|
204
|
+
return "\n".join(out)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
208
|
+
# evidence.year_x_dimension — L9 Bubble Almanac
|
|
209
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
210
|
+
|
|
211
|
+
def render_bubble_almanac(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
|
|
212
|
+
p = get_theme(theme)
|
|
213
|
+
A = Audit(audit)
|
|
214
|
+
years = bundle.get("years") or []
|
|
215
|
+
dims = bundle.get("dimensions") or []
|
|
216
|
+
cells = bundle.get("cells") or []
|
|
217
|
+
w, h = 540, 300
|
|
218
|
+
out = _svg_open(p, w, h, meta.get("title", "bubble almanac"))
|
|
219
|
+
for y in range(70, 262, 7):
|
|
220
|
+
out.append(f'<line x1="44" y1="{y}" x2="520" y2="{y}" stroke="{p["grid"]}" stroke-width="0.5" {lf_fade((y - 70) * 2)}/>')
|
|
221
|
+
x_start, x_gap = 150, (520 - 150) / max(1, len(dims) - 1) if len(dims) > 1 else 0
|
|
222
|
+
if len(dims) == 1:
|
|
223
|
+
x_start = 335
|
|
224
|
+
y_start, y_gap = 92, 44
|
|
225
|
+
for j, d in enumerate(dims):
|
|
226
|
+
x = x_start + j * x_gap
|
|
227
|
+
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>')
|
|
228
|
+
for i, y_str in enumerate(years):
|
|
229
|
+
y = y_start + i * y_gap
|
|
230
|
+
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>')
|
|
231
|
+
for k, c in enumerate(cells):
|
|
232
|
+
j = dims.index(c["dim"]) if c["dim"] in dims else 0
|
|
233
|
+
i = years.index(c["year"]) if c["year"] in years else 0
|
|
234
|
+
n = int(c["n"])
|
|
235
|
+
A.log(f"cells[{k}].n", n)
|
|
236
|
+
if c.get("sig") is not None:
|
|
237
|
+
A.log(f"cells[{k}].sig", int(c["sig"]))
|
|
238
|
+
bx = x_start + j * x_gap
|
|
239
|
+
by = y_start + i * y_gap
|
|
240
|
+
r = max(3.5, math.sqrt(n) * 3.6)
|
|
241
|
+
color = p["secondary"] if int(c.get("sig") or 0) > 0 else p["primary"]
|
|
242
|
+
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)}>'
|
|
243
|
+
f'<title>{esc(str(c["dim"]))} ({esc(str(c["year"]))}) — N = {n} studies, significant = {c.get("sig", 0)}</title></circle>')
|
|
244
|
+
if int(c.get("sig") or 0) > 0:
|
|
245
|
+
out.append(f'<circle cx="{bx:.1f}" cy="{by:.1f}" r="3.2" fill="{color}" {lf_pop(k * STAGGER_DOT + 160)}/>')
|
|
246
|
+
out.append("</svg>")
|
|
247
|
+
return "\n".join(out)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
251
|
+
# evidence.grouped_distribution — G15 Jitter Strip
|
|
252
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
253
|
+
|
|
254
|
+
def render_jitter_strip(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
|
|
255
|
+
p = get_theme(theme)
|
|
256
|
+
A = Audit(audit)
|
|
257
|
+
groups = bundle.get("groups") or []
|
|
258
|
+
w, h = 540, 300
|
|
259
|
+
out = _svg_open(p, w, h, meta.get("title", "jitter strip"))
|
|
260
|
+
vals = [v for g in groups for v in g["values"]]
|
|
261
|
+
lo, hi = min(vals), max(vals)
|
|
262
|
+
pad = max((hi - lo) * 0.08, 0.05)
|
|
263
|
+
lo, hi = lo - pad, hi + pad
|
|
264
|
+
px = lambda v: 148 + (v - lo) / max(hi - lo, 1e-9) * 352
|
|
265
|
+
lane_h = 168 / max(1, len(groups))
|
|
266
|
+
for gi, g in enumerate(groups):
|
|
267
|
+
yc = 86 + gi * lane_h + lane_h / 2
|
|
268
|
+
out.append(f'<line x1="148" y1="{yc:.1f}" x2="500" y2="{yc:.1f}" stroke="{p["border"]}" stroke-width="1" {lf_fade(gi * STAGGER_BAR + 40)}/>')
|
|
269
|
+
out.append(f'<text x="136" y="{yc + 3:.1f}" text-anchor="end" font-size="9" font-weight="600" fill="{p["subtext"]}" {lf_fade(gi * STAGGER_BAR + 60)}>{esc(str(g["label"]).replace("_", " ")[:18])}</text>')
|
|
270
|
+
for i, v in enumerate(g["values"]):
|
|
271
|
+
A.log(f"groups[{gi}].values[{i}]", round(float(v), 4))
|
|
272
|
+
jit = ((i * 37) % 11 - 5) * 0.9
|
|
273
|
+
delay = min(gi * STAGGER_BAR + i * STAGGER_DOT, 1400)
|
|
274
|
+
out.append(f'<circle cx="{px(v):.1f}" cy="{yc + jit:.1f}" r="2.7" fill="{p["primary"] if gi % 2 else p["text"]}" opacity="0.8" {lf_pop(delay)}><title>{esc(str(g["label"]))} — {v:+.2f}</title></circle>')
|
|
275
|
+
for t, tv in ((0, lo), (1, (lo + hi) / 2), (2, hi)):
|
|
276
|
+
tx = px(tv)
|
|
277
|
+
out.append(f'<line x1="{tx:.1f}" y1="260" x2="{tx:.1f}" y2="266" stroke="{p["muted"]}" stroke-width="1" {lf_fade(300)}/>')
|
|
278
|
+
out.append(f'<text x="{tx:.1f}" y="278" text-anchor="middle" font-size="{MIN_FONT + 1.5}" font-weight="600" fill="{p["muted"]}" {lf_fade(320)}>{tv:+.1f}</text>')
|
|
279
|
+
out.append("</svg>")
|
|
280
|
+
return "\n".join(out)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
284
|
+
# evidence.multidim_top — L20 Parallel Coordinates
|
|
285
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
286
|
+
|
|
287
|
+
def render_parallel_coordinates(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
|
|
288
|
+
p = get_theme(theme)
|
|
289
|
+
A = Audit(audit)
|
|
290
|
+
axes = bundle.get("axes") or []
|
|
291
|
+
rows = bundle.get("rows") or []
|
|
292
|
+
w, h = 720, 300
|
|
293
|
+
out = _svg_open(p, w, h, meta.get("title", "parallel coordinates"))
|
|
294
|
+
top, bot = 78, 252
|
|
295
|
+
n_ax = len(axes)
|
|
296
|
+
xs = [96 + i * (720 - 192) / max(1, n_ax - 1) for i in range(n_ax)]
|
|
297
|
+
if n_ax == 1:
|
|
298
|
+
xs = [408]
|
|
299
|
+
norms: dict[str, tuple] = {}
|
|
300
|
+
for ax in axes:
|
|
301
|
+
key = ax["key"]
|
|
302
|
+
vals = [r[key] for r in rows if r.get(key) is not None]
|
|
303
|
+
lo = min(vals) if vals else 0
|
|
304
|
+
hi = max(vals) if vals else 1
|
|
305
|
+
norms[key] = (lo, hi if hi > lo else lo + 1.0)
|
|
306
|
+
for i, ax in enumerate(axes):
|
|
307
|
+
x = xs[i]
|
|
308
|
+
out.append(f'<line x1="{x:.1f}" y1="{top}" x2="{x:.1f}" y2="{bot}" stroke="{p["border"]}" stroke-width="1" {lf_fade(i * STAGGER_BAR + 40)}/>')
|
|
309
|
+
out.append(f'<text x="{x:.1f}" y="{top - 12}" text-anchor="middle" font-size="10" font-weight="700" fill="{p["text"]}" {lf_fade(i * STAGGER_BAR + 60)}>{esc(ax.get("label_" + meta.get("lang", "en"), ax.get("label_en", ax["key"])))}</text>')
|
|
310
|
+
lo, hi = norms[ax["key"]]
|
|
311
|
+
out.append(f'<text x="{x:.1f}" y="{bot + 16}" text-anchor="middle" font-size="{MIN_FONT + 1.5}" font-weight="800" fill="{p["subtext"]}" {lf_fade(i * STAGGER_BAR + 80)}>{_fmt(lo, 1)}</text>')
|
|
312
|
+
out.append(f'<text x="{x:.1f}" y="{top - 18}" text-anchor="middle" font-size="{MIN_FONT + 1.5}" font-weight="800" fill="{p["subtext"]}" {lf_fade(i * STAGGER_BAR + 100)}>{_fmt(hi, 1)}</text>')
|
|
313
|
+
for r_i, r in enumerate(rows):
|
|
314
|
+
pts = []
|
|
315
|
+
for i, ax in enumerate(axes):
|
|
316
|
+
v = r.get(ax["key"])
|
|
317
|
+
if v is None:
|
|
318
|
+
pts.append(None)
|
|
319
|
+
continue
|
|
320
|
+
A.log(f"rows[{r_i}].{ax['key']}", round(float(v), 4))
|
|
321
|
+
lo, hi = norms[ax["key"]]
|
|
322
|
+
y = bot - (float(v) - lo) / (hi - lo) * (bot - top)
|
|
323
|
+
pts.append((xs[i], y))
|
|
324
|
+
d_parts = []
|
|
325
|
+
for pt in pts:
|
|
326
|
+
if pt is None:
|
|
327
|
+
continue
|
|
328
|
+
d_parts.append(f"{pt[0]:.1f} {pt[1]:.1f}")
|
|
329
|
+
if len(d_parts) < 2:
|
|
330
|
+
continue
|
|
331
|
+
title = f'{esc(str(r["label"]))} — ' + ", ".join(f'{ax["key"]}={r.get(ax["key"])}' for ax in axes)
|
|
332
|
+
out.append(f'<polyline points="{" ".join(d_parts)}" fill="none" stroke="{p["primary"]}" stroke-width="1" opacity="0.45" {lf_draw(r_i * STAGGER_BAR + 120)}><title>{title}</title></polyline>')
|
|
333
|
+
out.append(f'<circle cx="{d_parts[0].split()[0]}" cy="{d_parts[0].split()[1]}" r="2.4" fill="{p["primary"]}" {lf_pop(r_i * STAGGER_BAR + 140)}/>')
|
|
334
|
+
out.append("</svg>")
|
|
335
|
+
return "\n".join(out)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
339
|
+
# evidence.study_type_composition / wwc — L14 Hundred Field
|
|
340
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
341
|
+
|
|
342
|
+
def render_hundred_field(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
|
|
343
|
+
p = get_theme(theme)
|
|
344
|
+
A = Audit(audit)
|
|
345
|
+
cats = bundle.get("categories") or []
|
|
346
|
+
w, h = 540, 300
|
|
347
|
+
out = _svg_open(p, w, h, meta.get("title", "hundred field"))
|
|
348
|
+
colors = [p["primary"], p["secondary"], p["accent"], p["muted"]]
|
|
349
|
+
grid_x, grid_y, cell, gap = 40, 74, 15.5, 2.5
|
|
350
|
+
idx = 0
|
|
351
|
+
for c_i, c in enumerate(cats):
|
|
352
|
+
A.log(f"categories[{c_i}].count", int(c["count"]))
|
|
353
|
+
for _ in range(int(c["count"])):
|
|
354
|
+
r, col = divmod(idx, 10)
|
|
355
|
+
if r >= 10:
|
|
356
|
+
break
|
|
357
|
+
x = grid_x + col * (cell + gap)
|
|
358
|
+
y = grid_y + r * (cell + gap)
|
|
359
|
+
out.append(f'<rect x="{x:.1f}" y="{y:.1f}" width="{cell}" height="{cell}" rx="3" fill="{colors[c_i % len(colors)]}" {lf_pop(min(idx * STAGGER_DOT, 1100))}><title>{esc(str(c["label"]))} — 1 study</title></rect>')
|
|
360
|
+
idx += 1
|
|
361
|
+
out.append(f'<text x="30" y="270" font-size="8" font-weight="600" fill="{p["muted"]}" {lf_fade(400)}>{esc(meta.get("lang", "en") == "zh" and "每格 = 1 篇研究" or "one cell = one study")}</text>')
|
|
362
|
+
out.append("</svg>")
|
|
363
|
+
return "\n".join(out)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
367
|
+
# evidence.study_type_composition / wwc — F4 Tick Donut
|
|
368
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
369
|
+
|
|
370
|
+
def render_tick_donut(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
|
|
371
|
+
p = get_theme(theme)
|
|
372
|
+
A = Audit(audit)
|
|
373
|
+
cats = bundle.get("categories") or []
|
|
374
|
+
total = int(bundle.get("total") or 0) or sum(int(c["count"]) for c in cats)
|
|
375
|
+
w, h = 540, 300
|
|
376
|
+
out = _svg_open(p, w, h, meta.get("title", "tick donut"))
|
|
377
|
+
cx, cy, r0, r1 = 200, 158, 88, 104
|
|
378
|
+
colors = [p["primary"], p["secondary"], p["accent"], p["muted"]]
|
|
379
|
+
# largest-remainder share of 100 ticks
|
|
380
|
+
ticks_per = []
|
|
381
|
+
assigned = 0
|
|
382
|
+
for c_i, c in enumerate(cats):
|
|
383
|
+
A.log(f"categories[{c_i}].count", int(c["count"]))
|
|
384
|
+
share = int(c["count"]) / max(total, 1) * 100
|
|
385
|
+
n = int(round(share))
|
|
386
|
+
ticks_per.append((c_i, n))
|
|
387
|
+
assigned += n
|
|
388
|
+
while assigned > 100: # trim the largest over-assignment
|
|
389
|
+
c_i, n = max(ticks_per, key=lambda t: t[1])
|
|
390
|
+
ticks_per[ticks_per.index((c_i, n))] = (c_i, n - 1)
|
|
391
|
+
assigned -= 1
|
|
392
|
+
while assigned < 100: # give the remainder to the largest category
|
|
393
|
+
c_i, _ = max(ticks_per, key=lambda t: t[1])
|
|
394
|
+
ticks_per[ticks_per.index((c_i, _))] = (c_i, _ + 1)
|
|
395
|
+
assigned += 1
|
|
396
|
+
tick_i = 0
|
|
397
|
+
for c_i, n in ticks_per:
|
|
398
|
+
for _ in range(n):
|
|
399
|
+
ang = math.radians(-90 + tick_i * 3.6)
|
|
400
|
+
x1, y1 = cx + r0 * math.cos(ang), cy + r0 * math.sin(ang)
|
|
401
|
+
x2, y2 = cx + r1 * math.cos(ang), cy + r1 * math.sin(ang)
|
|
402
|
+
out.append(f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" stroke="{colors[c_i % len(colors)]}" stroke-width="2.4" stroke-linecap="round" {lf_pop(min(tick_i * STAGGER_DOT, 900))}><title>{esc(str(cats[c_i]["label"]))}</title></line>')
|
|
403
|
+
tick_i += 1
|
|
404
|
+
out.append(f'<text x="{cx}" y="{cy - 8}" text-anchor="middle" font-size="22" font-weight="800" fill="{p["text"]}" {lf_pop(500)}>{total}</text>')
|
|
405
|
+
out.append(f'<text x="{cx}" y="{cy + 14}" text-anchor="middle" font-size="9" font-weight="600" fill="{p["subtext"]}" {lf_fade(560)}>{esc(meta.get("lang", "en") == "zh" and "篇研究" or "studies")}</text>')
|
|
406
|
+
out.append(f'<text x="30" y="272" font-size="8" font-weight="600" fill="{p["muted"]}" {lf_fade(600)}>{esc(meta.get("lang", "en") == "zh" and "每 1 tick ≈ 1% 构成" or "one tick ≈ 1% share")}</text>')
|
|
407
|
+
out.append("</svg>")
|
|
408
|
+
return "\n".join(out)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
412
|
+
# outcomes.direction_counts — F5 Tick Rows
|
|
413
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
414
|
+
|
|
415
|
+
def _dir_colors(p: Dict[str, str]) -> Dict[str, str]:
|
|
416
|
+
return {"positive": p["secondary"], "negative": p["primary"], "null": p["muted"]}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def render_tick_rows(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
|
|
420
|
+
p = get_theme(theme)
|
|
421
|
+
A = Audit(audit)
|
|
422
|
+
rows = sorted(bundle.get("rows") or [], key=lambda r: (r["positive"] - r["negative"]), reverse=True)
|
|
423
|
+
w, h = 540, 300
|
|
424
|
+
out = _svg_open(p, w, h, meta.get("title", "tick rows"))
|
|
425
|
+
colors = _dir_colors(p)
|
|
426
|
+
lane_y = 88
|
|
427
|
+
lane_h = 176 / max(1, len(rows))
|
|
428
|
+
for r_i, r in enumerate(rows):
|
|
429
|
+
yc = lane_y + r_i * lane_h + lane_h / 2
|
|
430
|
+
label = r.get("label_" + meta.get("lang", "en"), r.get("label_en", r["label"]))
|
|
431
|
+
out.append(f'<text x="128" y="{yc + 3:.1f}" text-anchor="end" font-size="9" font-weight="600" fill="{p["subtext"]}" {lf_fade(r_i * STAGGER_BAR + 40)}>{esc(str(label)[:12])}</text>')
|
|
432
|
+
tick = 0
|
|
433
|
+
for key, color in (("positive", colors["positive"]), ("null", colors["null"]), ("negative", colors["negative"])):
|
|
434
|
+
count = int(r.get(key) or 0)
|
|
435
|
+
A.log(f"rows[{r_i}].{key}", count)
|
|
436
|
+
drawn = 0
|
|
437
|
+
for _ in range(count):
|
|
438
|
+
x = 140 + tick * 8
|
|
439
|
+
if x > 496:
|
|
440
|
+
break
|
|
441
|
+
out.append(f'<circle cx="{x:.1f}" cy="{yc:.1f}" r="2.3" fill="{color}" {lf_pop(min(r_i * STAGGER_BAR + tick * STAGGER_DOT, 1500))}><title>{esc(str(label))} — {key} evidence</title></circle>')
|
|
442
|
+
tick += 1
|
|
443
|
+
drawn += 1
|
|
444
|
+
if drawn < count:
|
|
445
|
+
out.append(f'<text x="{min(140 + tick * 8 + 8, 508)}" y="{yc + 3:.1f}" font-size="{MIN_FONT + 1.5}" font-weight="800" fill="{p["subtext"]}" {lf_fade(r_i * STAGGER_BAR + 400)}>+{count - drawn}</text>')
|
|
446
|
+
out.append(f'<text x="512" y="{yc + 3:.1f}" text-anchor="end" font-size="{MIN_FONT + 1.5}" font-weight="800" fill="{p["text"]}" {lf_fade(r_i * STAGGER_BAR + 500)}>{r["positive"] - r["negative"]:+d}</text>')
|
|
447
|
+
out.append("</svg>")
|
|
448
|
+
return "\n".join(out)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
452
|
+
# outcomes.direction_counts — F1 Rung Bars
|
|
453
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
454
|
+
|
|
455
|
+
def render_rung_bars(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
|
|
456
|
+
p = get_theme(theme)
|
|
457
|
+
A = Audit(audit)
|
|
458
|
+
rows = bundle.get("rows") or []
|
|
459
|
+
w, h = 540, 300
|
|
460
|
+
out = _svg_open(p, w, h, meta.get("title", "rung bars"))
|
|
461
|
+
colors = _dir_colors(p)
|
|
462
|
+
x_start, x_end, y_base = 60, 516, 240
|
|
463
|
+
bar_w = min(52, (x_end - x_start) / max(1, len(rows)) * 0.62)
|
|
464
|
+
total_max = max((r["positive"] + r["negative"] + r["null"]) for r in rows) or 1
|
|
465
|
+
rung_h = 5.5
|
|
466
|
+
for r_i, r in enumerate(rows):
|
|
467
|
+
cx = x_start + (r_i + 0.5) * (x_end - x_start) / len(rows)
|
|
468
|
+
label = r.get("label_" + meta.get("lang", "en"), r.get("label_en", r["label"]))
|
|
469
|
+
y = y_base
|
|
470
|
+
for key in ("positive", "null", "negative"):
|
|
471
|
+
count = int(r.get(key) or 0)
|
|
472
|
+
A.log(f"rows[{r_i}].{key}", count)
|
|
473
|
+
shown = 0
|
|
474
|
+
for _ in range(count):
|
|
475
|
+
if y_base - y > 118:
|
|
476
|
+
break
|
|
477
|
+
y -= rung_h + 2.2
|
|
478
|
+
out.append(f'<rect x="{cx - bar_w / 2:.1f}" y="{y:.1f}" width="{bar_w:.1f}" height="{rung_h}" rx="2.6" fill="{colors[key]}" {lf_fade(r_i * STAGGER_BAR + shown * STAGGER_DOT + 60)}><title>{esc(str(label))} — {key} evidence</title></rect>')
|
|
479
|
+
shown += 1
|
|
480
|
+
if shown < count:
|
|
481
|
+
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>')
|
|
482
|
+
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>')
|
|
483
|
+
out.append(f'<text x="{cx:.1f}" y="{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"] + r["null"]}</text>')
|
|
484
|
+
out.append(f'<line x1="{x_start}" y1="{y_base}" x2="{x_end}" y2="{y_base}" stroke="{p["text"]}" stroke-width="1.2" {lf_draw(120)}/>')
|
|
485
|
+
out.append("</svg>")
|
|
486
|
+
return "\n".join(out)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
490
|
+
# outcomes.paired_counts — F6 Paired Rungs
|
|
491
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
492
|
+
|
|
493
|
+
def render_paired_rungs(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
|
|
494
|
+
p = get_theme(theme)
|
|
495
|
+
A = Audit(audit)
|
|
496
|
+
rows = bundle.get("rows") or []
|
|
497
|
+
w, h = 540, 300
|
|
498
|
+
out = _svg_open(p, w, h, meta.get("title", "paired rungs"))
|
|
499
|
+
colors = _dir_colors(p)
|
|
500
|
+
group_w = 480 / max(1, len(rows))
|
|
501
|
+
col_w = 13
|
|
502
|
+
y_base = 238
|
|
503
|
+
lang = meta.get("lang", "en")
|
|
504
|
+
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>')
|
|
505
|
+
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>')
|
|
506
|
+
for r_i, r in enumerate(rows):
|
|
507
|
+
cx = 60 + (r_i + 0.5) * group_w
|
|
508
|
+
label = r.get("label_" + lang, r.get("label_en", r["label"]))
|
|
509
|
+
for col, key in ((0, "positive"), (1, "negative")):
|
|
510
|
+
count = int(r.get(key) or 0)
|
|
511
|
+
A.log(f"rows[{r_i}].{key}", count)
|
|
512
|
+
shown = 0
|
|
513
|
+
y = y_base
|
|
514
|
+
for _ in range(count):
|
|
515
|
+
if y_base - y > 130:
|
|
516
|
+
break
|
|
517
|
+
y -= 5.5 + 2.2
|
|
518
|
+
x = cx - col_w - 4 + col * (col_w + 8)
|
|
519
|
+
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>')
|
|
520
|
+
shown += 1
|
|
521
|
+
if shown < count:
|
|
522
|
+
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>')
|
|
523
|
+
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>')
|
|
524
|
+
out.append(f'<text x="{cx:.1f}" y="{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>')
|
|
525
|
+
out.append(f'<line x1="46" y1="{y_base}" x2="512" y2="{y_base}" stroke="{p["text"]}" stroke-width="1.2" {lf_draw(120)}/>')
|
|
526
|
+
out.append("</svg>")
|
|
527
|
+
return "\n".join(out)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
531
|
+
# outcomes.bipolar_axes — L7 Brand Spectrum
|
|
532
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
533
|
+
|
|
534
|
+
def render_brand_spectrum(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
|
|
535
|
+
p = get_theme(theme)
|
|
536
|
+
A = Audit(audit)
|
|
537
|
+
axes = bundle.get("axes") or []
|
|
538
|
+
w, h = 540, 300
|
|
539
|
+
out = _svg_open(p, w, h, meta.get("title", "brand spectrum"))
|
|
540
|
+
lang = meta.get("lang", "en")
|
|
541
|
+
left_t = bundle.get("left_zh" if lang == "zh" else "left_en", "negative")
|
|
542
|
+
right_t = bundle.get("right_zh" if lang == "zh" else "right_en", "positive")
|
|
543
|
+
x0, x1 = 150, 400
|
|
544
|
+
y0, gap = 88, 46
|
|
545
|
+
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>')
|
|
546
|
+
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>')
|
|
547
|
+
px = lambda t: x0 + t * (x1 - x0)
|
|
548
|
+
ribbon = [f"{px(axes[0]['position']):.1f} {y0}"]
|
|
549
|
+
for i in range(1, len(axes)):
|
|
550
|
+
ribbon.append(f"C {px(axes[i-1]['position']):.1f} {y0 + (i-1)*gap + gap/2:.1f}, {px(axes[i]['position']):.1f} {y0 + i*gap - gap/2:.1f}, {px(axes[i]['position']):.1f} {y0 + i*gap}")
|
|
551
|
+
ribbon_d = f"M {ribbon[0]} " + " ".join(ribbon[1:])
|
|
552
|
+
out.append(f'<path d="{ribbon_d}" fill="none" stroke="{p["ribbon"]}" stroke-width="26" stroke-linecap="round" stroke-linejoin="round" opacity="0.95" {lf_draw(60)}/>')
|
|
553
|
+
for i, ax in enumerate(axes):
|
|
554
|
+
y = y0 + i * gap
|
|
555
|
+
pos = float(ax["position"])
|
|
556
|
+
A.log(f"axes[{i}].position", round(pos, 4))
|
|
557
|
+
A.log(f"axes[{i}].positive", int(ax["positive"]))
|
|
558
|
+
A.log(f"axes[{i}].negative", int(ax["negative"]))
|
|
559
|
+
A.log(f"axes[{i}].null", int(ax["null"]))
|
|
560
|
+
out.append(f'<line x1="{x0}" y1="{y}" x2="{x1}" y2="{y}" stroke="{p["border"]}" stroke-width="1" {lf_fade(i * STAGGER_BAR + 60)}/>')
|
|
561
|
+
out.append(f'<line x1="{x0}" y1="{y - 4}" x2="{x0}" y2="{y + 4}" stroke="{p["border"]}" stroke-width="1" {lf_fade(i * STAGGER_BAR + 60)}/>')
|
|
562
|
+
out.append(f'<line x1="{x1}" y1="{y - 4}" x2="{x1}" y2="{y + 4}" stroke="{p["border"]}" stroke-width="1" {lf_fade(i * STAGGER_BAR + 60)}/>')
|
|
563
|
+
label = ax.get("label_" + lang, ax.get("label_en", ax["label"]))
|
|
564
|
+
out.append(f'<text x="{x0 - 16}" y="{y + 3}" text-anchor="end" font-size="8.5" font-weight="600" fill="{p["subtext"]}" {lf_fade(i * STAGGER_BAR + 90)}>{esc(str(label)[:10])}</text>')
|
|
565
|
+
ux = px(pos)
|
|
566
|
+
net = float(ax["net"])
|
|
567
|
+
A.log(f"axes[{i}].net", round(net, 4))
|
|
568
|
+
net_txt = f"{net * 100:+.0f}%"
|
|
569
|
+
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>')
|
|
570
|
+
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>')
|
|
571
|
+
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>')
|
|
572
|
+
out.append("</svg>")
|
|
573
|
+
return "\n".join(out)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
577
|
+
# intervention.phase_weeks — L3 Barcode Lollipop
|
|
578
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
579
|
+
|
|
580
|
+
def render_barcode_lollipop(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
|
|
581
|
+
p = get_theme(theme)
|
|
582
|
+
A = Audit(audit)
|
|
583
|
+
weeks = bundle.get("weeks") or []
|
|
584
|
+
peaks = bundle.get("peaks") or []
|
|
585
|
+
phase_labels = bundle.get("phases") or []
|
|
586
|
+
w, h = 540, 290
|
|
587
|
+
out = _svg_open(p, w, h, meta.get("title", "barcode lollipop"))
|
|
588
|
+
n = len(weeks)
|
|
589
|
+
x_start, x_end = 46, 498
|
|
590
|
+
dx = (x_end - x_start) / max(1, n)
|
|
591
|
+
y_base = 226
|
|
592
|
+
peak_weeks = {pk["week"] for pk in peaks}
|
|
593
|
+
lang = meta.get("lang", "en")
|
|
594
|
+
for i, wk in enumerate(weeks):
|
|
595
|
+
x = x_start + (i + 0.5) * dx
|
|
596
|
+
phase = int(wk["phase"])
|
|
597
|
+
A.log(f"weeks[{i}].week", int(wk["week"]))
|
|
598
|
+
A.log(f"weeks[{i}].phase", phase)
|
|
599
|
+
stem_h = 10 + phase * 16
|
|
600
|
+
y = y_base - stem_h
|
|
601
|
+
out.append(f'<line x1="{x:.1f}" y1="{y_base}" x2="{x:.1f}" y2="{y:.1f}" stroke="{p["grid"]}" stroke-width="0.7" {lf_fade(60)}/>')
|
|
602
|
+
is_peak = wk["week"] in peak_weeks
|
|
603
|
+
out.append(f'<line x1="{x:.1f}" y1="{y:.1f}" x2="{x:.1f}" y2="{min(y_base, y + 12 + ((i * 7) % 12)):.1f}" stroke="{p["text"]}" stroke-width="{1.4 if is_peak else 0.9}" {lf_fade(min(i * STAGGER_BAR, 1200) + 80)}/>')
|
|
604
|
+
out.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{4.8 if is_peak else 2.5:.1f}" fill="{p["primary"] if is_peak else p["text"]}" {lf_pop(min(i * STAGGER_DOT, 700) + 160)}><title>{esc(lang == "zh" and f"第 {wk['week']} 周 — 阶段 {phase}" or f"week {wk['week']} — phase {phase}")}</title></circle>')
|
|
605
|
+
if is_peak:
|
|
606
|
+
pk = next((q for q in peaks if q["week"] == wk["week"]), None)
|
|
607
|
+
pk_txt = (pk or {}).get("label_zh" if lang == "zh" else "label_en", "")
|
|
608
|
+
out.append(f'<text x="{x:.1f}" y="{y - 9:.1f}" font-size="{MIN_FONT + 1}" font-weight="800" fill="{p["text"]}" text-anchor="middle" {lf_fade(i * STAGGER_BAR + 420)}>{esc(pk_txt)[:12]}</text>')
|
|
609
|
+
for pl in phase_labels:
|
|
610
|
+
i = pl["start"] - bundle.get("first", weeks[0]["week"]) if weeks else 0
|
|
611
|
+
x = x_start + max(0, min(i, n - 1) + 0.5) * dx
|
|
612
|
+
out.append(f'<text x="{x:.1f}" y="248" font-size="8" font-weight="600" fill="{p["muted"]}" {lf_fade(500)}>{esc((pl.get("label_zh" if lang == "zh" else "label_en") or "")[:12])}</text>')
|
|
613
|
+
out.append(f'<line x1="{x_start}" y1="{y_base}" x2="{x_end}" y2="{y_base}" stroke="{p["text"]}" stroke-width="1" {lf_draw(90)}/>')
|
|
614
|
+
out.append(f'<text x="30" y="274" font-size="7.5" font-weight="600" fill="{p["muted"]}" {lf_fade(560)}>{esc(str(bundle.get("derived", ""))[:70])}</text>')
|
|
615
|
+
out.append("</svg>")
|
|
616
|
+
return "\n".join(out)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
620
|
+
# intervention.activity_weights — L1 Launch Fan
|
|
621
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
622
|
+
|
|
623
|
+
def render_launch_fan(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
|
|
624
|
+
p = get_theme(theme)
|
|
625
|
+
A = Audit(audit)
|
|
626
|
+
items = bundle.get("items") or []
|
|
627
|
+
w, h = 540, 290
|
|
628
|
+
out = _svg_open(p, w, h, meta.get("title", "launch fan"))
|
|
629
|
+
cx, cy, radius = 108, 232, 168
|
|
630
|
+
start_angle, end_angle = -78, 12
|
|
631
|
+
n = len(items)
|
|
632
|
+
d_angle = (end_angle - start_angle) / max(1, n - 1)
|
|
633
|
+
for i, it in enumerate(items):
|
|
634
|
+
wgt = int(it["w"])
|
|
635
|
+
A.log(f"items[{i}].w", wgt)
|
|
636
|
+
ang = math.radians(start_angle + i * d_angle)
|
|
637
|
+
ex, ey = cx + radius * math.cos(ang), cy + radius * math.sin(ang)
|
|
638
|
+
out.append(f'<line x1="{cx}" y1="{cy}" x2="{ex:.1f}" y2="{ey:.1f}" stroke="{p["border"]}" stroke-width="1" stroke-dasharray="3,2" {lf_fade(i * STAGGER_BAR + 60)}/>')
|
|
639
|
+
for frac in (0.4, 0.65, 0.85):
|
|
640
|
+
mx, my = cx + radius * frac * math.cos(ang), cy + radius * frac * math.sin(ang)
|
|
641
|
+
out.append(f'<circle cx="{mx:.1f}" cy="{my:.1f}" r="2" fill="{p["muted"]}" opacity="0.55" {lf_fade(i * STAGGER_BAR + 90)}/>')
|
|
642
|
+
r_head = 4.5 + math.sqrt(wgt) * 2.1
|
|
643
|
+
out.append(f'<circle cx="{ex:.1f}" cy="{ey:.1f}" r="{r_head:.1f}" fill="{p["secondary"]}" stroke="{p["card_bg"]}" stroke-width="1.5" {lf_pop(i * STAGGER_BAR + 180)}><title>{esc(str(it["label"]))} — {wgt} activities</title></circle>')
|
|
644
|
+
lx, ly = cx + (radius + 22) * math.cos(ang), cy + (radius + 22) * math.sin(ang)
|
|
645
|
+
out.append(f'<text x="{lx:.1f}" y="{ly + 3:.1f}" fill="{p["text"]}" font-size="8.5" font-weight="600" {lf_fade(i * STAGGER_BAR + 240)}>{esc(str(it["label"])[:16])}</text>')
|
|
646
|
+
out.append("</svg>")
|
|
647
|
+
return "\n".join(out)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
651
|
+
# intervention.phase_groups — L8 Dotty Matrix
|
|
652
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
653
|
+
|
|
654
|
+
def render_dotty_matrix(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
|
|
655
|
+
p = get_theme(theme)
|
|
656
|
+
A = Audit(audit)
|
|
657
|
+
layers = bundle.get("layers") or []
|
|
658
|
+
w, h = 540, 310
|
|
659
|
+
out = _svg_open(p, w, h, meta.get("title", "dotty matrix"))
|
|
660
|
+
shades = [p["text"], p["secondary"], p["accent"], p["muted"]]
|
|
661
|
+
|
|
662
|
+
def P(c, r, k):
|
|
663
|
+
return 236 + (c - r) * 17, 244 + (c + r) * 8.4 - k * 52
|
|
664
|
+
for k in range(len(layers)):
|
|
665
|
+
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)]
|
|
666
|
+
poly_d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in cs) + " Z"
|
|
667
|
+
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)}/>')
|
|
668
|
+
layer = layers[k]
|
|
669
|
+
for ci, cell in enumerate(layer["cells"]):
|
|
670
|
+
A.log(f"layers[{k}].cells[{ci}].r", int(cell["r"]))
|
|
671
|
+
A.log(f"layers[{k}].cells[{ci}].c", int(cell["c"]))
|
|
672
|
+
x, y = P(cell["c"], cell["r"], k)
|
|
673
|
+
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>')
|
|
674
|
+
cxr, cyr = P(5.6, -0.6, k)
|
|
675
|
+
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)}/>')
|
|
676
|
+
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>')
|
|
677
|
+
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>')
|
|
678
|
+
out.append("</svg>")
|
|
679
|
+
return "\n".join(out)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
683
|
+
# decision.confidence_score — F11 Tick Gauge
|
|
684
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
685
|
+
|
|
686
|
+
def render_tick_gauge(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
|
|
687
|
+
p = get_theme(theme)
|
|
688
|
+
A = Audit(audit)
|
|
689
|
+
score = float(bundle["score"])
|
|
690
|
+
A.log("score", round(score, 4))
|
|
691
|
+
w, h = 540, 290
|
|
692
|
+
out = _svg_open(p, w, h, meta.get("title", "tick gauge"))
|
|
693
|
+
cx, cy, r0, r1 = 240, 158, 92, 108
|
|
694
|
+
filled = int(round(score * 100))
|
|
695
|
+
for i in range(100):
|
|
696
|
+
ang = math.radians(-90 + i * 2.7)
|
|
697
|
+
x1, y1 = cx + r0 * math.cos(ang), cy + r0 * math.sin(ang)
|
|
698
|
+
x2, y2 = cx + r1 * math.cos(ang), cy + r1 * math.sin(ang)
|
|
699
|
+
is_filled = i < filled
|
|
700
|
+
out.append(f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" stroke="{p["primary"] if is_filled else p["grid"]}" stroke-width="3" stroke-linecap="round" {lf_pop(min(i * STAGGER_DOT, 800)) if is_filled else lf_fade(i * 2 + 60)}><title>{i}%</title></line>')
|
|
701
|
+
out.append(f'<text x="{cx}" y="{cy - 6}" text-anchor="middle" font-size="30" font-weight="800" fill="{p["text"]}" {lf_pop(600)}>{filled}%</text>')
|
|
702
|
+
label = str(bundle.get("label") or "")
|
|
703
|
+
out.append(f'<text x="{cx}" y="{cy + 22}" text-anchor="middle" font-size="10" font-weight="600" fill="{p["subtext"]}" {lf_fade(680)}>{esc(label[:24])}</text>')
|
|
704
|
+
out.append("</svg>")
|
|
705
|
+
return "\n".join(out)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
709
|
+
# methodology.flag_rates — L15 Ballot Tally
|
|
710
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
711
|
+
|
|
712
|
+
def render_ballot_tally(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
|
|
713
|
+
p = get_theme(theme)
|
|
714
|
+
A = Audit(audit)
|
|
715
|
+
items = bundle.get("items") or []
|
|
716
|
+
w, h = 540, 300
|
|
717
|
+
out = _svg_open(p, w, h, meta.get("title", "ballot tally"))
|
|
718
|
+
lane_y = 92
|
|
719
|
+
lane_h = 176 / max(1, len(items))
|
|
720
|
+
lang = meta.get("lang", "en")
|
|
721
|
+
for r_i, it in enumerate(items):
|
|
722
|
+
total = int(it["total"])
|
|
723
|
+
flagged = int(it["flagged"])
|
|
724
|
+
A.log(f"items[{r_i}].total", total)
|
|
725
|
+
A.log(f"items[{r_i}].flagged", flagged)
|
|
726
|
+
yc = lane_y + r_i * lane_h + lane_h / 2
|
|
727
|
+
label = it.get("label_" + lang, it.get("label_en", it["label"]))
|
|
728
|
+
out.append(f'<text x="150" y="{yc + 3:.1f}" text-anchor="end" font-size="8.5" font-weight="600" fill="{p["subtext"]}" {lf_fade(r_i * STAGGER_BAR + 40)}>{esc(str(label)[:12])}</text>')
|
|
729
|
+
for t in range(total):
|
|
730
|
+
x = 162 + t * 11
|
|
731
|
+
if x > 470:
|
|
732
|
+
break
|
|
733
|
+
out.append(f'<rect x="{x:.1f}" y="{yc - 4:.1f}" width="8" height="8" rx="2" fill="{p["primary"] if t < flagged else p["grid"]}" {lf_pop(min(r_i * STAGGER_BAR + t * STAGGER_DOT, 1400))}><title>{esc(str(label))} — verdict {t + 1}/{total}{" (flagged)" if t < flagged else ""}</title></rect>')
|
|
734
|
+
out.append(f'<text x="512" y="{yc + 3:.1f}" text-anchor="end" font-size="9" font-weight="800" fill="{p["text"]}" {lf_fade(r_i * STAGGER_BAR + 300)}>{flagged}/{total}</text>')
|
|
735
|
+
out.append(f'<text x="30" y="280" font-size="7.5" font-weight="600" fill="{p["muted"]}" {lf_fade(500)}>{esc(str(bundle.get("derived", ""))[:70])}</text>')
|
|
736
|
+
out.append("</svg>")
|
|
737
|
+
return "\n".join(out)
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
741
|
+
# evidence.year_x_outcome_counts — L16 Matrix Heat
|
|
742
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
743
|
+
|
|
744
|
+
def render_matrix_heat(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
|
|
745
|
+
p = get_theme(theme)
|
|
746
|
+
A = Audit(audit)
|
|
747
|
+
years = bundle.get("years") or []
|
|
748
|
+
outcomes = bundle.get("outcomes") or []
|
|
749
|
+
cells = bundle.get("cells") or []
|
|
750
|
+
w = 540
|
|
751
|
+
h = 96 + len(outcomes) * 40 + 24
|
|
752
|
+
out = _svg_open(p, w, h, meta.get("title", "matrix heat"))
|
|
753
|
+
lang = meta.get("lang", "en")
|
|
754
|
+
cell_w = 460 / max(1, len(years))
|
|
755
|
+
cell_h = 32
|
|
756
|
+
x0, y0 = 150, 78
|
|
757
|
+
maxv = max((v for row in cells for v in row), default=1) or 1
|
|
758
|
+
for j, y in enumerate(years):
|
|
759
|
+
x = x0 + (j + 0.5) * cell_w
|
|
760
|
+
out.append(f'<text x="{x:.1f}" y="{y0 - 12}" text-anchor="middle" font-size="9" font-weight="800" fill="{p["subtext"]}" {lf_fade(j * STAGGER_BAR + 40)}>{esc(str(y))}</text>')
|
|
761
|
+
for i, o in enumerate(outcomes):
|
|
762
|
+
label = o.get("label_" + lang, o.get("label_en", o["label"]))
|
|
763
|
+
y = y0 + i * cell_h
|
|
764
|
+
out.append(f'<text x="{x0 - 12}" y="{y + cell_h / 2 + 3:.1f}" text-anchor="end" font-size="8" font-weight="600" fill="{p["subtext"]}" {lf_fade(i * STAGGER_BAR + 60)}>{esc(str(label).replace("_", " ")[:14])}</text>')
|
|
765
|
+
for j in range(len(years)):
|
|
766
|
+
v = int(cells[i][j]) if i < len(cells) and j < len(cells[i]) else 0
|
|
767
|
+
A.log(f"cells[{i}][{j}]", v)
|
|
768
|
+
x = x0 + j * cell_w
|
|
769
|
+
if v > 0:
|
|
770
|
+
opacity = 0.18 + 0.72 * (v / maxv)
|
|
771
|
+
out.append(f'<rect x="{x + 2:.1f}" y="{y + 2:.1f}" width="{cell_w - 4:.1f}" height="{cell_h - 4}" rx="4" fill="{p["primary"]}" fill-opacity="{opacity:.2f}" {lf_pop(min((i + j) * STAGGER_DOT, 900))}/>')
|
|
772
|
+
else:
|
|
773
|
+
out.append(f'<rect x="{x + 2:.1f}" y="{y + 2:.1f}" width="{cell_w - 4:.1f}" height="{cell_h - 4}" rx="4" fill="{p["grid"]}" fill-opacity="0.5" {lf_fade((i + j) * STAGGER_DOT)}/>')
|
|
774
|
+
out.append(f'<text x="{x + cell_w / 2:.1f}" y="{y + cell_h / 2 + 3:.1f}" text-anchor="middle" font-size="{MIN_FONT + 1.5}" font-weight="800" fill="{p["text"]}" {lf_pop(min((i + j) * STAGGER_DOT + 60, 900))}>{v}</text>')
|
|
775
|
+
out.append(f'<title>{esc(str(label))} × {esc(str(years[j]))} — {v} studies</title>')
|
|
776
|
+
out.append("</svg>")
|
|
777
|
+
return "\n".join(out)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
781
|
+
# Registry + dispatcher
|
|
782
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
783
|
+
|
|
784
|
+
REGISTRY: Dict[str, Dict[str, Any]] = {
|
|
785
|
+
"forest_plot": {"catalog_ref": "FOREST-PLOT (publication figure)", "source": "meta.forest",
|
|
786
|
+
"extractor": CD.extract_meta_forest, "renderer": render_forest_plot,
|
|
787
|
+
"params": {"max_studies": int}},
|
|
788
|
+
"dot_cascade": {"catalog_ref": "L2 Dot Cascade", "source": "evidence.ranked_effects",
|
|
789
|
+
"extractor": CD.extract_ranked_effects, "renderer": render_dot_cascade,
|
|
790
|
+
"params": {"limit": int}},
|
|
791
|
+
"bubble_almanac": {"catalog_ref": "L9 Bubble Almanac", "source": "evidence.year_x_dimension",
|
|
792
|
+
"extractor": CD.extract_year_x_dimension, "renderer": render_bubble_almanac,
|
|
793
|
+
"params": {}},
|
|
794
|
+
"jitter_strip": {"catalog_ref": "G15 Jitter Strip", "source": "evidence.grouped_distribution",
|
|
795
|
+
"extractor": CD.extract_grouped_distribution, "renderer": render_jitter_strip,
|
|
796
|
+
"params": {"limit": int}},
|
|
797
|
+
"parallel_coordinates": {"catalog_ref": "L20 Parallel Coordinates", "source": "evidence.multidim_top",
|
|
798
|
+
"extractor": CD.extract_multidim_top, "renderer": render_parallel_coordinates,
|
|
799
|
+
"params": {"limit": int}},
|
|
800
|
+
"hundred_field": {"catalog_ref": "L14 Hundred Field", "source": "evidence.study_type_composition",
|
|
801
|
+
"extractor": CD.extract_study_type_composition, "renderer": render_hundred_field,
|
|
802
|
+
"params": {}},
|
|
803
|
+
"tick_donut": {"catalog_ref": "F4 Tick Donut", "source": "evidence.wwc_composition",
|
|
804
|
+
"extractor": CD.extract_wwc_composition, "renderer": render_tick_donut,
|
|
805
|
+
"params": {}},
|
|
806
|
+
"tick_rows": {"catalog_ref": "F5 Tick Rows", "source": "outcomes.direction_counts",
|
|
807
|
+
"extractor": CD.extract_direction_counts, "renderer": render_tick_rows,
|
|
808
|
+
"params": {"outcomes": list}},
|
|
809
|
+
"rung_bars": {"catalog_ref": "F1 Rung Bars", "source": "outcomes.direction_counts",
|
|
810
|
+
"extractor": CD.extract_direction_counts, "renderer": render_rung_bars,
|
|
811
|
+
"params": {"outcomes": list}},
|
|
812
|
+
"paired_rungs": {"catalog_ref": "F6 Paired Rungs", "source": "outcomes.paired_counts",
|
|
813
|
+
"extractor": CD.extract_paired_counts, "renderer": render_paired_rungs,
|
|
814
|
+
"params": {"outcomes": list}},
|
|
815
|
+
"brand_spectrum": {"catalog_ref": "L7 Brand Spectrum", "source": "outcomes.bipolar_axes",
|
|
816
|
+
"extractor": CD.extract_bipolar_axes, "renderer": render_brand_spectrum,
|
|
817
|
+
"params": {"outcomes": list}},
|
|
818
|
+
"barcode_lollipop": {"catalog_ref": "L3 Barcode Lollipop", "source": "intervention.phase_weeks",
|
|
819
|
+
"extractor": CD.extract_phase_weeks, "renderer": render_barcode_lollipop,
|
|
820
|
+
"params": {}},
|
|
821
|
+
"launch_fan": {"catalog_ref": "L1 Launch Fan", "source": "intervention.activity_weights",
|
|
822
|
+
"extractor": CD.extract_activity_weights, "renderer": render_launch_fan,
|
|
823
|
+
"params": {"max_items": int}},
|
|
824
|
+
"dotty_matrix": {"catalog_ref": "L8 Dotty Matrix", "source": "intervention.phase_groups",
|
|
825
|
+
"extractor": CD.extract_phase_groups, "renderer": render_dotty_matrix,
|
|
826
|
+
"params": {}},
|
|
827
|
+
"tick_gauge": {"catalog_ref": "F11 Tick Gauge", "source": "decision.confidence_score",
|
|
828
|
+
"extractor": CD.extract_confidence_score, "renderer": render_tick_gauge,
|
|
829
|
+
"params": {}},
|
|
830
|
+
"ballot_tally": {"catalog_ref": "L15 Ballot Tally", "source": "methodology.flag_rates",
|
|
831
|
+
"extractor": CD.extract_flag_rates, "renderer": render_ballot_tally,
|
|
832
|
+
"params": {}},
|
|
833
|
+
"matrix_heat": {"catalog_ref": "L16 Matrix Heat", "source": "evidence.year_x_outcome_counts",
|
|
834
|
+
"extractor": CD.extract_year_x_outcome_counts, "renderer": render_matrix_heat,
|
|
835
|
+
"params": {}},
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
LEGACY_TYPES = ("brand_spectrum", "forest_plot", "dot_cascade", "dotty_matrix",
|
|
839
|
+
"barcode_lollipop", "bubble_almanac", "launch_fan")
|
|
840
|
+
|
|
841
|
+
ACADEMIC_FIGURE_KEYS = ("outcome-comparison.svg", "benchmark-citation-support.svg",
|
|
842
|
+
"benchmark-quality-cost.svg", "forest-plot.svg")
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def render_figure(fig_type: str, bundle: dict, theme: str, meta: dict,
|
|
846
|
+
audit: Optional[list] = None) -> str:
|
|
847
|
+
"""Dispatch to the registered renderer. Unknown types raise ValueError —
|
|
848
|
+
the caller records the reason and never silently falls back."""
|
|
849
|
+
entry = REGISTRY.get(fig_type)
|
|
850
|
+
if entry is None:
|
|
851
|
+
raise ValueError(f"unregistered lieflat chart type: {fig_type!r}")
|
|
852
|
+
return entry["renderer"](bundle, theme, meta or {}, audit)
|