holey-shapes 2.0.0 → 2.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/README.md +14 -175
- package/color.js +54 -0
- package/doodle.js +79 -0
- package/geometry.js +30 -9
- package/index.d.ts +30 -2
- package/index.js +1 -1
- package/package.json +7 -10
- package/script.js +85 -233
package/README.md
CHANGED
|
@@ -1,195 +1,34 @@
|
|
|
1
1
|
# holey-shapes
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Avatars with holes. Turn any name into a colorful, one-of-a-kind SVG shape. The same name always gives the same shape. It runs in Node and browsers with no dependencies.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
## Install
|
|
8
|
-
|
|
9
|
-
### JavaScript
|
|
5
|
+
<img src="https://raw.githubusercontent.com/skyaara/holey-shapes/main/.github/media/names.gif" width="720" alt="Ten names, each turned into its own holey shape">
|
|
10
6
|
|
|
11
7
|
```sh
|
|
12
8
|
npm install holey-shapes
|
|
13
9
|
```
|
|
14
10
|
|
|
15
|
-
### Swift Package Manager
|
|
16
|
-
|
|
17
|
-
In Xcode, add `https://github.com/skyaara/holey-shapes` as a package dependency, or add it to `Package.swift`:
|
|
18
|
-
|
|
19
|
-
```swift
|
|
20
|
-
dependencies: [
|
|
21
|
-
.package(url: "https://github.com/skyaara/holey-shapes.git", from: "2.0.0")
|
|
22
|
-
]
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
Then render any catalog shape directly in SwiftUI:
|
|
26
|
-
|
|
27
|
-
```swift
|
|
28
|
-
import HoleyShapes
|
|
29
|
-
import SwiftUI
|
|
30
|
-
|
|
31
|
-
struct Logo: View {
|
|
32
|
-
var body: some View {
|
|
33
|
-
HoleyShapeView(
|
|
34
|
-
.prism,
|
|
35
|
-
faceColor: HoleyColor(hex: "#FF4FA3"),
|
|
36
|
-
shadowColor: HoleyColor(hex: "#7A1748"),
|
|
37
|
-
holes: 6,
|
|
38
|
-
seed: 2
|
|
39
|
-
)
|
|
40
|
-
.frame(width: 240, height: 240)
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
```
|
|
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. 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
|
-
```
|
|
114
|
-
|
|
115
|
-
## Render an SVG string
|
|
116
|
-
|
|
117
|
-
```js
|
|
118
|
-
import { createHoleySvg } from 'holey-shapes';
|
|
119
|
-
|
|
120
|
-
const svg = createHoleySvg({
|
|
121
|
-
shape: 'prism',
|
|
122
|
-
color: '#FF4FA3',
|
|
123
|
-
shadowColor: '#7A1748',
|
|
124
|
-
holes: 6,
|
|
125
|
-
seed: 2
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
document.querySelector('#shape').innerHTML = svg;
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
`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.
|
|
132
|
-
|
|
133
|
-
## Mount and update a shape
|
|
134
|
-
|
|
135
11
|
```js
|
|
136
|
-
import { mountHoleyShape } from 'holey-shapes';
|
|
12
|
+
import { createHoleySvg, mountHoleyShape } from 'holey-shapes';
|
|
137
13
|
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
color: '#6337FF',
|
|
141
|
-
holes: 5
|
|
142
|
-
});
|
|
14
|
+
const svg = createHoleySvg({ shape: 'prism', color: '#FF4FA3', holes: 6, seed: 2 });
|
|
15
|
+
document.querySelector('#preview').innerHTML = svg;
|
|
143
16
|
|
|
144
|
-
|
|
145
|
-
|
|
17
|
+
const mounted = mountHoleyShape('#avatar', { shape: 'disc', holes: 5 });
|
|
18
|
+
mounted.update({ color: '#00D7B9' });
|
|
146
19
|
```
|
|
147
20
|
|
|
148
|
-
`
|
|
149
|
-
|
|
150
|
-
The generated SVG scales to its container:
|
|
151
|
-
|
|
152
|
-
```css
|
|
153
|
-
#shape {
|
|
154
|
-
width: 320px;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
#shape svg {
|
|
158
|
-
display: block;
|
|
159
|
-
width: 100%;
|
|
160
|
-
height: auto;
|
|
161
|
-
}
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
## Options
|
|
165
|
-
|
|
166
|
-
| Option | Default | Description |
|
|
167
|
-
| --- | --- | --- |
|
|
168
|
-
| `shape` | `disc` | One of the keys in `shapeNames` |
|
|
169
|
-
| `color` | Shape color | Six-digit hex face color |
|
|
170
|
-
| `shadowColor` | Darker face color | Six-digit hex extrusion color |
|
|
171
|
-
| `holes` | Shape default | Hole count from 0 to 8 |
|
|
172
|
-
| `seed` | `0` | Changes the hole layout |
|
|
173
|
-
| `animated` | `true` | Includes the hover hole animation |
|
|
174
|
-
| `duration` | `900` | Total hole-animation duration in milliseconds |
|
|
175
|
-
| `shadowX` | `18` | Horizontal extrusion offset |
|
|
176
|
-
| `shadowY` | `21` | Vertical extrusion offset |
|
|
177
|
-
| `shadowSteps` | `12` | Number of solid extrusion slices |
|
|
178
|
-
|
|
179
|
-
Available shape keys: `disc`, `round-block`, `hex`, `capsule`, `prism`, `cross`, `triangle`, `diamond`, `sunburst`, `octagon`, `chevron`, `long-bar`, `flower-star`, and `bowtie`.
|
|
180
|
-
|
|
181
|
-
You can also build a picker from the exported metadata:
|
|
21
|
+
Give every user a stable avatar from their ID. `identityHash(id)` returns the 16-digit hex hash that `seededHoleyOptions(id)` derives the shape and seed from:
|
|
182
22
|
|
|
183
23
|
```js
|
|
184
|
-
import {
|
|
24
|
+
import { createHoleySvg, identityHash, seededHoleyOptions } from 'holey-shapes';
|
|
185
25
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
26
|
+
identityHash('relay-agent'); // '0cc0c8aa3dbd893e'
|
|
27
|
+
avatar.innerHTML = createHoleySvg({ ...seededHoleyOptions('relay-agent'), idPrefix: 'relay-agent' });
|
|
189
28
|
```
|
|
190
29
|
|
|
191
|
-
|
|
30
|
+
Pass a unique `idPrefix` to `createHoleySvg()` when adding multiple SVG strings to one document. `mountHoleyShape()` creates isolated IDs automatically.
|
|
192
31
|
|
|
193
|
-
|
|
32
|
+
For the option reference, shape catalog, native packages, and contribution guide, see the [reference](https://github.com/skyaara/holey-shapes/blob/main/docs/REFERENCE.md) and the [project README](https://github.com/skyaara/holey-shapes#readme). Source for this package is in [`packages/js`](https://github.com/skyaara/holey-shapes/tree/main/packages/js).
|
|
194
33
|
|
|
195
|
-
|
|
34
|
+
MIT licensed.
|
package/color.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Colors are chosen in OKLCH, where equal lightness looks equally bright. Every
|
|
2
|
+
// seeded hue then reads as one family, and every extrusion darkens by the same
|
|
3
|
+
// perceived amount. Swift and Kotlin repeat these steps exactly; results are
|
|
4
|
+
// rounded to sRGB hex.
|
|
5
|
+
|
|
6
|
+
const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
7
|
+
const toGamma = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055);
|
|
8
|
+
|
|
9
|
+
function oklabToLinear(L, a, b) {
|
|
10
|
+
const l = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
11
|
+
const m = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
12
|
+
const s = L - 0.0894841775 * a - 1.291485548 * b;
|
|
13
|
+
const l3 = l * l * l, m3 = m * m * m, s3 = s * s * s;
|
|
14
|
+
return [
|
|
15
|
+
4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3,
|
|
16
|
+
-1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3,
|
|
17
|
+
-0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3,
|
|
18
|
+
];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const inGamut = (rgb) => rgb.every((v) => v >= -0.0001 && v <= 1.0001);
|
|
22
|
+
const hex = (rgb) => `#${rgb.map((v) => Math.round(toGamma(Math.min(1, Math.max(0, v))) * 255).toString(16).padStart(2, '0')).join('').toUpperCase()}`;
|
|
23
|
+
|
|
24
|
+
// Keeps lightness and hue, and reduces chroma until the color fits in sRGB.
|
|
25
|
+
function oklabHex(L, a, b) {
|
|
26
|
+
if (inGamut(oklabToLinear(L, a, b))) return hex(oklabToLinear(L, a, b));
|
|
27
|
+
let low = 0, high = 1;
|
|
28
|
+
for (let pass = 0; pass < 24; pass++) {
|
|
29
|
+
const mid = (low + high) / 2;
|
|
30
|
+
if (inGamut(oklabToLinear(L, a * mid, b * mid))) low = mid;
|
|
31
|
+
else high = mid;
|
|
32
|
+
}
|
|
33
|
+
return hex(oklabToLinear(L, a * low, b * low));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Face color for a hue in degrees. Yellows only look clean when lighter, so lightness rises around 100°. */
|
|
37
|
+
export function hueColor(hue) {
|
|
38
|
+
const distance = Math.abs(((hue - 100) % 360 + 540) % 360 - 180);
|
|
39
|
+
const lift = distance < 60 ? Math.pow(Math.cos((distance / 60) * (Math.PI / 2)), 2) : 0;
|
|
40
|
+
const angle = (hue * Math.PI) / 180;
|
|
41
|
+
return oklabHex(0.74 + 0.12 * lift, 0.19 * Math.cos(angle), 0.19 * Math.sin(angle));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Extrusion color for a face: the same hue at 56% of its lightness and 70% of its chroma. */
|
|
45
|
+
export function extrusionColor(face) {
|
|
46
|
+
const [r, g, b] = [1, 3, 5].map((i) => toLinear(parseInt(face.slice(i, i + 2), 16) / 255));
|
|
47
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
48
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
49
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
50
|
+
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
|
|
51
|
+
const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
|
|
52
|
+
const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
|
|
53
|
+
return oklabHex(L * 0.56, A * 0.7, B * 0.7);
|
|
54
|
+
}
|
package/doodle.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { compileShapePath } from "./geometry.js";
|
|
2
|
+
|
|
3
|
+
const ink = "#25262A";
|
|
4
|
+
const n = (value) => String(Math.round(value * 100) / 100);
|
|
5
|
+
const point = ([x, y]) => `${n(x)} ${n(y)}`;
|
|
6
|
+
const contours = new Map();
|
|
7
|
+
|
|
8
|
+
// A wobbly polyline around the outline. Straight segments keep corners crisp.
|
|
9
|
+
function outline(shape, seed) {
|
|
10
|
+
let vertices = contours.get(shape.key);
|
|
11
|
+
if (!vertices) {
|
|
12
|
+
vertices = compileShapePath(shape.path).contour;
|
|
13
|
+
contours.set(shape.key, vertices);
|
|
14
|
+
}
|
|
15
|
+
const phase = seed * 0.017;
|
|
16
|
+
const wobble = ([x, y]) => [
|
|
17
|
+
x + 2.6 * Math.sin(y / 23 + phase) + 1.3 * Math.sin(x / 13 + y / 37),
|
|
18
|
+
y + 2.6 * Math.sin(x / 27 - phase) + 1.3 * Math.cos(y / 17 + x / 31),
|
|
19
|
+
];
|
|
20
|
+
const points = [vertices[0]];
|
|
21
|
+
// Retain polygon corners; decimate the densely flattened curves.
|
|
22
|
+
for (let i = 1; i < vertices.length; i++) {
|
|
23
|
+
const current = vertices[i], previous = vertices[i - 1];
|
|
24
|
+
const next = vertices[(i + 1) % vertices.length];
|
|
25
|
+
const a = Math.atan2(current[1] - previous[1], current[0] - previous[0]);
|
|
26
|
+
const b = Math.atan2(next[1] - current[1], next[0] - current[0]);
|
|
27
|
+
const last = points[points.length - 1];
|
|
28
|
+
const distance = Math.hypot(current[0] - last[0], current[1] - last[1]);
|
|
29
|
+
if (distance < 9 && Math.abs(Math.atan2(Math.sin(b - a), Math.cos(b - a))) < 0.3 && i < vertices.length - 1) continue;
|
|
30
|
+
const steps = Math.max(1, Math.ceil(distance / 10));
|
|
31
|
+
for (let step = 1; step <= steps; step++) {
|
|
32
|
+
points.push([last[0] + ((current[0] - last[0]) * step) / steps, last[1] + ((current[1] - last[1]) * step) / steps]);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return points.map((p, i) => `${i ? "L" : "M"}${point(wobble(p))}`).join("") + "Z";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A slightly irregular closed curve inside each packed hole.
|
|
39
|
+
function cutout(hole, seed, index) {
|
|
40
|
+
const phase = seed * 0.013 + index * 2.4;
|
|
41
|
+
const rotation = (hole.rotate * Math.PI) / 180;
|
|
42
|
+
const points = Array.from({ length: 12 }, (_, i) => {
|
|
43
|
+
const angle = (i * Math.PI) / 6;
|
|
44
|
+
// Shrink before wobbling to stay inside the original packing clearance.
|
|
45
|
+
const radius = 0.92 + 0.045 * Math.sin(angle * 3 + phase) + 0.03 * Math.cos(angle * 2 - phase);
|
|
46
|
+
const x = Math.cos(angle) * hole.rx * radius;
|
|
47
|
+
const y = Math.sin(angle) * hole.ry * radius;
|
|
48
|
+
return [hole.cx + x * Math.cos(rotation) - y * Math.sin(rotation), hole.cy + x * Math.sin(rotation) + y * Math.cos(rotation)];
|
|
49
|
+
});
|
|
50
|
+
let path = `M${point(points[0])}`;
|
|
51
|
+
for (let i = 0; i < points.length; i++) {
|
|
52
|
+
const prev = points[(i + 11) % 12], start = points[i];
|
|
53
|
+
const end = points[(i + 1) % 12], next = points[(i + 2) % 12];
|
|
54
|
+
path += `C${point(start.map((v, j) => v + (end[j] - prev[j]) / 6))} ${point(end.map((v, j) => v - (next[j] - start[j]) / 6))} ${point(end)}`;
|
|
55
|
+
}
|
|
56
|
+
return path + "Z";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createDoodleSvg(shape, holes, options) {
|
|
60
|
+
const seed = options.seed || 0;
|
|
61
|
+
const path = outline(shape, seed);
|
|
62
|
+
const paths = holes.map((hole, index) => cutout(hole, seed, index));
|
|
63
|
+
const duration = Math.max(100, Math.min(10000, Number(options.duration) || 900));
|
|
64
|
+
// The offset shadow is ink by default, or a solid silhouette in the shadow
|
|
65
|
+
// color when one is given, so it reads on dark backgrounds too.
|
|
66
|
+
const shadow = options.doodleShadow ?? ink;
|
|
67
|
+
// Turn and mirror the drawing, not its offset, so the ink shadow keeps its direction.
|
|
68
|
+
const turned = options.transform ? ` transform="${options.transform}"` : "";
|
|
69
|
+
const hole = (d, index, stroke = false) => {
|
|
70
|
+
const mark = `<path d="${d}" fill="${stroke ? "none" : "black"}"${stroke ? ` stroke="${ink}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"` : ""}/>`;
|
|
71
|
+
return options.animated === false
|
|
72
|
+
? mark
|
|
73
|
+
: `<g class="doodle-cutout" style="transform-origin:${n(holes[index].cx)}px ${n(holes[index].cy)}px;--doodle-delay:${index * 27}ms;--doodle-duration:${duration}ms">${mark}</g>`;
|
|
74
|
+
};
|
|
75
|
+
const animation = options.animated === false
|
|
76
|
+
? ""
|
|
77
|
+
: "<style>svg:hover .doodle-cutout{animation:doodle-cutout-pulse var(--doodle-duration) ease-in-out var(--doodle-delay) both}@keyframes doodle-cutout-pulse{0%,100%{transform:scale(1)}50%{transform:scale(.75)}}@media(prefers-reduced-motion:reduce){svg:hover .doodle-cutout{animation:none}}</style>";
|
|
78
|
+
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"/>${paths.map((d, i) => hole(d, i)).join("")}</mask></defs><g stroke="${ink}" stroke-width="7" stroke-linejoin="round" stroke-linecap="round"><g class="doodle-shadow-offset" transform="translate(9 11)"><g class="doodle-shadow"${turned}><path d="${path}" fill="${shadow}" stroke="${shadow}" mask="url(#h)"/></g></g><g class="doodle-face"${turned}><path d="${path}" fill="${shape.color}" mask="url(#h)"/><path d="${path}" fill="none"/>${paths.map((d, i) => hole(d, i, true)).join("")}</g></g></svg>`;
|
|
79
|
+
}
|
package/geometry.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
export function compileShapePath(source) {
|
|
5
5
|
const tokens = source.match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:e[-+]?\d+)?/g) || [];
|
|
6
6
|
const edges = [];
|
|
7
|
+
const contour = [];
|
|
7
8
|
let i = 0, command, x = 0, y = 0, startX = 0, startY = 0;
|
|
8
9
|
const number = () => {
|
|
9
10
|
const value = Number(tokens[i++]);
|
|
@@ -13,6 +14,7 @@ export function compileShapePath(source) {
|
|
|
13
14
|
const line = (nx, ny) => {
|
|
14
15
|
if (y !== ny) edges.push({ x, y, nx, ny });
|
|
15
16
|
x = nx; y = ny;
|
|
17
|
+
contour.push([x, y]);
|
|
16
18
|
};
|
|
17
19
|
function quadratic(x0, y0, cx, cy, x1, y1) {
|
|
18
20
|
// The curve's deviation from its chord is at most half this distance.
|
|
@@ -31,6 +33,7 @@ export function compileShapePath(source) {
|
|
|
31
33
|
switch (command) {
|
|
32
34
|
case 'M':
|
|
33
35
|
x = number(); y = number(); startX = x; startY = y; command = 'L';
|
|
36
|
+
contour.push([x, y]);
|
|
34
37
|
break;
|
|
35
38
|
case 'L': line(number(), number()); break;
|
|
36
39
|
case 'H': line(number(), y); break;
|
|
@@ -74,20 +77,38 @@ export function compileShapePath(source) {
|
|
|
74
77
|
default: throw new Error(`Unsupported shape path command: ${command}`);
|
|
75
78
|
}
|
|
76
79
|
}
|
|
77
|
-
// Index edges by vertical band
|
|
78
|
-
|
|
80
|
+
// Index edges by 1-unit vertical band in flat arrays: the optimizer makes
|
|
81
|
+
// millions of point queries, and each one only scans the few edges that can
|
|
82
|
+
// cross its row. The crossing arithmetic is unchanged, so results are exact.
|
|
83
|
+
let top = Infinity, bottom = -Infinity;
|
|
79
84
|
for (const edge of edges) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
bands.get(band).push(edge);
|
|
83
|
-
}
|
|
85
|
+
top = Math.min(top, edge.y, edge.ny);
|
|
86
|
+
bottom = Math.max(bottom, edge.y, edge.ny);
|
|
84
87
|
}
|
|
88
|
+
const first = Math.floor(top), bandCount = edges.length ? Math.floor(bottom) - first + 1 : 0;
|
|
89
|
+
const bandEdges = Array.from({ length: bandCount }, () => []);
|
|
90
|
+
edges.forEach((edge, index) => {
|
|
91
|
+
for (let band = Math.floor(Math.min(edge.y, edge.ny)); band <= Math.floor(Math.max(edge.y, edge.ny)); band++) {
|
|
92
|
+
bandEdges[band - first].push(index);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
const offsets = new Uint32Array(bandCount + 1);
|
|
96
|
+
bandEdges.forEach((list, band) => { offsets[band + 1] = offsets[band] + list.length; });
|
|
97
|
+
const data = new Float64Array(offsets[bandCount] * 4);
|
|
98
|
+
bandEdges.flat().forEach((index, slot) => {
|
|
99
|
+
const e = edges[index];
|
|
100
|
+
data.set([e.x, e.y, e.nx, e.ny], slot * 4);
|
|
101
|
+
});
|
|
85
102
|
return {
|
|
103
|
+
contour,
|
|
86
104
|
contains(px, py) {
|
|
105
|
+
const band = Math.floor(py) - first;
|
|
106
|
+
if (!(band >= 0 && band < bandCount)) return false;
|
|
87
107
|
let winding = 0;
|
|
88
|
-
for (
|
|
89
|
-
|
|
90
|
-
if (
|
|
108
|
+
for (let k = offsets[band] * 4, end = offsets[band + 1] * 4; k < end; k += 4) {
|
|
109
|
+
const x = data[k], y = data[k + 1], nx = data[k + 2], ny = data[k + 3];
|
|
110
|
+
if ((y > py) === (ny > py)) continue;
|
|
111
|
+
if (px < x + (py - y) * (nx - x) / (ny - y)) winding += ny > y ? 1 : -1;
|
|
91
112
|
}
|
|
92
113
|
return winding !== 0;
|
|
93
114
|
}
|
package/index.d.ts
CHANGED
|
@@ -15,11 +15,20 @@ export type HoleyShapeKey =
|
|
|
15
15
|
| 'bowtie';
|
|
16
16
|
|
|
17
17
|
export interface HoleyOptions {
|
|
18
|
+
/** Hand-drawn outline and fixed ink shadow. */
|
|
19
|
+
doodle?: boolean;
|
|
20
|
+
/** Unique, stable prefix when inserting multiple SVG strings in one document. */
|
|
21
|
+
idPrefix?: string;
|
|
18
22
|
shape?: HoleyShapeKey;
|
|
19
23
|
color?: `#${string}`;
|
|
24
|
+
/** Extrusion color. For doodles, the offset shadow's color; ink when omitted. */
|
|
20
25
|
shadowColor?: `#${string}`;
|
|
21
26
|
holes?: number;
|
|
22
27
|
seed?: number;
|
|
28
|
+
/** Quarter turns of the face: 0, 90, 180 or 270. Other values snap to the nearest. The extrusion keeps its direction. */
|
|
29
|
+
turn?: number;
|
|
30
|
+
/** Flip the face left to right. The extrusion keeps its direction. */
|
|
31
|
+
mirror?: boolean;
|
|
23
32
|
animated?: boolean;
|
|
24
33
|
duration?: number;
|
|
25
34
|
shadowX?: number;
|
|
@@ -41,7 +50,26 @@ export interface HoleyMount {
|
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
export const shapeNames: readonly HoleyShapeInfo[];
|
|
44
|
-
export
|
|
45
|
-
|
|
53
|
+
export interface HoleyShape extends HoleyShapeInfo {
|
|
54
|
+
readonly bg: string;
|
|
55
|
+
readonly initial: number;
|
|
56
|
+
readonly path: string;
|
|
57
|
+
readonly holes: readonly { cx: number; cy: number; rx: number; ry: number; rotate: number }[];
|
|
58
|
+
}
|
|
59
|
+
export const shapes: readonly HoleyShape[];
|
|
60
|
+
/** FNV-1a 64 hash of the UTF-8 ID as 16 lowercase hex digits. `seededHoleyOptions` starts from it. */
|
|
61
|
+
export function identityHash(identity: string): string;
|
|
62
|
+
/** Options derived from an identity. Swift and Kotlin derive the same values. */
|
|
63
|
+
export interface SeededHoleyOptions {
|
|
64
|
+
shape: HoleyShapeKey;
|
|
65
|
+
holes: number;
|
|
66
|
+
seed: number;
|
|
67
|
+
turn: 0 | 90 | 180 | 270;
|
|
68
|
+
mirror: boolean;
|
|
69
|
+
color: `#${string}`;
|
|
70
|
+
shadowColor: `#${string}`;
|
|
71
|
+
animated: boolean;
|
|
72
|
+
}
|
|
73
|
+
export function seededHoleyOptions(identity: string, animated?: boolean): SeededHoleyOptions;
|
|
46
74
|
export function createHoleySvg(options?: HoleyOptions): string;
|
|
47
75
|
export function mountHoleyShape(target: string | Element, options?: HoleyOptions): HoleyMount;
|
package/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { createHoleySvg, mountHoleyShape, seededHoleyOptions, shapeNames, shapes } from './script.js';
|
|
1
|
+
export { createHoleySvg, identityHash, mountHoleyShape, seededHoleyOptions, shapeNames, shapes } from './script.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "holey-shapes",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Colorful perforated SVG shapes with solid extrusion and animated holes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
},
|
|
12
12
|
"repository": {
|
|
13
13
|
"type": "git",
|
|
14
|
-
"url": "git+https://github.com/skyaara/holey-shapes.git"
|
|
14
|
+
"url": "git+https://github.com/skyaara/holey-shapes.git",
|
|
15
|
+
"directory": "packages/js"
|
|
15
16
|
},
|
|
16
17
|
"homepage": "https://github.com/skyaara/holey-shapes#readme",
|
|
17
18
|
"bugs": {
|
|
@@ -32,6 +33,8 @@
|
|
|
32
33
|
"index.js",
|
|
33
34
|
"script.js",
|
|
34
35
|
"geometry.js",
|
|
36
|
+
"doodle.js",
|
|
37
|
+
"color.js",
|
|
35
38
|
"index.d.ts",
|
|
36
39
|
"README.md",
|
|
37
40
|
"LICENSE"
|
|
@@ -55,13 +58,7 @@
|
|
|
55
58
|
"access": "public"
|
|
56
59
|
},
|
|
57
60
|
"scripts": {
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"preview": "vite preview --host 127.0.0.1",
|
|
61
|
-
"test": "node tests/package.test.mjs && node tests/packing.test.mjs && node tests/site.test.mjs",
|
|
62
|
-
"prepublishOnly": "npm test && npm run build"
|
|
63
|
-
},
|
|
64
|
-
"devDependencies": {
|
|
65
|
-
"vite": "^7.1.3"
|
|
61
|
+
"test": "node tests/package.test.mjs && node tests/packing.test.mjs",
|
|
62
|
+
"prepublishOnly": "npm test"
|
|
66
63
|
}
|
|
67
64
|
}
|
package/script.js
CHANGED
|
@@ -1,51 +1,10 @@
|
|
|
1
1
|
import { compileShapePath } from './geometry.js';
|
|
2
|
+
import { extrusionColor, hueColor } from './color.js';
|
|
3
|
+
import { createDoodleSvg } from './doodle.js';
|
|
2
4
|
|
|
3
5
|
const h = (cx, cy, rx, ry, rotate = 0) => ({ cx, cy, rx, ry, rotate });
|
|
4
6
|
|
|
5
7
|
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
6
|
-
const root = isBrowser ? document.documentElement : null;
|
|
7
|
-
const isHoleyApp = root?.hasAttribute('data-holey-app');
|
|
8
|
-
const themeToggle = isHoleyApp ? document.querySelector('.theme-toggle') : null;
|
|
9
|
-
const themeLabel = themeToggle?.querySelector('.theme-label');
|
|
10
|
-
|
|
11
|
-
function applyTheme(theme) {
|
|
12
|
-
if (!themeToggle || !themeLabel) return;
|
|
13
|
-
const isDark = theme === 'dark';
|
|
14
|
-
root.dataset.theme = theme;
|
|
15
|
-
themeToggle.setAttribute('aria-pressed', String(isDark));
|
|
16
|
-
themeToggle.setAttribute('aria-label', `Switch to ${isDark ? 'light' : 'dark'} mode`);
|
|
17
|
-
themeLabel.textContent = isDark ? 'LIGHT' : 'DARK';
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
if (themeToggle) {
|
|
21
|
-
const savedTheme = localStorage.getItem('holey-theme');
|
|
22
|
-
const initialTheme = savedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
23
|
-
applyTheme(initialTheme);
|
|
24
|
-
themeToggle.addEventListener('click', () => {
|
|
25
|
-
const nextTheme = root.dataset.theme === 'dark' ? 'light' : 'dark';
|
|
26
|
-
applyTheme(nextTheme);
|
|
27
|
-
localStorage.setItem('holey-theme', nextTheme);
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const installButton = isHoleyApp ? document.querySelector('.install-command') : null;
|
|
32
|
-
|
|
33
|
-
if (installButton) {
|
|
34
|
-
installButton.addEventListener('click', async () => {
|
|
35
|
-
const label = installButton.querySelector('.install-copy-label');
|
|
36
|
-
try {
|
|
37
|
-
await navigator.clipboard.writeText('npm install holey-shapes');
|
|
38
|
-
if (label) label.textContent = 'COPIED';
|
|
39
|
-
window.setTimeout(() => {
|
|
40
|
-
if (label) label.textContent = 'COPY';
|
|
41
|
-
}, 1400);
|
|
42
|
-
} catch {
|
|
43
|
-
if (label) label.textContent = 'SELECT';
|
|
44
|
-
window.getSelection()?.selectAllChildren(installButton.querySelector('code'));
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
8
|
export const shapes = [
|
|
50
9
|
{
|
|
51
10
|
key: 'disc', name: 'Perforated disc', color: '#6337FF', bg: '#D7CEFF', initial: 5,
|
|
@@ -119,12 +78,10 @@ export const shapes = [
|
|
|
119
78
|
}
|
|
120
79
|
];
|
|
121
80
|
|
|
122
|
-
const collection = isHoleyApp ? document.querySelector('#collection') : null;
|
|
123
81
|
const MAX_HOLES = 8;
|
|
124
82
|
const SHADOW_X = 18;
|
|
125
83
|
const SHADOW_Y = 21;
|
|
126
84
|
const SHADOW_STEPS = 12;
|
|
127
|
-
const states = new Map();
|
|
128
85
|
const packingCache = new Map();
|
|
129
86
|
|
|
130
87
|
const holeMarkup = ({ cx, cy, rx, ry, rotate }) =>
|
|
@@ -138,11 +95,6 @@ function hoverHoleMarkup(hole, index, seed = 0, duration = 900) {
|
|
|
138
95
|
return `<g class="hole-hover" style="--hole-duration:${pulseDuration}ms;--hole-delay:${delay}ms">${holeMarkup(hole)}</g>`;
|
|
139
96
|
}
|
|
140
97
|
|
|
141
|
-
function darkShadow(color, factor = 0.52) {
|
|
142
|
-
const value = color.replace('#', '');
|
|
143
|
-
const channels = [0, 2, 4].map((offset) => parseInt(value.slice(offset, offset + 2), 16));
|
|
144
|
-
return `#${channels.map((channel) => Math.round(channel * factor).toString(16).padStart(2, '0')).join('')}`;
|
|
145
|
-
}
|
|
146
98
|
|
|
147
99
|
function circleFits(path, cx, cy, radius) {
|
|
148
100
|
for (let step = 0; step < 32; step += 1) {
|
|
@@ -263,6 +215,10 @@ function maximizeHoleAreas(shape, holes) {
|
|
|
263
215
|
}
|
|
264
216
|
return low;
|
|
265
217
|
}
|
|
218
|
+
function fitsAt(point, profile, radius) {
|
|
219
|
+
return radius < 180 && path.contains(point.x, point.y) && profile.edge.every(e => path.contains(
|
|
220
|
+
point.x + radius * e.x + (gap + 1) * e.nx, point.y + radius * e.y + (gap + 1) * e.ny));
|
|
221
|
+
}
|
|
266
222
|
const boundaries = points.map((p, i) => boundaryLimit(p, profiles[i]));
|
|
267
223
|
function capacity(point, index, boundary) {
|
|
268
224
|
let limit = boundary;
|
|
@@ -286,8 +242,12 @@ function maximizeHoleAreas(shape, holes) {
|
|
|
286
242
|
let bestLimit = capacity(point, i, bestBoundary);
|
|
287
243
|
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) {
|
|
288
244
|
const candidate = { x: point.x + dx * step, y: point.y + dy * step };
|
|
245
|
+
// A move wins only if both limits beat the threshold, so reject on
|
|
246
|
+
// the cheap separation test and a single rim test before the search.
|
|
247
|
+
const separation = capacity(candidate, i, Infinity);
|
|
248
|
+
if (separation <= bestLimit + 0.02 || !fitsAt(candidate, profiles[i], bestLimit + 0.02)) continue;
|
|
289
249
|
const boundary = boundaryLimit(candidate, profiles[i]);
|
|
290
|
-
const limit =
|
|
250
|
+
const limit = Math.min(separation, boundary);
|
|
291
251
|
if (limit > bestLimit + 0.02) {
|
|
292
252
|
best = candidate; bestBoundary = boundary; bestLimit = limit;
|
|
293
253
|
}
|
|
@@ -380,12 +340,6 @@ function initialHoleLayout(shape, count, seed) {
|
|
|
380
340
|
}));
|
|
381
341
|
}
|
|
382
342
|
|
|
383
|
-
function svgMarkup(shape, holes, className, maskSuffix, accessible = false) {
|
|
384
|
-
const maskId = `mask-${shape.key}-${maskSuffix}`;
|
|
385
|
-
const fill = className === 'shape-shadow' ? darkShadow(shape.color) : shape.color;
|
|
386
|
-
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"'}><defs><mask id="${maskId}"><rect width="420" height="420" fill="white"/>${holes.map(holeMarkup).join('')}</mask></defs><path d="${shape.path}" fill="${fill}" mask="url(#${maskId})"/></svg>`;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
343
|
function compactNumber(value) {
|
|
390
344
|
const rounded = Math.round(value * 100) / 100;
|
|
391
345
|
return String(Object.is(rounded, -0) ? 0 : rounded);
|
|
@@ -395,7 +349,7 @@ function extrusionFilterMarkup(shape, filterId, options = {}) {
|
|
|
395
349
|
const shadowX = options.shadowX ?? SHADOW_X;
|
|
396
350
|
const shadowY = options.shadowY ?? SHADOW_Y;
|
|
397
351
|
const shadowSteps = options.shadowSteps ?? SHADOW_STEPS;
|
|
398
|
-
const shadowColor = options.shadowColor ??
|
|
352
|
+
const shadowColor = options.shadowColor ?? extrusionColor(shape.color);
|
|
399
353
|
const offsets = Array.from({ length: shadowSteps }, (_, index) => {
|
|
400
354
|
const step = index + 1;
|
|
401
355
|
const ratio = step / shadowSteps;
|
|
@@ -407,23 +361,14 @@ function extrusionFilterMarkup(shape, filterId, options = {}) {
|
|
|
407
361
|
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>`;
|
|
408
362
|
}
|
|
409
363
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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>`;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
function renderShape(card, shape, state) {
|
|
420
|
-
const holes = packedHoleLayout(shape, state.count, state.seed);
|
|
421
|
-
card.querySelector('.shape-art').innerHTML = objectMarkup(shape, holes, 'card', true, { seed: state.seed });
|
|
422
|
-
card.querySelector('.hole-count').textContent = `${String(state.count).padStart(2, '0')} / ${String(state.limit).padStart(2, '0')}`;
|
|
423
|
-
card.querySelector('.hole-range').value = state.count;
|
|
364
|
+
// Quarter turns and mirroring apply to the face only. The extrusion is drawn
|
|
365
|
+
// around it in unturned space, so every holey's depth points the same way.
|
|
366
|
+
function orientation(turn = 0, mirror = false) {
|
|
367
|
+
return [turn ? `rotate(${turn} 210 210)` : '', mirror ? 'translate(420 0) scale(-1 1)' : ''].filter(Boolean).join(' ');
|
|
424
368
|
}
|
|
425
369
|
|
|
426
370
|
function exportSvg(shape, holes, options = {}) {
|
|
371
|
+
if (options.doodle) return createDoodleSvg(shape, holes, options);
|
|
427
372
|
const filter = extrusionFilterMarkup(shape, 'e', options);
|
|
428
373
|
const duration = Math.max(100, Math.min(10000, Number(options.duration) || 900));
|
|
429
374
|
const seed = Math.trunc(Number(options.seed) || 0);
|
|
@@ -433,7 +378,7 @@ function exportSvg(shape, holes, options = {}) {
|
|
|
433
378
|
const animation = options.animated === false
|
|
434
379
|
? ''
|
|
435
380
|
: `<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>`;
|
|
381
|
+
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${options.transform ? ` transform="${options.transform}"` : ''} d="${shape.path}" fill="${shape.color}" mask="url(#h)"/></g></svg>`;
|
|
437
382
|
}
|
|
438
383
|
|
|
439
384
|
function normalizeColor(color, fallback) {
|
|
@@ -441,29 +386,58 @@ function normalizeColor(color, fallback) {
|
|
|
441
386
|
}
|
|
442
387
|
|
|
443
388
|
function numberInRange(value, fallback, min, max) {
|
|
389
|
+
if (value === null || value === undefined || value === '') return fallback;
|
|
444
390
|
const number = Number(value);
|
|
445
391
|
return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
|
|
446
392
|
}
|
|
447
393
|
|
|
448
|
-
export const shapeNames = Object.freeze(shapes.map(({ key, name, color }) => ({ key, name, color })));
|
|
394
|
+
export const shapeNames = Object.freeze(shapes.map(({ key, name, color }) => Object.freeze({ key, name, color })));
|
|
449
395
|
|
|
450
|
-
|
|
451
|
-
|
|
396
|
+
// Callers inserting several SVG strings can supply a stable, unique prefix.
|
|
397
|
+
// Encoding each code point avoids collisions between punctuation and underscores.
|
|
398
|
+
function scopeSvg(source, prefix) {
|
|
399
|
+
if (prefix === undefined) return source;
|
|
400
|
+
const id = `holey-${Array.from(String(prefix), c => c.codePointAt(0).toString(16)).join('-') || 'empty'}`;
|
|
401
|
+
return source.replaceAll('id="h"', `id="${id}-h"`).replaceAll('url(#h)', `url(#${id}-h)`)
|
|
402
|
+
.replaceAll('id="e"', `id="${id}-e"`).replaceAll('url(#e)', `url(#${id}-e)`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function fnv1a64(identity) {
|
|
452
406
|
let hash = 14695981039346656037n;
|
|
453
|
-
for (const byte of
|
|
407
|
+
for (const byte of new TextEncoder().encode(String(identity))) {
|
|
454
408
|
hash = BigInt.asUintN(64, (hash ^ BigInt(byte)) * 1099511628211n);
|
|
455
409
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
410
|
+
return hash;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function identityHash(identity) {
|
|
414
|
+
return fnv1a64(identity).toString(16).padStart(16, '0');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Seeding: the identity hash is mixed once, with a bijection so no bits are
|
|
418
|
+
// lost, then read as mixed-radix digits, so every field gets its own bits.
|
|
419
|
+
function mix64(value) {
|
|
420
|
+
value = BigInt.asUintN(64, (value ^ (value >> 30n)) * 0xBF58476D1CE4E5B9n);
|
|
421
|
+
value = BigInt.asUintN(64, (value ^ (value >> 27n)) * 0x94D049BB133111EBn);
|
|
422
|
+
return value ^ (value >> 31n);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function seededHoleyOptions(identity, animated = true) {
|
|
426
|
+
let rest = mix64(fnv1a64(identity));
|
|
427
|
+
const take = (count) => {
|
|
428
|
+
const digit = Number(rest % BigInt(count));
|
|
429
|
+
rest /= BigInt(count);
|
|
430
|
+
return digit;
|
|
463
431
|
};
|
|
464
|
-
const shape = shapes[
|
|
465
|
-
const
|
|
466
|
-
|
|
432
|
+
const shape = shapes[take(shapes.length)];
|
|
433
|
+
const fewest = Math.max(2, shape.initial - 1);
|
|
434
|
+
const most = Math.min(MAX_HOLES, shape.holes.length, shape.initial + 1);
|
|
435
|
+
const holes = fewest + take(most - fewest + 1);
|
|
436
|
+
const turn = take(4) * 90;
|
|
437
|
+
const mirror = take(2) === 1;
|
|
438
|
+
const color = hueColor(take(3600) / 10);
|
|
439
|
+
const seed = take(1000001);
|
|
440
|
+
return { shape: shape.key, holes, seed, turn, mirror, color, shadowColor: extrusionColor(color), animated };
|
|
467
441
|
}
|
|
468
442
|
|
|
469
443
|
export function createHoleySvg(options = {}) {
|
|
@@ -475,19 +449,25 @@ export function createHoleySvg(options = {}) {
|
|
|
475
449
|
const shadowX = numberInRange(options.shadowX, SHADOW_X, -80, 80);
|
|
476
450
|
const shadowY = numberInRange(options.shadowY, SHADOW_Y, -80, 80);
|
|
477
451
|
const shadowSteps = Math.round(numberInRange(options.shadowSteps, SHADOW_STEPS, 1, 32));
|
|
478
|
-
const shadowColor = normalizeColor(options.shadowColor,
|
|
452
|
+
const shadowColor = normalizeColor(options.shadowColor, extrusionColor(color));
|
|
453
|
+
const turn = ((Math.round(numberInRange(options.turn, 0, -36000, 36000) / 90) % 4) + 4) % 4 * 90;
|
|
479
454
|
const holes = packedHoleLayout(shape, holeCount, seed);
|
|
480
|
-
return exportSvg(shape, holes, {
|
|
455
|
+
return scopeSvg(exportSvg(shape, holes, {
|
|
456
|
+
doodle: options.doodle === true,
|
|
457
|
+
transform: orientation(turn, options.mirror === true),
|
|
481
458
|
animated: options.animated,
|
|
482
459
|
duration: options.duration,
|
|
483
460
|
seed,
|
|
484
461
|
shadowColor,
|
|
462
|
+
// Doodles keep their ink shadow unless a shadow color is asked for.
|
|
463
|
+
doodleShadow: options.shadowColor === undefined ? undefined : shadowColor,
|
|
485
464
|
shadowX,
|
|
486
465
|
shadowY,
|
|
487
466
|
shadowSteps
|
|
488
|
-
});
|
|
467
|
+
}), options.idPrefix);
|
|
489
468
|
}
|
|
490
469
|
|
|
470
|
+
let mountSequence = 0;
|
|
491
471
|
export function mountHoleyShape(target, options = {}) {
|
|
492
472
|
const element = typeof target === 'string' && isBrowser ? document.querySelector(target) : target;
|
|
493
473
|
if (!element || typeof element.replaceChildren !== 'function') {
|
|
@@ -495,10 +475,18 @@ export function mountHoleyShape(target, options = {}) {
|
|
|
495
475
|
}
|
|
496
476
|
|
|
497
477
|
let currentOptions = { ...options };
|
|
478
|
+
const idPrefix = `mount-${++mountSequence}`;
|
|
479
|
+
const ownerDocument = element.ownerDocument;
|
|
480
|
+
let mounted;
|
|
481
|
+
let destroyed = false;
|
|
498
482
|
const render = () => {
|
|
499
|
-
|
|
500
|
-
template
|
|
501
|
-
|
|
483
|
+
if (destroyed) return;
|
|
484
|
+
const template = ownerDocument.createElement('template');
|
|
485
|
+
template.innerHTML = createHoleySvg({ ...currentOptions, idPrefix }).trim();
|
|
486
|
+
const next = template.content.firstElementChild;
|
|
487
|
+
if (mounted?.parentNode === element) mounted.replaceWith(next);
|
|
488
|
+
else element.append(next);
|
|
489
|
+
mounted = next;
|
|
502
490
|
};
|
|
503
491
|
render();
|
|
504
492
|
|
|
@@ -509,149 +497,13 @@ export function mountHoleyShape(target, options = {}) {
|
|
|
509
497
|
render();
|
|
510
498
|
},
|
|
511
499
|
shuffle() {
|
|
512
|
-
|
|
500
|
+
const seed = Math.trunc(numberInRange(currentOptions.seed, 0, -1000000, 1000000));
|
|
501
|
+
currentOptions.seed = seed >= 1000000 ? -1000000 : seed + 1;
|
|
513
502
|
render();
|
|
514
503
|
},
|
|
515
504
|
destroy() {
|
|
516
|
-
|
|
505
|
+
destroyed = true;
|
|
506
|
+
mounted?.remove();
|
|
517
507
|
}
|
|
518
508
|
};
|
|
519
509
|
}
|
|
520
|
-
|
|
521
|
-
function downloadSvg(shape, holes) {
|
|
522
|
-
const source = exportSvg(shape, holes);
|
|
523
|
-
const url = URL.createObjectURL(new Blob([source], { type: 'image/svg+xml' }));
|
|
524
|
-
const link = document.createElement('a');
|
|
525
|
-
link.href = url;
|
|
526
|
-
link.download = `${shape.key}-${holes.length}-holes.svg`;
|
|
527
|
-
link.click();
|
|
528
|
-
URL.revokeObjectURL(url);
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
if (collection) shapes.forEach((shape, index) => {
|
|
532
|
-
const number = String(index + 1).padStart(2, '0');
|
|
533
|
-
const holeLimit = Math.min(MAX_HOLES, shape.holes.length);
|
|
534
|
-
const wide = index === 0 || index === shapes.length - 1;
|
|
535
|
-
const card = document.createElement('article');
|
|
536
|
-
card.className = `form-card${wide ? ' form-card--wide' : ''}`;
|
|
537
|
-
card.dataset.index = number;
|
|
538
|
-
card.innerHTML = `
|
|
539
|
-
<div class="card-meta"><span>${number}</span><h2>${shape.name}</h2><span>MAX ${String(holeLimit).padStart(2, '0')}</span></div>
|
|
540
|
-
<div class="stage" style="background:${shape.bg}">
|
|
541
|
-
<div class="shape-art${wide ? ' shape-art--large' : ''}"></div>
|
|
542
|
-
${index === 0 ? '<span class="orbit-line" aria-hidden="true"></span>' : ''}
|
|
543
|
-
${index === shapes.length - 1 ? '<span class="crosshair" aria-hidden="true"></span>' : ''}
|
|
544
|
-
</div>
|
|
545
|
-
<div class="card-actions">
|
|
546
|
-
<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>
|
|
547
|
-
<button class="shuffle" type="button">SHUFFLE ↻</button>
|
|
548
|
-
<button class="download" type="button">GET SVG <span>↓</span></button>
|
|
549
|
-
</div>`;
|
|
550
|
-
collection.append(card);
|
|
551
|
-
const state = { count: Math.min(shape.initial, holeLimit), seed: 0, limit: holeLimit };
|
|
552
|
-
states.set(shape.key, state);
|
|
553
|
-
renderShape(card, shape, state);
|
|
554
|
-
|
|
555
|
-
card.querySelector('.hole-range').addEventListener('input', (event) => {
|
|
556
|
-
state.count = Number(event.target.value);
|
|
557
|
-
renderShape(card, shape, state);
|
|
558
|
-
});
|
|
559
|
-
card.querySelector('.shuffle').addEventListener('click', () => {
|
|
560
|
-
state.seed += 1;
|
|
561
|
-
renderShape(card, shape, state);
|
|
562
|
-
});
|
|
563
|
-
card.querySelector('.download').addEventListener('click', () => {
|
|
564
|
-
downloadSvg(shape, packedHoleLayout(shape, state.count, state.seed));
|
|
565
|
-
});
|
|
566
|
-
});
|
|
567
|
-
|
|
568
|
-
const presentApp = isHoleyApp ? document.querySelector('#present-app') : null;
|
|
569
|
-
|
|
570
|
-
if (presentApp) {
|
|
571
|
-
const art = document.querySelector('#present-art');
|
|
572
|
-
const shapePicker = document.querySelector('#shape-picker');
|
|
573
|
-
const colorPicker = document.querySelector('#color-picker');
|
|
574
|
-
const range = document.querySelector('#present-holes');
|
|
575
|
-
const count = document.querySelector('#present-count');
|
|
576
|
-
const selectedName = document.querySelector('#selected-name');
|
|
577
|
-
const shuffle = document.querySelector('#present-shuffle');
|
|
578
|
-
const download = document.querySelector('#present-download');
|
|
579
|
-
const canvas = document.querySelector('.present-canvas');
|
|
580
|
-
const palette = ['#6337FF', '#9BED00', '#FF0878', '#2878FF', '#FF6A00', '#00DBFF', '#FFD000', '#C94DFF', '#00D7B9'];
|
|
581
|
-
const presentState = { index: 0, count: shapes[0].initial, seed: 0, color: shapes[0].color };
|
|
582
|
-
|
|
583
|
-
const activeShape = () => ({ ...shapes[presentState.index], color: presentState.color });
|
|
584
|
-
|
|
585
|
-
function syncPresentControls() {
|
|
586
|
-
const shape = shapes[presentState.index];
|
|
587
|
-
const limit = Math.min(MAX_HOLES, shape.holes.length);
|
|
588
|
-
selectedName.textContent = shape.name;
|
|
589
|
-
range.max = limit;
|
|
590
|
-
range.value = presentState.count;
|
|
591
|
-
count.textContent = `${String(presentState.count).padStart(2, '0')} / ${String(limit).padStart(2, '0')}`;
|
|
592
|
-
shapePicker.querySelectorAll('.shape-option').forEach((button, index) => {
|
|
593
|
-
button.classList.toggle('is-active', index === presentState.index);
|
|
594
|
-
button.setAttribute('aria-pressed', String(index === presentState.index));
|
|
595
|
-
});
|
|
596
|
-
colorPicker.querySelectorAll('.color-option').forEach((button) => {
|
|
597
|
-
const active = button.dataset.color === presentState.color;
|
|
598
|
-
button.classList.toggle('is-active', active);
|
|
599
|
-
button.setAttribute('aria-pressed', String(active));
|
|
600
|
-
});
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
function renderPresent() {
|
|
604
|
-
const shape = activeShape();
|
|
605
|
-
const holes = packedHoleLayout(shape, presentState.count, presentState.seed);
|
|
606
|
-
art.innerHTML = objectMarkup(shape, holes, 'present', true, { seed: presentState.seed });
|
|
607
|
-
syncPresentControls();
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
shapes.forEach((shape, index) => {
|
|
611
|
-
const button = document.createElement('button');
|
|
612
|
-
const previewHoles = packedHoleLayout(shape, Math.min(3, shape.holes.length), 0);
|
|
613
|
-
button.className = 'shape-option';
|
|
614
|
-
button.type = 'button';
|
|
615
|
-
button.setAttribute('aria-label', shape.name);
|
|
616
|
-
button.innerHTML = `<span class="shape-thumb">${svgMarkup(shape, previewHoles, 'picker-shape', `picker-${index}`)}</span><span>${String(index + 1).padStart(2, '0')}</span>`;
|
|
617
|
-
button.addEventListener('click', () => {
|
|
618
|
-
presentState.index = index;
|
|
619
|
-
presentState.count = Math.min(shape.initial, MAX_HOLES, shape.holes.length);
|
|
620
|
-
presentState.seed = 0;
|
|
621
|
-
presentState.color = shape.color;
|
|
622
|
-
renderPresent();
|
|
623
|
-
});
|
|
624
|
-
shapePicker.append(button);
|
|
625
|
-
});
|
|
626
|
-
|
|
627
|
-
palette.forEach((color) => {
|
|
628
|
-
const button = document.createElement('button');
|
|
629
|
-
button.className = 'color-option';
|
|
630
|
-
button.type = 'button';
|
|
631
|
-
button.dataset.color = color;
|
|
632
|
-
button.style.setProperty('--swatch', color);
|
|
633
|
-
button.setAttribute('aria-label', `Use color ${color}`);
|
|
634
|
-
button.addEventListener('click', () => {
|
|
635
|
-
presentState.color = color;
|
|
636
|
-
renderPresent();
|
|
637
|
-
});
|
|
638
|
-
colorPicker.append(button);
|
|
639
|
-
});
|
|
640
|
-
|
|
641
|
-
range.addEventListener('input', () => {
|
|
642
|
-
presentState.count = Number(range.value);
|
|
643
|
-
renderPresent();
|
|
644
|
-
});
|
|
645
|
-
|
|
646
|
-
shuffle.addEventListener('click', () => {
|
|
647
|
-
presentState.seed += 1;
|
|
648
|
-
renderPresent();
|
|
649
|
-
});
|
|
650
|
-
|
|
651
|
-
download.addEventListener('click', () => {
|
|
652
|
-
const shape = activeShape();
|
|
653
|
-
downloadSvg(shape, packedHoleLayout(shape, presentState.count, presentState.seed));
|
|
654
|
-
});
|
|
655
|
-
|
|
656
|
-
renderPresent();
|
|
657
|
-
}
|