@1aboveio/skills 1.0.10 → 1.0.11
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/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/resources.json +297 -216
- package/README.md +3 -3
- package/package.json +10 -8
- package/skills/app-dev/airflow/SKILL.md +1 -1
- package/skills/app-dev/android-foundation/SKILL.md +35 -37
- package/skills/app-dev/android-foundation/references/delivery-focus.md +4 -2
- package/skills/app-dev/android-foundation/references/feature-work.md +50 -0
- package/skills/app-dev/android-foundation/references/setup.md +24 -9
- package/skills/app-dev/android-foundation/references/workspace.md +81 -0
- package/skills/app-dev/android-foundation/scripts/workspace-runtime/bin/workspaces.mjs +52 -0
- package/skills/app-dev/android-foundation/scripts/workspace-runtime/native-workspace.mjs +239 -0
- package/skills/app-dev/android-foundation/scripts/workspace-runtime/workspace-lifecycle.mjs +2154 -0
- package/skills/app-dev/app-debug/SKILL.md +1 -1
- package/skills/app-dev/better-auth/SKILL.md +1 -1
- package/skills/app-dev/data-catalog/SKILL.md +1 -1
- package/skills/app-dev/data-quality/SKILL.md +1 -1
- package/skills/app-dev/ios-foundation/SKILL.md +15 -10
- package/skills/app-dev/ios-foundation/references/setup.md +25 -9
- package/skills/app-dev/ios-foundation/references/workspace.md +82 -0
- package/skills/app-dev/ios-foundation/scripts/workspace-runtime/bin/workspaces.mjs +52 -0
- package/skills/app-dev/ios-foundation/scripts/workspace-runtime/native-workspace.mjs +239 -0
- package/skills/app-dev/ios-foundation/scripts/workspace-runtime/workspace-lifecycle.mjs +2154 -0
- package/skills/app-dev/monorepo/SKILL.md +1 -1
- package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
- package/skills/app-dev/prisma-setup/SKILL.md +1 -1
- package/skills/app-dev/pyspark/SKILL.md +1 -1
- package/skills/app-dev/python-backend/SKILL.md +1 -1
- package/skills/app-dev/shadcn/SKILL.md +1 -1
- package/skills/app-dev/zod-v4/SKILL.md +1 -1
- package/skills/cicd/cloud-build/SKILL.md +1 -1
- package/skills/cicd/cloud-debug/SKILL.md +1 -1
- package/skills/cicd/cloud-deploy/SKILL.md +1 -1
- package/skills/cicd/docker/SKILL.md +1 -1
- package/skills/cicd/github-actions/SKILL.md +1 -1
- package/skills/cicd/github-flow/SKILL.md +1 -1
- package/skills/cicd/google-cloud/SKILL.md +1 -1
- package/skills/cicd/mergify/SKILL.md +1 -1
- package/skills/cicd/podman/SKILL.md +1 -1
- package/skills/content/article-review/SKILL.md +1 -1
- package/skills/content/article-to-image/SKILL.md +1 -1
- package/skills/content/flowchart-svg/SKILL.md +1 -1
- package/skills/content/image-gen/SKILL.md +1 -1
- package/skills/content/mermaid/SKILL.md +1 -1
- package/skills/content/wechat-article-writer/SKILL.md +1 -1
- package/skills/content/wechat-publisher/SKILL.md +1 -1
- package/skills/content/wechat-search/SKILL.md +1 -1
- package/skills/content/xai-search/SKILL.md +1 -1
- package/skills/core/distill-writing/SKILL.md +3 -3
- package/skills/core/distill-writing/references/extraction.md +1 -1
- package/skills/core/doc-to-md/SKILL.md +1 -1
- package/skills/core/editorial-review/SKILL.md +82 -12
- package/skills/core/editorial-review/references/checklist.md +26 -1
- package/skills/core/house-style/SKILL.md +8 -3
- package/skills/core/house-style/references/analytical-reporting.md +123 -0
- package/skills/core/house-style/references/analytical-writing-patterns.md +79 -0
- package/skills/core/house-style/references/analytical-writing-research.md +129 -0
- package/skills/core/{write-doc → house-style}/references/bilingual.md +7 -5
- package/skills/core/house-style/references/definitions-and-metrics.md +86 -0
- package/skills/core/house-style/references/document-workflow.md +193 -0
- package/skills/core/house-style/references/initialization.md +66 -0
- package/skills/core/house-style/references/persistence.md +9 -4
- package/skills/core/house-style/references/rules.md +11 -4
- package/skills/core/house-style/scripts/init-guidance.mjs +213 -0
- package/skills/core/house-style/templates/analytical-report.md +54 -0
- package/skills/core/house-style/templates/gap-analysis.md +1 -1
- package/skills/core/plain-language/SKILL.md +2 -2
- package/skills/core/polish-cn/SKILL.md +9 -2
- package/skills/core/reg-parser/SKILL.md +3 -3
- package/skills/core/terminology/SKILL.md +8 -4
- package/skills/core/write-doc/SKILL.md +40 -151
- package/skills/core/write-report/SKILL.md +129 -0
- package/skills/core/{write-doc → write-report}/references/compliance-reports.md +4 -4
- package/skills/engineering/better-review/SKILL.md +37 -37
- package/skills/engineering/better-review/references/passes.md +35 -41
- package/skills/engineering/better-review/references/result.md +10 -9
- package/skills/engineering/e2e-test/SKILL.md +17 -17
- package/skills/engineering/e2e-test/references/authoring/playwright-config.md +11 -13
- package/skills/engineering/e2e-test/references/presentation-sweep.md +7 -7
- package/skills/engineering/e2e-test/references/reviewing.md +9 -11
- package/skills/engineering/e2e-test/references/route-manifest.md +1 -1
- package/skills/engineering/engineering/SKILL.md +124 -0
- package/skills/engineering/engineering/references/engineering-conventions.md +32 -0
- package/skills/engineering/engineering/references/feature-delivery.md +8 -0
- package/skills/engineering/engineering/references/init-scope.md +37 -0
- package/skills/engineering/engineering/scripts/init.mjs +160 -0
- package/skills/engineering/{contributing → engineering}/scripts/persist-conventions.mjs +1 -1
- package/skills/engineering/engineering-runtime/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/coherence/workflow.json +19 -19
- package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
- package/skills/engineering/ensure-coverage/SKILL.md +22 -109
- package/skills/engineering/ensure-coverage/references/breadth/coverage-ledger.md +7 -5
- package/skills/engineering/ensure-coverage/references/breadth/surface-baseline.md +4 -4
- package/skills/engineering/ensure-coverage/references/breadth/surface-discovery.md +3 -3
- package/skills/engineering/ensure-coverage/references/depth/characterization.md +4 -65
- package/skills/engineering/ensure-coverage/references/depth/grading.md +5 -7
- package/skills/engineering/ensure-coverage/references/depth/mock-policy.md +6 -12
- package/skills/engineering/ensure-coverage/references/enforcement/ci-contract.md +1 -1
- package/skills/engineering/ensure-coverage/references/presentation/axis.md +6 -6
- package/skills/engineering/ensure-coverage/references/process/audit-mode.md +7 -23
- package/skills/engineering/ensure-coverage/references/process/output-template.md +18 -133
- package/skills/engineering/ensure-coverage/references/process/test-strategy.md +7 -12
- package/skills/engineering/ensure-coverage/scripts/README.md +12 -4
- package/skills/engineering/golden-path/SKILL.md +1 -1
- package/skills/engineering/harness-runtime/SKILL.md +3 -1
- package/skills/engineering/harness-runtime/bin/workspaces.mjs +52 -0
- package/skills/engineering/harness-runtime/native-workspace.mjs +239 -0
- package/skills/engineering/harness-runtime/references/workspaces.md +78 -0
- package/skills/engineering/harness-runtime/workspace-lifecycle.mjs +2154 -0
- package/skills/engineering/resolve-issues/SKILL.md +40 -61
- package/skills/engineering/resolve-issues/references/ci.md +13 -3
- package/skills/engineering/resolve-issues/references/delivery-progress.md +4 -3
- package/skills/engineering/resolve-issues/references/done.md +11 -2
- package/skills/engineering/resolve-issues/references/expire.md +9 -2
- package/skills/engineering/resolve-issues/references/implementation.md +33 -17
- package/skills/engineering/resolve-issues/references/preflight.md +3 -1
- package/skills/engineering/resolve-issues/references/principles.md +52 -0
- package/skills/engineering/resolve-issues/references/review.md +25 -15
- package/skills/engineering/resolve-issues/references/task-plan.md +64 -27
- package/skills/engineering/resolve-issues/references/workspaces.md +164 -0
- package/skills/engineering/resolve-issues/scripts/cleanup-runs.mjs +185 -317
- package/skills/engineering/resolve-issues/scripts/next-actions.mjs +44 -7
- package/skills/engineering/resolve-issues/scripts/rehydrate.mjs +36 -2
- package/skills/engineering/resolve-issues/scripts/review-verdict.mjs +9 -8
- package/skills/engineering/resolve-issues/scripts/workspace-lifecycle.mjs +1 -0
- package/skills/engineering/resolve-release/SKILL.md +1 -1
- package/skills/engineering/rush-issues/SKILL.md +7 -5
- package/skills/engineering/rush-release/SKILL.md +1 -1
- package/skills/engineering/test-quality/SKILL.md +19 -4
- package/skills/engineering/test-quality/references/lint.md +1 -1
- package/skills/engineering/test-quality/references/principles.md +2 -2
- package/skills/engineering/test-quality/references/testing-conventions.md +1 -1
- package/skills/risk-compliance/compliance-audit/SKILL.md +4 -8
- package/skills/risk-compliance/compliance-audit/references/audience-and-reports.md +12 -0
- package/skills/risk-compliance/contract-compare/SKILL.md +17 -1
- package/skills/risk-compliance/contract-review/SKILL.md +6 -1
- package/skills/risk-compliance/contract-review/references/report-stage.md +17 -0
- package/skills/risk-compliance/doc-authenticity/SKILL.md +11 -1
- package/skills/risk-compliance/fraud-detection/LICENSE +3 -0
- package/skills/risk-compliance/fraud-detection/SKILL.md +148 -0
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/archetypes/first-party-fraud.md +3 -2
- package/skills/risk-compliance/fraud-detection/references/archetypes/third-party-fraud.md +47 -0
- package/skills/risk-compliance/fraud-detection/references/collection-payout-features.md +151 -0
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/contexts/bank-transfer.md +4 -3
- package/skills/risk-compliance/fraud-detection/references/contexts/card-payment.md +70 -0
- package/skills/risk-compliance/fraud-detection/references/contexts/payment-collection.md +34 -0
- package/skills/risk-compliance/fraud-detection/references/contexts/payout.md +34 -0
- package/skills/risk-compliance/fraud-detection/references/enrichment.md +74 -0
- package/skills/risk-compliance/fraud-detection/references/entities.md +48 -0
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/feature-engineering.md +69 -33
- package/skills/risk-compliance/fraud-detection/references/feature-sql.md +150 -0
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/report-rationale.md +4 -2
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/report-template.md +45 -23
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/review-checklist.md +60 -20
- package/skills/risk-compliance/fraud-detection/references/workflow.md +216 -0
- package/skills/risk-compliance/fraud-detection/release-check.mjs +95 -0
- package/skills/risk-compliance/payment-analysis/SKILL.md +36 -17
- package/skills/risk-compliance/payment-analysis/references/auth-rate-actions.md +2 -2
- package/skills/risk-compliance/payment-analysis/references/report-template.md +10 -2
- package/skills/risk-compliance/payment-analysis/references/visualization.md +11 -1
- package/skills/risk-compliance/portfolio-payment-analysis/LICENSE +3 -0
- package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +170 -0
- package/skills/risk-compliance/portfolio-payment-analysis/references/identity-and-reconciliation.md +46 -0
- package/skills/risk-compliance/portfolio-payment-analysis/references/metrics-and-reporting.md +170 -0
- package/skills/risk-compliance/portfolio-payment-analysis/references/risk-history.md +89 -0
- package/skills/risk-compliance/portfolio-payment-analysis/release-check.mjs +95 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/analyze.py +636 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/build_report.py +386 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/comparison_charts.py +425 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/csv_export.py +52 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/render.py +160 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/risk_history.py +435 -0
- package/skills/risk-compliance/similar-domain/SKILL.md +9 -4
- package/skills/risk-compliance/tld-detection/SKILL.md +124 -239
- package/skills/risk-compliance/tld-detection/agents/openai.yaml +1 -1
- package/skills/risk-compliance/tld-detection/references/clean.md +18 -51
- package/skills/risk-compliance/tld-detection/references/fx-conversion.md +56 -219
- package/skills/risk-compliance/tld-detection/references/metric-contract.md +109 -0
- package/skills/risk-compliance/tld-detection/references/pattern.md +99 -0
- package/skills/risk-compliance/tld-detection/references/report-template.md +49 -272
- package/skills/risk-compliance/tld-detection/scripts/detect_ladders.py +46 -13
- package/skills/risk-compliance/tld-detection/scripts/detect_patterns.py +346 -0
- package/skills/risk-compliance/tld-detection/scripts/detect_streams.py +51 -19
- package/skills/risk-compliance/tld-detection/scripts/profile_groups.py +109 -0
- package/skills/risk-compliance/tld-detection/scripts/render_charts.py +218 -0
- package/skills/risk-compliance/tld-detection/scripts/tld_common.py +243 -0
- package/skills/engineering/contributing/SKILL.md +0 -70
- package/skills/risk-compliance/fraud-analysis/SKILL.md +0 -114
- package/skills/risk-compliance/fraud-analysis/references/archetypes/third-party-fraud.md +0 -27
- package/skills/risk-compliance/fraud-analysis/references/contexts/card-payment.md +0 -30
- package/skills/risk-compliance/fraud-analysis/references/contexts/payment-collection.md +0 -20
- package/skills/risk-compliance/fraud-analysis/references/contexts/payout.md +0 -20
- package/skills/risk-compliance/fraud-analysis/references/workflow.md +0 -175
- package/skills/risk-compliance/tld-detection/references/chinese-polish.md +0 -55
- package/skills/risk-compliance/tld-detection/references/clustering.md +0 -235
- package/skills/risk-compliance/tld-detection/references/decision.md +0 -189
- package/skills/risk-compliance/tld-detection/references/streams.md +0 -141
- package/skills/risk-compliance/tld-detection/references/terminology.md +0 -95
- package/skills/risk-compliance/tld-detection/references/workflow.md +0 -95
- /package/skills/{engineering/contributing → core/write-report}/LICENSE +0 -0
- /package/skills/core/{write-doc → write-report}/references/reports/audit-report-template.md +0 -0
- /package/skills/core/{write-doc → write-report}/references/reports/external-assessment-template.md +0 -0
- /package/skills/core/{write-doc → write-report}/references/reports/management-report-template.md +0 -0
- /package/skills/core/{write-doc → write-report}/references/reports/vars-report-template.md +0 -0
- /package/skills/{engineering/contributing → core/write-report}/release-check.mjs +0 -0
- /package/skills/{risk-compliance/fraud-analysis → engineering/engineering}/LICENSE +0 -0
- /package/skills/engineering/{contributing → engineering}/references/tracker-conventions.md +0 -0
- /package/skills/{risk-compliance/fraud-analysis → engineering/engineering}/release-check.mjs +0 -0
- /package/skills/engineering/{contributing → engineering}/scripts/persist-conventions-core.mjs +0 -0
- /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/archetypes/authorized-payment-scam.md +0 -0
- /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/mechanisms/account-takeover.md +0 -0
- /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/taxonomy.md +0 -0
- /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/terminology.md +0 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Standalone, offline week-comparison charts for report renderers.
|
|
2
|
+
|
|
3
|
+
Append CSS to the report stylesheet and JS to its script. chart() returns an
|
|
4
|
+
HTML fragment and its Markdown equivalent; identifiers should be unique per page.
|
|
5
|
+
"""
|
|
6
|
+
import hashlib
|
|
7
|
+
import html
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
from decimal import Decimal, InvalidOperation
|
|
11
|
+
|
|
12
|
+
CSS = """
|
|
13
|
+
.comparison-chart{max-width:100%;min-width:0;margin:24px 0;scroll-margin-top:90px}.comparison-chart h3{margin-bottom:8px}.comparison-chart select{max-width:100%;font:inherit;padding:6px}.comparison-chart select:focus-visible{outline:3px solid #c16613;outline-offset:3px}.comparison-legend{display:flex;flex-wrap:wrap;gap:8px 20px;margin:12px 0}.comparison-legend span{overflow-wrap:anywhere}.comparison-key{display:inline-block;width:12px;height:12px;margin-right:6px}.comparison-previous{background:#727c80}.comparison-current{background:#087b71}.comparison-chart svg{display:block;width:100%;height:auto;max-width:100%}.comparison-chart svg text{font-family:inherit;font-size:13px;fill:#344a51}.comparison-chart .comparison-table{width:100%;table-layout:fixed;border-collapse:collapse}.comparison-chart th,.comparison-chart td{white-space:normal;overflow-wrap:anywhere;text-align:left;padding:8px;border-bottom:1px solid #d8e2df}.comparison-chart details{max-width:100%}.comparison-chart select{min-height:44px}.comparison-chart [data-comparison-plot]{overflow-x:auto}.comparison-chart [data-comparison-plot]:focus-visible{outline:3px solid #c16613}@media(max-width:700px){.comparison-chart svg{min-width:760px;max-width:none}.comparison-chart [data-comparison-plot]::before{content:'横向滚动查看图表';display:block;font-size:12px;color:#536770;margin-bottom:8px}}
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
CSS += """
|
|
17
|
+
.comparison-chart details{overflow-x:auto}.comparison-chart[data-layout="time_series_chart"] .comparison-table{min-width:760px}.comparison-compact[data-layout="pie_pair"] .comparison-panel svg{min-width:0;max-width:300px}.comparison-compact [data-comparison-plot]{overflow-x:visible}.comparison-compact svg text{font-size:16px}.comparison-panels{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.comparison-panel{min-width:0}.comparison-panel h4{margin:4px 0}@media(max-width:700px){.comparison-compact svg{min-width:0;max-width:100%}.comparison-compact [data-comparison-plot]::before{content:none}.comparison-panels{grid-template-columns:1fr}.comparison-compact[data-layout="paired_bars"] [data-comparison-plot]{overflow-x:auto}.comparison-compact[data-layout="paired_bars"] svg{min-width:900px}.comparison-panel{overflow-x:auto}.comparison-compact .comparison-panel svg{min-width:520px;max-width:none}}
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
# SVGs are pre-rendered from the same data as the table. Cloning a template
|
|
21
|
+
# avoids parsing source labels as HTML or evaluating data as code in the browser.
|
|
22
|
+
JS = """
|
|
23
|
+
(()=>{let opened=[];
|
|
24
|
+
window.addEventListener('beforeprint',()=>{
|
|
25
|
+
if(opened.length)return;
|
|
26
|
+
opened=[...document.querySelectorAll('details:not([open])')];
|
|
27
|
+
opened.forEach(detail=>detail.open=true);
|
|
28
|
+
});
|
|
29
|
+
window.addEventListener('afterprint',()=>{opened.forEach(detail=>detail.open=false);opened=[];});
|
|
30
|
+
document.querySelectorAll('.comparison-chart').forEach(chart=>{
|
|
31
|
+
const select=chart.querySelector('select'), target=chart.querySelector('[data-comparison-plot]');
|
|
32
|
+
if(!select||!target)return;
|
|
33
|
+
select.addEventListener('change',()=>{
|
|
34
|
+
const template=chart.querySelectorAll('template')[select.selectedIndex];
|
|
35
|
+
if(!template)return;
|
|
36
|
+
target.replaceChildren(template.content.cloneNode(true));
|
|
37
|
+
chart.dataset.metric=select.options[select.selectedIndex].dataset.key;
|
|
38
|
+
});
|
|
39
|
+
});})();
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _esc(value):
|
|
44
|
+
return html.escape(str(value), quote=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _md(value):
|
|
48
|
+
text = html.escape(str(value), quote=False).replace('\n', ' ')
|
|
49
|
+
for char in ('\\', '*', '_', '[', ']', '`', '#', '|'):
|
|
50
|
+
text = text.replace(char, '\\' + char)
|
|
51
|
+
return text
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _number(value):
|
|
55
|
+
if value is None or isinstance(value, bool):
|
|
56
|
+
return None
|
|
57
|
+
try:
|
|
58
|
+
number = Decimal(str(value))
|
|
59
|
+
return number if number.is_finite() else None
|
|
60
|
+
except (InvalidOperation, ValueError):
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _display(value, fmt):
|
|
65
|
+
number = _number(value)
|
|
66
|
+
if number is None:
|
|
67
|
+
return ''
|
|
68
|
+
if fmt == 'percent':
|
|
69
|
+
return f'{number * 100:,.2f}%'
|
|
70
|
+
if fmt == 'usd':
|
|
71
|
+
return f'USD {number:,.2f}'
|
|
72
|
+
return f'{number:,.0f}'
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _value(category, series, metric):
|
|
76
|
+
return category.get('values', {}).get(series['key'], {}).get(metric['key'])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _svg(block, metric):
|
|
80
|
+
categories, series = block['categories'], block['series']
|
|
81
|
+
height = 75 + len(categories) * 48
|
|
82
|
+
left, right = 230, 640
|
|
83
|
+
values = [_number(_value(c, s, metric)) for c in categories for s in series]
|
|
84
|
+
maximum = Decimal(1) if metric['format'] == 'percent' else max([v for v in values if v is not None and v > 0] or [Decimal(1)])
|
|
85
|
+
title = block['title'] + ' — ' + metric['label']
|
|
86
|
+
out = [f'<svg viewBox="0 0 800 {height}" role="img" aria-label="{_esc(title)}"><title>{_esc(title)}</title>']
|
|
87
|
+
for i in range(5):
|
|
88
|
+
x = left + (right-left)*i/4
|
|
89
|
+
label = f'{i*25}%' if metric['format'] == 'percent' else _display(maximum*Decimal(i)/4, metric['format'])
|
|
90
|
+
out.append(f'<line x1="{x}" y1="25" x2="{x}" y2="{height-12}" stroke="#e2eae6"/><text x="{x}" y="18" text-anchor="middle">{_esc(label)}</text>')
|
|
91
|
+
for row, category in enumerate(categories):
|
|
92
|
+
y = 50 + row*48
|
|
93
|
+
label = str(category['label'])
|
|
94
|
+
short = label if len(label) <= 24 else label[:23]+'…'
|
|
95
|
+
out.append(f'<text x="8" y="{y+8}"><title>{_esc(label)}</title>{_esc(short)}</text>')
|
|
96
|
+
for index, s in enumerate(series):
|
|
97
|
+
value = _number(_value(category, s, metric))
|
|
98
|
+
display = _display(_value(category, s, metric), metric['format'])
|
|
99
|
+
color = '#727c80' if s['key'] == 'previous' else '#087b71'
|
|
100
|
+
yy = y + index*19
|
|
101
|
+
tooltip = _esc(f'{label} — {s["label"]}: {display}')
|
|
102
|
+
out.append(f'<text x="790" y="{yy+5}" text-anchor="end" style="fill:{color}">{_esc(display)}</text>')
|
|
103
|
+
if value is None:
|
|
104
|
+
continue
|
|
105
|
+
ratio = max(Decimal(0), min(Decimal(1), value/maximum))
|
|
106
|
+
x = left + (right-left)*float(ratio)
|
|
107
|
+
if metric['format'] == 'percent':
|
|
108
|
+
out.append(f'<circle cx="{x:.2f}" cy="{yy}" r="6" fill="{color}"><title>{tooltip}</title></circle>')
|
|
109
|
+
elif value == 0:
|
|
110
|
+
out.append(f'<text x="{left+5}" y="{yy+5}"><title>{tooltip}</title>0</text>')
|
|
111
|
+
else:
|
|
112
|
+
out.append(f'<rect x="{left}" y="{yy-6}" width="{x-left:.2f}" height="12" fill="{color}"><title>{tooltip}</title></rect>')
|
|
113
|
+
out.append('</svg>')
|
|
114
|
+
return ''.join(out)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_BANDS = ('[0,25)', '[25,50)', '[50,100)', '[100,200)', '[200,500)', '[500,+inf)')
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _axis_label(value, fmt):
|
|
121
|
+
if fmt == 'percent':
|
|
122
|
+
scaled=value*100
|
|
123
|
+
return f'{scaled:.0f}%' if scaled==scaled.to_integral_value() else f'{scaled:.2f}%'
|
|
124
|
+
if abs(value) >= 1000000:
|
|
125
|
+
return f'{value / Decimal(1000000):.1f}m'
|
|
126
|
+
if abs(value) >= 1000:
|
|
127
|
+
return f'{value / Decimal(1000):.1f}k'
|
|
128
|
+
return f'{value:,.0f}'
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _compact_svg(block, metric, categories, series, maximum=None):
|
|
132
|
+
values = [_number(_value(c, s, metric)) for c in categories for s in series]
|
|
133
|
+
if maximum is None:
|
|
134
|
+
maximum = Decimal(1) if metric['format'] == 'percent' else max([v for v in values if v is not None and v > 0] or [Decimal(1)])
|
|
135
|
+
paired = len(series) == 2
|
|
136
|
+
width = 1000 if paired else 600
|
|
137
|
+
pitch = 34 if paired else 32
|
|
138
|
+
height = 50 + len(categories) * pitch
|
|
139
|
+
left, right = (240, 840) if paired else (185, 470)
|
|
140
|
+
title = block['title'] + ' — ' + metric['label'] + (' — ' + series[0]['label'] if len(series) == 1 else '')
|
|
141
|
+
out = [f'<svg viewBox="0 0 {width} {height}" role="img" aria-label="{_esc(title)}"><title>{_esc(title)}</title>']
|
|
142
|
+
for i in range(5):
|
|
143
|
+
x = left + (right-left)*i/4
|
|
144
|
+
label = _axis_label(maximum*Decimal(i)/4, metric['format'])
|
|
145
|
+
out.append(f'<line x1="{x}" y1="25" x2="{x}" y2="{height-5}" stroke="#e2eae6"/><text x="{x}" y="18" text-anchor="middle">{_esc(label)}</text>')
|
|
146
|
+
for row, category in enumerate(categories):
|
|
147
|
+
y = 43 + row * pitch
|
|
148
|
+
label = str(category['label'])
|
|
149
|
+
limit = 28 if paired else 18
|
|
150
|
+
short = label if len(label) <= limit else label[:limit-1] + '…'
|
|
151
|
+
out.append(f'<text x="4" y="{y+4}"><title>{_esc(label)}</title>{_esc(short)}</text>')
|
|
152
|
+
for index, s in enumerate(series):
|
|
153
|
+
raw = _value(category, s, metric)
|
|
154
|
+
value, display = _number(raw), _display(raw, metric['format'])
|
|
155
|
+
color = '#727c80' if s['key'] == 'previous' else '#087b71'
|
|
156
|
+
yy = y + index * 14
|
|
157
|
+
tooltip = _esc(f'{label} — {s["label"]}: {display}')
|
|
158
|
+
out.append(f'<text x="{width-5}" y="{yy+4}" text-anchor="end" style="fill:{color};font-size:12px"><title>{tooltip}</title>{_esc(display)}</text>')
|
|
159
|
+
if value is None or value == 0:
|
|
160
|
+
continue
|
|
161
|
+
else:
|
|
162
|
+
ratio = max(Decimal(0), min(Decimal(1), value/maximum))
|
|
163
|
+
out.append(f'<rect x="{left}" y="{yy-5}" width="{(right-left)*float(ratio):.2f}" height="9" fill="{color}"><title>{tooltip}</title></rect>')
|
|
164
|
+
out.append('</svg>')
|
|
165
|
+
return ''.join(out)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _histogram_svg(block, metric, categories, series, maximum):
|
|
169
|
+
title = block['title'] + ' — ' + metric['label'] + ' — ' + series['label']
|
|
170
|
+
out = [f'<svg viewBox="0 0 600 270" role="img" aria-label="{_esc(title)}"><title>{_esc(title)}</title>']
|
|
171
|
+
for i in range(5):
|
|
172
|
+
y = 210 - i * 45
|
|
173
|
+
label = _axis_label(maximum * Decimal(i) / 4, metric['format'])
|
|
174
|
+
out.append(f'<line x1="85" y1="{y}" x2="590" y2="{y}" stroke="#e2eae6"/><text x="80" y="{y+4}" text-anchor="end">{_esc(label)}</text>')
|
|
175
|
+
color = '#727c80' if series['key'] == 'previous' else '#087b71'
|
|
176
|
+
for i, category in enumerate(categories):
|
|
177
|
+
x = 85 + i * 84
|
|
178
|
+
raw = _value(category, series, metric)
|
|
179
|
+
value, display = _number(raw), _display(raw, metric['format'])
|
|
180
|
+
tooltip = _esc(f'{category["label"]} — {series["label"]}: {display}')
|
|
181
|
+
band_label = ('0–<25','25–<50','50–<100','100–<200','200–<500','500+')[i]
|
|
182
|
+
out.append(f'<text x="{x+42}" y="232" text-anchor="middle" style="font-size:14px">{_esc(band_label)}</text>')
|
|
183
|
+
if value is None or value == 0:
|
|
184
|
+
out.append(f'<text x="{x+42}" y="203" text-anchor="middle"><title>{tooltip}</title>{_esc(display)}</text>')
|
|
185
|
+
else:
|
|
186
|
+
bar_height = 180 * float(max(Decimal(0), min(Decimal(1), value/maximum)))
|
|
187
|
+
out.append(f'<rect x="{x}" y="{210-bar_height:.2f}" width="84" height="{bar_height:.2f}" fill="{color}" stroke="white"><title>{tooltip}</title></rect>')
|
|
188
|
+
return ''.join(out) + '</svg>'
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _compact_plot(block, metric, layout):
|
|
192
|
+
categories, series = block['categories'], block['series']
|
|
193
|
+
if layout == 'paired_bars':
|
|
194
|
+
return _compact_svg(block, metric, categories, series)
|
|
195
|
+
if layout == 'histogram_pair':
|
|
196
|
+
lookup = {c['label']: c for c in categories if c['label'] in _BANDS}
|
|
197
|
+
plotted = [lookup.get(label, {'label': label, 'values': {}}) for label in _BANDS]
|
|
198
|
+
extra = [c for c in categories if c['label'] not in _BANDS]
|
|
199
|
+
else:
|
|
200
|
+
plotted, extra = categories, []
|
|
201
|
+
values = [_number(_value(c, s, metric)) for c in plotted for s in series]
|
|
202
|
+
maximum = Decimal(1) if metric['format'] == 'percent' else max([v for v in values if v is not None and v > 0] or [Decimal(1)])
|
|
203
|
+
panels = []
|
|
204
|
+
for s in series:
|
|
205
|
+
svg = _histogram_svg(block, metric, plotted, s, maximum) if layout == 'histogram_pair' else _compact_svg(block, metric, plotted, [s], maximum=maximum)
|
|
206
|
+
panels.append('<section class="comparison-panel"><h4><i aria-hidden="true" class="comparison-key comparison-'+_esc(s['key'])+'"></i>'+_esc(s['label'])+'</h4>'+svg+'</section>')
|
|
207
|
+
note = '<p>金额区间宽度不相等;以等宽类别显示,500+ 无上限,不表示密度或连续区间宽度。</p>' if layout == 'histogram_pair' else ''
|
|
208
|
+
if extra:
|
|
209
|
+
note += '<p>区间外或未知(不作为金额区间绘制):' + '; '.join(_esc(c['label']) + ' — ' + ', '.join(_esc(s['label']) + ': ' + _esc(_display(_value(c, s, metric), metric['format'])) for s in series) for c in extra) + '</p>'
|
|
210
|
+
return note + '<div class="comparison-panels">' + ''.join(panels) + '</div>'
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
_PALETTE = ('#087b71', '#727c80', '#c16613', '#426b94', '#a05178', '#759b47', '#8c6954', '#6b61a8')
|
|
214
|
+
_SERIES_COLORS = {'visa_predispute':'#087b71','visa_dispute':'#426b94','mastercard_chargeback':'#c16613','visa':'#426b94','mastercard':'#c16613','mastercard_efm':'#c16613','vamp_ratio':'#6b61a8'}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _pie_colors(categories):
|
|
218
|
+
known={'None':'#727c80','3DS attempted':'#c16613','3DS validated':'#087b71'}
|
|
219
|
+
used={known[c['label']] for c in categories if c['label'] in known}
|
|
220
|
+
colors=[]
|
|
221
|
+
for i,category in enumerate(categories):
|
|
222
|
+
color=known.get(category['label'])
|
|
223
|
+
if color is None:
|
|
224
|
+
color=next((c for c in _PALETTE if c not in used),_PALETTE[i%len(_PALETTE)])
|
|
225
|
+
used.add(color)
|
|
226
|
+
colors.append(color)
|
|
227
|
+
return colors
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _pie_plot(block, metric):
|
|
231
|
+
categories = block['categories']
|
|
232
|
+
colors = _pie_colors(categories)
|
|
233
|
+
legend = '<div class="comparison-legend">' + ''.join(
|
|
234
|
+
f'<span><i class="comparison-key" aria-hidden="true" style="background:{colors[i]}"></i>{_esc(c["label"])}</span>'
|
|
235
|
+
for i, c in enumerate(categories)) + '</div>'
|
|
236
|
+
panels = []
|
|
237
|
+
for series in block['series']:
|
|
238
|
+
values = [_number(_value(c, series, metric)) for c in categories]
|
|
239
|
+
known = [v for v in values if v is not None]
|
|
240
|
+
heading = '<h4>' + _esc(series['label']) + '</h4>'
|
|
241
|
+
if not known or any(v is None or v < 0 for v in values):
|
|
242
|
+
panels.append('<section class="comparison-panel">' + heading + '<p>数据不可用</p></section>')
|
|
243
|
+
continue
|
|
244
|
+
total = sum(known, Decimal(0))
|
|
245
|
+
if total == 0:
|
|
246
|
+
panels.append('<section class="comparison-panel">' + heading + '<p>合计为 0</p></section>')
|
|
247
|
+
continue
|
|
248
|
+
title = block['title'] + ' — ' + metric['label'] + ' — ' + series['label']
|
|
249
|
+
svg = [f'<svg viewBox="0 0 300 300" role="img" aria-label="{_esc(title)}"><title>{_esc(title)}</title>']
|
|
250
|
+
angle = -math.pi / 2
|
|
251
|
+
for i, (category, value) in enumerate(zip(categories, values)):
|
|
252
|
+
if value == 0:
|
|
253
|
+
continue
|
|
254
|
+
sweep = float(value / total) * 2 * math.pi
|
|
255
|
+
color = colors[i]
|
|
256
|
+
tooltip = _esc(f'{category["label"]} — {series["label"]}: {_display(value, metric["format"])}')
|
|
257
|
+
if value == total:
|
|
258
|
+
svg.append(f'<circle cx="150" cy="150" r="125" fill="{color}"><title>{tooltip}</title></circle>')
|
|
259
|
+
else:
|
|
260
|
+
x1, y1 = 150 + 125 * math.cos(angle), 150 + 125 * math.sin(angle)
|
|
261
|
+
x2, y2 = 150 + 125 * math.cos(angle + sweep), 150 + 125 * math.sin(angle + sweep)
|
|
262
|
+
svg.append(f'<path d="M 150 150 L {x1:.6f} {y1:.6f} A 125 125 0 {int(sweep > math.pi)} 1 {x2:.6f} {y2:.6f} Z" fill="{color}"><title>{tooltip}</title></path>')
|
|
263
|
+
angle += sweep
|
|
264
|
+
svg.append('</svg>')
|
|
265
|
+
amounts = '<div class="comparison-legend">' + ''.join(
|
|
266
|
+
f'<span><i class="comparison-key" aria-hidden="true" style="background:{colors[i]}"></i>{_esc(c["label"])}: {_esc(_display(v, metric["format"]))}</span>'
|
|
267
|
+
for i, (c, v) in enumerate(zip(categories, values))) + '</div>'
|
|
268
|
+
panels.append('<section class="comparison-panel">' + heading + ''.join(svg) + amounts + '</section>')
|
|
269
|
+
return legend + '<div class="comparison-panels">' + ''.join(panels) + '</div>'
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _time_series_content(block):
|
|
273
|
+
unit, series, points = block['unit'], block['series'], block['points']
|
|
274
|
+
if unit not in ('count', 'usd', 'percent'):
|
|
275
|
+
raise ValueError('unsupported time series unit')
|
|
276
|
+
keys = [s['key'] for s in series]
|
|
277
|
+
if len(set(keys)) != len(keys):
|
|
278
|
+
raise ValueError('duplicate time series key')
|
|
279
|
+
colors = []
|
|
280
|
+
for i, s in enumerate(series):
|
|
281
|
+
color = s.get('color', _SERIES_COLORS.get(s['key'], _PALETTE[i % len(_PALETTE)]))
|
|
282
|
+
if not isinstance(color, str) or not re.fullmatch(r'#[0-9a-fA-F]{6}', color):
|
|
283
|
+
raise ValueError('time series color must be a six-digit hex color')
|
|
284
|
+
colors.append(color)
|
|
285
|
+
values = [[_number(p.get('values', {}).get(s['key'])) for p in points] for s in series]
|
|
286
|
+
observed = [v for row in values for v in row if v is not None]
|
|
287
|
+
low = min([Decimal(0)] + observed)
|
|
288
|
+
high = max([Decimal(0)] + observed)
|
|
289
|
+
if unit == 'count':
|
|
290
|
+
low,high=Decimal(math.floor(low/4))*4,Decimal(math.ceil(high/4))*4
|
|
291
|
+
if high == low:
|
|
292
|
+
high = low + Decimal(4 if unit=='count' else 1)
|
|
293
|
+
width = max(800, 80 * len(points) + 100)
|
|
294
|
+
left, right, top, bottom = 85, width - 30, 30, 240
|
|
295
|
+
def x(i):
|
|
296
|
+
return (left + right) / 2 if len(points) == 1 else left + (right-left)*i/max(1, len(points)-1)
|
|
297
|
+
def y(value):
|
|
298
|
+
return bottom - float((value-low)/(high-low)) * (bottom-top)
|
|
299
|
+
title = _esc(block['title'])
|
|
300
|
+
svg = [f'<svg viewBox="0 0 {width} 290" role="img" aria-label="{title}"><title>{title}</title>']
|
|
301
|
+
for i in range(5):
|
|
302
|
+
tick = low + (high-low)*Decimal(i)/4
|
|
303
|
+
yy = bottom - (bottom-top)*i/4
|
|
304
|
+
svg.append(f'<line x1="{left}" y1="{yy}" x2="{right}" y2="{yy}" stroke="#e2eae6"/><text x="{left-8}" y="{yy+4}" text-anchor="end">{_esc(_axis_label(tick, unit))}</text>')
|
|
305
|
+
for i, p in enumerate(points):
|
|
306
|
+
axis_label = str(p['label']) + ('*' if p.get('partial') else '')
|
|
307
|
+
svg.append(f'<text x="{x(i):.2f}" y="265" text-anchor="middle">{_esc(axis_label)}</text>')
|
|
308
|
+
for s, color, row in zip(series, colors, values):
|
|
309
|
+
previous = None
|
|
310
|
+
for i, value in enumerate(row):
|
|
311
|
+
if value is None:
|
|
312
|
+
previous = None
|
|
313
|
+
continue
|
|
314
|
+
xx, yy = x(i), y(value)
|
|
315
|
+
if previous is not None:
|
|
316
|
+
svg.append(f'<line x1="{previous[0]:.2f}" y1="{previous[1]:.2f}" x2="{xx:.2f}" y2="{yy:.2f}" stroke="{color}" stroke-width="2"/>')
|
|
317
|
+
point = points[i]
|
|
318
|
+
dates = ' – '.join(str(point[k]) for k in ('start', 'end') if point.get(k))
|
|
319
|
+
tooltip = f'{point["label"]}' + (f' ({dates})' if dates else '') + f' — {s["label"]}: {_display(value, unit)}'
|
|
320
|
+
if block.get('date_basis'):
|
|
321
|
+
tooltip += ' · ' + block['date_basis']
|
|
322
|
+
coverage=point.get('coverage')
|
|
323
|
+
if isinstance(coverage,dict) and 'valid_file_dates' in coverage:
|
|
324
|
+
tooltip += ' · 有效文件日期 '+str(coverage['valid_file_dates'])
|
|
325
|
+
svg.append(f'<circle cx="{xx:.2f}" cy="{yy:.2f}" r="4" fill="{color}"><title>{_esc(tooltip)}</title></circle>')
|
|
326
|
+
previous = (xx, yy)
|
|
327
|
+
svg.append('</svg>')
|
|
328
|
+
legend = ''.join(f'<span><i class="comparison-key" aria-hidden="true" style="background:{color}"></i>{_esc(s["label"])}</span>' for s, color in zip(series, colors))
|
|
329
|
+
headers = ['指标'] + [p['label'] for p in points]
|
|
330
|
+
rows=[]
|
|
331
|
+
if any(p.get('partial') for p in points):
|
|
332
|
+
rows.append(['期间范围(含首尾)'] + [
|
|
333
|
+
'不完整:' + ' 至 '.join(str(p.get(k) or '') for k in ('start', 'end')) if p.get('partial') else ''
|
|
334
|
+
for p in points])
|
|
335
|
+
for s in series:
|
|
336
|
+
rows.append([s['label']]+[_display(p.get('values',{}).get(s['key']),unit) for p in points])
|
|
337
|
+
if unit=='percent' and any(s['key'] in p.get('rate_details',{}) for p in points):
|
|
338
|
+
details=[p.get('rate_details',{}).get(s['key'],{}) for p in points]
|
|
339
|
+
for key,label in [('numerator','分子笔数'),('denominator','分母笔数')]:
|
|
340
|
+
rows.append([s['label']+' · '+label]+[_display(d.get(key),'count') for d in details])
|
|
341
|
+
rows.append([s['label']+' · 分母月份']+[str(d.get('denominator_start') or '')[:7] for d in details])
|
|
342
|
+
table = '<table class="comparison-table"><thead><tr>' + ''.join('<th scope="col">' + _esc(h) + '</th>' for h in headers) + '</tr></thead><tbody>'
|
|
343
|
+
table += ''.join('<tr><th scope="row">' + _esc(row[0]) + '</th>' + ''.join('<td>' + _esc(v) + '</td>' for v in row[1:]) + '</tr>' for row in rows) + '</tbody></table>'
|
|
344
|
+
note = '<p>' + _esc(block['status_note']) + '</p>' if block.get('status_note') else ''
|
|
345
|
+
if any(p.get('partial') for p in points):
|
|
346
|
+
note += '<p>* 不完整期间;具体日期见数据明细。</p>'
|
|
347
|
+
content = note + f'<div class="comparison-legend">{legend}</div>' + ''.join(svg) + '<details><summary>查看数据明细</summary>' + table + '</details>'
|
|
348
|
+
markdown = ''
|
|
349
|
+
if block.get('status_note'):
|
|
350
|
+
markdown += _md(block['status_note']) + '\n\n'
|
|
351
|
+
markdown += '| ' + ' | '.join(map(_md, headers)) + ' |\n| ' + ' | '.join('---' for _ in headers) + ' |\n'
|
|
352
|
+
markdown += '\n'.join('| ' + ' | '.join(map(_md, row)) + ' |' for row in rows)
|
|
353
|
+
return content, markdown, (note, legend, ''.join(svg), table)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _time_series_chart(block, identifier):
|
|
357
|
+
prefix = 'comparison-' + hashlib.sha256(str(identifier).encode('utf-8')).hexdigest()[:20]
|
|
358
|
+
title = _esc(block['title'])
|
|
359
|
+
if 'views' not in block:
|
|
360
|
+
_, md, (note, legend, svg, table) = _time_series_content(block)
|
|
361
|
+
return (f'<div class="comparison-chart" id="{prefix}" data-layout="time_series_chart"><h3>{title}</h3>{note}'
|
|
362
|
+
f'<div class="comparison-legend">{legend}</div><div data-comparison-plot tabindex="0">{svg}</div>'
|
|
363
|
+
f'<details><summary>查看数据明细</summary>{table}</details></div>',
|
|
364
|
+
'### ' + _md(block['title']) + '\n\n' + md)
|
|
365
|
+
|
|
366
|
+
views = block['views']
|
|
367
|
+
if not isinstance(views, list) or not views:
|
|
368
|
+
raise ValueError('time series views must be a non-empty list')
|
|
369
|
+
keys = [view.get('key') for view in views]
|
|
370
|
+
if any(not isinstance(key, str) or not re.fullmatch(r'[A-Za-z][A-Za-z0-9_-]*', key) for key in keys) or len(set(keys)) != len(keys):
|
|
371
|
+
raise ValueError('invalid or duplicate time series view key')
|
|
372
|
+
if block.get('default_view') not in keys:
|
|
373
|
+
raise ValueError('default time series view is not present')
|
|
374
|
+
selected = keys.index(block['default_view'])
|
|
375
|
+
rendered = [_time_series_content({**block, **view}) for view in views]
|
|
376
|
+
options = ''.join(f'<option value="{i}" data-key="{_esc(view["key"])}"' +
|
|
377
|
+
(' selected' if i == selected else '') + f'>{_esc(view["label"])}</option>'
|
|
378
|
+
for i, view in enumerate(views))
|
|
379
|
+
result = f'<div class="comparison-chart" id="{prefix}" data-layout="time_series_chart" data-metric="{_esc(keys[selected])}"><h3>{title}</h3>'
|
|
380
|
+
result += f'<label for="{prefix}-metric">选择指标</label> <select id="{prefix}-metric" aria-controls="{prefix}-plot">{options}</select>'
|
|
381
|
+
result += f'<div id="{prefix}-plot" data-comparison-plot tabindex="0">{rendered[selected][0]}</div>'
|
|
382
|
+
result += ''.join('<template>' + content + '</template>' for content, _, _ in rendered) + '</div>'
|
|
383
|
+
markdown = '### ' + _md(block['title']) + '\n\n' + '\n\n'.join(
|
|
384
|
+
'#### ' + _md(view['label']) + '\n\n' + md for view, (_, md, _) in zip(views, rendered))
|
|
385
|
+
return result, markdown
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def chart(block, identifier):
|
|
389
|
+
"""Return (HTML, Markdown) for a comparison_chart or time_series_chart block."""
|
|
390
|
+
if block.get('type') == 'time_series_chart':
|
|
391
|
+
return _time_series_chart(block, identifier)
|
|
392
|
+
metrics, series = block['metrics'], block['series']
|
|
393
|
+
if not metrics or len(series) != 2 or {s['key'] for s in series} != {'previous', 'current'}:
|
|
394
|
+
raise ValueError('comparison chart requires metrics and previous/current series')
|
|
395
|
+
if any(m['format'] not in ('count', 'percent', 'usd') for m in metrics):
|
|
396
|
+
raise ValueError('unsupported comparison metric format')
|
|
397
|
+
layout = block.get('layout')
|
|
398
|
+
if layout not in (None, 'paired_bars', 'histogram_pair', 'bars_pair', 'pie_pair'):
|
|
399
|
+
raise ValueError('unsupported comparison layout')
|
|
400
|
+
if layout in ('histogram_pair', 'pie_pair') and any(m['format'] == 'percent' for m in metrics):
|
|
401
|
+
raise ValueError('histogram and pie metrics must be counts or amounts')
|
|
402
|
+
selected = next((i for i, m in enumerate(metrics) if m['key'] == block['default_metric']), None)
|
|
403
|
+
if selected is None:
|
|
404
|
+
raise ValueError('default metric is not present')
|
|
405
|
+
prefix = 'comparison-' + hashlib.sha256(str(identifier).encode('utf-8')).hexdigest()[:20]
|
|
406
|
+
svgs = [_pie_plot(block, metric) if layout == 'pie_pair' else _compact_plot(block, metric, layout) if layout else _svg(block, metric) for metric in metrics]
|
|
407
|
+
options = ''.join(f'<option value="{i}" data-key="{_esc(m["key"])}"'+(' selected' if i == selected else '')+f'>{_esc(m["label"])}</option>' for i, m in enumerate(metrics))
|
|
408
|
+
legend = ''.join(f'<span><i aria-hidden="true" class="comparison-key comparison-{_esc(s["key"])}"></i>{_esc(s["label"])}</span>' for s in series)
|
|
409
|
+
if layout in ('histogram_pair','bars_pair','pie_pair'):legend=''
|
|
410
|
+
description = '<p>'+_esc(block['description'])+'</p>' if block.get('description') else ''
|
|
411
|
+
headers = ['分类', '指标'] + [s['label'] for s in series]
|
|
412
|
+
rows = [[c['label'], m['label']] + [_display(_value(c, s, m), m['format']) for s in series] for c in block['categories'] for m in metrics]
|
|
413
|
+
table = '<table class="comparison-table"><thead><tr>'+''.join('<th scope="col">'+_esc(h)+'</th>' for h in headers)+'</tr></thead><tbody>'
|
|
414
|
+
table += ''.join('<tr><th scope="row">'+_esc(row[0])+'</th>'+''.join('<td>'+_esc(v)+'</td>' for v in row[1:])+'</tr>' for row in rows)+'</tbody></table>'
|
|
415
|
+
compact_class = ' comparison-compact' if layout else ''
|
|
416
|
+
result = f'<div class="comparison-chart{compact_class}" id="{prefix}" data-layout="{_esc(layout or "default")}" data-metric="{_esc(metrics[selected]["key"])}"><h3>{_esc(block["title"])}</h3>{description}'
|
|
417
|
+
result += f'<label for="{prefix}-metric">选择指标</label> <select id="{prefix}-metric" aria-controls="{prefix}-plot">{options}</select><div class="comparison-legend">{legend}</div>'
|
|
418
|
+
result += f'<div id="{prefix}-plot" data-comparison-plot tabindex="0" aria-label="可横向滚动的对比图">{svgs[selected]}</div>' + ''.join('<template>'+svg+'</template>' for svg in svgs)
|
|
419
|
+
result += '<details><summary>查看数据明细</summary>'+table+'</details></div>'
|
|
420
|
+
markdown = '### '+_md(block['title'])+'\n\n'
|
|
421
|
+
if block.get('description'):
|
|
422
|
+
markdown += _md(block['description'])+'\n\n'
|
|
423
|
+
markdown += '| '+' | '.join(map(_md, headers))+' |\n| '+' | '.join('---' for _ in headers)+' |\n'
|
|
424
|
+
markdown += '\n'.join('| '+' | '.join(map(_md, row))+' |' for row in rows)
|
|
425
|
+
return result, markdown
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Spreadsheet-safe CSV writers.
|
|
2
|
+
|
|
3
|
+
Text beginning with a formula marker or whitespace gets a leading apostrophe.
|
|
4
|
+
The apostrophe is part of the CSV value; JSON and source data remain unchanged.
|
|
5
|
+
Numeric strings are only exempted in explicitly designated numeric columns, after
|
|
6
|
+
strict validation. Never designate identifiers or other source text as numeric.
|
|
7
|
+
"""
|
|
8
|
+
import csv
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
_NUMERIC = re.compile(r'-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\Z')
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _cell(value, numeric=False):
|
|
15
|
+
if isinstance(value, str):
|
|
16
|
+
if numeric and _NUMERIC.fullmatch(value):
|
|
17
|
+
return value
|
|
18
|
+
if value and (value[0] in '=+-@' or value[0].isspace()):
|
|
19
|
+
return "'" + value
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _SafeWriter:
|
|
24
|
+
def __init__(self, writer, numeric_columns, dictionary=False):
|
|
25
|
+
self._writer = writer
|
|
26
|
+
self._numeric_columns = frozenset(numeric_columns)
|
|
27
|
+
self._dictionary = dictionary
|
|
28
|
+
|
|
29
|
+
def writerow(self, row):
|
|
30
|
+
if self._dictionary:
|
|
31
|
+
return self._writer.writerow({key: _cell(value, key in self._numeric_columns)
|
|
32
|
+
for key, value in row.items()})
|
|
33
|
+
return self._writer.writerow([_cell(value, index in self._numeric_columns)
|
|
34
|
+
for index, value in enumerate(row)])
|
|
35
|
+
|
|
36
|
+
def writerows(self, rows):
|
|
37
|
+
for row in rows:
|
|
38
|
+
self.writerow(row)
|
|
39
|
+
|
|
40
|
+
def writeheader(self):
|
|
41
|
+
# DictWriter's header contains field names, which are text, not numbers.
|
|
42
|
+
return self._writer.writerow({key: _cell(key) for key in self._writer.fieldnames})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def safe_writer(handle, *, numeric_columns=(), **kwargs):
|
|
46
|
+
"""Like csv.writer; numeric_columns contains zero-based numeric column indices."""
|
|
47
|
+
return _SafeWriter(csv.writer(handle, **kwargs), numeric_columns)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def safe_dict_writer(handle, fieldnames, *, numeric_columns=(), **kwargs):
|
|
51
|
+
"""Like csv.DictWriter; numeric_columns contains numeric field names."""
|
|
52
|
+
return _SafeWriter(csv.DictWriter(handle, fieldnames, **kwargs), numeric_columns, True)
|