holey-shapes 0.1.0 → 0.1.2

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.
Files changed (3) hide show
  1. package/README.md +36 -2
  2. package/package.json +8 -2
  3. package/script.js +90 -23
package/README.md CHANGED
@@ -1,15 +1,49 @@
1
1
  # holey-shapes
2
2
 
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.
3
+ Solid, perforated shapes for the web and SwiftUI, 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.
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
 
9
+ ### JavaScript
10
+
9
11
  ```sh
10
12
  npm install holey-shapes
11
13
  ```
12
14
 
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.2")
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
+ .flower,
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. The pointer hover animation follows Reduce Motion automatically.
46
+
13
47
  ## Render an SVG string
14
48
 
15
49
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "holey-shapes",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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})"` : ''} fill="black"/>`;
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
- <defs><mask id="${maskId}"><rect width="420" height="420" fill="white"/>${holes.map(holeMarkup).join('')}</mask></defs>
276
- <path d="${shape.path}" fill="${fill}" mask="url(#${maskId})"/>
277
- </svg>`;
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, suffix, options = {}) {
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="shadow-color" dx="${(shadowX * ratio).toFixed(2)}" dy="${(shadowY * ratio).toFixed(2)}" result="shadow-${step}"/>`;
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="shadow-${shadowSteps - index}"/>`
367
+ `<feMergeNode in="s${shadowSteps - index}"/>`
292
368
  ).join('');
293
- return `<filter id="extrusion-${shape.key}-${suffix}" x="-20%" y="-20%" width="160%" height="160%" color-interpolation-filters="sRGB">
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, 'download', options).replaceAll(`extrusion-${shape.key}-download`, 'extrusion');
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="holes"><rect width="420" height="420" fill="white"/>${maskHoles}</mask>${filter}</defs><g filter="url(#extrusion)"><g class="spin-group"><path d="${shape.path}" fill="${shape.color}" mask="url(#holes)"/></g></g></svg>`;
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
  }