holey-shapes 0.1.2 → 2.0.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,6 +1,6 @@
1
1
  # holey-shapes
2
2
 
3
- Solid, perforated shapes for the web and SwiftUI, with a connected extrusion and a built-in hover spin. Pick from 15 shapes, set the face and shadow colors, and control the hole layout.
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.
4
4
 
5
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
6
 
@@ -18,7 +18,7 @@ In Xcode, add `https://github.com/skyaara/holey-shapes` as a package dependency,
18
18
 
19
19
  ```swift
20
20
  dependencies: [
21
- .package(url: "https://github.com/skyaara/holey-shapes.git", from: "0.1.2")
21
+ .package(url: "https://github.com/skyaara/holey-shapes.git", from: "2.0.0")
22
22
  ]
23
23
  ```
24
24
 
@@ -31,7 +31,7 @@ import SwiftUI
31
31
  struct Logo: View {
32
32
  var body: some View {
33
33
  HoleyShapeView(
34
- .flower,
34
+ .prism,
35
35
  faceColor: HoleyColor(hex: "#FF4FA3"),
36
36
  shadowColor: HoleyColor(hex: "#7A1748"),
37
37
  holes: 6,
@@ -42,7 +42,75 @@ struct Logo: View {
42
42
  }
43
43
  ```
44
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. The pointer hover animation follows Reduce Motion automatically.
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
+ ```
46
114
 
47
115
  ## Render an SVG string
48
116
 
@@ -50,7 +118,7 @@ The Swift SDK supports iOS 16+, macOS 13+, tvOS 16+, and visionOS 1+. It uses as
50
118
  import { createHoleySvg } from 'holey-shapes';
51
119
 
52
120
  const svg = createHoleySvg({
53
- shape: 'flower',
121
+ shape: 'prism',
54
122
  color: '#FF4FA3',
55
123
  shadowColor: '#7A1748',
56
124
  holes: 6,
@@ -102,13 +170,13 @@ The generated SVG scales to its container:
102
170
  | `shadowColor` | Darker face color | Six-digit hex extrusion color |
103
171
  | `holes` | Shape default | Hole count from 0 to 8 |
104
172
  | `seed` | `0` | Changes the hole layout |
105
- | `animated` | `true` | Includes the hover spin |
106
- | `duration` | `900` | Spin duration in milliseconds |
173
+ | `animated` | `true` | Includes the hover hole animation |
174
+ | `duration` | `900` | Total hole-animation duration in milliseconds |
107
175
  | `shadowX` | `18` | Horizontal extrusion offset |
108
176
  | `shadowY` | `21` | Vertical extrusion offset |
109
177
  | `shadowSteps` | `12` | Number of solid extrusion slices |
110
178
 
111
- Available shape keys: `disc`, `round-block`, `hex`, `capsule`, `prism`, `cross`, `triangle`, `diamond`, `sunburst`, `octagon`, `chevron`, `long-bar`, `flower-star`, `flower`, and `bowtie`.
179
+ Available shape keys: `disc`, `round-block`, `hex`, `capsule`, `prism`, `cross`, `triangle`, `diamond`, `sunburst`, `octagon`, `chevron`, `long-bar`, `flower-star`, and `bowtie`.
112
180
 
113
181
  You can also build a picker from the exported metadata:
114
182
 
@@ -122,6 +190,6 @@ for (const { key, name, color } of shapeNames) {
122
190
 
123
191
  ## Browser support
124
192
 
125
- 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.
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.
126
194
 
127
195
  The hover animation respects `prefers-reduced-motion`.
package/geometry.js ADDED
@@ -0,0 +1,95 @@
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
+ let i = 0, command, x = 0, y = 0, startX = 0, startY = 0;
8
+ const number = () => {
9
+ const value = Number(tokens[i++]);
10
+ if (!Number.isFinite(value)) throw new Error('Invalid shape path');
11
+ return value;
12
+ };
13
+ const line = (nx, ny) => {
14
+ if (y !== ny) edges.push({ x, y, nx, ny });
15
+ x = nx; y = ny;
16
+ };
17
+ function quadratic(x0, y0, cx, cy, x1, y1) {
18
+ // The curve's deviation from its chord is at most half this distance.
19
+ if (Math.hypot(cx - (x0 + x1) / 2, cy - (y0 + y1) / 2) <= 0.02) {
20
+ line(x1, y1);
21
+ return;
22
+ }
23
+ const ax = (x0 + cx) / 2, ay = (y0 + cy) / 2;
24
+ const bx = (cx + x1) / 2, by = (cy + y1) / 2;
25
+ const mx = (ax + bx) / 2, my = (ay + by) / 2;
26
+ quadratic(x0, y0, ax, ay, mx, my);
27
+ quadratic(mx, my, bx, by, x1, y1);
28
+ }
29
+ while (i < tokens.length) {
30
+ if (/^[a-zA-Z]$/.test(tokens[i])) command = tokens[i++];
31
+ switch (command) {
32
+ case 'M':
33
+ x = number(); y = number(); startX = x; startY = y; command = 'L';
34
+ break;
35
+ case 'L': line(number(), number()); break;
36
+ case 'H': line(number(), y); break;
37
+ case 'V': line(x, number()); break;
38
+ case 'Q': {
39
+ const cx = number(), cy = number(), nx = number(), ny = number();
40
+ quadratic(x, y, cx, cy, nx, ny);
41
+ break;
42
+ }
43
+ case 'A': {
44
+ let rx = Math.abs(number()), ry = Math.abs(number());
45
+ const rotation = number() * Math.PI / 180;
46
+ const large = number(), sweep = number(), nx = number(), ny = number();
47
+ if (!rx || !ry) { line(nx, ny); break; }
48
+ if (x === nx && y === ny) break;
49
+ const c = Math.cos(rotation), s = Math.sin(rotation);
50
+ const dx = (x - nx) / 2, dy = (y - ny) / 2;
51
+ const px = c * dx + s * dy, py = -s * dx + c * dy;
52
+ const scale = Math.hypot(px / rx, py / ry);
53
+ if (scale > 1) { rx *= scale; ry *= scale; }
54
+ const factor = (large === sweep ? -1 : 1) * Math.sqrt(Math.max(0,
55
+ (rx * rx * ry * ry - rx * rx * py * py - ry * ry * px * px) /
56
+ (rx * rx * py * py + ry * ry * px * px)));
57
+ const cx = factor * rx * py / ry, cy = -factor * ry * px / rx;
58
+ const centerX = c * cx - s * cy + (x + nx) / 2;
59
+ const centerY = s * cx + c * cy + (y + ny) / 2;
60
+ const angle = Math.atan2((py - cy) / ry, (px - cx) / rx);
61
+ let delta = Math.atan2((-py - cy) / ry, (-px - cx) / rx) - angle;
62
+ if (sweep && delta < 0) delta += Math.PI * 2;
63
+ if (!sweep && delta > 0) delta -= Math.PI * 2;
64
+ const steps = Math.ceil(Math.abs(delta) / (2 * Math.acos(1 - Math.min(1, 0.01 / Math.max(rx, ry)))));
65
+ for (let step = 1; step < steps; step++) {
66
+ const a = angle + delta * step / steps;
67
+ line(centerX + c * rx * Math.cos(a) - s * ry * Math.sin(a),
68
+ centerY + s * rx * Math.cos(a) + c * ry * Math.sin(a));
69
+ }
70
+ line(nx, ny);
71
+ break;
72
+ }
73
+ case 'Z': line(startX, startY); command = null; break;
74
+ default: throw new Error(`Unsupported shape path command: ${command}`);
75
+ }
76
+ }
77
+ // Index edges by vertical band to keep the optimizer's many point queries cheap.
78
+ const bands = new Map();
79
+ 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
+ }
84
+ }
85
+ return {
86
+ contains(px, py) {
87
+ 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;
91
+ }
92
+ return winding !== 0;
93
+ }
94
+ };
95
+ }
package/index.d.ts CHANGED
@@ -12,7 +12,6 @@ export type HoleyShapeKey =
12
12
  | 'chevron'
13
13
  | 'long-bar'
14
14
  | 'flower-star'
15
- | 'flower'
16
15
  | 'bowtie';
17
16
 
18
17
  export interface HoleyOptions {
@@ -43,5 +42,6 @@ export interface HoleyMount {
43
42
 
44
43
  export const shapeNames: readonly HoleyShapeInfo[];
45
44
  export const shapes: readonly Record<string, unknown>[];
45
+ export function seededHoleyOptions(identity: string, animated?: boolean): HoleyOptions;
46
46
  export function createHoleySvg(options?: HoleyOptions): string;
47
47
  export function mountHoleyShape(target: string | Element, options?: HoleyOptions): HoleyMount;
package/index.js CHANGED
@@ -1 +1 @@
1
- export { createHoleySvg, mountHoleyShape, shapeNames, shapes } from './script.js';
1
+ export { createHoleySvg, mountHoleyShape, seededHoleyOptions, shapeNames, shapes } from './script.js';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "holey-shapes",
3
- "version": "0.1.2",
4
- "description": "Colorful perforated SVG shapes with solid extrusion and hover animation.",
3
+ "version": "2.0.0",
4
+ "description": "Colorful perforated SVG shapes with solid extrusion and animated holes.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": {
@@ -31,6 +31,7 @@
31
31
  "files": [
32
32
  "index.js",
33
33
  "script.js",
34
+ "geometry.js",
34
35
  "index.d.ts",
35
36
  "README.md",
36
37
  "LICENSE"
@@ -57,7 +58,7 @@
57
58
  "dev": "vite --host 127.0.0.1",
58
59
  "build": "vite build",
59
60
  "preview": "vite preview --host 127.0.0.1",
60
- "test": "node tests/package.test.mjs && node tests/site.test.mjs",
61
+ "test": "node tests/package.test.mjs && node tests/packing.test.mjs && node tests/site.test.mjs",
61
62
  "prepublishOnly": "npm test && npm run build"
62
63
  },
63
64
  "devDependencies": {
package/script.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { compileShapePath } from './geometry.js';
2
+
1
3
  const h = (cx, cy, rx, ry, rotate = 0) => ({ cx, cy, rx, ry, rotate });
2
4
 
3
5
  const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
@@ -110,11 +112,6 @@ export const shapes = [
110
112
  path: 'M274.7 121Q467.8 126.3 314.6 244Q369.3 429.2 210 320Q50.7 429.2 105.4 244Q-47.8 126.3 145.3 121Q210 -61 274.7 121Z',
111
113
  holes: [h(210,104,18,24),h(304,174,23,17,20),h(269,282,18,24,-22),h(151,282,22,17,18),h(116,174,17,23,-18),h(210,210,23,18),h(254,211,16,21,12),h(168,210,16,21,-12)]
112
114
  },
113
- {
114
- key: 'flower', name: 'Daisy flower', color: '#C94DFF', bg: '#F2D8FF', initial: 6,
115
- path: 'M315 210A105 105 0 1 1 105 210A105 105 0 1 1 315 210ZM210 20A60 90 0 1 1 210 200A60 90 0 1 1 210 20ZM374.5 115A60 90 60 1 1 218.7 205A60 90 60 1 1 374.5 115ZM374.5 305A60 90 120 1 1 218.7 215A60 90 120 1 1 374.5 305ZM210 400A60 90 0 1 1 210 220A60 90 0 1 1 210 400ZM45.5 305A60 90 60 1 1 201.3 215A60 90 60 1 1 45.5 305ZM45.5 115A60 90 120 1 1 201.3 205A60 90 120 1 1 45.5 115Z',
116
- holes: [h(210,104,18,24),h(304,174,23,17,20),h(269,282,18,24,-22),h(151,282,22,17,18),h(116,174,17,23,-18),h(210,210,23,18),h(254,211,16,21,12),h(168,210,16,21,-12)]
117
- },
118
115
  {
119
116
  key: 'bowtie', name: 'Bowtie slab', color: '#00D7B9', bg: '#D0F7F0', initial: 6,
120
117
  path: 'M40 70H380L286 210 380 350H40L134 210Z',
@@ -128,77 +125,18 @@ const SHADOW_X = 18;
128
125
  const SHADOW_Y = 21;
129
126
  const SHADOW_STEPS = 12;
130
127
  const states = new Map();
131
- let packingContext = null;
132
128
  const packingCache = new Map();
133
- const SHOWCASE_MOTIONS = [
134
- ['scatter', '4.2s', '-1.4s'],
135
- ['magnet', '4.6s', '-3.2s'],
136
- ['blinkwave', '3.7s', '-2.1s'],
137
- ['orbit', '5.2s', '-.6s'],
138
- ['hop', '3.5s', '-1.9s'],
139
- ['scan', '4.3s', '-.8s'],
140
- ['wobble', '4.8s', '-2.4s'],
141
- ['pinch', '3.9s', '-1.1s'],
142
- ['carousel', '5.6s', '-4.1s'],
143
- ['heartbeat', '3.8s', '-2.2s'],
144
- ['peek', '4.5s', '-3.5s'],
145
- ['shuffle', '4.1s', '-1.7s'],
146
- ['tumble', '4.7s', '-3.3s'],
147
- ['ripple', '3.6s', '-1.2s'],
148
- ['breathe', '5s', '-2.5s']
149
- ];
150
- const showcaseObserver = isBrowser && typeof IntersectionObserver === 'function'
151
- ? new IntersectionObserver((entries) => {
152
- entries.forEach((entry) => entry.target.classList.toggle('is-showcase-visible', entry.isIntersecting));
153
- }, { rootMargin: '120px 0px', threshold: 0.05 })
154
- : null;
155
-
156
- function applyShowcaseMotion(element, index) {
157
- const [name, duration, delay] = SHOWCASE_MOTIONS[index % SHOWCASE_MOTIONS.length];
158
- element.dataset.motion = name;
159
- element.style.setProperty('--motion-duration', duration);
160
- element.style.setProperty('--motion-delay', delay);
161
- if (showcaseObserver) showcaseObserver.observe(element);
162
- else element.classList.add('is-showcase-visible');
163
- }
164
-
165
- function getPackingContext() {
166
- if (packingContext) return packingContext;
167
- if (!isBrowser || typeof Path2D === 'undefined') return null;
168
- const packingCanvas = document.createElement('canvas');
169
- packingCanvas.width = 420;
170
- packingCanvas.height = 420;
171
- packingContext = packingCanvas.getContext('2d');
172
- return packingContext;
173
- }
174
129
 
175
130
  const holeMarkup = ({ cx, cy, rx, ry, rotate }) =>
176
131
  `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}"${rotate ? ` transform="rotate(${rotate} ${cx} ${cy})"` : ''}/>`;
177
132
 
178
- const showcaseHoleMarkup = (hole, index) => {
179
- const angle = (index * 137.5 * Math.PI) / 180;
180
- const scatterX = Math.round(Math.cos(angle) * 17);
181
- const scatterY = Math.round(Math.sin(angle) * 17);
182
- const inwardX = Math.round((210 - hole.cx) * .26);
183
- const inwardY = Math.round((210 - hole.cy) * .26);
184
- const outwardX = Math.round((hole.cx - 210) * .11);
185
- const outwardY = Math.round((hole.cy - 210) * .11);
186
- const turn = (index % 2 ? 1 : -1) * (14 + (index % 3) * 7);
187
- const variables = [
188
- `--hole-origin:${hole.cx}px ${hole.cy}px`,
189
- `--scatter-x:${scatterX}px`,
190
- `--scatter-y:${scatterY}px`,
191
- `--scatter-back-x:${Math.round(scatterX * -.3)}px`,
192
- `--scatter-back-y:${Math.round(scatterY * -.3)}px`,
193
- `--in-x:${inwardX}px`,
194
- `--in-y:${inwardY}px`,
195
- `--out-x:${outwardX}px`,
196
- `--out-y:${outwardY}px`,
197
- `--turn:${turn}deg`,
198
- `--turn-back:${turn * -1}deg`
199
- ].join(';');
200
- return `<g class="hole-motion" style="${variables}">${holeMarkup(hole)}</g>`;
201
- };
133
+ function hoverHoleMarkup(hole, index, seed = 0, duration = 900) {
134
+ const durationNoise = seededNoise(index + 1, 17, seed);
135
+ const delayNoise = seededNoise(index + 1, 43, seed);
136
+ const pulseDuration = Math.round(duration * (0.42 + durationNoise * 0.16));
137
+ const delay = Math.round(duration * (0.04 + delayNoise * 0.34));
138
+ return `<g class="hole-hover" style="--hole-duration:${pulseDuration}ms;--hole-delay:${delay}ms">${holeMarkup(hole)}</g>`;
139
+ }
202
140
 
203
141
  function darkShadow(color, factor = 0.52) {
204
142
  const value = color.replace('#', '');
@@ -207,20 +145,17 @@ function darkShadow(color, factor = 0.52) {
207
145
  }
208
146
 
209
147
  function circleFits(path, cx, cy, radius) {
210
- const context = getPackingContext();
211
- if (!context) return false;
212
148
  for (let step = 0; step < 32; step += 1) {
213
149
  const angle = (Math.PI * 2 * step) / 32;
214
150
  const x = cx + Math.cos(angle) * radius;
215
151
  const y = cy + Math.sin(angle) * radius;
216
- if (!context.isPointInPath(path, x, y)) return false;
152
+ if (!path.contains(x, y)) return false;
217
153
  }
218
154
  return true;
219
155
  }
220
156
 
221
157
  function boundaryClearance(path, cx, cy) {
222
- const context = getPackingContext();
223
- if (!context || !context.isPointInPath(path, cx, cy)) return 0;
158
+ if (!path.contains(cx, cy)) return 0;
224
159
  let low = 0;
225
160
  let high = 180;
226
161
  for (let pass = 0; pass < 9; pass += 1) {
@@ -233,13 +168,11 @@ function boundaryClearance(path, cx, cy) {
233
168
 
234
169
  function packingCandidates(shape) {
235
170
  if (packingCache.has(shape.key)) return packingCache.get(shape.key);
236
- const context = getPackingContext();
237
- if (!context) return { path: null, candidates: [] };
238
- const path = new Path2D(shape.path);
171
+ const path = compileShapePath(shape.path);
239
172
  const candidates = [];
240
173
  for (let y = 50; y <= 370; y += 12) {
241
174
  for (let x = 50; x <= 370; x += 12) {
242
- if (!context.isPointInPath(path, x, y)) continue;
175
+ if (!path.contains(x, y)) continue;
243
176
  const clearance = boundaryClearance(path, x, y);
244
177
  if (clearance >= 17) candidates.push({ x, y, clearance });
245
178
  }
@@ -271,13 +204,118 @@ function assignVoronoiCells(points, candidates) {
271
204
  return cells;
272
205
  }
273
206
 
207
+ // Shrink to the smallest fitted area so spacing and oval proportions stay intact.
208
+ function equalizeHoleAreas(holes) {
209
+ if (!holes.length) return holes;
210
+ const targetArea = Math.min(...holes.map(({ rx, ry }) => rx * ry));
211
+ return holes.map((hole) => {
212
+ const scale = Math.sqrt(targetArea / (hole.rx * hole.ry));
213
+ return {
214
+ ...hole,
215
+ rx: Number((hole.rx * scale).toFixed(3)),
216
+ ry: Number((hole.ry * scale).toFixed(3))
217
+ };
218
+ });
219
+ }
220
+
221
+ // Equal-area ellipses use radius sqrt(rx * ry). Support projections give a
222
+ // separating line with a 12-unit gap, including for differently rotated ovals.
223
+ const packingDirections = Array.from({ length: 48 }, (_, i) => {
224
+ const angle = i * Math.PI * 2 / 48;
225
+ return { x: Math.cos(angle), y: Math.sin(angle) };
226
+ });
227
+ const perimeterDirections = Array.from({ length: 192 }, (_, i) => {
228
+ const angle = i * Math.PI * 2 / 192;
229
+ return { x: Math.cos(angle), y: Math.sin(angle) };
230
+ });
231
+ const optimizedLayoutCache = new Map();
232
+
233
+ function maximizeHoleAreas(shape, holes) {
234
+ if (!holes.length) return holes;
235
+ const { path } = packingCandidates(shape);
236
+ const gap = 12;
237
+ const profiles = holes.map(hole => {
238
+ const a = Math.sqrt(hole.rx / hole.ry);
239
+ const b = 1 / a;
240
+ const angle = hole.rotate * Math.PI / 180;
241
+ const c = Math.cos(angle), s = Math.sin(angle);
242
+ return {
243
+ a, b,
244
+ support: packingDirections.map(n => Math.hypot(a * (n.x * c + n.y * s), b * (-n.x * s + n.y * c))),
245
+ edge: perimeterDirections.map(n => {
246
+ const nx = n.x / a, ny = n.y / b;
247
+ const length = Math.hypot(nx, ny);
248
+ return { x: a * n.x * c - b * n.y * s, y: a * n.x * s + b * n.y * c,
249
+ nx: (nx * c - ny * s) / length, ny: (nx * s + ny * c) / length };
250
+ })
251
+ };
252
+ });
253
+ const points = holes.map(h => ({ x: h.cx, y: h.cy }));
254
+ function boundaryLimit(point, profile) {
255
+ if (!path.contains(point.x, point.y)) return 0;
256
+ let low = 0, high = 180;
257
+ for (let pass = 0; pass < 11; pass++) {
258
+ const radius = (low + high) / 2;
259
+ // An extra rim unit covers the space between perimeter samples.
260
+ const fits = profile.edge.every(e => path.contains(
261
+ point.x + radius * e.x + (gap + 1) * e.nx, point.y + radius * e.y + (gap + 1) * e.ny));
262
+ if (fits) low = radius; else high = radius;
263
+ }
264
+ return low;
265
+ }
266
+ const boundaries = points.map((p, i) => boundaryLimit(p, profiles[i]));
267
+ function capacity(point, index, boundary) {
268
+ let limit = boundary;
269
+ points.forEach((other, j) => {
270
+ if (j === index) return;
271
+ let separation = 0;
272
+ packingDirections.forEach((n, k) => {
273
+ separation = Math.max(separation, (Math.abs((point.x - other.x) * n.x + (point.y - other.y) * n.y) - gap)
274
+ / (profiles[index].support[k] + profiles[j].support[k]));
275
+ });
276
+ limit = Math.min(limit, separation);
277
+ });
278
+ return limit;
279
+ }
280
+ // Coordinate search improves each constrained hole, then refines its position.
281
+ for (const step of [12, 6, 3, 1.5]) {
282
+ for (let pass = 0; pass < 3; pass++) {
283
+ let moved = false;
284
+ points.forEach((point, i) => {
285
+ let best = point, bestBoundary = boundaries[i];
286
+ let bestLimit = capacity(point, i, bestBoundary);
287
+ for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) {
288
+ const candidate = { x: point.x + dx * step, y: point.y + dy * step };
289
+ const boundary = boundaryLimit(candidate, profiles[i]);
290
+ const limit = capacity(candidate, i, boundary);
291
+ if (limit > bestLimit + 0.02) {
292
+ best = candidate; bestBoundary = boundary; bestLimit = limit;
293
+ }
294
+ }
295
+ if (best !== point) moved = true;
296
+ points[i] = best; boundaries[i] = bestBoundary;
297
+ });
298
+ if (!moved) break;
299
+ }
300
+ }
301
+ const radius = Math.max(0, Math.min(...points.map((p, i) => capacity(p, i, boundaries[i]))) - 0.05);
302
+ return holes.map((hole, i) => ({ ...hole, cx: points[i].x, cy: points[i].y,
303
+ rx: Number((radius * profiles[i].a).toFixed(3)), ry: Number((radius * profiles[i].b).toFixed(3)) }));
304
+ }
305
+
274
306
  function packedHoleLayout(shape, count, seed) {
307
+ const key = `${shape.key}:${count}:${seed}`;
308
+ if (optimizedLayoutCache.has(key)) return optimizedLayoutCache.get(key);
309
+ const holes = maximizeHoleAreas(shape, initialHoleLayout(shape, count, seed));
310
+ if (optimizedLayoutCache.size >= 512) optimizedLayoutCache.delete(optimizedLayoutCache.keys().next().value);
311
+ optimizedLayoutCache.set(key, holes);
312
+ return holes;
313
+ }
314
+
315
+ function initialHoleLayout(shape, count, seed) {
275
316
  if (count === 0) return [];
276
317
  const { candidates } = packingCandidates(shape);
277
- if (!candidates.length) {
278
- const start = ((Math.trunc(seed) % shape.holes.length) + shape.holes.length) % shape.holes.length;
279
- return Array.from({ length: count }, (_, index) => ({ ...shape.holes[(start + index) % shape.holes.length] }));
280
- }
318
+ if (candidates.length < count) throw new Error(`Insufficient packing space for ${shape.key}`);
281
319
  const maximumClearance = Math.max(...candidates.map(({ clearance }) => clearance));
282
320
  const starterPool = candidates.filter(({ clearance }) => clearance >= maximumClearance * 0.72);
283
321
  const first = seed === 0
@@ -320,12 +358,12 @@ function packedHoleLayout(shape, count, seed) {
320
358
  }
321
359
 
322
360
  const cells = assignVoronoiCells(selected, candidates);
323
- return selected.map((point, index) => {
361
+ return equalizeHoleAreas(selected.map((point, index) => {
324
362
  const nearest = selected.length === 1
325
363
  ? Infinity
326
364
  : Math.min(...selected.filter((_, otherIndex) => otherIndex !== index).map((other) =>
327
365
  Math.hypot(point.x - other.x, point.y - other.y)));
328
- const style = shape.holes[(index + seed) % shape.holes.length];
366
+ const style = shape.holes[((index + seed) % shape.holes.length + shape.holes.length) % shape.holes.length];
329
367
  const styleRadius = Math.max(style.rx, style.ry);
330
368
  const aspectArea = (style.rx / styleRadius) * (style.ry / styleRadius);
331
369
  const territoryArea = cells[index].length * 144;
@@ -339,7 +377,7 @@ function packedHoleLayout(shape, count, seed) {
339
377
  ry: Math.round(radius * (style.ry / styleRadius)),
340
378
  rotate: style.rotate
341
379
  };
342
- });
380
+ }));
343
381
  }
344
382
 
345
383
  function svgMarkup(shape, holes, className, maskSuffix, accessible = false) {
@@ -369,27 +407,33 @@ function extrusionFilterMarkup(shape, filterId, options = {}) {
369
407
  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>`;
370
408
  }
371
409
 
372
- function objectMarkup(shape, holes, suffix, accessible = false) {
410
+ function objectMarkup(shape, holes, suffix, accessible = false, options = {}) {
373
411
  const maskId = `mask-${shape.key}-${suffix}`;
374
412
  const filterId = `extrusion-${shape.key}-${suffix}`;
375
- 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"/>${holes.map(showcaseHoleMarkup).join('')}</mask>${extrusionFilterMarkup(shape, filterId)}</defs><g filter="url(#${filterId})"><g class="shape-spin"><path d="${shape.path}" fill="${shape.color}" mask="url(#${maskId})"/></g></g></svg>`;
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>`;
376
417
  }
377
418
 
378
419
  function renderShape(card, shape, state) {
379
420
  const holes = packedHoleLayout(shape, state.count, state.seed);
380
- card.querySelector('.shape-art').innerHTML = objectMarkup(shape, holes, 'card', true);
421
+ card.querySelector('.shape-art').innerHTML = objectMarkup(shape, holes, 'card', true, { seed: state.seed });
381
422
  card.querySelector('.hole-count').textContent = `${String(state.count).padStart(2, '0')} / ${String(state.limit).padStart(2, '0')}`;
382
423
  card.querySelector('.hole-range').value = state.count;
383
424
  }
384
425
 
385
426
  function exportSvg(shape, holes, options = {}) {
386
- const maskHoles = holes.map(holeMarkup).join('');
387
427
  const filter = extrusionFilterMarkup(shape, 'e', options);
388
428
  const duration = Math.max(100, Math.min(10000, Number(options.duration) || 900));
429
+ const seed = Math.trunc(Number(options.seed) || 0);
430
+ const maskHoles = options.animated === false
431
+ ? holes.map(holeMarkup).join('')
432
+ : holes.map((hole, index) => hoverHoleMarkup(hole, index, seed, duration)).join('');
389
433
  const animation = options.animated === false
390
434
  ? ''
391
- : `<style>.spin-group{transform-box:fill-box;transform-origin:center;will-change:transform}svg:hover .spin-group{animation:spin ${duration}ms cubic-bezier(.33,0,.2,1) both}@keyframes spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){svg:hover .spin-group{animation:none}}</style>`;
392
- 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)"><g class="spin-group"><path d="${shape.path}" fill="${shape.color}" mask="url(#h)"/></g></g></svg>`;
435
+ : `<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>`;
393
437
  }
394
438
 
395
439
  function normalizeColor(color, fallback) {
@@ -403,6 +447,25 @@ function numberInRange(value, fallback, min, max) {
403
447
 
404
448
  export const shapeNames = Object.freeze(shapes.map(({ key, name, color }) => ({ key, name, color })));
405
449
 
450
+ export function seededHoleyOptions(identity, animated = true) {
451
+ const bytes = new TextEncoder().encode(String(identity));
452
+ let hash = 14695981039346656037n;
453
+ for (const byte of bytes) {
454
+ hash = BigInt.asUintN(64, (hash ^ BigInt(byte)) * 1099511628211n);
455
+ }
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));
463
+ };
464
+ const shape = shapes[Number(next() % BigInt(shapes.length))];
465
+ const seed = Number(next() % 1000001n);
466
+ return { shape: shape.key, seed, animated };
467
+ }
468
+
406
469
  export function createHoleySvg(options = {}) {
407
470
  const source = shapes.find(({ key }) => key === options.shape) || shapes[0];
408
471
  const color = normalizeColor(options.color, source.color);
@@ -417,6 +480,7 @@ export function createHoleySvg(options = {}) {
417
480
  return exportSvg(shape, holes, {
418
481
  animated: options.animated,
419
482
  duration: options.duration,
483
+ seed,
420
484
  shadowColor,
421
485
  shadowX,
422
486
  shadowY,
@@ -484,8 +548,6 @@ if (collection) shapes.forEach((shape, index) => {
484
548
  <button class="download" type="button">GET SVG <span>↓</span></button>
485
549
  </div>`;
486
550
  collection.append(card);
487
- applyShowcaseMotion(card, index);
488
-
489
551
  const state = { count: Math.min(shape.initial, holeLimit), seed: 0, limit: holeLimit };
490
552
  states.set(shape.key, state);
491
553
  renderShape(card, shape, state);
@@ -541,8 +603,7 @@ if (presentApp) {
541
603
  function renderPresent() {
542
604
  const shape = activeShape();
543
605
  const holes = packedHoleLayout(shape, presentState.count, presentState.seed);
544
- applyShowcaseMotion(art, presentState.index);
545
- art.innerHTML = objectMarkup(shape, holes, 'present', true);
606
+ art.innerHTML = objectMarkup(shape, holes, 'present', true, { seed: presentState.seed });
546
607
  syncPresentControls();
547
608
  }
548
609