@sketchmark/plugin-geometry 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 +70 -0
- package/dist/index.cjs +561 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +558 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) sketchmark contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# @sketchmark/plugin-geometry
|
|
2
|
+
|
|
3
|
+
Lightweight textbook-style geometry for Sketchmark.
|
|
4
|
+
|
|
5
|
+
This first version keeps the core renderer small by compiling `geo.*` commands into ordinary Sketchmark `circle`, `path`, and `text` nodes. It focuses on drawing and labeling, not solving.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install sketchmark @sketchmark/plugin-geometry
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { render } from "sketchmark";
|
|
17
|
+
import { geometry } from "@sketchmark/plugin-geometry";
|
|
18
|
+
|
|
19
|
+
render({
|
|
20
|
+
container: document.getElementById("diagram")!,
|
|
21
|
+
dsl: `
|
|
22
|
+
diagram
|
|
23
|
+
title label="Triangle"
|
|
24
|
+
geo.point A x=90 y=220
|
|
25
|
+
geo.point B x=290 y=220
|
|
26
|
+
geo.point C x=190 y=90
|
|
27
|
+
|
|
28
|
+
geo.triangle tri points=[A,B,C]
|
|
29
|
+
geo.segment AB from=A to=B label="c"
|
|
30
|
+
geo.segment BC from=B to=C label="a"
|
|
31
|
+
geo.segment CA from=C to=A label="b"
|
|
32
|
+
end
|
|
33
|
+
`.trim(),
|
|
34
|
+
plugins: [geometry()],
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Supported Commands
|
|
39
|
+
|
|
40
|
+
- `geo.point <id> x=<n> y=<n> [label="..."] [r=<n>]`
|
|
41
|
+
- `geo.segment <id> from=<pointId> to=<pointId> [label="..."]`
|
|
42
|
+
- `geo.ray <id> from=<pointId> to=<pointId> [extend=<n>] [label="..."]`
|
|
43
|
+
- `geo.line <id> from=<pointId> to=<pointId> [extend=<n>] [label="..."]`
|
|
44
|
+
- `geo.circle <id> center=<pointId> r=<n> [label="..."]`
|
|
45
|
+
- `geo.arc <id> center=<pointId> r=<n> start=<deg> end=<deg> [close=none|chord|center] [label="..."]`
|
|
46
|
+
- `geo.ellipse <id> center=<pointId> rx=<n> ry=<n> [label="..."]`
|
|
47
|
+
- `geo.polygon <id> points=[A,B,C,...] [label="..."]`
|
|
48
|
+
- `geo.triangle <id> points=[A,B,C] [label="..."]`
|
|
49
|
+
|
|
50
|
+
## Notes
|
|
51
|
+
|
|
52
|
+
- Geometry commands are root-level in `v1`.
|
|
53
|
+
- The plugin auto-inserts `layout absolute` if the diagram does not declare a layout yet.
|
|
54
|
+
- If a diagram already declares a layout, it must be `layout absolute`.
|
|
55
|
+
- `geo.ray` renders with a single arrow tip at the extending end.
|
|
56
|
+
- `geo.arc` uses degree-based angles with `0` pointing right and `90` pointing up.
|
|
57
|
+
- `geo.arc close=none` draws an open arc, `close=chord` closes it with a straight chord, and `close=center` draws a sector.
|
|
58
|
+
- Labels are emitted as helper `text` nodes positioned near the geometry primitive.
|
|
59
|
+
|
|
60
|
+
## Options
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
geometry({
|
|
64
|
+
pointRadius: 4,
|
|
65
|
+
pointLabelDx: 10,
|
|
66
|
+
pointLabelDy: -12,
|
|
67
|
+
lineExtend: 80,
|
|
68
|
+
autoAbsoluteLayout: true,
|
|
69
|
+
});
|
|
70
|
+
```
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const SUPPORTED_COMMANDS = new Set([
|
|
4
|
+
"point",
|
|
5
|
+
"segment",
|
|
6
|
+
"ray",
|
|
7
|
+
"line",
|
|
8
|
+
"circle",
|
|
9
|
+
"arc",
|
|
10
|
+
"ellipse",
|
|
11
|
+
"polygon",
|
|
12
|
+
"triangle",
|
|
13
|
+
]);
|
|
14
|
+
const DEFAULTS = {
|
|
15
|
+
pointRadius: 4,
|
|
16
|
+
pointLabelDx: 12,
|
|
17
|
+
pointLabelDy: -16,
|
|
18
|
+
lineExtend: 80,
|
|
19
|
+
autoAbsoluteLayout: true,
|
|
20
|
+
};
|
|
21
|
+
const TEXT_KEYS = [
|
|
22
|
+
"color",
|
|
23
|
+
"opacity",
|
|
24
|
+
"font-size",
|
|
25
|
+
"font-weight",
|
|
26
|
+
"font",
|
|
27
|
+
"letter-spacing",
|
|
28
|
+
"text-align",
|
|
29
|
+
"vertical-align",
|
|
30
|
+
"line-height",
|
|
31
|
+
];
|
|
32
|
+
function geometry(options = {}) {
|
|
33
|
+
return {
|
|
34
|
+
name: "geometry",
|
|
35
|
+
preprocess(source) {
|
|
36
|
+
return compileGeometry(source, options);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function compileGeometry(source, options = {}) {
|
|
41
|
+
const settings = { ...DEFAULTS, ...options };
|
|
42
|
+
const lines = source.split(/\r?\n/);
|
|
43
|
+
const commandByLine = new Map();
|
|
44
|
+
const pointMap = new Map();
|
|
45
|
+
let hasGeometry = false;
|
|
46
|
+
let inTripleQuoteBlock = false;
|
|
47
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
48
|
+
const trimmed = lines[index]?.trim() ?? "";
|
|
49
|
+
if (trimmed === '"""') {
|
|
50
|
+
inTripleQuoteBlock = !inTripleQuoteBlock;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (inTripleQuoteBlock || !trimmed.startsWith("geo."))
|
|
54
|
+
continue;
|
|
55
|
+
const command = parseGeometryCommand(trimmed, index + 1);
|
|
56
|
+
commandByLine.set(index, command);
|
|
57
|
+
hasGeometry = true;
|
|
58
|
+
if (command.type === "point") {
|
|
59
|
+
if (pointMap.has(command.id)) {
|
|
60
|
+
throw new Error(`Duplicate geo.point "${command.id}" on line ${command.lineNumber}`);
|
|
61
|
+
}
|
|
62
|
+
pointMap.set(command.id, buildPointSpec(command, settings));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!hasGeometry)
|
|
66
|
+
return source;
|
|
67
|
+
const layoutDecision = resolveLayout(lines, settings.autoAbsoluteLayout);
|
|
68
|
+
const output = [];
|
|
69
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
70
|
+
output.push(lines[index] ?? "");
|
|
71
|
+
if (layoutDecision.insertAfterDiagramIndex === index) {
|
|
72
|
+
output.push("layout absolute");
|
|
73
|
+
}
|
|
74
|
+
const command = commandByLine.get(index);
|
|
75
|
+
if (!command)
|
|
76
|
+
continue;
|
|
77
|
+
output.pop();
|
|
78
|
+
output.push(...emitCommand(command, pointMap, settings));
|
|
79
|
+
}
|
|
80
|
+
return output.join("\n");
|
|
81
|
+
}
|
|
82
|
+
function parseGeometryCommand(line, lineNumber) {
|
|
83
|
+
const tokens = tokenizeLine(line);
|
|
84
|
+
if (tokens.length < 2) {
|
|
85
|
+
throw new Error(`Invalid geometry command on line ${lineNumber}`);
|
|
86
|
+
}
|
|
87
|
+
const commandToken = tokens[0] ?? "";
|
|
88
|
+
const type = commandToken.slice("geo.".length);
|
|
89
|
+
if (!SUPPORTED_COMMANDS.has(type)) {
|
|
90
|
+
throw new Error(`Unsupported geometry command "${commandToken}" on line ${lineNumber}`);
|
|
91
|
+
}
|
|
92
|
+
const id = tokens[1] ?? "";
|
|
93
|
+
if (!id || id.includes("=")) {
|
|
94
|
+
throw new Error(`Geometry command "${commandToken}" requires an explicit id on line ${lineNumber}`);
|
|
95
|
+
}
|
|
96
|
+
const props = {};
|
|
97
|
+
for (const token of tokens.slice(2)) {
|
|
98
|
+
const eqIndex = token.indexOf("=");
|
|
99
|
+
if (eqIndex < 1) {
|
|
100
|
+
throw new Error(`Invalid geometry property "${token}" on line ${lineNumber}`);
|
|
101
|
+
}
|
|
102
|
+
const key = token.slice(0, eqIndex);
|
|
103
|
+
const value = stripWrapping(token.slice(eqIndex + 1));
|
|
104
|
+
props[key] = value;
|
|
105
|
+
}
|
|
106
|
+
return { type, id, props, lineNumber };
|
|
107
|
+
}
|
|
108
|
+
function tokenizeLine(line) {
|
|
109
|
+
const tokens = [];
|
|
110
|
+
let index = 0;
|
|
111
|
+
while (index < line.length) {
|
|
112
|
+
while (index < line.length && /\s/.test(line[index] ?? ""))
|
|
113
|
+
index += 1;
|
|
114
|
+
if (index >= line.length)
|
|
115
|
+
break;
|
|
116
|
+
const start = index;
|
|
117
|
+
let inQuote = false;
|
|
118
|
+
let listDepth = 0;
|
|
119
|
+
while (index < line.length) {
|
|
120
|
+
const ch = line[index] ?? "";
|
|
121
|
+
if (ch === '"' && line[index - 1] !== "\\") {
|
|
122
|
+
inQuote = !inQuote;
|
|
123
|
+
index += 1;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (!inQuote) {
|
|
127
|
+
if (ch === "[")
|
|
128
|
+
listDepth += 1;
|
|
129
|
+
if (ch === "]" && listDepth > 0)
|
|
130
|
+
listDepth -= 1;
|
|
131
|
+
if (/\s/.test(ch) && listDepth === 0)
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
index += 1;
|
|
135
|
+
}
|
|
136
|
+
tokens.push(line.slice(start, index));
|
|
137
|
+
}
|
|
138
|
+
return tokens;
|
|
139
|
+
}
|
|
140
|
+
function stripWrapping(value) {
|
|
141
|
+
if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') {
|
|
142
|
+
return value.slice(1, -1).replace(/\\"/g, '"');
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
function resolveLayout(lines, autoAbsoluteLayout) {
|
|
147
|
+
const diagramIndex = lines.findIndex((line) => (line.trim() ?? "") === "diagram");
|
|
148
|
+
if (diagramIndex < 0) {
|
|
149
|
+
throw new Error('Geometry plugin requires a root "diagram" block');
|
|
150
|
+
}
|
|
151
|
+
const layoutIndex = lines.findIndex((line, index) => {
|
|
152
|
+
if (index <= diagramIndex)
|
|
153
|
+
return false;
|
|
154
|
+
return line.trim().startsWith("layout ");
|
|
155
|
+
});
|
|
156
|
+
if (layoutIndex < 0) {
|
|
157
|
+
if (!autoAbsoluteLayout) {
|
|
158
|
+
throw new Error('Geometry plugin requires "layout absolute"');
|
|
159
|
+
}
|
|
160
|
+
return { insertAfterDiagramIndex: diagramIndex };
|
|
161
|
+
}
|
|
162
|
+
if (lines[layoutIndex]?.trim() !== "layout absolute") {
|
|
163
|
+
throw new Error('Geometry commands require the root diagram to use "layout absolute"');
|
|
164
|
+
}
|
|
165
|
+
return {};
|
|
166
|
+
}
|
|
167
|
+
function buildPointSpec(command, settings) {
|
|
168
|
+
const x = requireNumber(command.props, "x", command.lineNumber);
|
|
169
|
+
const y = requireNumber(command.props, "y", command.lineNumber);
|
|
170
|
+
const radius = readNumber(command.props.r ?? command.props.radius) ?? settings.pointRadius;
|
|
171
|
+
const labelDx = readNumber(command.props["label-dx"]) ?? settings.pointLabelDx;
|
|
172
|
+
const labelDy = readNumber(command.props["label-dy"]) ?? settings.pointLabelDy;
|
|
173
|
+
return {
|
|
174
|
+
id: command.id,
|
|
175
|
+
x,
|
|
176
|
+
y,
|
|
177
|
+
radius,
|
|
178
|
+
label: command.props.label ?? command.id,
|
|
179
|
+
labelDx,
|
|
180
|
+
labelDy,
|
|
181
|
+
props: command.props,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
function emitCommand(command, pointMap, settings) {
|
|
185
|
+
switch (command.type) {
|
|
186
|
+
case "point":
|
|
187
|
+
return emitPoint(pointMap.get(command.id));
|
|
188
|
+
case "segment":
|
|
189
|
+
return emitLinear(command, pointMap, "segment", settings.lineExtend);
|
|
190
|
+
case "ray":
|
|
191
|
+
return emitLinear(command, pointMap, "ray", settings.lineExtend);
|
|
192
|
+
case "line":
|
|
193
|
+
return emitLinear(command, pointMap, "line", settings.lineExtend);
|
|
194
|
+
case "circle":
|
|
195
|
+
return emitCircle(command, pointMap);
|
|
196
|
+
case "arc":
|
|
197
|
+
return emitArc(command, pointMap);
|
|
198
|
+
case "ellipse":
|
|
199
|
+
return emitEllipse(command, pointMap);
|
|
200
|
+
case "polygon":
|
|
201
|
+
return emitPolygon(command, pointMap, false);
|
|
202
|
+
case "triangle":
|
|
203
|
+
return emitPolygon(command, pointMap, true);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function emitPoint(point) {
|
|
207
|
+
const fill = point.props.fill ?? "#1a1208";
|
|
208
|
+
const stroke = point.props.stroke ?? fill;
|
|
209
|
+
const strokeWidth = readNumber(point.props["stroke-width"]) ?? 1;
|
|
210
|
+
const lines = [
|
|
211
|
+
serializeNode("circle", point.id, "", {
|
|
212
|
+
x: point.x - point.radius,
|
|
213
|
+
y: point.y - point.radius,
|
|
214
|
+
width: point.radius * 2,
|
|
215
|
+
height: point.radius * 2,
|
|
216
|
+
fill,
|
|
217
|
+
stroke,
|
|
218
|
+
"stroke-width": strokeWidth,
|
|
219
|
+
...pickKeys(point.props, ["theme", "opacity", "dash", "stroke-dash"]),
|
|
220
|
+
}),
|
|
221
|
+
];
|
|
222
|
+
if (point.label) {
|
|
223
|
+
lines.push(serializeNode("text", labelNodeId(point.id), point.label, {
|
|
224
|
+
x: point.x + point.labelDx,
|
|
225
|
+
y: point.y + point.labelDy,
|
|
226
|
+
"animation-parent": point.id,
|
|
227
|
+
color: point.props.color ?? stroke,
|
|
228
|
+
...pickKeys(point.props, TEXT_KEYS),
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
return lines;
|
|
232
|
+
}
|
|
233
|
+
function emitLinear(command, pointMap, mode, defaultExtend) {
|
|
234
|
+
const from = requirePoint(pointMap, command.props.from, "from", command.lineNumber);
|
|
235
|
+
const to = requirePoint(pointMap, command.props.to, "to", command.lineNumber);
|
|
236
|
+
const length = distance(from, to);
|
|
237
|
+
if (length === 0) {
|
|
238
|
+
throw new Error(`Geometry command "${command.id}" on line ${command.lineNumber} needs distinct points`);
|
|
239
|
+
}
|
|
240
|
+
const extend = readNumber(command.props.extend) ?? defaultExtend;
|
|
241
|
+
const unit = { x: (to.x - from.x) / length, y: (to.y - from.y) / length };
|
|
242
|
+
const start = mode === "line"
|
|
243
|
+
? { x: from.x - unit.x * extend, y: from.y - unit.y * extend }
|
|
244
|
+
: { x: from.x, y: from.y };
|
|
245
|
+
const end = mode === "segment"
|
|
246
|
+
? { x: to.x, y: to.y }
|
|
247
|
+
: { x: to.x + unit.x * extend, y: to.y + unit.y * extend };
|
|
248
|
+
const lines = [serializePathNode(command.id, [start, end], false, command.props)];
|
|
249
|
+
if (mode === "ray") {
|
|
250
|
+
lines.push(serializeArrowHeadNode(arrowNodeId(command.id), end, unit, command.props, command.id));
|
|
251
|
+
}
|
|
252
|
+
if (command.props.label) {
|
|
253
|
+
const labelDx = readNumber(command.props["label-dx"]) ?? 0;
|
|
254
|
+
const labelDy = readNumber(command.props["label-dy"]) ?? 0;
|
|
255
|
+
const labelOffset = readNumber(command.props["label-offset"]) ?? 18;
|
|
256
|
+
const anchor = lineLabelAnchor(start, end, labelOffset);
|
|
257
|
+
lines.push(serializeNode("text", labelNodeId(command.id), command.props.label, {
|
|
258
|
+
x: anchor.x + labelDx,
|
|
259
|
+
y: anchor.y + labelDy,
|
|
260
|
+
"animation-parent": command.id,
|
|
261
|
+
color: command.props.color ?? command.props.stroke ?? "#1a1208",
|
|
262
|
+
...pickKeys(command.props, TEXT_KEYS),
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
return lines;
|
|
266
|
+
}
|
|
267
|
+
function serializeArrowHeadNode(id, tip, direction, props, animationParent) {
|
|
268
|
+
const strokeWidth = readNumber(props["stroke-width"]) ?? 1.5;
|
|
269
|
+
const arrowLength = readNumber(props["tip-size"]) ?? Math.max(10, strokeWidth * 6.5);
|
|
270
|
+
const arrowWidth = arrowLength * 0.65;
|
|
271
|
+
const base = {
|
|
272
|
+
x: tip.x - direction.x * arrowLength,
|
|
273
|
+
y: tip.y - direction.y * arrowLength,
|
|
274
|
+
};
|
|
275
|
+
const normal = { x: -direction.y, y: direction.x };
|
|
276
|
+
const left = {
|
|
277
|
+
x: base.x + normal.x * (arrowWidth / 2),
|
|
278
|
+
y: base.y + normal.y * (arrowWidth / 2),
|
|
279
|
+
};
|
|
280
|
+
const right = {
|
|
281
|
+
x: base.x - normal.x * (arrowWidth / 2),
|
|
282
|
+
y: base.y - normal.y * (arrowWidth / 2),
|
|
283
|
+
};
|
|
284
|
+
return serializePathNode(id, [left, tip, right], true, {
|
|
285
|
+
...pickKeys(props, ["theme", "opacity"]),
|
|
286
|
+
fill: props.stroke ?? "#1a1208",
|
|
287
|
+
stroke: props.stroke ?? "#1a1208",
|
|
288
|
+
"stroke-width": String(Math.max(1, strokeWidth * 0.75)),
|
|
289
|
+
"animation-parent": animationParent,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
function emitCircle(command, pointMap) {
|
|
293
|
+
const center = requirePoint(pointMap, command.props.center, "center", command.lineNumber);
|
|
294
|
+
const radius = requireNumber(command.props, "r", command.lineNumber, "radius");
|
|
295
|
+
const diameter = radius * 2;
|
|
296
|
+
const lines = [
|
|
297
|
+
serializeNode("circle", command.id, "", {
|
|
298
|
+
x: center.x - radius,
|
|
299
|
+
y: center.y - radius,
|
|
300
|
+
width: diameter,
|
|
301
|
+
height: diameter,
|
|
302
|
+
fill: command.props.fill ?? "none",
|
|
303
|
+
stroke: command.props.stroke ?? "#1a1208",
|
|
304
|
+
"stroke-width": readNumber(command.props["stroke-width"]) ?? 1.5,
|
|
305
|
+
...pickKeys(command.props, ["theme", "opacity", "dash", "stroke-dash"]),
|
|
306
|
+
}),
|
|
307
|
+
];
|
|
308
|
+
if (command.props.label) {
|
|
309
|
+
const labelDx = readNumber(command.props["label-dx"]) ?? radius + 10;
|
|
310
|
+
const labelDy = readNumber(command.props["label-dy"]) ?? -radius - 10;
|
|
311
|
+
lines.push(serializeNode("text", labelNodeId(command.id), command.props.label, {
|
|
312
|
+
x: center.x + labelDx,
|
|
313
|
+
y: center.y + labelDy,
|
|
314
|
+
"animation-parent": command.id,
|
|
315
|
+
color: command.props.color ?? command.props.stroke ?? "#1a1208",
|
|
316
|
+
...pickKeys(command.props, TEXT_KEYS),
|
|
317
|
+
}));
|
|
318
|
+
}
|
|
319
|
+
return lines;
|
|
320
|
+
}
|
|
321
|
+
function emitArc(command, pointMap) {
|
|
322
|
+
const center = requirePoint(pointMap, command.props.center, "center", command.lineNumber);
|
|
323
|
+
const radius = requireNumber(command.props, "r", command.lineNumber, "radius");
|
|
324
|
+
const startDeg = requireNumber(command.props, "start", command.lineNumber);
|
|
325
|
+
const endDeg = requireNumber(command.props, "end", command.lineNumber);
|
|
326
|
+
const closeMode = normalizeArcClose(command.props.close, command.lineNumber);
|
|
327
|
+
const arcPoints = sampleArcPoints(center, radius, startDeg, endDeg);
|
|
328
|
+
const pathPoints = closeMode === "center" ? [center, ...arcPoints] : arcPoints;
|
|
329
|
+
const lines = [
|
|
330
|
+
serializePathNode(command.id, pathPoints, closeMode !== "none", {
|
|
331
|
+
...command.props,
|
|
332
|
+
fill: closeMode === "none" ? "none" : (command.props.fill ?? "none"),
|
|
333
|
+
}),
|
|
334
|
+
];
|
|
335
|
+
if (command.props.label) {
|
|
336
|
+
const labelDx = readNumber(command.props["label-dx"]) ?? 0;
|
|
337
|
+
const labelDy = readNumber(command.props["label-dy"]) ?? 0;
|
|
338
|
+
const labelOffset = readNumber(command.props["label-offset"])
|
|
339
|
+
?? (closeMode === "center" ? 0 : 12);
|
|
340
|
+
const labelAngle = startDeg + (endDeg - startDeg) / 2;
|
|
341
|
+
const anchor = polarPoint(center, radius + labelOffset, labelAngle);
|
|
342
|
+
lines.push(serializeNode("text", labelNodeId(command.id), command.props.label, {
|
|
343
|
+
x: anchor.x + labelDx,
|
|
344
|
+
y: anchor.y + labelDy,
|
|
345
|
+
"animation-parent": command.id,
|
|
346
|
+
color: command.props.color ?? command.props.stroke ?? "#1a1208",
|
|
347
|
+
...pickKeys(command.props, TEXT_KEYS),
|
|
348
|
+
}));
|
|
349
|
+
}
|
|
350
|
+
return lines;
|
|
351
|
+
}
|
|
352
|
+
function emitEllipse(command, pointMap) {
|
|
353
|
+
const center = requirePoint(pointMap, command.props.center, "center", command.lineNumber);
|
|
354
|
+
const rx = requireNumber(command.props, "rx", command.lineNumber, "radius");
|
|
355
|
+
const ry = requireNumber(command.props, "ry", command.lineNumber, "radius");
|
|
356
|
+
const points = sampleEllipsePoints(center, rx, ry);
|
|
357
|
+
const lines = [
|
|
358
|
+
serializePathNode(command.id, points, true, {
|
|
359
|
+
...command.props,
|
|
360
|
+
fill: command.props.fill ?? "none",
|
|
361
|
+
}),
|
|
362
|
+
];
|
|
363
|
+
if (command.props.label) {
|
|
364
|
+
const labelDx = readNumber(command.props["label-dx"]) ?? rx + 10;
|
|
365
|
+
const labelDy = readNumber(command.props["label-dy"]) ?? -ry - 10;
|
|
366
|
+
lines.push(serializeNode("text", labelNodeId(command.id), command.props.label, {
|
|
367
|
+
x: center.x + labelDx,
|
|
368
|
+
y: center.y + labelDy,
|
|
369
|
+
"animation-parent": command.id,
|
|
370
|
+
color: command.props.color ?? command.props.stroke ?? "#1a1208",
|
|
371
|
+
...pickKeys(command.props, TEXT_KEYS),
|
|
372
|
+
}));
|
|
373
|
+
}
|
|
374
|
+
return lines;
|
|
375
|
+
}
|
|
376
|
+
function emitPolygon(command, pointMap, isTriangle) {
|
|
377
|
+
const ids = parseList(command.props.points, command.lineNumber, "points");
|
|
378
|
+
if (isTriangle && ids.length !== 3) {
|
|
379
|
+
throw new Error(`geo.triangle "${command.id}" on line ${command.lineNumber} needs exactly 3 points`);
|
|
380
|
+
}
|
|
381
|
+
if (!isTriangle && ids.length < 3) {
|
|
382
|
+
throw new Error(`geo.polygon "${command.id}" on line ${command.lineNumber} needs at least 3 points`);
|
|
383
|
+
}
|
|
384
|
+
const points = ids.map((id) => requirePoint(pointMap, id, "points", command.lineNumber));
|
|
385
|
+
const lines = [serializePathNode(command.id, points, true, command.props)];
|
|
386
|
+
if (command.props.label) {
|
|
387
|
+
const labelDx = readNumber(command.props["label-dx"]) ?? 0;
|
|
388
|
+
const labelDy = readNumber(command.props["label-dy"]) ?? -26;
|
|
389
|
+
const labelX = (Math.min(...points.map((point) => point.x)) + Math.max(...points.map((point) => point.x))) / 2;
|
|
390
|
+
const labelY = Math.min(...points.map((point) => point.y));
|
|
391
|
+
lines.push(serializeNode("text", labelNodeId(command.id), command.props.label, {
|
|
392
|
+
x: labelX + labelDx,
|
|
393
|
+
y: labelY + labelDy,
|
|
394
|
+
"animation-parent": command.id,
|
|
395
|
+
color: command.props.color ?? command.props.stroke ?? "#1a1208",
|
|
396
|
+
...pickKeys(command.props, TEXT_KEYS),
|
|
397
|
+
}));
|
|
398
|
+
}
|
|
399
|
+
return lines;
|
|
400
|
+
}
|
|
401
|
+
function serializePathNode(id, points, closePath, props) {
|
|
402
|
+
const minX = Math.min(...points.map((point) => point.x));
|
|
403
|
+
const minY = Math.min(...points.map((point) => point.y));
|
|
404
|
+
const maxX = Math.max(...points.map((point) => point.x));
|
|
405
|
+
const maxY = Math.max(...points.map((point) => point.y));
|
|
406
|
+
const d = points
|
|
407
|
+
.map((point, index) => {
|
|
408
|
+
const prefix = index === 0 ? "M" : "L";
|
|
409
|
+
return `${prefix} ${formatNumber(point.x - minX)} ${formatNumber(point.y - minY)}`;
|
|
410
|
+
})
|
|
411
|
+
.join(" ");
|
|
412
|
+
return serializeNode("path", id, "", {
|
|
413
|
+
value: `${d}${closePath ? " Z" : ""}`,
|
|
414
|
+
x: minX,
|
|
415
|
+
y: minY,
|
|
416
|
+
width: Math.max(1, maxX - minX),
|
|
417
|
+
height: Math.max(1, maxY - minY),
|
|
418
|
+
fill: props.fill ?? "none",
|
|
419
|
+
stroke: props.stroke ?? "#1a1208",
|
|
420
|
+
"stroke-width": readNumber(props["stroke-width"]) ?? 1.5,
|
|
421
|
+
...pickKeys(props, ["theme", "opacity", "dash", "stroke-dash", "animation-parent"]),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
function requirePoint(pointMap, id, propName, lineNumber) {
|
|
425
|
+
if (!id) {
|
|
426
|
+
throw new Error(`Geometry command on line ${lineNumber} requires "${propName}"`);
|
|
427
|
+
}
|
|
428
|
+
const point = pointMap.get(id);
|
|
429
|
+
if (!point) {
|
|
430
|
+
throw new Error(`Unknown point "${id}" referenced by "${propName}" on line ${lineNumber}`);
|
|
431
|
+
}
|
|
432
|
+
return point;
|
|
433
|
+
}
|
|
434
|
+
function parseList(value, lineNumber, propName) {
|
|
435
|
+
if (!value || value[0] !== "[" || value[value.length - 1] !== "]") {
|
|
436
|
+
throw new Error(`Geometry command on line ${lineNumber} requires ${propName}=[...]`);
|
|
437
|
+
}
|
|
438
|
+
return value
|
|
439
|
+
.slice(1, -1)
|
|
440
|
+
.split(",")
|
|
441
|
+
.map((part) => part.trim())
|
|
442
|
+
.filter(Boolean);
|
|
443
|
+
}
|
|
444
|
+
function serializeNode(shape, id, label, props) {
|
|
445
|
+
const parts = [shape, id, `label=${quote(label)}`];
|
|
446
|
+
for (const [key, value] of Object.entries(props)) {
|
|
447
|
+
if (value === undefined)
|
|
448
|
+
continue;
|
|
449
|
+
parts.push(`${key}=${formatPropValue(value)}`);
|
|
450
|
+
}
|
|
451
|
+
return parts.join(" ");
|
|
452
|
+
}
|
|
453
|
+
function quote(value) {
|
|
454
|
+
return `"${value
|
|
455
|
+
.replace(/\\/g, "\\\\")
|
|
456
|
+
.replace(/"/g, '\\"')
|
|
457
|
+
.replace(/\n/g, "\\n")}"`;
|
|
458
|
+
}
|
|
459
|
+
function formatPropValue(value) {
|
|
460
|
+
if (typeof value === "number")
|
|
461
|
+
return formatNumber(value);
|
|
462
|
+
return quote(value);
|
|
463
|
+
}
|
|
464
|
+
function formatNumber(value) {
|
|
465
|
+
const rounded = Math.round(value * 100) / 100;
|
|
466
|
+
return Number.isInteger(rounded) ? String(rounded) : String(rounded).replace(/\.?0+$/, "");
|
|
467
|
+
}
|
|
468
|
+
function labelNodeId(id) {
|
|
469
|
+
return `__geo_${sanitizeId(id)}_label`;
|
|
470
|
+
}
|
|
471
|
+
function arrowNodeId(id) {
|
|
472
|
+
return `__geo_${sanitizeId(id)}_tip`;
|
|
473
|
+
}
|
|
474
|
+
function sanitizeId(value) {
|
|
475
|
+
return value.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
476
|
+
}
|
|
477
|
+
function pickKeys(props, keys) {
|
|
478
|
+
const next = {};
|
|
479
|
+
for (const key of keys) {
|
|
480
|
+
if (props[key] !== undefined)
|
|
481
|
+
next[key] = props[key];
|
|
482
|
+
}
|
|
483
|
+
return next;
|
|
484
|
+
}
|
|
485
|
+
function requireNumber(props, key, lineNumber, alias) {
|
|
486
|
+
const value = readNumber(props[key] ?? (alias ? props[alias] : undefined));
|
|
487
|
+
if (value === undefined || Number.isNaN(value)) {
|
|
488
|
+
throw new Error(`Geometry command on line ${lineNumber} requires numeric "${key}"`);
|
|
489
|
+
}
|
|
490
|
+
return value;
|
|
491
|
+
}
|
|
492
|
+
function readNumber(value) {
|
|
493
|
+
if (value === undefined || value === "")
|
|
494
|
+
return undefined;
|
|
495
|
+
const parsed = Number(value);
|
|
496
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
497
|
+
}
|
|
498
|
+
function distance(from, to) {
|
|
499
|
+
return Math.hypot(to.x - from.x, to.y - from.y);
|
|
500
|
+
}
|
|
501
|
+
function polarPoint(center, radius, deg) {
|
|
502
|
+
const radians = (deg * Math.PI) / 180;
|
|
503
|
+
return {
|
|
504
|
+
x: center.x + Math.cos(radians) * radius,
|
|
505
|
+
y: center.y - Math.sin(radians) * radius,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
function sampleArcPoints(center, radius, startDeg, endDeg) {
|
|
509
|
+
const delta = endDeg - startDeg;
|
|
510
|
+
const segments = Math.max(8, Math.ceil(Math.abs(delta) / 12));
|
|
511
|
+
return Array.from({ length: segments + 1 }, (_, index) => polarPoint(center, radius, startDeg + (delta * index) / segments));
|
|
512
|
+
}
|
|
513
|
+
function sampleEllipsePoints(center, rx, ry) {
|
|
514
|
+
const segments = 36;
|
|
515
|
+
return Array.from({ length: segments }, (_, index) => {
|
|
516
|
+
const deg = (index / segments) * 360;
|
|
517
|
+
const radians = (deg * Math.PI) / 180;
|
|
518
|
+
return {
|
|
519
|
+
x: center.x + Math.cos(radians) * rx,
|
|
520
|
+
y: center.y - Math.sin(radians) * ry,
|
|
521
|
+
};
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
function normalizeArcClose(value, lineNumber) {
|
|
525
|
+
if (!value || value === "none")
|
|
526
|
+
return "none";
|
|
527
|
+
if (value === "chord" || value === "center")
|
|
528
|
+
return value;
|
|
529
|
+
throw new Error(`Invalid geo.arc close "${value}" on line ${lineNumber}; expected none, chord, or center`);
|
|
530
|
+
}
|
|
531
|
+
function lineLabelAnchor(start, end, offset) {
|
|
532
|
+
const mid = {
|
|
533
|
+
x: (start.x + end.x) / 2,
|
|
534
|
+
y: (start.y + end.y) / 2,
|
|
535
|
+
};
|
|
536
|
+
const dx = end.x - start.x;
|
|
537
|
+
const dy = end.y - start.y;
|
|
538
|
+
const length = Math.hypot(dx, dy) || 1;
|
|
539
|
+
const unit = { x: dx / length, y: dy / length };
|
|
540
|
+
const normalA = { x: unit.y, y: -unit.x };
|
|
541
|
+
const normalB = { x: -unit.y, y: unit.x };
|
|
542
|
+
const normal = normalScore(normalA) >= normalScore(normalB) ? normalA : normalB;
|
|
543
|
+
return {
|
|
544
|
+
x: mid.x + normal.x * offset,
|
|
545
|
+
y: mid.y + normal.y * offset,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
function normalScore(normal) {
|
|
549
|
+
let score = 0;
|
|
550
|
+
if (normal.y < 0)
|
|
551
|
+
score += 2;
|
|
552
|
+
if (Math.abs(normal.x) > 0.6)
|
|
553
|
+
score += 1;
|
|
554
|
+
if (normal.x > 0)
|
|
555
|
+
score += 0.25;
|
|
556
|
+
return score;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
exports.compileGeometry = compileGeometry;
|
|
560
|
+
exports.geometry = geometry;
|
|
561
|
+
//# sourceMappingURL=index.cjs.map
|