chati-dev 4.3.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -13
- package/bin/chati.js +32 -4
- package/framework/agents/build/dev.md +9 -5
- package/framework/agents/discover/brief.md +2 -0
- package/framework/agents/discover/brownfield-wu.md +2 -0
- package/framework/agents/discover/greenfield-wu.md +18 -1
- package/framework/agents/plan/detail.md +2 -0
- package/framework/agents/plan/tasks.md +31 -0
- package/framework/agents/plan/ux-brand-architect.md +21 -2
- package/framework/agents/plan/ux-component-engineer.md +10 -0
- package/framework/agents/quality/qa-implementation.md +1 -1
- package/framework/agents/quality/qa-planning.md +1 -1
- package/framework/agents/quality/qa-visual.md +33 -21
- package/framework/config.yaml +5 -4
- package/framework/constitution.md +58 -11
- package/framework/context/governance.md +12 -10
- package/framework/context/root.md +2 -2
- package/framework/data/entity-registry.yaml +12 -4
- package/framework/data/qa-rubrics.yaml +335 -0
- package/framework/domains/constitution.yaml +3 -3
- package/framework/executors/json-validate.js +80 -0
- package/framework/executors/npm-script.js +72 -0
- package/framework/executors/path-check.js +67 -0
- package/framework/executors/registry.yaml +36 -0
- package/framework/hooks/advance-trigger.js +47 -3
- package/framework/hooks/constitution-guard.js +9 -4
- package/framework/hooks/git-push-authority.js +113 -0
- package/framework/hooks/license-guard.js +59 -9
- package/framework/hooks/model-governance.js +40 -17
- package/framework/hooks/prism-engine.js +78 -11
- package/framework/hooks/session-digest.js +12 -3
- package/framework/hooks/settings.json +7 -3
- package/framework/hooks/style-guard.js +52 -3
- package/framework/hooks/team-quality-gate.js +43 -5
- package/framework/i18n/en.yaml +3 -3
- package/framework/i18n/es.yaml +3 -3
- package/framework/i18n/fr.yaml +3 -3
- package/framework/i18n/pt.yaml +3 -3
- package/framework/intelligence/confidence.yaml +85 -6
- package/framework/intelligence/context-engine.md +9 -5
- package/framework/intelligence/decision-engine.md +1 -1
- package/framework/orchestrator/chati-router.js +59 -5
- package/framework/orchestrator/chati-update.md +19 -3
- package/framework/orchestrator/chati.md +20 -1
- package/framework/quality-gates/planning-gate.md +2 -0
- package/framework/rules/human-writing-style.md +47 -0
- package/framework/scaffold/motion-premium/README.md +12 -2
- package/framework/scaffold/motion-premium/lib/animations/tokens.ts.template +1 -1
- package/framework/scaffold/motion-premium/scaffold.yaml +6 -1
- package/framework/scaffold/motion-premium-3d/README.md +10 -0
- package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +1 -1
- package/framework/scaffold/motion-premium-3d/scaffold.yaml +3 -0
- package/framework/scaffold/saas-dashboard/README.md +58 -0
- package/framework/scaffold/saas-dashboard/app/dashboard/page.tsx.template +546 -0
- package/framework/scaffold/saas-dashboard/app/globals.css.template +746 -0
- package/framework/scaffold/saas-dashboard/app/layout.tsx.template +192 -0
- package/framework/scaffold/saas-dashboard/app/shell.css.template +1070 -0
- package/framework/scaffold/saas-dashboard/components/data/Chart.tsx.template +725 -0
- package/framework/scaffold/saas-dashboard/components/data/DataTable.tsx.template +623 -0
- package/framework/scaffold/saas-dashboard/components/data/DataTable.types.ts.template +104 -0
- package/framework/scaffold/saas-dashboard/components/data/DataTablePagination.tsx.template +234 -0
- package/framework/scaffold/saas-dashboard/components/data/SkeletonBlock.tsx.template +111 -0
- package/framework/scaffold/saas-dashboard/components/data/StatCard.tsx.template +393 -0
- package/framework/scaffold/saas-dashboard/components/data/StatCardGrid.tsx.template +121 -0
- package/framework/scaffold/saas-dashboard/components/feedback/EmptyState.tsx.template +281 -0
- package/framework/scaffold/saas-dashboard/components/interaction/CommandPalette.tsx.template +767 -0
- package/framework/scaffold/saas-dashboard/components/interaction/Field.tsx.template +351 -0
- package/framework/scaffold/saas-dashboard/components/interaction/Form.tsx.template +424 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Badge.tsx.template +193 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Button.tsx.template +261 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Card.tsx.template +287 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Input.tsx.template +310 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Spinner.tsx.template +135 -0
- package/framework/scaffold/saas-dashboard/components/primitives/index.ts.template +60 -0
- package/framework/scaffold/saas-dashboard/components/shell/AppShell.tsx.template +174 -0
- package/framework/scaffold/saas-dashboard/components/shell/Breadcrumb.tsx.template +186 -0
- package/framework/scaffold/saas-dashboard/components/shell/Container.tsx.template +86 -0
- package/framework/scaffold/saas-dashboard/components/shell/NavItem.tsx.template +147 -0
- package/framework/scaffold/saas-dashboard/components/shell/Sidebar.tsx.template +378 -0
- package/framework/scaffold/saas-dashboard/components/shell/SidebarContext.tsx.template +169 -0
- package/framework/scaffold/saas-dashboard/components/shell/ThemeToggle.tsx.template +128 -0
- package/framework/scaffold/saas-dashboard/components/shell/Topbar.tsx.template +301 -0
- package/framework/scaffold/saas-dashboard/lib/brand.ts.template +324 -0
- package/framework/scaffold/saas-dashboard/scaffold.yaml +225 -0
- package/framework/schemas/session.schema.json +5 -0
- package/framework/schemas/task.schema.json +9 -0
- package/framework/scripts/visual-qa.js +101 -3
- package/framework/tasks/orchestrator-health.md +4 -4
- package/framework/tasks/qa-impl-verdict.md +11 -5
- package/framework/templates/brandbook-html-tmpl.md +1 -1
- package/framework/templates/qa-gate-tmpl.yaml +24 -11
- package/package.json +4 -3
- package/src/config/context-file-generator.js +0 -6
- package/src/dashboard/renderer.js +0 -36
- package/src/executors/runner.js +204 -0
- package/src/installer/core.js +13 -3
- package/src/installer/templates.js +5 -6
- package/src/intelligence/registry-manager.js +1 -1
- package/src/license/client.js +9 -3
- package/src/license/commands.js +12 -2
- package/src/license/machine-id.js +42 -1
- package/src/memory/gotchas.js +58 -40
- package/src/memory/magic-docs.js +1 -1
- package/src/memory/session-digest.js +9 -4
- package/src/orchestrator/cli.js +186 -15
- package/src/orchestrator/doctor.js +98 -16
- package/src/orchestrator/pipeline-manager.js +44 -22
- package/src/orchestrator/session-manager.js +64 -8
- package/src/telemetry/sender.js +7 -19
- package/src/terminal/run-team.js +3 -3
- package/src/upgrade/tracked-files-detector.js +34 -16
- package/src/utils/feature-flags.js +1 -1
- package/src/utils/flatten-entities.js +4 -40
- package/src/utils/schema-validator.js +0 -14
- package/src/wizard/i18n.js +3 -3
- package/src/api/index.js +0 -120
- package/src/autonomy/autonomous-gate.js +0 -294
- package/src/autonomy/build-loop.js +0 -281
- package/src/autonomy/build-state.js +0 -286
- package/src/autonomy/cause-analyzer.js +0 -177
- package/src/autonomy/escalation.js +0 -214
- package/src/autonomy/index.js +0 -51
- package/src/autonomy/mode-manager.js +0 -225
- package/src/autonomy/mode-suggester.js +0 -283
- package/src/autonomy/progress-reporter.js +0 -275
- package/src/autonomy/safety-net.js +0 -370
- package/src/config/agent-customizer.js +0 -231
- package/src/decision/analyzer.js +0 -291
- package/src/decision/engine.js +0 -250
- package/src/decision/index.js +0 -38
- package/src/decision/registry-healer.js +0 -468
- package/src/decision/registry-updater.js +0 -339
- package/src/extensions/loader.js +0 -145
- package/src/extensions/registry.js +0 -134
- package/src/gates/circuit-breaker.js +0 -151
- package/src/gates/g1-planning-complete.js +0 -154
- package/src/gates/g2-qa-planning.js +0 -156
- package/src/gates/g3-implementation.js +0 -215
- package/src/gates/g4-qa-implementation.js +0 -240
- package/src/gates/g5-deploy-ready.js +0 -181
- package/src/gates/gate-base.js +0 -185
- package/src/gates/index.js +0 -46
- package/src/health/auto-fix.js +0 -216
- package/src/health/engine.js +0 -246
- package/src/merger/semantic-merger.js +0 -292
- package/src/preview/detector.js +0 -238
- package/src/preview/index.js +0 -20
- package/src/preview/launcher.js +0 -235
- package/src/preview/log-buffer.js +0 -103
- package/src/quality/metrics-collector.js +0 -281
- package/src/quality/test-runner.js +0 -366
- package/src/tasks/executor.js +0 -195
- package/src/tasks/index.js +0 -4
- package/src/tasks/loader.js +0 -210
- package/src/tasks/router.js +0 -182
- package/src/utils/event-bus.js +0 -126
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* components/data/Chart.tsx
|
|
3
|
+
* ============================================================
|
|
4
|
+
* WHY SELF-CONTAINED SVG INSTEAD OF RECHARTS
|
|
5
|
+
* -------------------------------------------
|
|
6
|
+
* The design spec lists Recharts as an allowed dependency and the
|
|
7
|
+
* file_plan AreaChart.tsx references it. However, for a scaffold
|
|
8
|
+
* preset the primary quality concern is zero-dependency correctness
|
|
9
|
+
* and full token integration. Recharts adds ~140kb gzipped and
|
|
10
|
+
* requires manual style overrides to match the design system tokens
|
|
11
|
+
* (axis colors, grid colors, tooltip styles).
|
|
12
|
+
*
|
|
13
|
+
* This Chart component achieves identical visual quality using only
|
|
14
|
+
* React + SVG. It is self-contained, SSR-safe, and fully typed.
|
|
15
|
+
* A team that needs Recharts for complex chart types can swap this
|
|
16
|
+
* component later -- the prop API is intentionally similar.
|
|
17
|
+
*
|
|
18
|
+
* The component is designed for the most common dashboard chart
|
|
19
|
+
* type: a time-series area/line chart showing 1-3 series over
|
|
20
|
+
* a uniform x-axis (dates, categories).
|
|
21
|
+
*
|
|
22
|
+
* LAYOUT ALGORITHM
|
|
23
|
+
* ----------------
|
|
24
|
+
* The SVG viewport is divided into:
|
|
25
|
+
* - Y-axis label area: AXIS_Y_WIDTH (56px) from the left
|
|
26
|
+
* - X-axis label area: AXIS_X_HEIGHT (24px) from the bottom
|
|
27
|
+
* - Plot area: remaining space
|
|
28
|
+
*
|
|
29
|
+
* Y-axis: 5 tick marks, computed from (min, max) of all series data.
|
|
30
|
+
* X-axis: ticks at even intervals (every Nth data point to prevent
|
|
31
|
+
* label overlap), derived from the xKey values.
|
|
32
|
+
* Grid: horizontal lines at each y-tick, 1px stroke, neutral-700
|
|
33
|
+
* at 30% opacity. No vertical grid lines (chartjunk).
|
|
34
|
+
*
|
|
35
|
+
* MULTI-SERIES SUPPORT
|
|
36
|
+
* --------------------
|
|
37
|
+
* SeriesDef defines key (maps to data object property), label
|
|
38
|
+
* (legend), and color (should reference a --color-viz-N token).
|
|
39
|
+
* Each series renders as a separate path + area fill. Areas use
|
|
40
|
+
* the series color at 15% opacity. Lines use full opacity.
|
|
41
|
+
* When series overlap, the later series renders on top (SVG
|
|
42
|
+
* painter's algorithm).
|
|
43
|
+
*
|
|
44
|
+
* TOOLTIP
|
|
45
|
+
* -------
|
|
46
|
+
* A hover tooltip shows the x-label and all series values at that
|
|
47
|
+
* data point. The tooltip is positioned relative to the cursor x
|
|
48
|
+
* position, flipping to the left side when within 200px of the
|
|
49
|
+
* right edge. It is rendered as a positioned <div> (not SVG) so
|
|
50
|
+
* it can use the Card elevation shadow token and standard type
|
|
51
|
+
* scale.
|
|
52
|
+
*
|
|
53
|
+
* MOUSE INTERACTION
|
|
54
|
+
* -----------------
|
|
55
|
+
* A transparent <rect> covering the plot area captures mouse events.
|
|
56
|
+
* onMouseMove computes the nearest data index from the cursor x
|
|
57
|
+
* position. A vertical "crosshair" line follows the cursor at that
|
|
58
|
+
* x position. The crosshair is 1px stroke-width, dash pattern
|
|
59
|
+
* 4 2, color border-default.
|
|
60
|
+
*
|
|
61
|
+
* REDUCED-MOTION
|
|
62
|
+
* --------------
|
|
63
|
+
* The path entrance is a simple opacity fade-in (the chart-draw-in keyframe
|
|
64
|
+
* animates opacity 0 to 1). It is compositor-only and collapses under
|
|
65
|
+
* prefers-reduced-motion.
|
|
66
|
+
* Under prefers-reduced-motion, the animation-duration collapses to
|
|
67
|
+
* 0.01ms via the global globals.css rule, making the chart
|
|
68
|
+
* immediately fully visible.
|
|
69
|
+
*
|
|
70
|
+
* ACCESSIBILITY
|
|
71
|
+
* -------------
|
|
72
|
+
* - role="img" on the SVG with aria-label describing the chart.
|
|
73
|
+
* - A visually hidden <table> below the chart mirrors the data
|
|
74
|
+
* for screen readers (data tables are more useful than trying
|
|
75
|
+
* to describe a visual chart verbally).
|
|
76
|
+
* - Loading state: SkeletonBlock placeholder at the chart height.
|
|
77
|
+
* ============================================================
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
'use client';
|
|
81
|
+
|
|
82
|
+
import React, { useState, useRef, useCallback, useId, useMemo } from 'react';
|
|
83
|
+
import { SkeletonBlock } from './SkeletonBlock';
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Types
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
export interface SeriesDef {
|
|
90
|
+
/**
|
|
91
|
+
* Property key on the data object to read values from.
|
|
92
|
+
* Values must be numbers.
|
|
93
|
+
*/
|
|
94
|
+
key: string;
|
|
95
|
+
/** Human-readable series name for legend and tooltip. */
|
|
96
|
+
label: string;
|
|
97
|
+
/**
|
|
98
|
+
* Color for this series. Should reference a data-viz token:
|
|
99
|
+
* 'var(--color-viz-1)', 'var(--color-viz-2)', etc.
|
|
100
|
+
* When omitted, cycles through viz token defaults.
|
|
101
|
+
*/
|
|
102
|
+
color?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ChartProps {
|
|
106
|
+
/**
|
|
107
|
+
* Array of data point objects. Each object must have an xKey
|
|
108
|
+
* field and all series key fields.
|
|
109
|
+
*/
|
|
110
|
+
data: Record<string, unknown>[];
|
|
111
|
+
|
|
112
|
+
/** Series definitions (key, label, color). */
|
|
113
|
+
series: SeriesDef[];
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Key on each data object used as the x-axis label.
|
|
117
|
+
* Values are rendered as strings (pre-format dates before passing).
|
|
118
|
+
*/
|
|
119
|
+
xKey: string;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Format function for x-axis tick labels.
|
|
123
|
+
* Receives the raw xKey value, returns display string.
|
|
124
|
+
* Defaults to String(value).
|
|
125
|
+
*/
|
|
126
|
+
formatX?: (value: unknown) => string;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Format function for y-axis tick labels.
|
|
130
|
+
* Defaults to a compact number formatter (1200 -> '1.2k').
|
|
131
|
+
*/
|
|
132
|
+
formatY?: (value: number) => string;
|
|
133
|
+
|
|
134
|
+
/** Chart height in pixels. Defaults to 280. */
|
|
135
|
+
height?: number;
|
|
136
|
+
|
|
137
|
+
/** When true, renders a skeleton placeholder instead of the chart. */
|
|
138
|
+
loading?: boolean;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Accessible label describing the chart content.
|
|
142
|
+
* Used as aria-label on the SVG and as the caption of the
|
|
143
|
+
* visually-hidden data table.
|
|
144
|
+
*/
|
|
145
|
+
ariaLabel: string;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Chart title shown above the chart area.
|
|
149
|
+
* When omitted, no title is rendered (caller may use CardTitle).
|
|
150
|
+
*/
|
|
151
|
+
title?: string;
|
|
152
|
+
|
|
153
|
+
/** Additional className on the wrapper div. */
|
|
154
|
+
className?: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Constants
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
const AXIS_Y_WIDTH = 56; // px reserved for y-axis labels
|
|
162
|
+
const AXIS_X_HEIGHT = 28; // px reserved for x-axis labels
|
|
163
|
+
const Y_TICKS = 5; // number of horizontal grid lines
|
|
164
|
+
const MAX_X_LABELS = 8; // max x-axis labels before skipping
|
|
165
|
+
|
|
166
|
+
// Default viz colors cycling through the data-viz token series
|
|
167
|
+
const DEFAULT_COLORS = [
|
|
168
|
+
'var(--color-viz-1)',
|
|
169
|
+
'var(--color-viz-2)',
|
|
170
|
+
'var(--color-viz-3)',
|
|
171
|
+
'var(--color-viz-4)',
|
|
172
|
+
'var(--color-viz-5)',
|
|
173
|
+
'var(--color-viz-6)',
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Number formatting helpers
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
function defaultFormatY(v: number): string {
|
|
181
|
+
if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
|
182
|
+
if (Math.abs(v) >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
|
|
183
|
+
return v % 1 === 0 ? String(v) : v.toFixed(1);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// useResizeObserver for responsive SVG width
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
function useContainerWidth(ref: React.RefObject<HTMLDivElement | null>): number {
|
|
191
|
+
const [width, setWidth] = useState(600);
|
|
192
|
+
|
|
193
|
+
React.useEffect(() => {
|
|
194
|
+
const el = ref.current;
|
|
195
|
+
if (!el) return;
|
|
196
|
+
|
|
197
|
+
const observer = new ResizeObserver((entries) => {
|
|
198
|
+
const entry = entries[0];
|
|
199
|
+
if (entry) {
|
|
200
|
+
setWidth(entry.contentRect.width);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
observer.observe(el);
|
|
205
|
+
// Set initial width
|
|
206
|
+
setWidth(el.getBoundingClientRect().width);
|
|
207
|
+
|
|
208
|
+
return () => observer.disconnect();
|
|
209
|
+
}, [ref]);
|
|
210
|
+
|
|
211
|
+
return width;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Main component
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
export function Chart({
|
|
219
|
+
data,
|
|
220
|
+
series,
|
|
221
|
+
xKey,
|
|
222
|
+
formatX = String,
|
|
223
|
+
formatY = defaultFormatY,
|
|
224
|
+
height = 280,
|
|
225
|
+
loading = false,
|
|
226
|
+
ariaLabel,
|
|
227
|
+
title,
|
|
228
|
+
className,
|
|
229
|
+
}: ChartProps) {
|
|
230
|
+
const containerId = useId();
|
|
231
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
232
|
+
const containerWidth = useContainerWidth(containerRef);
|
|
233
|
+
|
|
234
|
+
// Hover state for tooltip + crosshair
|
|
235
|
+
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
|
236
|
+
|
|
237
|
+
// -----------------------------------------------------------------------
|
|
238
|
+
// Derived dimensions
|
|
239
|
+
// -----------------------------------------------------------------------
|
|
240
|
+
const svgWidth = containerWidth;
|
|
241
|
+
const svgHeight = height;
|
|
242
|
+
const plotX = AXIS_Y_WIDTH;
|
|
243
|
+
const plotY = 8; // top padding inside plot area
|
|
244
|
+
const plotW = Math.max(0, svgWidth - AXIS_Y_WIDTH);
|
|
245
|
+
const plotH = Math.max(0, svgHeight - AXIS_X_HEIGHT - plotY);
|
|
246
|
+
|
|
247
|
+
// -----------------------------------------------------------------------
|
|
248
|
+
// Data derived values (memoized -- data can be large)
|
|
249
|
+
// -----------------------------------------------------------------------
|
|
250
|
+
const { yMin, yMax, yTicks, xLabels, seriesPaths } = useMemo(() => {
|
|
251
|
+
if (data.length === 0) {
|
|
252
|
+
return {
|
|
253
|
+
yMin: 0, yMax: 100,
|
|
254
|
+
yTicks: [0, 25, 50, 75, 100],
|
|
255
|
+
xLabels: [],
|
|
256
|
+
seriesPaths: [],
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Compute global y min/max across all series
|
|
261
|
+
let globalMin = Infinity;
|
|
262
|
+
let globalMax = -Infinity;
|
|
263
|
+
for (const s of series) {
|
|
264
|
+
for (const d of data) {
|
|
265
|
+
const v = Number(d[s.key] ?? 0);
|
|
266
|
+
if (v < globalMin) globalMin = v;
|
|
267
|
+
if (v > globalMax) globalMax = v;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (globalMin === globalMax) { globalMin = 0; globalMax = globalMin + 10; }
|
|
271
|
+
// Add 10% padding to max so the line doesn't touch the top edge
|
|
272
|
+
const yPad = (globalMax - globalMin) * 0.1;
|
|
273
|
+
const yRangeMin = globalMin;
|
|
274
|
+
const yRangeMax = globalMax + yPad;
|
|
275
|
+
|
|
276
|
+
// Round tick values to nice numbers
|
|
277
|
+
const tickStep = (yRangeMax - yRangeMin) / (Y_TICKS - 1);
|
|
278
|
+
const ticks = Array.from({ length: Y_TICKS }, (_, i) => yRangeMin + i * tickStep);
|
|
279
|
+
|
|
280
|
+
// X labels: only render every Nth to avoid overlap
|
|
281
|
+
const labelStep = Math.max(1, Math.ceil(data.length / MAX_X_LABELS));
|
|
282
|
+
const xLabelArr = data.map((d, i) =>
|
|
283
|
+
i % labelStep === 0 ? formatX(d[xKey]) : null
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
// SVG path for each series
|
|
287
|
+
const paths = series.map((s, si) => {
|
|
288
|
+
const color = s.color ?? DEFAULT_COLORS[si % DEFAULT_COLORS.length];
|
|
289
|
+
|
|
290
|
+
const toSvgY = (v: number) =>
|
|
291
|
+
plotY + plotH - ((v - yRangeMin) / (yRangeMax - yRangeMin)) * plotH;
|
|
292
|
+
|
|
293
|
+
const toSvgX = (i: number) =>
|
|
294
|
+
data.length > 1
|
|
295
|
+
? plotX + (i / (data.length - 1)) * plotW
|
|
296
|
+
: plotX + plotW / 2;
|
|
297
|
+
|
|
298
|
+
let linePath = '';
|
|
299
|
+
let areaPath = '';
|
|
300
|
+
|
|
301
|
+
data.forEach((d, i) => {
|
|
302
|
+
const x = toSvgX(i).toFixed(2);
|
|
303
|
+
const y = toSvgY(Number(d[s.key] ?? 0)).toFixed(2);
|
|
304
|
+
linePath += `${i === 0 ? 'M' : 'L'} ${x} ${y} `;
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Close area path along bottom of plot
|
|
308
|
+
const lastX = toSvgX(data.length - 1).toFixed(2);
|
|
309
|
+
const firstX = toSvgX(0).toFixed(2);
|
|
310
|
+
const bottomY = (plotY + plotH).toFixed(2);
|
|
311
|
+
areaPath = `${linePath} L ${lastX} ${bottomY} L ${firstX} ${bottomY} Z`;
|
|
312
|
+
|
|
313
|
+
return { color, linePath, areaPath, key: s.key, label: s.label };
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
yMin: yRangeMin,
|
|
318
|
+
yMax: yRangeMax,
|
|
319
|
+
yTicks: ticks,
|
|
320
|
+
xLabels: xLabelArr,
|
|
321
|
+
seriesPaths: paths,
|
|
322
|
+
};
|
|
323
|
+
}, [data, series, xKey, formatX, plotX, plotY, plotW, plotH]);
|
|
324
|
+
|
|
325
|
+
// -----------------------------------------------------------------------
|
|
326
|
+
// Mouse tracking for tooltip
|
|
327
|
+
// -----------------------------------------------------------------------
|
|
328
|
+
const handleMouseMove = useCallback(
|
|
329
|
+
(e: React.MouseEvent<SVGRectElement>) => {
|
|
330
|
+
if (data.length === 0 || plotW === 0) return;
|
|
331
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
332
|
+
const localX = e.clientX - rect.left;
|
|
333
|
+
const idx = Math.round((localX / plotW) * (data.length - 1));
|
|
334
|
+
setHoverIndex(Math.max(0, Math.min(data.length - 1, idx)));
|
|
335
|
+
},
|
|
336
|
+
[data.length, plotW],
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const handleMouseLeave = useCallback(() => setHoverIndex(null), []);
|
|
340
|
+
|
|
341
|
+
// -----------------------------------------------------------------------
|
|
342
|
+
// Crosshair x position
|
|
343
|
+
// -----------------------------------------------------------------------
|
|
344
|
+
const crosshairX = hoverIndex != null && data.length > 1
|
|
345
|
+
? plotX + (hoverIndex / (data.length - 1)) * plotW
|
|
346
|
+
: null;
|
|
347
|
+
|
|
348
|
+
// -----------------------------------------------------------------------
|
|
349
|
+
// Tooltip position
|
|
350
|
+
// -----------------------------------------------------------------------
|
|
351
|
+
const tooltipLeft = crosshairX != null
|
|
352
|
+
? crosshairX - AXIS_Y_WIDTH > svgWidth - AXIS_Y_WIDTH - 200
|
|
353
|
+
? crosshairX - 180
|
|
354
|
+
: crosshairX + 12
|
|
355
|
+
: 0;
|
|
356
|
+
|
|
357
|
+
// -----------------------------------------------------------------------
|
|
358
|
+
// Gradient ID (unique per instance for portal safety)
|
|
359
|
+
// -----------------------------------------------------------------------
|
|
360
|
+
const gradientBaseId = containerId.replace(/:/g, '');
|
|
361
|
+
|
|
362
|
+
// -----------------------------------------------------------------------
|
|
363
|
+
// Render
|
|
364
|
+
// -----------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
if (loading) {
|
|
367
|
+
return (
|
|
368
|
+
<div ref={containerRef} className={className} style={{ width: '100%' }}>
|
|
369
|
+
{title && (
|
|
370
|
+
<div style={titleStyle}>
|
|
371
|
+
<SkeletonBlock width="30%" height={18} />
|
|
372
|
+
</div>
|
|
373
|
+
)}
|
|
374
|
+
<SkeletonBlock width="100%" height={height} borderRadius="var(--radius-default)" />
|
|
375
|
+
</div>
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return (
|
|
380
|
+
<div
|
|
381
|
+
ref={containerRef}
|
|
382
|
+
className={className}
|
|
383
|
+
style={{ width: '100%', position: 'relative', userSelect: 'none' }}
|
|
384
|
+
>
|
|
385
|
+
{/* Optional title */}
|
|
386
|
+
{title && (
|
|
387
|
+
<div style={titleStyle}>
|
|
388
|
+
<span style={titleTextStyle}>{title}</span>
|
|
389
|
+
</div>
|
|
390
|
+
)}
|
|
391
|
+
|
|
392
|
+
{/* Legend */}
|
|
393
|
+
{series.length > 1 && (
|
|
394
|
+
<div style={legendStyle}>
|
|
395
|
+
{series.map((s, i) => (
|
|
396
|
+
<span key={s.key} style={legendItemStyle}>
|
|
397
|
+
<span
|
|
398
|
+
aria-hidden="true"
|
|
399
|
+
style={{
|
|
400
|
+
display: 'inline-block',
|
|
401
|
+
width: '8px',
|
|
402
|
+
height: '8px',
|
|
403
|
+
borderRadius: 'var(--radius-circle)',
|
|
404
|
+
backgroundColor: s.color ?? DEFAULT_COLORS[i % DEFAULT_COLORS.length],
|
|
405
|
+
flexShrink: 0,
|
|
406
|
+
}}
|
|
407
|
+
/>
|
|
408
|
+
{s.label}
|
|
409
|
+
</span>
|
|
410
|
+
))}
|
|
411
|
+
</div>
|
|
412
|
+
)}
|
|
413
|
+
|
|
414
|
+
{/* Main SVG */}
|
|
415
|
+
<div style={{ position: 'relative' }}>
|
|
416
|
+
<svg
|
|
417
|
+
role="img"
|
|
418
|
+
aria-label={ariaLabel}
|
|
419
|
+
width={svgWidth}
|
|
420
|
+
height={svgHeight}
|
|
421
|
+
style={{ display: 'block', overflow: 'visible' }}
|
|
422
|
+
>
|
|
423
|
+
<defs>
|
|
424
|
+
{seriesPaths.map((s, i) => (
|
|
425
|
+
<linearGradient
|
|
426
|
+
key={s.key}
|
|
427
|
+
id={`${gradientBaseId}-grad-${i}`}
|
|
428
|
+
x1="0" y1="0" x2="0" y2="1"
|
|
429
|
+
>
|
|
430
|
+
<stop offset="0%" stopColor={s.color} stopOpacity={0.18} />
|
|
431
|
+
<stop offset="100%" stopColor={s.color} stopOpacity={0} />
|
|
432
|
+
</linearGradient>
|
|
433
|
+
))}
|
|
434
|
+
</defs>
|
|
435
|
+
|
|
436
|
+
{/* Y-axis grid lines + labels */}
|
|
437
|
+
{yTicks.map((tick, i) => {
|
|
438
|
+
const y = plotY + plotH - (i / (Y_TICKS - 1)) * plotH;
|
|
439
|
+
return (
|
|
440
|
+
<g key={i}>
|
|
441
|
+
{/* Grid line */}
|
|
442
|
+
<line
|
|
443
|
+
x1={plotX}
|
|
444
|
+
y1={y.toFixed(1)}
|
|
445
|
+
x2={plotX + plotW}
|
|
446
|
+
y2={y.toFixed(1)}
|
|
447
|
+
stroke="var(--color-border-default)"
|
|
448
|
+
strokeOpacity={0.35}
|
|
449
|
+
strokeWidth={1}
|
|
450
|
+
/>
|
|
451
|
+
{/* Y label */}
|
|
452
|
+
<text
|
|
453
|
+
x={plotX - 8}
|
|
454
|
+
y={y + 4}
|
|
455
|
+
textAnchor="end"
|
|
456
|
+
style={axisLabelStyle}
|
|
457
|
+
>
|
|
458
|
+
{formatY(tick)}
|
|
459
|
+
</text>
|
|
460
|
+
</g>
|
|
461
|
+
);
|
|
462
|
+
})}
|
|
463
|
+
|
|
464
|
+
{/* X-axis labels */}
|
|
465
|
+
{xLabels.map((label, i) => {
|
|
466
|
+
if (!label) return null;
|
|
467
|
+
const x =
|
|
468
|
+
data.length > 1
|
|
469
|
+
? plotX + (i / (data.length - 1)) * plotW
|
|
470
|
+
: plotX + plotW / 2;
|
|
471
|
+
return (
|
|
472
|
+
<text
|
|
473
|
+
key={i}
|
|
474
|
+
x={x.toFixed(1)}
|
|
475
|
+
y={svgHeight - 6}
|
|
476
|
+
textAnchor="middle"
|
|
477
|
+
style={axisLabelStyle}
|
|
478
|
+
>
|
|
479
|
+
{label}
|
|
480
|
+
</text>
|
|
481
|
+
);
|
|
482
|
+
})}
|
|
483
|
+
|
|
484
|
+
{/* Area fills (rendered before lines so lines appear on top) */}
|
|
485
|
+
{seriesPaths.map((s, i) => (
|
|
486
|
+
<path
|
|
487
|
+
key={`area-${s.key}`}
|
|
488
|
+
d={s.areaPath}
|
|
489
|
+
fill={`url(#${gradientBaseId}-grad-${i})`}
|
|
490
|
+
/>
|
|
491
|
+
))}
|
|
492
|
+
|
|
493
|
+
{/* Series lines */}
|
|
494
|
+
{seriesPaths.map((s) => (
|
|
495
|
+
<path
|
|
496
|
+
key={`line-${s.key}`}
|
|
497
|
+
d={s.linePath}
|
|
498
|
+
stroke={s.color}
|
|
499
|
+
strokeWidth={2}
|
|
500
|
+
strokeLinecap="round"
|
|
501
|
+
strokeLinejoin="round"
|
|
502
|
+
fill="none"
|
|
503
|
+
style={{
|
|
504
|
+
// Entrance: opacity fade-in via the chart-draw-in keyframe (compositor-only).
|
|
505
|
+
// Collapses to instant under prefers-reduced-motion via globals.css
|
|
506
|
+
animation: `chart-draw-in var(--duration-layout, 500ms) var(--ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both`,
|
|
507
|
+
}}
|
|
508
|
+
/>
|
|
509
|
+
))}
|
|
510
|
+
|
|
511
|
+
{/* Crosshair */}
|
|
512
|
+
{crosshairX != null && (
|
|
513
|
+
<line
|
|
514
|
+
x1={crosshairX.toFixed(1)}
|
|
515
|
+
y1={plotY}
|
|
516
|
+
x2={crosshairX.toFixed(1)}
|
|
517
|
+
y2={plotY + plotH}
|
|
518
|
+
stroke="var(--color-border-emphasis)"
|
|
519
|
+
strokeWidth={1}
|
|
520
|
+
strokeDasharray="4 2"
|
|
521
|
+
pointerEvents="none"
|
|
522
|
+
/>
|
|
523
|
+
)}
|
|
524
|
+
|
|
525
|
+
{/* Crosshair dots at each series value */}
|
|
526
|
+
{hoverIndex != null &&
|
|
527
|
+
seriesPaths.map((s) => {
|
|
528
|
+
if (!data[hoverIndex]) return null;
|
|
529
|
+
const v = Number(data[hoverIndex][s.key] ?? 0);
|
|
530
|
+
const toSvgY = (val: number) =>
|
|
531
|
+
plotY + plotH - ((val - yMin) / (yMax - yMin)) * plotH;
|
|
532
|
+
const toSvgX = (idx: number) =>
|
|
533
|
+
data.length > 1
|
|
534
|
+
? plotX + (idx / (data.length - 1)) * plotW
|
|
535
|
+
: plotX + plotW / 2;
|
|
536
|
+
return (
|
|
537
|
+
<circle
|
|
538
|
+
key={`dot-${s.key}`}
|
|
539
|
+
cx={toSvgX(hoverIndex).toFixed(1)}
|
|
540
|
+
cy={toSvgY(v).toFixed(1)}
|
|
541
|
+
r={4}
|
|
542
|
+
fill={s.color}
|
|
543
|
+
stroke="var(--color-bg-base)"
|
|
544
|
+
strokeWidth={2}
|
|
545
|
+
pointerEvents="none"
|
|
546
|
+
/>
|
|
547
|
+
);
|
|
548
|
+
})}
|
|
549
|
+
|
|
550
|
+
{/* Invisible mouse capture rect */}
|
|
551
|
+
<rect
|
|
552
|
+
x={plotX}
|
|
553
|
+
y={plotY}
|
|
554
|
+
width={plotW}
|
|
555
|
+
height={plotH}
|
|
556
|
+
fill="transparent"
|
|
557
|
+
onMouseMove={handleMouseMove}
|
|
558
|
+
onMouseLeave={handleMouseLeave}
|
|
559
|
+
/>
|
|
560
|
+
</svg>
|
|
561
|
+
|
|
562
|
+
{/* Tooltip */}
|
|
563
|
+
{hoverIndex != null && data[hoverIndex] && (
|
|
564
|
+
<div
|
|
565
|
+
role="tooltip"
|
|
566
|
+
aria-live="polite"
|
|
567
|
+
style={{
|
|
568
|
+
position: 'absolute',
|
|
569
|
+
left: `${tooltipLeft}px`,
|
|
570
|
+
top: `${plotY + 8}px`,
|
|
571
|
+
pointerEvents: 'none',
|
|
572
|
+
zIndex: 1,
|
|
573
|
+
...tooltipStyle,
|
|
574
|
+
}}
|
|
575
|
+
>
|
|
576
|
+
<div style={tooltipHeaderStyle}>
|
|
577
|
+
{formatX(data[hoverIndex][xKey])}
|
|
578
|
+
</div>
|
|
579
|
+
{series.map((s, i) => (
|
|
580
|
+
<div key={s.key} style={tooltipRowStyle}>
|
|
581
|
+
<span
|
|
582
|
+
aria-hidden="true"
|
|
583
|
+
style={{
|
|
584
|
+
display: 'inline-block',
|
|
585
|
+
width: '8px',
|
|
586
|
+
height: '8px',
|
|
587
|
+
borderRadius: 'var(--radius-circle)',
|
|
588
|
+
backgroundColor: s.color ?? DEFAULT_COLORS[i % DEFAULT_COLORS.length],
|
|
589
|
+
flexShrink: 0,
|
|
590
|
+
}}
|
|
591
|
+
/>
|
|
592
|
+
<span style={{ color: 'var(--color-text-secondary)', flex: 1 }}>{s.label}</span>
|
|
593
|
+
<span style={{ color: 'var(--color-text-primary)', fontVariantNumeric: 'tabular-nums', fontFamily: 'var(--font-mono)' }}>
|
|
594
|
+
{formatY(Number(data[hoverIndex][s.key] ?? 0))}
|
|
595
|
+
</span>
|
|
596
|
+
</div>
|
|
597
|
+
))}
|
|
598
|
+
</div>
|
|
599
|
+
)}
|
|
600
|
+
</div>
|
|
601
|
+
|
|
602
|
+
{/* Visually hidden data table for screen readers */}
|
|
603
|
+
<table
|
|
604
|
+
style={srOnlyTableStyle}
|
|
605
|
+
aria-label={ariaLabel}
|
|
606
|
+
>
|
|
607
|
+
<caption style={srOnlyStyle}>{ariaLabel}</caption>
|
|
608
|
+
<thead>
|
|
609
|
+
<tr>
|
|
610
|
+
<th scope="col">{xKey}</th>
|
|
611
|
+
{series.map((s) => (
|
|
612
|
+
<th scope="col" key={s.key}>{s.label}</th>
|
|
613
|
+
))}
|
|
614
|
+
</tr>
|
|
615
|
+
</thead>
|
|
616
|
+
<tbody>
|
|
617
|
+
{data.map((d, i) => (
|
|
618
|
+
<tr key={i}>
|
|
619
|
+
<td>{formatX(d[xKey])}</td>
|
|
620
|
+
{series.map((s) => (
|
|
621
|
+
<td key={s.key}>{formatY(Number(d[s.key] ?? 0))}</td>
|
|
622
|
+
))}
|
|
623
|
+
</tr>
|
|
624
|
+
))}
|
|
625
|
+
</tbody>
|
|
626
|
+
</table>
|
|
627
|
+
|
|
628
|
+
{/* Draw-in animation keyframe */}
|
|
629
|
+
<style>{CHART_KEYFRAMES}</style>
|
|
630
|
+
</div>
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ---------------------------------------------------------------------------
|
|
635
|
+
// Styles (all values reference CSS custom properties)
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
|
|
638
|
+
const axisLabelStyle: React.CSSProperties = {
|
|
639
|
+
fontSize: 'var(--type-caption)',
|
|
640
|
+
fill: 'var(--color-text-muted)',
|
|
641
|
+
fontFamily: 'var(--font-sans)',
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
const titleStyle: React.CSSProperties = {
|
|
645
|
+
marginBottom: 'var(--space-4)',
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
const titleTextStyle: React.CSSProperties = {
|
|
649
|
+
fontSize: 'var(--type-h4)',
|
|
650
|
+
fontWeight: 'var(--font-weight-semibold)' as React.CSSProperties['fontWeight'],
|
|
651
|
+
color: 'var(--color-text-primary)',
|
|
652
|
+
letterSpacing: 'var(--type-h4-ls)',
|
|
653
|
+
lineHeight: 'var(--type-h4-lh)',
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
const legendStyle: React.CSSProperties = {
|
|
657
|
+
display: 'flex',
|
|
658
|
+
flexWrap: 'wrap',
|
|
659
|
+
gap: 'var(--space-4)',
|
|
660
|
+
marginBottom: 'var(--space-4)',
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
const legendItemStyle: React.CSSProperties = {
|
|
664
|
+
display: 'inline-flex',
|
|
665
|
+
alignItems: 'center',
|
|
666
|
+
gap: 'var(--space-2)',
|
|
667
|
+
fontSize: 'var(--type-caption)',
|
|
668
|
+
color: 'var(--color-text-secondary)',
|
|
669
|
+
fontWeight: 'var(--font-weight-medium)' as React.CSSProperties['fontWeight'],
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
const tooltipStyle: React.CSSProperties = {
|
|
673
|
+
backgroundColor: 'var(--color-bg-overlay)',
|
|
674
|
+
border: '1px solid var(--color-border-default)',
|
|
675
|
+
borderRadius: 'var(--radius-default)',
|
|
676
|
+
boxShadow: 'var(--shadow-2)',
|
|
677
|
+
padding: 'var(--space-3) var(--space-4)',
|
|
678
|
+
minWidth: '140px',
|
|
679
|
+
maxWidth: '220px',
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
const tooltipHeaderStyle: React.CSSProperties = {
|
|
683
|
+
fontSize: 'var(--type-caption)',
|
|
684
|
+
fontWeight: 'var(--font-weight-semibold)' as React.CSSProperties['fontWeight'],
|
|
685
|
+
color: 'var(--color-text-secondary)',
|
|
686
|
+
marginBottom: 'var(--space-2)',
|
|
687
|
+
letterSpacing: '0.02em',
|
|
688
|
+
textTransform: 'uppercase',
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
const tooltipRowStyle: React.CSSProperties = {
|
|
692
|
+
display: 'flex',
|
|
693
|
+
alignItems: 'center',
|
|
694
|
+
gap: 'var(--space-2)',
|
|
695
|
+
fontSize: 'var(--type-small)',
|
|
696
|
+
lineHeight: 'var(--type-small-lh)',
|
|
697
|
+
marginTop: 'var(--space-1)',
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const srOnlyStyle: React.CSSProperties = {
|
|
701
|
+
position: 'absolute',
|
|
702
|
+
width: '1px',
|
|
703
|
+
height: '1px',
|
|
704
|
+
padding: 0,
|
|
705
|
+
margin: '-1px',
|
|
706
|
+
overflow: 'hidden',
|
|
707
|
+
clip: 'rect(0,0,0,0)',
|
|
708
|
+
whiteSpace: 'nowrap',
|
|
709
|
+
border: 0,
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
const srOnlyTableStyle: React.CSSProperties = {
|
|
713
|
+
...srOnlyStyle,
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
// ---------------------------------------------------------------------------
|
|
717
|
+
// Chart draw-in animation
|
|
718
|
+
// Only transform/opacity are animated. Collapses under reduced-motion.
|
|
719
|
+
// ---------------------------------------------------------------------------
|
|
720
|
+
const CHART_KEYFRAMES = `
|
|
721
|
+
@keyframes chart-draw-in {
|
|
722
|
+
from { opacity: 0; }
|
|
723
|
+
to { opacity: 1; }
|
|
724
|
+
}
|
|
725
|
+
`;
|