@stacksjs/defaults 0.75.360 → 0.75.362
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/ai/skills/stacks-ai/SKILL.md +22 -0
- package/ai/skills/stacks-native/CATALOG.md +4 -4
- package/ai/skills/stacks-payments/SKILL.md +28 -0
- package/ai/skills/stacks-technical-diagrams/LICENSE +202 -22
- package/ai/skills/stacks-technical-diagrams/LICENSE-MIT +22 -0
- package/ai/skills/stacks-technical-diagrams/NOTICE +26 -0
- package/ai/skills/stacks-technical-diagrams/SKILL.md +106 -266
- package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +11 -2
- package/ai/skills/stacks-technical-diagrams/UPSTREAM.json +12 -0
- package/ai/skills/stacks-technical-diagrams/agents/openai.yaml +2 -2
- package/ai/skills/stacks-technical-diagrams/assets/template.html +3655 -3798
- package/ai/skills/stacks-technical-diagrams/bin/archify.mjs +7290 -0
- package/ai/skills/stacks-technical-diagrams/bin/delivery-update.mjs +7 -0
- package/ai/skills/stacks-technical-diagrams/bin/diagrams +1 -0
- package/ai/skills/stacks-technical-diagrams/bin/finalize.mjs +1056 -0
- package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +72 -12
- package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +348 -31
- package/ai/skills/stacks-technical-diagrams/bin/recover-output.mjs +41 -0
- package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +3 -2097
- package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +2142 -157
- package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +3 -3
- package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +130 -33
- package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +25 -30
- package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +0 -20
- package/ai/skills/stacks-technical-diagrams/examples/archify-dev-activity.timeline.json +176 -0
- package/ai/skills/stacks-technical-diagrams/examples/archify-repository.tree.json +197 -0
- package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +3 -16
- package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +2 -7
- package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +2 -1
- package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +2 -1
- package/ai/skills/stacks-technical-diagrams/examples/checkout-request.waterfall.json +20 -0
- package/ai/skills/stacks-technical-diagrams/examples/class-payment-processors-rendered.html +14987 -0
- package/ai/skills/stacks-technical-diagrams/examples/class-payments-rendered.html +14787 -0
- package/ai/skills/stacks-technical-diagrams/examples/dataflow-product-analytics.html +14880 -0
- package/ai/skills/stacks-technical-diagrams/examples/delivery.explainer.json +155 -0
- package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +57 -34
- package/ai/skills/stacks-technical-diagrams/examples/erd-orders-rendered.html +15112 -0
- package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/example-rebuild.waterfall.json +212 -0
- package/ai/skills/stacks-technical-diagrams/examples/holiday-planning.workflow.json +143 -0
- package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/lifecycle-agent-run.html +14892 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/de.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/fr.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/fr.partial.json +22 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/it.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/ja.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/pt.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/ru.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/orders.erd.json +337 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-incident.timeline.json +24 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-platform.tree.json +22 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-processors.class.json +177 -0
- package/ai/skills/stacks-technical-diagrams/examples/payments.class.json +85 -0
- package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +3 -23
- package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/sequence-cache-miss-request.html +14895 -0
- package/ai/skills/stacks-technical-diagrams/examples/subscription-billing-rendered.html +15369 -0
- package/ai/skills/stacks-technical-diagrams/examples/subscription-billing.erd.json +548 -0
- package/ai/skills/stacks-technical-diagrams/examples/timeline-archify-dev-activity-rendered.html +15002 -0
- package/ai/skills/stacks-technical-diagrams/examples/timeline-payment-incident-rendered.html +14772 -0
- package/ai/skills/stacks-technical-diagrams/examples/tree-archify-repository-rendered.html +15071 -0
- package/ai/skills/stacks-technical-diagrams/examples/tree-payment-platform-rendered.html +14810 -0
- package/ai/skills/stacks-technical-diagrams/examples/waterfall-checkout-request-rendered.html +14754 -0
- package/ai/skills/stacks-technical-diagrams/examples/waterfall-example-rebuild-rendered.html +14940 -0
- package/ai/skills/stacks-technical-diagrams/examples/web-app-rendered.html +14889 -0
- package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/workflow-agent-tool-call-rendered.html +14907 -0
- package/ai/skills/stacks-technical-diagrams/explainer/browser-check.mjs +208 -0
- package/ai/skills/stacks-technical-diagrams/explainer/charts.mjs +70 -0
- package/ai/skills/stacks-technical-diagrams/explainer/check.mjs +168 -0
- package/ai/skills/stacks-technical-diagrams/explainer/diagrams.mjs +518 -0
- package/ai/skills/stacks-technical-diagrams/explainer/diff.mjs +124 -0
- package/ai/skills/stacks-technical-diagrams/explainer/inline.mjs +155 -0
- package/ai/skills/stacks-technical-diagrams/explainer/layout.mjs +35 -0
- package/ai/skills/stacks-technical-diagrams/explainer/page-bundle.mjs +16 -0
- package/ai/skills/stacks-technical-diagrams/explainer/parse.mjs +300 -0
- package/ai/skills/stacks-technical-diagrams/explainer/patch.mjs +117 -0
- package/ai/skills/stacks-technical-diagrams/explainer/prepare.mjs +131 -0
- package/ai/skills/stacks-technical-diagrams/explainer/reader.css +472 -0
- package/ai/skills/stacks-technical-diagrams/explainer/reader.js +667 -0
- package/ai/skills/stacks-technical-diagrams/explainer/render.mjs +224 -0
- package/ai/skills/stacks-technical-diagrams/explainer/revision.mjs +20 -0
- package/ai/skills/stacks-technical-diagrams/explainer/sources.mjs +318 -0
- package/ai/skills/stacks-technical-diagrams/explainer/validate.mjs +138 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/LICENSE +40 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/README.md +5 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/42UTT654.js +3 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/core.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/pie.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/radar.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/sankey.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/venn.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/xychart.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/writing.mjs +139 -0
- package/ai/skills/stacks-technical-diagrams/locales/README.md +40 -0
- package/ai/skills/stacks-technical-diagrams/locales/ar.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/en.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/es.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/ko.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/manifest.json +10 -0
- package/ai/skills/stacks-technical-diagrams/locales/zh-CN.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/zh-TW.json +488 -0
- package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +1 -0
- package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +115 -51
- package/ai/skills/stacks-technical-diagrams/references/architecture-layout-repair.md +18 -0
- package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +453 -51
- package/ai/skills/stacks-technical-diagrams/references/authoring-defaults.md +57 -0
- package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +57 -3
- package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +603 -58
- package/ai/skills/stacks-technical-diagrams/references/explainer-authoring.md +274 -0
- package/ai/skills/stacks-technical-diagrams/references/repository-authoring.md +126 -0
- package/ai/skills/stacks-technical-diagrams/references/update-awareness.md +13 -0
- package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +18 -11
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/grid.mjs +1 -1
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/labels.mjs +2 -0
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +550 -424
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/routing.mjs +1469 -0
- package/ai/skills/stacks-technical-diagrams/renderers/class/README.md +129 -0
- package/ai/skills/stacks-technical-diagrams/renderers/class/render-class.mjs +1095 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +89 -17
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/dataflow-compiler.mjs +1605 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/joint-label-routing.mjs +165 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/label-capacity.mjs +81 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +14 -474
- package/ai/skills/stacks-technical-diagrams/renderers/erd/README.md +246 -0
- package/ai/skills/stacks-technical-diagrams/renderers/erd/grid.mjs +150 -0
- package/ai/skills/stacks-technical-diagrams/renderers/erd/render-erd.mjs +1588 -0
- package/ai/skills/stacks-technical-diagrams/renderers/explainer/render-explainer.mjs +42 -0
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +83 -94
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +990 -417
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +49 -17
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +735 -94
- package/ai/skills/stacks-technical-diagrams/renderers/shared/atomic-output.mjs +1982 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/automatic-labels.mjs +290 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +169 -26
- package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +266 -54
- package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +106 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +0 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +268 -2
- package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +774 -124
- package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +289 -556
- package/ai/skills/stacks-technical-diagrams/renderers/shared/layout-report.mjs +18 -1
- package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +16 -6
- package/ai/skills/stacks-technical-diagrams/renderers/shared/node-card-style.mjs +32 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +329 -229
- package/ai/skills/stacks-technical-diagrams/renderers/shared/owned-temp-directory.mjs +82 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/path-semantics.mjs +1017 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/portable-path.mjs +408 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +235 -79
- package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +17 -4
- package/ai/skills/stacks-technical-diagrams/renderers/shared/route-quality.mjs +670 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/sidecar-path.mjs +38 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/software-notices.mjs +6 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/spatial-grid.mjs +66 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/svg-path-analysis.mjs +135 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +59 -3
- package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +63 -35
- package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +2 -5
- package/ai/skills/stacks-technical-diagrams/renderers/timeline/README.md +76 -0
- package/ai/skills/stacks-technical-diagrams/renderers/timeline/render-timeline.mjs +576 -0
- package/ai/skills/stacks-technical-diagrams/renderers/tree/README.md +96 -0
- package/ai/skills/stacks-technical-diagrams/renderers/tree/render-tree.mjs +573 -0
- package/ai/skills/stacks-technical-diagrams/renderers/waterfall/README.md +73 -0
- package/ai/skills/stacks-technical-diagrams/renderers/waterfall/render-waterfall.mjs +413 -0
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -23
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +18 -6
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +2230 -710
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +17 -0
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-placement-repair.mjs +94 -0
- package/ai/skills/stacks-technical-diagrams/schemas/README.md +87 -40
- package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +7 -33
- package/ai/skills/stacks-technical-diagrams/schemas/class.schema.json +345 -0
- package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +68 -2
- package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +14 -3
- package/ai/skills/stacks-technical-diagrams/schemas/erd.schema.json +381 -0
- package/ai/skills/stacks-technical-diagrams/schemas/explainer.schema.json +927 -0
- package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +23 -110
- package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +15 -4
- package/ai/skills/stacks-technical-diagrams/schemas/timeline.schema.json +160 -0
- package/ai/skills/stacks-technical-diagrams/schemas/tree.schema.json +141 -0
- package/ai/skills/stacks-technical-diagrams/schemas/waterfall.schema.json +147 -0
- package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +14 -3
- package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +876 -243
- package/ai/skills/stacks-technical-diagrams/scripts/check-update.mjs +1717 -0
- package/ai/skills/stacks-technical-diagrams/scripts/delivery-update-child.mjs +26 -0
- package/ai/skills/stacks-technical-diagrams/scripts/generate-brand-marks.mjs +141 -0
- package/ai/skills/stacks-technical-diagrams/scripts/generate-validators.mjs +105 -0
- package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +11 -1
- package/ai/skills/stacks-technical-diagrams/scripts/update-contract.mjs +187 -0
- package/ai/skills/stacks-technical-diagrams/skill-release.json +10 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { esc, renderDefinitions, textUnits } from '../shared/utils.mjs';
|
|
4
|
+
import {
|
|
5
|
+
animateAttr,
|
|
6
|
+
focusNodeAttrs,
|
|
7
|
+
focusNodeTitle,
|
|
8
|
+
loadDiagram,
|
|
9
|
+
svgAccessibleText,
|
|
10
|
+
svgRootAttrs,
|
|
11
|
+
writeDiagram,
|
|
12
|
+
} from '../shared/cli.mjs';
|
|
13
|
+
import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
|
|
14
|
+
import { legendFootprint, measureLegend, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
|
|
15
|
+
import { nodeTextFit } from '../shared/text-fit.mjs';
|
|
16
|
+
import { translateMessage as i18nText } from '../shared/i18n.mjs';
|
|
17
|
+
import { asArray } from '../shared/geometry.mjs';
|
|
18
|
+
|
|
19
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const layoutJsonMode = process.argv.includes('--layout-json');
|
|
21
|
+
const cliArgs = process.argv.filter((arg) => arg !== '--layout-json');
|
|
22
|
+
const { diagram: tl, template, outPath, sourceEvidence } = loadDiagram({
|
|
23
|
+
rendererDir: __dirname,
|
|
24
|
+
diagramType: 'timeline',
|
|
25
|
+
defaultExample: 'payment-incident.timeline.json',
|
|
26
|
+
argv: cliArgs,
|
|
27
|
+
});
|
|
28
|
+
const locale = tl.meta.locale;
|
|
29
|
+
const ADVANCE = nodeTextFit.widthFactor;
|
|
30
|
+
const MINUTE = 60_000;
|
|
31
|
+
const HOUR = 60 * MINUTE;
|
|
32
|
+
const DAY = 24 * HOUR;
|
|
33
|
+
|
|
34
|
+
const layout = {
|
|
35
|
+
margin: 28,
|
|
36
|
+
top: 52,
|
|
37
|
+
axisH: 60,
|
|
38
|
+
// The lane column fits its longest label (13px bold), so a lane name never
|
|
39
|
+
// runs into the axis.
|
|
40
|
+
laneLabelW: asArray(tl.lanes).length
|
|
41
|
+
? Math.ceil(Math.max(88, ...tl.lanes.map((lane) => textUnits(lane.label) * 13 * nodeTextFit.widthFactor * 1.06 + 24)))
|
|
42
|
+
: 0,
|
|
43
|
+
axisWidth: tl.layout?.width ?? 960,
|
|
44
|
+
edgePad: 72,
|
|
45
|
+
breakW: 60,
|
|
46
|
+
laneGap: 10,
|
|
47
|
+
lanePad: 14,
|
|
48
|
+
// Room between the lane's time line and the nearest card on either side,
|
|
49
|
+
// which the stem crosses.
|
|
50
|
+
stemGap: 16,
|
|
51
|
+
rowGap: 8,
|
|
52
|
+
cardMaxW: 200,
|
|
53
|
+
cardPadX: 10,
|
|
54
|
+
cardAccent: 12,
|
|
55
|
+
titleFont: 12.5,
|
|
56
|
+
titleLine: 15,
|
|
57
|
+
timeFont: 10.5,
|
|
58
|
+
minTickPx: 84,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ---- Input checks --------------------------------------------------------------
|
|
62
|
+
// Time is the one fact this diagram draws, so every timestamp must parse and
|
|
63
|
+
// state its own offset (the schema requires Z or ±HH:MM); the display zone must
|
|
64
|
+
// be a real IANA zone, and every event must sit in a declared lane.
|
|
65
|
+
// Date.parse normalizes some impossible dates (February 30 becomes March 2).
|
|
66
|
+
// Check the authored calendar date independently of its UTC offset first.
|
|
67
|
+
function validCalendarDate(timestamp) {
|
|
68
|
+
const [year, month, day] = timestamp.slice(0, 10).split('-').map(Number);
|
|
69
|
+
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
70
|
+
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
71
|
+
return month >= 1 && month <= 12 && day >= 1 && day <= days[month - 1];
|
|
72
|
+
}
|
|
73
|
+
const timezone = tl.meta.timezone || 'UTC';
|
|
74
|
+
{
|
|
75
|
+
const problems = [];
|
|
76
|
+
const details = [];
|
|
77
|
+
const fail = (code, message, subject, evidence, supportedFixes) => {
|
|
78
|
+
problems.push(message);
|
|
79
|
+
details.push({ code, severity: 'error', message, subject: { diagramType: 'timeline', ...subject }, evidence, supportedFixes });
|
|
80
|
+
};
|
|
81
|
+
try {
|
|
82
|
+
new Intl.DateTimeFormat('en-US', { timeZone: timezone });
|
|
83
|
+
} catch {
|
|
84
|
+
fail('timeline/invalid-timezone', `meta.timezone "${timezone}" is not a recognised IANA time zone.`,
|
|
85
|
+
{ path: '/meta/timezone' }, { timezone }, ['Use an IANA zone such as "UTC", "Europe/Berlin", or "Asia/Shanghai".']);
|
|
86
|
+
}
|
|
87
|
+
const laneIds = new Set();
|
|
88
|
+
for (const [index, lane] of asArray(tl.lanes).entries()) {
|
|
89
|
+
if (laneIds.has(lane.id)) fail('timeline/duplicate-lane-id', `Lane id "${lane.id}" is declared twice.`,
|
|
90
|
+
{ path: `/lanes/${index}/id` }, { id: lane.id }, ['Give every lane a unique id and update its event references.']);
|
|
91
|
+
laneIds.add(lane.id);
|
|
92
|
+
}
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
for (const [index, event] of asArray(tl.events).entries()) {
|
|
95
|
+
if (seen.has(event.id)) fail('timeline/duplicate-id', `Event id "${event.id}" is declared twice.`, { path: `/events/${index}/id` }, { id: event.id }, ['Give every event a unique id.']);
|
|
96
|
+
seen.add(event.id);
|
|
97
|
+
if (!validCalendarDate(event.at) || !Number.isFinite(Date.parse(event.at))) {
|
|
98
|
+
fail('timeline/invalid-timestamp', `Event "${event.id}" timestamp "${event.at}" is not a valid date and time.`,
|
|
99
|
+
{ path: `/events/${index}/at`, nodeId: event.id }, { at: event.at }, ['Use ISO 8601 with an explicit offset, e.g. "2026-10-01T10:05:00+08:00".']);
|
|
100
|
+
}
|
|
101
|
+
if (laneIds.size && !laneIds.has(event.lane)) {
|
|
102
|
+
fail('timeline/unknown-lane', event.lane === undefined
|
|
103
|
+
? `Event "${event.id}" has no lane, but the timeline declares lanes.`
|
|
104
|
+
: `Event "${event.id}" names lane "${event.lane}", which is not declared.`,
|
|
105
|
+
{ path: `/events/${index}/lane`, nodeId: event.id }, { lane: event.lane ?? null, lanes: [...laneIds] },
|
|
106
|
+
['Set lane to one of the declared lane ids.', 'Declare the missing lane.']);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (problems.length) throwDiagnosticProblems('Timeline validation failed', problems, { code: 'timeline/input', subject: { diagramType: 'timeline' }, diagnostics: details });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- Time zone formatting ------------------------------------------------------
|
|
113
|
+
const partsFormat = new Intl.DateTimeFormat('en-US', {
|
|
114
|
+
timeZone: timezone, hourCycle: 'h23', era: 'short', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
115
|
+
});
|
|
116
|
+
function wall(ms) {
|
|
117
|
+
const parts = Object.fromEntries(partsFormat.formatToParts(new Date(ms)).map((part) => [part.type, part.value]));
|
|
118
|
+
return { y: parts.era === 'BC' ? String(1 - Number(parts.year)) : parts.year, mo: parts.month, d: parts.day, h: parts.hour, mi: parts.minute, s: parts.second };
|
|
119
|
+
}
|
|
120
|
+
// Offset of the display zone at an instant, in ms (local wall clock - UTC).
|
|
121
|
+
function zoneOffset(ms) {
|
|
122
|
+
const w = wall(ms);
|
|
123
|
+
const local = new Date(0);
|
|
124
|
+
local.setUTCFullYear(+w.y, +w.mo - 1, +w.d);
|
|
125
|
+
local.setUTCHours(+w.h, +w.mi, +w.s, 0);
|
|
126
|
+
return local.getTime() - Math.floor(ms / 1000) * 1000;
|
|
127
|
+
}
|
|
128
|
+
function offsetLabel(ms) {
|
|
129
|
+
const minutes = Math.round(zoneOffset(ms) / MINUTE);
|
|
130
|
+
const sign = minutes < 0 ? '-' : '+';
|
|
131
|
+
const abs = Math.abs(minutes);
|
|
132
|
+
return `UTC${sign}${String(Math.floor(abs / 60)).padStart(2, '0')}:${String(abs % 60).padStart(2, '0')}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- Events in time order -------------------------------------------------------
|
|
136
|
+
const events = asArray(tl.events)
|
|
137
|
+
.map((event, index) => ({ ...event, index, t: Date.parse(event.at) }))
|
|
138
|
+
.sort((a, b) => a.t - b.t || a.index - b.index);
|
|
139
|
+
const times = [...new Set(events.map((event) => event.t))];
|
|
140
|
+
const first = times[0];
|
|
141
|
+
const last = times.at(-1);
|
|
142
|
+
const span = last - first;
|
|
143
|
+
|
|
144
|
+
const showSeconds = events.some((event) => new Date(event.t).getUTCSeconds() !== 0 || new Date(event.t).getUTCMilliseconds() !== 0);
|
|
145
|
+
const multiDay = (() => {
|
|
146
|
+
const a = wall(first);
|
|
147
|
+
const b = wall(last);
|
|
148
|
+
return a.y !== b.y || a.mo !== b.mo || a.d !== b.d;
|
|
149
|
+
})();
|
|
150
|
+
function clockLabel(ms, { seconds = showSeconds, date = multiDay } = {}) {
|
|
151
|
+
const w = wall(ms);
|
|
152
|
+
const fraction = new Date(ms).getUTCMilliseconds();
|
|
153
|
+
return `${date ? `${w.mo}-${w.d} ` : ''}${w.h}:${w.mi}${seconds ? `:${w.s}${fraction ? `.${String(fraction).padStart(3, '0')}` : ''}` : ''}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function durationLabel(ms) {
|
|
157
|
+
const units = [['d', DAY], ['h', HOUR], ['min', MINUTE], ['s', 1000]];
|
|
158
|
+
const parts = [];
|
|
159
|
+
let rest = ms;
|
|
160
|
+
for (const [unit, size] of units) {
|
|
161
|
+
const value = Math.floor(rest / size);
|
|
162
|
+
if (value) parts.push(`${value} ${unit}`);
|
|
163
|
+
rest -= value * size;
|
|
164
|
+
if (parts.length === 2) break;
|
|
165
|
+
}
|
|
166
|
+
return parts.join(' ') || '0 s';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The visible break label is approximate (it carries ≈), so it rounds to the
|
|
170
|
+
// two most significant adjacent units: "15 h", never "15 h 3 s".
|
|
171
|
+
function approximateDuration(ms) {
|
|
172
|
+
const units = [['d', DAY], ['h', HOUR], ['min', MINUTE], ['s', 1000]];
|
|
173
|
+
const index = units.findIndex(([, size]) => ms >= size);
|
|
174
|
+
if (index === -1) return '0 s';
|
|
175
|
+
const [major, majorSize] = units[index];
|
|
176
|
+
if (index === units.length - 1) return `${Math.round(ms / majorSize)} ${major}`;
|
|
177
|
+
const [minor, minorSize] = units[index + 1];
|
|
178
|
+
let whole = Math.floor(ms / majorSize);
|
|
179
|
+
let rest = Math.round((ms - whole * majorSize) / minorSize);
|
|
180
|
+
if (rest * minorSize >= majorSize) { whole += 1; rest = 0; }
|
|
181
|
+
return rest ? `${whole} ${major} ${rest} ${minor}` : `${whole} ${major}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---- Axis segments and disclosed breaks ----------------------------------------
|
|
185
|
+
// A quiet period much longer than the timeline's ordinary rhythm would squeeze
|
|
186
|
+
// every other event into a sliver. With `layout.breaks: "auto"` (the default)
|
|
187
|
+
// such a gap is drawn as a fixed-width break that states the omitted duration,
|
|
188
|
+
// so the axis stays proportional inside each segment and never hides that it
|
|
189
|
+
// was compressed. A gap is a break when it is longer than eight times the
|
|
190
|
+
// median gap and longer than a tenth of the whole span; at most eight are
|
|
191
|
+
// drawn (the longest), so bursts of activity get readable room.
|
|
192
|
+
const gaps = times.slice(1).map((time, index) => ({ from: times[index], to: time, length: time - times[index] }));
|
|
193
|
+
const sortedGaps = gaps.map((gap) => gap.length).sort((a, b) => a - b);
|
|
194
|
+
const medianGap = sortedGaps.length ? sortedGaps[Math.floor((sortedGaps.length - 1) / 2)] : 0;
|
|
195
|
+
const SEGMENT_FLOOR = 28;
|
|
196
|
+
const candidates = tl.layout?.breaks === 'none' ? [] : gaps
|
|
197
|
+
.filter((gap) => gap.length > 8 * medianGap && gap.length > 0.1 * span)
|
|
198
|
+
.sort((a, b) => b.length - a.length)
|
|
199
|
+
.slice(0, 8);
|
|
200
|
+
|
|
201
|
+
// Reserve room for proportional time before accepting a break. If fixed-width
|
|
202
|
+
// labels would exhaust the authored axis, leave shorter quiet periods on the
|
|
203
|
+
// linear scale instead of collapsing all distinct instants to one position.
|
|
204
|
+
const breaks = [];
|
|
205
|
+
let fixedWidth = layout.edgePad * 2 + SEGMENT_FLOOR;
|
|
206
|
+
for (const gap of candidates) {
|
|
207
|
+
gap.label = `≈ ${approximateDuration(gap.length)}`;
|
|
208
|
+
gap.width = Math.max(layout.breakW, Math.ceil(textUnits(gap.label) * 10.5 * ADVANCE + 18));
|
|
209
|
+
const addedWidth = gap.width + SEGMENT_FLOOR;
|
|
210
|
+
if (fixedWidth + addedWidth + layout.minTickPx > layout.axisWidth) continue;
|
|
211
|
+
breaks.push(gap);
|
|
212
|
+
fixedWidth += addedWidth;
|
|
213
|
+
}
|
|
214
|
+
breaks.sort((a, b) => a.from - b.from);
|
|
215
|
+
|
|
216
|
+
const segments = [];
|
|
217
|
+
{
|
|
218
|
+
let start = first;
|
|
219
|
+
for (const gap of breaks) {
|
|
220
|
+
segments.push({ start, end: gap.from });
|
|
221
|
+
start = gap.to;
|
|
222
|
+
}
|
|
223
|
+
segments.push({ start, end: last });
|
|
224
|
+
}
|
|
225
|
+
const linearTime = segments.reduce((sum, segment) => sum + (segment.end - segment.start), 0);
|
|
226
|
+
const axisX0 = layout.margin + layout.laneLabelW;
|
|
227
|
+
// A break is as wide as its duration label; every segment gets a small floor
|
|
228
|
+
// width so a lone event between two breaks never shares their x, and the rest
|
|
229
|
+
// of the axis is shared in proportion to time. Inside a segment the scale is
|
|
230
|
+
// one constant, so distances there are exact.
|
|
231
|
+
const drawable = layout.axisWidth - layout.edgePad * 2
|
|
232
|
+
- breaks.reduce((sum, gap) => sum + gap.width, 0) - SEGMENT_FLOOR * segments.length;
|
|
233
|
+
const scale = linearTime > 0 ? Math.max(0, drawable) / linearTime : 0;
|
|
234
|
+
{
|
|
235
|
+
let x = axisX0 + layout.edgePad;
|
|
236
|
+
const share = linearTime > 0 ? 0 : Math.max(0, drawable) / segments.length;
|
|
237
|
+
for (const [index, segment] of segments.entries()) {
|
|
238
|
+
const inner = (segment.end - segment.start) * scale + share;
|
|
239
|
+
segment.x0 = x + SEGMENT_FLOOR / 2 + (segment.end === segment.start ? inner / 2 : 0);
|
|
240
|
+
segment.x1 = segment.end === segment.start ? segment.x0 : segment.x0 + inner;
|
|
241
|
+
x += SEGMENT_FLOOR + inner;
|
|
242
|
+
if (index < breaks.length) {
|
|
243
|
+
breaks[index].x0 = x;
|
|
244
|
+
breaks[index].x1 = x + breaks[index].width;
|
|
245
|
+
x += breaks[index].width;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const axisX1 = axisX0 + layout.axisWidth;
|
|
250
|
+
|
|
251
|
+
function xOf(time) {
|
|
252
|
+
const segment = segments.find((candidate) => time >= candidate.start && time <= candidate.end) ?? segments[0];
|
|
253
|
+
return Math.round((segment.x0 + (time - segment.start) * scale) * 100) / 100;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Ticks fall on the display zone's wall clock, so a +05:30 zone still ticks
|
|
257
|
+
// on its own whole hours.
|
|
258
|
+
const TICKS = [1000, 2000, 5000, 10_000, 15_000, 30_000, MINUTE, 2 * MINUTE, 5 * MINUTE, 10 * MINUTE, 15 * MINUTE, 30 * MINUTE,
|
|
259
|
+
HOUR, 2 * HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, DAY, 2 * DAY, 7 * DAY];
|
|
260
|
+
|
|
261
|
+
function wallClockTicks(start, end, interval) {
|
|
262
|
+
// 日探测只收集时区偏移,不把一天当成固定长度的当地日历日。
|
|
263
|
+
// 即使时间段两端的偏移相同,也不能漏掉段内先进入、再退出的夏令时。
|
|
264
|
+
const offsets = new Set([zoneOffset(start), zoneOffset(end)]);
|
|
265
|
+
for (let probe = start + DAY; probe < end; probe += DAY) offsets.add(zoneOffset(probe));
|
|
266
|
+
const candidates = new Set();
|
|
267
|
+
for (const offset of offsets) {
|
|
268
|
+
const firstTick = Math.ceil((start + offset) / interval) * interval - offset;
|
|
269
|
+
for (let t = firstTick; t <= end; t += interval) {
|
|
270
|
+
// 同一个墙钟时刻可能对应两个真实瞬间,也可能不存在。按实际偏移回查,
|
|
271
|
+
// 保留回拨小时的两个刻度,并排除跳时或跳日期产生的虚构时刻。
|
|
272
|
+
if (offsets.size === 1 || zoneOffset(t) === offset) candidates.add(t);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return [...candidates].sort((left, right) => left - right);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const ticks = [];
|
|
279
|
+
for (const segment of segments) {
|
|
280
|
+
const length = segment.end - segment.start;
|
|
281
|
+
if (length <= 0 || scale <= 0) {
|
|
282
|
+
ticks.push({ t: segment.start, x: segment.x0 });
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const interval = TICKS.find((candidate) => candidate * scale >= layout.minTickPx) ?? TICKS.at(-1);
|
|
286
|
+
const before = ticks.length;
|
|
287
|
+
for (const t of wallClockTicks(segment.start, segment.end, interval)) ticks.push({ t, x: xOf(t), interval });
|
|
288
|
+
// A segment too short for a regular tick still states where it starts.
|
|
289
|
+
if (ticks.length === before) ticks.push({ t: segment.start, x: segment.x0, interval });
|
|
290
|
+
}
|
|
291
|
+
const tickSeconds = ticks.some((tick) => tick.interval < MINUTE);
|
|
292
|
+
|
|
293
|
+
// ---- Lanes and event cards ------------------------------------------------------
|
|
294
|
+
const lanes = asArray(tl.lanes).length ? tl.lanes : [{ id: '__all', label: '' }];
|
|
295
|
+
const laneOf = (event) => (asArray(tl.lanes).length ? event.lane : '__all');
|
|
296
|
+
|
|
297
|
+
function wrapTitle(text, maxWidth) {
|
|
298
|
+
const limit = Math.max(6, Math.floor(maxWidth / (layout.titleFont * ADVANCE * 1.05)));
|
|
299
|
+
const words = String(text).split(/(\s+)/);
|
|
300
|
+
const lines = [];
|
|
301
|
+
let current = '';
|
|
302
|
+
for (const word of words) {
|
|
303
|
+
if (textUnits((current + word).trim()) <= limit) { current += word; continue; }
|
|
304
|
+
if (current.trim()) lines.push(current.trim());
|
|
305
|
+
current = word.trimStart();
|
|
306
|
+
while (textUnits(current) > limit) {
|
|
307
|
+
const chars = Array.from(current);
|
|
308
|
+
let cut = 0;
|
|
309
|
+
for (let units = 0; cut < chars.length && units + textUnits(chars[cut]) <= limit; cut += 1) units += textUnits(chars[cut]);
|
|
310
|
+
lines.push(chars.slice(0, Math.max(1, cut)).join(''));
|
|
311
|
+
current = chars.slice(Math.max(1, cut)).join('');
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (current.trim()) lines.push(current.trim());
|
|
315
|
+
return lines;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const cards = events.map((event) => {
|
|
319
|
+
const time = clockLabel(event.t);
|
|
320
|
+
const padLeft = layout.cardAccent + 2;
|
|
321
|
+
const natural = Math.max(textUnits(event.title) * layout.titleFont * ADVANCE * 1.05, textUnits(time) * layout.timeFont * ADVANCE);
|
|
322
|
+
const width = Math.ceil(Math.min(layout.cardMaxW, natural + padLeft + layout.cardPadX));
|
|
323
|
+
const lines = wrapTitle(event.title, width - padLeft - layout.cardPadX);
|
|
324
|
+
const height = 9 + 12 + lines.length * layout.titleLine + 7;
|
|
325
|
+
const x = xOf(event.t);
|
|
326
|
+
// The card is centred on its instant but never leaves the axis area.
|
|
327
|
+
const left = Math.min(Math.max(x - width / 2, axisX0 + 4), axisX1 - width - 4);
|
|
328
|
+
return { ...event, time, width, height, lines, x, left, padLeft };
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Within a lane, cards sit on either side of the lane's time line and pack
|
|
332
|
+
// into rows outward from it: a card takes whichever side lets it sit closest
|
|
333
|
+
// to the line without touching an earlier card, so simultaneous and close
|
|
334
|
+
// events stack instead of overprinting, and a burst spreads both ways rather
|
|
335
|
+
// than growing one tall column. Ties go below the line. The dot always stays
|
|
336
|
+
// on the line, at the event's exact instant.
|
|
337
|
+
const laneLayout = [];
|
|
338
|
+
{
|
|
339
|
+
let y = layout.top + layout.axisH;
|
|
340
|
+
for (const lane of lanes) {
|
|
341
|
+
const members = cards.filter((card) => laneOf(card) === lane.id);
|
|
342
|
+
const sides = { above: { ends: [], heights: [] }, below: { ends: [], heights: [] } };
|
|
343
|
+
const fit = (side, card) => {
|
|
344
|
+
const index = sides[side].ends.findIndex((end) => card.left >= end + 10);
|
|
345
|
+
return index === -1 ? sides[side].ends.length : index;
|
|
346
|
+
};
|
|
347
|
+
for (const card of members) {
|
|
348
|
+
const above = fit('above', card);
|
|
349
|
+
const below = fit('below', card);
|
|
350
|
+
card.side = above < below ? 'above' : 'below';
|
|
351
|
+
card.row = card.side === 'above' ? above : below;
|
|
352
|
+
const side = sides[card.side];
|
|
353
|
+
side.ends[card.row] = card.left + card.width;
|
|
354
|
+
side.heights[card.row] = Math.max(side.heights[card.row] || 0, card.height);
|
|
355
|
+
}
|
|
356
|
+
const extent = (side) => side.heights.reduce((sum, height) => sum + height + layout.rowGap, 0) - (side.heights.length ? layout.rowGap : 0);
|
|
357
|
+
const aboveExtent = extent(sides.above);
|
|
358
|
+
const lineY = y + layout.lanePad + (aboveExtent ? aboveExtent + layout.stemGap : 10);
|
|
359
|
+
const offsets = (side) => {
|
|
360
|
+
const out = [];
|
|
361
|
+
side.heights.reduce((offset, height, row) => { out[row] = offset; return offset + height + layout.rowGap; }, layout.stemGap);
|
|
362
|
+
return out;
|
|
363
|
+
};
|
|
364
|
+
const aboveOffset = offsets(sides.above);
|
|
365
|
+
const belowOffset = offsets(sides.below);
|
|
366
|
+
for (const card of members) {
|
|
367
|
+
card.lineY = lineY;
|
|
368
|
+
// Above the line a row is bottom-aligned to its edge nearest the line,
|
|
369
|
+
// below it top-aligned, so every stem in a row has the same length.
|
|
370
|
+
card.top = card.side === 'above' ? lineY - aboveOffset[card.row] - card.height : lineY + belowOffset[card.row];
|
|
371
|
+
}
|
|
372
|
+
const bottom = sides.below.heights.length ? lineY + belowOffset.at(-1) + sides.below.heights.at(-1) : lineY + 10;
|
|
373
|
+
const height = Math.max(56, bottom - y + layout.lanePad);
|
|
374
|
+
laneLayout.push({ ...lane, y, height, lineY });
|
|
375
|
+
y += height + layout.laneGap;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---- Legend ---------------------------------------------------------------------
|
|
380
|
+
const KIND_TONE = { default: 'external', change: 'frontend', alert: 'security', action: 'messagebus', recovery: 'backend' };
|
|
381
|
+
const kindOf = (event) => event.kind || 'default';
|
|
382
|
+
const legendEntries = resolveLegend(undefined,
|
|
383
|
+
Object.keys(KIND_TONE).map((kind) => ({ kind, label: i18nText(locale, `legend.timeline.${kind}`), interactive: false })),
|
|
384
|
+
new Set(events.map(kindOf)))
|
|
385
|
+
.filter((entry, _, all) => all.length > 1);
|
|
386
|
+
|
|
387
|
+
const contentBottom = laneLayout.at(-1).y + laneLayout.at(-1).height;
|
|
388
|
+
const width = Math.ceil(axisX1 + layout.margin);
|
|
389
|
+
const footprint = legendFootprint(legendEntries, { width: width - layout.margin * 2, fontSize: 11 });
|
|
390
|
+
const height = Math.ceil(contentBottom + layout.margin + (legendEntries.length ? 34 + footprint.extraHeight : 0));
|
|
391
|
+
const viewBox = [width, height];
|
|
392
|
+
|
|
393
|
+
function legendLayout() {
|
|
394
|
+
return { x: layout.margin, baselineY: height - 16, width: width - layout.margin * 2, fontSize: 11, minTitleY: contentBottom + 8, diagramType: 'timeline' };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ---- Rendering ------------------------------------------------------------------
|
|
398
|
+
function renderAxis() {
|
|
399
|
+
const top = layout.top;
|
|
400
|
+
const bottom = contentBottom;
|
|
401
|
+
const lineY = top + layout.axisH - 10;
|
|
402
|
+
// Tick labels never overprint each other or a break's duration label. On a
|
|
403
|
+
// multi-day axis a tick names its date only when the date changes from the
|
|
404
|
+
// previous label, so the axis reads "09-29 15:00 · 18:00 · 21:00".
|
|
405
|
+
const taken = [];
|
|
406
|
+
let lastDate = null;
|
|
407
|
+
const shown = ticks.flatMap((tick) => {
|
|
408
|
+
const w = wall(tick.t);
|
|
409
|
+
const date = `${w.mo}-${w.d}`;
|
|
410
|
+
const label = clockLabel(tick.t, { seconds: tickSeconds, date: multiDay && date !== lastDate });
|
|
411
|
+
const half = textUnits(label) * 10.5 * ADVANCE / 2 + 5;
|
|
412
|
+
const box = [tick.x - half, tick.x + half];
|
|
413
|
+
if (taken.some(([a, b]) => box[0] < b && a < box[1])) return [];
|
|
414
|
+
taken.push(box);
|
|
415
|
+
lastDate = date;
|
|
416
|
+
return [{ ...tick, label }];
|
|
417
|
+
});
|
|
418
|
+
const grid = shown.map((tick) => ` <line x1="${tick.x}" y1="${lineY}" x2="${tick.x}" y2="${bottom}" class="tl-grid" stroke-width="1"/>
|
|
419
|
+
<line x1="${tick.x}" y1="${lineY - 4}" x2="${tick.x}" y2="${lineY}" class="tl-axis" stroke-width="1"/>
|
|
420
|
+
<text x="${tick.x}" y="${lineY - 10}" class="t-muted tl-tick" font-size="10.5" text-anchor="middle">${esc(tick.label)}</text>`).join('\n');
|
|
421
|
+
const segmentLines = segments.map((segment, index) => {
|
|
422
|
+
const from = index ? breaks[index - 1].x1 : axisX0 + 8;
|
|
423
|
+
const to = index < breaks.length ? breaks[index].x0 : axisX1 - 8;
|
|
424
|
+
return ` <line x1="${from}" y1="${lineY}" x2="${to}" y2="${lineY}" class="tl-axis" stroke-width="1"/>`;
|
|
425
|
+
}).join('\n');
|
|
426
|
+
const breakMarks = breaks.map((gap) => {
|
|
427
|
+
const mid = (gap.x0 + gap.x1) / 2;
|
|
428
|
+
const zig = (x) => `M ${x - 2.5} ${lineY - 6} L ${x + 2.5} ${lineY + 6}`;
|
|
429
|
+
const omitted = i18nText(locale, 'timeline.break', { duration: durationLabel(gap.length) });
|
|
430
|
+
return ` <g data-timeline-break="" data-break-ms="${gap.length}" aria-label="${esc(omitted)}" role="img">
|
|
431
|
+
<rect x="${gap.x0 + 6}" y="${lineY}" width="${gap.width - 12}" height="${bottom - lineY}" fill="url(#tl-break-hatch)" class="tl-break-band"/>
|
|
432
|
+
<path d="${zig(gap.x0 + 6)} ${zig(gap.x0 + 10)} ${zig(gap.x1 - 10)} ${zig(gap.x1 - 6)}" class="tl-break-zig" stroke-width="1.3" stroke-linecap="round" fill="none"/>
|
|
433
|
+
<rect x="${mid - gap.width / 2 + 4}" y="${lineY - 41}" width="${gap.width - 8}" height="17" rx="8.5" class="tl-break-chip"/>
|
|
434
|
+
<text x="${mid}" y="${lineY - 29}" class="t-muted" font-size="10" font-weight="700" text-anchor="middle">${esc(gap.label)}</text>
|
|
435
|
+
</g>`;
|
|
436
|
+
}).join('\n');
|
|
437
|
+
return `${grid}\n${segmentLines}\n${breakMarks}`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function renderHeader() {
|
|
441
|
+
const evidence = i18nText(locale, `timeline.evidence.${tl.meta.evidence}`);
|
|
442
|
+
const evidenceW = Math.ceil(textUnits(evidence) * 10.5 * ADVANCE + 24);
|
|
443
|
+
const date = multiDay ? '' : ` · ${wall(first).y}-${wall(first).mo}-${wall(first).d}`;
|
|
444
|
+
const zone = `${timezone === 'UTC' ? 'UTC' : `${timezone} (${offsetLabel(first)})`}${date}`;
|
|
445
|
+
const disclosure = breaks.length ? ` · ${i18nText(locale, 'timeline.compressed', { count: breaks.length })}` : '';
|
|
446
|
+
const caption = `${i18nText(locale, 'timeline.axis')}: ${zone}${disclosure}`;
|
|
447
|
+
const tone = tl.meta.evidence === 'observed' ? 'backend' : 'messagebus';
|
|
448
|
+
return ` <g data-timeline-evidence="${esc(tl.meta.evidence)}">
|
|
449
|
+
<rect x="${layout.margin}" y="14" width="${evidenceW}" height="22" rx="11" class="c-${tone}" stroke-width="1"/>
|
|
450
|
+
<circle cx="${layout.margin + 11}" cy="25" r="3" class="t-${tone}"/>
|
|
451
|
+
<text x="${layout.margin + evidenceW / 2 + 5}" y="29" class="t-${tone}" font-size="10.5" font-weight="700" text-anchor="middle">${esc(evidence)}</text>
|
|
452
|
+
</g>
|
|
453
|
+
<text data-timeline-axis-caption="" x="${layout.margin + evidenceW + 14}" y="29.5" class="t-muted" font-size="11.5">${esc(caption)}</text>`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function renderLanes() {
|
|
457
|
+
return laneLayout.map((lane, index) => ` <g data-timeline-lane="${esc(lane.id)}">
|
|
458
|
+
<rect x="${layout.margin - 8}" y="${lane.y}" width="${axisX1 - layout.margin + 8}" height="${lane.height}" rx="10" class="tl-lane${index % 2 ? ' tl-lane-alt' : ''}"/>
|
|
459
|
+
<line x1="${axisX0 + 8}" y1="${lane.lineY}" x2="${axisX1 - 8}" y2="${lane.lineY}" class="tl-lane-line" stroke-width="1.5" stroke-linecap="round"/>
|
|
460
|
+
${lane.label ? `<text x="${layout.margin}" y="${lane.lineY + 4.5}" class="t-primary" font-size="13" font-weight="700">${esc(lane.label)}</text>` : ''}
|
|
461
|
+
</g>`).join('\n');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function renderEvent(card, order) {
|
|
465
|
+
const tone = KIND_TONE[kindOf(card)];
|
|
466
|
+
const lane = laneLayout.find((entry) => entry.id === laneOf(card));
|
|
467
|
+
// The Node index groups by the context before " › ", so the lane leads.
|
|
468
|
+
const context = [lane?.label, `${clockLabel(card.t, { seconds: true, date: true })} ${offsetLabel(card.t)}`].filter(Boolean).join(' \u203a ');
|
|
469
|
+
const passport = { kind: tone, sublabel: card.detail, context };
|
|
470
|
+
const textX = card.left + card.padLeft;
|
|
471
|
+
let y = card.top + 9 + 10;
|
|
472
|
+
const time = `<text x="${textX}" y="${y}" class="t-${tone} tl-time" font-size="${layout.timeFont}" font-weight="600">${esc(card.time)}</text>`;
|
|
473
|
+
y += 2;
|
|
474
|
+
const title = card.lines.map((line) => {
|
|
475
|
+
y += layout.titleLine;
|
|
476
|
+
return `<tspan x="${textX}" y="${y}">${esc(line)}</tspan>`;
|
|
477
|
+
}).join('');
|
|
478
|
+
// A tone accent down the card's leading edge carries the event kind, so the
|
|
479
|
+
// card body stays a quiet surface for the text.
|
|
480
|
+
const accent = `<line x1="${card.left + 6}" y1="${card.top + 7}" x2="${card.left + 6}" y2="${card.top + card.height - 7}" class="tl-accent tl-accent-${tone}" stroke-width="3" stroke-linecap="round"/>`;
|
|
481
|
+
return ` <g ${focusNodeAttrs(card.id, card.title, passport, locale)} data-timeline-at="${esc(card.at)}" data-timeline-ms="${card.t}" data-timeline-x="${card.x}">
|
|
482
|
+
${focusNodeTitle(card.title, passport)}
|
|
483
|
+
<rect x="${card.left}" y="${card.top}" width="${card.width}" height="${card.height}" rx="7" class="c-mask"/>
|
|
484
|
+
<rect x="${card.left}" y="${card.top}" width="${card.width}" height="${card.height}" rx="7" class="c-${tone} tl-card"${animateAttr(tl.meta, 'node', order)} stroke-width="1"/>
|
|
485
|
+
${accent}
|
|
486
|
+
${time}
|
|
487
|
+
<text data-node-label="" class="t-primary" font-size="${layout.titleFont}" font-weight="650">${title}</text>
|
|
488
|
+
<circle cx="${card.x}" cy="${card.lineY}" r="4.75" class="t-${tone}"/>
|
|
489
|
+
</g>`;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function legendSwatch(entry) {
|
|
493
|
+
return `<circle cx="${entry.x + 6}" cy="${entry.baseline - 4}" r="4.5" class="t-${KIND_TONE[entry.kind]} tl-dot" stroke-width="2"/>`;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function renderSvg() {
|
|
497
|
+
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(tl.meta)} data-motion-choreography="sweep" data-timeline-ui="" data-reader-fit="intrinsic-height" data-reader-min-text="7.5">
|
|
498
|
+
${svgAccessibleText(tl.meta, 'timeline')}
|
|
499
|
+
${renderDefinitions(breaks.length ? `
|
|
500
|
+
<pattern id="tl-break-hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="6" class="tl-hatch" stroke-width="1"/></pattern>` : '')}
|
|
501
|
+
<style>
|
|
502
|
+
svg[data-timeline-ui] .tl-grid { stroke: var(--lane-stroke); stroke-dasharray: 2 4; opacity: .7; }
|
|
503
|
+
svg[data-timeline-ui] .tl-axis { stroke: var(--text-dim); }
|
|
504
|
+
svg[data-timeline-ui] .tl-lane { fill: var(--lane-fill); stroke: none; opacity: .75; }
|
|
505
|
+
svg[data-timeline-ui] .tl-lane-alt { opacity: 0; }
|
|
506
|
+
svg[data-timeline-ui] .tl-lane-line { stroke: var(--lane-stroke); }
|
|
507
|
+
svg[data-timeline-ui] .tl-stem { stroke: var(--text-dim); }
|
|
508
|
+
svg[data-timeline-ui] .tl-card { fill-opacity: .55; stroke-opacity: .5; }
|
|
509
|
+
svg[data-timeline-ui] .tl-card.c-external { fill: var(--mask); stroke: var(--lane-stroke); stroke-opacity: 1; }
|
|
510
|
+
svg[data-timeline-ui] .tl-dot { stroke: var(--mask); }
|
|
511
|
+
svg[data-timeline-ui] .tl-halo { fill: var(--mask); }
|
|
512
|
+
svg[data-timeline-ui] .tl-break-band { opacity: .55; }
|
|
513
|
+
svg[data-timeline-ui] .tl-break-chip { fill: var(--mask); stroke: var(--lane-stroke); stroke-width: 1; }
|
|
514
|
+
svg[data-timeline-ui] .tl-hatch { stroke: var(--lane-stroke); }
|
|
515
|
+
svg[data-timeline-ui] .tl-break-zig { stroke: var(--text-dim); }
|
|
516
|
+
${Object.values(KIND_TONE).map((tone) => ` svg[data-timeline-ui] .tl-accent-${tone} { stroke: var(--${tone}-stroke); }
|
|
517
|
+
svg[data-timeline-ui] .tl-stem-${tone} { stroke: color-mix(in srgb, var(--${tone}-stroke) 45%, var(--lane-stroke)); }
|
|
518
|
+
svg[data-timeline-ui] .tl-pin-${tone} { fill: var(--${tone}-fill); stroke: color-mix(in srgb, var(--${tone}-stroke) 35%, transparent); stroke-width: 1; }`).join('\n')}
|
|
519
|
+
svg[data-timeline-ui] .tl-tick, svg[data-timeline-ui] .tl-time { font-variant-numeric: tabular-nums; }
|
|
520
|
+
</style>
|
|
521
|
+
|
|
522
|
+
<!-- Background Grid -->
|
|
523
|
+
<rect width="100%" height="100%" fill="url(#grid)" />
|
|
524
|
+
|
|
525
|
+
${renderHeader()}
|
|
526
|
+
|
|
527
|
+
<!-- Lanes -->
|
|
528
|
+
${renderLanes()}
|
|
529
|
+
|
|
530
|
+
<!-- Time axis (proportional within each segment; breaks are disclosed) -->
|
|
531
|
+
${renderAxis()}
|
|
532
|
+
|
|
533
|
+
<!-- Stems sit behind every card, so a stacked card is never crossed by
|
|
534
|
+
another event's connector -->
|
|
535
|
+
${cards.map((card) => ` <line data-detail="context" data-timeline-stem="${esc(card.id)}" x1="${card.x}" y1="${card.lineY}" x2="${card.x}" y2="${card.side === 'above' ? card.top + card.height : card.top}" class="tl-stem tl-stem-${KIND_TONE[kindOf(card)]}" stroke-width="1"/>`).join('\n')}
|
|
536
|
+
|
|
537
|
+
<!-- One halo layer under every dot, so events seconds apart merge into
|
|
538
|
+
one outlined capsule instead of cutting crescents into each other -->
|
|
539
|
+
${cards.map((card) => ` <circle data-detail="context" cx="${card.x}" cy="${card.lineY}" r="7.5" class="tl-halo"/>`).join('\n')}
|
|
540
|
+
${cards.map((card) => ` <circle data-detail="context" cx="${card.x}" cy="${card.lineY}" r="6.5" class="tl-pin tl-pin-${KIND_TONE[kindOf(card)]}"/>`).join('\n')}
|
|
541
|
+
|
|
542
|
+
<!-- Events in time order -->
|
|
543
|
+
${cards.map(renderEvent).join('\n')}
|
|
544
|
+
|
|
545
|
+
<!-- Legend -->
|
|
546
|
+
${legendEntries.length ? renderResolvedLegend({ entries: legendEntries, locale, layout: legendLayout(), renderSwatch: legendSwatch }) : ''}
|
|
547
|
+
</svg>`;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function buildLayoutReport() {
|
|
551
|
+
return {
|
|
552
|
+
diagram_type: 'timeline',
|
|
553
|
+
viewBox,
|
|
554
|
+
timezone,
|
|
555
|
+
scalePxPerMinute: Math.round(scale * MINUTE * 1000) / 1000,
|
|
556
|
+
segments: segments.map(({ start, end, x0, x1 }) => ({ start, end, x0, x1 })),
|
|
557
|
+
breaks: breaks.map(({ from, to, length, x0, x1 }) => ({ from, to, length, x0, x1 })),
|
|
558
|
+
ticks: ticks.map(({ t, x }) => ({ t, x })),
|
|
559
|
+
lanes: laneLayout.map(({ id, y, height, lineY }) => ({ id, y, height, lineY })),
|
|
560
|
+
events: cards.map((card) => ({ id: card.id, t: card.t, lane: laneOf(card), x: card.x, left: card.left, top: card.top, width: card.width, height: card.height, row: card.row })),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (layoutJsonMode) {
|
|
565
|
+
console.log(JSON.stringify(buildLayoutReport(), null, 2));
|
|
566
|
+
process.exit(0);
|
|
567
|
+
}
|
|
568
|
+
writeDiagram({
|
|
569
|
+
outPath,
|
|
570
|
+
template,
|
|
571
|
+
diagramType: 'timeline',
|
|
572
|
+
meta: tl.meta,
|
|
573
|
+
svg: renderSvg(),
|
|
574
|
+
cards: tl.cards,
|
|
575
|
+
sourceEvidence,
|
|
576
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Hierarchy and Tree Renderer
|
|
2
|
+
|
|
3
|
+
Render `diagram_type: "tree"` JSON files into the standard Archify HTML template.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
node archify/renderers/tree/render-tree.mjs input.tree.json output.html
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The renderer validates input against `archify/schemas/tree.schema.json` with the
|
|
10
|
+
bundled standalone validator. If `output.html` is omitted, it uses
|
|
11
|
+
`meta.output` or falls back to `tree.html`. `--layout-json` prints the placed
|
|
12
|
+
nodes and links instead of writing HTML.
|
|
13
|
+
|
|
14
|
+
## Input
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"schema_version": 1,
|
|
19
|
+
"diagram_type": "tree",
|
|
20
|
+
"meta": { "title": "Payment platform capabilities" },
|
|
21
|
+
"nodes": [
|
|
22
|
+
{ "id": "platform", "label": "Payment platform" },
|
|
23
|
+
{ "id": "orders", "label": "Orders", "parent": "platform" }
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
A node has `id`, `label`, and optional `parent`, `sublabel`, `collapsed`, and
|
|
29
|
+
`sources`. Children are read in declaration order. Links are containment only;
|
|
30
|
+
the first slice has no cross-branch relationships.
|
|
31
|
+
|
|
32
|
+
## Structure checks
|
|
33
|
+
|
|
34
|
+
The hierarchy is exactly what the author declared; nothing is attached or
|
|
35
|
+
inferred.
|
|
36
|
+
|
|
37
|
+
| Code | Refused input |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `tree/missing-parent` | `parent` names an undeclared node |
|
|
40
|
+
| `tree/root-count` | zero or more than one node without `parent` |
|
|
41
|
+
| `tree/cycle` | a parent chain that loops; every node beneath the loop is unreachable, so this one diagnostic covers them |
|
|
42
|
+
| `tree/duplicate-id` | an id declared twice |
|
|
43
|
+
|
|
44
|
+
## Layout
|
|
45
|
+
|
|
46
|
+
Each generation shares one band: a row for `layout.direction: "down"` (the
|
|
47
|
+
default) and a column for `"right"`. Along the other axis every subtree owns a
|
|
48
|
+
contiguous span, so unequal branch widths and depths never overlap. A parent is
|
|
49
|
+
centred over its first and last child (exactly over a middle child that sits
|
|
50
|
+
within a few pixels of that midpoint, so the stem never jogs; edge drawing also snaps stems aligned within 2px after rounding); going right,
|
|
51
|
+
nodes are left-aligned in their column.
|
|
52
|
+
|
|
53
|
+
Going down, a run of three or more consecutive leaf siblings is stacked as an
|
|
54
|
+
indented list under its parent, sharing one width and reached along a spine,
|
|
55
|
+
instead of fanning out across the row. This keeps a real hierarchy such as a
|
|
56
|
+
repository readable at one screen's scale; shorter runs and every branch keep
|
|
57
|
+
the row layout. Leaves are quiet cards; the root and branches carry the tone.
|
|
58
|
+
|
|
59
|
+
Nodes size to their label between `layout.nodeW` (140) and `layout.nodeMaxW`
|
|
60
|
+
(220 down, 200 right). Labels and sublabels wrap between words (CJK at any
|
|
61
|
+
character), at the narrowest width that keeps the same number of lines, so a
|
|
62
|
+
long label becomes a balanced box; a single word may widen a node to 1.5 × `nodeMaxW` before it is
|
|
63
|
+
split, so nothing is truncated. `gapX`/`gapY` set the sibling and generation
|
|
64
|
+
gaps.
|
|
65
|
+
|
|
66
|
+
An automatic canvas declares `data-reader-fit="intrinsic-height"` and a 7.5px
|
|
67
|
+
reader text floor, so a deep rightward tree scrolls instead of shrinking.
|
|
68
|
+
|
|
69
|
+
## Expand and collapse
|
|
70
|
+
|
|
71
|
+
Every node with children gets a toggle on its outgoing side, a separate
|
|
72
|
+
focusable button after the node, with `aria-expanded` and a localized label
|
|
73
|
+
that states how many descendants it hides. The Viewer module
|
|
74
|
+
`viewer/tree-branches.js` owns the state:
|
|
75
|
+
|
|
76
|
+
- Collapsing hides descendants and their links only. Positions are computed
|
|
77
|
+
once for the complete tree, so nothing visible moves and the reader keeps
|
|
78
|
+
their place. A collapsed node shows a stacked card and its hidden count.
|
|
79
|
+
- Keyboard: Enter/Space on a toggle; ArrowLeft collapses and ArrowRight expands
|
|
80
|
+
the focused branch node. Focus stays on the control that was used.
|
|
81
|
+
- **Expand all** appears at the top-left while any branch is collapsed and
|
|
82
|
+
restores the authored hierarchy.
|
|
83
|
+
- Selecting a hidden node through Finder, Node Outline, guided views, or a
|
|
84
|
+
shared link expands its ancestors first.
|
|
85
|
+
- `collapsed: true` only sets the initial Viewer state. The SVG always contains
|
|
86
|
+
the complete tree.
|
|
87
|
+
|
|
88
|
+
Export scope is explicit: every SVG/PNG/JPEG/WebP export is the complete tree,
|
|
89
|
+
whatever is collapsed on screen. Export Cleanup removes the collapsed state from
|
|
90
|
+
the clone and leaves the live view unchanged.
|
|
91
|
+
|
|
92
|
+
## Tested size
|
|
93
|
+
|
|
94
|
+
`examples/archify-repository.tree.json` (31 nodes, depth 4, unbalanced, four
|
|
95
|
+
stacked lists) renders in about 0.2s and passes the showcase gates;
|
|
96
|
+
a toggle updates the DOM in under a few milliseconds.
|