holey-shapes 2.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,195 +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: "2.0.0")
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:v2.0.0")
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
- ## Generate layouts in Swift and JVM processes
85
-
86
- Swift packing uses Foundation and plain geometry. It needs no SwiftUI view or CoreGraphics context. The package conditionally includes its SwiftUI renderer on supported Apple platforms.
87
-
88
- ```swift
89
- import HoleyShapes
90
-
91
- let holes = HoleyShapes.layout(shape: .prism, count: 6, seed: 2)
92
- // Each hole exposes cx, cy, rx, ry, and rotation.
93
- ```
94
-
95
- The Kotlin `core` module is a standalone JVM library with no Android or Compose dependencies. The Android SDK uses this same core. For a local JVM consumer, run `./gradlew :core:publishToMavenLocal`, enable `mavenLocal()`, and add `com.github.skyaara:holey-shapes-core:2.0.0`. For JitPack, the standalone module coordinate is `com.github.skyaara.holey-shapes:holey-shapes-core:v2.0.0`.
96
-
97
- ```kotlin
98
- import com.aakashreddy.holeyshapes.core.HoleyPacking
99
- import com.aakashreddy.holeyshapes.core.HoleyShape
100
-
101
- val holes = HoleyPacking.layout(HoleyShape.Prism, count = 6, seed = 2)
102
- ```
103
-
104
- Both APIs use full equal-area packing, clamp counts to 0–8 and seeds to ±1,000,000, and cache layouts. They never substitute preset layouts. Floating-point differences between language runtimes can produce different positions; byte-identical layouts across Swift, Kotlin, and JavaScript are not guaranteed.
105
-
106
- Run the process checks locally:
107
-
108
- ```sh
109
- swift run --package-path tests/native/swift -c release Verify /tmp/swift-layouts.json
110
- ./gradlew :core:verifyPacking
111
- # Independent Canvas checks require Playwright and installed Google Chrome.
112
- node scripts/verify-native-layouts.mjs /tmp/swift-layouts.json core/build/packing-layouts.json
113
- ```
114
-
115
- ## Render an SVG string
116
-
117
- ```js
118
- import { createHoleySvg } from 'holey-shapes';
119
-
120
- const svg = createHoleySvg({
121
- shape: 'prism',
122
- color: '#FF4FA3',
123
- shadowColor: '#7A1748',
124
- holes: 6,
125
- seed: 2
126
- });
127
-
128
- document.querySelector('#shape').innerHTML = svg;
129
- ```
130
-
131
- `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.
132
-
133
- ## Mount and update a shape
134
-
135
11
  ```js
136
- import { mountHoleyShape } from 'holey-shapes';
12
+ import { createHoleySvg, mountHoleyShape } from 'holey-shapes';
137
13
 
138
- const shape = mountHoleyShape('#shape', {
139
- shape: 'disc',
140
- color: '#6337FF',
141
- holes: 5
142
- });
14
+ const svg = createHoleySvg({ shape: 'prism', color: '#FF4FA3', holes: 6, seed: 2 });
15
+ document.querySelector('#preview').innerHTML = svg;
143
16
 
144
- shape.update({ color: '#00D7B9', holes: 8 });
145
- shape.shuffle();
17
+ const mounted = mountHoleyShape('#avatar', { shape: 'disc', holes: 5 });
18
+ mounted.update({ color: '#00D7B9' });
146
19
  ```
147
20
 
148
- `mountHoleyShape()` returns `update()`, `shuffle()`, and `destroy()` methods. Updates replace only the mounted SVG.
149
-
150
- The generated SVG scales to its container:
151
-
152
- ```css
153
- #shape {
154
- width: 320px;
155
- }
156
-
157
- #shape svg {
158
- display: block;
159
- width: 100%;
160
- height: auto;
161
- }
162
- ```
163
-
164
- ## Options
165
-
166
- | Option | Default | Description |
167
- | --- | --- | --- |
168
- | `shape` | `disc` | One of the keys in `shapeNames` |
169
- | `color` | Shape color | Six-digit hex face color |
170
- | `shadowColor` | Darker face color | Six-digit hex extrusion color |
171
- | `holes` | Shape default | Hole count from 0 to 8 |
172
- | `seed` | `0` | Changes the hole layout |
173
- | `animated` | `true` | Includes the hover hole animation |
174
- | `duration` | `900` | Total hole-animation duration in milliseconds |
175
- | `shadowX` | `18` | Horizontal extrusion offset |
176
- | `shadowY` | `21` | Vertical extrusion offset |
177
- | `shadowSteps` | `12` | Number of solid extrusion slices |
178
-
179
- Available shape keys: `disc`, `round-block`, `hex`, `capsule`, `prism`, `cross`, `triangle`, `diamond`, `sunburst`, `octagon`, `chevron`, `long-bar`, `flower-star`, and `bowtie`.
180
-
181
- 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:
182
22
 
183
23
  ```js
184
- import { shapeNames } from 'holey-shapes';
24
+ import { createHoleySvg, identityHash, seededHoleyOptions } from 'holey-shapes';
185
25
 
186
- for (const { key, name, color } of shapeNames) {
187
- console.log(key, name, color);
188
- }
26
+ identityHash('relay-agent'); // '0cc0c8aa3dbd893e'
27
+ avatar.innerHTML = createHoleySvg({ ...seededHoleyOptions('relay-agent'), idPrefix: 'relay-agent' });
189
28
  ```
190
29
 
191
- ## Browser support
30
+ Pass a unique `idPrefix` to `createHoleySvg()` when adding multiple SVG strings to one document. `mountHoleyShape()` creates isolated IDs automatically.
192
31
 
193
- The package has no runtime dependencies. Node processes, server-side rendering, and browsers use the same JavaScript geometry and full hole-packing algorithm. SVG generation needs no DOM, Canvas, or `Path2D`; identical options produce identical SVG markup. Only `mountHoleyShape` and interactive animation require a browser.
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).
194
33
 
195
- 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 CHANGED
@@ -4,6 +4,7 @@
4
4
  export function compileShapePath(source) {
5
5
  const tokens = source.match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:e[-+]?\d+)?/g) || [];
6
6
  const edges = [];
7
+ const contour = [];
7
8
  let i = 0, command, x = 0, y = 0, startX = 0, startY = 0;
8
9
  const number = () => {
9
10
  const value = Number(tokens[i++]);
@@ -13,6 +14,7 @@ export function compileShapePath(source) {
13
14
  const line = (nx, ny) => {
14
15
  if (y !== ny) edges.push({ x, y, nx, ny });
15
16
  x = nx; y = ny;
17
+ contour.push([x, y]);
16
18
  };
17
19
  function quadratic(x0, y0, cx, cy, x1, y1) {
18
20
  // The curve's deviation from its chord is at most half this distance.
@@ -31,6 +33,7 @@ export function compileShapePath(source) {
31
33
  switch (command) {
32
34
  case 'M':
33
35
  x = number(); y = number(); startX = x; startY = y; command = 'L';
36
+ contour.push([x, y]);
34
37
  break;
35
38
  case 'L': line(number(), number()); break;
36
39
  case 'H': line(number(), y); break;
@@ -74,20 +77,38 @@ export function compileShapePath(source) {
74
77
  default: throw new Error(`Unsupported shape path command: ${command}`);
75
78
  }
76
79
  }
77
- // Index edges by vertical band to keep the optimizer's many point queries cheap.
78
- const bands = new Map();
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;
79
84
  for (const edge of edges) {
80
- for (let band = Math.floor(Math.min(edge.y, edge.ny) / 8); band <= Math.floor(Math.max(edge.y, edge.ny) / 8); band++) {
81
- if (!bands.has(band)) bands.set(band, []);
82
- bands.get(band).push(edge);
83
- }
85
+ top = Math.min(top, edge.y, edge.ny);
86
+ bottom = Math.max(bottom, edge.y, edge.ny);
84
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
+ });
85
102
  return {
103
+ contour,
86
104
  contains(px, py) {
105
+ const band = Math.floor(py) - first;
106
+ if (!(band >= 0 && band < bandCount)) return false;
87
107
  let winding = 0;
88
- for (const e of bands.get(Math.floor(py / 8)) || []) {
89
- if ((e.y > py) === (e.ny > py)) continue;
90
- if (px < e.x + (py - e.y) * (e.nx - e.x) / (e.ny - e.y)) winding += e.ny > e.y ? 1 : -1;
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;
91
112
  }
92
113
  return winding !== 0;
93
114
  }
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": "2.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": {
@@ -32,6 +33,8 @@
32
33
  "index.js",
33
34
  "script.js",
34
35
  "geometry.js",
36
+ "doodle.js",
37
+ "color.js",
35
38
  "index.d.ts",
36
39
  "README.md",
37
40
  "LICENSE"
@@ -55,13 +58,7 @@
55
58
  "access": "public"
56
59
  },
57
60
  "scripts": {
58
- "dev": "vite --host 127.0.0.1",
59
- "build": "vite build",
60
- "preview": "vite preview --host 127.0.0.1",
61
- "test": "node tests/package.test.mjs && node tests/packing.test.mjs && node tests/site.test.mjs",
62
- "prepublishOnly": "npm test && npm run build"
63
- },
64
- "devDependencies": {
65
- "vite": "^7.1.3"
61
+ "test": "node tests/package.test.mjs && node tests/packing.test.mjs",
62
+ "prepublishOnly": "npm test"
66
63
  }
67
64
  }
package/script.js CHANGED
@@ -1,51 +1,10 @@
1
1
  import { compileShapePath } from './geometry.js';
2
+ import { extrusionColor, hueColor } from './color.js';
3
+ import { createDoodleSvg } from './doodle.js';
2
4
 
3
5
  const h = (cx, cy, rx, ry, rotate = 0) => ({ cx, cy, rx, ry, rotate });
4
6
 
5
7
  const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
6
- const root = isBrowser ? document.documentElement : null;
7
- const isHoleyApp = root?.hasAttribute('data-holey-app');
8
- const themeToggle = isHoleyApp ? document.querySelector('.theme-toggle') : null;
9
- const themeLabel = themeToggle?.querySelector('.theme-label');
10
-
11
- function applyTheme(theme) {
12
- if (!themeToggle || !themeLabel) return;
13
- const isDark = theme === 'dark';
14
- root.dataset.theme = theme;
15
- themeToggle.setAttribute('aria-pressed', String(isDark));
16
- themeToggle.setAttribute('aria-label', `Switch to ${isDark ? 'light' : 'dark'} mode`);
17
- themeLabel.textContent = isDark ? 'LIGHT' : 'DARK';
18
- }
19
-
20
- if (themeToggle) {
21
- const savedTheme = localStorage.getItem('holey-theme');
22
- const initialTheme = savedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
23
- applyTheme(initialTheme);
24
- themeToggle.addEventListener('click', () => {
25
- const nextTheme = root.dataset.theme === 'dark' ? 'light' : 'dark';
26
- applyTheme(nextTheme);
27
- localStorage.setItem('holey-theme', nextTheme);
28
- });
29
- }
30
-
31
- const installButton = isHoleyApp ? document.querySelector('.install-command') : null;
32
-
33
- if (installButton) {
34
- installButton.addEventListener('click', async () => {
35
- const label = installButton.querySelector('.install-copy-label');
36
- try {
37
- await navigator.clipboard.writeText('npm install holey-shapes');
38
- if (label) label.textContent = 'COPIED';
39
- window.setTimeout(() => {
40
- if (label) label.textContent = 'COPY';
41
- }, 1400);
42
- } catch {
43
- if (label) label.textContent = 'SELECT';
44
- window.getSelection()?.selectAllChildren(installButton.querySelector('code'));
45
- }
46
- });
47
- }
48
-
49
8
  export const shapes = [
50
9
  {
51
10
  key: 'disc', name: 'Perforated disc', color: '#6337FF', bg: '#D7CEFF', initial: 5,
@@ -119,12 +78,10 @@ export const shapes = [
119
78
  }
120
79
  ];
121
80
 
122
- const collection = isHoleyApp ? document.querySelector('#collection') : null;
123
81
  const MAX_HOLES = 8;
124
82
  const SHADOW_X = 18;
125
83
  const SHADOW_Y = 21;
126
84
  const SHADOW_STEPS = 12;
127
- const states = new Map();
128
85
  const packingCache = new Map();
129
86
 
130
87
  const holeMarkup = ({ cx, cy, rx, ry, rotate }) =>
@@ -138,11 +95,6 @@ function hoverHoleMarkup(hole, index, seed = 0, duration = 900) {
138
95
  return `<g class="hole-hover" style="--hole-duration:${pulseDuration}ms;--hole-delay:${delay}ms">${holeMarkup(hole)}</g>`;
139
96
  }
140
97
 
141
- function darkShadow(color, factor = 0.52) {
142
- const value = color.replace('#', '');
143
- const channels = [0, 2, 4].map((offset) => parseInt(value.slice(offset, offset + 2), 16));
144
- return `#${channels.map((channel) => Math.round(channel * factor).toString(16).padStart(2, '0')).join('')}`;
145
- }
146
98
 
147
99
  function circleFits(path, cx, cy, radius) {
148
100
  for (let step = 0; step < 32; step += 1) {
@@ -263,6 +215,10 @@ function maximizeHoleAreas(shape, holes) {
263
215
  }
264
216
  return low;
265
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
+ }
266
222
  const boundaries = points.map((p, i) => boundaryLimit(p, profiles[i]));
267
223
  function capacity(point, index, boundary) {
268
224
  let limit = boundary;
@@ -286,8 +242,12 @@ function maximizeHoleAreas(shape, holes) {
286
242
  let bestLimit = capacity(point, i, bestBoundary);
287
243
  for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) {
288
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;
289
249
  const boundary = boundaryLimit(candidate, profiles[i]);
290
- const limit = capacity(candidate, i, boundary);
250
+ const limit = Math.min(separation, boundary);
291
251
  if (limit > bestLimit + 0.02) {
292
252
  best = candidate; bestBoundary = boundary; bestLimit = limit;
293
253
  }
@@ -380,12 +340,6 @@ function initialHoleLayout(shape, count, seed) {
380
340
  }));
381
341
  }
382
342
 
383
- function svgMarkup(shape, holes, className, maskSuffix, accessible = false) {
384
- const maskId = `mask-${shape.key}-${maskSuffix}`;
385
- const fill = className === 'shape-shadow' ? darkShadow(shape.color) : shape.color;
386
- 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>`;
387
- }
388
-
389
343
  function compactNumber(value) {
390
344
  const rounded = Math.round(value * 100) / 100;
391
345
  return String(Object.is(rounded, -0) ? 0 : rounded);
@@ -395,7 +349,7 @@ function extrusionFilterMarkup(shape, filterId, options = {}) {
395
349
  const shadowX = options.shadowX ?? SHADOW_X;
396
350
  const shadowY = options.shadowY ?? SHADOW_Y;
397
351
  const shadowSteps = options.shadowSteps ?? SHADOW_STEPS;
398
- const shadowColor = options.shadowColor ?? darkShadow(shape.color);
352
+ const shadowColor = options.shadowColor ?? extrusionColor(shape.color);
399
353
  const offsets = Array.from({ length: shadowSteps }, (_, index) => {
400
354
  const step = index + 1;
401
355
  const ratio = step / shadowSteps;
@@ -407,23 +361,14 @@ function extrusionFilterMarkup(shape, filterId, options = {}) {
407
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>`;
408
362
  }
409
363
 
410
- function objectMarkup(shape, holes, suffix, accessible = false, options = {}) {
411
- const maskId = `mask-${shape.key}-${suffix}`;
412
- const filterId = `extrusion-${shape.key}-${suffix}`;
413
- const seed = options.seed ?? 0;
414
- const duration = options.duration ?? 900;
415
- const maskHoles = holes.map((hole, index) => hoverHoleMarkup(hole, index, seed, duration)).join('');
416
- 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>`;
417
- }
418
-
419
- function renderShape(card, shape, state) {
420
- const holes = packedHoleLayout(shape, state.count, state.seed);
421
- card.querySelector('.shape-art').innerHTML = objectMarkup(shape, holes, 'card', true, { seed: state.seed });
422
- card.querySelector('.hole-count').textContent = `${String(state.count).padStart(2, '0')} / ${String(state.limit).padStart(2, '0')}`;
423
- 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(' ');
424
368
  }
425
369
 
426
370
  function exportSvg(shape, holes, options = {}) {
371
+ if (options.doodle) return createDoodleSvg(shape, holes, options);
427
372
  const filter = extrusionFilterMarkup(shape, 'e', options);
428
373
  const duration = Math.max(100, Math.min(10000, Number(options.duration) || 900));
429
374
  const seed = Math.trunc(Number(options.seed) || 0);
@@ -433,7 +378,7 @@ function exportSvg(shape, holes, options = {}) {
433
378
  const animation = options.animated === false
434
379
  ? ''
435
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>`;
436
- 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>`;
437
382
  }
438
383
 
439
384
  function normalizeColor(color, fallback) {
@@ -441,29 +386,58 @@ function normalizeColor(color, fallback) {
441
386
  }
442
387
 
443
388
  function numberInRange(value, fallback, min, max) {
389
+ if (value === null || value === undefined || value === '') return fallback;
444
390
  const number = Number(value);
445
391
  return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
446
392
  }
447
393
 
448
- 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 })));
449
395
 
450
- export function seededHoleyOptions(identity, animated = true) {
451
- 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) {
452
406
  let hash = 14695981039346656037n;
453
- for (const byte of bytes) {
407
+ for (const byte of new TextEncoder().encode(String(identity))) {
454
408
  hash = BigInt.asUintN(64, (hash ^ BigInt(byte)) * 1099511628211n);
455
409
  }
456
- let state = hash;
457
- const next = () => {
458
- state = BigInt.asUintN(64, state + 0x9E3779B97F4A7C15n);
459
- let value = state;
460
- value = BigInt.asUintN(64, (value ^ (value >> 30n)) * 0xBF58476D1CE4E5B9n);
461
- value = BigInt.asUintN(64, (value ^ (value >> 27n)) * 0x94D049BB133111EBn);
462
- 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;
463
431
  };
464
- const shape = shapes[Number(next() % BigInt(shapes.length))];
465
- const seed = Number(next() % 1000001n);
466
- 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 };
467
441
  }
468
442
 
469
443
  export function createHoleySvg(options = {}) {
@@ -475,19 +449,25 @@ export function createHoleySvg(options = {}) {
475
449
  const shadowX = numberInRange(options.shadowX, SHADOW_X, -80, 80);
476
450
  const shadowY = numberInRange(options.shadowY, SHADOW_Y, -80, 80);
477
451
  const shadowSteps = Math.round(numberInRange(options.shadowSteps, SHADOW_STEPS, 1, 32));
478
- 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;
479
454
  const holes = packedHoleLayout(shape, holeCount, seed);
480
- return exportSvg(shape, holes, {
455
+ return scopeSvg(exportSvg(shape, holes, {
456
+ doodle: options.doodle === true,
457
+ transform: orientation(turn, options.mirror === true),
481
458
  animated: options.animated,
482
459
  duration: options.duration,
483
460
  seed,
484
461
  shadowColor,
462
+ // Doodles keep their ink shadow unless a shadow color is asked for.
463
+ doodleShadow: options.shadowColor === undefined ? undefined : shadowColor,
485
464
  shadowX,
486
465
  shadowY,
487
466
  shadowSteps
488
- });
467
+ }), options.idPrefix);
489
468
  }
490
469
 
470
+ let mountSequence = 0;
491
471
  export function mountHoleyShape(target, options = {}) {
492
472
  const element = typeof target === 'string' && isBrowser ? document.querySelector(target) : target;
493
473
  if (!element || typeof element.replaceChildren !== 'function') {
@@ -495,10 +475,18 @@ export function mountHoleyShape(target, options = {}) {
495
475
  }
496
476
 
497
477
  let currentOptions = { ...options };
478
+ const idPrefix = `mount-${++mountSequence}`;
479
+ const ownerDocument = element.ownerDocument;
480
+ let mounted;
481
+ let destroyed = false;
498
482
  const render = () => {
499
- const template = document.createElement('template');
500
- template.innerHTML = createHoleySvg(currentOptions).trim();
501
- 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;
502
490
  };
503
491
  render();
504
492
 
@@ -509,149 +497,13 @@ export function mountHoleyShape(target, options = {}) {
509
497
  render();
510
498
  },
511
499
  shuffle() {
512
- 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;
513
502
  render();
514
503
  },
515
504
  destroy() {
516
- element.replaceChildren();
505
+ destroyed = true;
506
+ mounted?.remove();
517
507
  }
518
508
  };
519
509
  }
520
-
521
- function downloadSvg(shape, holes) {
522
- const source = exportSvg(shape, holes);
523
- const url = URL.createObjectURL(new Blob([source], { type: 'image/svg+xml' }));
524
- const link = document.createElement('a');
525
- link.href = url;
526
- link.download = `${shape.key}-${holes.length}-holes.svg`;
527
- link.click();
528
- URL.revokeObjectURL(url);
529
- }
530
-
531
- if (collection) shapes.forEach((shape, index) => {
532
- const number = String(index + 1).padStart(2, '0');
533
- const holeLimit = Math.min(MAX_HOLES, shape.holes.length);
534
- const wide = index === 0 || index === shapes.length - 1;
535
- const card = document.createElement('article');
536
- card.className = `form-card${wide ? ' form-card--wide' : ''}`;
537
- card.dataset.index = number;
538
- card.innerHTML = `
539
- <div class="card-meta"><span>${number}</span><h2>${shape.name}</h2><span>MAX ${String(holeLimit).padStart(2, '0')}</span></div>
540
- <div class="stage" style="background:${shape.bg}">
541
- <div class="shape-art${wide ? ' shape-art--large' : ''}"></div>
542
- ${index === 0 ? '<span class="orbit-line" aria-hidden="true"></span>' : ''}
543
- ${index === shapes.length - 1 ? '<span class="crosshair" aria-hidden="true"></span>' : ''}
544
- </div>
545
- <div class="card-actions">
546
- <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>
547
- <button class="shuffle" type="button">SHUFFLE ↻</button>
548
- <button class="download" type="button">GET SVG <span>↓</span></button>
549
- </div>`;
550
- collection.append(card);
551
- const state = { count: Math.min(shape.initial, holeLimit), seed: 0, limit: holeLimit };
552
- states.set(shape.key, state);
553
- renderShape(card, shape, state);
554
-
555
- card.querySelector('.hole-range').addEventListener('input', (event) => {
556
- state.count = Number(event.target.value);
557
- renderShape(card, shape, state);
558
- });
559
- card.querySelector('.shuffle').addEventListener('click', () => {
560
- state.seed += 1;
561
- renderShape(card, shape, state);
562
- });
563
- card.querySelector('.download').addEventListener('click', () => {
564
- downloadSvg(shape, packedHoleLayout(shape, state.count, state.seed));
565
- });
566
- });
567
-
568
- const presentApp = isHoleyApp ? document.querySelector('#present-app') : null;
569
-
570
- if (presentApp) {
571
- const art = document.querySelector('#present-art');
572
- const shapePicker = document.querySelector('#shape-picker');
573
- const colorPicker = document.querySelector('#color-picker');
574
- const range = document.querySelector('#present-holes');
575
- const count = document.querySelector('#present-count');
576
- const selectedName = document.querySelector('#selected-name');
577
- const shuffle = document.querySelector('#present-shuffle');
578
- const download = document.querySelector('#present-download');
579
- const canvas = document.querySelector('.present-canvas');
580
- const palette = ['#6337FF', '#9BED00', '#FF0878', '#2878FF', '#FF6A00', '#00DBFF', '#FFD000', '#C94DFF', '#00D7B9'];
581
- const presentState = { index: 0, count: shapes[0].initial, seed: 0, color: shapes[0].color };
582
-
583
- const activeShape = () => ({ ...shapes[presentState.index], color: presentState.color });
584
-
585
- function syncPresentControls() {
586
- const shape = shapes[presentState.index];
587
- const limit = Math.min(MAX_HOLES, shape.holes.length);
588
- selectedName.textContent = shape.name;
589
- range.max = limit;
590
- range.value = presentState.count;
591
- count.textContent = `${String(presentState.count).padStart(2, '0')} / ${String(limit).padStart(2, '0')}`;
592
- shapePicker.querySelectorAll('.shape-option').forEach((button, index) => {
593
- button.classList.toggle('is-active', index === presentState.index);
594
- button.setAttribute('aria-pressed', String(index === presentState.index));
595
- });
596
- colorPicker.querySelectorAll('.color-option').forEach((button) => {
597
- const active = button.dataset.color === presentState.color;
598
- button.classList.toggle('is-active', active);
599
- button.setAttribute('aria-pressed', String(active));
600
- });
601
- }
602
-
603
- function renderPresent() {
604
- const shape = activeShape();
605
- const holes = packedHoleLayout(shape, presentState.count, presentState.seed);
606
- art.innerHTML = objectMarkup(shape, holes, 'present', true, { seed: presentState.seed });
607
- syncPresentControls();
608
- }
609
-
610
- shapes.forEach((shape, index) => {
611
- const button = document.createElement('button');
612
- const previewHoles = packedHoleLayout(shape, Math.min(3, shape.holes.length), 0);
613
- button.className = 'shape-option';
614
- button.type = 'button';
615
- button.setAttribute('aria-label', shape.name);
616
- button.innerHTML = `<span class="shape-thumb">${svgMarkup(shape, previewHoles, 'picker-shape', `picker-${index}`)}</span><span>${String(index + 1).padStart(2, '0')}</span>`;
617
- button.addEventListener('click', () => {
618
- presentState.index = index;
619
- presentState.count = Math.min(shape.initial, MAX_HOLES, shape.holes.length);
620
- presentState.seed = 0;
621
- presentState.color = shape.color;
622
- renderPresent();
623
- });
624
- shapePicker.append(button);
625
- });
626
-
627
- palette.forEach((color) => {
628
- const button = document.createElement('button');
629
- button.className = 'color-option';
630
- button.type = 'button';
631
- button.dataset.color = color;
632
- button.style.setProperty('--swatch', color);
633
- button.setAttribute('aria-label', `Use color ${color}`);
634
- button.addEventListener('click', () => {
635
- presentState.color = color;
636
- renderPresent();
637
- });
638
- colorPicker.append(button);
639
- });
640
-
641
- range.addEventListener('input', () => {
642
- presentState.count = Number(range.value);
643
- renderPresent();
644
- });
645
-
646
- shuffle.addEventListener('click', () => {
647
- presentState.seed += 1;
648
- renderPresent();
649
- });
650
-
651
- download.addEventListener('click', () => {
652
- const shape = activeShape();
653
- downloadSvg(shape, packedHoleLayout(shape, presentState.count, presentState.seed));
654
- });
655
-
656
- renderPresent();
657
- }