@fluritheme/spider
Version:
A tiny, expressive spider for your website. Delicate webs that follow DOM layers, blinking eyes, and playful pointer reactions.
259 lines • 11.2 kB
JavaScript
import { clamp } from './geometry.js';
export const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
export function curvePoint(from, to, sag, t) {
return {
x: from.x + (to.x - from.x) * t,
y: from.y +
(to.y - from.y) * t +
(sag * (Math.cosh(1.2) - Math.cosh(2.4 * (t - 0.5)))) / (Math.cosh(1.2) - 1),
};
}
export function taskPoint(task, t) {
const route = task.route;
return route
? curvePoint(route.curve.from, route.curve.to, route.curve.sag, route.start + (route.end - route.start) * t)
: curvePoint(task.from, task.to, task.sag, t);
}
function randomSource(seed) {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) | 0;
let n = Math.imul(state ^ (state >>> 15), 1 | state);
n ^= n + Math.imul(n ^ (n >>> 7), 61 | n);
return ((n ^ (n >>> 14)) >>> 0) / 4294967296;
};
}
/** Knots share live curve references. Return trips use only silk already laid. */
class Loom {
random;
tasks = [];
cables = [];
cursor;
constructor(origin, random) {
this.random = random;
this.cursor = origin;
}
knot(cable, t) {
if (t <= 0)
return cable.from;
if (t >= 1)
return cable.to;
const point = {
get x() {
return curvePoint(cable.from, cable.to, cable.sag, t).x;
},
get y() {
return curvePoint(cable.from, cable.to, cable.sag, t).y;
},
};
cable.knots.push({ point, t });
return point;
}
add(to, sag, kind) {
const from = this.cursor;
const cable = {
from,
to,
sag,
knots: [
{ point: from, t: 0 },
{ point: to, t: 1 },
],
};
this.tasks.push({
from,
to,
sag,
lay: true,
kind,
grain: this.random(),
length: distance(from, to) + Math.abs(sag) * 1.2,
});
this.cables.push(cable);
this.cursor = to;
return cable;
}
walk(to) {
if (to === this.cursor)
return;
const graph = new Map();
for (const curve of this.cables) {
const knots = curve.knots.slice().sort((a, b) => a.t - b.t);
for (let i = 1; i < knots.length; i++) {
const a = knots[i - 1], b = knots[i];
const length = Math.abs(b.t - a.t) * (distance(curve.from, curve.to) + Math.abs(curve.sag) * 1.2);
for (const [from, target] of [
[a, b],
[b, a],
]) {
if (!graph.has(from.point))
graph.set(from.point, []);
graph
.get(from.point)
.push({ to: target.point, curve, start: from.t, end: target.t, length });
}
}
}
const costs = new Map([[this.cursor, 0]]);
const previous = new Map();
const pending = new Set([this.cursor]);
while (pending.size) {
const point = [...pending].reduce((a, b) => (costs.get(a) < costs.get(b) ? a : b));
pending.delete(point);
if (point === to)
break;
for (const edge of graph.get(point) ?? []) {
const cost = costs.get(point) + edge.length;
if (cost < (costs.get(edge.to) ?? Infinity)) {
costs.set(edge.to, cost);
previous.set(edge.to, { from: point, edge });
pending.add(edge.to);
}
}
}
if (!previous.has(to)) {
// A new real-world support needs a guy line, not a jump through empty space.
this.add(to, Math.min(22, distance(this.cursor, to) * 0.055), 'frame');
return;
}
const route = [];
for (let point = to; point !== this.cursor;) {
const step = previous.get(point);
route.unshift(step);
point = step.from;
}
for (const { from, edge } of route)
this.tasks.push({
from,
to: edge.to,
sag: 0,
lay: false,
kind: 'tie',
grain: 0,
length: edge.length,
route: { curve: edge.curve, start: edge.start, end: edge.end },
});
this.cursor = to;
}
connect(from, to, sag, kind) {
this.walk(from);
return this.add(to, sag, kind);
}
}
/** Site geometry sets the footprint; a private seed varies tension, branching, gaps and spacing. */
export function buildWeb(origin, anchors, density, span, seed) {
const random = randomSource(seed), between = (a, b) => a + (b - a) * random();
const near = anchors
.filter((a) => a.element.isConnected && distance(a, origin) < Math.min(640, span * 3.5 + 100))
.sort((a, b) => distance(a, origin) - distance(b, origin));
if (near.length < 3)
return null;
const starts = near.filter((p) => p.side !== 'bottom');
const choices = starts.length ? starts : near;
const a = choices[Math.floor(random() * Math.min(5, choices.length))];
const preferred = span * between(0.85, 1.9);
const partners = near
.filter((p) => distance(p, a) > span * 0.65 &&
p.side !== 'bottom' &&
(p.element !== a.element || p.glyph !== a.glyph || p.glyph === undefined))
.sort((p, q) => Math.abs(distance(p, a) - preferred) - Math.abs(distance(q, a) - preferred));
const b = partners[Math.floor(random() * Math.min(4, partners.length))];
if (!b)
return null;
const cross = (p) => Math.abs((b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x));
const thirds = near
.filter((p) => distance(p, a) > 20 && distance(p, b) > 20 && cross(p) > distance(a, b) * 24)
.sort((p, q) => distance(p, b) - distance(q, b));
const c = thirds[Math.floor(random() * Math.min(8, thirds.length))];
if (!c)
return null;
const fourths = near
.filter((p) => p !== a &&
p !== b &&
p !== c &&
distance(p, c) > span * 0.35 &&
cross(p) > distance(a, b) * 18)
.sort((p, q) => distance(p, a) - distance(q, a));
const d = fourths[Math.floor(random() * Math.min(5, fourths.length))] ?? c;
const loom = new Loom(origin, random);
const sag = (from, to, low = 0.06, high = 0.2) => Math.min(70, distance(from, to) * between(low, high));
const top = loom.connect(a, b, sag(a, b), 'frame');
const style = ['drape', 'fan', 'bridge'][Math.floor(random() * 3)];
if (style === 'drape') {
const right = loom.connect(b, c, sag(b, c, 0.025, 0.08), 'frame');
const bottom = loom.connect(c, d, sag(c, d), 'frame');
const left = loom.connect(d, a, sag(d, a, 0.025, 0.08), 'frame');
const rows = [];
const count = Math.round(between(4, 7) + density * 5);
let level = 0;
const intervals = Array.from({ length: count + 1 }, () => between(0.45, 1.7));
const total = intervals.reduce((sum, n) => sum + n, 0);
for (let i = 0; i < count; i++) {
level += intervals[i] / total;
const p = loom.knot(left, clamp(1 - level + between(-0.035, 0.035), 0.03, 0.97));
const q = loom.knot(right, clamp(level + between(-0.055, 0.055), 0.03, 0.97));
const row = loom.connect(i % 2 ? q : p, i % 2 ? p : q, sag(p, q, 0.08, 0.23), 'capture');
rows.push(row);
if (i && random() > 0.2) {
const prior = rows[i - 1], fraction = between(0.1, 0.86);
const knot = loom.knot(row, fraction);
const other = loom.knot(prior, clamp(1 - fraction + between(-0.14, 0.14), 0.06, 0.94));
loom.connect(knot, other, sag(knot, other, 0.03, 0.12), 'tie');
}
}
// Sparse diagonal lacing leaves open pockets instead of filling a uniform grid.
for (let i = 0; i < Math.round(2 + density * 4); i++) {
const first = rows[Math.floor(random() * Math.max(1, rows.length - 2))];
const second = rows[Math.min(rows.length - 1, rows.indexOf(first) + 2)];
const p = loom.knot(first, between(0.12, 0.88)), q = loom.knot(second, between(0.12, 0.88));
loom.connect(p, q, sag(p, q, 0.02, 0.09), 'tie');
}
// An uneven secondary hem ties into the lower foundation.
const p = loom.knot(bottom, between(0.18, 0.8));
loom.connect(p, loom.knot(rows.at(-1), between(0.2, 0.8)), sag(p, c, 0.015, 0.035), 'tie');
}
else if (style === 'fan') {
const side = loom.connect(b, c, sag(b, c, 0.025, 0.09), 'frame');
const base = loom.connect(c, a, sag(c, a, 0.03, 0.1), 'frame');
const hub = loom.knot(top, between(0.16, 0.48));
const spokes = [];
const count = Math.round(between(5, 7) + density * 3);
for (let i = 0; i < count; i++) {
const t = (i + between(0.1, 0.75)) / count;
const end = t < 0.5 ? loom.knot(side, t * 2) : loom.knot(base, (t - 0.5) * 2);
spokes.push(loom.connect(hub, end, sag(hub, end, 0.015, 0.07), 'frame'));
}
const rings = Math.round(between(2, 3) + density * 2);
for (let ring = 0; ring < rings; ring++) {
const level = (ring + between(0.5, 0.9)) / (rings + 0.3);
for (let i = 1; i < spokes.length; i++) {
// A missing sector and offset junctions avoid a stamped, perfect orb.
if (random() < 0.12 + (ring === 0 ? 0.1 : 0))
continue;
const p = loom.knot(spokes[i - 1], clamp(level + between(-0.05, 0.05), 0.08, 0.96));
const q = loom.knot(spokes[i], clamp(level + between(-0.06, 0.06), 0.08, 0.96));
loom.connect(p, q, sag(p, q, 0.12, 0.3), 'capture');
}
}
}
else {
const lower = loom.connect(b, c, sag(b, c, 0.1, 0.22), 'frame');
const returnLine = loom.connect(c, a, sag(c, a, 0.035, 0.14), 'frame');
const braces = [];
const count = Math.round(between(3, 5) + density * 3);
for (let i = 0; i < count; i++) {
const p = loom.knot(top, between(0.08, 0.92));
const q = loom.knot(i % 3 ? lower : returnLine, between(0.1, 0.9));
braces.push(loom.connect(p, q, sag(p, q, 0.07, 0.22), i % 3 ? 'capture' : 'frame'));
}
const countTies = Math.round(between(4, 7) + density * 7);
for (let i = 0; i < countTies; i++) {
const index = Math.floor(random() * (braces.length - 1));
const p = loom.knot(braces[index], between(0.12, 0.88));
const q = loom.knot(braces[index + 1], between(0.12, 0.88));
loom.connect(p, q, sag(p, q, 0.025, 0.11), 'tie');
}
}
return { tasks: loom.tasks, style, ink: a.ink };
}
//# sourceMappingURL=weaving.js.map