iso-kit.ts
842 lines · kit/iso-kit.ts at 1482e84
1export type Flat = [number, number];
2export type Point3 = [number, number, number];
3export type Plane = [number, number];
5export interface Projection {
6 origin: Flat;
7 scale: number;
8 azimuth?: number;
9 elevation?: number;
10}
12export interface Plan {
13 x: number;
14 y: number;
15 w: number;
16 d: number;
17 r: number;
18}
20export interface SolidPaths {
21 fill: string;
22 outline: string;
23 crease: string;
24 top: string;
25 shades: string[];
26 bevel?: string;
27 seamless?: boolean;
28}
30export type Tone = "hi" | "mid" | "lo" | "faint";
31export type LineTone = Tone | "lit" | "red" | "green" | "blue";
33export const AZIMUTH = 45;
34export const ELEVATION = 30;
35export const SHADES = 4;
37const RADIANS = Math.PI / 180;
38const UNIT = Math.sqrt(1.6);
40interface Camera {
41 sinA: number;
42 cosA: number;
43 sinE: number;
44 cosE: number;
45 k: number;
46}
48export function cameraOf({ scale, azimuth = AZIMUTH, elevation = ELEVATION }: Projection): Camera {
49 const a = azimuth * RADIANS;
50 const e = elevation * RADIANS;
51 return { sinA: Math.sin(a), cosA: Math.cos(a), sinE: Math.sin(e), cosE: Math.cos(e), k: scale * UNIT };
52}
54export function iso([x, y, z]: Point3, projection: Projection): Flat {
55 const { origin } = projection;
56 const { sinA, cosA, sinE, cosE, k } = cameraOf(projection);
57 return [origin[0] + (x * sinA - y * cosA) * k, origin[1] + ((x * cosA + y * sinA) * sinE - z * cosE) * k];
58}
60export function towardViewer(projection: Projection): Plane {
61 const { sinA, cosA } = cameraOf(projection);
62 return [cosA, sinA];
63}
65export function depthOf([x, y, z]: Point3, projection: Projection) {
66 const { sinA, cosA, sinE, cosE } = cameraOf(projection);
67 return (x * cosA + y * sinA) * cosE + z * sinE;
68}
70const tenth = (value: number) => {
71 const text = (Math.round(value * 10) / 10).toFixed(1).replace(/\.0$/, "");
72 return text === "-0" ? "0" : text;
73};
75export function pathOf(points: Flat[], closed = false) {
76 if (!points.length) return "";
77 return points.map(([x, y], at) => `${at ? "L" : "M"}${tenth(x)} ${tenth(y)}`).join("") + (closed ? "Z" : "");
78}
80export const joinPaths = (paths: string[]) => paths.filter(Boolean).join("");
82export function roundedPlan({ x, y, w, d, r }: Plan, steps = 8): Plane[] {
83 const radius = Math.max(0, Math.min(r, w / 2, d / 2));
84 const corners: [number, number, number][] = [
85 [x + w - radius, y + radius, -90],
86 [x + w - radius, y + d - radius, 0],
87 [x + radius, y + d - radius, 90],
88 [x + radius, y + radius, 180],
89 ];
90 const out: Plane[] = [];
91 for (const [cx, cy, start] of corners) {
92 const count = radius > 0 ? steps : 0;
93 for (let step = 0; step <= count; step++) {
94 const angle = (start + (count ? (90 * step) / count : 45)) * RADIANS;
95 out.push([cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]);
96 }
97 }
98 return out;
99}
101function signedArea(points: Plane[] | Flat[]) {
102 let area = 0;
103 for (let index = 0; index < points.length; index++) {
104 const [ax, ay] = points[index]!;
105 const [bx, by] = points[(index + 1) % points.length]!;
106 area += ax * by - bx * ay;
107 }
108 return area / 2;
109}
111const oriented = (points: Flat[]) => (signedArea(points) < 0 ? [...points].reverse() : points);
113function convexHull(points: Flat[]): Flat[] {
114 const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
115 if (sorted.length < 3) return sorted;
116 const cross = (o: Flat, a: Flat, b: Flat) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
117 const lower: Flat[] = [];
118 for (const point of sorted) {
119 while (lower.length >= 2 && cross(lower[lower.length - 2]!, lower[lower.length - 1]!, point) <= 0) lower.pop();
120 lower.push(point);
121 }
122 const upper: Flat[] = [];
123 for (let index = sorted.length - 1; index >= 0; index--) {
124 const point = sorted[index]!;
125 while (upper.length >= 2 && cross(upper[upper.length - 2]!, upper[upper.length - 1]!, point) <= 0) upper.pop();
126 upper.push(point);
127 }
128 return [...lower.slice(0, -1), ...upper.slice(0, -1)];
129}
131function runsOf(flags: boolean[], want: boolean) {
132 const count = flags.length;
133 const runs: number[][] = [];
134 const start = flags.findIndex((flag, index) => flag !== flags[(index + count - 1) % count]);
135 if (start < 0) return flags[0] === want ? [Array.from({ length: count + 1 }, (_, index) => index % count)] : [];
136 let run: number[] | null = null;
137 for (let step = 0; step < count; step++) {
138 const index = (start + step) % count;
139 if (flags[index] === want) {
140 if (!run) run = [index];
141 run.push((index + 1) % count);
142 } else if (run) {
143 runs.push(run);
144 run = null;
145 }
146 }
147 if (run) runs.push(run);
148 return runs;
149}
151export interface ExtrudeOptions {
152 convex?: boolean;
153 bevel?: number;
154}
156export function extrude(ring: Plane[], z: number, height: number, projection: Projection, options: ExtrudeOptions = {}): SolidPaths {
157 const convex = options.convex ?? true;
158 const count = ring.length;
159 const counter = signedArea(ring) > 0;
160 const [vx, vy] = towardViewer(projection);
161 const { sinA, cosA } = cameraOf(projection);
162 const normals = ring.map(([ax, ay], index) => {
163 const [bx, by] = ring[(index + 1) % count]!;
164 const dx = bx - ax;
165 const dy = by - ay;
166 const length = Math.hypot(dx, dy) || 1;
167 return (counter ? [dy / length, -dx / length] : [-dy / length, dx / length]) as Plane;
168 });
169 const facing = normals.map(([nx, ny]) => nx * vx + ny * vy > 1e-9);
170 const top = ring.map(([x, y]) => iso([x, y, z + height], projection));
171 const bottom = ring.map(([x, y]) => iso([x, y, z], projection));
172 const outline: string[] = [];
173 const crease: string[] = [];
174 for (const run of runsOf(facing, false)) outline.push(pathOf(run.map((index) => top[index]!)));
175 for (const run of runsOf(facing, true)) {
176 crease.push(pathOf(run.map((index) => top[index]!)));
177 if (height > 0) outline.push(pathOf(run.map((index) => bottom[index]!)));
178 }
179 if (height > 0) {
180 for (let index = 0; index < count; index++) {
181 if (facing[(index + count - 1) % count] !== facing[index]) outline.push(pathOf([top[index]!, bottom[index]!]));
182 }
183 }
184 let fill: string;
185 if (convex) {
186 fill = pathOf(convexHull([...top, ...bottom]), true);
187 } else {
188 const parts = [pathOf(oriented(top), true), pathOf(oriented(bottom), true)];
189 for (let index = 0; index < count; index++) {
190 if (!facing[index]) continue;
191 const next = (index + 1) % count;
192 parts.push(pathOf(oriented([top[index]!, top[next]!, bottom[next]!, bottom[index]!]), true));
193 }
194 fill = parts.join("");
195 }
196 const buckets: string[][] = Array.from({ length: SHADES }, () => []);
197 if (height > 0) {
198 const [lx, ly] = [-sinA, cosA];
199 for (let index = 0; index < count; index++) {
200 if (!facing[index]) continue;
201 const [nx, ny] = normals[index]!;
202 const lit = (nx * lx + ny * ly + 1) / 2;
203 const shade = Math.min(SHADES - 1, Math.max(0, Math.floor(lit * SHADES)));
204 const next = (index + 1) % count;
205 buckets[shade]!.push(pathOf(oriented([top[index]!, top[next]!, bottom[next]!, bottom[index]!]), true));
206 }
207 }
208 let bevel: string | undefined;
209 if (options.bevel && options.bevel > 0) {
210 const by = options.bevel;
211 const inset = ring.map(([x, y], index) => {
212 const [ax, ay] = normals[(index + count - 1) % count]!;
213 const [bx, by2] = normals[index]!;
214 const mx = ax + bx;
215 const my = ay + by2;
216 const length = Math.hypot(mx, my) || 1;
217 return [x - (mx / length) * by, y - (my / length) * by] as Plane;
218 });
219 const insetTop = inset.map(([x, y]) => iso([x, y, z + height], projection));
220 bevel = runsOf(facing, true)
221 .map((run) => pathOf([top[run[0]!]!, ...run.slice(1, -1).map((index) => insetTop[index]!), top[run[run.length - 1]!]!]))
222 .join("");
223 }
224 return {
225 fill,
226 outline: outline.join(""),
227 crease: crease.join(""),
228 top: pathOf(oriented(top), true),
229 shades: buckets.map((paths) => paths.join("")),
230 bevel,
231 };
232}
234export const ringOf = (plan: Plan, steps = 8): Plane[] => roundedPlan(plan, steps);
236export function slabOf(plan: Plan, z: number, height: number, projection: Projection, steps = 8, bevel = 0) {
237 return extrude(ringOf(plan, steps), z, height, projection, { convex: true, bevel });
238}
240export function circleRing(cx: number, cy: number, radius: number, steps = 40): Plane[] {
241 return Array.from({ length: steps }, (_, step) => {
242 const angle = (step / steps) * Math.PI * 2;
243 return [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)] as Plane;
244 });
245}
247export function cylinder(cx: number, cy: number, radius: number, z: number, height: number, projection: Projection, steps = 32, bevel = 0) {
248 return extrude(circleRing(cx, cy, radius, steps), z, height, projection, { convex: true, bevel });
249}
251export const insetPlan = ({ x, y, w, d, r }: Plan, by: number): Plan => ({ x: x + by, y: y + by, w: w - by * 2, d: d - by * 2, r: Math.max(0, r - by) });
253export function onTop(points: Plane[], z: number, projection: Projection, closed = false) {
254 return pathOf(
255 points.map(([x, y]) => iso([x, y, z], projection)),
256 closed,
257 );
258}
260export const planOutline = (plan: Plan, z: number, projection: Projection, steps = 8) => onTop(ringOf(plan, steps), z, projection, true);
262export const lineOnTop = (from: Plane, to: Plane, z: number, projection: Projection) => onTop([from, to], z, projection);
264export const segment = (from: Point3, to: Point3, projection: Projection) => pathOf([iso(from, projection), iso(to, projection)]);
266export const rise = (at: Plane, from: number, to: number, projection: Projection) => segment([at[0], at[1], from], [at[0], at[1], to], projection);
268export const ring = (cx: number, cy: number, radius: number, z: number, projection: Projection, steps = 40) =>
269 onTop(circleRing(cx, cy, radius, steps), z, projection, true);
271export const pointsAt = (points: Point3[], projection: Projection): Flat[] => points.map((point) => iso(point, projection));
273export function dotGrid(x: number, y: number, columns: number, rows: number, gap: number, z: number, projection: Projection): Flat[] {
274 const dots: Flat[] = [];
275 for (let row = 0; row < rows; row++) {
276 for (let column = 0; column < columns; column++) dots.push(iso([x + column * gap, y + row * gap, z], projection));
277 }
278 return dots;
279}
281export function corners({ x, y, w, d }: Plan, inset = 0): Plane[] {
282 return [
283 [x + inset, y + inset],
284 [x + w - inset, y + inset],
285 [x + inset, y + d - inset],
286 [x + w - inset, y + d - inset],
287 ];
288}
290export function boxCorners({ x, y, w, d }: Plan, z0: number, z1: number): Point3[] {
291 const out: Point3[] = [];
292 for (const px of [x, x + w]) for (const py of [y, y + d]) for (const z of [z0, z1]) out.push([px, py, z]);
293 return out;
294}
296export function knurl(cx: number, cy: number, radius: number, z0: number, z1: number, count: number, projection: Projection, inset = 0.4) {
297 const [vx, vy] = towardViewer(projection);
298 const lines: string[] = [];
299 for (let index = 0; index < count; index++) {
300 const angle = (index / count) * Math.PI * 2;
301 const nx = Math.cos(angle);
302 const ny = Math.sin(angle);
303 if (nx * vx + ny * vy < 0.15) continue;
304 lines.push(segment([cx + radius * nx, cy + radius * ny, z0 + inset], [cx + radius * nx, cy + radius * ny, z1 - inset], projection));
305 }
306 return lines.join("");
307}
309export function sideArc(cx: number, cy: number, radius: number, z: number, projection: Projection, steps = 24) {
310 const [vx, vy] = towardViewer(projection);
311 const facing = Math.atan2(vy, vx);
312 const points: Flat[] = [];
313 for (let step = 0; step <= steps; step++) {
314 const angle = facing - Math.PI / 2 + (step / steps) * Math.PI;
315 points.push(iso([cx + radius * Math.cos(angle), cy + radius * Math.sin(angle), z], projection));
316 }
317 return pathOf(points);
318}
320export function ringSeam(ring: Plane[], z: number, projection: Projection) {
321 const count = ring.length;
322 const counter = signedArea(ring) > 0;
323 const [vx, vy] = towardViewer(projection);
324 const facing = ring.map(([ax, ay], index) => {
325 const [bx, by] = ring[(index + 1) % count];
326 const dx = bx - ax;
327 const dy = by - ay;
328 const [nx, ny] = counter ? [dy, -dx] : [-dy, dx];
329 return nx * vx + ny * vy > 1e-9;
330 });
331 return runsOf(facing, true).map((run) => pathOf(run.map((index) => iso([ring[index][0], ring[index][1], z], projection)))).join("");
332}
334export const sideSeam = (plan: Plan, z: number, projection: Projection, steps = 8) => ringSeam(ringOf(plan, steps), z, projection);
336export function sideRing(center: Point3, radius: number, axis: "x" | "y", projection: Projection, steps = 20) {
337 const [px, py, pz] = center;
338 const points: Flat[] = [];
339 for (let step = 0; step < steps; step++) {
340 const angle = (step / steps) * Math.PI * 2;
341 const u = radius * Math.cos(angle);
342 const v = radius * Math.sin(angle);
343 points.push(iso(axis === "x" ? [px, py + u, pz + v] : [px + u, py, pz + v], projection));
344 }
345 return pathOf(points, true);
346}
348export function coil(from: number, to: number, center: Plane, radius: number, turns: number, axis: "x" | "y", projection: Projection, perTurn = 16) {
349 const count = Math.max(1, Math.round(turns * perTurn));
350 const back: string[] = [];
351 const front: string[] = [];
352 let run: Flat[] = [];
353 let near: boolean | null = null;
354 for (let index = 0; index <= count; index++) {
355 const share = index / count;
356 const angle = share * turns * Math.PI * 2;
357 const u = radius * Math.cos(angle);
358 const v = radius * Math.sin(angle);
359 const along = from + (to - from) * share;
360 const point: Point3 = axis === "x" ? [along, center[0] + u, center[1] + v] : [center[0] + u, along, center[1] + v];
361 const normal: Point3 = axis === "x" ? [0, Math.cos(angle), Math.sin(angle)] : [Math.cos(angle), 0, Math.sin(angle)];
362 const facing = depthOf(normal, projection) > 0;
363 const flat = iso(point, projection);
364 if (near === null) near = facing;
365 if (facing !== near) {
366 run.push(flat);
367 (near ? front : back).push(pathOf(run));
368 run = [flat];
369 near = facing;
370 continue;
371 }
372 run.push(flat);
373 }
374 if (run.length > 1) (near ? front : back).push(pathOf(run));
375 return { back: back.join(""), front: front.join("") };
376}
378export interface Ticks {
379 minor: string;
380 major: string;
381}
383export function topTicks(from: number, to: number, step: number, every: number, edge: number, z: number, lengths: [number, number], projection: Projection, axis: "x" | "y" = "x"): Ticks {
384 const minor: string[] = [];
385 const major: string[] = [];
386 const first = Math.ceil(from / step) * step;
387 for (let at = first, index = Math.round(first / step); at <= to + 1e-9; at += step, index++) {
388 const big = index % every === 0;
389 const length = big ? lengths[1] : lengths[0];
390 const a: Plane = axis === "x" ? [at, edge] : [edge, at];
391 const b: Plane = axis === "x" ? [at, edge + length] : [edge + length, at];
392 (big ? major : minor).push(lineOnTop(a, b, z, projection));
393 }
394 return { minor: minor.join(""), major: major.join("") };
395}
397export function sideTicks(from: number, to: number, step: number, every: number, face: number, top: number, lengths: [number, number], projection: Projection, axis: "x" | "y" = "x"): Ticks {
398 const minor: string[] = [];
399 const major: string[] = [];
400 const first = Math.ceil(from / step) * step;
401 for (let at = first, index = Math.round(first / step); at <= to + 1e-9; at += step, index++) {
402 const big = index % every === 0;
403 const length = big ? lengths[1] : lengths[0];
404 const a: Point3 = axis === "x" ? [at, face, top] : [face, at, top];
405 const b: Point3 = axis === "x" ? [at, face, top - length] : [face, at, top - length];
406 (big ? major : minor).push(segment(a, b, projection));
407 }
408 return { minor: minor.join(""), major: major.join("") };
409}
411export function radialTicks(cx: number, cy: number, radius: number, count: number, every: number, z: number, lengths: [number, number], projection: Projection, from = 0, span = Math.PI * 2): Ticks {
412 const minor: string[] = [];
413 const major: string[] = [];
414 const closed = Math.abs(span - Math.PI * 2) < 1e-9;
415 const steps = closed ? count : count + 1;
416 for (let index = 0; index < steps; index++) {
417 const angle = from + (index / count) * span;
418 const big = index % every === 0;
419 const inner = radius - (big ? lengths[1] : lengths[0]);
420 const sx = Math.sin(angle);
421 const sy = -Math.cos(angle);
422 (big ? major : minor).push(lineOnTop([cx + sx * inner, cy + sy * inner], [cx + sx * radius, cy + sy * radius], z, projection));
423 }
424 return { minor: minor.join(""), major: major.join("") };
425}
427export const haloOf = (plan: Plan, z: number, height: number, projection: Projection) => slabOf(plan, z, height, projection).fill;
429export function axisVector(projection: Projection, [dx, dy, dz]: Point3): Flat {
430 const origin = iso([0, 0, 0], projection);
431 const moved = iso([dx, dy, dz], projection);
432 return [moved[0] - origin[0], moved[1] - origin[1]];
433}
435const fine = (value: number) => Math.round(value * 100) / 100;
437export function translateAlong(projection: Projection, by: Point3) {
438 const [x, y] = axisVector(projection, by);
439 return `translate(${fine(x)} ${fine(y)})`;
440}
442const thousandth = (value: number) => Math.round(value * 1000) / 1000;
444function axesAt(at: Plane, z: number, projection: Projection) {
445 const origin = iso([at[0], at[1], z], projection);
446 const along = (point: Point3): Flat => {
447 const [px, py] = iso(point, projection);
448 return [px - origin[0], py - origin[1]];
449 };
450 return { origin, x: along([at[0] + 1, at[1], z]), y: along([at[0], at[1] + 1, z]), z: along([at[0], at[1], z + 1]) };
451}
453const matrixOf = (u: Flat, v: Flat, origin: Flat) =>
454 `matrix(${thousandth(u[0])} ${thousandth(u[1])} ${thousandth(v[0])} ${thousandth(v[1])} ${thousandth(origin[0])} ${thousandth(origin[1])})`;
456export function holdStill(element: Element, [dx, dy]: Flat) {
457 const text = dx || dy ? `translate(${-dx} ${-dy})` : "";
458 element.querySelectorAll("[data-hold]").forEach((node) => (text ? node.setAttribute("transform", text) : node.removeAttribute("transform")));
459}
461export function topMatrix(at: Plane, z: number, projection: Projection, along: "x" | "y" = "x") {
462 const { origin, x, y } = axesAt(at, z, projection);
463 return along === "x" ? matrixOf(x, y, origin) : matrixOf([-y[0], -y[1]], x, origin);
464}
466export function sideMatrix(at: Plane, z: number, projection: Projection, face: "left" | "right") {
467 const { origin, x, y, z: up } = axesAt(at, z, projection);
468 const down: Flat = [-up[0], -up[1]];
469 return face === "left" ? matrixOf(x, down, origin) : matrixOf([-y[0], -y[1]], down, origin);
470}
472export function screenRise(height: number, projection: Projection) {
473 const { cosE, k } = cameraOf(projection);
474 return height * cosE * k;
475}
477export interface FitOptions {
478 pad?: number;
479 headroom?: number;
480 azimuth?: number;
481 elevation?: number;
482}
484export function fitProjection(points: Point3[], width: number, height: number, { pad = 24, headroom = 0, azimuth = AZIMUTH, elevation = ELEVATION }: FitOptions = {}): Projection {
485 const unit: Projection = { origin: [0, 0], scale: 1, azimuth, elevation };
486 const flat = points.map((point) => iso(point, unit));
487 const xs = flat.map(([x]) => x);
488 const ys = flat.map(([, y]) => y);
489 const left = Math.min(...xs);
490 const right = Math.max(...xs);
491 const up = Math.min(...ys);
492 const down = Math.max(...ys);
493 const scale = Math.min((width - pad * 2) / (right - left), (height - pad * 2 - headroom) / (down - up));
494 return {
495 origin: [width / 2 - ((left + right) / 2) * scale, headroom + (height - headroom) / 2 - ((up + down) / 2) * scale],
496 scale,
497 azimuth,
498 elevation,
499 };
500}
502const escape = (text: string) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
504const attrs = (pairs: Record<string, string | number | undefined | false>) =>
505 Object.entries(pairs)
506 .filter(([, value]) => value !== undefined && value !== false)
507 .map(([key, value]) => ` ${key}="${escape(String(value))}"`)
508 .join("");
510export interface SolidStyle {
511 tone?: Tone;
512 crease?: Tone | "none";
513 halo?: boolean;
514 lit?: boolean;
515 flat?: boolean;
516 className?: string;
517 inner?: string;
518}
520export function solidSvg(paths: SolidPaths, { tone = "hi", crease = "faint", halo = false, lit = false, flat = false, className, inner = "" }: SolidStyle = {}) {
521 const parts: string[] = [];
522 if (halo) parts.push(`<path class="iso-halo" d="${paths.fill}"/>`);
523 parts.push(`<path class="iso-fill" d="${paths.fill}"/>`);
524 if (!flat) {
525 paths.shades.forEach((d, shade) => {
526 if (d) parts.push(`<path class="iso-shade" data-shade="${shade}" d="${d}"/>`);
527 });
528 parts.push(`<path class="iso-top" d="${paths.top}"/>`);
529 }
530 if (crease !== "none" && paths.crease) parts.push(`<path class="iso-line iso-crease" data-tone="${crease}" d="${paths.crease}"/>`);
531 if (crease !== "none" && paths.bevel) parts.push(`<path class="iso-line iso-bevel" data-tone="${crease}" d="${paths.bevel}"/>`);
532 if (inner) parts.push(inner);
533 parts.push(`<path class="iso-line iso-edge" data-tone="${tone}" d="${paths.outline}"/>`);
534 return `<g${attrs({ class: className ? `iso-solid ${className}` : "iso-solid", "data-lit": lit ? "" : undefined, "data-seamless": paths.seamless ? "" : undefined })}>${parts.join("")}</g>`;
535}
537export interface LineStyle {
538 tone?: LineTone;
539 dotted?: boolean;
540 dashed?: boolean;
541 flow?: boolean;
542 className?: string;
543 free?: boolean | "start" | "end";
544}
546export function lineSvg(d: string, { tone = "lo", dotted = false, dashed = false, flow = false, className, free }: LineStyle = {}) {
547 if (!d) return "";
548 return `<path${attrs({
549 class: className ? `iso-line ${className}` : "iso-line",
550 "data-tone": tone,
551 "data-free": free === true ? "" : free || undefined,
552 "data-dotted": dotted ? "" : undefined,
553 "data-dashed": dashed ? "" : undefined,
554 "data-flow": flow ? "" : undefined,
555 d,
556 })}/>`;
557}
559export function dotsSvg(points: Array<Flat | [number, number, number]>, { size = 0.5, tone = "mid", pulse = false }: { size?: number; tone?: Tone; pulse?: boolean } = {}) {
560 const dots = points
561 .map(([x, y, alpha]: number[], index) => `<circle cx="${tenth(x)}" cy="${tenth(y)}" r="${size}"${alpha !== undefined && alpha < 1 ? ` fill-opacity="${Math.max(0, alpha).toFixed(2)}"` : ""}${pulse ? ` data-pulse="" style="--pulse-at:${(index * 137) % 1600}ms"` : ""}/>`)
562 .join("");
563 return `<g class="iso-dots" data-tone="${tone}">${dots}</g>`;
564}
566export type Faded = { d: string; alpha: number } | [string, number];
568export function fadedSvg(list: Faded[], style: LineStyle = {}) {
569 const groups = new Map<number, string>();
570 for (const entry of list) {
571 const [d, alpha] = Array.isArray(entry) ? entry : [entry.d, entry.alpha];
572 if (!d || !(alpha > 0.02)) continue;
573 const level = Math.min(1, Math.round(alpha * 20) / 20);
574 groups.set(level, (groups.get(level) ?? "") + d);
575 }
576 return [...groups].map(([alpha, d]) => (alpha >= 1 ? lineSvg(d, style) : `<g opacity="${alpha.toFixed(2)}">${lineSvg(d, style)}</g>`)).join("");
577}
579export interface FadeLineStyle {
580 fade?: number | [number, number];
581 tone?: LineTone;
582 className?: string;
583 stops?: number;
584}
586export function fadeLineSvg(points: Flat[], { fade = [0, 0], tone = "lo", className, stops = 6 }: FadeLineStyle = {}) {
587 if (points.length < 2) return "";
588 const ease = (t: number) => t * t * (3 - 2 * t);
589 const hashOf = (text: string) => {
590 let hash = 2166136261;
591 for (let index = 0; index < text.length; index++) hash = Math.imul(hash ^ text.charCodeAt(index), 16777619);
592 return (hash >>> 0).toString(36);
593 };
594 const lengths = [0];
595 for (let index = 1; index < points.length; index++) lengths.push(lengths[index - 1] + Math.hypot(points[index][0] - points[index - 1][0], points[index][1] - points[index - 1][1]));
596 const at = (length: number): Flat => {
597 let index = 1;
598 while (index < points.length - 1 && lengths[index] < length) index++;
599 const span = lengths[index] - lengths[index - 1] || 1;
600 const t = Math.max(0, Math.min(1, (length - lengths[index - 1]) / span));
601 return [points[index - 1][0] + (points[index][0] - points[index - 1][0]) * t, points[index - 1][1] + (points[index][1] - points[index - 1][1]) * t];
602 };
603 const pieceOf = (from: number, to: number): Flat[] => [at(from), ...points.filter((_, index) => lengths[index] > from + 1e-6 && lengths[index] < to - 1e-6), at(to)];
604 const total = lengths[lengths.length - 1];
605 if (total < 1e-6) return "";
606 let [start, end] = Array.isArray(fade) ? fade : [fade, fade];
607 if (start + end > total) {
608 const share = total / (start + end);
609 start *= share;
610 end *= share;
611 }
612 const paint = `var(--anatomy-${tone})`;
613 const ramp = (piece: Flat[], rising: boolean) => {
614 const d = pathOf(piece);
615 const [x1, y1] = piece[0];
616 const [x2, y2] = piece[piece.length - 1];
617 const id = `iso-fade-${hashOf(`${d}|${tone}|${rising}`)}`;
618 const marks = Array.from({ length: stops + 1 }, (_, index) => {
619 const t = index / stops;
620 const alpha = ease(rising ? t : 1 - t);
621 return `<stop offset="${t.toFixed(3)}" stop-opacity="${alpha.toFixed(3)}" style="stop-color:${paint}"/>`;
622 }).join("");
623 const gradient = `<linearGradient id="${id}" gradientUnits="userSpaceOnUse" x1="${tenth(x1)}" y1="${tenth(y1)}" x2="${tenth(x2)}" y2="${tenth(y2)}">${marks}</linearGradient>`;
624 return `<defs>${gradient}</defs><path${attrs({ class: className ? `iso-line ${className}` : "iso-line", "data-tone": tone, "data-free": rising ? "start" : "end", d, style: `stroke:url(#${id})` })}/>`;
625 };
626 const parts: string[] = [];
627 if (start > 0.05) parts.push(ramp(pieceOf(0, start), true));
628 if (total - end - start > 0.05) parts.push(lineSvg(pathOf(pieceOf(start, total - end)), { tone, className }));
629 if (end > 0.05) parts.push(ramp(pieceOf(total - end, total), false));
630 return `<g class="iso-fade">${parts.join("")}</g>`;
631}
633export interface WireStyle {
634 via?: Flat[];
635 tone?: LineTone;
636 dotted?: boolean;
637 dashed?: boolean;
638 flow?: boolean;
639 ends?: "both" | "from" | "to" | "none";
640 size?: number;
641}
643export function wireSvg(from: Flat, to: Flat, { via = [], tone = "lo", dotted = false, dashed = false, flow = false, ends = "both", size = 1.2 }: WireStyle = {}) {
644 const d = pathOf([from, ...via, to]);
645 const end = ([x, y]: Flat) => `<circle class="iso-wire-end" cx="${tenth(x)}" cy="${tenth(y)}" r="${size}"/>`;
646 return `<g class="iso-wire" data-tone="${tone}">${lineSvg(d, { tone, dotted, dashed, flow })}${ends === "both" || ends === "from" ? end(from) : ""}${ends === "both" || ends === "to" ? end(to) : ""}</g>`;
647}
649export function faceTextSvg(transform: string, text: string, { size = 3, tone = "mid", anchor = "start" }: { size?: number; tone?: Tone; anchor?: "start" | "middle" | "end" } = {}) {
650 return `<text class="iso-face-text" data-tone="${tone}" transform="${transform}" font-size="${size}" text-anchor="${anchor}">${escape(text)}</text>`;
651}
653export function groupSvg(children: string | string[], extra: Record<string, string | number | undefined | false> = {}) {
654 return `<g${attrs(extra)}>${Array.isArray(children) ? children.join("") : children}</g>`;
655}
657export type Theme = "dark" | "light";
659export interface FigureOptions {
660 width: number;
661 height: number;
662 label: string;
663 body: string | string[];
664 standalone?: Theme | false;
665 className?: string;
666}
668export function figureSvg({ width, height, label, body, standalone = false, className }: FigureOptions) {
669 const style = standalone ? `<style>${ISO_CSS}</style>` : "";
670 return `<svg${attrs({
671 xmlns: "http://www.w3.org/2000/svg",
672 class: `iso-svg${standalone ? " iso" : ""}${className ? ` ${className}` : ""}`,
673 "data-theme": standalone || undefined,
674 viewBox: `0 0 ${width} ${height}`,
675 role: "img",
676 "aria-label": label,
677 })}>${style}${Array.isArray(body) ? body.join("") : body}</svg>`;
678}
680export type KeyMark = "raised" | "flat" | "lit" | "edge" | "dim" | "dotted" | "dashed" | "red" | "green" | "blue";
682export interface LegendKey {
683 mark: KeyMark;
684 label: string;
685}
687function swatchSvg(mark: KeyMark) {
688 if (mark === "raised" || mark === "flat" || mark === "lit") {
689 const body = mark === "flat" ? "M2 6.5 8 3.5 14 6.5 8 9.5Z" : "M2 4.5 8 1.5 14 4.5 14 6 8 9 2 6Z";
690 const crease = mark === "flat" ? "" : `<path class="iso-swatch-crease" d="M2 4.5 8 7.5 14 4.5"/>`;
691 return `<svg class="iso-swatch" data-mark="${mark}" viewBox="0 0 16 10" aria-hidden="true"><path d="${body}"/>${crease}</svg>`;
692 }
693 return `<svg class="iso-swatch" data-mark="${mark}" viewBox="0 0 16 10" aria-hidden="true"><path d="M1.5 5h13"/></svg>`;
694}
696export interface PlateOptions {
697 fig: string;
698 title?: string;
699 hint?: string;
700 readout?: string;
701 keys?: LegendKey[];
702 caption?: string;
703 body: string;
704}
706export function plateHtml({ fig, title, hint, readout = "", keys = [], caption, body }: PlateOptions) {
707 const corner = (name: string, text: string, extra = "") => `<span class="iso-plate-corner" data-corner="${name}"${extra}>${escape(text)}</span>`;
708 const legend =
709 keys.length || caption
710 ? `<figcaption class="iso-legend">${keys.length ? `<span class="iso-keys">${keys.map((key) => `<span class="iso-key">${swatchSvg(key.mark)}${escape(key.label)}</span>`).join("")}</span>` : ""}${caption ? `<span class="iso-caption">${escape(caption)}</span>` : ""}</figcaption>`
711 : "";
712 return `<figure class="iso-figure"><div class="iso-plate"><div class="iso-plate-corners" aria-hidden="true">${corner("fig", fig)}${title ? corner("title", title) : ""}${hint ? corner("hint", hint) : ""}${corner("readout", readout, " data-readout")}</div>${body}</div>${legend}</figure>`;
713}
715export interface PageOptions {
716 title: string;
717 theme?: Theme;
718 body: string;
719 script?: string;
720 width?: number;
721}
723export function pageHtml({ title, theme = "dark", body, script = "", width = 650 }: PageOptions) {
724 return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>${ISO_CSS}${PAGE_CSS.replace("__WIDTH__", String(width))}</style></head><body class="iso" data-theme="${theme}"><main class="iso-page">${body}</main>${script ? `<script type="module">${script.replace(/<\/(script)/gi, "<\\/$1")}</script>` : ""}</body></html>`;
725}
727const PAGE_CSS = `
728*{box-sizing:border-box}
729html{color-scheme:dark}
730body[data-theme="light"]{color-scheme:light}
731body{margin:0;background:var(--anatomy-page);color:var(--anatomy-text);font:14px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}
732.iso-page{width:min(__WIDTH__px,100% - 32px);margin:64px auto 96px}
733`;
735export const ISO_CSS = `
736.iso{
737 --anatomy-page:#0b0b0b;--anatomy-card:#131313;--anatomy-text:#a3a3a3;--anatomy-strong:#e6e6e6;--anatomy-muted:#6e6e6e;
738 --anatomy-paper:#131313;--anatomy-face:#1b1b1b;--anatomy-top:#1c1c1c;
739 --anatomy-shade-0:#0f0f0f;--anatomy-shade-1:#121212;--anatomy-shade-2:#151515;--anatomy-shade-3:#181818;
740 --anatomy-lit-top:#262626;--anatomy-lit-shade:#1d1d1d;
741 --anatomy-hi:#d4d4d4;--anatomy-mid:#6a6a6a;--anatomy-lo:#3e3e3e;--anatomy-faint:#2a2a2a;--anatomy-lit:#ffffff;
742 --anatomy-halo:#050505;--anatomy-halo-opacity:.9;--anatomy-dot:#e6e6e6;
743 --anatomy-red:#ff6b6b;--anatomy-green:#6be0a0;--anatomy-blue:#6b9bff;
744 --anatomy-weight:.6px;--anatomy-ease:cubic-bezier(.32,.72,0,1);
745 --anatomy-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
746 --anatomy-lift:inset 0 1px 0 0 #ffffff0a,inset 0 0 0 1px #ffffff06,0 0 0 1px #00000026,0 1px 1px -.5px #00000014,0 3px 3px -1.5px #00000014,0 6px 6px -3px #00000014,0 12px 12px -6px #00000014;
747}
748.iso[data-theme="light"]{
749 --anatomy-page:#f2f2ef;--anatomy-card:#fbfbfa;--anatomy-text:#57575a;--anatomy-strong:#18181a;--anatomy-muted:#8c8c90;
750 --anatomy-paper:#fbfbfa;--anatomy-face:#ffffff;--anatomy-top:#ffffff;
751 --anatomy-shade-0:#dcdcd9;--anatomy-shade-1:#e5e5e2;--anatomy-shade-2:#ededea;--anatomy-shade-3:#f4f4f1;
752 --anatomy-lit-top:#ffffff;--anatomy-lit-shade:#eeeeeb;
753 --anatomy-hi:#26262a;--anatomy-mid:#8a8a8f;--anatomy-lo:#b4b4b8;--anatomy-faint:#d4d4d6;--anatomy-lit:#000000;
754 --anatomy-halo:#8a8a80;--anatomy-halo-opacity:.32;--anatomy-dot:#3a3a3e;
755 --anatomy-red:#e5484d;--anatomy-green:#30a46c;--anatomy-blue:#3e63dd;
756 --anatomy-lift:inset 0 1px 0 0 #ffffff,0 0 0 1px #0000000f,0 1px 1px -.5px #0000000a,0 3px 3px -1.5px #0000000a,0 6px 6px -3px #0000000a,0 12px 12px -6px #0000000a;
757}
758.iso-svg{display:block;width:100%;height:auto;overflow:visible}
759.iso-svg path,.iso-svg circle{vector-effect:non-scaling-stroke}
760.iso-fill{fill:var(--anatomy-paper);stroke:none}
761.iso-solid[data-lit]>.iso-fill{fill:var(--anatomy-face)}
762.iso-shade,.iso-top{stroke:none}
763.iso-shade[data-shade="0"]{fill:var(--anatomy-shade-0)}
764.iso-shade[data-shade="1"]{fill:var(--anatomy-shade-1)}
765.iso-shade[data-shade="2"]{fill:var(--anatomy-shade-2)}
766.iso-shade[data-shade="3"]{fill:var(--anatomy-shade-3)}
767.iso-top{fill:var(--anatomy-top)}
768.iso-solid[data-lit]>.iso-top{fill:var(--anatomy-lit-top)}
769.iso-solid[data-lit]>.iso-shade{fill:var(--anatomy-lit-shade)}
770.iso-solid[data-seamless]>.iso-shade,.iso-solid[data-seamless]>.iso-top{stroke-width:.4;stroke-linejoin:round}
771.iso-solid[data-seamless]>.iso-shade[data-shade="0"]{stroke:var(--anatomy-shade-0)}
772.iso-solid[data-seamless]>.iso-shade[data-shade="1"]{stroke:var(--anatomy-shade-1)}
773.iso-solid[data-seamless]>.iso-shade[data-shade="2"]{stroke:var(--anatomy-shade-2)}
774.iso-solid[data-seamless]>.iso-shade[data-shade="3"]{stroke:var(--anatomy-shade-3)}
775.iso-solid[data-seamless]>.iso-top{stroke:var(--anatomy-top)}
776.iso-solid[data-seamless][data-lit]>.iso-top{stroke:var(--anatomy-lit-top)}
777.iso-solid[data-seamless][data-lit]>.iso-shade{stroke:var(--anatomy-lit-shade)}
778.iso-solid[data-lit]>.iso-edge{stroke:var(--anatomy-lit)}
779.iso-halo{fill:var(--anatomy-halo);stroke:none;filter:blur(9px);opacity:var(--anatomy-halo-opacity);transform:translateY(3px)}
780.iso-line{fill:none;stroke:var(--anatomy-lo);stroke-width:var(--anatomy-weight);stroke-linecap:round;stroke-linejoin:round}
781.iso-line[data-tone="hi"]{stroke:var(--anatomy-hi)}
782.iso-line[data-tone="mid"]{stroke:var(--anatomy-mid)}
783.iso-line[data-tone="lo"]{stroke:var(--anatomy-lo)}
784.iso-line[data-tone="faint"]{stroke:var(--anatomy-faint)}
785.iso-line[data-tone="lit"]{stroke:var(--anatomy-lit)}
786.iso-line[data-tone="red"]{stroke:var(--anatomy-red)}
787.iso-line[data-tone="green"]{stroke:var(--anatomy-green)}
788.iso-line[data-tone="blue"]{stroke:var(--anatomy-blue)}
789.iso-line[data-dotted]{stroke-dasharray:.01 3.5;stroke-width:1}
790.iso-line[data-dashed]{stroke-dasharray:3 3}
791.iso-line[data-flow]{stroke-dasharray:2 6}
792.iso-wire-end{fill:var(--anatomy-paper);stroke:var(--anatomy-lo);stroke-width:var(--anatomy-weight)}
793.iso-wire[data-tone="hi"] .iso-wire-end{stroke:var(--anatomy-hi)}
794.iso-wire[data-tone="mid"] .iso-wire-end{stroke:var(--anatomy-mid)}
795.iso-wire[data-tone="faint"] .iso-wire-end{stroke:var(--anatomy-faint)}
796.iso-wire[data-tone="lit"] .iso-wire-end{stroke:var(--anatomy-lit)}
797.iso-dots circle{fill:var(--anatomy-dot);stroke:none;opacity:.32}
798.iso-dots[data-tone="hi"] circle{opacity:.85}
799.iso-dots[data-tone="mid"] circle{opacity:.45}
800.iso-dots[data-tone="lo"] circle{opacity:.22}
801.iso-dots[data-tone="faint"] circle{opacity:.12}
802.iso-face-text{fill:var(--anatomy-mid);font-family:var(--anatomy-mono);letter-spacing:.04em}
803.iso-face-text[data-tone="hi"]{fill:var(--anatomy-hi)}
804.iso-face-text[data-tone="lo"]{fill:var(--anatomy-lo)}
805.iso-face-text[data-tone="faint"]{fill:var(--anatomy-faint)}
806.tb-body{fill:var(--anatomy-shade-2);stroke:none}
807.tb-shine{fill:var(--anatomy-top);stroke:none}
808.tb-shade{fill:var(--anatomy-shade-0);stroke:none}
809.tb-ring{fill:none;stroke:var(--anatomy-lo);stroke-width:.45;stroke-linecap:butt;stroke-linejoin:round}
810.tb-ring[data-tone="mid"]{stroke:var(--anatomy-mid)}
811.tb-ring[data-tone="faint"]{stroke:var(--anatomy-faint)}
812.tb-ring[data-tone="hi"]{stroke:var(--anatomy-hi)}
813@keyframes iso-flow{to{stroke-dashoffset:-32}}
814@keyframes iso-pulse{0%,100%{opacity:.18}12%{opacity:1}40%{opacity:.18}}
815.iso-line[data-flow]{animation:iso-flow 1600ms linear infinite}
816.iso-dots circle[data-pulse]{animation:iso-pulse 1600ms steps(1,end) infinite;animation-delay:var(--pulse-at,0ms)}
817.iso-figure{margin:32px 0 36px}
818.iso-plate{position:relative;padding:52px 24px;border-radius:0;background:var(--anatomy-card);box-shadow:0 0 0 1px var(--anatomy-mid);--anatomy-paper:var(--anatomy-card)}
819.iso-plate-corners{position:absolute;inset:18px 20px;pointer-events:none}
820.iso-plate-corner{position:absolute;color:var(--anatomy-muted);font-family:var(--anatomy-mono);font-size:10.5px;line-height:1;letter-spacing:.04em;white-space:nowrap}
821.iso-plate-corner[data-corner="fig"]{top:0;left:0;color:var(--anatomy-text)}
822.iso-plate-corner[data-corner="title"]{top:0;right:0;text-transform:uppercase;letter-spacing:.1em}
823.iso-plate-corner[data-corner="hint"]{bottom:0;left:0;text-transform:uppercase;letter-spacing:.1em}
824.iso-plate-corner[data-corner="readout"]{right:0;bottom:0;color:var(--anatomy-text);font-variant-numeric:tabular-nums}
825.iso-plate .iso-svg{margin:0 auto}
826.iso-legend{display:grid;gap:8px;margin-top:12px;color:var(--anatomy-muted);font-size:13px;line-height:1.55}
827.iso-keys{display:flex;flex-wrap:wrap;gap:6px 18px}
828.iso-key{display:inline-flex;align-items:center;gap:7px}
829.iso-swatch{width:16px;height:10px;flex:none;overflow:visible}
830.iso-swatch path{fill:none;stroke:var(--anatomy-hi);stroke-width:1;vector-effect:non-scaling-stroke;stroke-linejoin:round;stroke-linecap:round}
831.iso-swatch[data-mark="raised"] path,.iso-swatch[data-mark="flat"] path{stroke:var(--anatomy-mid)}
832.iso-swatch[data-mark="lit"] path{stroke:var(--anatomy-lit)}
833.iso-swatch[data-mark="dim"] path{stroke:var(--anatomy-lo)}
834.iso-swatch[data-mark="dotted"] path{stroke-dasharray:.01 3;stroke-width:1.4}
835.iso-swatch[data-mark="dashed"] path{stroke-dasharray:3 2.5}
836.iso-swatch[data-mark="red"] path{stroke:var(--anatomy-red)}
837.iso-swatch[data-mark="green"] path{stroke:var(--anatomy-green)}
838.iso-swatch[data-mark="blue"] path{stroke:var(--anatomy-blue)}
839.iso-swatch-crease{stroke:var(--anatomy-lo)!important}
840@media (max-width:560px){.iso-plate{padding:48px 12px 70px}.iso-plate-corner[data-corner="hint"]{bottom:18px}}
841@media (prefers-reduced-motion:reduce){.iso-line[data-flow],.iso-dots circle[data-pulse]{animation:none}}
842`;