UNPKG

@fluritheme/spider

Version:

A tiny, expressive spider for your website. Delicate webs that follow DOM layers, blinking eyes, and playful pointer reactions.

476 lines • 21.2 kB
import { clamp, escapeOffset } from './geometry.js'; import { isVisible, measureSurface } from './layers.js'; import { resolveOptions } from './options.js'; import { paintSpider, paintWeb } from './paint.js'; import { pageObjects, outsideObjects } from './objects.js'; import { makeResident, stepResident, paintThreads } from './resident.js'; import { measureNetwork } from './silk.js'; import { silkWhite, strokeSilk } from './silk-paint.js'; /** Create a decorative, non-interactive overlay. Importing and calling in SSR are safe no-ops. */ export function createSpider(input = {}) { const options = resolveOptions(input); let state = 'idle'; let reduced = options.motion === 'none'; let surfaces = [], residents = []; let roamers = [], objects = [], network = { anchors: [], retained: new Map() }; let worldObjects = []; const snapshot = () => ({ state, reducedMotion: reduced, webs: surfaces.length, spiders: options.roam ? roamers.map(({ x, y, scared, visible, behavior, threads }) => ({ x, y, scared, visible, behavior, threads: threads.length, })) : residents.map(({ x, y, scared, visible }) => ({ x, y, scared, visible, behavior: 'hanging', threads: 0, })), }); const noop = { refresh() { }, clearWebs() { }, update(patch) { resolveOptions({ ...options, ...patch }); }, pause() { }, resume() { }, destroy() { state = 'destroyed'; }, getState: snapshot, }; if (typeof window === 'undefined' || typeof document === 'undefined') return noop; const win = window, doc = document; const root = input.root ?? doc; if (root !== doc && (root.nodeType !== 1 || root.ownerDocument !== doc)) throw new TypeError('Spider: root must belong to the current document.'); if (input.targets !== undefined && typeof input.targets !== 'string' && !Array.isArray(input.targets)) throw new TypeError('Spider: targets must be a selector or an array of elements.'); doc.querySelector(options.objects); if (typeof input.targets === 'string') root.querySelector(input.targets); // Fail before installing listeners for an invalid selector. if (options.color !== 'auto' && !CSS.supports('color', options.color)) throw new TypeError('Spider: color must be a valid CSS color.'); const canvas = doc.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) return noop; canvas.setAttribute('data-fluri-spider', ''); canvas.setAttribute('aria-hidden', 'true'); canvas.style.cssText = `position:fixed;inset:0;width:100%;height:100%;pointer-events:none!important;z-index:${options.zIndex};display:block;contain:strict;`; (doc.body ?? doc.documentElement).append(canvas); const media = win.matchMedia('(prefers-reduced-motion: reduce)'); reduced = options.motion === 'none' || (options.motion === 'system' && media.matches); let frame = null, dirty = true, lastTime = 0, clock = 0, width = 0, height = 0, dpr = 1; let lastMeasure = -Infinity; let silkCleared = false; let pointer = null, pointerUntil = Infinity; const birth = new WeakMap(); const cleanup = []; let resizeObserver; let observed = []; const select = () => { let elements; if (typeof input.targets === 'string') elements = [...root.querySelectorAll(input.targets)]; else if (input.targets) elements = [...input.targets]; else { elements = [...root.querySelectorAll('[data-spider-web]')]; if (!elements.length) elements = [...root.querySelectorAll('article, aside, section')]; } return elements .filter((el) => el instanceof win.HTMLElement && root.contains(el) && !el.closest('[data-spider-ignore]')) .slice(0, options.maxWebs); }; function measure() { dirty = false; width = win.innerWidth; height = win.innerHeight; // Bound backing-store memory on large/high-DPI displays (at most six million pixels). dpr = Math.min(win.devicePixelRatio || 1, 2, Math.sqrt(6_000_000 / Math.max(1, width * height))); const pixelWidth = Math.max(1, Math.round(width * dpr)), pixelHeight = Math.max(1, Math.round(height * dpr)); if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) { canvas.width = pixelWidth; canvas.height = pixelHeight; } const targets = select(); if (resizeObserver && (targets.length !== observed.length || targets.some((el, i) => el !== observed[i]))) { resizeObserver.disconnect(); resizeObserver.observe(doc.documentElement); targets.forEach((el) => resizeObserver?.observe(el)); observed = targets; } const oldSurfaces = new Map(surfaces.map((s) => [s.element, s])); surfaces = targets.flatMap((el, index) => { if (!birth.has(el)) birth.set(el, clock); const surface = measureSurface(el, index, options.webSize, options.color, birth.get(el), doc, win); return surface ? [surface] : []; }); const previous = new Map(residents.map((r) => [r.element, r])); residents = surfaces.slice(0, options.spiders).map((s) => { const resident = previous.get(s.element); const old = oldSurfaces.get(s.element); if (resident && old) { resident.x += s.home.x - old.home.x; resident.y += s.home.y - old.home.y; return resident; } return { element: s.element, x: s.home.x, y: s.home.y, scared: false, visible: true }; }); if (options.roam) { objects = pageObjects(doc, win, options.objects); network = measureNetwork(root, targets, network); worldObjects = objects.map((o) => ({ ...o, box: { ...o.box, x: o.box.x + win.scrollX, y: o.box.y + win.scrollY }, center: { x: o.center.x + win.scrollX, y: o.center.y + win.scrollY }, })); if (!roamers.length) roamers = Array.from({ length: options.spiders }, (_, index) => { const first = surfaces[index]; const r = 'getBoundingClientRect' in root ? root.getBoundingClientRect() : null; const point = options.territory === 'root' && r ? { x: r.left + r.width * 0.6 + win.scrollX, y: r.top + Math.min(r.height * 0.4, 220) + win.scrollY, } : first && !root.querySelector('h1') ? { x: first.home.x + win.scrollX, y: first.home.y + win.scrollY } : { x: width * (0.73 - index * 0.12) + win.scrollX, y: Math.min(height * 0.35, 280) + win.scrollY, }; const nearby = network.anchors.filter((a) => a.y >= win.scrollY + 90 && a.y <= win.scrollY + height * 0.7 && a.x >= 35 && a.x <= width - 35); const landing = nearby.sort((a, b) => Math.hypot(a.x - point.x, a.y - point.y) - Math.hypot(b.x - point.x, b.y - point.y))[index] ?? point; return makeResident(landing, index, !reduced && options.entrance === 'silk', options.territory === 'root' && r ? r.top + win.scrollY : 0); }); } lastMeasure = win.performance.now(); } function draw(dt) { if (!ctx) return; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, width, height); if (win.performance.now() > pointerUntil) pointer = null; surfaces.forEach((surface, index) => { ctx.save(); ctx.clip(surface.clip); ctx.beginPath(); ctx.roundRect(surface.box.x, surface.box.y, surface.box.width, surface.box.height, surface.radius); ctx.clip(); const progress = reduced || !options.weave ? 1 : clamp((clock - surface.born) / 1.8, 0, 1); if (!options.roam && !silkCleared) paintWeb(ctx, surface, progress, options.webOpacity); const resident = options.roam ? undefined : residents[index]; if (resident) { const fear = !reduced && pointer ? escapeOffset(surface.home, pointer, options.fearRadius) : { x: 0, y: 0, scared: false }; const sway = reduced ? 0 : Math.sin(clock * 1.1 + index * 2) * 4; const x = clamp(surface.home.x + fear.x + sway, surface.box.x + 22 * options.size, surface.box.x + surface.box.width - 22 * options.size); const y = clamp(surface.home.y + fear.y + (reduced ? 0 : Math.sin(clock * 1.6 + index) * 3), surface.box.y + 18 * options.size, surface.box.y + surface.box.height - 20 * options.size); const ease = reduced || dt === 0 ? 1 : 1 - Math.exp(-dt * (fear.scared ? 13 : 3) * options.speed); resident.x += (x - resident.x) * ease; resident.y += (y - resident.y) * ease; resident.scared = fear.scared; resident.visible = isVisible(surface.element, resident, doc); if (resident.visible) { ctx.globalAlpha = reduced || !options.weave ? 1 : clamp((clock - surface.born - 0.8) / 0.5, 0, 1); paintSpider(ctx, resident, surface.anchor, options.color === 'auto' ? '#6c5140' : surface.ink, options.size, clock + index * 1.7, reduced ? null : pointer, fear.scared ? 1 : 0, reduced, 0, 0, false, surface.ink, options.webOpacity); } } ctx.restore(); }); if (options.roam) { const scroll = { x: win.scrollX, y: win.scrollY }; ctx.save(); ctx.translate(-scroll.x, -scroll.y); // Webs are laid only by the resident's actual construction itinerary. ctx.restore(); const r = 'getBoundingClientRect' in root ? root.getBoundingClientRect() : null; const territory = options.territory === 'root' && r ? { x: r.left + scroll.x, y: r.top + scroll.y, width: r.width, height: r.height } : { x: 0, y: 0, width: Math.max(width, doc.documentElement.scrollWidth), height: Math.max(height, doc.documentElement.scrollHeight), }; roamers.forEach((resident, index) => { if (!reduced && state === 'running' && dt > 0 && territory.width > 80 && territory.height > 80) stepResident(resident, dt, clock, pointer ? { x: pointer.x + scroll.x, y: pointer.y + scroll.y } : null, options.fearRadius, options.speed, territory, worldObjects, index, network.anchors, options.webDensity, options.webSize, options.color); const ink = options.color === 'auto' ? '#6c5140' : options.color; resident.ink = ink; resident.silkInk = options.color === 'auto' ? silkWhite : options.color; const screen = { x: resident.x - scroll.x, y: resident.y - scroll.y }; ctx.save(); ctx.translate(-scroll.x, -scroll.y); paintThreads(ctx, resident.threads, options.webOpacity); if (resident.dragFrom && resident.behavior !== 'descending' && !resident.activeThread && !resident.task && options.webOpacity > 0) { const distance = Math.hypot(resident.x - resident.dragFrom.x, resident.y - resident.dragFrom.y); if (distance > 3) { ctx.strokeStyle = resident.silkInk; ctx.lineWidth = 0.65; ctx.globalAlpha = options.webOpacity * 0.7; ctx.beginPath(); ctx.moveTo(resident.dragFrom.x, resident.dragFrom.y); ctx.quadraticCurveTo((resident.dragFrom.x + resident.x) / 2, (resident.dragFrom.y + resident.y) / 2 + Math.min(26, distance * 0.06), resident.x, resident.y); strokeSilk(ctx, resident.silkInk, 0.65, options.webOpacity * 0.7); } } if (resident.tether && options.webOpacity > 0 && resident.behavior !== 'descending') { ctx.strokeStyle = resident.silkInk; ctx.lineWidth = 0.7; ctx.globalAlpha = options.webOpacity; ctx.beginPath(); ctx.moveTo(resident.tether.x, resident.tether.y); ctx.quadraticCurveTo(resident.tether.x - 8, (resident.tether.y + resident.landing.y) / 2, resident.landing.x, resident.landing.y); strokeSilk(ctx, resident.silkInk, 0.7, options.webOpacity); } ctx.restore(); ctx.globalAlpha = 1; ctx.save(); if (options.territory === 'root' && r) { ctx.beginPath(); ctx.rect(territory.x - scroll.x, territory.y - scroll.y, Math.max(0, territory.width), Math.max(0, territory.height)); ctx.clip(); } if (resident.behind) outsideObjects(ctx, objects, width, height); resident.visible = screen.x >= 0 && screen.x <= width && screen.y >= 0 && screen.y <= height && (!resident.behind || !objects.some((object) => ctx.isPointInPath(object.path, screen.x * dpr, screen.y * dpr))); paintSpider(ctx, screen, resident.behavior === 'descending' && resident.tether && options.webOpacity > 0 ? { x: resident.tether.x - scroll.x, y: resident.tether.y - scroll.y } : null, ink, options.size, clock + index * 1.7, reduced ? null : pointer, resident.scared ? 1 : 0, reduced, resident.angle, resident.gait, resident.velocity > 4, resident.silkInk, options.webOpacity); ctx.restore(); }); } } function schedule() { if (frame === null && state !== 'destroyed' && !doc.hidden) frame = win.requestAnimationFrame(tick); } function invalidate() { dirty = true; schedule(); } function tick(time) { frame = null; if (state === 'destroyed' || doc.hidden) return; const dt = lastTime ? Math.min((time - lastTime) / 1000, 0.05) : 0; lastTime = time; if (state === 'running' && !reduced) clock += dt * options.speed; if (dirty || (options.observe && state === 'running' && !reduced && time - lastMeasure > 750)) measure(); draw(state === 'running' ? dt : 0); if (state === 'running' && !reduced) schedule(); } function listen(target, name, handler, capture = false) { target.addEventListener(name, handler, { passive: true, capture }); cleanup.push(() => target.removeEventListener(name, handler, capture)); } function clearSilk() { silkCleared = true; roamers.forEach((r) => { r.threads = []; r.tasks = []; r.task = null; r.activeThread = null; r.tether = null; r.dragFrom = null; r.tip = null; r.behavior = 'resting'; r.until = clock + 1.5; r.velocity = 0; }); schedule(); } listen(win, 'resize', invalidate); listen(doc, 'scroll', () => { pointer = null; invalidate(); }, true); listen(doc, 'pointermove', (event) => { if (state !== 'running' || reduced) return; const e = event; pointer = { x: e.clientX, y: e.clientY }; pointerUntil = e.pointerType === 'touch' ? win.performance.now() + 900 : Infinity; }); listen(doc, 'pointerdown', (event) => { if (state !== 'running' || reduced) return; const e = event; pointer = { x: e.clientX, y: e.clientY }; pointerUntil = win.performance.now() + 900; }); listen(doc, 'pointerleave', () => { pointer = null; }); listen(win, 'blur', () => { pointer = null; }); listen(doc, 'visibilitychange', () => { lastTime = 0; if (doc.hidden && frame !== null) { win.cancelAnimationFrame(frame); frame = null; } else invalidate(); }); listen(media, 'change', () => { reduced = options.motion === 'none' || (options.motion === 'system' && media.matches); pointer = null; if (reduced) roamers.forEach((r) => { if (r.behavior === 'descending') { r.x = r.landing.x; r.y = r.landing.y; r.behavior = 'resting'; r.until = clock + 1; } r.scared = false; }); lastTime = 0; invalidate(); }); if (options.observe) { const mutation = new MutationObserver((records) => { if (records.some((r) => { const el = r.target.nodeType === 1 ? r.target : r.target.parentElement; return (r.target !== canvas && !canvas.contains(r.target) && !el?.closest('[data-spider-ui]')); })) invalidate(); }); mutation.observe(root === doc ? doc.documentElement : root, { subtree: true, childList: true, attributes: true, attributeFilter: [ 'class', 'style', 'hidden', 'open', 'data-spider-web', 'data-spider-ignore', ], }); cleanup.push(() => mutation.disconnect()); resizeObserver = new ResizeObserver(invalidate); cleanup.push(() => resizeObserver?.disconnect()); } void doc.fonts.ready.then(() => { if (state !== 'destroyed') invalidate(); }); state = 'running'; measure(); draw(0); if (!reduced) schedule(); return { update(patch) { if (state === 'destroyed') return; const allowed = [ 'size', 'speed', 'fearRadius', 'webOpacity', 'webDensity', 'webSize', 'color', ]; if (Object.keys(patch).some((key) => !allowed.includes(key))) throw new TypeError('Spider: update accepts appearance and behavior options only.'); const next = resolveOptions({ ...options, ...patch }); if (next.color !== 'auto' && !CSS.supports('color', next.color)) throw new TypeError('Spider: color must be a valid CSS color.'); Object.assign(options, next); invalidate(); }, clearWebs() { if (state !== 'destroyed') clearSilk(); }, refresh() { if (state !== 'destroyed') invalidate(); }, pause() { if (state === 'running') { state = 'paused'; pointer = null; if (frame !== null) win.cancelAnimationFrame(frame); frame = null; } }, resume() { if (state === 'paused') { state = 'running'; lastTime = 0; invalidate(); } }, destroy() { if (state === 'destroyed') return; state = 'destroyed'; if (frame !== null) win.cancelAnimationFrame(frame); frame = null; cleanup.forEach((fn) => fn()); canvas.remove(); surfaces = []; residents = []; observed = []; roamers = []; objects = []; network = { anchors: [], retained: new Map() }; worldObjects = []; }, getState: snapshot, }; } //# sourceMappingURL=index.js.map