holey-shapes 0.1.0 → 0.1.1
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 +1 -1
- package/package.json +8 -2
- package/script.js +90 -23
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Solid, perforated SVG shapes with a connected extrusion and a built-in hover spin. Pick from 15 shapes, set the face and shadow colors, and control the hole layout. No framework or stylesheet required.
|
|
4
4
|
|
|
5
|
-
[npm package](https://www.npmjs.com/package/holey-shapes) · [source](https://github.com/skyaara/holey-shapes)
|
|
5
|
+
[live playground](https://holey-shapes.aakashreddy.com) · [npm package](https://www.npmjs.com/package/holey-shapes) · [source](https://github.com/skyaara/holey-shapes)
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "holey-shapes",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Colorful perforated SVG shapes with solid extrusion and hover animation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"main": "./index.js",
|
|
21
21
|
"types": "./index.d.ts",
|
|
22
|
+
"sideEffects": false,
|
|
22
23
|
"exports": {
|
|
23
24
|
".": {
|
|
24
25
|
"types": "./index.d.ts",
|
|
@@ -36,8 +37,13 @@
|
|
|
36
37
|
],
|
|
37
38
|
"keywords": [
|
|
38
39
|
"svg",
|
|
40
|
+
"svg-generator",
|
|
41
|
+
"svg-animation",
|
|
39
42
|
"shapes",
|
|
40
43
|
"animation",
|
|
44
|
+
"perforated",
|
|
45
|
+
"extrusion",
|
|
46
|
+
"creative-coding",
|
|
41
47
|
"generative-art",
|
|
42
48
|
"web"
|
|
43
49
|
],
|
|
@@ -51,7 +57,7 @@
|
|
|
51
57
|
"dev": "vite --host 127.0.0.1",
|
|
52
58
|
"build": "vite build",
|
|
53
59
|
"preview": "vite preview --host 127.0.0.1",
|
|
54
|
-
"test": "node tests/package.test.mjs",
|
|
60
|
+
"test": "node tests/package.test.mjs && node tests/site.test.mjs",
|
|
55
61
|
"prepublishOnly": "npm test && npm run build"
|
|
56
62
|
},
|
|
57
63
|
"devDependencies": {
|
package/script.js
CHANGED
|
@@ -26,6 +26,24 @@ if (themeToggle) {
|
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
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
|
+
|
|
29
47
|
export const shapes = [
|
|
30
48
|
{
|
|
31
49
|
key: 'disc', name: 'Perforated disc', color: '#6337FF', bg: '#D7CEFF', initial: 5,
|
|
@@ -112,6 +130,37 @@ const SHADOW_STEPS = 12;
|
|
|
112
130
|
const states = new Map();
|
|
113
131
|
let packingContext = null;
|
|
114
132
|
const packingCache = new Map();
|
|
133
|
+
const SHOWCASE_MOTIONS = [
|
|
134
|
+
['scatter', '4.2s', '-1.4s'],
|
|
135
|
+
['magnet', '4.6s', '-3.2s'],
|
|
136
|
+
['blinkwave', '3.7s', '-2.1s'],
|
|
137
|
+
['orbit', '5.2s', '-.6s'],
|
|
138
|
+
['hop', '3.5s', '-1.9s'],
|
|
139
|
+
['scan', '4.3s', '-.8s'],
|
|
140
|
+
['wobble', '4.8s', '-2.4s'],
|
|
141
|
+
['pinch', '3.9s', '-1.1s'],
|
|
142
|
+
['carousel', '5.6s', '-4.1s'],
|
|
143
|
+
['heartbeat', '3.8s', '-2.2s'],
|
|
144
|
+
['peek', '4.5s', '-3.5s'],
|
|
145
|
+
['shuffle', '4.1s', '-1.7s'],
|
|
146
|
+
['tumble', '4.7s', '-3.3s'],
|
|
147
|
+
['ripple', '3.6s', '-1.2s'],
|
|
148
|
+
['breathe', '5s', '-2.5s']
|
|
149
|
+
];
|
|
150
|
+
const showcaseObserver = isBrowser && typeof IntersectionObserver === 'function'
|
|
151
|
+
? new IntersectionObserver((entries) => {
|
|
152
|
+
entries.forEach((entry) => entry.target.classList.toggle('is-showcase-visible', entry.isIntersecting));
|
|
153
|
+
}, { rootMargin: '120px 0px', threshold: 0.05 })
|
|
154
|
+
: null;
|
|
155
|
+
|
|
156
|
+
function applyShowcaseMotion(element, index) {
|
|
157
|
+
const [name, duration, delay] = SHOWCASE_MOTIONS[index % SHOWCASE_MOTIONS.length];
|
|
158
|
+
element.dataset.motion = name;
|
|
159
|
+
element.style.setProperty('--motion-duration', duration);
|
|
160
|
+
element.style.setProperty('--motion-delay', delay);
|
|
161
|
+
if (showcaseObserver) showcaseObserver.observe(element);
|
|
162
|
+
else element.classList.add('is-showcase-visible');
|
|
163
|
+
}
|
|
115
164
|
|
|
116
165
|
function getPackingContext() {
|
|
117
166
|
if (packingContext) return packingContext;
|
|
@@ -124,7 +173,32 @@ function getPackingContext() {
|
|
|
124
173
|
}
|
|
125
174
|
|
|
126
175
|
const holeMarkup = ({ cx, cy, rx, ry, rotate }) =>
|
|
127
|
-
`<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}"${rotate ? ` transform="rotate(${rotate} ${cx} ${cy})"` : ''}
|
|
176
|
+
`<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}"${rotate ? ` transform="rotate(${rotate} ${cx} ${cy})"` : ''}/>`;
|
|
177
|
+
|
|
178
|
+
const showcaseHoleMarkup = (hole, index) => {
|
|
179
|
+
const angle = (index * 137.5 * Math.PI) / 180;
|
|
180
|
+
const scatterX = Math.round(Math.cos(angle) * 17);
|
|
181
|
+
const scatterY = Math.round(Math.sin(angle) * 17);
|
|
182
|
+
const inwardX = Math.round((210 - hole.cx) * .26);
|
|
183
|
+
const inwardY = Math.round((210 - hole.cy) * .26);
|
|
184
|
+
const outwardX = Math.round((hole.cx - 210) * .11);
|
|
185
|
+
const outwardY = Math.round((hole.cy - 210) * .11);
|
|
186
|
+
const turn = (index % 2 ? 1 : -1) * (14 + (index % 3) * 7);
|
|
187
|
+
const variables = [
|
|
188
|
+
`--hole-origin:${hole.cx}px ${hole.cy}px`,
|
|
189
|
+
`--scatter-x:${scatterX}px`,
|
|
190
|
+
`--scatter-y:${scatterY}px`,
|
|
191
|
+
`--scatter-back-x:${Math.round(scatterX * -.3)}px`,
|
|
192
|
+
`--scatter-back-y:${Math.round(scatterY * -.3)}px`,
|
|
193
|
+
`--in-x:${inwardX}px`,
|
|
194
|
+
`--in-y:${inwardY}px`,
|
|
195
|
+
`--out-x:${outwardX}px`,
|
|
196
|
+
`--out-y:${outwardY}px`,
|
|
197
|
+
`--turn:${turn}deg`,
|
|
198
|
+
`--turn-back:${turn * -1}deg`
|
|
199
|
+
].join(';');
|
|
200
|
+
return `<g class="hole-motion" style="${variables}">${holeMarkup(hole)}</g>`;
|
|
201
|
+
};
|
|
128
202
|
|
|
129
203
|
function darkShadow(color, factor = 0.52) {
|
|
130
204
|
const value = color.replace('#', '');
|
|
@@ -271,13 +345,15 @@ function packedHoleLayout(shape, count, seed) {
|
|
|
271
345
|
function svgMarkup(shape, holes, className, maskSuffix, accessible = false) {
|
|
272
346
|
const maskId = `mask-${shape.key}-${maskSuffix}`;
|
|
273
347
|
const fill = className === 'shape-shadow' ? darkShadow(shape.color) : shape.color;
|
|
274
|
-
return `<svg class="shape-layer ${className}" viewBox="0 0 420 420" ${accessible ? `role="img" aria-label="${shape.name} with ${holes.length} holes"` : 'aria-hidden="true"'}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
348
|
+
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>`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function compactNumber(value) {
|
|
352
|
+
const rounded = Math.round(value * 100) / 100;
|
|
353
|
+
return String(Object.is(rounded, -0) ? 0 : rounded);
|
|
278
354
|
}
|
|
279
355
|
|
|
280
|
-
function extrusionFilterMarkup(shape,
|
|
356
|
+
function extrusionFilterMarkup(shape, filterId, options = {}) {
|
|
281
357
|
const shadowX = options.shadowX ?? SHADOW_X;
|
|
282
358
|
const shadowY = options.shadowY ?? SHADOW_Y;
|
|
283
359
|
const shadowSteps = options.shadowSteps ?? SHADOW_STEPS;
|
|
@@ -285,29 +361,18 @@ function extrusionFilterMarkup(shape, suffix, options = {}) {
|
|
|
285
361
|
const offsets = Array.from({ length: shadowSteps }, (_, index) => {
|
|
286
362
|
const step = index + 1;
|
|
287
363
|
const ratio = step / shadowSteps;
|
|
288
|
-
return `<feOffset in="
|
|
364
|
+
return `<feOffset in="s" dx="${compactNumber(shadowX * ratio)}" dy="${compactNumber(shadowY * ratio)}" result="s${step}"/>`;
|
|
289
365
|
}).join('');
|
|
290
366
|
const merge = Array.from({ length: shadowSteps }, (_, index) =>
|
|
291
|
-
`<feMergeNode in="
|
|
367
|
+
`<feMergeNode in="s${shadowSteps - index}"/>`
|
|
292
368
|
).join('');
|
|
293
|
-
return `<filter id="
|
|
294
|
-
<feFlood flood-color="${shadowColor}" result="shadow-paint"/>
|
|
295
|
-
<feComposite in="shadow-paint" in2="SourceGraphic" operator="in" result="shadow-color"/>
|
|
296
|
-
${offsets}
|
|
297
|
-
<feMerge>${merge}<feMergeNode in="SourceGraphic"/></feMerge>
|
|
298
|
-
</filter>`;
|
|
369
|
+
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>`;
|
|
299
370
|
}
|
|
300
371
|
|
|
301
372
|
function objectMarkup(shape, holes, suffix, accessible = false) {
|
|
302
373
|
const maskId = `mask-${shape.key}-${suffix}`;
|
|
303
374
|
const filterId = `extrusion-${shape.key}-${suffix}`;
|
|
304
|
-
return `<svg class="shape-layer shape-object" viewBox="0 0 420 420" ${accessible ? `role="img" aria-label="${shape.name} with ${holes.length} holes"` : 'aria-hidden="true"'}
|
|
305
|
-
<defs>
|
|
306
|
-
<mask id="${maskId}"><rect width="420" height="420" fill="white"/>${holes.map(holeMarkup).join('')}</mask>
|
|
307
|
-
${extrusionFilterMarkup(shape, suffix)}
|
|
308
|
-
</defs>
|
|
309
|
-
<g filter="url(#${filterId})"><g class="shape-spin"><path d="${shape.path}" fill="${shape.color}" mask="url(#${maskId})"/></g></g>
|
|
310
|
-
</svg>`;
|
|
375
|
+
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"/>${holes.map(showcaseHoleMarkup).join('')}</mask>${extrusionFilterMarkup(shape, filterId)}</defs><g filter="url(#${filterId})"><g class="shape-spin"><path d="${shape.path}" fill="${shape.color}" mask="url(#${maskId})"/></g></g></svg>`;
|
|
311
376
|
}
|
|
312
377
|
|
|
313
378
|
function renderShape(card, shape, state) {
|
|
@@ -319,12 +384,12 @@ function renderShape(card, shape, state) {
|
|
|
319
384
|
|
|
320
385
|
function exportSvg(shape, holes, options = {}) {
|
|
321
386
|
const maskHoles = holes.map(holeMarkup).join('');
|
|
322
|
-
const filter = extrusionFilterMarkup(shape, '
|
|
387
|
+
const filter = extrusionFilterMarkup(shape, 'e', options);
|
|
323
388
|
const duration = Math.max(100, Math.min(10000, Number(options.duration) || 900));
|
|
324
389
|
const animation = options.animated === false
|
|
325
390
|
? ''
|
|
326
391
|
: `<style>.spin-group{transform-box:fill-box;transform-origin:center;will-change:transform}svg:hover .spin-group{animation:spin ${duration}ms cubic-bezier(.33,0,.2,1) both}@keyframes spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){svg:hover .spin-group{animation:none}}</style>`;
|
|
327
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 420" role="img" aria-label="${shape.name} with ${holes.length} holes">${animation}<defs><mask id="
|
|
392
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 420" role="img" aria-label="${shape.name} with ${holes.length} holes">${animation}<defs><mask id="h"><rect width="420" height="420" fill="white"/>${maskHoles}</mask>${filter}</defs><g filter="url(#e)"><g class="spin-group"><path d="${shape.path}" fill="${shape.color}" mask="url(#h)"/></g></g></svg>`;
|
|
328
393
|
}
|
|
329
394
|
|
|
330
395
|
function normalizeColor(color, fallback) {
|
|
@@ -419,6 +484,7 @@ if (collection) shapes.forEach((shape, index) => {
|
|
|
419
484
|
<button class="download" type="button">GET SVG <span>↓</span></button>
|
|
420
485
|
</div>`;
|
|
421
486
|
collection.append(card);
|
|
487
|
+
applyShowcaseMotion(card, index);
|
|
422
488
|
|
|
423
489
|
const state = { count: Math.min(shape.initial, holeLimit), seed: 0, limit: holeLimit };
|
|
424
490
|
states.set(shape.key, state);
|
|
@@ -475,6 +541,7 @@ if (presentApp) {
|
|
|
475
541
|
function renderPresent() {
|
|
476
542
|
const shape = activeShape();
|
|
477
543
|
const holes = packedHoleLayout(shape, presentState.count, presentState.seed);
|
|
544
|
+
applyShowcaseMotion(art, presentState.index);
|
|
478
545
|
art.innerHTML = objectMarkup(shape, holes, 'present', true);
|
|
479
546
|
syncPresentControls();
|
|
480
547
|
}
|