pmx-canvas 0.1.25 → 0.1.27

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.
Files changed (63) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +191 -0
  2. package/CHANGELOG.md +116 -0
  3. package/Readme.md +74 -27
  4. package/dist/canvas/index.js +82 -82
  5. package/dist/json-render/index.css +1 -1
  6. package/dist/json-render/index.js +944 -164
  7. package/dist/types/json-render/catalog.d.ts +195 -20
  8. package/dist/types/json-render/charts/components.d.ts +7 -0
  9. package/dist/types/json-render/charts/definitions.d.ts +13 -1
  10. package/dist/types/json-render/charts/tufte-components.d.ts +65 -0
  11. package/dist/types/json-render/charts/tufte-definitions.d.ts +164 -0
  12. package/dist/types/json-render/directives.d.ts +23 -0
  13. package/dist/types/json-render/renderer/index.d.ts +1 -0
  14. package/dist/types/json-render/server.d.ts +32 -1
  15. package/dist/types/mcp/canvas-access.d.ts +62 -0
  16. package/dist/types/server/ax-state.d.ts +170 -0
  17. package/dist/types/server/canvas-db.d.ts +17 -1
  18. package/dist/types/server/canvas-operations.d.ts +45 -0
  19. package/dist/types/server/canvas-schema.d.ts +5 -1
  20. package/dist/types/server/canvas-state.d.ts +95 -4
  21. package/dist/types/server/index.d.ts +118 -2
  22. package/dist/types/server/mutation-history.d.ts +1 -1
  23. package/docs/cli.md +42 -0
  24. package/docs/http-api.md +64 -0
  25. package/docs/mcp.md +23 -5
  26. package/docs/node-types.md +1 -1
  27. package/docs/screenshots/codex-app.png +0 -0
  28. package/docs/screenshots/github-copilot-app.png +0 -0
  29. package/docs/sdk.md +19 -1
  30. package/package.json +10 -7
  31. package/skills/control-session-orchestrator/SKILL.md +359 -0
  32. package/skills/control-session-orchestrator/evals/evals.json +75 -0
  33. package/skills/data-analysis/SKILL.md +6 -0
  34. package/skills/pmx-canvas/SKILL.md +63 -4
  35. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +6 -0
  36. package/skills/tufte-viz/SKILL.md +157 -0
  37. package/skills/tufte-viz/references/analytical-design.md +217 -0
  38. package/skills/tufte-viz/references/tufte-principles.md +147 -0
  39. package/src/cli/agent.ts +280 -2
  40. package/src/cli/index.ts +2 -1
  41. package/src/client/nodes/ExtAppFrame.tsx +23 -1
  42. package/src/client/nodes/McpAppNode.tsx +6 -2
  43. package/src/json-render/catalog.ts +22 -1
  44. package/src/json-render/charts/components.tsx +97 -10
  45. package/src/json-render/charts/definitions.ts +19 -2
  46. package/src/json-render/charts/extra-components.tsx +5 -4
  47. package/src/json-render/charts/tufte-components.tsx +383 -0
  48. package/src/json-render/charts/tufte-definitions.ts +128 -0
  49. package/src/json-render/directives.ts +29 -0
  50. package/src/json-render/renderer/index.css +101 -0
  51. package/src/json-render/renderer/index.tsx +33 -0
  52. package/src/json-render/server.ts +257 -5
  53. package/src/mcp/canvas-access.ts +261 -0
  54. package/src/mcp/server.ts +500 -7
  55. package/src/server/ax-context.ts +8 -3
  56. package/src/server/ax-state.ts +447 -0
  57. package/src/server/canvas-db.ts +184 -1
  58. package/src/server/canvas-operations.ts +107 -0
  59. package/src/server/canvas-schema.ts +26 -3
  60. package/src/server/canvas-state.ts +349 -2
  61. package/src/server/index.ts +250 -2
  62. package/src/server/mutation-history.ts +6 -0
  63. package/src/server/server.ts +428 -2
@@ -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;
@@ -121,6 +129,7 @@ export function useChartFrameHeight(explicitHeight: number | null | undefined, f
121
129
  const overflow = Math.max(0, doc.scrollHeight - doc.clientHeight);
122
130
  const available = overflow > 0 ? currentHeight - overflow : window.innerHeight - rect.top - 24;
123
131
  setAutoHeight(Math.max(220, Math.round(available)));
132
+ setAutoWidth(Math.round(rect.width));
124
133
  };
125
134
 
126
135
  updateHeight();
@@ -137,6 +146,7 @@ export function useChartFrameHeight(explicitHeight: number | null | undefined, f
137
146
  return {
138
147
  frameRef,
139
148
  height: typeof explicitHeight === 'number' ? Math.min(explicitHeight, autoHeight) : autoHeight,
149
+ width: autoWidth,
140
150
  };
141
151
  }
142
152
 
@@ -187,19 +197,96 @@ function ChartLineChart({ props }: BaseComponentProps<CartesianChartProps>) {
187
197
  );
188
198
  }
189
199
 
200
+ /**
201
+ * Resolve the highlighted bar index for colorBy='series'.
202
+ * 'max'/'min' pick the tallest/shortest yKey value; a number is used as-is
203
+ * (clamped to range); null/out-of-range means no bar is emphasized.
204
+ */
205
+ function resolveHighlightIndex(
206
+ data: Record<string, unknown>[],
207
+ yKey: string,
208
+ highlight: BarHighlight,
209
+ ): number {
210
+ if (highlight === null || data.length === 0) return -1;
211
+ if (typeof highlight === 'number') {
212
+ return highlight >= 0 && highlight < data.length ? highlight : -1;
213
+ }
214
+ let best = 0;
215
+ let bestVal = Number(data[0]?.[yKey] ?? 0);
216
+ for (let i = 1; i < data.length; i++) {
217
+ const val = Number(data[i]?.[yKey] ?? 0);
218
+ if (highlight === 'max' ? val > bestVal : val < bestVal) {
219
+ best = i;
220
+ bestVal = val;
221
+ }
222
+ }
223
+ return best;
224
+ }
225
+
226
+ /** Per-bar fill for each colorBy mode. Reuses the <Cell> pattern proven in ChartPieChart. */
227
+ function barCellFill(
228
+ mode: BarColorBy,
229
+ accent: string,
230
+ index: number,
231
+ value: number,
232
+ range: { min: number; max: number },
233
+ highlightIndex: number,
234
+ ): string {
235
+ switch (mode) {
236
+ case 'category':
237
+ return CHART_COLORS[index % CHART_COLORS.length];
238
+ case 'value': {
239
+ // Sequential shade by magnitude: 35% (lowest) -> 100% (highest) accent,
240
+ // mixed toward a SOLID background token so every bar is opaque and the
241
+ // ramp is a true lightness sequence (not a translucency that reads
242
+ // differently depending on what is behind the bar).
243
+ const span = range.max - range.min;
244
+ const t = span > 0 ? (value - range.min) / span : 1;
245
+ const pct = Math.round(35 + t * 65);
246
+ return `color-mix(in oklch, ${accent} ${pct}%, var(--card, var(--background)))`;
247
+ }
248
+ case 'none':
249
+ return accent;
250
+ case 'series':
251
+ default:
252
+ // Tufte-safe emphasis: one accent bar, the rest a muted version of it.
253
+ return index === highlightIndex
254
+ ? accent
255
+ : `color-mix(in oklch, ${accent} 32%, transparent)`;
256
+ }
257
+ }
258
+
190
259
  function ChartBarChart({ props }: BaseComponentProps<CartesianChartProps>) {
191
- const fill = props.color ?? CHART_COLORS[0];
260
+ const accent = props.color ?? CHART_COLORS[0];
261
+ const mode: BarColorBy = props.colorBy ?? 'series';
262
+ const highlight: BarHighlight = props.highlight === undefined ? 'max' : props.highlight;
192
263
  return (
193
264
  <CartesianChart props={props} className="pmx-chart--bar">
194
- {(data) => (
195
- <RechartsBarChart data={data} margin={chartMargin}>
196
- <CartesianGrid strokeDasharray="3 3" stroke="var(--border, #e5e5e5)" />
197
- <XAxis dataKey={props.xKey} tick={axisStyle} tickMargin={axisTickMargin} />
198
- <YAxis tick={axisStyle} tickMargin={axisTickMargin} />
199
- <Tooltip contentStyle={tooltipStyle} cursor={false} />
200
- <Bar dataKey={props.yKey} fill={fill} radius={[4, 4, 0, 0]} />
201
- </RechartsBarChart>
202
- )}
265
+ {(data) => {
266
+ const values = data.map((row) => Number(row[props.yKey] ?? 0));
267
+ const range = {
268
+ min: values.length ? Math.min(...values) : 0,
269
+ max: values.length ? Math.max(...values) : 0,
270
+ };
271
+ const highlightIndex =
272
+ mode === 'series' ? resolveHighlightIndex(data, props.yKey, highlight) : -1;
273
+ return (
274
+ <RechartsBarChart data={data} margin={chartMargin}>
275
+ <CartesianGrid strokeDasharray="3 3" stroke="var(--border, #e5e5e5)" />
276
+ <XAxis dataKey={props.xKey} tick={axisStyle} tickMargin={axisTickMargin} />
277
+ <YAxis domain={[0, 'auto']} tick={axisStyle} tickMargin={axisTickMargin} />
278
+ <Tooltip contentStyle={tooltipStyle} cursor={false} />
279
+ <Bar dataKey={props.yKey} radius={[4, 4, 0, 0]}>
280
+ {data.map((_, i) => (
281
+ <Cell
282
+ key={i}
283
+ fill={barCellFill(mode, accent, i, values[i], range, highlightIndex)}
284
+ />
285
+ ))}
286
+ </Bar>
287
+ </RechartsBarChart>
288
+ );
289
+ }}
203
290
  </CartesianChart>
204
291
  );
205
292
  }
@@ -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: cartesianProps,
56
+ props: barChartProps,
42
57
  description:
43
- 'Bar chart for comparing categories. Provide data as an array of objects with xKey and yKey fields.',
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.45} />
59
- <stop offset="100%" stopColor={stroke} stopOpacity={0.05} />
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,383 @@
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, 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
+ const rowH = rows.length > 0 ? Math.min(36, (height - 12) / rows.length) : 24;
159
+ const plotLeft = labelW + padX;
160
+
161
+ return (
162
+ <div ref={frameRef} className="pmx-chart pmx-chart--dot-plot" style={{ height }}>
163
+ {props.title && <div className="pmx-chart__title">{props.title}</div>}
164
+ <svg className="pmx-chart__dot-plot-svg" width="100%" height={rows.length * rowH} role="img" aria-label={props.title ?? 'dot plot'}>
165
+ {rows.map((row, i) => {
166
+ const cy = i * rowH + rowH / 2;
167
+ // Reference rule runs from the axis origin to the dot, so the line's
168
+ // length itself encodes the value (a full-width rule would make every
169
+ // row look equal and fight the dot encoding).
170
+ const span = max - domainMin || 1;
171
+ const frac = Math.max(0, Math.min(1, (row.value - domainMin) / span));
172
+ const dotCx = `calc(${plotLeft}px + (100% - ${plotLeft + valueW + padX}px) * ${frac})`;
173
+ return (
174
+ <g key={`${row.label}-${i}`}>
175
+ <text x={labelW} y={cy} textAnchor="end" dominantBaseline="central" fontSize={12} fill={INK}>
176
+ {row.label}
177
+ </text>
178
+ <line x1={plotLeft} y1={cy} x2={dotCx} y2={cy} stroke={FRAME} strokeWidth={1} />
179
+ <circle cx={dotCx} cy={cy} r={4.5} fill={dot} />
180
+ <text x="100%" dx={-padX} y={cy} textAnchor="end" dominantBaseline="central" fontSize={12} fill={MUTED}>
181
+ {row.value}
182
+ </text>
183
+ </g>
184
+ );
185
+ })}
186
+ </svg>
187
+ </div>
188
+ );
189
+ }
190
+
191
+ /* ───────────────────────── BulletChart (Few) ───────────────────────── */
192
+
193
+ interface BulletRow {
194
+ label?: string;
195
+ value: number;
196
+ target?: number;
197
+ ranges?: number[];
198
+ }
199
+
200
+ interface BulletChartProps {
201
+ title?: string | null;
202
+ data: Record<string, unknown>[];
203
+ labelKey?: string | null;
204
+ valueKey: string;
205
+ targetKey?: string | null;
206
+ rangesKey?: string | null;
207
+ color?: string | null;
208
+ height?: number | null;
209
+ }
210
+
211
+ function ChartBulletChart({ props }: BaseComponentProps<BulletChartProps>) {
212
+ const measure = props.color ?? ACCENT;
213
+ const labelKey = props.labelKey ?? 'label';
214
+ const targetKey = props.targetKey ?? 'target';
215
+ const rangesKey = props.rangesKey ?? 'ranges';
216
+
217
+ const rows: BulletRow[] = (props.data ?? []).map((row) => {
218
+ const rawRanges = row[rangesKey];
219
+ return {
220
+ label: String(row[labelKey] ?? ''),
221
+ value: toNumber(row[props.valueKey]),
222
+ ...(row[targetKey] !== undefined ? { target: toNumber(row[targetKey]) } : {}),
223
+ ...(Array.isArray(rawRanges) ? { ranges: rawRanges.map(toNumber).sort((a, b) => a - b) } : {}),
224
+ };
225
+ });
226
+
227
+ const fallback = Math.max(120, rows.length * 48 + 24);
228
+ const { frameRef, height, width } = useChartFrameHeight(props.height, fallback);
229
+ // SVG <text x> does not support calc(), so position everything in measured
230
+ // pixels (fallback width until the ResizeObserver reports the real size).
231
+ const w = width || 480;
232
+ const labelW = 120;
233
+ const padX = 12;
234
+ const rowH = rows.length > 0 ? Math.min(56, (height - 12) / rows.length) : 48;
235
+
236
+ return (
237
+ <div ref={frameRef} className="pmx-chart pmx-chart--bullet" style={{ height }}>
238
+ {props.title && <div className="pmx-chart__title">{props.title}</div>}
239
+ <svg className="pmx-chart__bullet-svg" width="100%" height={rows.length * rowH} role="img" aria-label={props.title ?? 'bullet chart'}>
240
+ {rows.map((row, i) => {
241
+ const top = i * rowH;
242
+ const cy = top + rowH / 2;
243
+ const domainMax =
244
+ Math.max(row.value, row.target ?? 0, ...(row.ranges ?? [0])) || 1;
245
+ const ranges = row.ranges ?? [];
246
+ const left = labelW + padX;
247
+ const rightInset = padX;
248
+ const plotW = Math.max(0, w - left - rightInset);
249
+ const pct = (v: number) => Math.max(0, Math.min(1, v / domainMax));
250
+ const xAt = (v: number) => left + plotW * pct(v);
251
+ const wBetween = (lo: number, hi: number) => plotW * Math.max(0, pct(hi) - pct(lo));
252
+ const barH = Math.min(20, rowH * 0.5);
253
+ const measureH = barH * 0.4;
254
+ // Qualitative bands: lightest (worst) to darkest (best) grayscale.
255
+ const bandShades = ['color-mix(in oklch, var(--muted) 35%, transparent)',
256
+ 'color-mix(in oklch, var(--muted) 60%, transparent)',
257
+ 'color-mix(in oklch, var(--muted) 90%, transparent)'];
258
+ return (
259
+ <g key={`${row.label}-${i}`}>
260
+ <text x={labelW} y={cy} textAnchor="end" dominantBaseline="central" fontSize={12} fill={INK}>
261
+ {row.label}
262
+ </text>
263
+ {ranges.map((hi, idx) => {
264
+ const lo = idx === 0 ? 0 : ranges[idx - 1];
265
+ return (
266
+ <rect
267
+ key={idx}
268
+ x={xAt(lo)}
269
+ y={cy - barH / 2}
270
+ width={wBetween(lo, hi)}
271
+ height={barH}
272
+ fill={bandShades[Math.min(idx, bandShades.length - 1)]}
273
+ />
274
+ );
275
+ })}
276
+ {/* Per-row scale ticks at each band boundary so the reader does not
277
+ compare bar lengths across rows that may be independently scaled. */}
278
+ {ranges.map((hi, idx) => (
279
+ <text key={`tick-${idx}`} x={xAt(hi)} y={cy + barH / 2 + 10} textAnchor="middle" fontSize={9} fill={MUTED}>
280
+ {hi}
281
+ </text>
282
+ ))}
283
+ {/* Measure bar (the actual value) — the only saturated ink. */}
284
+ <rect x={left} y={cy - measureH / 2} width={wBetween(0, row.value)} height={measureH} fill={measure} />
285
+ {/* Target tick. */}
286
+ {typeof row.target === 'number' && (
287
+ <rect x={xAt(row.target)} y={cy - barH / 2} width={2} height={barH} fill={INK} />
288
+ )}
289
+ </g>
290
+ );
291
+ })}
292
+ </svg>
293
+ </div>
294
+ );
295
+ }
296
+
297
+ /* ───────────────────────── Slopegraph ───────────────────────── */
298
+
299
+ interface SlopegraphProps {
300
+ title?: string | null;
301
+ data: Record<string, unknown>[];
302
+ labelKey: string;
303
+ beforeKey: string;
304
+ afterKey: string;
305
+ beforeLabel?: string | null;
306
+ afterLabel?: string | null;
307
+ color?: string | null;
308
+ colorByDirection?: boolean | null;
309
+ height?: number | null;
310
+ }
311
+
312
+ function ChartSlopegraph({ props }: BaseComponentProps<SlopegraphProps>) {
313
+ const stroke = props.color ?? ACCENT;
314
+ const rows = (props.data ?? []).map((row) => ({
315
+ label: String(row[props.labelKey] ?? ''),
316
+ before: toNumber(row[props.beforeKey]),
317
+ after: toNumber(row[props.afterKey]),
318
+ }));
319
+
320
+ const { frameRef, height, width } = useChartFrameHeight(props.height, 320);
321
+ const all = rows.flatMap((r) => [r.before, r.after]);
322
+ const { min, max } = extentOf(all);
323
+ const topPad = 28;
324
+ const botPad = 20;
325
+ const leftX = 150;
326
+ const rightInset = 150;
327
+ // SVG <text x> does not support calc(), so position the right column in
328
+ // measured pixels (fallback width until the ResizeObserver reports the size).
329
+ const w = width || 480;
330
+ const rightX = Math.max(leftX + 40, w - rightInset);
331
+ const scaleY = (v: number) => topPad + (1 - (v - min) / (max - min)) * (height - topPad - botPad);
332
+
333
+ return (
334
+ <div ref={frameRef} className="pmx-chart pmx-chart--slopegraph" style={{ height }}>
335
+ {props.title && <div className="pmx-chart__title">{props.title}</div>}
336
+ <svg className="pmx-chart__slopegraph-svg" width="100%" height={height} role="img" aria-label={props.title ?? 'slopegraph'}>
337
+ <text x={leftX} y={14} textAnchor="end" fontSize={12} fontWeight={600} fill={MUTED}>
338
+ {props.beforeLabel ?? props.beforeKey}
339
+ </text>
340
+ <text x={rightX} y={14} textAnchor="start" fontSize={12} fontWeight={600} fill={MUTED}>
341
+ {props.afterLabel ?? props.afterKey}
342
+ </text>
343
+ {rows.map((row, i) => {
344
+ const y1 = scaleY(row.before);
345
+ const y2 = scaleY(row.after);
346
+ const rose = row.after >= row.before;
347
+ // Lines default to a single neutral ink. Direction coloring (rising vs
348
+ // falling) is an opt-in via colorByDirection — by default it would be a
349
+ // redundant double-encoding of slope and editorializes (a falling
350
+ // error-rate is "good", a falling revenue is "bad").
351
+ const lineColor = props.colorByDirection ? (rose ? stroke : MUTED) : stroke;
352
+ return (
353
+ <g key={`${row.label}-${i}`}>
354
+ <line
355
+ x1={leftX}
356
+ y1={y1}
357
+ x2={rightX}
358
+ y2={y2}
359
+ stroke={lineColor}
360
+ strokeWidth={1.5}
361
+ />
362
+ <circle cx={leftX} cy={y1} r={2.5} fill={lineColor} />
363
+ <circle cx={rightX} cy={y2} r={2.5} fill={lineColor} />
364
+ <text x={leftX - 8} y={y1} textAnchor="end" dominantBaseline="central" fontSize={11} fill={INK}>
365
+ {`${row.label} ${row.before}`}
366
+ </text>
367
+ <text x={rightX + 8} y={y2} textAnchor="start" dominantBaseline="central" fontSize={11} fill={INK}>
368
+ {`${row.after} ${row.label}`}
369
+ </text>
370
+ </g>
371
+ );
372
+ })}
373
+ </svg>
374
+ </div>
375
+ );
376
+ }
377
+
378
+ export const tufteChartComponents = {
379
+ Sparkline: ChartSparkline,
380
+ DotPlot: ChartDotPlot,
381
+ BulletChart: ChartBulletChart,
382
+ Slopegraph: ChartSlopegraph,
383
+ };