holey-shapes 1.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 -144
- package/color.js +54 -0
- package/doodle.js +79 -0
- package/geometry.js +116 -0
- package/index.d.ts +30 -2
- package/index.js +1 -1
- package/package.json +8 -10
- package/script.js +95 -261
package/README.md
CHANGED
|
@@ -1,164 +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: "0.1.4")
|
|
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: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.
|
|
83
|
-
|
|
84
|
-
## Render an SVG string
|
|
85
|
-
|
|
86
11
|
```js
|
|
87
|
-
import { createHoleySvg } from 'holey-shapes';
|
|
12
|
+
import { createHoleySvg, mountHoleyShape } from 'holey-shapes';
|
|
88
13
|
|
|
89
|
-
const svg = createHoleySvg({
|
|
90
|
-
|
|
91
|
-
color: '#FF4FA3',
|
|
92
|
-
shadowColor: '#7A1748',
|
|
93
|
-
holes: 6,
|
|
94
|
-
seed: 2
|
|
95
|
-
});
|
|
14
|
+
const svg = createHoleySvg({ shape: 'prism', color: '#FF4FA3', holes: 6, seed: 2 });
|
|
15
|
+
document.querySelector('#preview').innerHTML = svg;
|
|
96
16
|
|
|
97
|
-
|
|
17
|
+
const mounted = mountHoleyShape('#avatar', { shape: 'disc', holes: 5 });
|
|
18
|
+
mounted.update({ color: '#00D7B9' });
|
|
98
19
|
```
|
|
99
20
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
## Mount and update a shape
|
|
103
|
-
|
|
104
|
-
```js
|
|
105
|
-
import { mountHoleyShape } from 'holey-shapes';
|
|
106
|
-
|
|
107
|
-
const shape = mountHoleyShape('#shape', {
|
|
108
|
-
shape: 'disc',
|
|
109
|
-
color: '#6337FF',
|
|
110
|
-
holes: 5
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
shape.update({ color: '#00D7B9', holes: 8 });
|
|
114
|
-
shape.shuffle();
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
`mountHoleyShape()` returns `update()`, `shuffle()`, and `destroy()` methods. Updates replace only the mounted SVG.
|
|
118
|
-
|
|
119
|
-
The generated SVG scales to its container:
|
|
120
|
-
|
|
121
|
-
```css
|
|
122
|
-
#shape {
|
|
123
|
-
width: 320px;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
#shape svg {
|
|
127
|
-
display: block;
|
|
128
|
-
width: 100%;
|
|
129
|
-
height: auto;
|
|
130
|
-
}
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
## Options
|
|
134
|
-
|
|
135
|
-
| Option | Default | Description |
|
|
136
|
-
| --- | --- | --- |
|
|
137
|
-
| `shape` | `disc` | One of the keys in `shapeNames` |
|
|
138
|
-
| `color` | Shape color | Six-digit hex face color |
|
|
139
|
-
| `shadowColor` | Darker face color | Six-digit hex extrusion color |
|
|
140
|
-
| `holes` | Shape default | Hole count from 0 to 8 |
|
|
141
|
-
| `seed` | `0` | Changes the hole layout |
|
|
142
|
-
| `animated` | `true` | Includes the hover hole animation |
|
|
143
|
-
| `duration` | `900` | Total hole-animation duration in milliseconds |
|
|
144
|
-
| `shadowX` | `18` | Horizontal extrusion offset |
|
|
145
|
-
| `shadowY` | `21` | Vertical extrusion offset |
|
|
146
|
-
| `shadowSteps` | `12` | Number of solid extrusion slices |
|
|
147
|
-
|
|
148
|
-
Available shape keys: `disc`, `round-block`, `hex`, `capsule`, `prism`, `cross`, `triangle`, `diamond`, `sunburst`, `octagon`, `chevron`, `long-bar`, `flower-star`, and `bowtie`.
|
|
149
|
-
|
|
150
|
-
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:
|
|
151
22
|
|
|
152
23
|
```js
|
|
153
|
-
import {
|
|
24
|
+
import { createHoleySvg, identityHash, seededHoleyOptions } from 'holey-shapes';
|
|
154
25
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
26
|
+
identityHash('relay-agent'); // '0cc0c8aa3dbd893e'
|
|
27
|
+
avatar.innerHTML = createHoleySvg({ ...seededHoleyOptions('relay-agent'), idPrefix: 'relay-agent' });
|
|
158
28
|
```
|
|
159
29
|
|
|
160
|
-
|
|
30
|
+
Pass a unique `idPrefix` to `createHoleySvg()` when adding multiple SVG strings to one document. `mountHoleyShape()` creates isolated IDs automatically.
|
|
161
31
|
|
|
162
|
-
|
|
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).
|
|
163
33
|
|
|
164
|
-
|
|
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
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
const contour = [];
|
|
8
|
+
let i = 0, command, x = 0, y = 0, startX = 0, startY = 0;
|
|
9
|
+
const number = () => {
|
|
10
|
+
const value = Number(tokens[i++]);
|
|
11
|
+
if (!Number.isFinite(value)) throw new Error('Invalid shape path');
|
|
12
|
+
return value;
|
|
13
|
+
};
|
|
14
|
+
const line = (nx, ny) => {
|
|
15
|
+
if (y !== ny) edges.push({ x, y, nx, ny });
|
|
16
|
+
x = nx; y = ny;
|
|
17
|
+
contour.push([x, y]);
|
|
18
|
+
};
|
|
19
|
+
function quadratic(x0, y0, cx, cy, x1, y1) {
|
|
20
|
+
// The curve's deviation from its chord is at most half this distance.
|
|
21
|
+
if (Math.hypot(cx - (x0 + x1) / 2, cy - (y0 + y1) / 2) <= 0.02) {
|
|
22
|
+
line(x1, y1);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const ax = (x0 + cx) / 2, ay = (y0 + cy) / 2;
|
|
26
|
+
const bx = (cx + x1) / 2, by = (cy + y1) / 2;
|
|
27
|
+
const mx = (ax + bx) / 2, my = (ay + by) / 2;
|
|
28
|
+
quadratic(x0, y0, ax, ay, mx, my);
|
|
29
|
+
quadratic(mx, my, bx, by, x1, y1);
|
|
30
|
+
}
|
|
31
|
+
while (i < tokens.length) {
|
|
32
|
+
if (/^[a-zA-Z]$/.test(tokens[i])) command = tokens[i++];
|
|
33
|
+
switch (command) {
|
|
34
|
+
case 'M':
|
|
35
|
+
x = number(); y = number(); startX = x; startY = y; command = 'L';
|
|
36
|
+
contour.push([x, y]);
|
|
37
|
+
break;
|
|
38
|
+
case 'L': line(number(), number()); break;
|
|
39
|
+
case 'H': line(number(), y); break;
|
|
40
|
+
case 'V': line(x, number()); break;
|
|
41
|
+
case 'Q': {
|
|
42
|
+
const cx = number(), cy = number(), nx = number(), ny = number();
|
|
43
|
+
quadratic(x, y, cx, cy, nx, ny);
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
case 'A': {
|
|
47
|
+
let rx = Math.abs(number()), ry = Math.abs(number());
|
|
48
|
+
const rotation = number() * Math.PI / 180;
|
|
49
|
+
const large = number(), sweep = number(), nx = number(), ny = number();
|
|
50
|
+
if (!rx || !ry) { line(nx, ny); break; }
|
|
51
|
+
if (x === nx && y === ny) break;
|
|
52
|
+
const c = Math.cos(rotation), s = Math.sin(rotation);
|
|
53
|
+
const dx = (x - nx) / 2, dy = (y - ny) / 2;
|
|
54
|
+
const px = c * dx + s * dy, py = -s * dx + c * dy;
|
|
55
|
+
const scale = Math.hypot(px / rx, py / ry);
|
|
56
|
+
if (scale > 1) { rx *= scale; ry *= scale; }
|
|
57
|
+
const factor = (large === sweep ? -1 : 1) * Math.sqrt(Math.max(0,
|
|
58
|
+
(rx * rx * ry * ry - rx * rx * py * py - ry * ry * px * px) /
|
|
59
|
+
(rx * rx * py * py + ry * ry * px * px)));
|
|
60
|
+
const cx = factor * rx * py / ry, cy = -factor * ry * px / rx;
|
|
61
|
+
const centerX = c * cx - s * cy + (x + nx) / 2;
|
|
62
|
+
const centerY = s * cx + c * cy + (y + ny) / 2;
|
|
63
|
+
const angle = Math.atan2((py - cy) / ry, (px - cx) / rx);
|
|
64
|
+
let delta = Math.atan2((-py - cy) / ry, (-px - cx) / rx) - angle;
|
|
65
|
+
if (sweep && delta < 0) delta += Math.PI * 2;
|
|
66
|
+
if (!sweep && delta > 0) delta -= Math.PI * 2;
|
|
67
|
+
const steps = Math.ceil(Math.abs(delta) / (2 * Math.acos(1 - Math.min(1, 0.01 / Math.max(rx, ry)))));
|
|
68
|
+
for (let step = 1; step < steps; step++) {
|
|
69
|
+
const a = angle + delta * step / steps;
|
|
70
|
+
line(centerX + c * rx * Math.cos(a) - s * ry * Math.sin(a),
|
|
71
|
+
centerY + s * rx * Math.cos(a) + c * ry * Math.sin(a));
|
|
72
|
+
}
|
|
73
|
+
line(nx, ny);
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
case 'Z': line(startX, startY); command = null; break;
|
|
77
|
+
default: throw new Error(`Unsupported shape path command: ${command}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
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;
|
|
84
|
+
for (const edge of edges) {
|
|
85
|
+
top = Math.min(top, edge.y, edge.ny);
|
|
86
|
+
bottom = Math.max(bottom, edge.y, edge.ny);
|
|
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
|
+
});
|
|
102
|
+
return {
|
|
103
|
+
contour,
|
|
104
|
+
contains(px, py) {
|
|
105
|
+
const band = Math.floor(py) - first;
|
|
106
|
+
if (!(band >= 0 && band < bandCount)) return false;
|
|
107
|
+
let winding = 0;
|
|
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;
|
|
112
|
+
}
|
|
113
|
+
return winding !== 0;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
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": "1.0
|
|
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": {
|
|
@@ -31,6 +32,9 @@
|
|
|
31
32
|
"files": [
|
|
32
33
|
"index.js",
|
|
33
34
|
"script.js",
|
|
35
|
+
"geometry.js",
|
|
36
|
+
"doodle.js",
|
|
37
|
+
"color.js",
|
|
34
38
|
"index.d.ts",
|
|
35
39
|
"README.md",
|
|
36
40
|
"LICENSE"
|
|
@@ -54,13 +58,7 @@
|
|
|
54
58
|
"access": "public"
|
|
55
59
|
},
|
|
56
60
|
"scripts": {
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"preview": "vite preview --host 127.0.0.1",
|
|
60
|
-
"test": "node tests/package.test.mjs && node tests/site.test.mjs",
|
|
61
|
-
"prepublishOnly": "npm test && npm run build"
|
|
62
|
-
},
|
|
63
|
-
"devDependencies": {
|
|
64
|
-
"vite": "^7.1.3"
|
|
61
|
+
"test": "node tests/package.test.mjs && node tests/packing.test.mjs",
|
|
62
|
+
"prepublishOnly": "npm test"
|
|
65
63
|
}
|
|
66
64
|
}
|
package/script.js
CHANGED
|
@@ -1,49 +1,10 @@
|
|
|
1
|
+
import { compileShapePath } from './geometry.js';
|
|
2
|
+
import { extrusionColor, hueColor } from './color.js';
|
|
3
|
+
import { createDoodleSvg } from './doodle.js';
|
|
4
|
+
|
|
1
5
|
const h = (cx, cy, rx, ry, rotate = 0) => ({ cx, cy, rx, ry, rotate });
|
|
2
6
|
|
|
3
7
|
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
4
|
-
const root = isBrowser ? document.documentElement : null;
|
|
5
|
-
const isHoleyApp = root?.hasAttribute('data-holey-app');
|
|
6
|
-
const themeToggle = isHoleyApp ? document.querySelector('.theme-toggle') : null;
|
|
7
|
-
const themeLabel = themeToggle?.querySelector('.theme-label');
|
|
8
|
-
|
|
9
|
-
function applyTheme(theme) {
|
|
10
|
-
if (!themeToggle || !themeLabel) return;
|
|
11
|
-
const isDark = theme === 'dark';
|
|
12
|
-
root.dataset.theme = theme;
|
|
13
|
-
themeToggle.setAttribute('aria-pressed', String(isDark));
|
|
14
|
-
themeToggle.setAttribute('aria-label', `Switch to ${isDark ? 'light' : 'dark'} mode`);
|
|
15
|
-
themeLabel.textContent = isDark ? 'LIGHT' : 'DARK';
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
if (themeToggle) {
|
|
19
|
-
const savedTheme = localStorage.getItem('holey-theme');
|
|
20
|
-
const initialTheme = savedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
21
|
-
applyTheme(initialTheme);
|
|
22
|
-
themeToggle.addEventListener('click', () => {
|
|
23
|
-
const nextTheme = root.dataset.theme === 'dark' ? 'light' : 'dark';
|
|
24
|
-
applyTheme(nextTheme);
|
|
25
|
-
localStorage.setItem('holey-theme', nextTheme);
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const installButton = isHoleyApp ? document.querySelector('.install-command') : null;
|
|
30
|
-
|
|
31
|
-
if (installButton) {
|
|
32
|
-
installButton.addEventListener('click', async () => {
|
|
33
|
-
const label = installButton.querySelector('.install-copy-label');
|
|
34
|
-
try {
|
|
35
|
-
await navigator.clipboard.writeText('npm install holey-shapes');
|
|
36
|
-
if (label) label.textContent = 'COPIED';
|
|
37
|
-
window.setTimeout(() => {
|
|
38
|
-
if (label) label.textContent = 'COPY';
|
|
39
|
-
}, 1400);
|
|
40
|
-
} catch {
|
|
41
|
-
if (label) label.textContent = 'SELECT';
|
|
42
|
-
window.getSelection()?.selectAllChildren(installButton.querySelector('code'));
|
|
43
|
-
}
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
|
|
47
8
|
export const shapes = [
|
|
48
9
|
{
|
|
49
10
|
key: 'disc', name: 'Perforated disc', color: '#6337FF', bg: '#D7CEFF', initial: 5,
|
|
@@ -117,23 +78,11 @@ export const shapes = [
|
|
|
117
78
|
}
|
|
118
79
|
];
|
|
119
80
|
|
|
120
|
-
const collection = isHoleyApp ? document.querySelector('#collection') : null;
|
|
121
81
|
const MAX_HOLES = 8;
|
|
122
82
|
const SHADOW_X = 18;
|
|
123
83
|
const SHADOW_Y = 21;
|
|
124
84
|
const SHADOW_STEPS = 12;
|
|
125
|
-
const states = new Map();
|
|
126
|
-
let packingContext = null;
|
|
127
85
|
const packingCache = new Map();
|
|
128
|
-
function getPackingContext() {
|
|
129
|
-
if (packingContext) return packingContext;
|
|
130
|
-
if (!isBrowser || typeof Path2D === 'undefined') return null;
|
|
131
|
-
const packingCanvas = document.createElement('canvas');
|
|
132
|
-
packingCanvas.width = 420;
|
|
133
|
-
packingCanvas.height = 420;
|
|
134
|
-
packingContext = packingCanvas.getContext('2d');
|
|
135
|
-
return packingContext;
|
|
136
|
-
}
|
|
137
86
|
|
|
138
87
|
const holeMarkup = ({ cx, cy, rx, ry, rotate }) =>
|
|
139
88
|
`<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}"${rotate ? ` transform="rotate(${rotate} ${cx} ${cy})"` : ''}/>`;
|
|
@@ -146,27 +95,19 @@ function hoverHoleMarkup(hole, index, seed = 0, duration = 900) {
|
|
|
146
95
|
return `<g class="hole-hover" style="--hole-duration:${pulseDuration}ms;--hole-delay:${delay}ms">${holeMarkup(hole)}</g>`;
|
|
147
96
|
}
|
|
148
97
|
|
|
149
|
-
function darkShadow(color, factor = 0.52) {
|
|
150
|
-
const value = color.replace('#', '');
|
|
151
|
-
const channels = [0, 2, 4].map((offset) => parseInt(value.slice(offset, offset + 2), 16));
|
|
152
|
-
return `#${channels.map((channel) => Math.round(channel * factor).toString(16).padStart(2, '0')).join('')}`;
|
|
153
|
-
}
|
|
154
98
|
|
|
155
99
|
function circleFits(path, cx, cy, radius) {
|
|
156
|
-
const context = getPackingContext();
|
|
157
|
-
if (!context) return false;
|
|
158
100
|
for (let step = 0; step < 32; step += 1) {
|
|
159
101
|
const angle = (Math.PI * 2 * step) / 32;
|
|
160
102
|
const x = cx + Math.cos(angle) * radius;
|
|
161
103
|
const y = cy + Math.sin(angle) * radius;
|
|
162
|
-
if (!
|
|
104
|
+
if (!path.contains(x, y)) return false;
|
|
163
105
|
}
|
|
164
106
|
return true;
|
|
165
107
|
}
|
|
166
108
|
|
|
167
109
|
function boundaryClearance(path, cx, cy) {
|
|
168
|
-
|
|
169
|
-
if (!context || !context.isPointInPath(path, cx, cy)) return 0;
|
|
110
|
+
if (!path.contains(cx, cy)) return 0;
|
|
170
111
|
let low = 0;
|
|
171
112
|
let high = 180;
|
|
172
113
|
for (let pass = 0; pass < 9; pass += 1) {
|
|
@@ -179,13 +120,11 @@ function boundaryClearance(path, cx, cy) {
|
|
|
179
120
|
|
|
180
121
|
function packingCandidates(shape) {
|
|
181
122
|
if (packingCache.has(shape.key)) return packingCache.get(shape.key);
|
|
182
|
-
const
|
|
183
|
-
if (!context) return { path: null, candidates: [] };
|
|
184
|
-
const path = new Path2D(shape.path);
|
|
123
|
+
const path = compileShapePath(shape.path);
|
|
185
124
|
const candidates = [];
|
|
186
125
|
for (let y = 50; y <= 370; y += 12) {
|
|
187
126
|
for (let x = 50; x <= 370; x += 12) {
|
|
188
|
-
if (!
|
|
127
|
+
if (!path.contains(x, y)) continue;
|
|
189
128
|
const clearance = boundaryClearance(path, x, y);
|
|
190
129
|
if (clearance >= 17) candidates.push({ x, y, clearance });
|
|
191
130
|
}
|
|
@@ -245,9 +184,7 @@ const optimizedLayoutCache = new Map();
|
|
|
245
184
|
|
|
246
185
|
function maximizeHoleAreas(shape, holes) {
|
|
247
186
|
if (!holes.length) return holes;
|
|
248
|
-
const context = getPackingContext();
|
|
249
187
|
const { path } = packingCandidates(shape);
|
|
250
|
-
if (!context || !path) return holes;
|
|
251
188
|
const gap = 12;
|
|
252
189
|
const profiles = holes.map(hole => {
|
|
253
190
|
const a = Math.sqrt(hole.rx / hole.ry);
|
|
@@ -267,17 +204,21 @@ function maximizeHoleAreas(shape, holes) {
|
|
|
267
204
|
});
|
|
268
205
|
const points = holes.map(h => ({ x: h.cx, y: h.cy }));
|
|
269
206
|
function boundaryLimit(point, profile) {
|
|
270
|
-
if (!
|
|
207
|
+
if (!path.contains(point.x, point.y)) return 0;
|
|
271
208
|
let low = 0, high = 180;
|
|
272
209
|
for (let pass = 0; pass < 11; pass++) {
|
|
273
210
|
const radius = (low + high) / 2;
|
|
274
211
|
// An extra rim unit covers the space between perimeter samples.
|
|
275
|
-
const fits = profile.edge.every(e =>
|
|
212
|
+
const fits = profile.edge.every(e => path.contains(
|
|
276
213
|
point.x + radius * e.x + (gap + 1) * e.nx, point.y + radius * e.y + (gap + 1) * e.ny));
|
|
277
214
|
if (fits) low = radius; else high = radius;
|
|
278
215
|
}
|
|
279
216
|
return low;
|
|
280
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
|
+
}
|
|
281
222
|
const boundaries = points.map((p, i) => boundaryLimit(p, profiles[i]));
|
|
282
223
|
function capacity(point, index, boundary) {
|
|
283
224
|
let limit = boundary;
|
|
@@ -301,8 +242,12 @@ function maximizeHoleAreas(shape, holes) {
|
|
|
301
242
|
let bestLimit = capacity(point, i, bestBoundary);
|
|
302
243
|
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) {
|
|
303
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;
|
|
304
249
|
const boundary = boundaryLimit(candidate, profiles[i]);
|
|
305
|
-
const limit =
|
|
250
|
+
const limit = Math.min(separation, boundary);
|
|
306
251
|
if (limit > bestLimit + 0.02) {
|
|
307
252
|
best = candidate; bestBoundary = boundary; bestLimit = limit;
|
|
308
253
|
}
|
|
@@ -330,10 +275,7 @@ function packedHoleLayout(shape, count, seed) {
|
|
|
330
275
|
function initialHoleLayout(shape, count, seed) {
|
|
331
276
|
if (count === 0) return [];
|
|
332
277
|
const { candidates } = packingCandidates(shape);
|
|
333
|
-
if (
|
|
334
|
-
const start = ((Math.trunc(seed) % shape.holes.length) + shape.holes.length) % shape.holes.length;
|
|
335
|
-
return equalizeHoleAreas(Array.from({ length: count }, (_, index) => ({ ...shape.holes[(start + index) % shape.holes.length] })));
|
|
336
|
-
}
|
|
278
|
+
if (candidates.length < count) throw new Error(`Insufficient packing space for ${shape.key}`);
|
|
337
279
|
const maximumClearance = Math.max(...candidates.map(({ clearance }) => clearance));
|
|
338
280
|
const starterPool = candidates.filter(({ clearance }) => clearance >= maximumClearance * 0.72);
|
|
339
281
|
const first = seed === 0
|
|
@@ -381,7 +323,7 @@ function initialHoleLayout(shape, count, seed) {
|
|
|
381
323
|
? Infinity
|
|
382
324
|
: Math.min(...selected.filter((_, otherIndex) => otherIndex !== index).map((other) =>
|
|
383
325
|
Math.hypot(point.x - other.x, point.y - other.y)));
|
|
384
|
-
const style = shape.holes[(index + seed) % shape.holes.length];
|
|
326
|
+
const style = shape.holes[((index + seed) % shape.holes.length + shape.holes.length) % shape.holes.length];
|
|
385
327
|
const styleRadius = Math.max(style.rx, style.ry);
|
|
386
328
|
const aspectArea = (style.rx / styleRadius) * (style.ry / styleRadius);
|
|
387
329
|
const territoryArea = cells[index].length * 144;
|
|
@@ -398,12 +340,6 @@ function initialHoleLayout(shape, count, seed) {
|
|
|
398
340
|
}));
|
|
399
341
|
}
|
|
400
342
|
|
|
401
|
-
function svgMarkup(shape, holes, className, maskSuffix, accessible = false) {
|
|
402
|
-
const maskId = `mask-${shape.key}-${maskSuffix}`;
|
|
403
|
-
const fill = className === 'shape-shadow' ? darkShadow(shape.color) : shape.color;
|
|
404
|
-
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>`;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
343
|
function compactNumber(value) {
|
|
408
344
|
const rounded = Math.round(value * 100) / 100;
|
|
409
345
|
return String(Object.is(rounded, -0) ? 0 : rounded);
|
|
@@ -413,7 +349,7 @@ function extrusionFilterMarkup(shape, filterId, options = {}) {
|
|
|
413
349
|
const shadowX = options.shadowX ?? SHADOW_X;
|
|
414
350
|
const shadowY = options.shadowY ?? SHADOW_Y;
|
|
415
351
|
const shadowSteps = options.shadowSteps ?? SHADOW_STEPS;
|
|
416
|
-
const shadowColor = options.shadowColor ??
|
|
352
|
+
const shadowColor = options.shadowColor ?? extrusionColor(shape.color);
|
|
417
353
|
const offsets = Array.from({ length: shadowSteps }, (_, index) => {
|
|
418
354
|
const step = index + 1;
|
|
419
355
|
const ratio = step / shadowSteps;
|
|
@@ -425,23 +361,14 @@ function extrusionFilterMarkup(shape, filterId, options = {}) {
|
|
|
425
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>`;
|
|
426
362
|
}
|
|
427
363
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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>`;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
function renderShape(card, shape, state) {
|
|
438
|
-
const holes = packedHoleLayout(shape, state.count, state.seed);
|
|
439
|
-
card.querySelector('.shape-art').innerHTML = objectMarkup(shape, holes, 'card', true, { seed: state.seed });
|
|
440
|
-
card.querySelector('.hole-count').textContent = `${String(state.count).padStart(2, '0')} / ${String(state.limit).padStart(2, '0')}`;
|
|
441
|
-
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(' ');
|
|
442
368
|
}
|
|
443
369
|
|
|
444
370
|
function exportSvg(shape, holes, options = {}) {
|
|
371
|
+
if (options.doodle) return createDoodleSvg(shape, holes, options);
|
|
445
372
|
const filter = extrusionFilterMarkup(shape, 'e', options);
|
|
446
373
|
const duration = Math.max(100, Math.min(10000, Number(options.duration) || 900));
|
|
447
374
|
const seed = Math.trunc(Number(options.seed) || 0);
|
|
@@ -451,7 +378,7 @@ function exportSvg(shape, holes, options = {}) {
|
|
|
451
378
|
const animation = options.animated === false
|
|
452
379
|
? ''
|
|
453
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>`;
|
|
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>`;
|
|
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>`;
|
|
455
382
|
}
|
|
456
383
|
|
|
457
384
|
function normalizeColor(color, fallback) {
|
|
@@ -459,29 +386,58 @@ function normalizeColor(color, fallback) {
|
|
|
459
386
|
}
|
|
460
387
|
|
|
461
388
|
function numberInRange(value, fallback, min, max) {
|
|
389
|
+
if (value === null || value === undefined || value === '') return fallback;
|
|
462
390
|
const number = Number(value);
|
|
463
391
|
return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
|
|
464
392
|
}
|
|
465
393
|
|
|
466
|
-
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 })));
|
|
467
395
|
|
|
468
|
-
|
|
469
|
-
|
|
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) {
|
|
470
406
|
let hash = 14695981039346656037n;
|
|
471
|
-
for (const byte of
|
|
407
|
+
for (const byte of new TextEncoder().encode(String(identity))) {
|
|
472
408
|
hash = BigInt.asUintN(64, (hash ^ BigInt(byte)) * 1099511628211n);
|
|
473
409
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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;
|
|
481
431
|
};
|
|
482
|
-
const shape = shapes[
|
|
483
|
-
const
|
|
484
|
-
|
|
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 };
|
|
485
441
|
}
|
|
486
442
|
|
|
487
443
|
export function createHoleySvg(options = {}) {
|
|
@@ -493,19 +449,25 @@ export function createHoleySvg(options = {}) {
|
|
|
493
449
|
const shadowX = numberInRange(options.shadowX, SHADOW_X, -80, 80);
|
|
494
450
|
const shadowY = numberInRange(options.shadowY, SHADOW_Y, -80, 80);
|
|
495
451
|
const shadowSteps = Math.round(numberInRange(options.shadowSteps, SHADOW_STEPS, 1, 32));
|
|
496
|
-
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;
|
|
497
454
|
const holes = packedHoleLayout(shape, holeCount, seed);
|
|
498
|
-
return exportSvg(shape, holes, {
|
|
455
|
+
return scopeSvg(exportSvg(shape, holes, {
|
|
456
|
+
doodle: options.doodle === true,
|
|
457
|
+
transform: orientation(turn, options.mirror === true),
|
|
499
458
|
animated: options.animated,
|
|
500
459
|
duration: options.duration,
|
|
501
460
|
seed,
|
|
502
461
|
shadowColor,
|
|
462
|
+
// Doodles keep their ink shadow unless a shadow color is asked for.
|
|
463
|
+
doodleShadow: options.shadowColor === undefined ? undefined : shadowColor,
|
|
503
464
|
shadowX,
|
|
504
465
|
shadowY,
|
|
505
466
|
shadowSteps
|
|
506
|
-
});
|
|
467
|
+
}), options.idPrefix);
|
|
507
468
|
}
|
|
508
469
|
|
|
470
|
+
let mountSequence = 0;
|
|
509
471
|
export function mountHoleyShape(target, options = {}) {
|
|
510
472
|
const element = typeof target === 'string' && isBrowser ? document.querySelector(target) : target;
|
|
511
473
|
if (!element || typeof element.replaceChildren !== 'function') {
|
|
@@ -513,10 +475,18 @@ export function mountHoleyShape(target, options = {}) {
|
|
|
513
475
|
}
|
|
514
476
|
|
|
515
477
|
let currentOptions = { ...options };
|
|
478
|
+
const idPrefix = `mount-${++mountSequence}`;
|
|
479
|
+
const ownerDocument = element.ownerDocument;
|
|
480
|
+
let mounted;
|
|
481
|
+
let destroyed = false;
|
|
516
482
|
const render = () => {
|
|
517
|
-
|
|
518
|
-
template
|
|
519
|
-
|
|
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;
|
|
520
490
|
};
|
|
521
491
|
render();
|
|
522
492
|
|
|
@@ -527,149 +497,13 @@ export function mountHoleyShape(target, options = {}) {
|
|
|
527
497
|
render();
|
|
528
498
|
},
|
|
529
499
|
shuffle() {
|
|
530
|
-
|
|
500
|
+
const seed = Math.trunc(numberInRange(currentOptions.seed, 0, -1000000, 1000000));
|
|
501
|
+
currentOptions.seed = seed >= 1000000 ? -1000000 : seed + 1;
|
|
531
502
|
render();
|
|
532
503
|
},
|
|
533
504
|
destroy() {
|
|
534
|
-
|
|
505
|
+
destroyed = true;
|
|
506
|
+
mounted?.remove();
|
|
535
507
|
}
|
|
536
508
|
};
|
|
537
509
|
}
|
|
538
|
-
|
|
539
|
-
function downloadSvg(shape, holes) {
|
|
540
|
-
const source = exportSvg(shape, holes);
|
|
541
|
-
const url = URL.createObjectURL(new Blob([source], { type: 'image/svg+xml' }));
|
|
542
|
-
const link = document.createElement('a');
|
|
543
|
-
link.href = url;
|
|
544
|
-
link.download = `${shape.key}-${holes.length}-holes.svg`;
|
|
545
|
-
link.click();
|
|
546
|
-
URL.revokeObjectURL(url);
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
if (collection) shapes.forEach((shape, index) => {
|
|
550
|
-
const number = String(index + 1).padStart(2, '0');
|
|
551
|
-
const holeLimit = Math.min(MAX_HOLES, shape.holes.length);
|
|
552
|
-
const wide = index === 0 || index === shapes.length - 1;
|
|
553
|
-
const card = document.createElement('article');
|
|
554
|
-
card.className = `form-card${wide ? ' form-card--wide' : ''}`;
|
|
555
|
-
card.dataset.index = number;
|
|
556
|
-
card.innerHTML = `
|
|
557
|
-
<div class="card-meta"><span>${number}</span><h2>${shape.name}</h2><span>MAX ${String(holeLimit).padStart(2, '0')}</span></div>
|
|
558
|
-
<div class="stage" style="background:${shape.bg}">
|
|
559
|
-
<div class="shape-art${wide ? ' shape-art--large' : ''}"></div>
|
|
560
|
-
${index === 0 ? '<span class="orbit-line" aria-hidden="true"></span>' : ''}
|
|
561
|
-
${index === shapes.length - 1 ? '<span class="crosshair" aria-hidden="true"></span>' : ''}
|
|
562
|
-
</div>
|
|
563
|
-
<div class="card-actions">
|
|
564
|
-
<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>
|
|
565
|
-
<button class="shuffle" type="button">SHUFFLE ↻</button>
|
|
566
|
-
<button class="download" type="button">GET SVG <span>↓</span></button>
|
|
567
|
-
</div>`;
|
|
568
|
-
collection.append(card);
|
|
569
|
-
const state = { count: Math.min(shape.initial, holeLimit), seed: 0, limit: holeLimit };
|
|
570
|
-
states.set(shape.key, state);
|
|
571
|
-
renderShape(card, shape, state);
|
|
572
|
-
|
|
573
|
-
card.querySelector('.hole-range').addEventListener('input', (event) => {
|
|
574
|
-
state.count = Number(event.target.value);
|
|
575
|
-
renderShape(card, shape, state);
|
|
576
|
-
});
|
|
577
|
-
card.querySelector('.shuffle').addEventListener('click', () => {
|
|
578
|
-
state.seed += 1;
|
|
579
|
-
renderShape(card, shape, state);
|
|
580
|
-
});
|
|
581
|
-
card.querySelector('.download').addEventListener('click', () => {
|
|
582
|
-
downloadSvg(shape, packedHoleLayout(shape, state.count, state.seed));
|
|
583
|
-
});
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
const presentApp = isHoleyApp ? document.querySelector('#present-app') : null;
|
|
587
|
-
|
|
588
|
-
if (presentApp) {
|
|
589
|
-
const art = document.querySelector('#present-art');
|
|
590
|
-
const shapePicker = document.querySelector('#shape-picker');
|
|
591
|
-
const colorPicker = document.querySelector('#color-picker');
|
|
592
|
-
const range = document.querySelector('#present-holes');
|
|
593
|
-
const count = document.querySelector('#present-count');
|
|
594
|
-
const selectedName = document.querySelector('#selected-name');
|
|
595
|
-
const shuffle = document.querySelector('#present-shuffle');
|
|
596
|
-
const download = document.querySelector('#present-download');
|
|
597
|
-
const canvas = document.querySelector('.present-canvas');
|
|
598
|
-
const palette = ['#6337FF', '#9BED00', '#FF0878', '#2878FF', '#FF6A00', '#00DBFF', '#FFD000', '#C94DFF', '#00D7B9'];
|
|
599
|
-
const presentState = { index: 0, count: shapes[0].initial, seed: 0, color: shapes[0].color };
|
|
600
|
-
|
|
601
|
-
const activeShape = () => ({ ...shapes[presentState.index], color: presentState.color });
|
|
602
|
-
|
|
603
|
-
function syncPresentControls() {
|
|
604
|
-
const shape = shapes[presentState.index];
|
|
605
|
-
const limit = Math.min(MAX_HOLES, shape.holes.length);
|
|
606
|
-
selectedName.textContent = shape.name;
|
|
607
|
-
range.max = limit;
|
|
608
|
-
range.value = presentState.count;
|
|
609
|
-
count.textContent = `${String(presentState.count).padStart(2, '0')} / ${String(limit).padStart(2, '0')}`;
|
|
610
|
-
shapePicker.querySelectorAll('.shape-option').forEach((button, index) => {
|
|
611
|
-
button.classList.toggle('is-active', index === presentState.index);
|
|
612
|
-
button.setAttribute('aria-pressed', String(index === presentState.index));
|
|
613
|
-
});
|
|
614
|
-
colorPicker.querySelectorAll('.color-option').forEach((button) => {
|
|
615
|
-
const active = button.dataset.color === presentState.color;
|
|
616
|
-
button.classList.toggle('is-active', active);
|
|
617
|
-
button.setAttribute('aria-pressed', String(active));
|
|
618
|
-
});
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
function renderPresent() {
|
|
622
|
-
const shape = activeShape();
|
|
623
|
-
const holes = packedHoleLayout(shape, presentState.count, presentState.seed);
|
|
624
|
-
art.innerHTML = objectMarkup(shape, holes, 'present', true, { seed: presentState.seed });
|
|
625
|
-
syncPresentControls();
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
shapes.forEach((shape, index) => {
|
|
629
|
-
const button = document.createElement('button');
|
|
630
|
-
const previewHoles = packedHoleLayout(shape, Math.min(3, shape.holes.length), 0);
|
|
631
|
-
button.className = 'shape-option';
|
|
632
|
-
button.type = 'button';
|
|
633
|
-
button.setAttribute('aria-label', shape.name);
|
|
634
|
-
button.innerHTML = `<span class="shape-thumb">${svgMarkup(shape, previewHoles, 'picker-shape', `picker-${index}`)}</span><span>${String(index + 1).padStart(2, '0')}</span>`;
|
|
635
|
-
button.addEventListener('click', () => {
|
|
636
|
-
presentState.index = index;
|
|
637
|
-
presentState.count = Math.min(shape.initial, MAX_HOLES, shape.holes.length);
|
|
638
|
-
presentState.seed = 0;
|
|
639
|
-
presentState.color = shape.color;
|
|
640
|
-
renderPresent();
|
|
641
|
-
});
|
|
642
|
-
shapePicker.append(button);
|
|
643
|
-
});
|
|
644
|
-
|
|
645
|
-
palette.forEach((color) => {
|
|
646
|
-
const button = document.createElement('button');
|
|
647
|
-
button.className = 'color-option';
|
|
648
|
-
button.type = 'button';
|
|
649
|
-
button.dataset.color = color;
|
|
650
|
-
button.style.setProperty('--swatch', color);
|
|
651
|
-
button.setAttribute('aria-label', `Use color ${color}`);
|
|
652
|
-
button.addEventListener('click', () => {
|
|
653
|
-
presentState.color = color;
|
|
654
|
-
renderPresent();
|
|
655
|
-
});
|
|
656
|
-
colorPicker.append(button);
|
|
657
|
-
});
|
|
658
|
-
|
|
659
|
-
range.addEventListener('input', () => {
|
|
660
|
-
presentState.count = Number(range.value);
|
|
661
|
-
renderPresent();
|
|
662
|
-
});
|
|
663
|
-
|
|
664
|
-
shuffle.addEventListener('click', () => {
|
|
665
|
-
presentState.seed += 1;
|
|
666
|
-
renderPresent();
|
|
667
|
-
});
|
|
668
|
-
|
|
669
|
-
download.addEventListener('click', () => {
|
|
670
|
-
const shape = activeShape();
|
|
671
|
-
downloadSvg(shape, packedHoleLayout(shape, presentState.count, presentState.seed));
|
|
672
|
-
});
|
|
673
|
-
|
|
674
|
-
renderPresent();
|
|
675
|
-
}
|