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.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/bin/create-imagine.js +407 -0
- package/package.json +19 -0
- package/templates/blank/README.md +96 -0
- package/templates/blank/gitignore +6 -0
- package/templates/blank/index.html +13 -0
- package/templates/blank/package.json +42 -0
- package/templates/blank/projects/example/figures/hello-world.tsx +45 -0
- package/templates/blank/projects/example/manifest.ts +44 -0
- package/templates/blank/projects/example/project.ts +16 -0
- package/templates/blank/projects/example/props.json +4 -0
- package/templates/blank/scripts/list.ts +46 -0
- package/templates/blank/scripts/projects.ts +43 -0
- package/templates/blank/scripts/render.ts +288 -0
- package/templates/blank/scripts/server.ts +117 -0
- package/templates/blank/src/core/__tests__/controls.test.ts +14 -0
- package/templates/blank/src/core/__tests__/manifest.test.ts +45 -0
- package/templates/blank/src/core/__tests__/props.test.ts +9 -0
- package/templates/blank/src/core/controls.ts +14 -0
- package/templates/blank/src/core/manifest.ts +134 -0
- package/templates/blank/src/core/props.ts +7 -0
- package/templates/blank/src/framework/Figure.tsx +34 -0
- package/templates/blank/src/framework/__tests__/sizing.test.ts +29 -0
- package/templates/blank/src/framework/charts/Axes.tsx +103 -0
- package/templates/blank/src/framework/charts/GridLines.tsx +59 -0
- package/templates/blank/src/framework/charts/Legend.tsx +31 -0
- package/templates/blank/src/framework/charts/Series.tsx +50 -0
- package/templates/blank/src/framework/charts/scales.ts +19 -0
- package/templates/blank/src/framework/diagrams/primitives.tsx +134 -0
- package/templates/blank/src/framework/layout/PanelGrid.tsx +60 -0
- package/templates/blank/src/framework/math/MathSvg.tsx +35 -0
- package/templates/blank/src/framework/math/mathjax.ts +64 -0
- package/templates/blank/src/framework/sizing.ts +28 -0
- package/templates/blank/src/framework/theme.ts +35 -0
- package/templates/blank/src/framework/types.ts +42 -0
- package/templates/blank/src/main.tsx +11 -0
- package/templates/blank/src/studio/StudioApp.tsx +130 -0
- package/templates/blank/src/studio/StudioRoot.tsx +14 -0
- package/templates/blank/src/studio/base64url.ts +8 -0
- package/templates/blank/src/studio/figureLoader.ts +30 -0
- package/templates/blank/src/studio/projectLoader.ts +40 -0
- package/templates/blank/src/studio/propsApi.ts +26 -0
- package/templates/blank/src/studio/routes/FigureView.tsx +365 -0
- package/templates/blank/src/studio/routes/ProjectHome.tsx +107 -0
- package/templates/blank/src/studio/routes/ProjectsHome.tsx +63 -0
- package/templates/blank/src/studio/routes/RenderView.tsx +123 -0
- package/templates/blank/src/studio/studio.css +540 -0
- package/templates/blank/src/studio/useProjectProps.ts +129 -0
- package/templates/blank/src/vite-env.d.ts +2 -0
- package/templates/blank/tsconfig.json +20 -0
- package/templates/blank/vite.config.ts +82 -0
- package/templates/blank/vitest.config.ts +8 -0
- package/templates/example/README.md +96 -0
- package/templates/example/gitignore +6 -0
- package/templates/example/index.html +13 -0
- package/templates/example/package.json +42 -0
- package/templates/example/projects/example/figures/ai-agent-architecture.tsx +133 -0
- package/templates/example/projects/example/figures/equation.tsx +29 -0
- package/templates/example/projects/example/figures/hello-world.tsx +45 -0
- package/templates/example/projects/example/figures/line-chart.tsx +80 -0
- package/templates/example/projects/example/figures/multi-panel.tsx +51 -0
- package/templates/example/projects/example/figures/pipeline-diagram.tsx +51 -0
- package/templates/example/projects/example/manifest.ts +161 -0
- package/templates/example/projects/example/project.ts +31 -0
- package/templates/example/projects/example/props.json +10 -0
- package/templates/example/public/projects/example/previews/ai-agent-architecture--default.png +0 -0
- package/templates/example/public/projects/example/previews/equation--default.png +0 -0
- package/templates/example/public/projects/example/previews/hello-world--default.png +0 -0
- package/templates/example/public/projects/example/previews/line-chart--default.png +0 -0
- package/templates/example/public/projects/example/previews/multi-panel--default.png +0 -0
- package/templates/example/public/projects/example/previews/pipeline-diagram--default.png +0 -0
- package/templates/example/scripts/list.ts +46 -0
- package/templates/example/scripts/projects.ts +43 -0
- package/templates/example/scripts/render.ts +288 -0
- package/templates/example/scripts/server.ts +117 -0
- package/templates/example/src/core/__tests__/controls.test.ts +14 -0
- package/templates/example/src/core/__tests__/manifest.test.ts +45 -0
- package/templates/example/src/core/__tests__/props.test.ts +9 -0
- package/templates/example/src/core/controls.ts +14 -0
- package/templates/example/src/core/manifest.ts +134 -0
- package/templates/example/src/core/props.ts +7 -0
- package/templates/example/src/framework/Figure.tsx +34 -0
- package/templates/example/src/framework/__tests__/sizing.test.ts +29 -0
- package/templates/example/src/framework/charts/Axes.tsx +103 -0
- package/templates/example/src/framework/charts/GridLines.tsx +59 -0
- package/templates/example/src/framework/charts/Legend.tsx +31 -0
- package/templates/example/src/framework/charts/Series.tsx +50 -0
- package/templates/example/src/framework/charts/scales.ts +19 -0
- package/templates/example/src/framework/diagrams/primitives.tsx +134 -0
- package/templates/example/src/framework/layout/PanelGrid.tsx +60 -0
- package/templates/example/src/framework/math/MathSvg.tsx +35 -0
- package/templates/example/src/framework/math/mathjax.ts +64 -0
- package/templates/example/src/framework/sizing.ts +28 -0
- package/templates/example/src/framework/theme.ts +35 -0
- package/templates/example/src/framework/types.ts +42 -0
- package/templates/example/src/main.tsx +11 -0
- package/templates/example/src/studio/StudioApp.tsx +130 -0
- package/templates/example/src/studio/StudioRoot.tsx +14 -0
- package/templates/example/src/studio/base64url.ts +8 -0
- package/templates/example/src/studio/figureLoader.ts +30 -0
- package/templates/example/src/studio/projectLoader.ts +40 -0
- package/templates/example/src/studio/propsApi.ts +26 -0
- package/templates/example/src/studio/routes/FigureView.tsx +365 -0
- package/templates/example/src/studio/routes/ProjectHome.tsx +107 -0
- package/templates/example/src/studio/routes/ProjectsHome.tsx +63 -0
- package/templates/example/src/studio/routes/RenderView.tsx +123 -0
- package/templates/example/src/studio/studio.css +540 -0
- package/templates/example/src/studio/useProjectProps.ts +129 -0
- package/templates/example/src/vite-env.d.ts +2 -0
- package/templates/example/tsconfig.json +20 -0
- package/templates/example/vite.config.ts +82 -0
- 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,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
|
+
}
|