diff --git a/examples/portfolio-gallery/davebot-gaze.html b/examples/portfolio-gallery/davebot-gaze.html new file mode 100644 index 0000000..9b9b065 --- /dev/null +++ b/examples/portfolio-gallery/davebot-gaze.html @@ -0,0 +1,51 @@ + + + + + + + Davebot gaze lab - tune how hard he looks at the cursor + + +
+
+

Davebot gaze lab

+

+ Move the cursor anywhere on the page. Davebot's eyes point at it, and the settings on the + right change how hard he locks on. Values save as you drag, and the portfolio header reads + the same values, so tune here and reload that page to see it in place. +

+
+ +
+
+
+
+
+ + +
+ + +
+ + + diff --git a/examples/portfolio-gallery/src/davebot-gaze-main.ts b/examples/portfolio-gallery/src/davebot-gaze-main.ts new file mode 100644 index 0000000..f0933f6 --- /dev/null +++ b/examples/portfolio-gallery/src/davebot-gaze-main.ts @@ -0,0 +1,288 @@ +import { createAvatar } from '@bible-strong/avatar-web' + +import { davebotHeaderVariants, HEADER_MODES, type HeaderMode } from './davebot-header' +import { + createGazeTracker, + GAZE_DEFAULTS, + GAZE_LIMITS, + GAZE_PRESETS, + loadGazeSettings, + saveGazeSettings, + type GazeSettings, +} from './davebotGaze' +import './davebot-gaze.css' + +const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + +const stage = document.querySelector('#stage') +const scales = document.querySelector('#scales') +const controls = document.querySelector('#controls') +const presetRow = document.querySelector('#presets') +const deflectionBar = document.querySelector('#deflection-bar') +const deflectionValue = document.querySelector('#deflection-value') +const output = document.querySelector('#settings-json') +const copyButton = document.querySelector('#copy') +const resetButton = document.querySelector('#reset') + +if ( + !stage || + !scales || + !controls || + !presetRow || + !deflectionBar || + !deflectionValue || + !output || + !copyButton || + !resetButton +) { + throw new Error('Davebot gaze lab shell is incomplete.') +} + +let settings = loadGazeSettings() +const gaze = createGazeTracker(settings) + +const mount = (host: HTMLElement, mode: HeaderMode): void => { + const variant = davebotHeaderVariants.find(candidate => candidate.mode === mode) + if (!variant) throw new Error(`Davebot variant for ${mode} mode was not found.`) + + const controller = createAvatar(host, { + definition: variant.definition, + defaultAnimation: reduceMotion ? undefined : 'idle', + autoplay: !reduceMotion, + size: '100%', + ariaLabel: `Davebot in ${mode} mode`, + onError: error => console.error(`[davebot-gaze/${mode}]`, error), + }) + + if (reduceMotion) controller.setExpression('neutral') + if (!gaze.track(host)) throw new Error(`Davebot eye paths for ${mode} mode were not found.`) +} + +// Two sizes matter. The large stage makes the feel judgeable; the chip row +// shows the same settings at the size Davebot actually ships at, where the +// 300-unit viewBox compresses into roughly 46 screen px. +for (const mode of Object.keys(HEADER_MODES) as HeaderMode[]) { + const tile = document.createElement('figure') + tile.className = 'tile' + tile.dataset.mode = mode + + const host = document.createElement('div') + host.className = 'tile-avatar' + tile.append(host) + + const caption = document.createElement('figcaption') + caption.textContent = `${mode} mode` + tile.append(caption) + + stage.append(tile) + mount(host, mode) + + const chip = document.createElement('div') + chip.className = 'chip' + chip.dataset.mode = mode + + const chipHost = document.createElement('div') + chipHost.className = 'chip-avatar' + chip.append(chipHost) + + const chipLabel = document.createElement('span') + chipLabel.textContent = `${mode} at header size` + chip.append(chipLabel) + + scales.append(chip) + mount(chipHost, mode) +} + +type Field = { + key: keyof GazeSettings + label: string + unit: string + hint: string +} + +const FIELDS: Field[] = [ + { + key: 'travel', + label: 'Travel', + unit: 'units', + hint: 'Furthest the pupils move. The head silhouette starts clipping them past 48.', + }, + { + key: 'depth', + label: 'Depth', + unit: 'px', + hint: 'How far away Davebot feels. Lower values lock on to a near cursor much harder.', + }, + { + key: 'smoothing', + label: 'Smoothing', + unit: '', + hint: 'Zero pins the eyes to the cursor. Raise it to let them trail behind.', + }, + { + key: 'convergence', + label: 'Convergence', + unit: '', + hint: 'How far the eyes cross inward when the cursor is close to him.', + }, + { + key: 'lean', + label: 'Lean', + unit: 'px', + hint: 'Whole-body shift toward the cursor. A little sells the look; a lot reads as sliding.', + }, +] + +// Show exactly as many decimals as the step can express, so a 0.5 step reads +// "3.0" rather than a falsely precise "3.00". +const format = (key: keyof GazeSettings, value: number): string => { + const step = GAZE_LIMITS[key].step + if (step >= 1) return String(Math.round(value)) + return value.toFixed(step < 0.1 ? 2 : 1) +} + +type Control = { + field: Field + input: HTMLInputElement + readout: HTMLElement +} + +const buildControl = (field: Field): Control => { + const limit = GAZE_LIMITS[field.key] + + const row = document.createElement('div') + row.className = 'field' + + const label = document.createElement('label') + label.className = 'field-head' + label.htmlFor = `gaze-${field.key}` + + const name = document.createElement('span') + name.textContent = field.label + + // A range input already announces its own value, so this readout is visual + // only and must not become a second announced label. + const readout = document.createElement('span') + readout.className = 'field-value' + readout.setAttribute('aria-hidden', 'true') + + label.append(name, readout) + + const input = document.createElement('input') + input.type = 'range' + input.id = `gaze-${field.key}` + input.min = String(limit.min) + input.max = String(limit.max) + input.step = String(limit.step) + input.value = String(settings[field.key]) + input.setAttribute('aria-describedby', `hint-${field.key}`) + + const hint = document.createElement('p') + hint.className = 'field-hint' + hint.id = `hint-${field.key}` + hint.textContent = field.hint + + row.append(label, input, hint) + controls.append(row) + + return { field, input, readout } +} + +const controlList: Control[] = FIELDS.map(buildControl) + +const syncReadouts = (): void => { + for (const control of controlList) { + const value = format(control.field.key, settings[control.field.key]) + control.readout.textContent = control.field.unit ? `${value} ${control.field.unit}` : value + } + + output.textContent = JSON.stringify(settings, null, 2) + + const active = Object.entries(GAZE_PRESETS).find(([, preset]) => + controlList.every(control => preset[control.field.key] === settings[control.field.key]) + ) + for (const button of presetRow.querySelectorAll('button')) { + button.setAttribute('aria-pressed', String(button.dataset.preset === active?.[0])) + } +} + +// The deflection meter mirrors the tracker's own settling loop: it runs while +// the value is still changing and then stops, so an idle page costs no frames. +let deflectionFrame: number | null = null +let lastDeflection = -1 + +const paintDeflection = (): void => { + deflectionFrame = null + const value = gaze.getDeflection() + const percent = Math.round(value * 100) + deflectionBar.style.setProperty('--fill', `${Math.min(percent, 100)}%`) + deflectionValue.textContent = `${percent}%` + + if (Math.abs(value - lastDeflection) > 0.001) { + lastDeflection = value + deflectionFrame = requestAnimationFrame(paintDeflection) + } +} + +const requestDeflection = (): void => { + lastDeflection = -1 + if (deflectionFrame === null) deflectionFrame = requestAnimationFrame(paintDeflection) +} + +const apply = (next: GazeSettings, syncInputs: boolean): void => { + gaze.setSettings(next) + // Read back the engine's clamped values so the panel can never display a + // number the tracker is not actually using. + settings = gaze.getSettings() + saveGazeSettings(settings) + + if (syncInputs) { + for (const control of controlList) { + control.input.value = String(settings[control.field.key]) + } + } + + syncReadouts() + requestDeflection() +} + +for (const control of controlList) { + control.input.addEventListener('input', () => { + apply({ ...settings, [control.field.key]: Number(control.input.value) }, false) + }) +} + +for (const [label, preset] of Object.entries(GAZE_PRESETS)) { + const button = document.createElement('button') + button.type = 'button' + button.dataset.preset = label + button.textContent = label + button.addEventListener('click', () => apply(preset, true)) + presetRow.append(button) +} + +resetButton.addEventListener('click', () => apply(GAZE_DEFAULTS, true)) + +copyButton.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(JSON.stringify(settings, null, 2)) + copyButton.textContent = 'Copied' + } catch { + // Clipboard access is denied outside a secure context. The JSON is already + // on screen, so selecting it is a working fallback rather than a dead end. + const range = document.createRange() + range.selectNodeContents(output) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + copyButton.textContent = 'Selected' + } + window.setTimeout(() => { + copyButton.textContent = 'Copy JSON' + }, 1400) +}) + +window.addEventListener('pointermove', requestDeflection, { passive: true }) + +syncReadouts() +requestDeflection() diff --git a/examples/portfolio-gallery/src/davebot-gaze.css b/examples/portfolio-gallery/src/davebot-gaze.css new file mode 100644 index 0000000..2d0815c --- /dev/null +++ b/examples/portfolio-gallery/src/davebot-gaze.css @@ -0,0 +1,335 @@ +@import url('https://fonts.bunny.net/css?family=geist:400,500,600|geist-mono:400,500'); + +/* Design System 01, monochrome. The lab is a tool, so it stays neutral and + lets the two Davebot tiles carry the only real contrast on the page. */ +:root { + --page: #f6f6f6; + --panel: #ffffff; + --ink: #262626; + --ink-muted: #5e5e5e; + --ink-faint: #909090; + --line: #0000001f; + --line-strong: #0000003d; + --font: 'Geist', system-ui, sans-serif; + --font-mono: 'Geist Mono', ui-monospace, monospace; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + color-scheme: light; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--page); + color: var(--ink); + font-family: var(--font); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; +} + +.shell { + margin: 0 auto; + max-width: 1180px; + padding: 40px 28px 72px; +} + +header h1 { + margin: 0; + font-size: 26px; + font-weight: 600; + letter-spacing: -0.02em; +} + +header p { + margin: 8px 0 0; + max-width: 62ch; + color: var(--ink-muted); + font-size: 14px; + line-height: 1.6; +} + +.layout { + display: grid; + gap: 28px; + margin-top: 32px; + grid-template-columns: minmax(0, 1fr) 340px; + align-items: start; +} + +@media (max-width: 940px) { + .layout { + grid-template-columns: minmax(0, 1fr); + } +} + +/* Stage */ + +.stage { + display: grid; + gap: 20px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.tile { + display: grid; + gap: 14px; + justify-items: center; + margin: 0; + padding: 52px 24px 24px; + border: 1px solid var(--line); + border-radius: 14px; +} + +.tile[data-mode='light'] { + background: #f6f6f6; +} + +.tile[data-mode='dark'] { + background: #0b0b0b; + border-color: #ffffff1f; +} + +.tile-avatar { + width: 100%; + max-width: 330px; + aspect-ratio: 1; + /* Lean is a compositor-only translate, so promote the layer once. */ + will-change: transform; +} + +.tile-avatar .bs-avatar, +.tile-avatar svg { + display: block; + width: 100%; + height: 100%; +} + +.tile figcaption { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-faint); +} + +.tile[data-mode='dark'] figcaption { + color: #909090; +} + +.scales { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 20px; +} + +.chip { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border: 1px solid var(--line); + border-radius: 10px; + font-size: 12px; + color: var(--ink-muted); +} + +.chip[data-mode='light'] { + background: #f6f6f6; +} + +.chip[data-mode='dark'] { + background: #0b0b0b; + border-color: #ffffff1f; + color: #909090; +} + +.chip-avatar { + flex: 0 0 auto; + width: 46px; + height: 46px; + will-change: transform; +} + +.chip-avatar .bs-avatar, +.chip-avatar svg { + display: block; + width: 100%; + height: 100%; +} + +/* Panel */ + +.panel { + padding: 22px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel); + position: sticky; + top: 28px; +} + +.panel h2 { + margin: 0 0 4px; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ink-faint); +} + +.presets { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 14px 0 22px; +} + +button { + padding: 7px 12px; + border: 1px solid var(--line-strong); + border-radius: 999px; + background: transparent; + color: var(--ink); + font-family: inherit; + font-size: 12px; + cursor: pointer; + transition: + background-color 140ms cubic-bezier(0.16, 1, 0.3, 1), + color 140ms cubic-bezier(0.16, 1, 0.3, 1); +} + +button:hover { + background: #0000000a; +} + +button[aria-pressed='true'] { + background: var(--ink); + border-color: var(--ink); + color: #f6f6f6; +} + +button:focus-visible, +input:focus-visible { + outline: 2px solid var(--ink); + outline-offset: 2px; +} + +.field { + margin-bottom: 18px; +} + +.field-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + font-size: 13px; + font-weight: 500; + cursor: pointer; +} + +.field-value { + font-family: var(--font-mono); + font-size: 12px; + color: var(--ink-muted); + font-variant-numeric: tabular-nums; +} + +.field input[type='range'] { + width: 100%; + margin: 8px 0 0; + accent-color: var(--ink); +} + +.field-hint { + margin: 6px 0 0; + font-size: 11.5px; + line-height: 1.5; + color: var(--ink-faint); +} + +/* Meter */ + +.meter { + margin: 24px 0 18px; + padding-top: 18px; + border-top: 1px solid var(--line); +} + +.meter-head { + display: flex; + align-items: baseline; + justify-content: space-between; + font-size: 13px; + font-weight: 500; +} + +.meter-head span:last-child { + font-family: var(--font-mono); + font-size: 12px; + color: var(--ink-muted); + font-variant-numeric: tabular-nums; +} + +.bar { + --fill: 0%; + margin-top: 8px; + height: 5px; + border-radius: 999px; + background: #00000014; + overflow: hidden; +} + +.bar::after { + content: ''; + display: block; + width: var(--fill); + height: 100%; + border-radius: inherit; + background: var(--ink); +} + +.actions { + display: flex; + gap: 8px; + margin-bottom: 14px; +} + +pre { + margin: 0; + padding: 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: #f6f6f6; + font-family: var(--font-mono); + font-size: 11.5px; + line-height: 1.55; + color: var(--ink-muted); + overflow-x: auto; +} + +footer { + margin-top: 36px; + padding-top: 18px; + border-top: 1px solid var(--line); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ink-faint); +} + +@media (prefers-reduced-motion: reduce) { + button { + transition: none; + } +} diff --git a/examples/portfolio-gallery/src/davebot-header-main.ts b/examples/portfolio-gallery/src/davebot-header-main.ts index 286ee7a..170dc11 100644 --- a/examples/portfolio-gallery/src/davebot-header-main.ts +++ b/examples/portfolio-gallery/src/davebot-header-main.ts @@ -1,21 +1,14 @@ import { createAvatar } from '@bible-strong/avatar-web' import { davebotHeaderVariants } from './davebot-header' +import { createGazeTracker, loadGazeSettings } from './davebotGaze' import './davebot-header.css' const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches -const maxGazeOffset = reduceMotion ? 6 : 9 -const fullGazeDistance = 240 -type GazeTarget = { - mount: HTMLElement - left: SVGTransform - right: SVGTransform - centerX: number - centerY: number -} - -const gazeTargets: GazeTarget[] = [] +// Values tuned in davebot-gaze.html are persisted, so the header reflects +// whatever feel was last chosen in the lab. +const gaze = createGazeTracker(loadGazeSettings()) for (const variant of davebotHeaderVariants) { const panel = document.querySelector(`.portfolio[data-mode="${variant.mode}"]`) @@ -42,104 +35,7 @@ for (const variant of davebotHeaderVariants) { davebot.addEventListener('blur', () => controller.play('idle')) davebot.addEventListener('click', () => controller.play('hello')) - const svg = mount.querySelector('.bs-avatar svg') - const eyePaths = svg?.querySelectorAll('g[clip-path] > path') - if (!svg || !eyePaths || eyePaths.length !== 2) { + if (!gaze.track(mount)) { throw new Error(`Davebot eye paths for ${variant.mode} mode were not found.`) } - - // The engine repaints each eye's path data during blinks and expressions, but - // does not touch this SVG transform list. One persistent transform per eye - // therefore layers high-frequency gaze over normal playback without - // regenerating paths or resetting the animation timeline. - const left = svg.createSVGTransform() - const right = svg.createSVGTransform() - left.setTranslate(0, 0) - right.setTranslate(0, 0) - // Chromium clones both the appended object and appendItem's return value. - // Only getItem() yields a live transform handle whose later setTranslate() - // calls update the eye path. - eyePaths[0].transform.baseVal.appendItem(left) - eyePaths[1].transform.baseVal.appendItem(right) - const attachedLeft = eyePaths[0].transform.baseVal.getItem(0) - const attachedRight = eyePaths[1].transform.baseVal.getItem(0) - gazeTargets.push({ mount, left: attachedLeft, right: attachedRight, centerX: 0, centerY: 0 }) } - -let pointerX = 0 -let pointerY = 0 -let pointerPresent = false -let geometryDirty = true -let gazeFrame: number | null = null - -const updateCenters = (): void => { - for (const target of gazeTargets) { - const rect = target.mount.getBoundingClientRect() - target.centerX = rect.left + rect.width / 2 - target.centerY = rect.top + rect.height / 2 - } - geometryDirty = false -} - -const paintGaze = (): void => { - gazeFrame = null - if (geometryDirty) updateCenters() - - for (const target of gazeTargets) { - let x = 0 - let y = 0 - if (pointerPresent) { - const deltaX = pointerX - target.centerX - const deltaY = pointerY - target.centerY - const distance = Math.hypot(deltaX, deltaY) - if (distance > 0) { - const offset = maxGazeOffset * Math.min(distance / fullGazeDistance, 1) - x = (deltaX / distance) * offset - y = (deltaY / distance) * offset - } - } - target.left.setTranslate(x, y) - target.right.setTranslate(x, y) - } -} - -const requestGazePaint = (): void => { - if (gazeFrame === null) gazeFrame = requestAnimationFrame(paintGaze) -} - -window.addEventListener( - 'pointermove', - event => { - if (event.pointerType === 'touch') return - pointerX = event.clientX - pointerY = event.clientY - pointerPresent = true - requestGazePaint() - }, - { passive: true } -) - -window.addEventListener('pointerout', event => { - if (event.relatedTarget !== null) return - pointerPresent = false - requestGazePaint() -}) - -window.addEventListener('blur', () => { - pointerPresent = false - requestGazePaint() -}) - -document.addEventListener('visibilitychange', () => { - if (!document.hidden) return - pointerPresent = false - requestGazePaint() -}) - -const markGeometryDirty = (): void => { - geometryDirty = true - requestGazePaint() -} - -new ResizeObserver(markGeometryDirty).observe(document.documentElement) -window.addEventListener('scroll', markGeometryDirty, { passive: true }) diff --git a/examples/portfolio-gallery/src/davebotGaze.ts b/examples/portfolio-gallery/src/davebotGaze.ts new file mode 100644 index 0000000..26c5d7f --- /dev/null +++ b/examples/portfolio-gallery/src/davebotGaze.ts @@ -0,0 +1,297 @@ +// Davebot's cursor gaze. +// +// The engine owns playback, blinking and body drift. Gaze is layered on top by +// translating the two existing eye paths, which sit inside a group clipped to +// the head silhouette — so the head outline bounds the pupils and extreme +// deflection clips naturally instead of floating outside the body. +// +// Direction is exact: the offset is the unit vector to the pointer. Magnitude +// uses the angle to the pointer rather than a linear distance ramp: +// +// gain = distance / hypot(distance, depth) = sin(atan2(distance, depth)) +// +// That is the projection of a real eyeball rotating to face a target `depth` +// away, so it saturates — once the pointer is well past the knee the eye holds +// near-full deflection and keeps pointing straight at it, which is what reads +// as "looking at you". A linear ramp instead keeps drifting and reads as vague. + +export type GazeSettings = { + /** Maximum pupil displacement, in SVG user units (viewBox is 300 wide). */ + travel: number + /** Perceived distance to Davebot in screen px; sets how fast gaze saturates. */ + depth: number + /** Exponential follow, 0 = locked to the pointer, 0.9 = slow and floaty. */ + smoothing: number + /** Inward eye rotation for near targets, 0 = parallel eyes, 1 = strongly crossed. */ + convergence: number + /** Whole-body lean toward the pointer at full deflection, in screen px. */ + lean: number +} + +export const GAZE_DEFAULTS: GazeSettings = { + travel: 30, + depth: 260, + smoothing: 0.18, + convergence: 0.35, + lean: 3, +} + +// `travel` stops at 48 because that is the measured distance from Davebot's +// outer eye edge to the head silhouette; past it the clip starts cutting. +export const GAZE_LIMITS: Record = { + travel: { min: 0, max: 48, step: 1 }, + depth: { min: 40, max: 900, step: 10 }, + smoothing: { min: 0, max: 0.9, step: 0.01 }, + convergence: { min: 0, max: 1, step: 0.01 }, + lean: { min: 0, max: 12, step: 0.5 }, +} + +export const GAZE_PRESETS: Record = { + Subtle: { travel: 14, depth: 460, smoothing: 0.34, convergence: 0.15, lean: 1 }, + Balanced: GAZE_DEFAULTS, + 'Locked on': { travel: 44, depth: 120, smoothing: 0.06, convergence: 0.5, lean: 6 }, +} + +const STORAGE_KEY = 'davebot-gaze-settings' + +const clamp = (value: number, min: number, max: number): number => + Math.min(Math.max(value, min), max) + +const sanitize = (raw: Partial | null | undefined): GazeSettings => { + const merged = { ...GAZE_DEFAULTS, ...(raw ?? {}) } + const keys = Object.keys(GAZE_DEFAULTS) as (keyof GazeSettings)[] + const result = { ...GAZE_DEFAULTS } + for (const key of keys) { + const value = Number(merged[key]) + const limit = GAZE_LIMITS[key] + result[key] = Number.isFinite(value) ? clamp(value, limit.min, limit.max) : GAZE_DEFAULTS[key] + } + return result +} + +export const loadGazeSettings = (): GazeSettings => { + try { + const stored = window.localStorage.getItem(STORAGE_KEY) + return stored ? sanitize(JSON.parse(stored) as Partial) : { ...GAZE_DEFAULTS } + } catch { + // A blocked or corrupt store must never stop Davebot from rendering. + return { ...GAZE_DEFAULTS } + } +} + +export const saveGazeSettings = (settings: GazeSettings): void => { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)) + } catch { + // Persisting the tuning is a convenience, not a requirement. + } +} + +type Eye = { + transform: SVGTransform + /** +1 when moving right takes this eye toward the face centre, -1 otherwise. */ + inward: number +} + +type Target = { + mount: HTMLElement + eyes: Eye[] + centerX: number + centerY: number + currentX: number + currentY: number + targetX: number + targetY: number +} + +export type GazeTracker = { + /** Registers one rendered avatar. Returns false when its eyes are missing. */ + track: (mount: HTMLElement) => boolean + setSettings: (next: GazeSettings) => void + getSettings: () => GazeSettings + /** Current deflection as a fraction of `travel`, for live readouts. */ + getDeflection: () => number + destroy: () => void +} + +export const createGazeTracker = (initial: GazeSettings): GazeTracker => { + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + let settings = sanitize(initial) + const targets: Target[] = [] + + let pointerX = 0 + let pointerY = 0 + let pointerPresent = false + let geometryDirty = true + let frame: number | null = null + + const measure = (): void => { + for (const target of targets) { + const rect = target.mount.getBoundingClientRect() + target.centerX = rect.left + rect.width / 2 + target.centerY = rect.top + rect.height / 2 + } + geometryDirty = false + } + + const paint = (): void => { + frame = null + if (geometryDirty) measure() + + // With smoothing the eyes keep easing after the pointer stops, so the loop + // must persist until every target has settled. + let moving = false + + for (const target of targets) { + let nextX = 0 + let nextY = 0 + let gain = 0 + + if (pointerPresent) { + const deltaX = pointerX - target.centerX + const deltaY = pointerY - target.centerY + const distance = Math.hypot(deltaX, deltaY) + if (distance > 0.0001) { + gain = distance / Math.hypot(distance, settings.depth) + const reach = settings.travel * gain + nextX = (deltaX / distance) * reach + nextY = (deltaY / distance) * reach + } + } + + target.targetX = nextX + target.targetY = nextY + + const follow = reduceMotion ? 1 : 1 - settings.smoothing + target.currentX += (target.targetX - target.currentX) * follow + target.currentY += (target.targetY - target.currentY) * follow + + if ( + Math.abs(target.targetX - target.currentX) > 0.01 || + Math.abs(target.targetY - target.currentY) > 0.01 + ) { + moving = true + } + + // Near targets converge; distant targets stay parallel. Nearness is read + // from the SMOOTHED offset, not the raw gain, so convergence eases with + // the eyes instead of jumping when the pointer teleports. + const settled = + settings.travel > 0 + ? Math.min(Math.hypot(target.currentX, target.currentY) / settings.travel, 1) + : 0 + const converge = pointerPresent + ? settings.convergence * settings.travel * 0.35 * (1 - settled) + : 0 + + for (const eye of target.eyes) { + eye.transform.setTranslate(target.currentX + converge * eye.inward, target.currentY) + } + + if (reduceMotion || settings.lean === 0) { + target.mount.style.transform = '' + } else { + const leanScale = settings.travel > 0 ? settings.lean / settings.travel : 0 + const leanX = target.currentX * leanScale + const leanY = target.currentY * leanScale + target.mount.style.transform = `translate3d(${leanX.toFixed(2)}px, ${leanY.toFixed(2)}px, 0)` + } + } + + if (moving) frame = requestAnimationFrame(paint) + } + + const request = (): void => { + if (frame === null) frame = requestAnimationFrame(paint) + } + + const onPointerMove = (event: PointerEvent): void => { + if (event.pointerType === 'touch') return + pointerX = event.clientX + pointerY = event.clientY + pointerPresent = true + request() + } + + const release = (): void => { + pointerPresent = false + request() + } + + const onPointerOut = (event: PointerEvent): void => { + if (event.relatedTarget !== null) return + release() + } + + const onVisibility = (): void => { + if (document.hidden) release() + } + + const markDirty = (): void => { + geometryDirty = true + request() + } + + window.addEventListener('pointermove', onPointerMove, { passive: true }) + window.addEventListener('pointerout', onPointerOut) + window.addEventListener('blur', release) + document.addEventListener('visibilitychange', onVisibility) + window.addEventListener('scroll', markDirty, { passive: true }) + const observer = new ResizeObserver(markDirty) + observer.observe(document.documentElement) + + return { + track: mount => { + const svg = mount.querySelector('svg') + const paths = svg?.querySelectorAll('g[clip-path] > path') + if (!svg || !paths || paths.length !== 2) return false + + const eyes: Eye[] = [] + for (const path of paths) { + const transform = svg.createSVGTransform() + transform.setTranslate(0, 0) + // Chromium clones both the appended object and appendItem's return + // value; only getItem yields the live handle that setTranslate updates. + path.transform.baseVal.appendItem(transform) + const attached = path.transform.baseVal.getItem(path.transform.baseVal.numberOfItems - 1) + // Which way is the face centre for this eye, read from its own geometry + // rather than assuming the engine's path order. + const box = path.getBBox() + const centre = box.x + box.width / 2 + eyes.push({ transform: attached, inward: centre > 0 ? -1 : 1 }) + } + + targets.push({ + mount, + eyes, + centerX: 0, + centerY: 0, + currentX: 0, + currentY: 0, + targetX: 0, + targetY: 0, + }) + markDirty() + return true + }, + setSettings: next => { + settings = sanitize(next) + request() + }, + getSettings: () => ({ ...settings }), + getDeflection: () => { + const target = targets[0] + if (!target || settings.travel === 0) return 0 + return Math.hypot(target.currentX, target.currentY) / settings.travel + }, + destroy: () => { + if (frame !== null) cancelAnimationFrame(frame) + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerout', onPointerOut) + window.removeEventListener('blur', release) + document.removeEventListener('visibilitychange', onVisibility) + window.removeEventListener('scroll', markDirty) + observer.disconnect() + }, + } +}