holey-shapes 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Holey Shapes contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # holey-shapes
2
+
3
+ Solid, perforated SVG shapes 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. No framework or stylesheet required.
4
+
5
+ [npm package](https://www.npmjs.com/package/holey-shapes) · [source](https://github.com/skyaara/holey-shapes)
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install holey-shapes
11
+ ```
12
+
13
+ ## Render an SVG string
14
+
15
+ ```js
16
+ import { createHoleySvg } from 'holey-shapes';
17
+
18
+ const svg = createHoleySvg({
19
+ shape: 'flower',
20
+ color: '#FF4FA3',
21
+ shadowColor: '#7A1748',
22
+ holes: 6,
23
+ seed: 2
24
+ });
25
+
26
+ document.querySelector('#shape').innerHTML = svg;
27
+ ```
28
+
29
+ `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.
30
+
31
+ ## Mount and update a shape
32
+
33
+ ```js
34
+ import { mountHoleyShape } from 'holey-shapes';
35
+
36
+ const shape = mountHoleyShape('#shape', {
37
+ shape: 'disc',
38
+ color: '#6337FF',
39
+ holes: 5
40
+ });
41
+
42
+ shape.update({ color: '#00D7B9', holes: 8 });
43
+ shape.shuffle();
44
+ ```
45
+
46
+ `mountHoleyShape()` returns `update()`, `shuffle()`, and `destroy()` methods. Updates replace only the mounted SVG.
47
+
48
+ The generated SVG scales to its container:
49
+
50
+ ```css
51
+ #shape {
52
+ width: 320px;
53
+ }
54
+
55
+ #shape svg {
56
+ display: block;
57
+ width: 100%;
58
+ height: auto;
59
+ }
60
+ ```
61
+
62
+ ## Options
63
+
64
+ | Option | Default | Description |
65
+ | --- | --- | --- |
66
+ | `shape` | `disc` | One of the keys in `shapeNames` |
67
+ | `color` | Shape color | Six-digit hex face color |
68
+ | `shadowColor` | Darker face color | Six-digit hex extrusion color |
69
+ | `holes` | Shape default | Hole count from 0 to 8 |
70
+ | `seed` | `0` | Changes the hole layout |
71
+ | `animated` | `true` | Includes the hover spin |
72
+ | `duration` | `900` | Spin duration in milliseconds |
73
+ | `shadowX` | `18` | Horizontal extrusion offset |
74
+ | `shadowY` | `21` | Vertical extrusion offset |
75
+ | `shadowSteps` | `12` | Number of solid extrusion slices |
76
+
77
+ Available shape keys: `disc`, `round-block`, `hex`, `capsule`, `prism`, `cross`, `triangle`, `diamond`, `sunburst`, `octagon`, `chevron`, `long-bar`, `flower-star`, `flower`, and `bowtie`.
78
+
79
+ You can also build a picker from the exported metadata:
80
+
81
+ ```js
82
+ import { shapeNames } from 'holey-shapes';
83
+
84
+ for (const { key, name, color } of shapeNames) {
85
+ console.log(key, name, color);
86
+ }
87
+ ```
88
+
89
+ ## Browser support
90
+
91
+ 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.
92
+
93
+ The hover animation respects `prefers-reduced-motion`.
package/index.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ export type HoleyShapeKey =
2
+ | 'disc'
3
+ | 'round-block'
4
+ | 'hex'
5
+ | 'capsule'
6
+ | 'prism'
7
+ | 'cross'
8
+ | 'triangle'
9
+ | 'diamond'
10
+ | 'sunburst'
11
+ | 'octagon'
12
+ | 'chevron'
13
+ | 'long-bar'
14
+ | 'flower-star'
15
+ | 'flower'
16
+ | 'bowtie';
17
+
18
+ export interface HoleyOptions {
19
+ shape?: HoleyShapeKey;
20
+ color?: `#${string}`;
21
+ shadowColor?: `#${string}`;
22
+ holes?: number;
23
+ seed?: number;
24
+ animated?: boolean;
25
+ duration?: number;
26
+ shadowX?: number;
27
+ shadowY?: number;
28
+ shadowSteps?: number;
29
+ }
30
+
31
+ export interface HoleyShapeInfo {
32
+ readonly key: HoleyShapeKey;
33
+ readonly name: string;
34
+ readonly color: string;
35
+ }
36
+
37
+ export interface HoleyMount {
38
+ readonly element: Element;
39
+ update(options?: Partial<HoleyOptions>): void;
40
+ shuffle(): void;
41
+ destroy(): void;
42
+ }
43
+
44
+ export const shapeNames: readonly HoleyShapeInfo[];
45
+ export const shapes: readonly Record<string, unknown>[];
46
+ export function createHoleySvg(options?: HoleyOptions): string;
47
+ export function mountHoleyShape(target: string | Element, options?: HoleyOptions): HoleyMount;
package/index.js ADDED
@@ -0,0 +1 @@
1
+ export { createHoleySvg, mountHoleyShape, shapeNames, shapes } from './script.js';
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "holey-shapes",
3
+ "version": "0.1.0",
4
+ "description": "Colorful perforated SVG shapes with solid extrusion and hover animation.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": {
8
+ "name": "Aakash Reddy",
9
+ "email": "kondampallyaakash@gmail.com",
10
+ "url": "https://github.com/skyaara"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/skyaara/holey-shapes.git"
15
+ },
16
+ "homepage": "https://github.com/skyaara/holey-shapes#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/skyaara/holey-shapes/issues"
19
+ },
20
+ "main": "./index.js",
21
+ "types": "./index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./index.d.ts",
25
+ "import": "./index.js",
26
+ "default": "./index.js"
27
+ },
28
+ "./package.json": "./package.json"
29
+ },
30
+ "files": [
31
+ "index.js",
32
+ "script.js",
33
+ "index.d.ts",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "keywords": [
38
+ "svg",
39
+ "shapes",
40
+ "animation",
41
+ "generative-art",
42
+ "web"
43
+ ],
44
+ "engines": {
45
+ "node": ">=18"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "scripts": {
51
+ "dev": "vite --host 127.0.0.1",
52
+ "build": "vite build",
53
+ "preview": "vite preview --host 127.0.0.1",
54
+ "test": "node tests/package.test.mjs",
55
+ "prepublishOnly": "npm test && npm run build"
56
+ },
57
+ "devDependencies": {
58
+ "vite": "^7.1.3"
59
+ }
60
+ }
package/script.js ADDED
@@ -0,0 +1,529 @@
1
+ const h = (cx, cy, rx, ry, rotate = 0) => ({ cx, cy, rx, ry, rotate });
2
+
3
+ const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
4
+ const root = isBrowser ? document.documentElement : null;
5
+ const isHoleyApp = root?.hasAttribute('data-holey-app');
6
+ const themeToggle = isHoleyApp ? document.querySelector('.theme-toggle') : null;
7
+ const themeLabel = themeToggle?.querySelector('.theme-label');
8
+
9
+ function applyTheme(theme) {
10
+ if (!themeToggle || !themeLabel) return;
11
+ const isDark = theme === 'dark';
12
+ root.dataset.theme = theme;
13
+ themeToggle.setAttribute('aria-pressed', String(isDark));
14
+ themeToggle.setAttribute('aria-label', `Switch to ${isDark ? 'light' : 'dark'} mode`);
15
+ themeLabel.textContent = isDark ? 'LIGHT' : 'DARK';
16
+ }
17
+
18
+ if (themeToggle) {
19
+ const savedTheme = localStorage.getItem('holey-theme');
20
+ const initialTheme = savedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
21
+ applyTheme(initialTheme);
22
+ themeToggle.addEventListener('click', () => {
23
+ const nextTheme = root.dataset.theme === 'dark' ? 'light' : 'dark';
24
+ applyTheme(nextTheme);
25
+ localStorage.setItem('holey-theme', nextTheme);
26
+ });
27
+ }
28
+
29
+ export const shapes = [
30
+ {
31
+ key: 'disc', name: 'Perforated disc', color: '#6337FF', bg: '#D7CEFF', initial: 5,
32
+ path: 'M210 38A164 164 0 1 1 209.9 38Z',
33
+ holes: [h(138,112,19,27,-24),h(247,95,24,16,12),h(310,178,17,25,25),h(285,280,20,29,32),h(166,312,25,17,8),h(93,225,16,24,-8),h(196,177,18,25,18),h(245,224,22,15,-18),h(145,236,16,21,30),h(211,285,15,20,-12),h(293,231,13,18,10),h(108,169,13,18,22)]
34
+ },
35
+ {
36
+ key: 'round-block', name: 'Round block', color: '#9BED00', bg: '#E7FF92', initial: 4,
37
+ path: 'M121 54H299Q365 54 365 120V298Q365 364 299 364H121Q55 364 55 298V120Q55 54 121 54Z',
38
+ holes: [h(123,116,23,17,-16),h(211,105,16,23,5),h(300,125,22,16,16),h(101,207,16,23,-4),h(190,190,23,16,22),h(292,211,17,25,12),h(125,300,22,16,-18),h(218,293,16,23,0),h(306,295,21,15,13),h(153,158,13,18,21),h(249,151,15,20,-14),h(245,246,13,18,8)]
39
+ },
40
+ {
41
+ key: 'hex', name: 'Hex slab', color: '#FF0878', bg: '#FFD6E4', initial: 5,
42
+ path: 'M210 36 353 119 353 285 210 368 67 285 67 119Z',
43
+ holes: [h(207,91,15,22),h(287,140,23,15,18),h(314,226,16,23,8),h(260,304,18,25,24),h(157,305,23,16,-15),h(101,229,16,23,-8),h(123,146,17,24,16),h(204,162,20,14,-12),h(244,231,16,22,22),h(159,237,20,14,9),h(210,274,12,17,-5)]
44
+ },
45
+ {
46
+ key: 'capsule', name: 'Capsule', color: '#2878FF', bg: '#CCEDFF', initial: 4,
47
+ path: 'M129 112H291A95 95 0 0 1 291 302H129A95 95 0 0 1 129 112Z',
48
+ holes: [h(91,205,15,23),h(150,164,23,15,-12),h(218,211,16,24,8),h(291,165,21,15,10),h(335,222,15,22,-8),h(145,252,21,14,17),h(262,260,22,15,-14),h(206,154,12,17,5)]
49
+ },
50
+ {
51
+ key: 'prism', name: 'Cut prism', color: '#FF6A00', bg: '#FFD4A3', initial: 4,
52
+ path: 'M210 34 370 164 309 358 111 358 50 164Z',
53
+ holes: [h(145,129,20,15,-18),h(244,108,15,21,10),h(309,173,20,15,20),h(284,270,16,23,17),h(211,317,22,15),h(130,278,16,22,-18),h(100,188,20,15,-12),h(197,186,15,22,8),h(237,245,20,14,18),h(151,218,13,18,-5)]
54
+ },
55
+ {
56
+ key: 'cross', name: 'Cross block', color: '#00DBFF', bg: '#D8D5CF', initial: 5,
57
+ path: 'M151 36H269V151H384V269H269V384H151V269H36V151H151Z',
58
+ holes: [h(210,86,15,22),h(210,135,13,18),h(327,210,22,15),h(278,210,18,13),h(210,334,15,22),h(210,285,13,18),h(91,210,22,15),h(140,210,18,13),h(210,210,21,21)]
59
+ },
60
+ {
61
+ key: 'triangle', name: 'Triangle plate', color: '#00D69F', bg: '#B9F4DF', initial: 4,
62
+ path: 'M210 38 382 356H38Z',
63
+ holes: [h(210,103,14,20),h(170,170,18,13,-15),h(252,172,18,13,15),h(127,257,16,22,-10),h(210,248,21,15),h(294,257,16,22,10),h(89,323,18,13),h(210,319,17,12),h(330,323,18,13)]
64
+ },
65
+ {
66
+ key: 'diamond', name: 'Diamond tile', color: '#FFD000', bg: '#FFF0A6', initial: 5,
67
+ path: 'M210 28 392 210 210 392 28 210Z',
68
+ holes: [h(210,83,16,22),h(142,142,20,14,-18),h(278,142,20,14,18),h(85,210,15,21),h(210,210,22,16),h(335,210,15,21),h(143,279,20,14,18),h(277,279,20,14,-18),h(210,337,16,22),h(210,145,12,17)]
69
+ },
70
+ {
71
+ key: 'sunburst', name: 'Sunburst slab', color: '#B94CFF', bg: '#E6D4FA', initial: 5,
72
+ path: 'M210 28 246 78 306 50 315 112 379 113 352 171 402 208 349 244 377 303 313 304 302 368 245 338 207 390 171 339 110 368 105 304 41 300 69 243 18 207 71 172 43 112 107 111 118 49 173 78Z',
73
+ holes: [h(210,91,16,22),h(143,126,19,14,-18),h(278,126,19,14,18),h(103,197,15,22),h(210,183,21,15),h(317,197,15,22),h(112,291,18,24,-8),h(210,278,22,15),h(307,291,18,24,8),h(162,224,12,17,15)]
74
+ },
75
+ {
76
+ key: 'octagon', name: 'Octagon', color: '#FF3838', bg: '#FFD0C8', initial: 6,
77
+ path: 'M130 40H290L380 130V290L290 380H130L40 290V130Z',
78
+ holes: [h(137,96,19,14,-15),h(229,87,14,20),h(313,131,19,14,18),h(328,221,14,20),h(298,310,19,14,-18),h(207,332,14,20),h(116,306,19,14,15),h(82,216,14,20),h(119,153,14,19,-8),h(208,164,20,14,12),h(271,231,15,21,18),h(171,257,20,14,-14)]
79
+ },
80
+ {
81
+ key: 'chevron', name: 'Bent chevron', color: '#00C9F2', bg: '#C9F3FF', initial: 4,
82
+ path: 'M48 76 210 181 372 76 398 150 210 353 22 150Z',
83
+ holes: [h(102,126,19,14,18),h(162,161,15,21,-12),h(258,160,15,21,12),h(319,126,19,14,-18),h(210,223,21,15),h(161,264,16,22,18),h(210,308,19,13),h(260,264,16,22,-18)]
84
+ },
85
+ {
86
+ key: 'long-bar', name: 'Long bar', color: '#FF4FA3', bg: '#FFE0EE', initial: 5,
87
+ path: 'M30 145H390V275H30Z',
88
+ holes: [h(146,91,20,14,-12),h(255,88,15,21,8),h(103,170,14,20),h(201,158,21,15,15),h(305,172,15,22,-8),h(137,246,18,25,12),h(245,238,21,15,-12),h(306,299,17,23,8),h(185,324,22,15),h(272,130,12,17,15)]
89
+ },
90
+ {
91
+ key: 'flower-star', name: 'Five-point bloom', color: '#FF7657', bg: '#FFE1D8', initial: 5,
92
+ 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',
93
+ 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)]
94
+ },
95
+ {
96
+ key: 'flower', name: 'Daisy flower', color: '#C94DFF', bg: '#F2D8FF', initial: 6,
97
+ 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',
98
+ 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)]
99
+ },
100
+ {
101
+ key: 'bowtie', name: 'Bowtie slab', color: '#00D7B9', bg: '#D0F7F0', initial: 6,
102
+ path: 'M40 70H380L286 210 380 350H40L134 210Z',
103
+ holes: [h(98,126,20,15,-15),h(190,116,16,22,8),h(322,126,20,15,15),h(322,294,20,15,-15),h(230,304,16,22,-8),h(98,294,20,15,15),h(176,210,15,20),h(244,210,15,20)]
104
+ }
105
+ ];
106
+
107
+ const collection = isHoleyApp ? document.querySelector('#collection') : null;
108
+ const MAX_HOLES = 8;
109
+ const SHADOW_X = 18;
110
+ const SHADOW_Y = 21;
111
+ const SHADOW_STEPS = 12;
112
+ const states = new Map();
113
+ let packingContext = null;
114
+ const packingCache = new Map();
115
+
116
+ function getPackingContext() {
117
+ if (packingContext) return packingContext;
118
+ if (!isBrowser || typeof Path2D === 'undefined') return null;
119
+ const packingCanvas = document.createElement('canvas');
120
+ packingCanvas.width = 420;
121
+ packingCanvas.height = 420;
122
+ packingContext = packingCanvas.getContext('2d');
123
+ return packingContext;
124
+ }
125
+
126
+ const holeMarkup = ({ cx, cy, rx, ry, rotate }) =>
127
+ `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}"${rotate ? ` transform="rotate(${rotate} ${cx} ${cy})"` : ''} fill="black"/>`;
128
+
129
+ function darkShadow(color, factor = 0.52) {
130
+ const value = color.replace('#', '');
131
+ const channels = [0, 2, 4].map((offset) => parseInt(value.slice(offset, offset + 2), 16));
132
+ return `#${channels.map((channel) => Math.round(channel * factor).toString(16).padStart(2, '0')).join('')}`;
133
+ }
134
+
135
+ function circleFits(path, cx, cy, radius) {
136
+ const context = getPackingContext();
137
+ if (!context) return false;
138
+ for (let step = 0; step < 32; step += 1) {
139
+ const angle = (Math.PI * 2 * step) / 32;
140
+ const x = cx + Math.cos(angle) * radius;
141
+ const y = cy + Math.sin(angle) * radius;
142
+ if (!context.isPointInPath(path, x, y)) return false;
143
+ }
144
+ return true;
145
+ }
146
+
147
+ function boundaryClearance(path, cx, cy) {
148
+ const context = getPackingContext();
149
+ if (!context || !context.isPointInPath(path, cx, cy)) return 0;
150
+ let low = 0;
151
+ let high = 180;
152
+ for (let pass = 0; pass < 9; pass += 1) {
153
+ const radius = (low + high) / 2;
154
+ if (circleFits(path, cx, cy, radius)) low = radius;
155
+ else high = radius;
156
+ }
157
+ return low;
158
+ }
159
+
160
+ function packingCandidates(shape) {
161
+ if (packingCache.has(shape.key)) return packingCache.get(shape.key);
162
+ const context = getPackingContext();
163
+ if (!context) return { path: null, candidates: [] };
164
+ const path = new Path2D(shape.path);
165
+ const candidates = [];
166
+ for (let y = 50; y <= 370; y += 12) {
167
+ for (let x = 50; x <= 370; x += 12) {
168
+ if (!context.isPointInPath(path, x, y)) continue;
169
+ const clearance = boundaryClearance(path, x, y);
170
+ if (clearance >= 17) candidates.push({ x, y, clearance });
171
+ }
172
+ }
173
+ const result = { path, candidates };
174
+ packingCache.set(shape.key, result);
175
+ return result;
176
+ }
177
+
178
+ function seededNoise(x, y, seed) {
179
+ const value = Math.sin(x * 12.9898 + y * 78.233 + seed * 41.137) * 43758.5453;
180
+ return value - Math.floor(value);
181
+ }
182
+
183
+ function assignVoronoiCells(points, candidates) {
184
+ const cells = points.map(() => []);
185
+ candidates.forEach((candidate) => {
186
+ let closestIndex = 0;
187
+ let closestDistance = Infinity;
188
+ points.forEach((point, index) => {
189
+ const distance = (candidate.x - point.x) ** 2 + (candidate.y - point.y) ** 2;
190
+ if (distance < closestDistance) {
191
+ closestDistance = distance;
192
+ closestIndex = index;
193
+ }
194
+ });
195
+ cells[closestIndex].push(candidate);
196
+ });
197
+ return cells;
198
+ }
199
+
200
+ function packedHoleLayout(shape, count, seed) {
201
+ if (count === 0) return [];
202
+ const { candidates } = packingCandidates(shape);
203
+ if (!candidates.length) {
204
+ const start = ((Math.trunc(seed) % shape.holes.length) + shape.holes.length) % shape.holes.length;
205
+ return Array.from({ length: count }, (_, index) => ({ ...shape.holes[(start + index) % shape.holes.length] }));
206
+ }
207
+ const maximumClearance = Math.max(...candidates.map(({ clearance }) => clearance));
208
+ const starterPool = candidates.filter(({ clearance }) => clearance >= maximumClearance * 0.72);
209
+ const first = seed === 0
210
+ ? starterPool.reduce((best, point) =>
211
+ Math.hypot(point.x - 210, point.y - 210) < Math.hypot(best.x - 210, best.y - 210) ? point : best)
212
+ : starterPool[Math.floor(seededNoise(seed, count, seed) * starterPool.length)];
213
+ let selected = [first];
214
+ const remaining = candidates.filter((point) => point !== first);
215
+
216
+ while (selected.length < count && remaining.length) {
217
+ let bestIndex = 0;
218
+ let bestScore = -1;
219
+ remaining.forEach((candidate, index) => {
220
+ const nearest = Math.min(...selected.map((picked) =>
221
+ Math.hypot(candidate.x - picked.x, candidate.y - picked.y)));
222
+ const packingRadius = Math.min(candidate.clearance - 12, nearest / 2 - 5);
223
+ const variation = seed === 0 ? 1 : 0.92 + seededNoise(candidate.x, candidate.y, seed) * 0.16;
224
+ const score = packingRadius * variation;
225
+ if (score > bestScore) {
226
+ bestScore = score;
227
+ bestIndex = index;
228
+ }
229
+ });
230
+ selected.push(remaining.splice(bestIndex, 1)[0]);
231
+ }
232
+
233
+ for (let iteration = 0; iteration < 8; iteration += 1) {
234
+ const cells = assignVoronoiCells(selected, candidates);
235
+ selected = cells.map((cell, index) => {
236
+ if (!cell.length) return selected[index];
237
+ const centroid = cell.reduce((sum, point) => ({ x: sum.x + point.x, y: sum.y + point.y }), { x: 0, y: 0 });
238
+ centroid.x /= cell.length;
239
+ centroid.y /= cell.length;
240
+ return cell.reduce((closest, point) => {
241
+ const pointDistance = (point.x - centroid.x) ** 2 + (point.y - centroid.y) ** 2;
242
+ const closestDistance = (closest.x - centroid.x) ** 2 + (closest.y - centroid.y) ** 2;
243
+ return pointDistance < closestDistance ? point : closest;
244
+ });
245
+ });
246
+ }
247
+
248
+ const cells = assignVoronoiCells(selected, candidates);
249
+ return selected.map((point, index) => {
250
+ const nearest = selected.length === 1
251
+ ? Infinity
252
+ : Math.min(...selected.filter((_, otherIndex) => otherIndex !== index).map((other) =>
253
+ Math.hypot(point.x - other.x, point.y - other.y)));
254
+ const style = shape.holes[(index + seed) % shape.holes.length];
255
+ const styleRadius = Math.max(style.rx, style.ry);
256
+ const aspectArea = (style.rx / styleRadius) * (style.ry / styleRadius);
257
+ const territoryArea = cells[index].length * 144;
258
+ const territoryRadius = Math.sqrt((territoryArea * 0.56) / (Math.PI * aspectArea));
259
+ const safeRadius = Math.min(point.clearance - 12, nearest / 2 - 6);
260
+ const radius = Math.max(10, Math.min(safeRadius, territoryRadius));
261
+ return {
262
+ cx: point.x,
263
+ cy: point.y,
264
+ rx: Math.round(radius * (style.rx / styleRadius)),
265
+ ry: Math.round(radius * (style.ry / styleRadius)),
266
+ rotate: style.rotate
267
+ };
268
+ });
269
+ }
270
+
271
+ function svgMarkup(shape, holes, className, maskSuffix, accessible = false) {
272
+ const maskId = `mask-${shape.key}-${maskSuffix}`;
273
+ const fill = className === 'shape-shadow' ? darkShadow(shape.color) : shape.color;
274
+ 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"'}>
275
+ <defs><mask id="${maskId}"><rect width="420" height="420" fill="white"/>${holes.map(holeMarkup).join('')}</mask></defs>
276
+ <path d="${shape.path}" fill="${fill}" mask="url(#${maskId})"/>
277
+ </svg>`;
278
+ }
279
+
280
+ function extrusionFilterMarkup(shape, suffix, options = {}) {
281
+ const shadowX = options.shadowX ?? SHADOW_X;
282
+ const shadowY = options.shadowY ?? SHADOW_Y;
283
+ const shadowSteps = options.shadowSteps ?? SHADOW_STEPS;
284
+ const shadowColor = options.shadowColor ?? darkShadow(shape.color);
285
+ const offsets = Array.from({ length: shadowSteps }, (_, index) => {
286
+ const step = index + 1;
287
+ const ratio = step / shadowSteps;
288
+ return `<feOffset in="shadow-color" dx="${(shadowX * ratio).toFixed(2)}" dy="${(shadowY * ratio).toFixed(2)}" result="shadow-${step}"/>`;
289
+ }).join('');
290
+ const merge = Array.from({ length: shadowSteps }, (_, index) =>
291
+ `<feMergeNode in="shadow-${shadowSteps - index}"/>`
292
+ ).join('');
293
+ return `<filter id="extrusion-${shape.key}-${suffix}" x="-20%" y="-20%" width="160%" height="160%" color-interpolation-filters="sRGB">
294
+ <feFlood flood-color="${shadowColor}" result="shadow-paint"/>
295
+ <feComposite in="shadow-paint" in2="SourceGraphic" operator="in" result="shadow-color"/>
296
+ ${offsets}
297
+ <feMerge>${merge}<feMergeNode in="SourceGraphic"/></feMerge>
298
+ </filter>`;
299
+ }
300
+
301
+ function objectMarkup(shape, holes, suffix, accessible = false) {
302
+ const maskId = `mask-${shape.key}-${suffix}`;
303
+ const filterId = `extrusion-${shape.key}-${suffix}`;
304
+ 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"'}>
305
+ <defs>
306
+ <mask id="${maskId}"><rect width="420" height="420" fill="white"/>${holes.map(holeMarkup).join('')}</mask>
307
+ ${extrusionFilterMarkup(shape, suffix)}
308
+ </defs>
309
+ <g filter="url(#${filterId})"><g class="shape-spin"><path d="${shape.path}" fill="${shape.color}" mask="url(#${maskId})"/></g></g>
310
+ </svg>`;
311
+ }
312
+
313
+ function renderShape(card, shape, state) {
314
+ const holes = packedHoleLayout(shape, state.count, state.seed);
315
+ card.querySelector('.shape-art').innerHTML = objectMarkup(shape, holes, 'card', true);
316
+ card.querySelector('.hole-count').textContent = `${String(state.count).padStart(2, '0')} / ${String(state.limit).padStart(2, '0')}`;
317
+ card.querySelector('.hole-range').value = state.count;
318
+ }
319
+
320
+ function exportSvg(shape, holes, options = {}) {
321
+ const maskHoles = holes.map(holeMarkup).join('');
322
+ const filter = extrusionFilterMarkup(shape, 'download', options).replaceAll(`extrusion-${shape.key}-download`, 'extrusion');
323
+ const duration = Math.max(100, Math.min(10000, Number(options.duration) || 900));
324
+ const animation = options.animated === false
325
+ ? ''
326
+ : `<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>`;
327
+ 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="holes"><rect width="420" height="420" fill="white"/>${maskHoles}</mask>${filter}</defs><g filter="url(#extrusion)"><g class="spin-group"><path d="${shape.path}" fill="${shape.color}" mask="url(#holes)"/></g></g></svg>`;
328
+ }
329
+
330
+ function normalizeColor(color, fallback) {
331
+ return typeof color === 'string' && /^#[0-9a-f]{6}$/i.test(color) ? color.toUpperCase() : fallback;
332
+ }
333
+
334
+ function numberInRange(value, fallback, min, max) {
335
+ const number = Number(value);
336
+ return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
337
+ }
338
+
339
+ export const shapeNames = Object.freeze(shapes.map(({ key, name, color }) => ({ key, name, color })));
340
+
341
+ export function createHoleySvg(options = {}) {
342
+ const source = shapes.find(({ key }) => key === options.shape) || shapes[0];
343
+ const color = normalizeColor(options.color, source.color);
344
+ const shape = { ...source, color };
345
+ const holeCount = Math.round(numberInRange(options.holes, source.initial, 0, Math.min(MAX_HOLES, source.holes.length)));
346
+ const seed = Math.trunc(numberInRange(options.seed, 0, -1000000, 1000000));
347
+ const shadowX = numberInRange(options.shadowX, SHADOW_X, -80, 80);
348
+ const shadowY = numberInRange(options.shadowY, SHADOW_Y, -80, 80);
349
+ const shadowSteps = Math.round(numberInRange(options.shadowSteps, SHADOW_STEPS, 1, 32));
350
+ const shadowColor = normalizeColor(options.shadowColor, darkShadow(color));
351
+ const holes = packedHoleLayout(shape, holeCount, seed);
352
+ return exportSvg(shape, holes, {
353
+ animated: options.animated,
354
+ duration: options.duration,
355
+ shadowColor,
356
+ shadowX,
357
+ shadowY,
358
+ shadowSteps
359
+ });
360
+ }
361
+
362
+ export function mountHoleyShape(target, options = {}) {
363
+ const element = typeof target === 'string' && isBrowser ? document.querySelector(target) : target;
364
+ if (!element || typeof element.replaceChildren !== 'function') {
365
+ throw new TypeError('mountHoleyShape needs an element or a valid selector.');
366
+ }
367
+
368
+ let currentOptions = { ...options };
369
+ const render = () => {
370
+ const template = document.createElement('template');
371
+ template.innerHTML = createHoleySvg(currentOptions).trim();
372
+ element.replaceChildren(template.content.firstElementChild);
373
+ };
374
+ render();
375
+
376
+ return {
377
+ element,
378
+ update(nextOptions = {}) {
379
+ currentOptions = { ...currentOptions, ...nextOptions };
380
+ render();
381
+ },
382
+ shuffle() {
383
+ currentOptions.seed = (Number(currentOptions.seed) || 0) + 1;
384
+ render();
385
+ },
386
+ destroy() {
387
+ element.replaceChildren();
388
+ }
389
+ };
390
+ }
391
+
392
+ function downloadSvg(shape, holes) {
393
+ const source = exportSvg(shape, holes);
394
+ const url = URL.createObjectURL(new Blob([source], { type: 'image/svg+xml' }));
395
+ const link = document.createElement('a');
396
+ link.href = url;
397
+ link.download = `${shape.key}-${holes.length}-holes.svg`;
398
+ link.click();
399
+ URL.revokeObjectURL(url);
400
+ }
401
+
402
+ if (collection) shapes.forEach((shape, index) => {
403
+ const number = String(index + 1).padStart(2, '0');
404
+ const holeLimit = Math.min(MAX_HOLES, shape.holes.length);
405
+ const wide = index === 0 || index === shapes.length - 1;
406
+ const card = document.createElement('article');
407
+ card.className = `form-card${wide ? ' form-card--wide' : ''}`;
408
+ card.dataset.index = number;
409
+ card.innerHTML = `
410
+ <div class="card-meta"><span>${number}</span><h2>${shape.name}</h2><span>MAX ${String(holeLimit).padStart(2, '0')}</span></div>
411
+ <div class="stage" style="background:${shape.bg}">
412
+ <div class="shape-art${wide ? ' shape-art--large' : ''}"></div>
413
+ ${index === 0 ? '<span class="orbit-line" aria-hidden="true"></span>' : ''}
414
+ ${index === shapes.length - 1 ? '<span class="crosshair" aria-hidden="true"></span>' : ''}
415
+ </div>
416
+ <div class="card-actions">
417
+ <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>
418
+ <button class="shuffle" type="button">SHUFFLE ↻</button>
419
+ <button class="download" type="button">GET SVG <span>↓</span></button>
420
+ </div>`;
421
+ collection.append(card);
422
+
423
+ const state = { count: Math.min(shape.initial, holeLimit), seed: 0, limit: holeLimit };
424
+ states.set(shape.key, state);
425
+ renderShape(card, shape, state);
426
+
427
+ card.querySelector('.hole-range').addEventListener('input', (event) => {
428
+ state.count = Number(event.target.value);
429
+ renderShape(card, shape, state);
430
+ });
431
+ card.querySelector('.shuffle').addEventListener('click', () => {
432
+ state.seed += 1;
433
+ renderShape(card, shape, state);
434
+ });
435
+ card.querySelector('.download').addEventListener('click', () => {
436
+ downloadSvg(shape, packedHoleLayout(shape, state.count, state.seed));
437
+ });
438
+ });
439
+
440
+ const presentApp = isHoleyApp ? document.querySelector('#present-app') : null;
441
+
442
+ if (presentApp) {
443
+ const art = document.querySelector('#present-art');
444
+ const shapePicker = document.querySelector('#shape-picker');
445
+ const colorPicker = document.querySelector('#color-picker');
446
+ const range = document.querySelector('#present-holes');
447
+ const count = document.querySelector('#present-count');
448
+ const selectedName = document.querySelector('#selected-name');
449
+ const shuffle = document.querySelector('#present-shuffle');
450
+ const download = document.querySelector('#present-download');
451
+ const canvas = document.querySelector('.present-canvas');
452
+ const palette = ['#6337FF', '#9BED00', '#FF0878', '#2878FF', '#FF6A00', '#00DBFF', '#FFD000', '#C94DFF', '#00D7B9'];
453
+ const presentState = { index: 0, count: shapes[0].initial, seed: 0, color: shapes[0].color };
454
+
455
+ const activeShape = () => ({ ...shapes[presentState.index], color: presentState.color });
456
+
457
+ function syncPresentControls() {
458
+ const shape = shapes[presentState.index];
459
+ const limit = Math.min(MAX_HOLES, shape.holes.length);
460
+ selectedName.textContent = shape.name;
461
+ range.max = limit;
462
+ range.value = presentState.count;
463
+ count.textContent = `${String(presentState.count).padStart(2, '0')} / ${String(limit).padStart(2, '0')}`;
464
+ shapePicker.querySelectorAll('.shape-option').forEach((button, index) => {
465
+ button.classList.toggle('is-active', index === presentState.index);
466
+ button.setAttribute('aria-pressed', String(index === presentState.index));
467
+ });
468
+ colorPicker.querySelectorAll('.color-option').forEach((button) => {
469
+ const active = button.dataset.color === presentState.color;
470
+ button.classList.toggle('is-active', active);
471
+ button.setAttribute('aria-pressed', String(active));
472
+ });
473
+ }
474
+
475
+ function renderPresent() {
476
+ const shape = activeShape();
477
+ const holes = packedHoleLayout(shape, presentState.count, presentState.seed);
478
+ art.innerHTML = objectMarkup(shape, holes, 'present', true);
479
+ syncPresentControls();
480
+ }
481
+
482
+ shapes.forEach((shape, index) => {
483
+ const button = document.createElement('button');
484
+ const previewHoles = packedHoleLayout(shape, Math.min(3, shape.holes.length), 0);
485
+ button.className = 'shape-option';
486
+ button.type = 'button';
487
+ button.setAttribute('aria-label', shape.name);
488
+ button.innerHTML = `<span class="shape-thumb">${svgMarkup(shape, previewHoles, 'picker-shape', `picker-${index}`)}</span><span>${String(index + 1).padStart(2, '0')}</span>`;
489
+ button.addEventListener('click', () => {
490
+ presentState.index = index;
491
+ presentState.count = Math.min(shape.initial, MAX_HOLES, shape.holes.length);
492
+ presentState.seed = 0;
493
+ presentState.color = shape.color;
494
+ renderPresent();
495
+ });
496
+ shapePicker.append(button);
497
+ });
498
+
499
+ palette.forEach((color) => {
500
+ const button = document.createElement('button');
501
+ button.className = 'color-option';
502
+ button.type = 'button';
503
+ button.dataset.color = color;
504
+ button.style.setProperty('--swatch', color);
505
+ button.setAttribute('aria-label', `Use color ${color}`);
506
+ button.addEventListener('click', () => {
507
+ presentState.color = color;
508
+ renderPresent();
509
+ });
510
+ colorPicker.append(button);
511
+ });
512
+
513
+ range.addEventListener('input', () => {
514
+ presentState.count = Number(range.value);
515
+ renderPresent();
516
+ });
517
+
518
+ shuffle.addEventListener('click', () => {
519
+ presentState.seed += 1;
520
+ renderPresent();
521
+ });
522
+
523
+ download.addEventListener('click', () => {
524
+ const shape = activeShape();
525
+ downloadSvg(shape, packedHoleLayout(shape, presentState.count, presentState.seed));
526
+ });
527
+
528
+ renderPresent();
529
+ }