@tinyvolt/pie 0.0.0-stage → 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/.nvmrc +1 -0
- package/LICENSE +21 -0
- package/README.md +56 -2
- package/functions/midpoint.ts +29 -0
- package/functions/square.ts +20 -0
- package/init/index.js +87 -0
- package/init/site-agents.md +253 -0
- package/init/site-gitignore +28 -0
- package/init/site-tsconfig.json +20 -0
- package/init/site-vite.config.ts +6 -0
- package/layouts/header.tsx +16 -0
- package/layouts/index.tsx +10 -0
- package/nodes/square.tsx +36 -0
- package/package.json +59 -4
- package/pages/example.md +7 -0
- package/pages/python-editor.md +29 -0
- package/public/favicon.ico +0 -0
- package/public/logo.svg +5 -0
- package/site.yml +1 -0
- package/src/app.css +343 -0
- package/src/app.tsx +30 -0
- package/src/authorStyles.ts +3 -0
- package/src/components/Header.tsx +16 -0
- package/src/components/PageTitle.tsx +10 -0
- package/src/components/SplitPane.tsx +170 -0
- package/src/components/icons.tsx +22 -0
- package/src/entry-client.tsx +4 -0
- package/src/entry-server.tsx +21 -0
- package/src/global.d.ts +7 -0
- package/src/lib/appDir.ts +4 -0
- package/src/lib/autodiff/index.ts +49 -0
- package/src/lib/layouts/layout.ts +20 -0
- package/src/lib/layouts/vitePlugin.ts +39 -0
- package/src/lib/markdown/components/CanvasPage.tsx +25 -0
- package/src/lib/markdown/components/MarkdownBody.tsx +41 -0
- package/src/lib/markdown/components/PythonEditor.tsx +166 -0
- package/src/lib/markdown/frontmatter.ts +81 -0
- package/src/lib/markdown/pages.ts +23 -0
- package/src/lib/markdown/render.ts +54 -0
- package/src/lib/markdown/vitePlugin.ts +94 -0
- package/src/lib/nodes/components/CenteredGrid.tsx +59 -0
- package/src/lib/nodes/components/NodeCanvas.tsx +58 -0
- package/src/lib/nodes/execute.ts +166 -0
- package/src/lib/nodes/functions/Node2NodeFunc.ts +307 -0
- package/src/lib/nodes/functions/array.ts +36 -0
- package/src/lib/nodes/functions/autodiff.ts +64 -0
- package/src/lib/nodes/functions/basic.ts +298 -0
- package/src/lib/nodes/functions/registry.ts +46 -0
- package/src/lib/nodes/functions/sideEffects.ts +193 -0
- package/src/lib/nodes/functions/vitePlugin.ts +40 -0
- package/src/lib/nodes/hooks/useCanvasResize.ts +38 -0
- package/src/lib/nodes/jax.ts +191 -0
- package/src/lib/nodes/render/renderLogic.tsx +306 -0
- package/src/lib/nodes/state/NameGenerator.ts +31 -0
- package/src/lib/nodes/state/Node.ts +211 -0
- package/src/lib/nodes/state/NodeStore.ts +110 -0
- package/src/lib/nodes/tensors/broadcast.ts +101 -0
- package/src/lib/nodes/tensors/tensor.ts +42 -0
- package/src/lib/python/lift.ts +105 -0
- package/src/lib/python/py/__pycache__/pie.cpython-314.pyc +0 -0
- package/src/lib/python/py/pie.py +231 -0
- package/src/lib/python/py/pie_mpl.py +15 -0
- package/src/lib/python/py/show_figures.py +5 -0
- package/src/lib/python/runner.ts +202 -0
- package/src/lib/python/worker.ts +109 -0
- package/src/lib/site/vitePlugin.ts +43 -0
- package/src/routes/[...slug].tsx +38 -0
- package/src/routes/about.tsx +10 -0
- package/src/routes/index.tsx +14 -0
- package/src/routes/pages/index.tsx +20 -0
- package/styles/theme.css +27 -0
- package/vite.ts +61 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { numpy as np, defaultDevice, init, jit, blockUntilReady, vmap } from '@jax-js/jax';
|
|
2
|
+
|
|
3
|
+
let currentDevice: 'cpu' | 'wasm' | 'webgpu' = 'cpu';
|
|
4
|
+
const setDevice = (d: 'cpu' | 'wasm' | 'webgpu'): void => {
|
|
5
|
+
currentDevice = d;
|
|
6
|
+
defaultDevice(d);
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// Keep cpu as an immediate synchronous fallback until init() resolves.
|
|
10
|
+
setDevice('cpu');
|
|
11
|
+
|
|
12
|
+
export const deviceReady: Promise<void> = (async () => {
|
|
13
|
+
const devices = await init();
|
|
14
|
+
if (devices.includes('webgpu')) {
|
|
15
|
+
setDevice('webgpu');
|
|
16
|
+
} else {
|
|
17
|
+
setDevice('wasm');
|
|
18
|
+
}
|
|
19
|
+
})();
|
|
20
|
+
|
|
21
|
+
export const getDevice = (): 'cpu' | 'wasm' | 'webgpu' => currentDevice;
|
|
22
|
+
|
|
23
|
+
export const withCpuDevice = <T>(fn: () => T): T => {
|
|
24
|
+
const prev = currentDevice;
|
|
25
|
+
if (prev === 'cpu') return fn();
|
|
26
|
+
setDevice('cpu');
|
|
27
|
+
try {
|
|
28
|
+
return fn();
|
|
29
|
+
} finally {
|
|
30
|
+
setDevice(prev);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export { np, jit, blockUntilReady, vmap };
|
|
35
|
+
|
|
36
|
+
const USE_JAX_TENSORS = true;
|
|
37
|
+
|
|
38
|
+
export type Dtype = typeof USE_JAX_TENSORS extends true ? np.Array : number;
|
|
39
|
+
|
|
40
|
+
let numericMode = false;
|
|
41
|
+
export const setNumericMode = (on: boolean): void => { numericMode = on; };
|
|
42
|
+
export const isNumericMode = (): boolean => numericMode;
|
|
43
|
+
const useJax = (): boolean => USE_JAX_TENSORS && !numericMode;
|
|
44
|
+
|
|
45
|
+
export const withNumericMode = <T>(fn: () => T): T => {
|
|
46
|
+
if (numericMode) return fn();
|
|
47
|
+
setNumericMode(true);
|
|
48
|
+
try {
|
|
49
|
+
return fn();
|
|
50
|
+
} finally {
|
|
51
|
+
setNumericMode(false);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Wrap a plain number as a Dtype value */
|
|
56
|
+
export const scalar = (v: number): Dtype =>
|
|
57
|
+
(useJax() ? np.array(v) : v) as Dtype;
|
|
58
|
+
|
|
59
|
+
/** Extract a plain number from a Dtype value */
|
|
60
|
+
export const num = (a: Dtype): number => {
|
|
61
|
+
try {
|
|
62
|
+
if (typeof a === 'number') return a;
|
|
63
|
+
|
|
64
|
+
const arr = a as np.Array;
|
|
65
|
+
|
|
66
|
+
if (typeof arr.item === 'function') {
|
|
67
|
+
// If it's a real array and we call item(), we should do so without consuming it,
|
|
68
|
+
// so we should use its .ref if available
|
|
69
|
+
const arrRef = (arr as np.Array).ref;
|
|
70
|
+
if (arrRef && typeof arrRef.item === 'function') {
|
|
71
|
+
try {
|
|
72
|
+
return arrRef.item();
|
|
73
|
+
} catch (e) {
|
|
74
|
+
// Fallback if ref is already freed or something
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return arr.item();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// If we're tracing and the underlying value has a primal
|
|
81
|
+
if ((arr as any).primal && typeof (arr as any).primal.pval?.val?.item === 'function') {
|
|
82
|
+
return (arr as any).primal.pval.val.item();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// As a last resort, just cast it to number (which usually yields NaN if it's an object)
|
|
86
|
+
return Number(arr);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error(`Error extracting number from Dtype: ${error instanceof Error ? error.message : String(error)}`);
|
|
89
|
+
return NaN;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Polymorphic Math Layer */
|
|
94
|
+
|
|
95
|
+
// In numeric mode an operand may still be a JAX tensor (a value cached by a
|
|
96
|
+
// node that last recomputed in tensor mode); borrow-read it to a number.
|
|
97
|
+
const asNum = (a: Dtype): number =>
|
|
98
|
+
(typeof a === 'number' ? a : num(a));
|
|
99
|
+
|
|
100
|
+
// In tensor mode an operand may still be a plain number (a value cached by a
|
|
101
|
+
// node that last recomputed in numeric mode); promote it to a fresh array.
|
|
102
|
+
const asArr = (a: Dtype): np.Array =>
|
|
103
|
+
(typeof a === 'number' ? np.array(a) : a) as np.Array;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Gets a reference to a Dtype if it is a JAX tensor,
|
|
107
|
+
* so that it is not consumed when passed to another operation.
|
|
108
|
+
*/
|
|
109
|
+
export const ref = (a: Dtype): Dtype =>
|
|
110
|
+
(useJax() && typeof a !== 'number' ? (a as np.Array).ref : a) as Dtype;
|
|
111
|
+
|
|
112
|
+
export const add = (a: Dtype, b: Dtype): Dtype =>
|
|
113
|
+
(useJax() ? np.add(asArr(a), asArr(b)) : (asNum(a) + asNum(b))) as Dtype;
|
|
114
|
+
|
|
115
|
+
export const sub = (a: Dtype, b: Dtype): Dtype =>
|
|
116
|
+
(useJax() ? np.subtract(asArr(a), asArr(b)) : (asNum(a) - asNum(b))) as Dtype;
|
|
117
|
+
|
|
118
|
+
export const mul = (a: Dtype, b: Dtype): Dtype =>
|
|
119
|
+
(useJax() ? np.multiply(asArr(a), asArr(b)) : (asNum(a) * asNum(b))) as Dtype;
|
|
120
|
+
|
|
121
|
+
export const div = (a: Dtype, b: Dtype): Dtype =>
|
|
122
|
+
(useJax() ? np.divide(asArr(a), asArr(b)) : (asNum(a) / asNum(b))) as Dtype;
|
|
123
|
+
|
|
124
|
+
export const sqrt = (a: Dtype): Dtype =>
|
|
125
|
+
(useJax() ? np.sqrt(asArr(a)) : Math.sqrt(asNum(a))) as Dtype;
|
|
126
|
+
|
|
127
|
+
export const pow = (a: Dtype, b: Dtype): Dtype =>
|
|
128
|
+
(useJax() ? np.power(asArr(a), asArr(b)) : Math.pow(asNum(a), asNum(b))) as Dtype;
|
|
129
|
+
|
|
130
|
+
export const cos = (a: Dtype): Dtype =>
|
|
131
|
+
(useJax() ? np.cos(asArr(a)) : Math.cos(asNum(a))) as Dtype;
|
|
132
|
+
|
|
133
|
+
export const sin = (a: Dtype): Dtype =>
|
|
134
|
+
(useJax() ? np.sin(asArr(a)) : Math.sin(asNum(a))) as Dtype;
|
|
135
|
+
|
|
136
|
+
export const acos = (a: Dtype): Dtype =>
|
|
137
|
+
(useJax() ? np.arccos(asArr(a)) : Math.acos(asNum(a))) as Dtype;
|
|
138
|
+
|
|
139
|
+
export const asin = (a: Dtype): Dtype =>
|
|
140
|
+
(useJax() ? np.arcsin(asArr(a)) : Math.asin(asNum(a))) as Dtype;
|
|
141
|
+
|
|
142
|
+
export const atan = (a: Dtype): Dtype =>
|
|
143
|
+
(useJax() ? np.arctan(asArr(a)) : Math.atan(asNum(a))) as Dtype;
|
|
144
|
+
|
|
145
|
+
export const atan2 = (a: Dtype, b: Dtype): Dtype =>
|
|
146
|
+
(useJax() ? np.arctan2(asArr(a), asArr(b)) : Math.atan2(asNum(a), asNum(b))) as Dtype;
|
|
147
|
+
|
|
148
|
+
export const log = (a: Dtype): Dtype =>
|
|
149
|
+
(useJax() ? np.log(asArr(a)) : Math.log(asNum(a))) as Dtype;
|
|
150
|
+
|
|
151
|
+
export const exp = (a: Dtype): Dtype =>
|
|
152
|
+
(useJax() ? np.exp(asArr(a)) : Math.exp(asNum(a))) as Dtype;
|
|
153
|
+
|
|
154
|
+
export const log10 = (a: Dtype): Dtype =>
|
|
155
|
+
(useJax() ? np.log10(asArr(a)) : Math.log10(asNum(a))) as Dtype;
|
|
156
|
+
|
|
157
|
+
export const abs = (a: Dtype): Dtype =>
|
|
158
|
+
(useJax() ? np.abs(asArr(a)) : Math.abs(asNum(a))) as Dtype;
|
|
159
|
+
|
|
160
|
+
export const sign = (a: Dtype): Dtype =>
|
|
161
|
+
(useJax() ? np.sign(asArr(a)) : Math.sign(asNum(a))) as Dtype;
|
|
162
|
+
|
|
163
|
+
export const floor = (a: Dtype): Dtype =>
|
|
164
|
+
(useJax() ? np.floor(asArr(a)) : Math.floor(asNum(a))) as Dtype;
|
|
165
|
+
|
|
166
|
+
export const ceil = (a: Dtype): Dtype =>
|
|
167
|
+
(useJax() ? np.ceil(asArr(a)) : Math.ceil(asNum(a))) as Dtype;
|
|
168
|
+
|
|
169
|
+
export const round = (a: Dtype): Dtype =>
|
|
170
|
+
(useJax() ? np.round(asArr(a)) : Math.round(asNum(a))) as Dtype;
|
|
171
|
+
|
|
172
|
+
export const tan = (a: Dtype): Dtype =>
|
|
173
|
+
(useJax() ? np.tan(asArr(a)) : Math.tan(asNum(a))) as Dtype;
|
|
174
|
+
|
|
175
|
+
export const mod = (a: Dtype, b: Dtype): Dtype =>
|
|
176
|
+
(useJax() ? np.fmod(asArr(a), asArr(b)) : (asNum(a) % asNum(b))) as Dtype;
|
|
177
|
+
|
|
178
|
+
export const minimum = (a: Dtype, b: Dtype): Dtype =>
|
|
179
|
+
(useJax() ? np.minimum(asArr(a), asArr(b)) : Math.min(asNum(a), asNum(b))) as Dtype;
|
|
180
|
+
|
|
181
|
+
export const maximum = (a: Dtype, b: Dtype): Dtype =>
|
|
182
|
+
(useJax() ? np.maximum(asArr(a), asArr(b)) : Math.max(asNum(a), asNum(b))) as Dtype;
|
|
183
|
+
|
|
184
|
+
export const mean = (a: Dtype): Dtype =>
|
|
185
|
+
(useJax() ? np.mean(asArr(a)) : asNum(a)) as Dtype;
|
|
186
|
+
|
|
187
|
+
export const std = (a: Dtype): Dtype =>
|
|
188
|
+
(useJax() ? np.std(asArr(a)) : asNum(a)) as Dtype;
|
|
189
|
+
|
|
190
|
+
export const var_ = (a: Dtype): Dtype =>
|
|
191
|
+
(useJax() ? np.var_(asArr(a)) : asNum(a)) as Dtype;
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import type { PointNode, LineNode, TriangleNode, CircleNode, EllipseNode, ArcNode, BezierQuadraticNode, BezierCubicNode, PointGradNode, TextNode } from "../state/Node";
|
|
2
|
+
import { num } from "../jax";
|
|
3
|
+
import { Accessor, For, Show } from "solid-js";
|
|
4
|
+
|
|
5
|
+
// Sizes are in pixels. Strokes use `non-scaling-stroke` so their width ignores zoom;
|
|
6
|
+
// the point radius is divided by `unit` (pixels per grid unit) instead.
|
|
7
|
+
const COLOR = 'currentColor';
|
|
8
|
+
const STROKE_WIDTH = 2;
|
|
9
|
+
const POINT_RADIUS = 4;
|
|
10
|
+
const ARROW_HEAD = 10;
|
|
11
|
+
|
|
12
|
+
// Each render function runs once per element. It reads the node accessor inside
|
|
13
|
+
// the attributes, so a node change updates those attributes in place.
|
|
14
|
+
const renderPoint = (point: Accessor<PointNode>, unit: Accessor<number>) => {
|
|
15
|
+
return (
|
|
16
|
+
<circle cx={num(point().x)} cy={num(point().y)} r={POINT_RADIUS / unit()} fill={point().fill ?? COLOR} />
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const renderLine = (line: Accessor<LineNode>) => {
|
|
21
|
+
return (
|
|
22
|
+
<Show when={line().p1 && line().p2}>
|
|
23
|
+
<line x1={num(line().p1.x)} y1={num(line().p1.y)} x2={num(line().p2.x)} y2={num(line().p2.y)} stroke={line().stroke ?? COLOR} stroke-width={STROKE_WIDTH} vector-effect="non-scaling-stroke" />
|
|
24
|
+
</Show>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const renderTriangle = (triangle: Accessor<TriangleNode>) => {
|
|
29
|
+
const pointsStr = () => {
|
|
30
|
+
const [p1, p2, p3] = triangle().vertices;
|
|
31
|
+
return `${num(p1.x)},${num(p1.y)} ${num(p2.x)},${num(p2.y)} ${num(p3.x)},${num(p3.y)}`;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<Show when={triangle().vertices[0] && triangle().vertices[1] && triangle().vertices[2]}>
|
|
36
|
+
<polygon points={pointsStr()} fill={triangle().fill ?? 'none'} stroke={triangle().stroke ?? COLOR} stroke-width={STROKE_WIDTH} vector-effect="non-scaling-stroke" />
|
|
37
|
+
</Show>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const renderCircle = (circle: Accessor<CircleNode>) => {
|
|
42
|
+
return (
|
|
43
|
+
<Show when={circle().center}>
|
|
44
|
+
<circle cx={num(circle().center.x)} cy={num(circle().center.y)} r={num(circle().radius.value)} fill={circle().fill ?? 'none'} stroke={circle().stroke ?? COLOR} stroke-width={STROKE_WIDTH} vector-effect="non-scaling-stroke" />
|
|
45
|
+
</Show>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const renderEllipse = (ellipse: Accessor<EllipseNode>) => {
|
|
50
|
+
const cx = () => num(ellipse().center.x);
|
|
51
|
+
const cy = () => num(ellipse().center.y);
|
|
52
|
+
const rx = () => num(ellipse().radiusX.value);
|
|
53
|
+
const ry = () => num(ellipse().radiusY.value);
|
|
54
|
+
const rotation = () => num(ellipse().rotation.value);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<Show when={ellipse().center}>
|
|
58
|
+
<ellipse cx={cx()} cy={cy()} rx={rx()} ry={ry()} transform={`rotate(${rotation()} ${cx()} ${cy()})`} fill={ellipse().fill ?? 'none'} stroke={ellipse().stroke ?? COLOR} stroke-width={STROKE_WIDTH} vector-effect="non-scaling-stroke" />
|
|
59
|
+
</Show>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const renderArc = (arc: Accessor<ArcNode>) => {
|
|
64
|
+
const pathData = () => {
|
|
65
|
+
const center = arc().center;
|
|
66
|
+
const startAngleRad = (num(arc().startAngle.value) * Math.PI) / 180;
|
|
67
|
+
const endAngleRad = (num(arc().endAngle.value) * Math.PI) / 180;
|
|
68
|
+
const arcRadius = num(arc().radius.value);
|
|
69
|
+
const cx = num(center.x), cy = num(center.y);
|
|
70
|
+
|
|
71
|
+
const startX = cx + arcRadius * Math.cos(startAngleRad);
|
|
72
|
+
const startY = cy + arcRadius * Math.sin(startAngleRad);
|
|
73
|
+
const endX = cx + arcRadius * Math.cos(endAngleRad);
|
|
74
|
+
const endY = cy + arcRadius * Math.sin(endAngleRad);
|
|
75
|
+
|
|
76
|
+
const largeArcFlag = Math.abs(num(arc().endAngle.value) - num(arc().startAngle.value)) > 180 ? 1 : 0;
|
|
77
|
+
|
|
78
|
+
return `M ${startX},${startY} A ${arcRadius},${arcRadius} 0 ${largeArcFlag} 1 ${endX},${endY}`;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<Show when={arc().center}>
|
|
83
|
+
<path d={pathData()} fill={arc().fill ?? 'none'} stroke={arc().stroke ?? COLOR} stroke-width={STROKE_WIDTH} vector-effect="non-scaling-stroke" />
|
|
84
|
+
</Show>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const renderBezierQuadratic = (bezier: Accessor<BezierQuadraticNode>) => {
|
|
89
|
+
const pathData = () => {
|
|
90
|
+
const { p1, control, p2 } = bezier();
|
|
91
|
+
return `M ${num(p1.x)},${num(p1.y)} Q ${num(control.x)},${num(control.y)} ${num(p2.x)},${num(p2.y)}`;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<Show when={bezier().p1 && bezier().control && bezier().p2}>
|
|
96
|
+
<path d={pathData()} fill={bezier().fill ?? 'none'} stroke={bezier().stroke ?? COLOR} stroke-width={STROKE_WIDTH} vector-effect="non-scaling-stroke" />
|
|
97
|
+
</Show>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const renderBezierCubic = (bezier: Accessor<BezierCubicNode>) => {
|
|
102
|
+
const pathData = () => {
|
|
103
|
+
const { p1, control1, control2, p2 } = bezier();
|
|
104
|
+
return `M ${num(p1.x)},${num(p1.y)} C ${num(control1.x)},${num(control1.y)} ${num(control2.x)},${num(control2.y)} ${num(p2.x)},${num(p2.y)}`;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<Show when={bezier().p1 && bezier().control1 && bezier().control2 && bezier().p2}>
|
|
109
|
+
<path d={pathData()} fill={bezier().fill ?? 'none'} stroke={bezier().stroke ?? COLOR} stroke-width={STROKE_WIDTH} vector-effect="non-scaling-stroke" />
|
|
110
|
+
</Show>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Draws (dx, dy) in grid units, starting at the point. No arrow when it is zero or NaN.
|
|
115
|
+
const renderPointGrad = (grad: Accessor<PointGradNode>, unit: Accessor<number>) => {
|
|
116
|
+
const arrow = () => {
|
|
117
|
+
const g = grad();
|
|
118
|
+
const x = num(g.point.x), y = num(g.point.y), dx = num(g.dx), dy = num(g.dy);
|
|
119
|
+
const length = Math.hypot(dx, dy);
|
|
120
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(length) || length === 0) return null;
|
|
121
|
+
const ux = dx / length, uy = dy / length;
|
|
122
|
+
// The head shrinks to fit an arrow shorter than it.
|
|
123
|
+
const head = Math.min(ARROW_HEAD / unit(), length);
|
|
124
|
+
const tipX = x + dx, tipY = y + dy;
|
|
125
|
+
const baseX = tipX - head * ux, baseY = tipY - head * uy;
|
|
126
|
+
const half = head / 2;
|
|
127
|
+
return {
|
|
128
|
+
x, y, baseX, baseY,
|
|
129
|
+
head: `${tipX},${tipY} ${baseX - half * uy},${baseY + half * ux} ${baseX + half * uy},${baseY - half * ux}`,
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<Show when={arrow()}>
|
|
135
|
+
{(a) => (
|
|
136
|
+
<>
|
|
137
|
+
<line x1={a().x} y1={a().y} x2={a().baseX} y2={a().baseY} stroke={grad().stroke ?? COLOR} stroke-width={STROKE_WIDTH} vector-effect="non-scaling-stroke" />
|
|
138
|
+
<polygon points={a().head} fill={grad().fill ?? COLOR} />
|
|
139
|
+
</>
|
|
140
|
+
)}
|
|
141
|
+
</Show>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A box's size in grid units when its width or height fits the text.
|
|
146
|
+
const TEXT_MAX_AUTO = 5;
|
|
147
|
+
// CSS variables don't work in SVG attributes, only in `style`.
|
|
148
|
+
const PAGE_BACKGROUND = 'var(--color-bg, #fff)';
|
|
149
|
+
|
|
150
|
+
interface TextLayout {
|
|
151
|
+
boxed: boolean;
|
|
152
|
+
/** Font size in grid units. */
|
|
153
|
+
fs: number;
|
|
154
|
+
lineHeight: number;
|
|
155
|
+
lines: string[];
|
|
156
|
+
outerW: number;
|
|
157
|
+
outerH: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Sizes in grid units. `unit` is pixels per grid unit. */
|
|
161
|
+
function textLayout(t: TextNode, unit: number): TextLayout {
|
|
162
|
+
const text = t.value;
|
|
163
|
+
const fs = (t.fontSize ? num(t.fontSize.value) : 12) / unit;
|
|
164
|
+
const lineHeight = fs * 1.4;
|
|
165
|
+
const rawW = t.w ? num(t.w.value) : -1;
|
|
166
|
+
const rawH = t.h ? num(t.h.value) : -1;
|
|
167
|
+
|
|
168
|
+
if (rawW !== 0 || rawH !== 0) {
|
|
169
|
+
const charW = 0.55 * fs;
|
|
170
|
+
// Space between the box's edge and the text.
|
|
171
|
+
const bgPad = 10 / unit;
|
|
172
|
+
// 0 means auto here too, when the other one isn't 0.
|
|
173
|
+
const wAuto = rawW <= 0;
|
|
174
|
+
const hAuto = rawH <= 0;
|
|
175
|
+
|
|
176
|
+
// Breaks at whitespace and after hyphens. A token longer than a line is cut.
|
|
177
|
+
const wrap = (charsPerLine: number): string[] => {
|
|
178
|
+
const cpl = Math.max(1, charsPerLine);
|
|
179
|
+
const tokens: string[] = [];
|
|
180
|
+
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
181
|
+
for (const part of word.split(/(?<=-)/)) {
|
|
182
|
+
if (part.length <= cpl) {
|
|
183
|
+
tokens.push(part);
|
|
184
|
+
} else {
|
|
185
|
+
for (let i = 0; i < part.length; i += cpl) tokens.push(part.slice(i, i + cpl));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const lines: string[] = [];
|
|
190
|
+
let current = '';
|
|
191
|
+
for (const token of tokens) {
|
|
192
|
+
const sep = current && !current.endsWith('-') ? ' ' : '';
|
|
193
|
+
const candidate = current + sep + token;
|
|
194
|
+
if (candidate.length <= cpl) {
|
|
195
|
+
current = candidate;
|
|
196
|
+
} else {
|
|
197
|
+
if (current) lines.push(current);
|
|
198
|
+
current = token;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (current) lines.push(current);
|
|
202
|
+
return lines;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// w and h are the outer box; the text fits in the box less its padding.
|
|
206
|
+
const innerFromOuter = (outer: number) => Math.max(0, outer - 2 * bgPad);
|
|
207
|
+
const innerCap = TEXT_MAX_AUTO - 2 * bgPad;
|
|
208
|
+
|
|
209
|
+
let innerW: number;
|
|
210
|
+
let lines: string[];
|
|
211
|
+
if (!wAuto) {
|
|
212
|
+
innerW = innerFromOuter(rawW);
|
|
213
|
+
lines = wrap(Math.floor(innerW / charW));
|
|
214
|
+
} else if (!hAuto) {
|
|
215
|
+
// Fixed height: widen until the text fits in the lines that fit.
|
|
216
|
+
const maxLines = Math.max(1, Math.floor(innerFromOuter(rawH) / lineHeight));
|
|
217
|
+
let cpl = Math.max(1, Math.ceil(text.length / maxLines));
|
|
218
|
+
lines = wrap(cpl);
|
|
219
|
+
while (lines.length > maxLines && cpl < text.length) {
|
|
220
|
+
cpl += 1;
|
|
221
|
+
lines = wrap(cpl);
|
|
222
|
+
}
|
|
223
|
+
innerW = lines.reduce((m, l) => Math.max(m, l.length), 1) * charW;
|
|
224
|
+
} else {
|
|
225
|
+
// Both auto: at most `TEXT_MAX_AUTO` wide, shrunk to the longest line.
|
|
226
|
+
lines = wrap(Math.floor(innerCap / charW));
|
|
227
|
+
innerW = Math.min(innerCap, lines.reduce((m, l) => Math.max(m, l.length), 1) * charW);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Lines that don't fit a fixed or capped height are dropped.
|
|
231
|
+
let innerH: number;
|
|
232
|
+
if (!hAuto) {
|
|
233
|
+
innerH = innerFromOuter(rawH);
|
|
234
|
+
lines = lines.slice(0, Math.max(1, Math.floor(innerH / lineHeight)));
|
|
235
|
+
} else if (!wAuto) {
|
|
236
|
+
innerH = lines.length * lineHeight;
|
|
237
|
+
} else {
|
|
238
|
+
lines = lines.slice(0, Math.max(1, Math.floor(innerCap / lineHeight)));
|
|
239
|
+
innerH = lines.length * lineHeight;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const outerW = wAuto ? innerW + 2 * bgPad : rawW;
|
|
243
|
+
const outerH = hAuto ? innerH + 2 * bgPad : rawH;
|
|
244
|
+
return { boxed: true, fs, lineHeight, lines, outerW, outerH };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const bgPad = 4 / unit;
|
|
248
|
+
return {
|
|
249
|
+
boxed: false, fs, lineHeight, lines: [text],
|
|
250
|
+
outerW: text.length * 0.55 * fs + 2 * bgPad,
|
|
251
|
+
outerH: lineHeight + 2 * bgPad,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Centered at (x, y). Not drawn without both. A box gets the page background and a border in the text's color.
|
|
256
|
+
const renderText = (text: Accessor<TextNode>, unit: Accessor<number>) => {
|
|
257
|
+
const box = () => {
|
|
258
|
+
const t = text();
|
|
259
|
+
if (!t.x || !t.y) return null;
|
|
260
|
+
const layout = textLayout(t, unit());
|
|
261
|
+
const cx = num(t.x.value), cy = num(t.y.value);
|
|
262
|
+
// y grows upward: the first line is the highest.
|
|
263
|
+
const firstY = cy + (layout.lines.length * layout.lineHeight) / 2 - layout.lineHeight / 2;
|
|
264
|
+
// `stroke` colors the text and the border; `fill` isn't used.
|
|
265
|
+
return { ...layout, cx, cy, firstY, color: t.stroke ?? COLOR };
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<Show when={box()}>
|
|
270
|
+
{(b) => (
|
|
271
|
+
<>
|
|
272
|
+
{/* Unboxed, it is invisible: it only catches the hover. */}
|
|
273
|
+
<rect
|
|
274
|
+
x={b().cx - b().outerW / 2} y={b().cy - b().outerH / 2}
|
|
275
|
+
width={b().outerW} height={b().outerH} rx={(b().boxed ? 6 : 2) / unit()}
|
|
276
|
+
style={{ fill: PAGE_BACKGROUND, 'fill-opacity': b().boxed ? 1 : 0 }}
|
|
277
|
+
stroke={b().boxed ? b().color : 'none'} stroke-width={1} vector-effect="non-scaling-stroke"
|
|
278
|
+
/>
|
|
279
|
+
<For each={b().lines}>
|
|
280
|
+
{(line, i) => (
|
|
281
|
+
// scale(1, -1) undoes CenteredGrid's flip, so the text is upright.
|
|
282
|
+
<g transform={`translate(${b().cx}, ${b().firstY - i() * b().lineHeight}) scale(1, -1)`}>
|
|
283
|
+
<text x={0} y={0} font-size={`${b().fs}`} text-anchor="middle" dominant-baseline="central" fill={b().color}>
|
|
284
|
+
{line}
|
|
285
|
+
</text>
|
|
286
|
+
</g>
|
|
287
|
+
)}
|
|
288
|
+
</For>
|
|
289
|
+
</>
|
|
290
|
+
)}
|
|
291
|
+
</Show>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export {
|
|
296
|
+
renderPoint,
|
|
297
|
+
renderLine,
|
|
298
|
+
renderTriangle,
|
|
299
|
+
renderCircle,
|
|
300
|
+
renderEllipse,
|
|
301
|
+
renderArc,
|
|
302
|
+
renderBezierQuadratic,
|
|
303
|
+
renderBezierCubic,
|
|
304
|
+
renderPointGrad,
|
|
305
|
+
renderText,
|
|
306
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NodeType, getNodeDefinition } from './Node';
|
|
2
|
+
|
|
3
|
+
export function getPrefixForType(type: NodeType | string): string {
|
|
4
|
+
return getNodeDefinition(type)?.prefix ?? type.toLowerCase();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class NameGenerator {
|
|
8
|
+
private counters: Record<string, number> = {};
|
|
9
|
+
|
|
10
|
+
constructor(private isTaken: (name: string) => boolean = () => false) {}
|
|
11
|
+
|
|
12
|
+
public getPrefixForType(type: string): string {
|
|
13
|
+
return getPrefixForType(type);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public generateAutoName(prefix: string): string {
|
|
17
|
+
/** An empty prefix (Dummy) always gives ''. */
|
|
18
|
+
if (prefix === '') return '';
|
|
19
|
+
let name: string;
|
|
20
|
+
do {
|
|
21
|
+
const current = this.counters[prefix] || 0;
|
|
22
|
+
this.counters[prefix] = current + 1;
|
|
23
|
+
name = `${prefix}${current}`;
|
|
24
|
+
} while (this.isTaken(name));
|
|
25
|
+
return name;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public resetCounters(): void {
|
|
29
|
+
this.counters = {};
|
|
30
|
+
}
|
|
31
|
+
}
|