@flogeez/murmuration 0.0.0-stage → 0.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/LICENSE +18 -0
- package/README.md +107 -2
- package/index.js +5 -0
- package/loop.js +15 -0
- package/murmuration.js +261 -0
- package/package.json +23 -4
- package/scenes.js +55 -0
- package/shapes.js +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Florian Etourneau
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
|
6
|
+
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
|
7
|
+
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
|
9
|
+
following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial
|
|
12
|
+
portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
|
15
|
+
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
16
|
+
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
17
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
18
|
+
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,108 @@
|
|
|
1
|
-
#
|
|
1
|
+
# murmuration
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A flock of birds that draws shapes. The birds land on a list of points, settle tight on a shape's outline and sparse inside it, circle a zone above it, shy away from the pointer, and follow the page section by section. One canvas, plain ES modules, no build, no dependency.
|
|
4
|
+
|
|
5
|
+
It is the engine behind the starlings of [florian-etourneau.dev](https://florian-etourneau.dev). Birds are only the default: give it a fish, a leaf or a dot and it becomes a school, a gust or a swarm of fireflies. **[Try the demo](https://flogeez.github.io/murmuration/demo/)**: type a word, draw a line, change the glyph, tune the flock and copy the code.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm i @flogeez/murmuration
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## A flock in a canvas
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { createMurmuration, sampleShape, fitShape, solid, hollow } from '@flogeez/murmuration';
|
|
15
|
+
|
|
16
|
+
const ring = (g) => { solid(g); g.beginPath(); g.arc(360, 320, 260, 0, 6.2832); g.fill(); hollow(g); g.beginPath(); g.arc(360, 320, 150, 0, 6.2832); g.fill(); solid(g); };
|
|
17
|
+
|
|
18
|
+
const m = createMurmuration(canvas, { frame: 'canvas', count: 320 });
|
|
19
|
+
m.perch(fitShape(sampleShape(ring, 288), { width: canvas.clientWidth, height: canvas.clientHeight }), {
|
|
20
|
+
rest: 'circle', zone: () => ({ x: canvas.clientWidth / 2, y: 40, rx: canvas.clientWidth * 0.3, ry: 20 }),
|
|
21
|
+
});
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`m.fly()` lets the flock fly freely, `m.scatter()` sends it away, `m.setColors(colors)` and `m.setGlyph(glyph)` restyle it, `m.setCount(n)` adds or removes birds (perch again to share the places out), `m.destroy()` stops it and removes its listeners.
|
|
25
|
+
|
|
26
|
+
A glyph is any SVG path facing right. Birds, fish and leaves work, and so does plain geometry: a dot, a square, or a triangle that travels nose first, the classic way of drawing boids.
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
const TRIANGLE = { path: 'M20 10 L0 2 L4 10 L0 18 Z', anchor: [8, 10], scale: 0.55, flight: 'glyph' };
|
|
30
|
+
m.setGlyph(TRIANGLE);
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## With a framework
|
|
34
|
+
|
|
35
|
+
The engine owns a canvas and nothing else, so any framework only has to create it after mounting and destroy it on unmount.
|
|
36
|
+
|
|
37
|
+
```jsx
|
|
38
|
+
// React
|
|
39
|
+
function Flock({ shape }) {
|
|
40
|
+
const ref = useRef(null);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const canvas = ref.current, m = createMurmuration(canvas, { frame: 'canvas' });
|
|
43
|
+
m.perch(fitShape(sampleShape(shape, 240), { width: canvas.clientWidth, height: canvas.clientHeight }));
|
|
44
|
+
return () => m.destroy();
|
|
45
|
+
}, [shape]);
|
|
46
|
+
return <canvas ref={ref} style={{ width: '100%', aspectRatio: '9 / 8' }} />;
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
// Vue
|
|
52
|
+
onMounted(() => { m = createMurmuration(canvas.value, { frame: 'canvas' }); });
|
|
53
|
+
onUnmounted(() => m.destroy());
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Options
|
|
57
|
+
|
|
58
|
+
`createMurmuration(canvas, options)`:
|
|
59
|
+
|
|
60
|
+
| Option | Default | |
|
|
61
|
+
| --- | --- | --- |
|
|
62
|
+
| `frame` | `'page'` | `'page'`: a fixed canvas over the whole page, the birds in document coordinates. `'canvas'`: the flock lives inside the canvas, in its own coordinates, with soft walls |
|
|
63
|
+
| `count` | `170` | How many birds |
|
|
64
|
+
| `colors` | two greys | The birds' colours, in turn |
|
|
65
|
+
| `glyph` | `PLAIN_BIRD` | The perched bird: `{ path, anchor, scale, flight }`, an SVG path facing right, the point that sits on its place, its scale, and how it travels: `'wings'` (default, flapping wings) or `'glyph'` (the glyph itself, turned towards its heading) |
|
|
66
|
+
| `scale` | the glyph's | Overrides the glyph's scale |
|
|
67
|
+
| `debug` | `false` | Shows the places, the swarm's zone and the current scene |
|
|
68
|
+
|
|
69
|
+
`m.perch(points, options)`:
|
|
70
|
+
|
|
71
|
+
| Option | |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `rest` | Birds without a place: `'fly'` (default), `'double'` (squeeze next to a perched one), `'circle'` (in `zone`), `'leave'` |
|
|
74
|
+
| `zone` | Function returning an ellipse `{ x, y, rx, ry }` or a path `{ path: [[x, y], …] }` where the swarm circles |
|
|
75
|
+
| `keep` | With `'circle'`: how many birds circle; the others leave |
|
|
76
|
+
| `anchor` | Function returning the origin `[x, y]` of the points: the shape follows a moving element |
|
|
77
|
+
| `shy` | Perched birds fly off when the pointer comes close, then land again |
|
|
78
|
+
| `instant` | Land at once |
|
|
79
|
+
|
|
80
|
+
`prefers-reduced-motion` is honoured: the birds are placed without moving.
|
|
81
|
+
|
|
82
|
+
## Shapes
|
|
83
|
+
|
|
84
|
+
A shape is a function that draws on a 720 × 640 canvas: fill the matter (`solid`), hollow out the holes (`hollow`). `sampleShape(draw, count)` turns it into about `count` landing points, cached per shape; `fitShape(points, { width, height }, { zoom, dx, dy })` centres them in a box. A shape that changes over time gives its cache key, as `draw.key()` or the `{ key }` option.
|
|
85
|
+
|
|
86
|
+
## A page in scenes
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
import { createScenes } from '@flogeez/murmuration';
|
|
90
|
+
|
|
91
|
+
const scenes = createScenes(m, [
|
|
92
|
+
{ name: 'hero', active: () => scrollY < 300, pose: ({ first }) => ({ points: wirePoints(), instant: first, rest: 'circle', zone: heroOrbit }) },
|
|
93
|
+
{ name: 'story', active: () => inView(story), variant: () => step.dataset.shape, pose: ({ variant }) => ({ points: shapePoints(variant), anchor: origin }) },
|
|
94
|
+
]);
|
|
95
|
+
addEventListener('scroll', () => requestAnimationFrame(() => scenes.update()), { passive: true });
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The first active scene wins; no active scene, the flock flies freely. `variant()` re-lands the flock when a scene changes without scrolling; `update(true)` lands it again after a resize.
|
|
99
|
+
|
|
100
|
+
`animate(update)` is a shared animation loop for anything else that moves: `update(dt)` returns `false` to stop.
|
|
101
|
+
|
|
102
|
+
## Demo
|
|
103
|
+
|
|
104
|
+
`npx serve .`, then open `demo/`.
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT, see [LICENSE](LICENSE).
|
package/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/* @flogeez/murmuration — a flock of birds that draws shapes, follows the page section by section, and its scene engine. */
|
|
2
|
+
export { createMurmuration, PLAIN_BIRD } from './murmuration.js';
|
|
3
|
+
export { createScenes } from './scenes.js';
|
|
4
|
+
export { sampleShape, fitShape, SHAPE_WIDTH, SHAPE_HEIGHT, roundRect, solid, hollow, stroke } from './shapes.js';
|
|
5
|
+
export { animate } from './loop.js';
|
package/loop.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/* One animation loop for every animated bird (or anything else) on the page.
|
|
2
|
+
animate(update): update(dt) is called on each frame, dt in seconds, capped at 50 ms. It returns false to stop.
|
|
3
|
+
The loop goes to sleep as soon as nothing moves. Returns a function that stops this update. */
|
|
4
|
+
const updates = new Set();
|
|
5
|
+
let raf = 0, prev = 0;
|
|
6
|
+
const tick = (now) => {
|
|
7
|
+
const dt = Math.min(0.05, Math.max(0, (now - prev) / 1000)); prev = now;
|
|
8
|
+
for (const update of [...updates]) if (update(dt) === false) updates.delete(update);
|
|
9
|
+
raf = updates.size ? requestAnimationFrame(tick) : 0;
|
|
10
|
+
};
|
|
11
|
+
export function animate(update) {
|
|
12
|
+
updates.add(update);
|
|
13
|
+
if (!raf) { prev = performance.now(); raf = requestAnimationFrame(tick); }
|
|
14
|
+
return () => updates.delete(update);
|
|
15
|
+
}
|
package/murmuration.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/* The murmuration — a flock of birds that goes along the whole page, or lives inside a canvas.
|
|
2
|
+
* A fixed canvas behind the content; the birds live in document coordinates.
|
|
3
|
+
* const m = createMurmuration(canvas, { count: 170, colors: ['#2B2A33', '#5E5C6B'], frame: 'page', glyph, debug: false });
|
|
4
|
+
* frame: 'page' (default) — a fixed canvas over the whole page, the birds in document coordinates;
|
|
5
|
+
* 'canvas' — the flock lives inside the canvas itself (a plate, a slide), in the canvas's own coordinates
|
|
6
|
+
* glyph: the perched bird, { path: SVG path d, anchor: [x, y] (the point that sits on its place), scale, flight }; a plain bird by default
|
|
7
|
+
* flight: 'wings' (default) draws flying birds as flapping wings; 'glyph' draws the glyph itself, turned towards its heading (a fish, a leaf)
|
|
8
|
+
* m.perch(points, options) // land on a list of [x, y] points (document coordinates); options: see perch
|
|
9
|
+
* (choosing the scene, section by section, is the scene engine's job: scenes.js)
|
|
10
|
+
* m.fly() // fly freely in the viewport's margin
|
|
11
|
+
* m.scatter() // leave the screen
|
|
12
|
+
* m.setGlyph(glyph) // change the glyph of the whole flock
|
|
13
|
+
* m.setCount(n) // add or remove birds; perch again to share the places out
|
|
14
|
+
* m.destroy() // stop and remove every listener
|
|
15
|
+
*/
|
|
16
|
+
// Flock speeds (1 = the original setting at 60 frames/s):
|
|
17
|
+
// SPEED_PERCH for birds on their way to a place (shapes, wire, path); SPEED_FREE for the circling flock, slower:
|
|
18
|
+
// it stays on screen during the whole read and must not draw the eye; SPEED_AWAY for those leaving the screen.
|
|
19
|
+
const SPEED_PERCH = 2, SPEED_FREE = 0.8, SPEED_AWAY = 1;
|
|
20
|
+
// the default perched bird: a plain songbird in profile, about 12 px high, its feet on its place
|
|
21
|
+
export const PLAIN_BIRD = { path: 'M66 14 C56 8 44 12 41 24 C39 33 39 42 33 52 C27 62 18 70 8 78 L22 77 C30 72 38 66 46 63 C60 58 70 46 70 32 L84 30 L70 25 C69 20 68 17 66 14 Z', anchor: [44, 64], scale: 0.17 };
|
|
22
|
+
export function createMurmuration(canvas, opts = {}) {
|
|
23
|
+
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
24
|
+
const ctx = canvas.getContext('2d');
|
|
25
|
+
let colors = opts.colors || ['#2B2A33', '#5E5C6B'];
|
|
26
|
+
let glyph, GLYPH, GX, GY, S;
|
|
27
|
+
const useGlyph = (g) => { glyph = g; GLYPH = new Path2D(g.path); [GX, GY] = g.anchor; S = opts.scale || g.scale; };
|
|
28
|
+
useGlyph(opts.glyph || PLAIN_BIRD);
|
|
29
|
+
// every listener goes through this signal, so that destroy() removes them all
|
|
30
|
+
const life = new AbortController(), signal = life.signal;
|
|
31
|
+
// the view: the viewport scrolled over the page, or the canvas itself
|
|
32
|
+
const inCanvas = opts.frame === 'canvas';
|
|
33
|
+
const viewX = () => (inCanvas ? 0 : scrollX), viewY = () => (inCanvas ? 0 : scrollY);
|
|
34
|
+
let zoneFn = null, Z = null, anchor = null, origin = [0, 0], prevAnchor = null, prevOrigin = [0, 0], waitEnd = 0, vw = inCanvas ? canvas.clientWidth : innerWidth, vh = inCanvas ? canvas.clientHeight : innerHeight, dpr = 1, raf = 0, mx = null, my = null, running = true;
|
|
35
|
+
const resize = () => {
|
|
36
|
+
dpr = Math.min(2, window.devicePixelRatio || 1);
|
|
37
|
+
if (inCanvas) { vw = canvas.clientWidth; vh = canvas.clientHeight; } else { vw = innerWidth; vh = innerHeight; canvas.style.width = vw + 'px'; canvas.style.height = vh + 'px'; }
|
|
38
|
+
canvas.width = vw * dpr; canvas.height = vh * dpr;
|
|
39
|
+
if (reduced) draw();
|
|
40
|
+
};
|
|
41
|
+
const B = [], grid = new Map();
|
|
42
|
+
const bird = (k) => ({ x: viewX() + vw * (0.2 + Math.random() * 0.6), y: viewY() + vh * Math.random() * 0.5, vx: Math.random() - 0.5, vy: Math.random() - 0.5, t: null, perched: false, away: false, ph: Math.random() * 6.283, c: colors[k % colors.length] });
|
|
43
|
+
for (let k = 0; k < (opts.count || 170); k++) B.push(bird(k));
|
|
44
|
+
resize(); addEventListener('resize', resize, { signal });
|
|
45
|
+
const sizeWatch = inCanvas && 'ResizeObserver' in window ? new ResizeObserver(resize) : null;
|
|
46
|
+
if (sizeWatch) sizeWatch.observe(canvas);
|
|
47
|
+
// the pointer, in the view's coordinates: recomputed on scroll too (the pointer stays still, the page moves)
|
|
48
|
+
let px0 = null, py0 = null;
|
|
49
|
+
const track = () => {
|
|
50
|
+
if (px0 == null) return;
|
|
51
|
+
if (inCanvas) { const r = canvas.getBoundingClientRect(); mx = px0 - r.left; my = py0 - r.top; } else { mx = px0 + scrollX; my = py0 + scrollY; }
|
|
52
|
+
if (shy) dirty = true;
|
|
53
|
+
};
|
|
54
|
+
addEventListener('pointermove', (e) => { px0 = e.clientX; py0 = e.clientY; track(); }, { passive: true, signal });
|
|
55
|
+
addEventListener('scroll', track, { passive: true, signal });
|
|
56
|
+
document.addEventListener('pointerleave', () => { px0 = mx = null; }, { signal });
|
|
57
|
+
// shy: perched birds fly off when the pointer comes close, circle above their place, then land on it again
|
|
58
|
+
let shy = false;
|
|
59
|
+
function startle(b, ms) { b.perched = false; const a = Math.atan2(b.y - my, b.x - mx); b.vx = Math.cos(a) * 2.5 + (Math.random() - 0.5); b.vy = -2.5 - Math.random() * 1.5; b.fright = ms + 900 + Math.random() * 500; }
|
|
60
|
+
|
|
61
|
+
function takeOff(b) { b.wait = 0; b.orb = false; b.fright = 0; if (b.perched) { b.vy -= 2 + Math.random() * 1.5; b.vx += (Math.random() - 0.5) * 2; } b.perched = false; b.t = null; }
|
|
62
|
+
// birds that left come back from the side the page comes from: from the top when scrolling up, from the bottom when scrolling down
|
|
63
|
+
let prevScroll = scrollY, goingUp = false;
|
|
64
|
+
addEventListener('scroll', () => { if (scrollY !== prevScroll) goingUp = scrollY < prevScroll; prevScroll = scrollY; }, { passive: true, signal });
|
|
65
|
+
function comeBack(b) {
|
|
66
|
+
if (!b.away) return; b.away = false;
|
|
67
|
+
b.x = viewX() + vw * (0.1 + Math.random() * 0.8);
|
|
68
|
+
b.y = viewY() + (goingUp ? -20 - Math.random() * 60 : vh + 20 + Math.random() * 60);
|
|
69
|
+
b.vx = (Math.random() - 0.5) * 2; b.vy = goingUp ? 2 + Math.random() : -2 - Math.random();
|
|
70
|
+
}
|
|
71
|
+
// Leaving the screen: the whole flock leaves the same way, up and to the right (±20°), not in a star from the centre
|
|
72
|
+
function leave(b) { b.away = true; const a = -Math.PI / 3 + (Math.random() - 0.5) * 0.7; b.ex = Math.cos(a); b.ey = Math.sin(a); }
|
|
73
|
+
// The attraction point of a path: it goes back and forth along the polyline, at 70 px/s, with a slight float
|
|
74
|
+
function alongPath(pts, now) {
|
|
75
|
+
const lg = pts.slice(1).map((p, i) => Math.hypot(p[0] - pts[i][0], p[1] - pts[i][1])), total = lg.reduce((a, l) => a + l, 0) || 1;
|
|
76
|
+
let s = (now * 70) % (2 * total); if (s > total) s = 2 * total - s;
|
|
77
|
+
let i = 0; while (i < lg.length - 1 && s > lg[i]) { s -= lg[i]; i++; }
|
|
78
|
+
const u = lg[i] ? Math.min(1, s / lg[i]) : 0, a = pts[i], z = pts[i + 1] || a;
|
|
79
|
+
return [a[0] + (z[0] - a[0]) * u + Math.cos(now * 0.87) * 14, a[1] + (z[1] - a[1]) * u + Math.sin(now * 1.13) * 14];
|
|
80
|
+
}
|
|
81
|
+
// Land on the points T. Options:
|
|
82
|
+
// instant: the birds land at once (first display, resize)
|
|
83
|
+
// rest: what do birds without a place do? 'fly' (default), 'double' (squeeze next to another one), 'circle' (in the zone), 'leave'
|
|
84
|
+
// anchor: function returning the origin [x, y] (document) of the points T; the shape then follows a moving element (position sticky…)
|
|
85
|
+
// zone: function returning the ellipse {x, y, rx, ry} (document) where birds without a place circle (rest = 'circle'),
|
|
86
|
+
// or a path {path: [[x, y], …]} (document) along which they go back and forth
|
|
87
|
+
// keep: with 'circle', how many birds without a place circle; the others leave (all of them circle by default)
|
|
88
|
+
// shy: perched birds fly off when the pointer comes close (see startle)
|
|
89
|
+
function perch(T, { instant = false, rest = 'fly', anchor: anc = null, zone = null, keep = Infinity, shy: isShy = false } = {}) {
|
|
90
|
+
idle = false; shy = isShy;
|
|
91
|
+
zoneFn = rest === 'circle' ? zone : null;
|
|
92
|
+
// the flock re-forms: perched birds fly off one after the other, from left to right
|
|
93
|
+
const now = performance.now(), before = B.map((b) => (b.perched && b.t && !reduced && !instant ? b.t : null));
|
|
94
|
+
prevAnchor = anchor; prevOrigin = anchor ? anchor() : [0, 0];
|
|
95
|
+
anchor = anc; origin = anc ? anc() : [0, 0];
|
|
96
|
+
B.forEach(takeOff);
|
|
97
|
+
// more places than birds: keep places spread over the whole shape, not only its left part
|
|
98
|
+
const spread = T.length > B.length ? Array.from({ length: B.length }, (_, j) => T[Math.floor(j * T.length / B.length)]) : [...T];
|
|
99
|
+
const tg = spread.sort((a, c) => a[0] - c[0]);
|
|
100
|
+
const n = Math.min(tg.length, B.length), taken = new Set();
|
|
101
|
+
// places go first to the birds on screen: birds that left only come back if some are missing
|
|
102
|
+
const present = B.map((b, k) => k).filter((k) => !B[k].away);
|
|
103
|
+
const order = (present.length >= n ? present : B.map((b, k) => k)).sort((a, c) => B[a].x - B[c].x), stride = order.length / n;
|
|
104
|
+
for (let j = 0; j < n; j++) { const k = order[Math.floor(j * stride)]; B[k].t = tg[j]; taken.add(k); }
|
|
105
|
+
let kept = 0;
|
|
106
|
+
B.forEach((b, k) => {
|
|
107
|
+
if (!taken.has(k) && n && n < B.length) {
|
|
108
|
+
if (rest === 'double') { const p = tg[Math.floor(Math.random() * n)]; b.t = [p[0] + (Math.random() < 0.5 ? -4 : 4), p[1]]; }
|
|
109
|
+
else if (rest === 'circle' && zone && kept < keep) { b.orb = true; kept++; }
|
|
110
|
+
else if (rest === 'leave' || (rest === 'circle' && zone)) { if (!b.away) leave(b); return; } // already gone: they stay gone
|
|
111
|
+
}
|
|
112
|
+
comeBack(b);
|
|
113
|
+
});
|
|
114
|
+
const waiting = B.map((b, k) => k).filter((k) => before[k]).sort((a, c) => before[a][0] - before[c][0]);
|
|
115
|
+
waiting.forEach((k, r) => { const b = B[k]; b.ot = before[k]; b.wait = now + (r / waiting.length) * 500 + Math.random() * 140; b.perched = true; b.vx = b.vy = 0; });
|
|
116
|
+
waitEnd = now + 700;
|
|
117
|
+
if (reduced || instant) { B.forEach((b) => { if (b.t) { b.x = b.t[0] + origin[0]; b.y = b.t[1] + origin[1]; b.perched = true; } }); draw(); }
|
|
118
|
+
}
|
|
119
|
+
function fly() { idle = false; shy = false; anchor = null; zoneFn = null; B.forEach((b) => { takeOff(b); comeBack(b); }); if (reduced) draw(); }
|
|
120
|
+
function scatter() {
|
|
121
|
+
idle = false; shy = false;
|
|
122
|
+
anchor = null; zoneFn = null;
|
|
123
|
+
B.forEach((b) => { takeOff(b); leave(b); });
|
|
124
|
+
if (reduced) draw();
|
|
125
|
+
}
|
|
126
|
+
function offscreen(b) { const x = viewX(), y = viewY(); return b.x < x - 60 || b.x > x + vw + 60 || b.y < y - 60 || b.y > y + vh + 60; }
|
|
127
|
+
// dt: the elapsed time, in "frames at 60 Hz"; each bird multiplies it by its speed (k)
|
|
128
|
+
function step(dt = 1) {
|
|
129
|
+
const now = performance.now() / 1000;
|
|
130
|
+
// free-flight zone: on a page, the right margin of the viewport, a little above the middle; in a canvas, around its centre
|
|
131
|
+
const ax = inCanvas ? vw / 2 + Math.cos(now * 0.33) * vw * 0.2 : scrollX + vw * (vw > 900 ? 0.86 : 0.66) + Math.cos(now * 0.33) * vw * 0.1;
|
|
132
|
+
const ay = inCanvas ? vh / 2 + Math.sin(now * 0.51) * vh * 0.1 : scrollY + vh * 0.3 + Math.sin(now * 0.51) * vh * 0.15;
|
|
133
|
+
origin = anchor ? anchor() : [0, 0];
|
|
134
|
+
Z = zoneFn ? zoneFn() : null;
|
|
135
|
+
if (Z && Z.path) Z.point = alongPath(Z.path, now);
|
|
136
|
+
const ms = performance.now(); if (prevAnchor && ms < waitEnd) prevOrigin = prevAnchor();
|
|
137
|
+
// neighbourhood: a 40 px grid for the free birds (the flock counts several hundred birds)
|
|
138
|
+
grid.clear();
|
|
139
|
+
for (const o of B) { if (o.perched || o.away || o.t) continue; const c = Math.floor(o.x / 40) * 100003 + Math.floor(o.y / 40); const l = grid.get(c); if (l) l.push(o); else grid.set(c, [o]); }
|
|
140
|
+
for (const b of B) {
|
|
141
|
+
if (b.perched) {
|
|
142
|
+
if (b.wait) {
|
|
143
|
+
if (ms < b.wait) { b.x = b.ot[0] + prevOrigin[0]; b.y = b.ot[1] + prevOrigin[1]; continue; }
|
|
144
|
+
b.wait = 0; b.perched = false; b.vy = -(1.5 + Math.random() * 2); b.vx = (Math.random() - 0.5) * 2;
|
|
145
|
+
} else {
|
|
146
|
+
if (anchor && b.t) { b.x = b.t[0] + origin[0]; b.y = b.t[1] + origin[1]; }
|
|
147
|
+
if (!(shy && b.t && mx != null && Math.hypot(b.x - mx, b.y - my) < 56)) continue;
|
|
148
|
+
startle(b, ms);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const k = dt * (b.away ? SPEED_AWAY : b.t && !b.fright ? SPEED_PERCH : SPEED_FREE);
|
|
152
|
+
if (b.away) { if (offscreen(b)) continue; b.vx += b.ex * 0.14 * k; b.vy += b.ey * 0.14 * k; const v = Math.hypot(b.vx, b.vy); if (v > 4.5) { b.vx = b.vx / v * 4.5; b.vy = b.vy / v * 4.5; } b.x += b.vx * k; b.y += b.vy * k; continue; }
|
|
153
|
+
let fx = 0, fy = 0;
|
|
154
|
+
// scrolling is faster than the birds: those that fall behind come back in through the nearest edge
|
|
155
|
+
if (!b.t) {
|
|
156
|
+
const y0 = viewY();
|
|
157
|
+
if (b.y < y0 - 220) { b.y = y0 - 30; b.vy = Math.abs(b.vy) + 1; }
|
|
158
|
+
else if (b.y > y0 + vh + 220) { b.y = y0 + vh + 30; b.vy = -Math.abs(b.vy) - 1; }
|
|
159
|
+
} else {
|
|
160
|
+
const ty = b.t[1] + origin[1];
|
|
161
|
+
if (Math.abs(b.y - ty) > vh * 0.9) { b.y = ty + (b.y < ty ? -vh * 0.6 : vh * 0.6); }
|
|
162
|
+
}
|
|
163
|
+
const tx = b.t ? b.t[0] + origin[0] : 0, ty = b.t ? b.t[1] + origin[1] : 0;
|
|
164
|
+
if (b.fright && b.t && (ms < b.fright || (mx != null && Math.hypot(tx - mx, ty - my) < 90))) {
|
|
165
|
+
// startled: it circles above its place while the pointer is there, then comes back to land on it
|
|
166
|
+
if (ms >= b.fright) b.fright = ms + 300;
|
|
167
|
+
const px = tx + Math.cos(now * 1.6 + b.ph) * 36, py = ty - 64 + Math.sin(now * 2.1 + b.ph) * 18;
|
|
168
|
+
fx = (px - b.x) * 0.006 - b.vx * 0.04; fy = (py - b.y) * 0.006 - b.vy * 0.04;
|
|
169
|
+
} else if (b.t) {
|
|
170
|
+
b.fright = 0;
|
|
171
|
+
const dx = tx - b.x, dy = ty - b.y, d = Math.hypot(dx, dy);
|
|
172
|
+
if (d < 1.2) { b.perched = true; b.x = b.t[0] + origin[0]; b.y = b.t[1] + origin[1]; b.vx = b.vy = 0; continue; }
|
|
173
|
+
const sp = Math.min(d > 300 ? 12 : 5.5, d * 0.08);
|
|
174
|
+
// on the way, the flock swirls a little before landing
|
|
175
|
+
const swirl = Math.min(0.75, Math.max(0, (d - 50) / 380)), ca = Math.cos(swirl), sa = Math.sin(swirl), ux = dx / d, uy = dy / d;
|
|
176
|
+
fx = (ux * ca - uy * sa) * sp - b.vx; fy = (ux * sa + uy * ca) * sp - b.vy;
|
|
177
|
+
const lim = d > 300 ? 1.2 : 0.45, f = Math.hypot(fx, fy); if (f > lim) { fx = fx / f * lim; fy = fy / f * lim; }
|
|
178
|
+
} else {
|
|
179
|
+
let sx = 0, sy = 0, axx = 0, ayy = 0, cx = 0, cy = 0, n = 0;
|
|
180
|
+
const gx = Math.floor(b.x / 40), gy = Math.floor(b.y / 40);
|
|
181
|
+
for (let i = -1; i <= 1; i++) for (let j = -1; j <= 1; j++) { const l = grid.get((gx + i) * 100003 + gy + j); if (l) for (const o of l) {
|
|
182
|
+
if (o === b) continue;
|
|
183
|
+
const dx = o.x - b.x, dy = o.y - b.y, d2 = dx * dx + dy * dy;
|
|
184
|
+
if (d2 < 1600) { n++; axx += o.vx; ayy += o.vy; cx += o.x; cy += o.y; if (d2 < 420) { const d = Math.sqrt(d2) || 1; sx -= dx / d * (1 - d / 20.5); sy -= dy / d * (1 - d / 20.5); } }
|
|
185
|
+
} }
|
|
186
|
+
if (n) { fx += (axx / n - b.vx) * 0.05 + (cx / n - b.x) * 0.0007; fy += (ayy / n - b.vy) * 0.05 + (cy / n - b.y) * 0.0007; }
|
|
187
|
+
if (b.orb && Z) {
|
|
188
|
+
// like the whole flock: an attraction point wandering in the zone, and the swarm's rules do the rest
|
|
189
|
+
let px, py;
|
|
190
|
+
if (Z.path) { [px, py] = Z.point; b.far = Math.hypot(b.x - px, b.y - py) > 220; }
|
|
191
|
+
else {
|
|
192
|
+
px = Z.x + Math.cos(now * 0.33) * Z.rx * 0.7 + Math.cos(now * 0.87) * Z.rx * 0.2; py = Z.y + Math.sin(now * 0.51) * Z.ry * 0.7 + Math.sin(now * 1.13) * Z.ry * 0.2;
|
|
193
|
+
b.far = Math.hypot((b.x - Z.x) / Z.rx, (b.y - Z.y) / Z.ry) > 1.8;
|
|
194
|
+
}
|
|
195
|
+
const pull = b.far ? 0.004 : 0.0011;
|
|
196
|
+
fx += sx * 0.35 + (px - b.x) * pull; fy += sy * 0.35 + (py - b.y) * pull;
|
|
197
|
+
} else { fx += sx * 0.35 + (ax - b.x) * 0.0006; fy += sy * 0.35 + (ay - b.y) * 0.0006; }
|
|
198
|
+
// in a canvas, soft walls: a free bird nearing an edge is turned back inside, so the flock is never cut by the frame
|
|
199
|
+
if (inCanvas) { const M = 48; if (b.x < M) fx += (M - b.x) * 0.01; else if (b.x > vw - M) fx -= (b.x - vw + M) * 0.01; if (b.y < M) fy += (M - b.y) * 0.01; else if (b.y > vh - M) fy -= (b.y - vh + M) * 0.01; }
|
|
200
|
+
}
|
|
201
|
+
if (mx != null) { const dx = b.x - mx, dy = b.y - my, d2 = dx * dx + dy * dy; if (d2 < 7000 && (!b.t || b.fright)) { const d = Math.sqrt(d2) || 1, f = (1 - d / 84) * 1.4; fx += dx / d * f; fy += dy / d * f; } }
|
|
202
|
+
b.vx += fx * k; b.vy += fy * k;
|
|
203
|
+
const v = Math.hypot(b.vx, b.vy), vmax = b.fright ? 3.2 : b.t ? 12 : b.orb && b.far ? 9 : 3.2, vmin = b.t && !b.fright ? 0 : 1.3;
|
|
204
|
+
if (v > vmax) { b.vx = b.vx / v * vmax; b.vy = b.vy / v * vmax; } else if (v < vmin && v > 0) { b.vx = b.vx / v * vmin; b.vy = b.vy / v * vmin; }
|
|
205
|
+
b.x += b.vx * k; b.y += b.vy * k;
|
|
206
|
+
}
|
|
207
|
+
draw(now);
|
|
208
|
+
}
|
|
209
|
+
function draw(now = 0) {
|
|
210
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, vw, vh);
|
|
211
|
+
ctx.translate(-viewX(), -viewY()); ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
|
212
|
+
for (const b of B) {
|
|
213
|
+
if (offscreen(b)) continue;
|
|
214
|
+
if (b.perched) {
|
|
215
|
+
ctx.globalAlpha = 1; ctx.save(); ctx.translate(b.x - GX * S, b.y - GY * S); ctx.scale(S, S); ctx.fillStyle = b.c; ctx.fill(GLYPH); ctx.restore();
|
|
216
|
+
} else if (!reduced && glyph.flight === 'glyph') {
|
|
217
|
+
ctx.globalAlpha = b.t ? 0.95 : 0.5; ctx.save(); ctx.translate(b.x, b.y); ctx.rotate(Math.atan2(b.vy, b.vx)); ctx.scale(S, S); ctx.translate(-GX, -GY); ctx.fillStyle = b.c; ctx.fill(GLYPH); ctx.restore();
|
|
218
|
+
} else if (!reduced) {
|
|
219
|
+
ctx.globalAlpha = b.t ? 0.95 : 0.5;
|
|
220
|
+
const w = Math.sin(now * 16 + b.ph) * 2.4; ctx.save(); ctx.translate(b.x, b.y); ctx.rotate(Math.atan2(b.vy, b.vx) * 0.25);
|
|
221
|
+
ctx.strokeStyle = b.c; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.moveTo(-5, w); ctx.quadraticCurveTo(-2.4, -1.2, 0, 0.9); ctx.quadraticCurveTo(2.4, -1.2, 5, w); ctx.stroke(); ctx.restore();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
ctx.globalAlpha = 1;
|
|
225
|
+
if (opts.debug) drawDebug();
|
|
226
|
+
}
|
|
227
|
+
// debug: the places (crosses), the zone where the swarm circles (ellipse or path) and its attraction point
|
|
228
|
+
function drawDebug() {
|
|
229
|
+
ctx.save(); ctx.lineWidth = 1; ctx.strokeStyle = '#C8612E'; ctx.fillStyle = '#C8612E';
|
|
230
|
+
for (const b of B) if (b.t) { const x = b.t[0] + origin[0], y = b.t[1] + origin[1]; ctx.beginPath(); ctx.moveTo(x - 2, y - 2); ctx.lineTo(x + 2, y + 2); ctx.moveTo(x + 2, y - 2); ctx.lineTo(x - 2, y + 2); ctx.stroke(); }
|
|
231
|
+
if (Z) {
|
|
232
|
+
ctx.setLineDash([4, 4]); ctx.beginPath();
|
|
233
|
+
if (Z.path) Z.path.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
|
|
234
|
+
else ctx.ellipse(Z.x, Z.y, Z.rx, Z.ry, 0, 0, Math.PI * 2);
|
|
235
|
+
ctx.stroke(); ctx.setLineDash([]);
|
|
236
|
+
if (Z.point) { ctx.beginPath(); ctx.arc(Z.point[0], Z.point[1], 4, 0, Math.PI * 2); ctx.fill(); }
|
|
237
|
+
}
|
|
238
|
+
ctx.restore();
|
|
239
|
+
}
|
|
240
|
+
// at rest (all perched, nothing moves) nothing is recomputed: the flock is only redrawn on scroll or resize
|
|
241
|
+
let idle = false, dirty = true;
|
|
242
|
+
const wake = () => { dirty = true; };
|
|
243
|
+
addEventListener('scroll', wake, { passive: true, signal }); addEventListener('resize', wake, { signal });
|
|
244
|
+
// The step follows real time, not the number of frames: same speed at 60, 120 or 144 Hz.
|
|
245
|
+
// Capped at 3 frames so that a hiccup (throttled tab, garbage collector) does not teleport the flock.
|
|
246
|
+
let prevT = 0;
|
|
247
|
+
const loop = (t) => {
|
|
248
|
+
const dt = prevT ? Math.min(3, (t - prevT) / (1000 / 60)) : 1; prevT = t;
|
|
249
|
+
if (running && (!idle || dirty)) { dirty = false; step(dt); idle = B.every((b) => (b.perched && !b.wait) || (b.away && offscreen(b))); }
|
|
250
|
+
raf = requestAnimationFrame(loop);
|
|
251
|
+
};
|
|
252
|
+
if (reduced) { addEventListener('scroll', () => { if (anchor) { origin = anchor(); B.forEach((b) => { if (b.perched && b.t) { b.x = b.t[0] + origin[0]; b.y = b.t[1] + origin[1]; } }); } draw(); }, { passive: true, signal }); draw(); } else loop();
|
|
253
|
+
document.addEventListener('visibilitychange', () => { running = !document.hidden; }, { signal });
|
|
254
|
+
return {
|
|
255
|
+
perch, fly, scatter,
|
|
256
|
+
setColors(c) { idle = false; colors = c; B.forEach((b, k) => { b.c = colors[k % colors.length]; }); if (reduced) draw(); },
|
|
257
|
+
setGlyph(g) { idle = false; useGlyph(g); if (reduced) draw(); },
|
|
258
|
+
setCount(n) { idle = false; while (B.length < n) B.push(bird(B.length)); B.length = Math.max(0, n); if (reduced) draw(); },
|
|
259
|
+
destroy() { cancelAnimationFrame(raf); life.abort(); if (sizeWatch) sizeWatch.disconnect(); },
|
|
260
|
+
};
|
|
261
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flogeez/murmuration",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A flock of birds that draws shapes on a canvas: lands on points, circles a zone, shies away from the pointer, follows the page section by section. Plain ES modules, no build, no dependency.",
|
|
5
|
+
"keywords": ["murmuration", "flock", "boids", "canvas", "animation", "starlings"],
|
|
6
|
+
"author": "Florian Etourneau",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/FloGeez/murmuration.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/FloGeez/murmuration#readme",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./index.js",
|
|
17
|
+
"./murmuration": "./murmuration.js",
|
|
18
|
+
"./scenes": "./scenes.js",
|
|
19
|
+
"./shapes": "./shapes.js",
|
|
20
|
+
"./loop": "./loop.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"*.js"
|
|
24
|
+
]
|
|
25
|
+
}
|
package/scenes.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/* The scene engine: each section of the page describes a scene; the engine picks the one on screen and lands the flock on it.
|
|
2
|
+
*
|
|
3
|
+
* const scenes = createScenes(murmuration, [
|
|
4
|
+
* { name: 'wire', active: () => scrollY < 300, pose: () => ({ points: wirePoints(), rest: 'circle', zone: orbit }) },
|
|
5
|
+
* …
|
|
6
|
+
* ]);
|
|
7
|
+
* scenes.update() // on scroll: switches scene if needed
|
|
8
|
+
* scenes.update(true) // the page moved (resize, fonts): lands the flock again on the current scene
|
|
9
|
+
*
|
|
10
|
+
* A scene:
|
|
11
|
+
* name: its name (shown in debug mode)
|
|
12
|
+
* active(): true when the scene is on screen; the first active scene, in array order, wins
|
|
13
|
+
* variant(): optional, for a scene that changes without scrolling (the current step's shape); the engine checks it
|
|
14
|
+
* twice a second and lands the flock again when it changes
|
|
15
|
+
* pose({ variant, first, force, same }): the points to land on (points) and the options of murmuration.perch (rest, zone,
|
|
16
|
+
* anchor, keep, shy, instant). By default, the flock lands at once on the first scene, or when the same scene
|
|
17
|
+
* is forced again; otherwise it flies to its places.
|
|
18
|
+
* No active scene: the flock flies freely.
|
|
19
|
+
*/
|
|
20
|
+
export function createScenes(murmuration, scenes, { debug = false } = {}) {
|
|
21
|
+
let key = '', current = null, first = true;
|
|
22
|
+
const label = debug ? createLabel() : null;
|
|
23
|
+
|
|
24
|
+
function update(force = false) {
|
|
25
|
+
const scene = scenes.find((s) => s.active());
|
|
26
|
+
const variant = scene && scene.variant ? scene.variant() : '';
|
|
27
|
+
const k = scene ? scene.name + (variant ? ':' + variant : '') : 'fly';
|
|
28
|
+
if (k !== key || force) {
|
|
29
|
+
const same = k === key;
|
|
30
|
+
key = k; current = scene;
|
|
31
|
+
if (!scene) murmuration.fly();
|
|
32
|
+
else {
|
|
33
|
+
const { points, ...options } = scene.pose({ variant, first, force, same });
|
|
34
|
+
murmuration.perch(points, { instant: first || (force && same), ...options });
|
|
35
|
+
}
|
|
36
|
+
if (label) label.textContent = k;
|
|
37
|
+
}
|
|
38
|
+
first = false;
|
|
39
|
+
}
|
|
40
|
+
const timer = setInterval(() => { if (current && current.variant) update(); }, 500);
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
update,
|
|
44
|
+
get scene() { return current ? current.name : 'fly'; },
|
|
45
|
+
destroy() { clearInterval(timer); if (label) label.remove(); },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// debug: the current scene's name, bottom left
|
|
50
|
+
function createLabel() {
|
|
51
|
+
const e = document.createElement('div');
|
|
52
|
+
e.style.cssText = 'position:fixed;left:8px;bottom:8px;z-index:9999;padding:4px 8px;border-radius:6px;background:#1F1E24;color:#FF8A3D;font:500 12px/1.4 "JetBrains Mono",monospace;pointer-events:none';
|
|
53
|
+
document.body.append(e);
|
|
54
|
+
return e;
|
|
55
|
+
}
|
package/shapes.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/* Shapes the flock draws, and their sampling into landing points.
|
|
2
|
+
A shape is a function that draws on a 720 × 640 canvas: fill (black), then hollow out (destination-out). The birds fill
|
|
3
|
+
the matter, and the shape reads through mass and holes, like a real murmuration. Each shape must read in one second.
|
|
4
|
+
|
|
5
|
+
const ring = (g) => { solid(g); g.beginPath(); g.arc(360, 320, 260, 0, 6.2832); g.fill(); hollow(g); g.beginPath(); g.arc(360, 320, 150, 0, 6.2832); g.fill(); solid(g); };
|
|
6
|
+
const points = sampleShape(ring, 300); // [[x, y], …] in the 720 × 640 frame
|
|
7
|
+
m.perch(fitShape(points, box), { anchor: () => [box.left + scrollX, box.top + scrollY] }); */
|
|
8
|
+
export const SHAPE_WIDTH = 720, SHAPE_HEIGHT = 640;
|
|
9
|
+
const W = SHAPE_WIDTH, H = SHAPE_HEIGHT;
|
|
10
|
+
|
|
11
|
+
/* ——— Drawing helpers, for writing shapes ——— */
|
|
12
|
+
export const roundRect = (g, x, y, w, h, r) => { g.beginPath(); g.roundRect(x, y, w, h, r); };
|
|
13
|
+
export const solid = (g) => { g.globalCompositeOperation = 'source-over'; };
|
|
14
|
+
export const hollow = (g) => { g.globalCompositeOperation = 'destination-out'; };
|
|
15
|
+
export const stroke = (g, x1, y1, x2, y2, width) => { g.lineWidth = width; g.lineCap = 'round'; g.beginPath(); g.moveTo(x1, y1); g.lineTo(x2, y2); g.stroke(); };
|
|
16
|
+
|
|
17
|
+
/* ——— Sampling ——— */
|
|
18
|
+
const mask = (draw) => {
|
|
19
|
+
const c = document.createElement('canvas'); c.width = W; c.height = H;
|
|
20
|
+
const g = c.getContext('2d', { willReadFrequently: true }); g.fillStyle = '#000'; g.strokeStyle = '#000'; draw(g);
|
|
21
|
+
return g.getImageData(0, 0, W, H).data;
|
|
22
|
+
};
|
|
23
|
+
const inside = (d, x, y) => { x = Math.round(x); y = Math.round(y); return x >= 0 && y >= 0 && x < W && y < H && d[(y * W + x) * 4 + 3] > 128; };
|
|
24
|
+
const fill = (d, De) => {
|
|
25
|
+
const pts = [], cell = new Map(), C = De;
|
|
26
|
+
const key = (i, j) => i * 1000 + j;
|
|
27
|
+
const add = (x, y) => { const k = key(Math.floor(x / C), Math.floor(y / C)); (cell.get(k) || cell.set(k, []).get(k)).push([x, y]); pts.push([x, y]); };
|
|
28
|
+
const near = (x, y, r) => { const cx = Math.floor(x / C), cy = Math.floor(y / C), n = Math.ceil(r / C); for (let i = -n; i <= n; i++) for (let j = -n; j <= n; j++) { const l = cell.get(key(cx + i, cy + j)); if (l) for (const q of l) if ((q[0] - x) ** 2 + (q[1] - y) ** 2 < r * r) return true; } return false; };
|
|
29
|
+
// the outline, tight, for legibility…
|
|
30
|
+
for (let y = 0; y < H; y += 2) for (let x = 0; x < W; x += 2) {
|
|
31
|
+
if (!inside(d, x, y) || (inside(d, x + 4, y) && inside(d, x - 4, y) && inside(d, x, y + 4) && inside(d, x, y - 4))) continue;
|
|
32
|
+
if (!near(x, y, De)) add(x, y);
|
|
33
|
+
}
|
|
34
|
+
// …and the inside, sparse, for the matter (a triangular grid)
|
|
35
|
+
const Di = De * 2.1, dy = Di * 0.866;
|
|
36
|
+
for (let y = Di / 2, r = 0; y < H; y += dy, r++) for (let x = (r % 2 ? Di / 2 : 0) + Di / 4; x < W; x += Di) if (inside(d, x, y) && !near(x, y, De * 1.1)) add(x, y);
|
|
37
|
+
return pts;
|
|
38
|
+
};
|
|
39
|
+
const fontsReady = () => !document.fonts || document.fonts.status === 'loaded';
|
|
40
|
+
const cache = new Map();
|
|
41
|
+
/** Landing points that draw the shape `draw` (720 × 640 frame), about `count` of them.
|
|
42
|
+
The result is cached per shape and count; a shape that changes gives its key, as `draw.key()`
|
|
43
|
+
or as the `key` option (a clock: key = the current minute). A shape that writes text is only cached once the fonts are loaded. */
|
|
44
|
+
export const sampleShape = (draw, count, { key = draw.key ? draw.key() : '' } = {}) => {
|
|
45
|
+
let perShape = cache.get(draw); if (!perShape) cache.set(draw, (perShape = new Map()));
|
|
46
|
+
const k = key + '/' + count;
|
|
47
|
+
if (perShape.has(k)) return perShape.get(k);
|
|
48
|
+
const d = mask(draw); let lo = 4, hi = 64, best = fill(d, 30);
|
|
49
|
+
for (let i = 0; i < 8; i++) { const D = (lo + hi) / 2, p = fill(d, D); if (p.length > count) lo = D; else { hi = D; best = p; } }
|
|
50
|
+
if (fontsReady()) perShape.set(k, best);
|
|
51
|
+
return best;
|
|
52
|
+
};
|
|
53
|
+
/** Fits shape points (720 × 640 frame) into a box of width × height, centred: points relative to the box's top-left corner.
|
|
54
|
+
zoom > 1 lets the shape overflow the box a little. */
|
|
55
|
+
export const fitShape = (points, { width, height }, { zoom = 1, dx = 0, dy = 0 } = {}) => {
|
|
56
|
+
const k = Math.min(width / W, height / H) * zoom, ox = (width - W * k) / 2 + dx, oy = (height - H * k) / 2 + dy;
|
|
57
|
+
return points.map(([x, y]) => [ox + x * k, oy + y * k]);
|
|
58
|
+
};
|