create-imagine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/bin/create-imagine.js +407 -0
  4. package/package.json +19 -0
  5. package/templates/blank/README.md +96 -0
  6. package/templates/blank/gitignore +6 -0
  7. package/templates/blank/index.html +13 -0
  8. package/templates/blank/package.json +42 -0
  9. package/templates/blank/projects/example/figures/hello-world.tsx +45 -0
  10. package/templates/blank/projects/example/manifest.ts +44 -0
  11. package/templates/blank/projects/example/project.ts +16 -0
  12. package/templates/blank/projects/example/props.json +4 -0
  13. package/templates/blank/scripts/list.ts +46 -0
  14. package/templates/blank/scripts/projects.ts +43 -0
  15. package/templates/blank/scripts/render.ts +288 -0
  16. package/templates/blank/scripts/server.ts +117 -0
  17. package/templates/blank/src/core/__tests__/controls.test.ts +14 -0
  18. package/templates/blank/src/core/__tests__/manifest.test.ts +45 -0
  19. package/templates/blank/src/core/__tests__/props.test.ts +9 -0
  20. package/templates/blank/src/core/controls.ts +14 -0
  21. package/templates/blank/src/core/manifest.ts +134 -0
  22. package/templates/blank/src/core/props.ts +7 -0
  23. package/templates/blank/src/framework/Figure.tsx +34 -0
  24. package/templates/blank/src/framework/__tests__/sizing.test.ts +29 -0
  25. package/templates/blank/src/framework/charts/Axes.tsx +103 -0
  26. package/templates/blank/src/framework/charts/GridLines.tsx +59 -0
  27. package/templates/blank/src/framework/charts/Legend.tsx +31 -0
  28. package/templates/blank/src/framework/charts/Series.tsx +50 -0
  29. package/templates/blank/src/framework/charts/scales.ts +19 -0
  30. package/templates/blank/src/framework/diagrams/primitives.tsx +134 -0
  31. package/templates/blank/src/framework/layout/PanelGrid.tsx +60 -0
  32. package/templates/blank/src/framework/math/MathSvg.tsx +35 -0
  33. package/templates/blank/src/framework/math/mathjax.ts +64 -0
  34. package/templates/blank/src/framework/sizing.ts +28 -0
  35. package/templates/blank/src/framework/theme.ts +35 -0
  36. package/templates/blank/src/framework/types.ts +42 -0
  37. package/templates/blank/src/main.tsx +11 -0
  38. package/templates/blank/src/studio/StudioApp.tsx +130 -0
  39. package/templates/blank/src/studio/StudioRoot.tsx +14 -0
  40. package/templates/blank/src/studio/base64url.ts +8 -0
  41. package/templates/blank/src/studio/figureLoader.ts +30 -0
  42. package/templates/blank/src/studio/projectLoader.ts +40 -0
  43. package/templates/blank/src/studio/propsApi.ts +26 -0
  44. package/templates/blank/src/studio/routes/FigureView.tsx +365 -0
  45. package/templates/blank/src/studio/routes/ProjectHome.tsx +107 -0
  46. package/templates/blank/src/studio/routes/ProjectsHome.tsx +63 -0
  47. package/templates/blank/src/studio/routes/RenderView.tsx +123 -0
  48. package/templates/blank/src/studio/studio.css +540 -0
  49. package/templates/blank/src/studio/useProjectProps.ts +129 -0
  50. package/templates/blank/src/vite-env.d.ts +2 -0
  51. package/templates/blank/tsconfig.json +20 -0
  52. package/templates/blank/vite.config.ts +82 -0
  53. package/templates/blank/vitest.config.ts +8 -0
  54. package/templates/example/README.md +96 -0
  55. package/templates/example/gitignore +6 -0
  56. package/templates/example/index.html +13 -0
  57. package/templates/example/package.json +42 -0
  58. package/templates/example/projects/example/figures/ai-agent-architecture.tsx +133 -0
  59. package/templates/example/projects/example/figures/equation.tsx +29 -0
  60. package/templates/example/projects/example/figures/hello-world.tsx +45 -0
  61. package/templates/example/projects/example/figures/line-chart.tsx +80 -0
  62. package/templates/example/projects/example/figures/multi-panel.tsx +51 -0
  63. package/templates/example/projects/example/figures/pipeline-diagram.tsx +51 -0
  64. package/templates/example/projects/example/manifest.ts +161 -0
  65. package/templates/example/projects/example/project.ts +31 -0
  66. package/templates/example/projects/example/props.json +10 -0
  67. package/templates/example/public/projects/example/previews/ai-agent-architecture--default.png +0 -0
  68. package/templates/example/public/projects/example/previews/equation--default.png +0 -0
  69. package/templates/example/public/projects/example/previews/hello-world--default.png +0 -0
  70. package/templates/example/public/projects/example/previews/line-chart--default.png +0 -0
  71. package/templates/example/public/projects/example/previews/multi-panel--default.png +0 -0
  72. package/templates/example/public/projects/example/previews/pipeline-diagram--default.png +0 -0
  73. package/templates/example/scripts/list.ts +46 -0
  74. package/templates/example/scripts/projects.ts +43 -0
  75. package/templates/example/scripts/render.ts +288 -0
  76. package/templates/example/scripts/server.ts +117 -0
  77. package/templates/example/src/core/__tests__/controls.test.ts +14 -0
  78. package/templates/example/src/core/__tests__/manifest.test.ts +45 -0
  79. package/templates/example/src/core/__tests__/props.test.ts +9 -0
  80. package/templates/example/src/core/controls.ts +14 -0
  81. package/templates/example/src/core/manifest.ts +134 -0
  82. package/templates/example/src/core/props.ts +7 -0
  83. package/templates/example/src/framework/Figure.tsx +34 -0
  84. package/templates/example/src/framework/__tests__/sizing.test.ts +29 -0
  85. package/templates/example/src/framework/charts/Axes.tsx +103 -0
  86. package/templates/example/src/framework/charts/GridLines.tsx +59 -0
  87. package/templates/example/src/framework/charts/Legend.tsx +31 -0
  88. package/templates/example/src/framework/charts/Series.tsx +50 -0
  89. package/templates/example/src/framework/charts/scales.ts +19 -0
  90. package/templates/example/src/framework/diagrams/primitives.tsx +134 -0
  91. package/templates/example/src/framework/layout/PanelGrid.tsx +60 -0
  92. package/templates/example/src/framework/math/MathSvg.tsx +35 -0
  93. package/templates/example/src/framework/math/mathjax.ts +64 -0
  94. package/templates/example/src/framework/sizing.ts +28 -0
  95. package/templates/example/src/framework/theme.ts +35 -0
  96. package/templates/example/src/framework/types.ts +42 -0
  97. package/templates/example/src/main.tsx +11 -0
  98. package/templates/example/src/studio/StudioApp.tsx +130 -0
  99. package/templates/example/src/studio/StudioRoot.tsx +14 -0
  100. package/templates/example/src/studio/base64url.ts +8 -0
  101. package/templates/example/src/studio/figureLoader.ts +30 -0
  102. package/templates/example/src/studio/projectLoader.ts +40 -0
  103. package/templates/example/src/studio/propsApi.ts +26 -0
  104. package/templates/example/src/studio/routes/FigureView.tsx +365 -0
  105. package/templates/example/src/studio/routes/ProjectHome.tsx +107 -0
  106. package/templates/example/src/studio/routes/ProjectsHome.tsx +63 -0
  107. package/templates/example/src/studio/routes/RenderView.tsx +123 -0
  108. package/templates/example/src/studio/studio.css +540 -0
  109. package/templates/example/src/studio/useProjectProps.ts +129 -0
  110. package/templates/example/src/vite-env.d.ts +2 -0
  111. package/templates/example/tsconfig.json +20 -0
  112. package/templates/example/vite.config.ts +82 -0
  113. package/templates/example/vitest.config.ts +8 -0
@@ -0,0 +1,134 @@
1
+ export type ProjectId = string;
2
+ export type FigureId = string;
3
+
4
+ export type FigureSize =
5
+ | { unit: 'px'; width: number; height: number }
6
+ | { unit: 'mm'; width: number; height: number; dpi: number };
7
+
8
+ export type FigureControl =
9
+ | { kind: 'text'; key: string; label?: string; multiline?: boolean; placeholder?: string }
10
+ | { kind: 'number'; key: string; label?: string; min?: number; max?: number; step?: number }
11
+ | { kind: 'boolean'; key: string; label?: string }
12
+ | { kind: 'select'; key: string; label?: string; options: Array<{ label: string; value: string }> };
13
+
14
+ export type FigureVariant = {
15
+ id: string;
16
+ title?: string;
17
+ props?: Record<string, unknown>;
18
+ controls?: FigureControl[];
19
+ size?: FigureSize;
20
+ background?: 'white' | 'transparent';
21
+ };
22
+
23
+ export type FigureManifestItem = {
24
+ id: FigureId;
25
+ title: string;
26
+ moduleKey: string;
27
+ size: FigureSize;
28
+ variants: FigureVariant[];
29
+ controls?: FigureControl[];
30
+ };
31
+
32
+ export type ProjectExample = {
33
+ figureId: FigureId;
34
+ variantId: string;
35
+ src: string;
36
+ caption?: string;
37
+ };
38
+
39
+ export type ProjectDefinition = {
40
+ id: ProjectId;
41
+ title: string;
42
+ description?: string;
43
+ examples?: ProjectExample[];
44
+ figures: FigureManifestItem[];
45
+ };
46
+
47
+ export type PropsFileV1 = {
48
+ version: 1;
49
+ overrides: Record<FigureId, Record<string, Record<string, unknown>>>;
50
+ };
51
+
52
+ export const PROJECT_ID_RE = /^[a-zA-Z0-9_-]+$/;
53
+
54
+ export function emptyPropsFile(): PropsFileV1 {
55
+ return { version: 1, overrides: {} };
56
+ }
57
+
58
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
59
+ return Boolean(value) && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype;
60
+ }
61
+
62
+ export function validatePropsFileV1(file: unknown): PropsFileV1 {
63
+ if (!isPlainObject(file)) throw new Error('props file must be an object');
64
+ if (file.version !== 1) throw new Error('props file version must be 1');
65
+ if (!isPlainObject(file.overrides)) throw new Error('props file overrides must be an object');
66
+ return file as PropsFileV1;
67
+ }
68
+
69
+ function validateSize(size: FigureSize, label: string) {
70
+ if (size.unit === 'px') {
71
+ if (size.width <= 0 || size.height <= 0) throw new Error(`${label} size must be positive`);
72
+ return;
73
+ }
74
+ if (size.width <= 0 || size.height <= 0 || size.dpi <= 0) throw new Error(`${label} mm/dpi size must be positive`);
75
+ }
76
+
77
+ function validateControls(controls: FigureControl[], label: string) {
78
+ for (const c of controls) {
79
+ if (!c.key.trim()) throw new Error(`${label} control key must be non-empty`);
80
+ if (c.kind === 'select') {
81
+ if (!c.options.length) throw new Error(`${label} select control options must be non-empty`);
82
+ for (const opt of c.options) {
83
+ if (!opt.label.trim()) throw new Error(`${label} select option label must be non-empty`);
84
+ if (!opt.value.trim()) throw new Error(`${label} select option value must be non-empty`);
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ export function validateFigures(figures: FigureManifestItem[]): void {
91
+ const seenFigureIds = new Set<string>();
92
+
93
+ for (const fig of figures) {
94
+ if (!fig.id.trim()) throw new Error('Figure id must be non-empty');
95
+ if (seenFigureIds.has(fig.id)) throw new Error(`Duplicate figure id: ${fig.id}`);
96
+ seenFigureIds.add(fig.id);
97
+
98
+ if (!fig.title.trim()) throw new Error(`Figure ${fig.id} title must be non-empty`);
99
+ if (!fig.moduleKey.trim()) throw new Error(`Figure ${fig.id} moduleKey must be non-empty`);
100
+ validateSize(fig.size, `Figure ${fig.id}`);
101
+ if (fig.controls?.length) validateControls(fig.controls, `Figure ${fig.id}`);
102
+
103
+ if (!fig.variants.length) throw new Error(`Figure ${fig.id} must have at least one variant`);
104
+ const seenVariantIds = new Set<string>();
105
+ for (const v of fig.variants) {
106
+ if (!v.id.trim()) throw new Error(`Figure ${fig.id} variant id must be non-empty`);
107
+ if (seenVariantIds.has(v.id)) throw new Error(`Figure ${fig.id} has duplicate variant id: ${v.id}`);
108
+ seenVariantIds.add(v.id);
109
+
110
+ if (v.size) validateSize(v.size, `Figure ${fig.id}/${v.id}`);
111
+ if (v.controls?.length) validateControls(v.controls, `Figure ${fig.id}/${v.id}`);
112
+ }
113
+ }
114
+ }
115
+
116
+ export function validateProject(project: ProjectDefinition): void {
117
+ if (!project.id.trim()) throw new Error('Project id must be non-empty');
118
+ if (!PROJECT_ID_RE.test(project.id)) throw new Error(`Project id contains invalid characters: ${project.id}`);
119
+ if (!project.title.trim()) throw new Error(`Project ${project.id} title must be non-empty`);
120
+
121
+ validateFigures(project.figures);
122
+
123
+ if (project.examples?.length) {
124
+ const byId = new Map(project.figures.map((f) => [f.id, f]));
125
+ for (const ex of project.examples) {
126
+ if (!ex.src.trim()) throw new Error(`Project ${project.id} example src must be non-empty`);
127
+ const fig = byId.get(ex.figureId);
128
+ if (!fig) throw new Error(`Project ${project.id} example references unknown figureId: ${ex.figureId}`);
129
+ const v = fig.variants.find((vv) => vv.id === ex.variantId);
130
+ if (!v) throw new Error(`Project ${project.id} example references unknown variantId: ${ex.figureId}/${ex.variantId}`);
131
+ }
132
+ }
133
+ }
134
+
@@ -0,0 +1,7 @@
1
+ export function mergeProps(
2
+ defaults: Record<string, unknown> | undefined,
3
+ overrides: Record<string, unknown> | undefined
4
+ ): Record<string, unknown> {
5
+ return { ...(defaults ?? {}), ...(overrides ?? {}) };
6
+ }
7
+
@@ -0,0 +1,34 @@
1
+ import type { ReactNode } from 'react';
2
+ import { theme } from './theme';
3
+
4
+ export type FigureProps = {
5
+ width: number;
6
+ height: number;
7
+ background?: 'white' | 'transparent';
8
+ viewBox?: string;
9
+ children: ReactNode;
10
+ title?: string;
11
+ };
12
+
13
+ export function Figure({ width, height, background = 'white', viewBox, children, title }: FigureProps) {
14
+ const vb = viewBox ?? `0 0 ${width} ${height}`;
15
+
16
+ return (
17
+ <svg
18
+ xmlns="http://www.w3.org/2000/svg"
19
+ width={width}
20
+ height={height}
21
+ viewBox={vb}
22
+ role="img"
23
+ aria-label={title}
24
+ style={{
25
+ fontFamily: theme.fontFamily,
26
+ color: theme.colors.text
27
+ }}
28
+ >
29
+ {background === 'white' ? <rect x={0} y={0} width={width} height={height} fill={theme.colors.bg} /> : null}
30
+ {children}
31
+ </svg>
32
+ );
33
+ }
34
+
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { mmToPx, pxToMm, resolveSize, MM_PER_INCH } from '../sizing';
3
+
4
+ describe('sizing', () => {
5
+ it('converts mm to px at dpi (25.4mm = 1in)', () => {
6
+ expect(mmToPx(MM_PER_INCH, 100)).toBe(100);
7
+ expect(mmToPx(MM_PER_INCH / 2, 200)).toBe(100);
8
+ });
9
+
10
+ it('converts px to mm at dpi', () => {
11
+ expect(pxToMm(300, 300)).toBeCloseTo(MM_PER_INCH, 6);
12
+ });
13
+
14
+ it('resolves px sizes', () => {
15
+ const r = resolveSize({ unit: 'px', width: 640, height: 480 });
16
+ expect(r.width).toBe(640);
17
+ expect(r.height).toBe(480);
18
+ expect(r.mm).toBeUndefined();
19
+ });
20
+
21
+ it('resolves mm+dpi sizes', () => {
22
+ const r = resolveSize({ unit: 'mm', width: MM_PER_INCH, height: MM_PER_INCH, dpi: 150 });
23
+ expect(r.width).toBe(150);
24
+ expect(r.height).toBe(150);
25
+ expect(r.mm?.width).toBe(MM_PER_INCH);
26
+ expect(r.dpi).toBe(150);
27
+ });
28
+ });
29
+
@@ -0,0 +1,103 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { ScaleLinear } from 'd3-scale';
3
+ import { theme } from '../theme';
4
+
5
+ export type TickFormatter = (value: number) => string;
6
+
7
+ function defaultFormat(value: number) {
8
+ return String(value);
9
+ }
10
+
11
+ export function AxisBottom({
12
+ x,
13
+ y,
14
+ scale,
15
+ tickCount = 5,
16
+ tickFormat = defaultFormat,
17
+ label
18
+ }: {
19
+ x: number;
20
+ y: number;
21
+ scale: ScaleLinear<number, number>;
22
+ tickCount?: number;
23
+ tickFormat?: TickFormatter;
24
+ label?: ReactNode;
25
+ }) {
26
+ const ticks = scale.ticks(tickCount);
27
+ const [d0, d1] = scale.domain();
28
+ const x0 = scale(d0);
29
+ const x1 = scale(d1);
30
+
31
+ return (
32
+ <g transform={`translate(${x}, ${y})`}>
33
+ <line x1={x0} y1={0} x2={x1} y2={0} stroke={theme.colors.axis} strokeWidth={theme.strokes.normal} />
34
+ {ticks.map((t) => {
35
+ const px = scale(t);
36
+ return (
37
+ <g key={t} transform={`translate(${px}, 0)`}>
38
+ <line y2={6} stroke={theme.colors.axis} strokeWidth={theme.strokes.thin} />
39
+ <text y={18} fontSize={11} fill={theme.colors.text} textAnchor="middle">
40
+ {tickFormat(t)}
41
+ </text>
42
+ </g>
43
+ );
44
+ })}
45
+ {label ? (
46
+ <text x={(x0 + x1) / 2} y={36} fontSize={12} fill={theme.colors.text} textAnchor="middle">
47
+ {label}
48
+ </text>
49
+ ) : null}
50
+ </g>
51
+ );
52
+ }
53
+
54
+ export function AxisLeft({
55
+ x,
56
+ y,
57
+ scale,
58
+ tickCount = 5,
59
+ tickFormat = defaultFormat,
60
+ label
61
+ }: {
62
+ x: number;
63
+ y: number;
64
+ scale: ScaleLinear<number, number>;
65
+ tickCount?: number;
66
+ tickFormat?: TickFormatter;
67
+ label?: ReactNode;
68
+ }) {
69
+ const ticks = scale.ticks(tickCount);
70
+ const [d0, d1] = scale.domain();
71
+ const y0 = scale(d0);
72
+ const y1 = scale(d1);
73
+
74
+ return (
75
+ <g transform={`translate(${x}, ${y})`}>
76
+ <line x1={0} y1={y0} x2={0} y2={y1} stroke={theme.colors.axis} strokeWidth={theme.strokes.normal} />
77
+ {ticks.map((t) => {
78
+ const py = scale(t);
79
+ return (
80
+ <g key={t} transform={`translate(0, ${py})`}>
81
+ <line x2={-6} stroke={theme.colors.axis} strokeWidth={theme.strokes.thin} />
82
+ <text x={-10} y={4} fontSize={11} fill={theme.colors.text} textAnchor="end">
83
+ {tickFormat(t)}
84
+ </text>
85
+ </g>
86
+ );
87
+ })}
88
+ {label ? (
89
+ <text
90
+ x={-42}
91
+ y={(y0 + y1) / 2}
92
+ fontSize={12}
93
+ fill={theme.colors.text}
94
+ textAnchor="middle"
95
+ transform={`rotate(-90, ${-42}, ${(y0 + y1) / 2})`}
96
+ >
97
+ {label}
98
+ </text>
99
+ ) : null}
100
+ </g>
101
+ );
102
+ }
103
+
@@ -0,0 +1,59 @@
1
+ import type { ScaleLinear } from 'd3-scale';
2
+ import { theme } from '../theme';
3
+
4
+ export function GridLines({
5
+ x,
6
+ y,
7
+ width,
8
+ height,
9
+ xScale,
10
+ yScale,
11
+ xTicks = 5,
12
+ yTicks = 5
13
+ }: {
14
+ x: number;
15
+ y: number;
16
+ width: number;
17
+ height: number;
18
+ xScale: ScaleLinear<number, number>;
19
+ yScale: ScaleLinear<number, number>;
20
+ xTicks?: number;
21
+ yTicks?: number;
22
+ }) {
23
+ const xt = xScale.ticks(xTicks);
24
+ const yt = yScale.ticks(yTicks);
25
+
26
+ return (
27
+ <g transform={`translate(${x}, ${y})`} opacity={0.9}>
28
+ {xt.map((t) => {
29
+ const px = xScale(t);
30
+ return (
31
+ <line
32
+ key={`x-${t}`}
33
+ x1={px}
34
+ y1={0}
35
+ x2={px}
36
+ y2={height}
37
+ stroke={theme.colors.grid}
38
+ strokeWidth={theme.strokes.thin}
39
+ />
40
+ );
41
+ })}
42
+ {yt.map((t) => {
43
+ const py = yScale(t);
44
+ return (
45
+ <line
46
+ key={`y-${t}`}
47
+ x1={0}
48
+ y1={py}
49
+ x2={width}
50
+ y2={py}
51
+ stroke={theme.colors.grid}
52
+ strokeWidth={theme.strokes.thin}
53
+ />
54
+ );
55
+ })}
56
+ </g>
57
+ );
58
+ }
59
+
@@ -0,0 +1,31 @@
1
+ import { theme } from '../theme';
2
+
3
+ export type LegendItem = { label: string; color: string };
4
+
5
+ export function Legend({
6
+ x,
7
+ y,
8
+ items,
9
+ fontSize = 12,
10
+ rowGap = 6
11
+ }: {
12
+ x: number;
13
+ y: number;
14
+ items: LegendItem[];
15
+ fontSize?: number;
16
+ rowGap?: number;
17
+ }) {
18
+ return (
19
+ <g transform={`translate(${x}, ${y})`}>
20
+ {items.map((it, i) => (
21
+ <g key={it.label} transform={`translate(0, ${i * (fontSize + rowGap)})`}>
22
+ <rect x={0} y={-fontSize + 3} width={12} height={12} fill={it.color} rx={2} />
23
+ <text x={18} y={0} fontSize={fontSize} fill={theme.colors.text} dominantBaseline="middle">
24
+ {it.label}
25
+ </text>
26
+ </g>
27
+ ))}
28
+ </g>
29
+ );
30
+ }
31
+
@@ -0,0 +1,50 @@
1
+ import type { ScaleLinear } from 'd3-scale';
2
+ import { line as d3Line, curveMonotoneX } from 'd3-shape';
3
+ import type { Point } from './scales';
4
+ import { theme } from '../theme';
5
+
6
+ export function LineSeries({
7
+ xScale,
8
+ yScale,
9
+ data,
10
+ stroke = theme.colors.blue,
11
+ strokeWidth = theme.strokes.thick
12
+ }: {
13
+ xScale: ScaleLinear<number, number>;
14
+ yScale: ScaleLinear<number, number>;
15
+ data: Point[];
16
+ stroke?: string;
17
+ strokeWidth?: number;
18
+ }) {
19
+ const d = d3Line<Point>()
20
+ .x((p) => xScale(p.x))
21
+ .y((p) => yScale(p.y))
22
+ .curve(curveMonotoneX)(data);
23
+
24
+ if (!d) return null;
25
+
26
+ return <path d={d} fill="none" stroke={stroke} strokeWidth={strokeWidth} />;
27
+ }
28
+
29
+ export function ScatterSeries({
30
+ xScale,
31
+ yScale,
32
+ data,
33
+ r = 3,
34
+ fill = theme.colors.teal
35
+ }: {
36
+ xScale: ScaleLinear<number, number>;
37
+ yScale: ScaleLinear<number, number>;
38
+ data: Point[];
39
+ r?: number;
40
+ fill?: string;
41
+ }) {
42
+ return (
43
+ <g>
44
+ {data.map((p, idx) => (
45
+ <circle key={idx} cx={xScale(p.x)} cy={yScale(p.y)} r={r} fill={fill} />
46
+ ))}
47
+ </g>
48
+ );
49
+ }
50
+
@@ -0,0 +1,19 @@
1
+ import { extent } from 'd3-array';
2
+ import { scaleLinear } from 'd3-scale';
3
+
4
+ export type Point = { x: number; y: number };
5
+
6
+ export function extentX(points: Point[]): [number, number] {
7
+ const ex = extent(points, (d) => d.x);
8
+ return [ex[0] ?? 0, ex[1] ?? 1];
9
+ }
10
+
11
+ export function extentY(points: Point[]): [number, number] {
12
+ const ex = extent(points, (d) => d.y);
13
+ return [ex[0] ?? 0, ex[1] ?? 1];
14
+ }
15
+
16
+ export function linearScale(domain: [number, number], range: [number, number]) {
17
+ return scaleLinear(domain, range);
18
+ }
19
+
@@ -0,0 +1,134 @@
1
+ import { useId } from 'react';
2
+ import { theme } from '../theme';
3
+
4
+ function safeId(id: string) {
5
+ return id.replace(/[^a-zA-Z0-9_-]/g, '-');
6
+ }
7
+
8
+ export function Label({
9
+ x,
10
+ y,
11
+ text,
12
+ fontSize = 13,
13
+ fill = theme.colors.text,
14
+ anchor = 'start'
15
+ }: {
16
+ x: number;
17
+ y: number;
18
+ text: string;
19
+ fontSize?: number;
20
+ fill?: string;
21
+ anchor?: 'start' | 'middle' | 'end';
22
+ }) {
23
+ return (
24
+ <text x={x} y={y} fontSize={fontSize} fill={fill} textAnchor={anchor} dominantBaseline="middle">
25
+ {text}
26
+ </text>
27
+ );
28
+ }
29
+
30
+ export function Box({
31
+ x,
32
+ y,
33
+ width,
34
+ height,
35
+ rx = theme.radii.md,
36
+ fill = theme.colors.panel,
37
+ stroke = theme.colors.axis,
38
+ strokeWidth = theme.strokes.normal,
39
+ label
40
+ }: {
41
+ x: number;
42
+ y: number;
43
+ width: number;
44
+ height: number;
45
+ rx?: number;
46
+ fill?: string;
47
+ stroke?: string;
48
+ strokeWidth?: number;
49
+ label?: string;
50
+ }) {
51
+ return (
52
+ <g>
53
+ <rect x={x} y={y} width={width} height={height} rx={rx} fill={fill} stroke={stroke} strokeWidth={strokeWidth} />
54
+ {label ? (
55
+ <text
56
+ x={x + width / 2}
57
+ y={y + height / 2}
58
+ fontSize={13}
59
+ fill={theme.colors.text}
60
+ textAnchor="middle"
61
+ dominantBaseline="middle"
62
+ >
63
+ {label}
64
+ </text>
65
+ ) : null}
66
+ </g>
67
+ );
68
+ }
69
+
70
+ export function Arrow({
71
+ x1,
72
+ y1,
73
+ x2,
74
+ y2,
75
+ stroke = theme.colors.axis,
76
+ strokeWidth = theme.strokes.thick
77
+ }: {
78
+ x1: number;
79
+ y1: number;
80
+ x2: number;
81
+ y2: number;
82
+ stroke?: string;
83
+ strokeWidth?: number;
84
+ }) {
85
+ const rid = safeId(useId());
86
+ const markerId = `im-arrow-${rid}`;
87
+
88
+ return (
89
+ <g>
90
+ <defs>
91
+ <marker id={markerId} markerWidth={10} markerHeight={10} refX={9} refY={5} orient="auto">
92
+ <path d="M 0 0 L 10 5 L 0 10 z" fill={stroke} />
93
+ </marker>
94
+ </defs>
95
+ <line
96
+ x1={x1}
97
+ y1={y1}
98
+ x2={x2}
99
+ y2={y2}
100
+ stroke={stroke}
101
+ strokeWidth={strokeWidth}
102
+ markerEnd={`url(#${markerId})`}
103
+ />
104
+ </g>
105
+ );
106
+ }
107
+
108
+ export function Callout({
109
+ x,
110
+ y,
111
+ width,
112
+ height,
113
+ text,
114
+ fill = '#FEF3C7',
115
+ stroke = '#F59E0B'
116
+ }: {
117
+ x: number;
118
+ y: number;
119
+ width: number;
120
+ height: number;
121
+ text: string;
122
+ fill?: string;
123
+ stroke?: string;
124
+ }) {
125
+ return (
126
+ <g>
127
+ <rect x={x} y={y} width={width} height={height} rx={theme.radii.sm} fill={fill} stroke={stroke} />
128
+ <text x={x + theme.spacing.sm} y={y + height / 2} fontSize={12} fill={theme.colors.text} dominantBaseline="middle">
129
+ {text}
130
+ </text>
131
+ </g>
132
+ );
133
+ }
134
+
@@ -0,0 +1,60 @@
1
+ import { Children } from 'react';
2
+ import { theme } from '../theme';
3
+
4
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz';
5
+
6
+ function panelLabel(i: number) {
7
+ return alphabet[i] ? `${alphabet[i]})` : `${i + 1})`;
8
+ }
9
+
10
+ export function PanelGrid({
11
+ x,
12
+ y,
13
+ width,
14
+ height,
15
+ rows,
16
+ cols,
17
+ gap = theme.spacing.lg,
18
+ margin = theme.spacing.lg,
19
+ showLabels = true,
20
+ children
21
+ }: {
22
+ x: number;
23
+ y: number;
24
+ width: number;
25
+ height: number;
26
+ rows: number;
27
+ cols: number;
28
+ gap?: number;
29
+ margin?: number;
30
+ showLabels?: boolean;
31
+ children: React.ReactNode;
32
+ }) {
33
+ const items = Children.toArray(children);
34
+ const innerW = width - 2 * margin - (cols - 1) * gap;
35
+ const innerH = height - 2 * margin - (rows - 1) * gap;
36
+ const cellW = innerW / cols;
37
+ const cellH = innerH / rows;
38
+
39
+ return (
40
+ <g transform={`translate(${x}, ${y})`}>
41
+ {items.map((child, i) => {
42
+ const row = Math.floor(i / cols);
43
+ const col = i % cols;
44
+ const cx = margin + col * (cellW + gap);
45
+ const cy = margin + row * (cellH + gap);
46
+ return (
47
+ <g key={i} transform={`translate(${cx}, ${cy})`}>
48
+ {showLabels ? (
49
+ <text x={0} y={-8} fontSize={12} fill={theme.colors.subtleText} fontWeight={600}>
50
+ {panelLabel(i)}
51
+ </text>
52
+ ) : null}
53
+ <g>{child}</g>
54
+ </g>
55
+ );
56
+ })}
57
+ </g>
58
+ );
59
+ }
60
+
@@ -0,0 +1,35 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { loadMathJax, trackMathTask } from './mathjax';
3
+
4
+ export function MathSvg({
5
+ tex,
6
+ x,
7
+ y,
8
+ scale = 1
9
+ }: {
10
+ tex: string;
11
+ x: number;
12
+ y: number;
13
+ scale?: number;
14
+ }) {
15
+ const ref = useRef<SVGGElement | null>(null);
16
+
17
+ useEffect(() => {
18
+ const run = async () => {
19
+ await loadMathJax();
20
+ if (!ref.current) return;
21
+
22
+ const node = await window.MathJax?.tex2svgPromise?.(tex, { display: true });
23
+ if (!node || !ref.current) return;
24
+
25
+ const el = (node as any).querySelector?.('svg') ?? node;
26
+ ref.current.replaceChildren(el as any);
27
+ ref.current.setAttribute('data-imagine-math', 'done');
28
+ };
29
+
30
+ const p = run();
31
+ trackMathTask(p);
32
+ }, [tex]);
33
+
34
+ return <g ref={ref} data-imagine-math="pending" transform={`translate(${x}, ${y}) scale(${scale})`} />;
35
+ }