holey-shapes 0.1.2 → 1.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: "0.1.4")
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,44 @@ 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: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.
46
83
 
47
84
  ## Render an SVG string
48
85
 
@@ -50,7 +87,7 @@ The Swift SDK supports iOS 16+, macOS 13+, tvOS 16+, and visionOS 1+. It uses as
50
87
  import { createHoleySvg } from 'holey-shapes';
51
88
 
52
89
  const svg = createHoleySvg({
53
- shape: 'flower',
90
+ shape: 'prism',
54
91
  color: '#FF4FA3',
55
92
  shadowColor: '#7A1748',
56
93
  holes: 6,
@@ -102,13 +139,13 @@ The generated SVG scales to its container:
102
139
  | `shadowColor` | Darker face color | Six-digit hex extrusion color |
103
140
  | `holes` | Shape default | Hole count from 0 to 8 |
104
141
  | `seed` | `0` | Changes the hole layout |
105
- | `animated` | `true` | Includes the hover spin |
106
- | `duration` | `900` | Spin duration in milliseconds |
142
+ | `animated` | `true` | Includes the hover hole animation |
143
+ | `duration` | `900` | Total hole-animation duration in milliseconds |
107
144
  | `shadowX` | `18` | Horizontal extrusion offset |
108
145
  | `shadowY` | `21` | Vertical extrusion offset |
109
146
  | `shadowSteps` | `12` | Number of solid extrusion slices |
110
147
 
111
- Available shape keys: `disc`, `round-block`, `hex`, `capsule`, `prism`, `cross`, `triangle`, `diamond`, `sunburst`, `octagon`, `chevron`, `long-bar`, `flower-star`, `flower`, and `bowtie`.
148
+ Available shape keys: `disc`, `round-block`, `hex`, `capsule`, `prism`, `cross`, `triangle`, `diamond`, `sunburst`, `octagon`, `chevron`, `long-bar`, `flower-star`, and `bowtie`.
112
149
 
113
150
  You can also build a picker from the exported metadata:
114
151
 
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": "1.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": {
package/script.js CHANGED
@@ -110,11 +110,6 @@ export const shapes = [
110
110
  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
111
  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
112
  },
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
113
  {
119
114
  key: 'bowtie', name: 'Bowtie slab', color: '#00D7B9', bg: '#D0F7F0', initial: 6,
120
115
  path: 'M40 70H380L286 210 380 350H40L134 210Z',
@@ -130,38 +125,6 @@ const SHADOW_STEPS = 12;
130
125
  const states = new Map();
131
126
  let packingContext = null;
132
127
  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
128
  function getPackingContext() {
166
129
  if (packingContext) return packingContext;
167
130
  if (!isBrowser || typeof Path2D === 'undefined') return null;
@@ -175,30 +138,13 @@ function getPackingContext() {
175
138
  const holeMarkup = ({ cx, cy, rx, ry, rotate }) =>
176
139
  `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}"${rotate ? ` transform="rotate(${rotate} ${cx} ${cy})"` : ''}/>`;
177
140
 
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
- };
141
+ function hoverHoleMarkup(hole, index, seed = 0, duration = 900) {
142
+ const durationNoise = seededNoise(index + 1, 17, seed);
143
+ const delayNoise = seededNoise(index + 1, 43, seed);
144
+ const pulseDuration = Math.round(duration * (0.42 + durationNoise * 0.16));
145
+ const delay = Math.round(duration * (0.04 + delayNoise * 0.34));
146
+ return `<g class="hole-hover" style="--hole-duration:${pulseDuration}ms;--hole-delay:${delay}ms">${holeMarkup(hole)}</g>`;
147
+ }
202
148
 
203
149
  function darkShadow(color, factor = 0.52) {
204
150
  const value = color.replace('#', '');
@@ -271,12 +217,122 @@ function assignVoronoiCells(points, candidates) {
271
217
  return cells;
272
218
  }
273
219
 
220
+ // Shrink to the smallest fitted area so spacing and oval proportions stay intact.
221
+ function equalizeHoleAreas(holes) {
222
+ if (!holes.length) return holes;
223
+ const targetArea = Math.min(...holes.map(({ rx, ry }) => rx * ry));
224
+ return holes.map((hole) => {
225
+ const scale = Math.sqrt(targetArea / (hole.rx * hole.ry));
226
+ return {
227
+ ...hole,
228
+ rx: Number((hole.rx * scale).toFixed(3)),
229
+ ry: Number((hole.ry * scale).toFixed(3))
230
+ };
231
+ });
232
+ }
233
+
234
+ // Equal-area ellipses use radius sqrt(rx * ry). Support projections give a
235
+ // separating line with a 12-unit gap, including for differently rotated ovals.
236
+ const packingDirections = Array.from({ length: 48 }, (_, i) => {
237
+ const angle = i * Math.PI * 2 / 48;
238
+ return { x: Math.cos(angle), y: Math.sin(angle) };
239
+ });
240
+ const perimeterDirections = Array.from({ length: 192 }, (_, i) => {
241
+ const angle = i * Math.PI * 2 / 192;
242
+ return { x: Math.cos(angle), y: Math.sin(angle) };
243
+ });
244
+ const optimizedLayoutCache = new Map();
245
+
246
+ function maximizeHoleAreas(shape, holes) {
247
+ if (!holes.length) return holes;
248
+ const context = getPackingContext();
249
+ const { path } = packingCandidates(shape);
250
+ if (!context || !path) return holes;
251
+ const gap = 12;
252
+ const profiles = holes.map(hole => {
253
+ const a = Math.sqrt(hole.rx / hole.ry);
254
+ const b = 1 / a;
255
+ const angle = hole.rotate * Math.PI / 180;
256
+ const c = Math.cos(angle), s = Math.sin(angle);
257
+ return {
258
+ a, b,
259
+ support: packingDirections.map(n => Math.hypot(a * (n.x * c + n.y * s), b * (-n.x * s + n.y * c))),
260
+ edge: perimeterDirections.map(n => {
261
+ const nx = n.x / a, ny = n.y / b;
262
+ const length = Math.hypot(nx, ny);
263
+ return { x: a * n.x * c - b * n.y * s, y: a * n.x * s + b * n.y * c,
264
+ nx: (nx * c - ny * s) / length, ny: (nx * s + ny * c) / length };
265
+ })
266
+ };
267
+ });
268
+ const points = holes.map(h => ({ x: h.cx, y: h.cy }));
269
+ function boundaryLimit(point, profile) {
270
+ if (!context.isPointInPath(path, point.x, point.y)) return 0;
271
+ let low = 0, high = 180;
272
+ for (let pass = 0; pass < 11; pass++) {
273
+ const radius = (low + high) / 2;
274
+ // An extra rim unit covers the space between perimeter samples.
275
+ const fits = profile.edge.every(e => context.isPointInPath(path,
276
+ point.x + radius * e.x + (gap + 1) * e.nx, point.y + radius * e.y + (gap + 1) * e.ny));
277
+ if (fits) low = radius; else high = radius;
278
+ }
279
+ return low;
280
+ }
281
+ const boundaries = points.map((p, i) => boundaryLimit(p, profiles[i]));
282
+ function capacity(point, index, boundary) {
283
+ let limit = boundary;
284
+ points.forEach((other, j) => {
285
+ if (j === index) return;
286
+ let separation = 0;
287
+ packingDirections.forEach((n, k) => {
288
+ separation = Math.max(separation, (Math.abs((point.x - other.x) * n.x + (point.y - other.y) * n.y) - gap)
289
+ / (profiles[index].support[k] + profiles[j].support[k]));
290
+ });
291
+ limit = Math.min(limit, separation);
292
+ });
293
+ return limit;
294
+ }
295
+ // Coordinate search improves each constrained hole, then refines its position.
296
+ for (const step of [12, 6, 3, 1.5]) {
297
+ for (let pass = 0; pass < 3; pass++) {
298
+ let moved = false;
299
+ points.forEach((point, i) => {
300
+ let best = point, bestBoundary = boundaries[i];
301
+ let bestLimit = capacity(point, i, bestBoundary);
302
+ for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) {
303
+ const candidate = { x: point.x + dx * step, y: point.y + dy * step };
304
+ const boundary = boundaryLimit(candidate, profiles[i]);
305
+ const limit = capacity(candidate, i, boundary);
306
+ if (limit > bestLimit + 0.02) {
307
+ best = candidate; bestBoundary = boundary; bestLimit = limit;
308
+ }
309
+ }
310
+ if (best !== point) moved = true;
311
+ points[i] = best; boundaries[i] = bestBoundary;
312
+ });
313
+ if (!moved) break;
314
+ }
315
+ }
316
+ const radius = Math.max(0, Math.min(...points.map((p, i) => capacity(p, i, boundaries[i]))) - 0.05);
317
+ return holes.map((hole, i) => ({ ...hole, cx: points[i].x, cy: points[i].y,
318
+ rx: Number((radius * profiles[i].a).toFixed(3)), ry: Number((radius * profiles[i].b).toFixed(3)) }));
319
+ }
320
+
274
321
  function packedHoleLayout(shape, count, seed) {
322
+ const key = `${shape.key}:${count}:${seed}`;
323
+ if (optimizedLayoutCache.has(key)) return optimizedLayoutCache.get(key);
324
+ const holes = maximizeHoleAreas(shape, initialHoleLayout(shape, count, seed));
325
+ if (optimizedLayoutCache.size >= 512) optimizedLayoutCache.delete(optimizedLayoutCache.keys().next().value);
326
+ optimizedLayoutCache.set(key, holes);
327
+ return holes;
328
+ }
329
+
330
+ function initialHoleLayout(shape, count, seed) {
275
331
  if (count === 0) return [];
276
332
  const { candidates } = packingCandidates(shape);
277
333
  if (!candidates.length) {
278
334
  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] }));
335
+ return equalizeHoleAreas(Array.from({ length: count }, (_, index) => ({ ...shape.holes[(start + index) % shape.holes.length] })));
280
336
  }
281
337
  const maximumClearance = Math.max(...candidates.map(({ clearance }) => clearance));
282
338
  const starterPool = candidates.filter(({ clearance }) => clearance >= maximumClearance * 0.72);
@@ -320,7 +376,7 @@ function packedHoleLayout(shape, count, seed) {
320
376
  }
321
377
 
322
378
  const cells = assignVoronoiCells(selected, candidates);
323
- return selected.map((point, index) => {
379
+ return equalizeHoleAreas(selected.map((point, index) => {
324
380
  const nearest = selected.length === 1
325
381
  ? Infinity
326
382
  : Math.min(...selected.filter((_, otherIndex) => otherIndex !== index).map((other) =>
@@ -339,7 +395,7 @@ function packedHoleLayout(shape, count, seed) {
339
395
  ry: Math.round(radius * (style.ry / styleRadius)),
340
396
  rotate: style.rotate
341
397
  };
342
- });
398
+ }));
343
399
  }
344
400
 
345
401
  function svgMarkup(shape, holes, className, maskSuffix, accessible = false) {
@@ -369,27 +425,33 @@ function extrusionFilterMarkup(shape, filterId, options = {}) {
369
425
  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
426
  }
371
427
 
372
- function objectMarkup(shape, holes, suffix, accessible = false) {
428
+ function objectMarkup(shape, holes, suffix, accessible = false, options = {}) {
373
429
  const maskId = `mask-${shape.key}-${suffix}`;
374
430
  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>`;
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>`;
376
435
  }
377
436
 
378
437
  function renderShape(card, shape, state) {
379
438
  const holes = packedHoleLayout(shape, state.count, state.seed);
380
- card.querySelector('.shape-art').innerHTML = objectMarkup(shape, holes, 'card', true);
439
+ card.querySelector('.shape-art').innerHTML = objectMarkup(shape, holes, 'card', true, { seed: state.seed });
381
440
  card.querySelector('.hole-count').textContent = `${String(state.count).padStart(2, '0')} / ${String(state.limit).padStart(2, '0')}`;
382
441
  card.querySelector('.hole-range').value = state.count;
383
442
  }
384
443
 
385
444
  function exportSvg(shape, holes, options = {}) {
386
- const maskHoles = holes.map(holeMarkup).join('');
387
445
  const filter = extrusionFilterMarkup(shape, 'e', options);
388
446
  const duration = Math.max(100, Math.min(10000, Number(options.duration) || 900));
447
+ const seed = Math.trunc(Number(options.seed) || 0);
448
+ const maskHoles = options.animated === false
449
+ ? holes.map(holeMarkup).join('')
450
+ : holes.map((hole, index) => hoverHoleMarkup(hole, index, seed, duration)).join('');
389
451
  const animation = options.animated === false
390
452
  ? ''
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>`;
453
+ : `<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>`;
393
455
  }
394
456
 
395
457
  function normalizeColor(color, fallback) {
@@ -403,6 +465,25 @@ function numberInRange(value, fallback, min, max) {
403
465
 
404
466
  export const shapeNames = Object.freeze(shapes.map(({ key, name, color }) => ({ key, name, color })));
405
467
 
468
+ export function seededHoleyOptions(identity, animated = true) {
469
+ const bytes = new TextEncoder().encode(String(identity));
470
+ let hash = 14695981039346656037n;
471
+ for (const byte of bytes) {
472
+ hash = BigInt.asUintN(64, (hash ^ BigInt(byte)) * 1099511628211n);
473
+ }
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));
481
+ };
482
+ const shape = shapes[Number(next() % BigInt(shapes.length))];
483
+ const seed = Number(next() % 1000001n);
484
+ return { shape: shape.key, seed, animated };
485
+ }
486
+
406
487
  export function createHoleySvg(options = {}) {
407
488
  const source = shapes.find(({ key }) => key === options.shape) || shapes[0];
408
489
  const color = normalizeColor(options.color, source.color);
@@ -417,6 +498,7 @@ export function createHoleySvg(options = {}) {
417
498
  return exportSvg(shape, holes, {
418
499
  animated: options.animated,
419
500
  duration: options.duration,
501
+ seed,
420
502
  shadowColor,
421
503
  shadowX,
422
504
  shadowY,
@@ -484,8 +566,6 @@ if (collection) shapes.forEach((shape, index) => {
484
566
  <button class="download" type="button">GET SVG <span>↓</span></button>
485
567
  </div>`;
486
568
  collection.append(card);
487
- applyShowcaseMotion(card, index);
488
-
489
569
  const state = { count: Math.min(shape.initial, holeLimit), seed: 0, limit: holeLimit };
490
570
  states.set(shape.key, state);
491
571
  renderShape(card, shape, state);
@@ -541,8 +621,7 @@ if (presentApp) {
541
621
  function renderPresent() {
542
622
  const shape = activeShape();
543
623
  const holes = packedHoleLayout(shape, presentState.count, presentState.seed);
544
- applyShowcaseMotion(art, presentState.index);
545
- art.innerHTML = objectMarkup(shape, holes, 'present', true);
624
+ art.innerHTML = objectMarkup(shape, holes, 'present', true, { seed: presentState.seed });
546
625
  syncPresentControls();
547
626
  }
548
627