import { h, Fragment, cloneElement } from 'preact'; import { useState, useEffect, useRef, useMemo } from 'preact/hooks'; import ColorHash from 'color-hash'; import css from './ui/css.js'; css` html { overflow: hidden; } `; const colors = new ColorHash({ lightness: 0.8 }); const abs = Math.abs; const clamp = (val) => Math.max(0, Math.min(val, 1)); const smoothstep = (min, max, value) => { const x = clamp((value - min)/(max - min)); return x*x*(3 - 2*x); }; const lag = (value, t) => { const a = Math.abs(value); if (a < t) return 0; if (a > 1) return value; const i = a - t; const res = i * (1 + a - i); return res * Math.sign(value); }; const keys = (a, b) => ( new Set([ ...Object.keys(a), ...Object.keys(b) ]) ); const addState = (a, b) => { if (!b) return a; const res = {}; for (const key of keys(a, b)) { const sum = (a[key] ?? 0) + (b[key] ?? 0) if (sum) res[key] = sum; } return res; }; const lerpState = (a, b, coeff) => { if (a === b) return a; const res = {}; for (const key of keys(a, b)) { const aval = a[key] ?? 0; const delta = (b[key] ?? 0) - aval; if (Math.abs(delta) < 0.01) res[key] = b[key]; else res[key] = aval + delta * coeff; } return res; }; const AnimationRoot = ({ children, fallback, commit, interactionRef }) => { children = Array.isArray(children) ? children : [children]; const interactionIndex = useRef(0); const [offsets, setOffsets] = useState({}); const [interactions, setInteractions] = useState({}); interactionRef.current = { start: (key) => { const id = interactionIndex.current++; setInteractions((i) => ({ ...i, [id]: { key, value: 0 } })); return id; }, update: (id, value) => { setInteractions((i) => ({ ...i, [id]: { ...i[id], value } })); }, end: (id, value) => { // move offset by nearerst integer, // subtract from interaction to allow fade-out const key = interactions[id].key; const target = Math.round(value); setOffsets((o) => addState(o, { [key]: target })); setInteractions((i) => ({ ...i, [id]: { ...i[id], value: value - target, from: value - target, expiry: 300 } })); }, }; useEffect(() => { if (!Object.values(interactions).find(int => int.expiry)) return; const t = document.timeline.currentTime; requestAnimationFrame((nt) => { const dt = nt - t; setInteractions((i) => { const res = {}; for (const id of Object.keys(i)) { const interaction = i[id]; if (!interaction.expiry) { res[id] = interaction; continue; } let expiry = interaction.expiry; expiry -= dt; if (expiry <= 0) continue; const t = expiry / 300; res[id] = { ...interaction, value: interaction.from * t * t, expiry }; } return res; }); }); }, [interactions]); const state = useMemo(() => { let state = Object.assign({}, fallback, offsets); for (const {key, value} of Object.values(interactions)) { state[key] = (state[key] ?? 0) + value; } return state; }, [fallback, offsets, interactions]); useEffect(() => { let delta = {}; for (const key in state) { if (state[key] > 0.7) { delta[key] = -1; } else if (state[key] < -0.7) { delta[key] = 1; } } if (Object.keys(delta).length) { // @TODO: make limits work delta = commit(delta); if (delta) setOffsets((o) => addState(o, delta)); } }, [state]); return children.map((child) => child && cloneElement(child, { state })); }; const Animatable = ({ state, offset, swipeI, color, children }) => { const scroll = lag(state.scroll, 0.05) + offset.scroll; const active = smoothstep(0.9, 0.1, abs(scroll)) * 2; const activeB = offset.scroll === 0; const swipe = lag(state[['swipe', swipeI]] ?? 0, 0.05) + offset.swipe; return (
{children}
{scroll.toFixed(2)} / {swipe.toFixed(2)}
); }; const make = (prefix, length) => Array.from( { length }, (_, i) => ({ content: `${prefix} ${i}`, color: colors.hex(prefix), }), ); const data = [ make('A', 3), make('B', 2), make('C', 7), make('D', 2), make('E', 2), make('F', 3), make('G', 1), make('H', 1), make('I', 4), ]; const range = (n) => [...new Array(n).keys()]; const grid = (w, h) => { const res = []; for (const x of range(w)) { for (const y of range(h)) { res.push([x, y]); } } return res; } export const Main = () => { const interaction = useRef({}); const [state, setState] = useState({ scrollOffset: 2, swipeOffsets: [] }); const { scrollOffset, swipeOffsets } = state; const pointers = useRef({}); const commit = (delta) => { let sw = swipeOffsets; for (const key in delta) { if (key.startsWith('swipe,')) { const i = key.split(',')[1]; sw = sw.slice(); sw[i] = (sw[i] ?? 0) + delta[key]; } } const sc = scrollOffset + (delta.scroll ?? 0); if (sc < 0) return null; if (delta.scroll) { for (const id in pointers.current) { if (!id.endsWith('-x')) continue; const ptr = pointers.current[id]; interaction.current.end(ptr.id, (ptr.last - ptr.start) / 250); ptr.id = interaction.current.start(['swipe', sc]); ptr.start = ptr.last; } } setState({ scrollOffset: sc, swipeOffsets: sw }); return delta; }; return (
{ e.target.setPointerCapture(e.pointerId); const x = e.clientX - e.target.clientLeft; const y = e.clientY - e.target.clientTop; pointers.current[e.pointerId + '-x'] = { id: interaction.current.start(['swipe', scrollOffset]), start: e.clientX, last: e.clientX, }; pointers.current[e.pointerId + '-y'] = { id: interaction.current.start('scroll'), start: e.clientY, last: e.clientY, }; }} onPointerMove={(e) => { const x = pointers.current[e.pointerId + '-x']; const y = pointers.current[e.pointerId + '-y']; if (x) { x.last = e.clientX; interaction.current.update(x.id, (x.last - x.start) / 250); } if (y) { y.last = e.clientY; interaction.current.update(y.id, (y.last - y.start) / 250); } }} onPointerUp={(e) => { e.target.releasePointerCapture(e.pointerId); const x = pointers.current[e.pointerId + '-x']; const y = pointers.current[e.pointerId + '-y']; if (x) { x.last = e.clientX; interaction.current.end(x.id, (x.last - x.start) / 250); } if (y) { y.last = e.clientY; interaction.current.end(y.id, (y.last - y.start) / 250); } delete pointers.current[e.pointerId + '-x']; delete pointers.current[e.pointerId + '-y']; }} > scroll: {scrollOffset}
{grid(7, 5).map(([scroll, swipe]) => { scroll -= 3; swipe -= 2; const swipeOffset = swipeOffsets[scroll+scrollOffset] ?? 0; let d = data[scrollOffset+scroll]; d = d && d[swipeOffset+swipe]; if (!d) return; return ( {d.content} ); })}
); };