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 +45 -8
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/package.json +2 -2
- package/script.js +153 -74
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# holey-shapes
|
|
2
2
|
|
|
3
|
-
Solid, perforated shapes for the web and
|
|
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.
|
|
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
|
-
.
|
|
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.
|
|
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: '
|
|
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
|
|
106
|
-
| `duration` | `900` |
|
|
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`,
|
|
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.
|
|
4
|
-
"description": "Colorful perforated SVG shapes with solid extrusion and
|
|
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
|
-
|
|
179
|
-
const
|
|
180
|
-
const
|
|
181
|
-
const
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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>.
|
|
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)"><
|
|
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
|
-
|
|
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
|
|