holey-shapes 1.0.0 → 2.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/README.md CHANGED
@@ -1,164 +1,34 @@
1
1
  # holey-shapes
2
2
 
3
- Solid, perforated shapes for the web, SwiftUI, and Jetpack Compose, with a connected extrusion and built-in hole animation. Hovering closes and reopens the holes in a seeded random order while the body stays still.
3
+ Avatars with holes. Turn any name into a colorful, one-of-a-kind SVG shape. The same name always gives the same shape. It runs in Node and browsers with no dependencies.
4
4
 
5
- [live playground](https://holey-shapes.aakashreddy.com) · [npm package](https://www.npmjs.com/package/holey-shapes) · [source](https://github.com/skyaara/holey-shapes)
6
-
7
- ## Install
8
-
9
- ### JavaScript
5
+ <img src="https://raw.githubusercontent.com/skyaara/holey-shapes/main/.github/media/names.gif" width="720" alt="Ten names, each turned into its own holey shape">
10
6
 
11
7
  ```sh
12
8
  npm install holey-shapes
13
9
  ```
14
10
 
15
- ### Swift Package Manager
16
-
17
- In Xcode, add `https://github.com/skyaara/holey-shapes` as a package dependency, or add it to `Package.swift`:
18
-
19
- ```swift
20
- dependencies: [
21
- .package(url: "https://github.com/skyaara/holey-shapes.git", from: "0.1.4")
22
- ]
23
- ```
24
-
25
- Then render any catalog shape directly in SwiftUI:
26
-
27
- ```swift
28
- import HoleyShapes
29
- import SwiftUI
30
-
31
- struct Logo: View {
32
- var body: some View {
33
- HoleyShapeView(
34
- .prism,
35
- faceColor: HoleyColor(hex: "#FF4FA3"),
36
- shadowColor: HoleyColor(hex: "#7A1748"),
37
- holes: 6,
38
- seed: 2
39
- )
40
- .frame(width: 240, height: 240)
41
- }
42
- }
43
- ```
44
-
45
- The Swift SDK supports iOS 16+, macOS 13+, tvOS 16+, and visionOS 1+. It uses asynchronous SwiftUI `Canvas` rendering and caches the immutable shape paths, packing candidates, and finished hole layouts. Pointer hover closes and reopens individual holes, and Reduce Motion disables the effect.
46
-
47
- ### Android
48
-
49
- Add JitPack to dependency resolution in `settings.gradle.kts`:
50
-
51
- ```kotlin
52
- dependencyResolutionManagement {
53
- repositories {
54
- google()
55
- mavenCentral()
56
- maven("https://jitpack.io")
57
- }
58
- }
59
- ```
60
-
61
- Then add the Compose SDK:
62
-
63
- ```kotlin
64
- dependencies {
65
- implementation("com.github.skyaara:holey-shapes:v0.1.4")
66
- }
67
- ```
68
-
69
- Use the composable directly or derive a stable shape from an ID:
70
-
71
- ```kotlin
72
- import com.aakashreddy.holeyshapes.HoleyShapeConfig
73
- import com.aakashreddy.holeyshapes.HoleyShapeView
74
-
75
- HoleyShapeView(
76
- config = HoleyShapeConfig.seeded(user.id),
77
- modifier = Modifier.size(64.dp),
78
- contentDescription = null,
79
- )
80
- ```
81
-
82
- The Android SDK requires API 26 or newer and a consuming project compiled with API 35 or newer. It renders through the native Android canvas and caches geometry and packed layouts. Mouse and stylus hover close and reopen individual holes; touch-only avatars remain still. Android's animation scale applies automatically.
83
-
84
- ## Render an SVG string
85
-
86
11
  ```js
87
- import { createHoleySvg } from 'holey-shapes';
12
+ import { createHoleySvg, mountHoleyShape } from 'holey-shapes';
88
13
 
89
- const svg = createHoleySvg({
90
- shape: 'prism',
91
- color: '#FF4FA3',
92
- shadowColor: '#7A1748',
93
- holes: 6,
94
- seed: 2
95
- });
14
+ const svg = createHoleySvg({ shape: 'prism', color: '#FF4FA3', holes: 6, seed: 2 });
15
+ document.querySelector('#preview').innerHTML = svg;
96
16
 
97
- document.querySelector('#shape').innerHTML = svg;
17
+ const mounted = mountHoleyShape('#avatar', { shape: 'disc', holes: 5 });
18
+ mounted.update({ color: '#00D7B9' });
98
19
  ```
99
20
 
100
- `createHoleySvg()` returns a complete, self-contained SVG string. The animation and extrusion are embedded in the SVG, so downloaded or copied shapes keep their hover behavior.
101
-
102
- ## Mount and update a shape
103
-
104
- ```js
105
- import { mountHoleyShape } from 'holey-shapes';
106
-
107
- const shape = mountHoleyShape('#shape', {
108
- shape: 'disc',
109
- color: '#6337FF',
110
- holes: 5
111
- });
112
-
113
- shape.update({ color: '#00D7B9', holes: 8 });
114
- shape.shuffle();
115
- ```
116
-
117
- `mountHoleyShape()` returns `update()`, `shuffle()`, and `destroy()` methods. Updates replace only the mounted SVG.
118
-
119
- The generated SVG scales to its container:
120
-
121
- ```css
122
- #shape {
123
- width: 320px;
124
- }
125
-
126
- #shape svg {
127
- display: block;
128
- width: 100%;
129
- height: auto;
130
- }
131
- ```
132
-
133
- ## Options
134
-
135
- | Option | Default | Description |
136
- | --- | --- | --- |
137
- | `shape` | `disc` | One of the keys in `shapeNames` |
138
- | `color` | Shape color | Six-digit hex face color |
139
- | `shadowColor` | Darker face color | Six-digit hex extrusion color |
140
- | `holes` | Shape default | Hole count from 0 to 8 |
141
- | `seed` | `0` | Changes the hole layout |
142
- | `animated` | `true` | Includes the hover hole animation |
143
- | `duration` | `900` | Total hole-animation duration in milliseconds |
144
- | `shadowX` | `18` | Horizontal extrusion offset |
145
- | `shadowY` | `21` | Vertical extrusion offset |
146
- | `shadowSteps` | `12` | Number of solid extrusion slices |
147
-
148
- Available shape keys: `disc`, `round-block`, `hex`, `capsule`, `prism`, `cross`, `triangle`, `diamond`, `sunburst`, `octagon`, `chevron`, `long-bar`, `flower-star`, and `bowtie`.
149
-
150
- You can also build a picker from the exported metadata:
21
+ Give every user a stable avatar from their ID. `identityHash(id)` returns the 16-digit hex hash that `seededHoleyOptions(id)` derives the shape and seed from:
151
22
 
152
23
  ```js
153
- import { shapeNames } from 'holey-shapes';
24
+ import { createHoleySvg, identityHash, seededHoleyOptions } from 'holey-shapes';
154
25
 
155
- for (const { key, name, color } of shapeNames) {
156
- console.log(key, name, color);
157
- }
26
+ identityHash('relay-agent'); // '0cc0c8aa3dbd893e'
27
+ avatar.innerHTML = createHoleySvg({ ...seededHoleyOptions('relay-agent'), idPrefix: 'relay-agent' });
158
28
  ```
159
29
 
160
- ## Browser support
30
+ Pass a unique `idPrefix` to `createHoleySvg()` when adding multiple SVG strings to one document. `mountHoleyShape()` creates isolated IDs automatically.
161
31
 
162
- The package has no runtime dependencies. Browsers with Canvas `Path2D` get the full packed-hole layout. Server-side rendering uses the built-in deterministic fallback layout.
32
+ For the option reference, shape catalog, native packages, and contribution guide, see the [reference](https://github.com/skyaara/holey-shapes/blob/main/docs/REFERENCE.md) and the [project README](https://github.com/skyaara/holey-shapes#readme). Source for this package is in [`packages/js`](https://github.com/skyaara/holey-shapes/tree/main/packages/js).
163
33
 
164
- The hover animation respects `prefers-reduced-motion`.
34
+ MIT licensed.
package/color.js ADDED
@@ -0,0 +1,54 @@
1
+ // Colors are chosen in OKLCH, where equal lightness looks equally bright. Every
2
+ // seeded hue then reads as one family, and every extrusion darkens by the same
3
+ // perceived amount. Swift and Kotlin repeat these steps exactly; results are
4
+ // rounded to sRGB hex.
5
+
6
+ const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
7
+ const toGamma = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055);
8
+
9
+ function oklabToLinear(L, a, b) {
10
+ const l = L + 0.3963377774 * a + 0.2158037573 * b;
11
+ const m = L - 0.1055613458 * a - 0.0638541728 * b;
12
+ const s = L - 0.0894841775 * a - 1.291485548 * b;
13
+ const l3 = l * l * l, m3 = m * m * m, s3 = s * s * s;
14
+ return [
15
+ 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3,
16
+ -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3,
17
+ -0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3,
18
+ ];
19
+ }
20
+
21
+ const inGamut = (rgb) => rgb.every((v) => v >= -0.0001 && v <= 1.0001);
22
+ const hex = (rgb) => `#${rgb.map((v) => Math.round(toGamma(Math.min(1, Math.max(0, v))) * 255).toString(16).padStart(2, '0')).join('').toUpperCase()}`;
23
+
24
+ // Keeps lightness and hue, and reduces chroma until the color fits in sRGB.
25
+ function oklabHex(L, a, b) {
26
+ if (inGamut(oklabToLinear(L, a, b))) return hex(oklabToLinear(L, a, b));
27
+ let low = 0, high = 1;
28
+ for (let pass = 0; pass < 24; pass++) {
29
+ const mid = (low + high) / 2;
30
+ if (inGamut(oklabToLinear(L, a * mid, b * mid))) low = mid;
31
+ else high = mid;
32
+ }
33
+ return hex(oklabToLinear(L, a * low, b * low));
34
+ }
35
+
36
+ /** Face color for a hue in degrees. Yellows only look clean when lighter, so lightness rises around 100°. */
37
+ export function hueColor(hue) {
38
+ const distance = Math.abs(((hue - 100) % 360 + 540) % 360 - 180);
39
+ const lift = distance < 60 ? Math.pow(Math.cos((distance / 60) * (Math.PI / 2)), 2) : 0;
40
+ const angle = (hue * Math.PI) / 180;
41
+ return oklabHex(0.74 + 0.12 * lift, 0.19 * Math.cos(angle), 0.19 * Math.sin(angle));
42
+ }
43
+
44
+ /** Extrusion color for a face: the same hue at 56% of its lightness and 70% of its chroma. */
45
+ export function extrusionColor(face) {
46
+ const [r, g, b] = [1, 3, 5].map((i) => toLinear(parseInt(face.slice(i, i + 2), 16) / 255));
47
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
48
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
49
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
50
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
51
+ const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
52
+ const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
53
+ return oklabHex(L * 0.56, A * 0.7, B * 0.7);
54
+ }
package/doodle.js ADDED
@@ -0,0 +1,79 @@
1
+ import { compileShapePath } from "./geometry.js";
2
+
3
+ const ink = "#25262A";
4
+ const n = (value) => String(Math.round(value * 100) / 100);
5
+ const point = ([x, y]) => `${n(x)} ${n(y)}`;
6
+ const contours = new Map();
7
+
8
+ // A wobbly polyline around the outline. Straight segments keep corners crisp.
9
+ function outline(shape, seed) {
10
+ let vertices = contours.get(shape.key);
11
+ if (!vertices) {
12
+ vertices = compileShapePath(shape.path).contour;
13
+ contours.set(shape.key, vertices);
14
+ }
15
+ const phase = seed * 0.017;
16
+ const wobble = ([x, y]) => [
17
+ x + 2.6 * Math.sin(y / 23 + phase) + 1.3 * Math.sin(x / 13 + y / 37),
18
+ y + 2.6 * Math.sin(x / 27 - phase) + 1.3 * Math.cos(y / 17 + x / 31),
19
+ ];
20
+ const points = [vertices[0]];
21
+ // Retain polygon corners; decimate the densely flattened curves.
22
+ for (let i = 1; i < vertices.length; i++) {
23
+ const current = vertices[i], previous = vertices[i - 1];
24
+ const next = vertices[(i + 1) % vertices.length];
25
+ const a = Math.atan2(current[1] - previous[1], current[0] - previous[0]);
26
+ const b = Math.atan2(next[1] - current[1], next[0] - current[0]);
27
+ const last = points[points.length - 1];
28
+ const distance = Math.hypot(current[0] - last[0], current[1] - last[1]);
29
+ if (distance < 9 && Math.abs(Math.atan2(Math.sin(b - a), Math.cos(b - a))) < 0.3 && i < vertices.length - 1) continue;
30
+ const steps = Math.max(1, Math.ceil(distance / 10));
31
+ for (let step = 1; step <= steps; step++) {
32
+ points.push([last[0] + ((current[0] - last[0]) * step) / steps, last[1] + ((current[1] - last[1]) * step) / steps]);
33
+ }
34
+ }
35
+ return points.map((p, i) => `${i ? "L" : "M"}${point(wobble(p))}`).join("") + "Z";
36
+ }
37
+
38
+ // A slightly irregular closed curve inside each packed hole.
39
+ function cutout(hole, seed, index) {
40
+ const phase = seed * 0.013 + index * 2.4;
41
+ const rotation = (hole.rotate * Math.PI) / 180;
42
+ const points = Array.from({ length: 12 }, (_, i) => {
43
+ const angle = (i * Math.PI) / 6;
44
+ // Shrink before wobbling to stay inside the original packing clearance.
45
+ const radius = 0.92 + 0.045 * Math.sin(angle * 3 + phase) + 0.03 * Math.cos(angle * 2 - phase);
46
+ const x = Math.cos(angle) * hole.rx * radius;
47
+ const y = Math.sin(angle) * hole.ry * radius;
48
+ return [hole.cx + x * Math.cos(rotation) - y * Math.sin(rotation), hole.cy + x * Math.sin(rotation) + y * Math.cos(rotation)];
49
+ });
50
+ let path = `M${point(points[0])}`;
51
+ for (let i = 0; i < points.length; i++) {
52
+ const prev = points[(i + 11) % 12], start = points[i];
53
+ const end = points[(i + 1) % 12], next = points[(i + 2) % 12];
54
+ path += `C${point(start.map((v, j) => v + (end[j] - prev[j]) / 6))} ${point(end.map((v, j) => v - (next[j] - start[j]) / 6))} ${point(end)}`;
55
+ }
56
+ return path + "Z";
57
+ }
58
+
59
+ export function createDoodleSvg(shape, holes, options) {
60
+ const seed = options.seed || 0;
61
+ const path = outline(shape, seed);
62
+ const paths = holes.map((hole, index) => cutout(hole, seed, index));
63
+ const duration = Math.max(100, Math.min(10000, Number(options.duration) || 900));
64
+ // The offset shadow is ink by default, or a solid silhouette in the shadow
65
+ // color when one is given, so it reads on dark backgrounds too.
66
+ const shadow = options.doodleShadow ?? ink;
67
+ // Turn and mirror the drawing, not its offset, so the ink shadow keeps its direction.
68
+ const turned = options.transform ? ` transform="${options.transform}"` : "";
69
+ const hole = (d, index, stroke = false) => {
70
+ const mark = `<path d="${d}" fill="${stroke ? "none" : "black"}"${stroke ? ` stroke="${ink}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"` : ""}/>`;
71
+ return options.animated === false
72
+ ? mark
73
+ : `<g class="doodle-cutout" style="transform-origin:${n(holes[index].cx)}px ${n(holes[index].cy)}px;--doodle-delay:${index * 27}ms;--doodle-duration:${duration}ms">${mark}</g>`;
74
+ };
75
+ const animation = options.animated === false
76
+ ? ""
77
+ : "<style>svg:hover .doodle-cutout{animation:doodle-cutout-pulse var(--doodle-duration) ease-in-out var(--doodle-delay) both}@keyframes doodle-cutout-pulse{0%,100%{transform:scale(1)}50%{transform:scale(.75)}}@media(prefers-reduced-motion:reduce){svg:hover .doodle-cutout{animation:none}}</style>";
78
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 420" role="img" aria-label="${shape.name} with ${holes.length} holes">${animation}<defs><mask id="h"><rect width="420" height="420" fill="white"/>${paths.map((d, i) => hole(d, i)).join("")}</mask></defs><g stroke="${ink}" stroke-width="7" stroke-linejoin="round" stroke-linecap="round"><g class="doodle-shadow-offset" transform="translate(9 11)"><g class="doodle-shadow"${turned}><path d="${path}" fill="${shadow}" stroke="${shadow}" mask="url(#h)"/></g></g><g class="doodle-face"${turned}><path d="${path}" fill="${shape.color}" mask="url(#h)"/><path d="${path}" fill="none"/>${paths.map((d, i) => hole(d, i, true)).join("")}</g></g></svg>`;
79
+ }
package/geometry.js ADDED
@@ -0,0 +1,116 @@
1
+ // Geometry for the absolute SVG commands used by the built-in shapes.
2
+ // Curves are flattened within 0.01 viewBox units, well below the packing rim.
3
+ // Both Node and browsers use this code so layout never depends on Canvas.
4
+ export function compileShapePath(source) {
5
+ const tokens = source.match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:e[-+]?\d+)?/g) || [];
6
+ const edges = [];
7
+ const contour = [];
8
+ let i = 0, command, x = 0, y = 0, startX = 0, startY = 0;
9
+ const number = () => {
10
+ const value = Number(tokens[i++]);
11
+ if (!Number.isFinite(value)) throw new Error('Invalid shape path');
12
+ return value;
13
+ };
14
+ const line = (nx, ny) => {
15
+ if (y !== ny) edges.push({ x, y, nx, ny });
16
+ x = nx; y = ny;
17
+ contour.push([x, y]);
18
+ };
19
+ function quadratic(x0, y0, cx, cy, x1, y1) {
20
+ // The curve's deviation from its chord is at most half this distance.
21
+ if (Math.hypot(cx - (x0 + x1) / 2, cy - (y0 + y1) / 2) <= 0.02) {
22
+ line(x1, y1);
23
+ return;
24
+ }
25
+ const ax = (x0 + cx) / 2, ay = (y0 + cy) / 2;
26
+ const bx = (cx + x1) / 2, by = (cy + y1) / 2;
27
+ const mx = (ax + bx) / 2, my = (ay + by) / 2;
28
+ quadratic(x0, y0, ax, ay, mx, my);
29
+ quadratic(mx, my, bx, by, x1, y1);
30
+ }
31
+ while (i < tokens.length) {
32
+ if (/^[a-zA-Z]$/.test(tokens[i])) command = tokens[i++];
33
+ switch (command) {
34
+ case 'M':
35
+ x = number(); y = number(); startX = x; startY = y; command = 'L';
36
+ contour.push([x, y]);
37
+ break;
38
+ case 'L': line(number(), number()); break;
39
+ case 'H': line(number(), y); break;
40
+ case 'V': line(x, number()); break;
41
+ case 'Q': {
42
+ const cx = number(), cy = number(), nx = number(), ny = number();
43
+ quadratic(x, y, cx, cy, nx, ny);
44
+ break;
45
+ }
46
+ case 'A': {
47
+ let rx = Math.abs(number()), ry = Math.abs(number());
48
+ const rotation = number() * Math.PI / 180;
49
+ const large = number(), sweep = number(), nx = number(), ny = number();
50
+ if (!rx || !ry) { line(nx, ny); break; }
51
+ if (x === nx && y === ny) break;
52
+ const c = Math.cos(rotation), s = Math.sin(rotation);
53
+ const dx = (x - nx) / 2, dy = (y - ny) / 2;
54
+ const px = c * dx + s * dy, py = -s * dx + c * dy;
55
+ const scale = Math.hypot(px / rx, py / ry);
56
+ if (scale > 1) { rx *= scale; ry *= scale; }
57
+ const factor = (large === sweep ? -1 : 1) * Math.sqrt(Math.max(0,
58
+ (rx * rx * ry * ry - rx * rx * py * py - ry * ry * px * px) /
59
+ (rx * rx * py * py + ry * ry * px * px)));
60
+ const cx = factor * rx * py / ry, cy = -factor * ry * px / rx;
61
+ const centerX = c * cx - s * cy + (x + nx) / 2;
62
+ const centerY = s * cx + c * cy + (y + ny) / 2;
63
+ const angle = Math.atan2((py - cy) / ry, (px - cx) / rx);
64
+ let delta = Math.atan2((-py - cy) / ry, (-px - cx) / rx) - angle;
65
+ if (sweep && delta < 0) delta += Math.PI * 2;
66
+ if (!sweep && delta > 0) delta -= Math.PI * 2;
67
+ const steps = Math.ceil(Math.abs(delta) / (2 * Math.acos(1 - Math.min(1, 0.01 / Math.max(rx, ry)))));
68
+ for (let step = 1; step < steps; step++) {
69
+ const a = angle + delta * step / steps;
70
+ line(centerX + c * rx * Math.cos(a) - s * ry * Math.sin(a),
71
+ centerY + s * rx * Math.cos(a) + c * ry * Math.sin(a));
72
+ }
73
+ line(nx, ny);
74
+ break;
75
+ }
76
+ case 'Z': line(startX, startY); command = null; break;
77
+ default: throw new Error(`Unsupported shape path command: ${command}`);
78
+ }
79
+ }
80
+ // Index edges by 1-unit vertical band in flat arrays: the optimizer makes
81
+ // millions of point queries, and each one only scans the few edges that can
82
+ // cross its row. The crossing arithmetic is unchanged, so results are exact.
83
+ let top = Infinity, bottom = -Infinity;
84
+ for (const edge of edges) {
85
+ top = Math.min(top, edge.y, edge.ny);
86
+ bottom = Math.max(bottom, edge.y, edge.ny);
87
+ }
88
+ const first = Math.floor(top), bandCount = edges.length ? Math.floor(bottom) - first + 1 : 0;
89
+ const bandEdges = Array.from({ length: bandCount }, () => []);
90
+ edges.forEach((edge, index) => {
91
+ for (let band = Math.floor(Math.min(edge.y, edge.ny)); band <= Math.floor(Math.max(edge.y, edge.ny)); band++) {
92
+ bandEdges[band - first].push(index);
93
+ }
94
+ });
95
+ const offsets = new Uint32Array(bandCount + 1);
96
+ bandEdges.forEach((list, band) => { offsets[band + 1] = offsets[band] + list.length; });
97
+ const data = new Float64Array(offsets[bandCount] * 4);
98
+ bandEdges.flat().forEach((index, slot) => {
99
+ const e = edges[index];
100
+ data.set([e.x, e.y, e.nx, e.ny], slot * 4);
101
+ });
102
+ return {
103
+ contour,
104
+ contains(px, py) {
105
+ const band = Math.floor(py) - first;
106
+ if (!(band >= 0 && band < bandCount)) return false;
107
+ let winding = 0;
108
+ for (let k = offsets[band] * 4, end = offsets[band + 1] * 4; k < end; k += 4) {
109
+ const x = data[k], y = data[k + 1], nx = data[k + 2], ny = data[k + 3];
110
+ if ((y > py) === (ny > py)) continue;
111
+ if (px < x + (py - y) * (nx - x) / (ny - y)) winding += ny > y ? 1 : -1;
112
+ }
113
+ return winding !== 0;
114
+ }
115
+ };
116
+ }
package/index.d.ts CHANGED
@@ -15,11 +15,20 @@ export type HoleyShapeKey =
15
15
  | 'bowtie';
16
16
 
17
17
  export interface HoleyOptions {
18
+ /** Hand-drawn outline and fixed ink shadow. */
19
+ doodle?: boolean;
20
+ /** Unique, stable prefix when inserting multiple SVG strings in one document. */
21
+ idPrefix?: string;
18
22
  shape?: HoleyShapeKey;
19
23
  color?: `#${string}`;
24
+ /** Extrusion color. For doodles, the offset shadow's color; ink when omitted. */
20
25
  shadowColor?: `#${string}`;
21
26
  holes?: number;
22
27
  seed?: number;
28
+ /** Quarter turns of the face: 0, 90, 180 or 270. Other values snap to the nearest. The extrusion keeps its direction. */
29
+ turn?: number;
30
+ /** Flip the face left to right. The extrusion keeps its direction. */
31
+ mirror?: boolean;
23
32
  animated?: boolean;
24
33
  duration?: number;
25
34
  shadowX?: number;
@@ -41,7 +50,26 @@ export interface HoleyMount {
41
50
  }
42
51
 
43
52
  export const shapeNames: readonly HoleyShapeInfo[];
44
- export const shapes: readonly Record<string, unknown>[];
45
- export function seededHoleyOptions(identity: string, animated?: boolean): HoleyOptions;
53
+ export interface HoleyShape extends HoleyShapeInfo {
54
+ readonly bg: string;
55
+ readonly initial: number;
56
+ readonly path: string;
57
+ readonly holes: readonly { cx: number; cy: number; rx: number; ry: number; rotate: number }[];
58
+ }
59
+ export const shapes: readonly HoleyShape[];
60
+ /** FNV-1a 64 hash of the UTF-8 ID as 16 lowercase hex digits. `seededHoleyOptions` starts from it. */
61
+ export function identityHash(identity: string): string;
62
+ /** Options derived from an identity. Swift and Kotlin derive the same values. */
63
+ export interface SeededHoleyOptions {
64
+ shape: HoleyShapeKey;
65
+ holes: number;
66
+ seed: number;
67
+ turn: 0 | 90 | 180 | 270;
68
+ mirror: boolean;
69
+ color: `#${string}`;
70
+ shadowColor: `#${string}`;
71
+ animated: boolean;
72
+ }
73
+ export function seededHoleyOptions(identity: string, animated?: boolean): SeededHoleyOptions;
46
74
  export function createHoleySvg(options?: HoleyOptions): string;
47
75
  export function mountHoleyShape(target: string | Element, options?: HoleyOptions): HoleyMount;
package/index.js CHANGED
@@ -1 +1 @@
1
- export { createHoleySvg, mountHoleyShape, seededHoleyOptions, shapeNames, shapes } from './script.js';
1
+ export { createHoleySvg, identityHash, mountHoleyShape, seededHoleyOptions, shapeNames, shapes } from './script.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "holey-shapes",
3
- "version": "1.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Colorful perforated SVG shapes with solid extrusion and animated holes.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,7 +11,8 @@
11
11
  },
12
12
  "repository": {
13
13
  "type": "git",
14
- "url": "git+https://github.com/skyaara/holey-shapes.git"
14
+ "url": "git+https://github.com/skyaara/holey-shapes.git",
15
+ "directory": "packages/js"
15
16
  },
16
17
  "homepage": "https://github.com/skyaara/holey-shapes#readme",
17
18
  "bugs": {
@@ -31,6 +32,9 @@
31
32
  "files": [
32
33
  "index.js",
33
34
  "script.js",
35
+ "geometry.js",
36
+ "doodle.js",
37
+ "color.js",
34
38
  "index.d.ts",
35
39
  "README.md",
36
40
  "LICENSE"
@@ -54,13 +58,7 @@
54
58
  "access": "public"
55
59
  },
56
60
  "scripts": {
57
- "dev": "vite --host 127.0.0.1",
58
- "build": "vite build",
59
- "preview": "vite preview --host 127.0.0.1",
60
- "test": "node tests/package.test.mjs && node tests/site.test.mjs",
61
- "prepublishOnly": "npm test && npm run build"
62
- },
63
- "devDependencies": {
64
- "vite": "^7.1.3"
61
+ "test": "node tests/package.test.mjs && node tests/packing.test.mjs",
62
+ "prepublishOnly": "npm test"
65
63
  }
66
64
  }
package/script.js CHANGED
@@ -1,49 +1,10 @@
1
+ import { compileShapePath } from './geometry.js';
2
+ import { extrusionColor, hueColor } from './color.js';
3
+ import { createDoodleSvg } from './doodle.js';
4
+
1
5
  const h = (cx, cy, rx, ry, rotate = 0) => ({ cx, cy, rx, ry, rotate });
2
6
 
3
7
  const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
4
- const root = isBrowser ? document.documentElement : null;
5
- const isHoleyApp = root?.hasAttribute('data-holey-app');
6
- const themeToggle = isHoleyApp ? document.querySelector('.theme-toggle') : null;
7
- const themeLabel = themeToggle?.querySelector('.theme-label');
8
-
9
- function applyTheme(theme) {
10
- if (!themeToggle || !themeLabel) return;
11
- const isDark = theme === 'dark';
12
- root.dataset.theme = theme;
13
- themeToggle.setAttribute('aria-pressed', String(isDark));
14
- themeToggle.setAttribute('aria-label', `Switch to ${isDark ? 'light' : 'dark'} mode`);
15
- themeLabel.textContent = isDark ? 'LIGHT' : 'DARK';
16
- }
17
-
18
- if (themeToggle) {
19
- const savedTheme = localStorage.getItem('holey-theme');
20
- const initialTheme = savedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
21
- applyTheme(initialTheme);
22
- themeToggle.addEventListener('click', () => {
23
- const nextTheme = root.dataset.theme === 'dark' ? 'light' : 'dark';
24
- applyTheme(nextTheme);
25
- localStorage.setItem('holey-theme', nextTheme);
26
- });
27
- }
28
-
29
- const installButton = isHoleyApp ? document.querySelector('.install-command') : null;
30
-
31
- if (installButton) {
32
- installButton.addEventListener('click', async () => {
33
- const label = installButton.querySelector('.install-copy-label');
34
- try {
35
- await navigator.clipboard.writeText('npm install holey-shapes');
36
- if (label) label.textContent = 'COPIED';
37
- window.setTimeout(() => {
38
- if (label) label.textContent = 'COPY';
39
- }, 1400);
40
- } catch {
41
- if (label) label.textContent = 'SELECT';
42
- window.getSelection()?.selectAllChildren(installButton.querySelector('code'));
43
- }
44
- });
45
- }
46
-
47
8
  export const shapes = [
48
9
  {
49
10
  key: 'disc', name: 'Perforated disc', color: '#6337FF', bg: '#D7CEFF', initial: 5,
@@ -117,23 +78,11 @@ export const shapes = [
117
78
  }
118
79
  ];
119
80
 
120
- const collection = isHoleyApp ? document.querySelector('#collection') : null;
121
81
  const MAX_HOLES = 8;
122
82
  const SHADOW_X = 18;
123
83
  const SHADOW_Y = 21;
124
84
  const SHADOW_STEPS = 12;
125
- const states = new Map();
126
- let packingContext = null;
127
85
  const packingCache = new Map();
128
- function getPackingContext() {
129
- if (packingContext) return packingContext;
130
- if (!isBrowser || typeof Path2D === 'undefined') return null;
131
- const packingCanvas = document.createElement('canvas');
132
- packingCanvas.width = 420;
133
- packingCanvas.height = 420;
134
- packingContext = packingCanvas.getContext('2d');
135
- return packingContext;
136
- }
137
86
 
138
87
  const holeMarkup = ({ cx, cy, rx, ry, rotate }) =>
139
88
  `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}"${rotate ? ` transform="rotate(${rotate} ${cx} ${cy})"` : ''}/>`;
@@ -146,27 +95,19 @@ function hoverHoleMarkup(hole, index, seed = 0, duration = 900) {
146
95
  return `<g class="hole-hover" style="--hole-duration:${pulseDuration}ms;--hole-delay:${delay}ms">${holeMarkup(hole)}</g>`;
147
96
  }
148
97
 
149
- function darkShadow(color, factor = 0.52) {
150
- const value = color.replace('#', '');
151
- const channels = [0, 2, 4].map((offset) => parseInt(value.slice(offset, offset + 2), 16));
152
- return `#${channels.map((channel) => Math.round(channel * factor).toString(16).padStart(2, '0')).join('')}`;
153
- }
154
98
 
155
99
  function circleFits(path, cx, cy, radius) {
156
- const context = getPackingContext();
157
- if (!context) return false;
158
100
  for (let step = 0; step < 32; step += 1) {
159
101
  const angle = (Math.PI * 2 * step) / 32;
160
102
  const x = cx + Math.cos(angle) * radius;
161
103
  const y = cy + Math.sin(angle) * radius;
162
- if (!context.isPointInPath(path, x, y)) return false;
104
+ if (!path.contains(x, y)) return false;
163
105
  }
164
106
  return true;
165
107
  }
166
108
 
167
109
  function boundaryClearance(path, cx, cy) {
168
- const context = getPackingContext();
169
- if (!context || !context.isPointInPath(path, cx, cy)) return 0;
110
+ if (!path.contains(cx, cy)) return 0;
170
111
  let low = 0;
171
112
  let high = 180;
172
113
  for (let pass = 0; pass < 9; pass += 1) {
@@ -179,13 +120,11 @@ function boundaryClearance(path, cx, cy) {
179
120
 
180
121
  function packingCandidates(shape) {
181
122
  if (packingCache.has(shape.key)) return packingCache.get(shape.key);
182
- const context = getPackingContext();
183
- if (!context) return { path: null, candidates: [] };
184
- const path = new Path2D(shape.path);
123
+ const path = compileShapePath(shape.path);
185
124
  const candidates = [];
186
125
  for (let y = 50; y <= 370; y += 12) {
187
126
  for (let x = 50; x <= 370; x += 12) {
188
- if (!context.isPointInPath(path, x, y)) continue;
127
+ if (!path.contains(x, y)) continue;
189
128
  const clearance = boundaryClearance(path, x, y);
190
129
  if (clearance >= 17) candidates.push({ x, y, clearance });
191
130
  }
@@ -245,9 +184,7 @@ const optimizedLayoutCache = new Map();
245
184
 
246
185
  function maximizeHoleAreas(shape, holes) {
247
186
  if (!holes.length) return holes;
248
- const context = getPackingContext();
249
187
  const { path } = packingCandidates(shape);
250
- if (!context || !path) return holes;
251
188
  const gap = 12;
252
189
  const profiles = holes.map(hole => {
253
190
  const a = Math.sqrt(hole.rx / hole.ry);
@@ -267,17 +204,21 @@ function maximizeHoleAreas(shape, holes) {
267
204
  });
268
205
  const points = holes.map(h => ({ x: h.cx, y: h.cy }));
269
206
  function boundaryLimit(point, profile) {
270
- if (!context.isPointInPath(path, point.x, point.y)) return 0;
207
+ if (!path.contains(point.x, point.y)) return 0;
271
208
  let low = 0, high = 180;
272
209
  for (let pass = 0; pass < 11; pass++) {
273
210
  const radius = (low + high) / 2;
274
211
  // An extra rim unit covers the space between perimeter samples.
275
- const fits = profile.edge.every(e => context.isPointInPath(path,
212
+ const fits = profile.edge.every(e => path.contains(
276
213
  point.x + radius * e.x + (gap + 1) * e.nx, point.y + radius * e.y + (gap + 1) * e.ny));
277
214
  if (fits) low = radius; else high = radius;
278
215
  }
279
216
  return low;
280
217
  }
218
+ function fitsAt(point, profile, radius) {
219
+ return radius < 180 && path.contains(point.x, point.y) && profile.edge.every(e => path.contains(
220
+ point.x + radius * e.x + (gap + 1) * e.nx, point.y + radius * e.y + (gap + 1) * e.ny));
221
+ }
281
222
  const boundaries = points.map((p, i) => boundaryLimit(p, profiles[i]));
282
223
  function capacity(point, index, boundary) {
283
224
  let limit = boundary;
@@ -301,8 +242,12 @@ function maximizeHoleAreas(shape, holes) {
301
242
  let bestLimit = capacity(point, i, bestBoundary);
302
243
  for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) {
303
244
  const candidate = { x: point.x + dx * step, y: point.y + dy * step };
245
+ // A move wins only if both limits beat the threshold, so reject on
246
+ // the cheap separation test and a single rim test before the search.
247
+ const separation = capacity(candidate, i, Infinity);
248
+ if (separation <= bestLimit + 0.02 || !fitsAt(candidate, profiles[i], bestLimit + 0.02)) continue;
304
249
  const boundary = boundaryLimit(candidate, profiles[i]);
305
- const limit = capacity(candidate, i, boundary);
250
+ const limit = Math.min(separation, boundary);
306
251
  if (limit > bestLimit + 0.02) {
307
252
  best = candidate; bestBoundary = boundary; bestLimit = limit;
308
253
  }
@@ -330,10 +275,7 @@ function packedHoleLayout(shape, count, seed) {
330
275
  function initialHoleLayout(shape, count, seed) {
331
276
  if (count === 0) return [];
332
277
  const { candidates } = packingCandidates(shape);
333
- if (!candidates.length) {
334
- const start = ((Math.trunc(seed) % shape.holes.length) + shape.holes.length) % shape.holes.length;
335
- return equalizeHoleAreas(Array.from({ length: count }, (_, index) => ({ ...shape.holes[(start + index) % shape.holes.length] })));
336
- }
278
+ if (candidates.length < count) throw new Error(`Insufficient packing space for ${shape.key}`);
337
279
  const maximumClearance = Math.max(...candidates.map(({ clearance }) => clearance));
338
280
  const starterPool = candidates.filter(({ clearance }) => clearance >= maximumClearance * 0.72);
339
281
  const first = seed === 0
@@ -381,7 +323,7 @@ function initialHoleLayout(shape, count, seed) {
381
323
  ? Infinity
382
324
  : Math.min(...selected.filter((_, otherIndex) => otherIndex !== index).map((other) =>
383
325
  Math.hypot(point.x - other.x, point.y - other.y)));
384
- const style = shape.holes[(index + seed) % shape.holes.length];
326
+ const style = shape.holes[((index + seed) % shape.holes.length + shape.holes.length) % shape.holes.length];
385
327
  const styleRadius = Math.max(style.rx, style.ry);
386
328
  const aspectArea = (style.rx / styleRadius) * (style.ry / styleRadius);
387
329
  const territoryArea = cells[index].length * 144;
@@ -398,12 +340,6 @@ function initialHoleLayout(shape, count, seed) {
398
340
  }));
399
341
  }
400
342
 
401
- function svgMarkup(shape, holes, className, maskSuffix, accessible = false) {
402
- const maskId = `mask-${shape.key}-${maskSuffix}`;
403
- const fill = className === 'shape-shadow' ? darkShadow(shape.color) : shape.color;
404
- return `<svg class="shape-layer ${className}" viewBox="0 0 420 420" ${accessible ? `role="img" aria-label="${shape.name} with ${holes.length} holes"` : 'aria-hidden="true"'}><defs><mask id="${maskId}"><rect width="420" height="420" fill="white"/>${holes.map(holeMarkup).join('')}</mask></defs><path d="${shape.path}" fill="${fill}" mask="url(#${maskId})"/></svg>`;
405
- }
406
-
407
343
  function compactNumber(value) {
408
344
  const rounded = Math.round(value * 100) / 100;
409
345
  return String(Object.is(rounded, -0) ? 0 : rounded);
@@ -413,7 +349,7 @@ function extrusionFilterMarkup(shape, filterId, options = {}) {
413
349
  const shadowX = options.shadowX ?? SHADOW_X;
414
350
  const shadowY = options.shadowY ?? SHADOW_Y;
415
351
  const shadowSteps = options.shadowSteps ?? SHADOW_STEPS;
416
- const shadowColor = options.shadowColor ?? darkShadow(shape.color);
352
+ const shadowColor = options.shadowColor ?? extrusionColor(shape.color);
417
353
  const offsets = Array.from({ length: shadowSteps }, (_, index) => {
418
354
  const step = index + 1;
419
355
  const ratio = step / shadowSteps;
@@ -425,23 +361,14 @@ function extrusionFilterMarkup(shape, filterId, options = {}) {
425
361
  return `<filter id="${filterId}" x="-20%" y="-20%" width="160%" height="160%" color-interpolation-filters="sRGB"><feFlood flood-color="${shadowColor}" result="p"/><feComposite in="p" in2="SourceGraphic" operator="in" result="s"/>${offsets}<feMerge>${merge}<feMergeNode in="SourceGraphic"/></feMerge></filter>`;
426
362
  }
427
363
 
428
- function objectMarkup(shape, holes, suffix, accessible = false, options = {}) {
429
- const maskId = `mask-${shape.key}-${suffix}`;
430
- const filterId = `extrusion-${shape.key}-${suffix}`;
431
- const seed = options.seed ?? 0;
432
- const duration = options.duration ?? 900;
433
- const maskHoles = holes.map((hole, index) => hoverHoleMarkup(hole, index, seed, duration)).join('');
434
- return `<svg class="shape-layer shape-object" viewBox="0 0 420 420" ${accessible ? `role="img" aria-label="${shape.name} with ${holes.length} holes"` : 'aria-hidden="true"'}><defs><mask id="${maskId}"><rect width="420" height="420" fill="white"/>${maskHoles}</mask>${extrusionFilterMarkup(shape, filterId)}</defs><g filter="url(#${filterId})"><path d="${shape.path}" fill="${shape.color}" mask="url(#${maskId})"/></g></svg>`;
435
- }
436
-
437
- function renderShape(card, shape, state) {
438
- const holes = packedHoleLayout(shape, state.count, state.seed);
439
- card.querySelector('.shape-art').innerHTML = objectMarkup(shape, holes, 'card', true, { seed: state.seed });
440
- card.querySelector('.hole-count').textContent = `${String(state.count).padStart(2, '0')} / ${String(state.limit).padStart(2, '0')}`;
441
- card.querySelector('.hole-range').value = state.count;
364
+ // Quarter turns and mirroring apply to the face only. The extrusion is drawn
365
+ // around it in unturned space, so every holey's depth points the same way.
366
+ function orientation(turn = 0, mirror = false) {
367
+ return [turn ? `rotate(${turn} 210 210)` : '', mirror ? 'translate(420 0) scale(-1 1)' : ''].filter(Boolean).join(' ');
442
368
  }
443
369
 
444
370
  function exportSvg(shape, holes, options = {}) {
371
+ if (options.doodle) return createDoodleSvg(shape, holes, options);
445
372
  const filter = extrusionFilterMarkup(shape, 'e', options);
446
373
  const duration = Math.max(100, Math.min(10000, Number(options.duration) || 900));
447
374
  const seed = Math.trunc(Number(options.seed) || 0);
@@ -451,7 +378,7 @@ function exportSvg(shape, holes, options = {}) {
451
378
  const animation = options.animated === false
452
379
  ? ''
453
380
  : `<style>.hole-hover{transform-box:fill-box;transform-origin:center;will-change:transform}svg:hover .hole-hover{animation:hole-open-close var(--hole-duration) cubic-bezier(.33,0,.2,1) var(--hole-delay) both}@keyframes hole-open-close{0%,100%{transform:scale(1)}50%{transform:scale(.06)}}@media(prefers-reduced-motion:reduce){svg:hover .hole-hover{animation:none}}</style>`;
454
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 420" role="img" aria-label="${shape.name} with ${holes.length} holes">${animation}<defs><mask id="h"><rect width="420" height="420" fill="white"/>${maskHoles}</mask>${filter}</defs><g filter="url(#e)"><path d="${shape.path}" fill="${shape.color}" mask="url(#h)"/></g></svg>`;
381
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 420" role="img" aria-label="${shape.name} with ${holes.length} holes">${animation}<defs><mask id="h"><rect width="420" height="420" fill="white"/>${maskHoles}</mask>${filter}</defs><g filter="url(#e)"><path${options.transform ? ` transform="${options.transform}"` : ''} d="${shape.path}" fill="${shape.color}" mask="url(#h)"/></g></svg>`;
455
382
  }
456
383
 
457
384
  function normalizeColor(color, fallback) {
@@ -459,29 +386,58 @@ function normalizeColor(color, fallback) {
459
386
  }
460
387
 
461
388
  function numberInRange(value, fallback, min, max) {
389
+ if (value === null || value === undefined || value === '') return fallback;
462
390
  const number = Number(value);
463
391
  return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
464
392
  }
465
393
 
466
- export const shapeNames = Object.freeze(shapes.map(({ key, name, color }) => ({ key, name, color })));
394
+ export const shapeNames = Object.freeze(shapes.map(({ key, name, color }) => Object.freeze({ key, name, color })));
467
395
 
468
- export function seededHoleyOptions(identity, animated = true) {
469
- const bytes = new TextEncoder().encode(String(identity));
396
+ // Callers inserting several SVG strings can supply a stable, unique prefix.
397
+ // Encoding each code point avoids collisions between punctuation and underscores.
398
+ function scopeSvg(source, prefix) {
399
+ if (prefix === undefined) return source;
400
+ const id = `holey-${Array.from(String(prefix), c => c.codePointAt(0).toString(16)).join('-') || 'empty'}`;
401
+ return source.replaceAll('id="h"', `id="${id}-h"`).replaceAll('url(#h)', `url(#${id}-h)`)
402
+ .replaceAll('id="e"', `id="${id}-e"`).replaceAll('url(#e)', `url(#${id}-e)`);
403
+ }
404
+
405
+ function fnv1a64(identity) {
470
406
  let hash = 14695981039346656037n;
471
- for (const byte of bytes) {
407
+ for (const byte of new TextEncoder().encode(String(identity))) {
472
408
  hash = BigInt.asUintN(64, (hash ^ BigInt(byte)) * 1099511628211n);
473
409
  }
474
- let state = hash;
475
- const next = () => {
476
- state = BigInt.asUintN(64, state + 0x9E3779B97F4A7C15n);
477
- let value = state;
478
- value = BigInt.asUintN(64, (value ^ (value >> 30n)) * 0xBF58476D1CE4E5B9n);
479
- value = BigInt.asUintN(64, (value ^ (value >> 27n)) * 0x94D049BB133111EBn);
480
- return BigInt.asUintN(64, value ^ (value >> 31n));
410
+ return hash;
411
+ }
412
+
413
+ export function identityHash(identity) {
414
+ return fnv1a64(identity).toString(16).padStart(16, '0');
415
+ }
416
+
417
+ // Seeding: the identity hash is mixed once, with a bijection so no bits are
418
+ // lost, then read as mixed-radix digits, so every field gets its own bits.
419
+ function mix64(value) {
420
+ value = BigInt.asUintN(64, (value ^ (value >> 30n)) * 0xBF58476D1CE4E5B9n);
421
+ value = BigInt.asUintN(64, (value ^ (value >> 27n)) * 0x94D049BB133111EBn);
422
+ return value ^ (value >> 31n);
423
+ }
424
+
425
+ export function seededHoleyOptions(identity, animated = true) {
426
+ let rest = mix64(fnv1a64(identity));
427
+ const take = (count) => {
428
+ const digit = Number(rest % BigInt(count));
429
+ rest /= BigInt(count);
430
+ return digit;
481
431
  };
482
- const shape = shapes[Number(next() % BigInt(shapes.length))];
483
- const seed = Number(next() % 1000001n);
484
- return { shape: shape.key, seed, animated };
432
+ const shape = shapes[take(shapes.length)];
433
+ const fewest = Math.max(2, shape.initial - 1);
434
+ const most = Math.min(MAX_HOLES, shape.holes.length, shape.initial + 1);
435
+ const holes = fewest + take(most - fewest + 1);
436
+ const turn = take(4) * 90;
437
+ const mirror = take(2) === 1;
438
+ const color = hueColor(take(3600) / 10);
439
+ const seed = take(1000001);
440
+ return { shape: shape.key, holes, seed, turn, mirror, color, shadowColor: extrusionColor(color), animated };
485
441
  }
486
442
 
487
443
  export function createHoleySvg(options = {}) {
@@ -493,19 +449,25 @@ export function createHoleySvg(options = {}) {
493
449
  const shadowX = numberInRange(options.shadowX, SHADOW_X, -80, 80);
494
450
  const shadowY = numberInRange(options.shadowY, SHADOW_Y, -80, 80);
495
451
  const shadowSteps = Math.round(numberInRange(options.shadowSteps, SHADOW_STEPS, 1, 32));
496
- const shadowColor = normalizeColor(options.shadowColor, darkShadow(color));
452
+ const shadowColor = normalizeColor(options.shadowColor, extrusionColor(color));
453
+ const turn = ((Math.round(numberInRange(options.turn, 0, -36000, 36000) / 90) % 4) + 4) % 4 * 90;
497
454
  const holes = packedHoleLayout(shape, holeCount, seed);
498
- return exportSvg(shape, holes, {
455
+ return scopeSvg(exportSvg(shape, holes, {
456
+ doodle: options.doodle === true,
457
+ transform: orientation(turn, options.mirror === true),
499
458
  animated: options.animated,
500
459
  duration: options.duration,
501
460
  seed,
502
461
  shadowColor,
462
+ // Doodles keep their ink shadow unless a shadow color is asked for.
463
+ doodleShadow: options.shadowColor === undefined ? undefined : shadowColor,
503
464
  shadowX,
504
465
  shadowY,
505
466
  shadowSteps
506
- });
467
+ }), options.idPrefix);
507
468
  }
508
469
 
470
+ let mountSequence = 0;
509
471
  export function mountHoleyShape(target, options = {}) {
510
472
  const element = typeof target === 'string' && isBrowser ? document.querySelector(target) : target;
511
473
  if (!element || typeof element.replaceChildren !== 'function') {
@@ -513,10 +475,18 @@ export function mountHoleyShape(target, options = {}) {
513
475
  }
514
476
 
515
477
  let currentOptions = { ...options };
478
+ const idPrefix = `mount-${++mountSequence}`;
479
+ const ownerDocument = element.ownerDocument;
480
+ let mounted;
481
+ let destroyed = false;
516
482
  const render = () => {
517
- const template = document.createElement('template');
518
- template.innerHTML = createHoleySvg(currentOptions).trim();
519
- element.replaceChildren(template.content.firstElementChild);
483
+ if (destroyed) return;
484
+ const template = ownerDocument.createElement('template');
485
+ template.innerHTML = createHoleySvg({ ...currentOptions, idPrefix }).trim();
486
+ const next = template.content.firstElementChild;
487
+ if (mounted?.parentNode === element) mounted.replaceWith(next);
488
+ else element.append(next);
489
+ mounted = next;
520
490
  };
521
491
  render();
522
492
 
@@ -527,149 +497,13 @@ export function mountHoleyShape(target, options = {}) {
527
497
  render();
528
498
  },
529
499
  shuffle() {
530
- currentOptions.seed = (Number(currentOptions.seed) || 0) + 1;
500
+ const seed = Math.trunc(numberInRange(currentOptions.seed, 0, -1000000, 1000000));
501
+ currentOptions.seed = seed >= 1000000 ? -1000000 : seed + 1;
531
502
  render();
532
503
  },
533
504
  destroy() {
534
- element.replaceChildren();
505
+ destroyed = true;
506
+ mounted?.remove();
535
507
  }
536
508
  };
537
509
  }
538
-
539
- function downloadSvg(shape, holes) {
540
- const source = exportSvg(shape, holes);
541
- const url = URL.createObjectURL(new Blob([source], { type: 'image/svg+xml' }));
542
- const link = document.createElement('a');
543
- link.href = url;
544
- link.download = `${shape.key}-${holes.length}-holes.svg`;
545
- link.click();
546
- URL.revokeObjectURL(url);
547
- }
548
-
549
- if (collection) shapes.forEach((shape, index) => {
550
- const number = String(index + 1).padStart(2, '0');
551
- const holeLimit = Math.min(MAX_HOLES, shape.holes.length);
552
- const wide = index === 0 || index === shapes.length - 1;
553
- const card = document.createElement('article');
554
- card.className = `form-card${wide ? ' form-card--wide' : ''}`;
555
- card.dataset.index = number;
556
- card.innerHTML = `
557
- <div class="card-meta"><span>${number}</span><h2>${shape.name}</h2><span>MAX ${String(holeLimit).padStart(2, '0')}</span></div>
558
- <div class="stage" style="background:${shape.bg}">
559
- <div class="shape-art${wide ? ' shape-art--large' : ''}"></div>
560
- ${index === 0 ? '<span class="orbit-line" aria-hidden="true"></span>' : ''}
561
- ${index === shapes.length - 1 ? '<span class="crosshair" aria-hidden="true"></span>' : ''}
562
- </div>
563
- <div class="card-actions">
564
- <label class="hole-control"><span>HOLES</span><input class="hole-range" type="range" min="0" max="${holeLimit}" value="${Math.min(shape.initial, holeLimit)}"/><output class="hole-count"></output></label>
565
- <button class="shuffle" type="button">SHUFFLE ↻</button>
566
- <button class="download" type="button">GET SVG <span>↓</span></button>
567
- </div>`;
568
- collection.append(card);
569
- const state = { count: Math.min(shape.initial, holeLimit), seed: 0, limit: holeLimit };
570
- states.set(shape.key, state);
571
- renderShape(card, shape, state);
572
-
573
- card.querySelector('.hole-range').addEventListener('input', (event) => {
574
- state.count = Number(event.target.value);
575
- renderShape(card, shape, state);
576
- });
577
- card.querySelector('.shuffle').addEventListener('click', () => {
578
- state.seed += 1;
579
- renderShape(card, shape, state);
580
- });
581
- card.querySelector('.download').addEventListener('click', () => {
582
- downloadSvg(shape, packedHoleLayout(shape, state.count, state.seed));
583
- });
584
- });
585
-
586
- const presentApp = isHoleyApp ? document.querySelector('#present-app') : null;
587
-
588
- if (presentApp) {
589
- const art = document.querySelector('#present-art');
590
- const shapePicker = document.querySelector('#shape-picker');
591
- const colorPicker = document.querySelector('#color-picker');
592
- const range = document.querySelector('#present-holes');
593
- const count = document.querySelector('#present-count');
594
- const selectedName = document.querySelector('#selected-name');
595
- const shuffle = document.querySelector('#present-shuffle');
596
- const download = document.querySelector('#present-download');
597
- const canvas = document.querySelector('.present-canvas');
598
- const palette = ['#6337FF', '#9BED00', '#FF0878', '#2878FF', '#FF6A00', '#00DBFF', '#FFD000', '#C94DFF', '#00D7B9'];
599
- const presentState = { index: 0, count: shapes[0].initial, seed: 0, color: shapes[0].color };
600
-
601
- const activeShape = () => ({ ...shapes[presentState.index], color: presentState.color });
602
-
603
- function syncPresentControls() {
604
- const shape = shapes[presentState.index];
605
- const limit = Math.min(MAX_HOLES, shape.holes.length);
606
- selectedName.textContent = shape.name;
607
- range.max = limit;
608
- range.value = presentState.count;
609
- count.textContent = `${String(presentState.count).padStart(2, '0')} / ${String(limit).padStart(2, '0')}`;
610
- shapePicker.querySelectorAll('.shape-option').forEach((button, index) => {
611
- button.classList.toggle('is-active', index === presentState.index);
612
- button.setAttribute('aria-pressed', String(index === presentState.index));
613
- });
614
- colorPicker.querySelectorAll('.color-option').forEach((button) => {
615
- const active = button.dataset.color === presentState.color;
616
- button.classList.toggle('is-active', active);
617
- button.setAttribute('aria-pressed', String(active));
618
- });
619
- }
620
-
621
- function renderPresent() {
622
- const shape = activeShape();
623
- const holes = packedHoleLayout(shape, presentState.count, presentState.seed);
624
- art.innerHTML = objectMarkup(shape, holes, 'present', true, { seed: presentState.seed });
625
- syncPresentControls();
626
- }
627
-
628
- shapes.forEach((shape, index) => {
629
- const button = document.createElement('button');
630
- const previewHoles = packedHoleLayout(shape, Math.min(3, shape.holes.length), 0);
631
- button.className = 'shape-option';
632
- button.type = 'button';
633
- button.setAttribute('aria-label', shape.name);
634
- button.innerHTML = `<span class="shape-thumb">${svgMarkup(shape, previewHoles, 'picker-shape', `picker-${index}`)}</span><span>${String(index + 1).padStart(2, '0')}</span>`;
635
- button.addEventListener('click', () => {
636
- presentState.index = index;
637
- presentState.count = Math.min(shape.initial, MAX_HOLES, shape.holes.length);
638
- presentState.seed = 0;
639
- presentState.color = shape.color;
640
- renderPresent();
641
- });
642
- shapePicker.append(button);
643
- });
644
-
645
- palette.forEach((color) => {
646
- const button = document.createElement('button');
647
- button.className = 'color-option';
648
- button.type = 'button';
649
- button.dataset.color = color;
650
- button.style.setProperty('--swatch', color);
651
- button.setAttribute('aria-label', `Use color ${color}`);
652
- button.addEventListener('click', () => {
653
- presentState.color = color;
654
- renderPresent();
655
- });
656
- colorPicker.append(button);
657
- });
658
-
659
- range.addEventListener('input', () => {
660
- presentState.count = Number(range.value);
661
- renderPresent();
662
- });
663
-
664
- shuffle.addEventListener('click', () => {
665
- presentState.seed += 1;
666
- renderPresent();
667
- });
668
-
669
- download.addEventListener('click', () => {
670
- const shape = activeShape();
671
- downloadSvg(shape, packedHoleLayout(shape, presentState.count, presentState.seed));
672
- });
673
-
674
- renderPresent();
675
- }