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 +77 -9
- package/geometry.js +95 -0
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/package.json +4 -3
- package/script.js +158 -97
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.
|
|
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
|
-
.
|
|
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.
|
|
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: '
|
|
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
|
|
106
|
-
| `duration` | `900` |
|
|
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`,
|
|
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.
|
|
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.
|
|
4
|
-
"description": "Colorful perforated SVG shapes with solid extrusion and
|
|
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
|
-
|
|
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
|
-
};
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
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 (
|
|
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
|
-
|
|
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>.
|
|
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)"><
|
|
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
|
-
|
|
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
|
|