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.
Files changed (64) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +191 -0
  2. package/CHANGELOG.md +110 -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 +17 -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 +33 -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 +53 -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 +120 -3
  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 +23 -5
  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 +50 -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 +302 -3
  40. package/src/cli/index.ts +2 -1
  41. package/src/client/nodes/ExtAppFrame.tsx +48 -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 +127 -15
  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 +395 -0
  48. package/src/json-render/charts/tufte-definitions.ts +128 -0
  49. package/src/json-render/directives.ts +64 -0
  50. package/src/json-render/renderer/index.css +107 -1
  51. package/src/json-render/renderer/index.tsx +33 -0
  52. package/src/json-render/server.ts +275 -5
  53. package/src/mcp/canvas-access.ts +264 -1
  54. package/src/mcp/server.ts +498 -9
  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 +123 -2
  59. package/src/server/canvas-schema.ts +27 -3
  60. package/src/server/canvas-state.ts +349 -2
  61. package/src/server/index.ts +259 -7
  62. package/src/server/mutation-history.ts +6 -0
  63. package/src/server/server.ts +442 -5
  64. 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
- const doc = document.documentElement;
120
- const currentHeight = frame.getBoundingClientRect().height;
121
- const overflow = Math.max(0, doc.scrollHeight - doc.clientHeight);
122
- const available = overflow > 0 ? currentHeight - overflow : window.innerHeight - rect.top - 24;
123
- setAutoHeight(Math.max(220, Math.round(available)));
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 fill = props.color ?? CHART_COLORS[0];
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
- <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
- )}
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: 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,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
+ };