pmx-canvas 0.1.26 → 0.1.28
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/.github/extensions/pmx-canvas/extension.mjs +191 -0
- package/CHANGELOG.md +110 -0
- package/Readme.md +74 -27
- package/dist/canvas/index.js +82 -82
- package/dist/json-render/index.css +1 -1
- package/dist/json-render/index.js +944 -164
- package/dist/types/json-render/catalog.d.ts +195 -20
- package/dist/types/json-render/charts/components.d.ts +17 -0
- package/dist/types/json-render/charts/definitions.d.ts +13 -1
- package/dist/types/json-render/charts/tufte-components.d.ts +65 -0
- package/dist/types/json-render/charts/tufte-definitions.d.ts +164 -0
- package/dist/types/json-render/directives.d.ts +33 -0
- package/dist/types/json-render/renderer/index.d.ts +1 -0
- package/dist/types/json-render/server.d.ts +32 -1
- package/dist/types/mcp/canvas-access.d.ts +62 -0
- package/dist/types/server/ax-state.d.ts +170 -0
- package/dist/types/server/canvas-db.d.ts +17 -1
- package/dist/types/server/canvas-operations.d.ts +53 -0
- package/dist/types/server/canvas-schema.d.ts +5 -1
- package/dist/types/server/canvas-state.d.ts +95 -4
- package/dist/types/server/index.d.ts +120 -3
- package/dist/types/server/mutation-history.d.ts +1 -1
- package/docs/cli.md +42 -0
- package/docs/http-api.md +64 -0
- package/docs/mcp.md +23 -5
- package/docs/node-types.md +1 -1
- package/docs/screenshots/codex-app.png +0 -0
- package/docs/screenshots/github-copilot-app.png +0 -0
- package/docs/sdk.md +23 -5
- package/package.json +10 -7
- package/skills/control-session-orchestrator/SKILL.md +359 -0
- package/skills/control-session-orchestrator/evals/evals.json +75 -0
- package/skills/data-analysis/SKILL.md +6 -0
- package/skills/pmx-canvas/SKILL.md +50 -4
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +6 -0
- package/skills/tufte-viz/SKILL.md +157 -0
- package/skills/tufte-viz/references/analytical-design.md +217 -0
- package/skills/tufte-viz/references/tufte-principles.md +147 -0
- package/src/cli/agent.ts +302 -3
- package/src/cli/index.ts +2 -1
- package/src/client/nodes/ExtAppFrame.tsx +48 -1
- package/src/client/nodes/McpAppNode.tsx +6 -2
- package/src/json-render/catalog.ts +22 -1
- package/src/json-render/charts/components.tsx +127 -15
- package/src/json-render/charts/definitions.ts +19 -2
- package/src/json-render/charts/extra-components.tsx +5 -4
- package/src/json-render/charts/tufte-components.tsx +395 -0
- package/src/json-render/charts/tufte-definitions.ts +128 -0
- package/src/json-render/directives.ts +64 -0
- package/src/json-render/renderer/index.css +107 -1
- package/src/json-render/renderer/index.tsx +33 -0
- package/src/json-render/server.ts +275 -5
- package/src/mcp/canvas-access.ts +264 -1
- package/src/mcp/server.ts +498 -9
- package/src/server/ax-context.ts +8 -3
- package/src/server/ax-state.ts +447 -0
- package/src/server/canvas-db.ts +184 -1
- package/src/server/canvas-operations.ts +123 -2
- package/src/server/canvas-schema.ts +27 -3
- package/src/server/canvas-state.ts +349 -2
- package/src/server/index.ts +259 -7
- package/src/server/mutation-history.ts +6 -0
- package/src/server/server.ts +442 -5
- package/src/server/web-artifacts.ts +31 -5
|
@@ -68,6 +68,9 @@ export function processChartData(
|
|
|
68
68
|
return result;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
export type BarColorBy = 'series' | 'category' | 'value' | 'none';
|
|
72
|
+
export type BarHighlight = number | 'max' | 'min' | null;
|
|
73
|
+
|
|
71
74
|
export interface CartesianChartProps {
|
|
72
75
|
title?: string | null;
|
|
73
76
|
data: Record<string, unknown>[];
|
|
@@ -76,6 +79,10 @@ export interface CartesianChartProps {
|
|
|
76
79
|
aggregate?: AggregateMode | null;
|
|
77
80
|
color?: string | null;
|
|
78
81
|
height?: number | null;
|
|
82
|
+
/** Bar-only: how bar fills are colored. Defaults to 'series'. Ignored by line charts. */
|
|
83
|
+
colorBy?: BarColorBy | null;
|
|
84
|
+
/** Bar-only: which bar gets the accent under colorBy='series'. Defaults to 'max'. */
|
|
85
|
+
highlight?: BarHighlight;
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
interface PieChartProps {
|
|
@@ -109,6 +116,7 @@ export const legendMargin = { top: 10 };
|
|
|
109
116
|
export function useChartFrameHeight(explicitHeight: number | null | undefined, fallbackHeight = 300) {
|
|
110
117
|
const frameRef = useRef<HTMLDivElement>(null);
|
|
111
118
|
const [autoHeight, setAutoHeight] = useState(fallbackHeight);
|
|
119
|
+
const [autoWidth, setAutoWidth] = useState(0);
|
|
112
120
|
|
|
113
121
|
useEffect(() => {
|
|
114
122
|
const frame = frameRef.current;
|
|
@@ -116,11 +124,24 @@ export function useChartFrameHeight(explicitHeight: number | null | undefined, f
|
|
|
116
124
|
|
|
117
125
|
const updateHeight = () => {
|
|
118
126
|
const rect = frame.getBoundingClientRect();
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
127
|
+
// Available height runs from the frame's top to the bottom of the iframe
|
|
128
|
+
// viewport. It is deliberately NOT derived from the document's own scroll
|
|
129
|
+
// overflow: feeding the chart's own overflow back into its height creates a
|
|
130
|
+
// shrink -> no-overflow -> grow -> overflow feedback loop that repaints
|
|
131
|
+
// forever (the reported Tufte-chart flicker). When natural content exceeds
|
|
132
|
+
// the viewport the document simply scrolls (with a stable gutter, see
|
|
133
|
+
// index.css) instead of the height oscillating.
|
|
134
|
+
// Reserve ~44px below the frame for the chrome that sits under the chart
|
|
135
|
+
// inside the json-render card (card padding/margin ≈ 41px, measured stable
|
|
136
|
+
// across node sizes). rect.top already accounts for everything above. With
|
|
137
|
+
// too small a reserve a filled chart spills ~17px past the viewport and the
|
|
138
|
+
// iframe document shows a needless scrollbar.
|
|
139
|
+
const available = Math.max(220, Math.round(window.innerHeight - rect.top - 44));
|
|
140
|
+
const nextWidth = Math.round(rect.width);
|
|
141
|
+
// Dead-band: ignore sub-threshold churn so a stray re-measure (e.g. a
|
|
142
|
+
// scrollbar toggling) can't ping-pong state and repaint.
|
|
143
|
+
setAutoHeight((prev) => (Math.abs(available - prev) > 2 ? available : prev));
|
|
144
|
+
setAutoWidth((prev) => (Math.abs(nextWidth - prev) > 2 ? nextWidth : prev));
|
|
124
145
|
};
|
|
125
146
|
|
|
126
147
|
updateHeight();
|
|
@@ -137,9 +158,23 @@ export function useChartFrameHeight(explicitHeight: number | null | undefined, f
|
|
|
137
158
|
return {
|
|
138
159
|
frameRef,
|
|
139
160
|
height: typeof explicitHeight === 'number' ? Math.min(explicitHeight, autoHeight) : autoHeight,
|
|
161
|
+
width: autoWidth,
|
|
140
162
|
};
|
|
141
163
|
}
|
|
142
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Height available for the plotted SVG inside `.pmx-chart`, i.e. the measured
|
|
167
|
+
* frame height minus the non-plot chrome: the `.pmx-chart__title` block (~24px
|
|
168
|
+
* of text + margin, only when a title is shown) plus the chart's own vertical
|
|
169
|
+
* padding. Sizing the SVG to this — instead of the full frame height — keeps a
|
|
170
|
+
* filled chart's title+plot within the frame so it doesn't push a scrollbar onto
|
|
171
|
+
* the single iframe-document scroller. Dense charts still exceed it and scroll
|
|
172
|
+
* (one scrollbar, as expected).
|
|
173
|
+
*/
|
|
174
|
+
export function chartPlotHeight(height: number, hasTitle: boolean): number {
|
|
175
|
+
return Math.max(60, height - (hasTitle ? 36 : 12));
|
|
176
|
+
}
|
|
177
|
+
|
|
143
178
|
/** Shared wrapper for cartesian charts (Line + Bar). */
|
|
144
179
|
export function CartesianChart({
|
|
145
180
|
props,
|
|
@@ -187,19 +222,96 @@ function ChartLineChart({ props }: BaseComponentProps<CartesianChartProps>) {
|
|
|
187
222
|
);
|
|
188
223
|
}
|
|
189
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Resolve the highlighted bar index for colorBy='series'.
|
|
227
|
+
* 'max'/'min' pick the tallest/shortest yKey value; a number is used as-is
|
|
228
|
+
* (clamped to range); null/out-of-range means no bar is emphasized.
|
|
229
|
+
*/
|
|
230
|
+
function resolveHighlightIndex(
|
|
231
|
+
data: Record<string, unknown>[],
|
|
232
|
+
yKey: string,
|
|
233
|
+
highlight: BarHighlight,
|
|
234
|
+
): number {
|
|
235
|
+
if (highlight === null || data.length === 0) return -1;
|
|
236
|
+
if (typeof highlight === 'number') {
|
|
237
|
+
return highlight >= 0 && highlight < data.length ? highlight : -1;
|
|
238
|
+
}
|
|
239
|
+
let best = 0;
|
|
240
|
+
let bestVal = Number(data[0]?.[yKey] ?? 0);
|
|
241
|
+
for (let i = 1; i < data.length; i++) {
|
|
242
|
+
const val = Number(data[i]?.[yKey] ?? 0);
|
|
243
|
+
if (highlight === 'max' ? val > bestVal : val < bestVal) {
|
|
244
|
+
best = i;
|
|
245
|
+
bestVal = val;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return best;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Per-bar fill for each colorBy mode. Reuses the <Cell> pattern proven in ChartPieChart. */
|
|
252
|
+
function barCellFill(
|
|
253
|
+
mode: BarColorBy,
|
|
254
|
+
accent: string,
|
|
255
|
+
index: number,
|
|
256
|
+
value: number,
|
|
257
|
+
range: { min: number; max: number },
|
|
258
|
+
highlightIndex: number,
|
|
259
|
+
): string {
|
|
260
|
+
switch (mode) {
|
|
261
|
+
case 'category':
|
|
262
|
+
return CHART_COLORS[index % CHART_COLORS.length];
|
|
263
|
+
case 'value': {
|
|
264
|
+
// Sequential shade by magnitude: 35% (lowest) -> 100% (highest) accent,
|
|
265
|
+
// mixed toward a SOLID background token so every bar is opaque and the
|
|
266
|
+
// ramp is a true lightness sequence (not a translucency that reads
|
|
267
|
+
// differently depending on what is behind the bar).
|
|
268
|
+
const span = range.max - range.min;
|
|
269
|
+
const t = span > 0 ? (value - range.min) / span : 1;
|
|
270
|
+
const pct = Math.round(35 + t * 65);
|
|
271
|
+
return `color-mix(in oklch, ${accent} ${pct}%, var(--card, var(--background)))`;
|
|
272
|
+
}
|
|
273
|
+
case 'none':
|
|
274
|
+
return accent;
|
|
275
|
+
case 'series':
|
|
276
|
+
default:
|
|
277
|
+
// Tufte-safe emphasis: one accent bar, the rest a muted version of it.
|
|
278
|
+
return index === highlightIndex
|
|
279
|
+
? accent
|
|
280
|
+
: `color-mix(in oklch, ${accent} 32%, transparent)`;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
190
284
|
function ChartBarChart({ props }: BaseComponentProps<CartesianChartProps>) {
|
|
191
|
-
const
|
|
285
|
+
const accent = props.color ?? CHART_COLORS[0];
|
|
286
|
+
const mode: BarColorBy = props.colorBy ?? 'series';
|
|
287
|
+
const highlight: BarHighlight = props.highlight === undefined ? 'max' : props.highlight;
|
|
192
288
|
return (
|
|
193
289
|
<CartesianChart props={props} className="pmx-chart--bar">
|
|
194
|
-
{(data) =>
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
290
|
+
{(data) => {
|
|
291
|
+
const values = data.map((row) => Number(row[props.yKey] ?? 0));
|
|
292
|
+
const range = {
|
|
293
|
+
min: values.length ? Math.min(...values) : 0,
|
|
294
|
+
max: values.length ? Math.max(...values) : 0,
|
|
295
|
+
};
|
|
296
|
+
const highlightIndex =
|
|
297
|
+
mode === 'series' ? resolveHighlightIndex(data, props.yKey, highlight) : -1;
|
|
298
|
+
return (
|
|
299
|
+
<RechartsBarChart data={data} margin={chartMargin}>
|
|
300
|
+
<CartesianGrid strokeDasharray="3 3" stroke="var(--border, #e5e5e5)" />
|
|
301
|
+
<XAxis dataKey={props.xKey} tick={axisStyle} tickMargin={axisTickMargin} />
|
|
302
|
+
<YAxis domain={[0, 'auto']} tick={axisStyle} tickMargin={axisTickMargin} />
|
|
303
|
+
<Tooltip contentStyle={tooltipStyle} cursor={false} />
|
|
304
|
+
<Bar dataKey={props.yKey} radius={[4, 4, 0, 0]}>
|
|
305
|
+
{data.map((_, i) => (
|
|
306
|
+
<Cell
|
|
307
|
+
key={i}
|
|
308
|
+
fill={barCellFill(mode, accent, i, values[i], range, highlightIndex)}
|
|
309
|
+
/>
|
|
310
|
+
))}
|
|
311
|
+
</Bar>
|
|
312
|
+
</RechartsBarChart>
|
|
313
|
+
);
|
|
314
|
+
}}
|
|
203
315
|
</CartesianChart>
|
|
204
316
|
);
|
|
205
317
|
}
|
|
@@ -17,6 +17,21 @@ const cartesianProps = z.object({
|
|
|
17
17
|
height: z.number().nullable(),
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
const barChartProps = cartesianProps.extend({
|
|
21
|
+
colorBy: z
|
|
22
|
+
.enum(['series', 'category', 'value', 'none'])
|
|
23
|
+
.nullable()
|
|
24
|
+
.describe(
|
|
25
|
+
"How bars are colored. 'series' (default) = single accent with ONE highlighted bar (Tufte-safe emphasis); 'category' = rotate the categorical palette per bar (only when the x-axis category itself is the message); 'value' = sequential shade by magnitude; 'none' = flat single accent.",
|
|
26
|
+
),
|
|
27
|
+
highlight: z
|
|
28
|
+
.union([z.number(), z.enum(['max', 'min'])])
|
|
29
|
+
.nullable()
|
|
30
|
+
.describe(
|
|
31
|
+
"For colorBy='series', which bar gets the accent: 'max' (default, tallest), 'min' (shortest), a 0-based index, or null for no emphasis. Ignored by other colorBy modes.",
|
|
32
|
+
),
|
|
33
|
+
});
|
|
34
|
+
|
|
20
35
|
export const chartComponentDefinitions = {
|
|
21
36
|
LineChart: {
|
|
22
37
|
props: cartesianProps,
|
|
@@ -38,9 +53,9 @@ export const chartComponentDefinitions = {
|
|
|
38
53
|
},
|
|
39
54
|
|
|
40
55
|
BarChart: {
|
|
41
|
-
props:
|
|
56
|
+
props: barChartProps,
|
|
42
57
|
description:
|
|
43
|
-
|
|
58
|
+
"Bar chart for comparing categories. Provide data as an array of objects with xKey and yKey fields. Color encodes data, not decoration: by default one accent with the tallest bar highlighted (colorBy='series'). Set colorBy='category' only when the category itself is the message, 'value' to shade by magnitude, or 'none' for a flat fill.",
|
|
44
59
|
example: {
|
|
45
60
|
title: 'Sales by region',
|
|
46
61
|
data: [
|
|
@@ -53,6 +68,8 @@ export const chartComponentDefinitions = {
|
|
|
53
68
|
aggregate: null,
|
|
54
69
|
color: null,
|
|
55
70
|
height: null,
|
|
71
|
+
colorBy: 'series',
|
|
72
|
+
highlight: 'max',
|
|
56
73
|
},
|
|
57
74
|
},
|
|
58
75
|
|
|
@@ -55,20 +55,21 @@ function ChartAreaChart({ props }: BaseComponentProps<AreaChartProps>) {
|
|
|
55
55
|
<RechartsAreaChart data={data} margin={chartMargin}>
|
|
56
56
|
<defs>
|
|
57
57
|
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
|
58
|
-
<stop offset="0%" stopColor={stroke} stopOpacity={0.
|
|
59
|
-
<stop offset="100%" stopColor={stroke} stopOpacity={0.
|
|
58
|
+
<stop offset="0%" stopColor={stroke} stopOpacity={0.55} />
|
|
59
|
+
<stop offset="100%" stopColor={stroke} stopOpacity={0.12} />
|
|
60
60
|
</linearGradient>
|
|
61
61
|
</defs>
|
|
62
62
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border, #e5e5e5)" />
|
|
63
63
|
<XAxis dataKey={props.xKey} tick={axisStyle} tickMargin={axisTickMargin} />
|
|
64
|
-
<YAxis tick={axisStyle} tickMargin={axisTickMargin} />
|
|
64
|
+
<YAxis domain={[0, 'auto']} tick={axisStyle} tickMargin={axisTickMargin} />
|
|
65
65
|
<Tooltip contentStyle={tooltipStyle} />
|
|
66
66
|
<Area
|
|
67
67
|
type="monotone"
|
|
68
68
|
dataKey={props.yKey}
|
|
69
69
|
stroke={stroke}
|
|
70
|
-
strokeWidth={2}
|
|
70
|
+
strokeWidth={2.5}
|
|
71
71
|
fill={`url(#${gradientId})`}
|
|
72
|
+
dot={{ r: 2.5, fill: stroke, strokeWidth: 0 }}
|
|
72
73
|
activeDot={{ r: 5 }}
|
|
73
74
|
/>
|
|
74
75
|
</RechartsAreaChart>
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/** @jsxImportSource react */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tufte primitive chart components for json-render.
|
|
5
|
+
*
|
|
6
|
+
* Word-sized / high-data-ink-ratio primitives that Recharts handles poorly:
|
|
7
|
+
* Sparkline, DotPlot (Cleveland), BulletChart (Few), and Slopegraph (Tufte).
|
|
8
|
+
* Hand-rolled SVG keeps the data-ink ratio high and avoids Recharts chartjunk.
|
|
9
|
+
*
|
|
10
|
+
* Lives alongside ./components.tsx and ./extra-components.tsx so the original
|
|
11
|
+
* chart sets stay unchanged; ./catalog.ts is the only merge surface.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { BaseComponentProps } from '@json-render/react';
|
|
15
|
+
import { CHART_COLORS, chartPlotHeight, useChartFrameHeight } from './components';
|
|
16
|
+
|
|
17
|
+
const ACCENT = CHART_COLORS[0];
|
|
18
|
+
const INK = 'var(--foreground, #111)';
|
|
19
|
+
const MUTED = 'var(--muted-foreground, #666)';
|
|
20
|
+
const FRAME = 'var(--border, #e5e5e5)';
|
|
21
|
+
|
|
22
|
+
function toNumber(value: unknown): number {
|
|
23
|
+
const n = Number(value);
|
|
24
|
+
return Number.isFinite(n) ? n : 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function extentOf(values: number[]): { min: number; max: number } {
|
|
28
|
+
if (values.length === 0) return { min: 0, max: 1 };
|
|
29
|
+
let min = values[0];
|
|
30
|
+
let max = values[0];
|
|
31
|
+
for (const v of values) {
|
|
32
|
+
if (v < min) min = v;
|
|
33
|
+
if (v > max) max = v;
|
|
34
|
+
}
|
|
35
|
+
if (min === max) {
|
|
36
|
+
// Avoid a zero-height/width domain so the line/dot still renders.
|
|
37
|
+
return { min: min - 1, max: max + 1 };
|
|
38
|
+
}
|
|
39
|
+
return { min, max };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* ───────────────────────── Sparkline ───────────────────────── */
|
|
43
|
+
|
|
44
|
+
interface SparklineProps {
|
|
45
|
+
title?: string | null;
|
|
46
|
+
data: Record<string, unknown>[];
|
|
47
|
+
valueKey: string;
|
|
48
|
+
color?: string | null;
|
|
49
|
+
fill?: boolean | null;
|
|
50
|
+
showEndDot?: boolean | null;
|
|
51
|
+
showMinMax?: boolean | null;
|
|
52
|
+
showValue?: boolean | null;
|
|
53
|
+
height?: number | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ChartSparkline({ props }: BaseComponentProps<SparklineProps>) {
|
|
57
|
+
const rows = props.data ?? [];
|
|
58
|
+
const values = rows.map((row) => toNumber(row[props.valueKey]));
|
|
59
|
+
const stroke = props.color ?? ACCENT;
|
|
60
|
+
const h = typeof props.height === 'number' ? props.height : 36;
|
|
61
|
+
const w = 240;
|
|
62
|
+
const padX = 4;
|
|
63
|
+
const padY = 4;
|
|
64
|
+
|
|
65
|
+
const { min, max } = extentOf(values);
|
|
66
|
+
const innerW = w - padX * 2;
|
|
67
|
+
const innerH = h - padY * 2;
|
|
68
|
+
const stepX = values.length > 1 ? innerW / (values.length - 1) : 0;
|
|
69
|
+
const scaleY = (v: number) => padY + innerH - ((v - min) / (max - min)) * innerH;
|
|
70
|
+
const points = values.map((v, i) => ({ x: padX + i * stepX, y: scaleY(v), v }));
|
|
71
|
+
|
|
72
|
+
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
|
|
73
|
+
const areaPath =
|
|
74
|
+
points.length > 0
|
|
75
|
+
? `${linePath} L${points[points.length - 1].x.toFixed(1)},${(padY + innerH).toFixed(1)} L${points[0].x.toFixed(1)},${(padY + innerH).toFixed(1)} Z`
|
|
76
|
+
: '';
|
|
77
|
+
|
|
78
|
+
let minIdx = 0;
|
|
79
|
+
let maxIdx = 0;
|
|
80
|
+
values.forEach((v, i) => {
|
|
81
|
+
if (v < values[minIdx]) minIdx = i;
|
|
82
|
+
if (v > values[maxIdx]) maxIdx = i;
|
|
83
|
+
});
|
|
84
|
+
const last = points[points.length - 1];
|
|
85
|
+
const lastValue = values.length > 0 ? values[values.length - 1] : 0;
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className="pmx-chart pmx-chart--sparkline">
|
|
89
|
+
{props.title && <div className="pmx-chart__title">{props.title}</div>}
|
|
90
|
+
<div className="pmx-chart__sparkline-row">
|
|
91
|
+
{/*
|
|
92
|
+
preserveAspectRatio="none" stretches the 240×36 viewBox to the cell.
|
|
93
|
+
Slope is faithful only when the cell's aspect ratio stays near 240:36;
|
|
94
|
+
this is acceptable for a word-sized strip whose job is shape, not exact angle.
|
|
95
|
+
*/}
|
|
96
|
+
<svg
|
|
97
|
+
className="pmx-chart__sparkline-svg"
|
|
98
|
+
viewBox={`0 0 ${w} ${h}`}
|
|
99
|
+
preserveAspectRatio="none"
|
|
100
|
+
role="img"
|
|
101
|
+
aria-label={props.title ?? 'sparkline'}
|
|
102
|
+
>
|
|
103
|
+
{props.fill && areaPath && (
|
|
104
|
+
<path d={areaPath} fill={stroke} fillOpacity={0.12} stroke="none" />
|
|
105
|
+
)}
|
|
106
|
+
{points.length > 1 && (
|
|
107
|
+
<path d={linePath} fill="none" stroke={stroke} strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
|
|
108
|
+
)}
|
|
109
|
+
{props.showMinMax && points.length > 0 && (
|
|
110
|
+
<>
|
|
111
|
+
<circle cx={points[minIdx].x} cy={points[minIdx].y} r={2} fill={MUTED} />
|
|
112
|
+
<circle cx={points[maxIdx].x} cy={points[maxIdx].y} r={2} fill={MUTED} />
|
|
113
|
+
</>
|
|
114
|
+
)}
|
|
115
|
+
{props.showEndDot !== false && last && <circle cx={last.x} cy={last.y} r={2.5} fill={stroke} />}
|
|
116
|
+
</svg>
|
|
117
|
+
{props.showValue && (
|
|
118
|
+
<span className="pmx-chart__sparkline-value" style={{ color: stroke }}>
|
|
119
|
+
{lastValue}
|
|
120
|
+
</span>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ───────────────────────── DotPlot (Cleveland) ───────────────────────── */
|
|
128
|
+
|
|
129
|
+
interface DotPlotProps {
|
|
130
|
+
title?: string | null;
|
|
131
|
+
data: Record<string, unknown>[];
|
|
132
|
+
labelKey: string;
|
|
133
|
+
valueKey: string;
|
|
134
|
+
color?: string | null;
|
|
135
|
+
sort?: 'asc' | 'desc' | 'none' | null;
|
|
136
|
+
height?: number | null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function ChartDotPlot({ props }: BaseComponentProps<DotPlotProps>) {
|
|
140
|
+
const dot = props.color ?? ACCENT;
|
|
141
|
+
const rows = (props.data ?? []).map((row) => ({
|
|
142
|
+
label: String(row[props.labelKey] ?? ''),
|
|
143
|
+
value: toNumber(row[props.valueKey]),
|
|
144
|
+
}));
|
|
145
|
+
const sort = props.sort ?? 'desc';
|
|
146
|
+
if (sort === 'asc') rows.sort((a, b) => a.value - b.value);
|
|
147
|
+
else if (sort === 'desc') rows.sort((a, b) => b.value - a.value);
|
|
148
|
+
|
|
149
|
+
const fallback = Math.max(160, rows.length * 28 + 24);
|
|
150
|
+
const { frameRef, height } = useChartFrameHeight(props.height, fallback);
|
|
151
|
+
|
|
152
|
+
const values = rows.map((r) => r.value);
|
|
153
|
+
const { min, max } = extentOf(values);
|
|
154
|
+
const domainMin = Math.min(0, min);
|
|
155
|
+
const labelW = 140;
|
|
156
|
+
const valueW = 52;
|
|
157
|
+
const padX = 12;
|
|
158
|
+
// Distribute rows across the available plot height with 36px as a MINIMUM (not
|
|
159
|
+
// a maximum): a sparse chart fills a tall expanded card instead of staying
|
|
160
|
+
// tile-sized and top-aligned with whitespace below; a dense chart keeps ≥36px
|
|
161
|
+
// rows and scrolls cleanly (height no longer oscillates — see useChartFrameHeight).
|
|
162
|
+
const plotH = chartPlotHeight(height, Boolean(props.title));
|
|
163
|
+
const rowH = rows.length > 0 ? Math.max(36, plotH / rows.length) : 24;
|
|
164
|
+
const plotLeft = labelW + padX;
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<div ref={frameRef} className="pmx-chart pmx-chart--dot-plot" style={{ height }}>
|
|
168
|
+
{props.title && <div className="pmx-chart__title">{props.title}</div>}
|
|
169
|
+
<svg className="pmx-chart__dot-plot-svg" width="100%" height={rows.length * rowH} role="img" aria-label={props.title ?? 'dot plot'}>
|
|
170
|
+
{rows.map((row, i) => {
|
|
171
|
+
const cy = i * rowH + rowH / 2;
|
|
172
|
+
// Reference rule runs from the axis origin to the dot, so the line's
|
|
173
|
+
// length itself encodes the value (a full-width rule would make every
|
|
174
|
+
// row look equal and fight the dot encoding).
|
|
175
|
+
const span = max - domainMin || 1;
|
|
176
|
+
const frac = Math.max(0, Math.min(1, (row.value - domainMin) / span));
|
|
177
|
+
const dotCx = `calc(${plotLeft}px + (100% - ${plotLeft + valueW + padX}px) * ${frac})`;
|
|
178
|
+
return (
|
|
179
|
+
<g key={`${row.label}-${i}`}>
|
|
180
|
+
<text x={labelW} y={cy} textAnchor="end" dominantBaseline="central" fontSize={12} fill={INK}>
|
|
181
|
+
{row.label}
|
|
182
|
+
</text>
|
|
183
|
+
<line x1={plotLeft} y1={cy} x2={dotCx} y2={cy} stroke={FRAME} strokeWidth={1} />
|
|
184
|
+
<circle cx={dotCx} cy={cy} r={4.5} fill={dot} />
|
|
185
|
+
<text x="100%" dx={-padX} y={cy} textAnchor="end" dominantBaseline="central" fontSize={12} fill={MUTED}>
|
|
186
|
+
{row.value}
|
|
187
|
+
</text>
|
|
188
|
+
</g>
|
|
189
|
+
);
|
|
190
|
+
})}
|
|
191
|
+
</svg>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* ───────────────────────── BulletChart (Few) ───────────────────────── */
|
|
197
|
+
|
|
198
|
+
interface BulletRow {
|
|
199
|
+
label?: string;
|
|
200
|
+
value: number;
|
|
201
|
+
target?: number;
|
|
202
|
+
ranges?: number[];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
interface BulletChartProps {
|
|
206
|
+
title?: string | null;
|
|
207
|
+
data: Record<string, unknown>[];
|
|
208
|
+
labelKey?: string | null;
|
|
209
|
+
valueKey: string;
|
|
210
|
+
targetKey?: string | null;
|
|
211
|
+
rangesKey?: string | null;
|
|
212
|
+
color?: string | null;
|
|
213
|
+
height?: number | null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function ChartBulletChart({ props }: BaseComponentProps<BulletChartProps>) {
|
|
217
|
+
const measure = props.color ?? ACCENT;
|
|
218
|
+
const labelKey = props.labelKey ?? 'label';
|
|
219
|
+
const targetKey = props.targetKey ?? 'target';
|
|
220
|
+
const rangesKey = props.rangesKey ?? 'ranges';
|
|
221
|
+
|
|
222
|
+
const rows: BulletRow[] = (props.data ?? []).map((row) => {
|
|
223
|
+
const rawRanges = row[rangesKey];
|
|
224
|
+
return {
|
|
225
|
+
label: String(row[labelKey] ?? ''),
|
|
226
|
+
value: toNumber(row[props.valueKey]),
|
|
227
|
+
...(row[targetKey] !== undefined ? { target: toNumber(row[targetKey]) } : {}),
|
|
228
|
+
...(Array.isArray(rawRanges) ? { ranges: rawRanges.map(toNumber).sort((a, b) => a - b) } : {}),
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const fallback = Math.max(120, rows.length * 48 + 24);
|
|
233
|
+
const { frameRef, height, width } = useChartFrameHeight(props.height, fallback);
|
|
234
|
+
// SVG <text x> does not support calc(), so position everything in measured
|
|
235
|
+
// pixels (fallback width until the ResizeObserver reports the real size).
|
|
236
|
+
const w = width || 480;
|
|
237
|
+
const labelW = 120;
|
|
238
|
+
const padX = 12;
|
|
239
|
+
// Fill the available plot height with 56px as a MINIMUM per row (the bar itself
|
|
240
|
+
// is capped at barH below, so extra row height just adds breathing room) so a
|
|
241
|
+
// sparse bullet chart fills a tall expanded card instead of leaving whitespace.
|
|
242
|
+
const plotH = chartPlotHeight(height, Boolean(props.title));
|
|
243
|
+
const rowH = rows.length > 0 ? Math.max(56, plotH / rows.length) : 48;
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
<div ref={frameRef} className="pmx-chart pmx-chart--bullet" style={{ height }}>
|
|
247
|
+
{props.title && <div className="pmx-chart__title">{props.title}</div>}
|
|
248
|
+
<svg className="pmx-chart__bullet-svg" width="100%" height={rows.length * rowH} role="img" aria-label={props.title ?? 'bullet chart'}>
|
|
249
|
+
{rows.map((row, i) => {
|
|
250
|
+
const top = i * rowH;
|
|
251
|
+
const cy = top + rowH / 2;
|
|
252
|
+
const domainMax =
|
|
253
|
+
Math.max(row.value, row.target ?? 0, ...(row.ranges ?? [0])) || 1;
|
|
254
|
+
const ranges = row.ranges ?? [];
|
|
255
|
+
const left = labelW + padX;
|
|
256
|
+
const rightInset = padX;
|
|
257
|
+
const plotW = Math.max(0, w - left - rightInset);
|
|
258
|
+
const pct = (v: number) => Math.max(0, Math.min(1, v / domainMax));
|
|
259
|
+
const xAt = (v: number) => left + plotW * pct(v);
|
|
260
|
+
const wBetween = (lo: number, hi: number) => plotW * Math.max(0, pct(hi) - pct(lo));
|
|
261
|
+
const barH = Math.min(20, rowH * 0.5);
|
|
262
|
+
const measureH = barH * 0.4;
|
|
263
|
+
// Qualitative bands: lightest (worst) to darkest (best) grayscale.
|
|
264
|
+
const bandShades = ['color-mix(in oklch, var(--muted) 35%, transparent)',
|
|
265
|
+
'color-mix(in oklch, var(--muted) 60%, transparent)',
|
|
266
|
+
'color-mix(in oklch, var(--muted) 90%, transparent)'];
|
|
267
|
+
return (
|
|
268
|
+
<g key={`${row.label}-${i}`}>
|
|
269
|
+
<text x={labelW} y={cy} textAnchor="end" dominantBaseline="central" fontSize={12} fill={INK}>
|
|
270
|
+
{row.label}
|
|
271
|
+
</text>
|
|
272
|
+
{ranges.map((hi, idx) => {
|
|
273
|
+
const lo = idx === 0 ? 0 : ranges[idx - 1];
|
|
274
|
+
return (
|
|
275
|
+
<rect
|
|
276
|
+
key={idx}
|
|
277
|
+
x={xAt(lo)}
|
|
278
|
+
y={cy - barH / 2}
|
|
279
|
+
width={wBetween(lo, hi)}
|
|
280
|
+
height={barH}
|
|
281
|
+
fill={bandShades[Math.min(idx, bandShades.length - 1)]}
|
|
282
|
+
/>
|
|
283
|
+
);
|
|
284
|
+
})}
|
|
285
|
+
{/* Per-row scale ticks at each band boundary so the reader does not
|
|
286
|
+
compare bar lengths across rows that may be independently scaled. */}
|
|
287
|
+
{ranges.map((hi, idx) => (
|
|
288
|
+
<text key={`tick-${idx}`} x={xAt(hi)} y={cy + barH / 2 + 10} textAnchor="middle" fontSize={9} fill={MUTED}>
|
|
289
|
+
{hi}
|
|
290
|
+
</text>
|
|
291
|
+
))}
|
|
292
|
+
{/* Measure bar (the actual value) — the only saturated ink. */}
|
|
293
|
+
<rect x={left} y={cy - measureH / 2} width={wBetween(0, row.value)} height={measureH} fill={measure} />
|
|
294
|
+
{/* Target tick. */}
|
|
295
|
+
{typeof row.target === 'number' && (
|
|
296
|
+
<rect x={xAt(row.target)} y={cy - barH / 2} width={2} height={barH} fill={INK} />
|
|
297
|
+
)}
|
|
298
|
+
</g>
|
|
299
|
+
);
|
|
300
|
+
})}
|
|
301
|
+
</svg>
|
|
302
|
+
</div>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* ───────────────────────── Slopegraph ───────────────────────── */
|
|
307
|
+
|
|
308
|
+
interface SlopegraphProps {
|
|
309
|
+
title?: string | null;
|
|
310
|
+
data: Record<string, unknown>[];
|
|
311
|
+
labelKey: string;
|
|
312
|
+
beforeKey: string;
|
|
313
|
+
afterKey: string;
|
|
314
|
+
beforeLabel?: string | null;
|
|
315
|
+
afterLabel?: string | null;
|
|
316
|
+
color?: string | null;
|
|
317
|
+
colorByDirection?: boolean | null;
|
|
318
|
+
height?: number | null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function ChartSlopegraph({ props }: BaseComponentProps<SlopegraphProps>) {
|
|
322
|
+
const stroke = props.color ?? ACCENT;
|
|
323
|
+
const rows = (props.data ?? []).map((row) => ({
|
|
324
|
+
label: String(row[props.labelKey] ?? ''),
|
|
325
|
+
before: toNumber(row[props.beforeKey]),
|
|
326
|
+
after: toNumber(row[props.afterKey]),
|
|
327
|
+
}));
|
|
328
|
+
|
|
329
|
+
const { frameRef, height, width } = useChartFrameHeight(props.height, 320);
|
|
330
|
+
const all = rows.flatMap((r) => [r.before, r.after]);
|
|
331
|
+
const { min, max } = extentOf(all);
|
|
332
|
+
const topPad = 28;
|
|
333
|
+
const botPad = 20;
|
|
334
|
+
const leftX = 150;
|
|
335
|
+
const rightInset = 150;
|
|
336
|
+
// SVG <text x> does not support calc(), so position the right column in
|
|
337
|
+
// measured pixels (fallback width until the ResizeObserver reports the size).
|
|
338
|
+
const w = width || 480;
|
|
339
|
+
const rightX = Math.max(leftX + 40, w - rightInset);
|
|
340
|
+
// Size the SVG to the plot height (frame minus title/padding) so the chart
|
|
341
|
+
// fills without pushing a scrollbar onto the iframe document.
|
|
342
|
+
const plotH = chartPlotHeight(height, Boolean(props.title));
|
|
343
|
+
const scaleY = (v: number) => topPad + (1 - (v - min) / (max - min)) * (plotH - topPad - botPad);
|
|
344
|
+
|
|
345
|
+
return (
|
|
346
|
+
<div ref={frameRef} className="pmx-chart pmx-chart--slopegraph" style={{ height }}>
|
|
347
|
+
{props.title && <div className="pmx-chart__title">{props.title}</div>}
|
|
348
|
+
<svg className="pmx-chart__slopegraph-svg" width="100%" height={plotH} role="img" aria-label={props.title ?? 'slopegraph'}>
|
|
349
|
+
<text x={leftX} y={14} textAnchor="end" fontSize={12} fontWeight={600} fill={MUTED}>
|
|
350
|
+
{props.beforeLabel ?? props.beforeKey}
|
|
351
|
+
</text>
|
|
352
|
+
<text x={rightX} y={14} textAnchor="start" fontSize={12} fontWeight={600} fill={MUTED}>
|
|
353
|
+
{props.afterLabel ?? props.afterKey}
|
|
354
|
+
</text>
|
|
355
|
+
{rows.map((row, i) => {
|
|
356
|
+
const y1 = scaleY(row.before);
|
|
357
|
+
const y2 = scaleY(row.after);
|
|
358
|
+
const rose = row.after >= row.before;
|
|
359
|
+
// Lines default to a single neutral ink. Direction coloring (rising vs
|
|
360
|
+
// falling) is an opt-in via colorByDirection — by default it would be a
|
|
361
|
+
// redundant double-encoding of slope and editorializes (a falling
|
|
362
|
+
// error-rate is "good", a falling revenue is "bad").
|
|
363
|
+
const lineColor = props.colorByDirection ? (rose ? stroke : MUTED) : stroke;
|
|
364
|
+
return (
|
|
365
|
+
<g key={`${row.label}-${i}`}>
|
|
366
|
+
<line
|
|
367
|
+
x1={leftX}
|
|
368
|
+
y1={y1}
|
|
369
|
+
x2={rightX}
|
|
370
|
+
y2={y2}
|
|
371
|
+
stroke={lineColor}
|
|
372
|
+
strokeWidth={1.5}
|
|
373
|
+
/>
|
|
374
|
+
<circle cx={leftX} cy={y1} r={2.5} fill={lineColor} />
|
|
375
|
+
<circle cx={rightX} cy={y2} r={2.5} fill={lineColor} />
|
|
376
|
+
<text x={leftX - 8} y={y1} textAnchor="end" dominantBaseline="central" fontSize={11} fill={INK}>
|
|
377
|
+
{`${row.label} ${row.before}`}
|
|
378
|
+
</text>
|
|
379
|
+
<text x={rightX + 8} y={y2} textAnchor="start" dominantBaseline="central" fontSize={11} fill={INK}>
|
|
380
|
+
{`${row.after} ${row.label}`}
|
|
381
|
+
</text>
|
|
382
|
+
</g>
|
|
383
|
+
);
|
|
384
|
+
})}
|
|
385
|
+
</svg>
|
|
386
|
+
</div>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export const tufteChartComponents = {
|
|
391
|
+
Sparkline: ChartSparkline,
|
|
392
|
+
DotPlot: ChartDotPlot,
|
|
393
|
+
BulletChart: ChartBulletChart,
|
|
394
|
+
Slopegraph: ChartSlopegraph,
|
|
395
|
+
};
|