UNPKG

@fluritheme/fluri

Version:

Interactive snowfall for websites: DOM-aware snow accumulation, mouse interactions, snow physics, Canvas 2D rendering, and Christmas effects.

526 lines • 22.9 kB
import { createPackingProfile } from '../physics/packing.js'; const eligibleTags = new Set([ 'NAV', 'HEADER', 'BUTTON', 'ARTICLE', 'ASIDE', 'FIGURE', 'IMG', 'DIALOG', 'SECTION', ]); export class SurfaceRegistry { root; config; emit; shed; onInvalidate; surfaces = []; registered = new Map(); excluded = new WeakSet(); dirty = true; observer; resizeObserver; nextId = 0; transforms = new WeakMap(); scrollOffsets = new WeakMap(); positionModes = new WeakMap(); scrollChains = new WeakMap(); lastTime = 0; lastSurfaceEvents = new WeakMap(); lastScan = -Infinity; occlusionCursor = 0; lastOcclusion = -Infinity; clipsDirty = true; geometryScanTime = 0; constructor(root, config, emit, shed, onInvalidate) { this.root = root; this.config = config; this.emit = emit; this.shed = shed; this.onInvalidate = onInvalidate; } start() { const win = this.root.ownerDocument.defaultView; if (!win) return; this.resizeObserver = new win.ResizeObserver(() => { this.clipsDirty = true; this.dirty = true; }); this.resizeObserver.observe(this.root); this.refreshObservers(); this.scan(); } refreshObservers() { this.observer?.disconnect(); this.observer = undefined; const win = this.root.ownerDocument.defaultView; if (!win) return; if (this.config().dom.observeMutations) { this.observer = new win.MutationObserver((records) => { if (records.some((r) => !(r.target instanceof win.Element && r.target.closest('[data-fluri-root]')))) this.invalidate(); }); this.observer.observe(this.root, { childList: true, subtree: true, attributes: true, attributeFilter: [ 'style', 'class', 'hidden', 'open', 'data-weather-surface', 'data-weather-ignore', 'data-weather-layer', 'data-weather-material', 'data-weather-snow-depth', 'data-weather-accumulation', 'data-weather-priority', 'data-weather-edge', 'data-weather-clip', ], }); } } invalidate() { this.dirty = true; this.clipsDirty = true; this.onInvalidate?.(); } invalidateGeometry() { this.clipsDirty = true; this.onInvalidate?.(); } register(element, options = {}) { this.registered.set(element, { ...options }); this.excluded.delete(element); this.invalidate(); return () => this.unregister(element); } unregister(element) { this.registered.delete(element); this.excluded.add(element); this.invalidate(); } update(time, physicalMotion = true) { const begin = performance.now(); if (this.dirty && time - this.lastScan > 0.1) { this.scan(); this.lastScan = time; } const dt = this.lastTime ? Math.max(1 / 240, Math.min(0.1, time - this.lastTime)) : 1 / 60; for (const surface of this.surfaces) { const oldX = surface.x, oldY = surface.y, oldWidth = surface.width, oldVx = surface.velocity.x, oldVy = surface.velocity.y; const scroll = this.scrollPosition(surface.element), previousScroll = this.scrollOffsets.get(surface.element) ?? scroll; this.measure(surface, this.clipsDirty); const mode = this.positionModes.get(surface.element); let dx = surface.x - oldX, dy = surface.y - oldY; if (mode !== 'fixed') { const sx = scroll.x - previousScroll.x, sy = scroll.y - previousScroll.y; // Sticky elements can consume only part of a scroll while transitioning to their constraint. const compensate = (delta, scrollDelta) => delta * scrollDelta < 0 ? delta + Math.sign(scrollDelta) * Math.min(Math.abs(delta), Math.abs(scrollDelta)) : delta; dx = mode === 'sticky' ? compensate(dx, sx) : dx + sx; dy = mode === 'sticky' ? compensate(dy, sy) : dy + sy; } this.scrollOffsets.set(surface.element, scroll); surface.previousVelocity = { x: oldVx, y: oldVy }; surface.velocity = { x: dx / dt, y: dy / dt }; surface.acceleration = { x: (surface.velocity.x - oldVx) / dt, y: (surface.velocity.y - oldVy) / dt, }; if (!physicalMotion) { surface.previousVelocity = { x: 0, y: 0 }; surface.velocity = { x: 0, y: 0 }; surface.acceleration = { x: 0, y: 0 }; } if (Math.abs(oldWidth - surface.width) > 1) this.resizeProfile(surface, oldWidth); if (Math.abs(oldX - surface.x) + Math.abs(oldY - surface.y) > 2 && time - (this.lastSurfaceEvents.get(surface.element) ?? -Infinity) > 0.1) { this.lastSurfaceEvents.set(surface.element, time); this.emit('surface:updated', { id: surface.id }); } } if (this.config().dom.respectLayers && (this.clipsDirty || time - this.lastOcclusion > 0.12)) { this.updateOcclusion(); this.lastOcclusion = time; } else if (!this.config().dom.respectLayers) { for (const s of this.surfaces) { delete s.exposed; s.occluded = false; } } this.lastTime = time; this.clipsDirty = false; this.geometryScanTime = performance.now() - begin; } resizeProfile(s, oldWidth) { const count = Math.max(2, Math.ceil(s.width / 6)), bins = new Float32Array(count); const oldBin = oldWidth / s.profile.length, newBin = s.width / count; let detached = 0; // Snow occupies physical local coordinates; a new extension starts bare, unsupported snow falls. for (let i = 0; i < s.profile.length; i++) { const mass = s.profile[i] ?? 0, start = i * oldBin, end = (i + 1) * oldBin; const retained = Math.max(0, Math.min(end, s.width) - start); detached += mass * (1 - retained / oldBin); for (let j = Math.max(0, Math.floor(start / newBin)); j < Math.min(count, Math.ceil(end / newBin)); j++) { const overlap = Math.max(0, Math.min(end, (j + 1) * newBin, s.width) - Math.max(start, j * newBin)); bins[j] = (bins[j] ?? 0) + (mass * overlap) / oldBin; } } s.profile = bins; s.binSize = newBin; s.packing = this.packing(s); delete s.exposed; if (detached > 0.01) { const extension = Math.max(1, oldWidth - s.width); const fragment = { ...s, x: s.x + Math.cos(s.angle) * s.width, y: s.y + Math.sin(s.angle) * s.width, width: extension, profile: new Float32Array([detached]), binSize: extension, }; this.shed(fragment, 'resize'); } } scan() { const config = this.config(); const doc = this.root.ownerDocument; const win = doc.defaultView; if (!win) return; // Selector errors are surfaced before mutating the active registry. for (const sel of [...config.dom.include, ...config.dom.exclude]) doc.querySelector(sel); const candidates = new Set(this.registered.keys()); const walk = (root) => { const walker = doc.createTreeWalker(root, 1); let count = 0; let node = walker.nextNode(); while (node && count++ < 12000) { const element = node; if (element.closest('[data-fluri-root]')) { node = walker.nextNode(); continue; } const explicit = element.hasAttribute('data-weather-surface') || config.dom.include.some((sel) => element.matches(sel)); if (explicit || (config.dom.detection === 'auto' && (eligibleTags.has(element.tagName) || element.classList.contains('card') || element.getAttribute('role') === 'dialog'))) candidates.add(element); if (element.shadowRoot) walk(element.shadowRoot); node = walker.nextNode(); } }; walk(this.root); if (this.root.hasAttribute('data-weather-surface')) candidates.add(this.root); const old = new Map(this.surfaces.map((s) => [s.element, s])); const next = []; const sorted = [...candidates].sort((a, b) => (this.registered.get(b)?.priority ?? (Number(b.getAttribute('data-weather-priority')) || 0)) - (this.registered.get(a)?.priority ?? (Number(a.getAttribute('data-weather-priority')) || 0))); for (const element of sorted) { if (next.length >= config.dom.maxSurfaces) break; if (!element.isConnected || this.excluded.has(element) || element.closest('[data-weather-ignore]') || config.dom.exclude.some((sel) => element.closest(sel))) continue; const style = win.getComputedStyle(element); const rect = element.getBoundingClientRect(); if (style.display === 'none' || style.visibility !== 'visible' || Number(style.opacity) === 0 || rect.width < 24 || rect.height < 8) continue; const explicit = this.registered.has(element) || element.hasAttribute('data-weather-surface'); if (!explicit && rect.height > win.innerHeight * 0.9 && rect.width > win.innerWidth * 0.9) continue; const options = this.readOptions(element, this.registered.get(element)); const existing = old.get(element); const s = existing ?? { id: `fluri-${++this.nextId}`, element, x: 0, y: 0, width: rect.width, height: rect.height, angle: 0, normal: { x: 0, y: -1 }, velocity: { x: 0, y: 0 }, acceleration: { x: 0, y: 0 }, profile: new Float32Array(Math.max(2, Math.ceil(rect.width / 6))), binSize: 6, layer: 'content', paintOrder: 0, clipRegions: [], radius: 0, material: 'flat', maxDepth: config.snow.accumulation.maxDepth, accumulation: true, visible: true, confidence: 'medium', options, }; s.options = options; s.material = options.material ?? 'flat'; s.accumulation = options.accumulation ?? true; s.maxDepth = options.maxSnowDepth ?? Math.min(config.snow.accumulation.maxDepth, Math.max(2, rect.height * 0.4), rect.width * 0.18); s.layer = options.layer ?? 'content'; s.opaque = style.backgroundColor !== 'rgba(0, 0, 0, 0)' || style.backgroundImage !== 'none' || ['IMG', 'DIALOG', 'IFRAME'].includes(element.tagName); this.transforms.set(element, style.transform); this.positionModes.set(element, style.position); this.cacheScrollChain(element); s.paintOrder = this.paintOrder(element); s.radius = Math.min(rect.width / 2, rect.height / 2, Math.max(0, parseFloat(style.borderTopLeftRadius) || 0)); if (!existing) { this.measure(s, true); s.packing = this.packing(s); this.scrollOffsets.set(element, this.scrollPosition(element)); } next.push(s); old.delete(element); if (!existing) { this.resizeObserver?.observe(element); this.emit('surface:added', { id: s.id }); } } for (const s of old.values()) { this.resizeObserver?.unobserve(s.element); if (config.dom.removedSurface === 'shed') this.shed(s); this.emit('surface:removed', { id: s.id }); } this.surfaces.splice(0, this.surfaces.length, ...next); this.dirty = false; } updateOcclusion() { const doc = this.root.ownerDocument, win = doc.defaultView; if (!win || !doc.elementsFromPoint || !this.surfaces.length) return; let budget = 72, visited = 0; const opaque = new WeakMap(); while (budget > 0 && visited < this.surfaces.length) { const surface = this.surfaces[this.occlusionCursor++ % this.surfaces.length]; visited++; if (!surface?.visible) continue; const samples = Math.min(budget, 12, Math.max(3, Math.ceil(surface.width / 32))); const exposed = new Uint8Array(surface.profile.length); const hits = []; for (let index = 0; index < samples; index++) { const localX = ((index + 0.5) * surface.width) / samples; const x = surface.x + Math.cos(surface.angle) * localX - Math.sin(surface.angle) * 2; const y = surface.y + Math.sin(surface.angle) * localX + Math.cos(surface.angle) * 2; let clear = true; if (x < 0 || x >= win.innerWidth || y < 0 || y >= win.innerHeight) clear = false; else for (const element of doc.elementsFromPoint(x, y)) { if (element.closest('[data-fluri-root]')) continue; if (element === surface.element || surface.element.contains(element)) break; if (element.contains(surface.element)) continue; let blocks = opaque.get(element); if (blocks === undefined) { const style = win.getComputedStyle(element); const alpha = style.backgroundColor.match(/rgba?\([^)]*[,/]\s*([\d.]+)\s*\)$/)?.[1]; const painted = style.backgroundImage !== 'none' || ['IMG', 'IFRAME', 'CANVAS', 'VIDEO'].includes(element.tagName) || (style.backgroundColor !== 'rgba(0, 0, 0, 0)' && (alpha === undefined || Number(alpha) >= 0.95)); blocks = style.visibility === 'visible' && Number(style.opacity) >= 0.95 && painted; opaque.set(element, blocks); } if (blocks) { clear = false; break; } } hits.push(clear); budget--; } for (let bin = 0; bin < exposed.length; bin++) exposed[bin] = hits[Math.min(samples - 1, Math.floor(((bin + 0.5) / exposed.length) * samples))] ? 1 : 0; surface.exposed = exposed; surface.occluded = !hits.some(Boolean); } } packing(surface) { let seed = this.config().seed; for (const code of surface.id) seed = Math.imul(seed ^ code.charCodeAt(0), 16777619); return createPackingProfile(surface.profile.length, surface.binSize, seed >>> 0); } cacheScrollChain(element) { const doc = element.ownerDocument, win = doc.defaultView; const parents = []; let includeWindow = true, parent = element.parentElement; while (parent && parent !== doc.body && parent !== doc.documentElement) { parents.push(parent); if (win?.getComputedStyle(parent).position === 'fixed') { includeWindow = false; break; } parent = parent.parentElement; } this.scrollChains.set(element, { parents, window: includeWindow }); } scrollPosition(element) { const win = element.ownerDocument.defaultView, chain = this.scrollChains.get(element); let x = chain?.window ? (win?.scrollX ?? 0) : 0, y = chain?.window ? (win?.scrollY ?? 0) : 0; for (const parent of chain?.parents ?? []) { x += parent.scrollLeft; y += parent.scrollTop; } return { x, y }; } readOptions(el, override = {}) { const result = {}; const material = el.getAttribute('data-weather-material'); if (material && ['flat', 'slippery', 'sticky', 'no-accumulation'].includes(material)) result.material = material; const layer = el.getAttribute('data-weather-layer'); if (layer && ['background', 'content', 'foreground'].includes(layer)) result.layer = layer; const depth = el.getAttribute('data-weather-snow-depth'); if (depth !== null && Number.isFinite(Number(depth))) result.maxSnowDepth = Math.max(0, Math.min(80, Number(depth))); const priority = el.getAttribute('data-weather-priority'); if (priority !== null && Number.isFinite(Number(priority))) result.priority = Number(priority); if (el.hasAttribute('data-weather-accumulation')) result.accumulation = el.getAttribute('data-weather-accumulation') !== 'false'; if (el.hasAttribute('data-weather-clip')) result.clip = el.getAttribute('data-weather-clip') !== 'false'; if (el.getAttribute('data-weather-edge') === 'bottom') result.edge = 'bottom'; return { ...result, ...override }; } measure(s, clips) { const rect = s.element.getBoundingClientRect(); const win = s.element.ownerDocument.defaultView; s.x = rect.left; s.y = s.options.edge === 'bottom' ? rect.bottom : rect.top; s.width = rect.width; s.height = rect.height; s.angle = 0; const quad = s.element.getBoxQuads?.()[0]; if (quad) { const a = s.options.edge === 'bottom' ? quad.p4 : quad.p1; const b = s.options.edge === 'bottom' ? quad.p3 : quad.p2; s.x = a.x; s.y = a.y; s.width = Math.hypot(b.x - a.x, b.y - a.y); s.angle = Math.atan2(b.y - a.y, b.x - a.x); s.confidence = 'high'; } else if (win) { if (clips || s.element.getAnimations().some((a) => a.playState === 'running')) this.transforms.set(s.element, win.getComputedStyle(s.element).transform); const transform = this.transforms.get(s.element) ?? 'none'; if (transform !== 'none') { const matrix = new win.DOMMatrixReadOnly(transform); const angle = Math.atan2(matrix.b, matrix.a); // Rect + affine local transform recovers common rotate/scale cards; skew/3D stays approximate. if (Math.abs(angle) > 1e-7 && Math.abs(angle) < 1.2 && Math.abs(matrix.c + matrix.b) < 0.05 && matrix.is2D) { const el = s.element; const width = (el.offsetWidth || rect.width) * Math.hypot(matrix.a, matrix.b); const height = (el.offsetHeight || rect.height) * Math.hypot(matrix.c, matrix.d); const cy = rect.top + rect.height / 2, cx = rect.left + rect.width / 2; const sign = s.options.edge === 'bottom' ? 1 : -1; s.angle = angle; s.width = width; s.x = cx - (Math.cos(angle) * width) / 2 - (sign * Math.sin(angle) * height) / 2; s.y = cy - (Math.sin(angle) * width) / 2 + (sign * Math.cos(angle) * height) / 2; s.confidence = 'low'; } } } s.normal = { x: Math.sin(s.angle), y: -Math.cos(s.angle) }; s.binSize = s.width / s.profile.length; s.visible = s.element.isConnected && rect.bottom >= 0 && rect.top <= (win?.innerHeight ?? 0) && rect.right >= 0 && rect.left <= (win?.innerWidth ?? 0) && rect.width > 0; if (clips) s.clipRegions = this.clips(s.element); } clips(el) { const regions = []; const win = el.ownerDocument.defaultView; if (!win) return regions; let parent = el.parentElement; while (parent && parent !== el.ownerDocument.documentElement) { const style = win.getComputedStyle(parent); if (/hidden|clip|scroll|auto/.test(`${style.overflowX} ${style.overflowY}`)) { const r = parent.getBoundingClientRect(); regions.push({ x: r.left, y: r.top, width: r.width, height: r.height, radius: parseFloat(style.borderRadius) || 0, }); } parent = parent.parentElement; } return regions; } paintOrder(el) { const win = el.ownerDocument.defaultView; if (!win) return 0; let order = 0, weight = 1, parent = el; while (parent) { const st = win.getComputedStyle(parent); const z = Number(st.zIndex); if (Number.isFinite(z)) order += Math.max(-1000, Math.min(1000, z)) * weight; weight *= 2; parent = parent.parentElement; } return order; } destroy() { this.observer?.disconnect(); this.resizeObserver?.disconnect(); this.surfaces.length = 0; this.registered.clear(); } } //# sourceMappingURL=SurfaceRegistry.js.map