Download src/figure-reader.js from oroikono/kaimerva: direct link, hf CLI and curl.
- Browser
- Download file 11.5 kB
-
https://huggingface.co/spaces/oroikono/kaimerva/resolve/main/src/figure-reader.js
- Command line
-
hf download hf://spaces/oroikono/kaimerva/src/figure-reader.js
-
curl -L -o figure-reader.js https://huggingface.co/spaces/oroikono/kaimerva/resolve/main/src/figure-reader.js
11.5 kB
| // Native reading interface for the authored inspection passage. Facts come only | |
| // from validated published records; the scene is accompaniment, not a gate. | |
| export function createFigureReader({ world, motionState, onReadIndex, onMotion, onSelect, sceneState = () => ({ theme: 'sea' }), onTheme = () => {} }) { | |
| const byId = id => document.getElementById(id); | |
| const dialog = byId('figure-passage'); | |
| let item = null; | |
| let stageIndex = 0; | |
| let invoker = null; | |
| let returning = false; | |
| let returnedAction = null; | |
| let textureReady = false; | |
| let graphicsUnavailable = false; | |
| const visited = new Map(); | |
| const stageUrl = stage => new URL(stage.src, document.baseURI).href; | |
| const instruments = { sea: 'Sea / optical telescope', orbital: 'Space / scanning gantry', woodland: 'Nature / specimen cabinet' }; | |
| const collectionNames = { work: 'projects', research: 'research', journal: 'journal entries', journey: 'journey chapters', news: 'news' }; | |
| function syncTheme() { | |
| const theme = sceneState().theme; | |
| byId('passage-instrument-label').textContent = instruments[theme] || instruments.sea; | |
| for (const button of byId('passage-worlds').querySelectorAll('button')) { | |
| button.setAttribute('aria-pressed', String(button.dataset.inspectionTheme === theme)); | |
| button.disabled = returning; | |
| } | |
| } | |
| function finishReturn() { | |
| if (!item) return; | |
| const entryButton = invoker?.dataset.entry ? document.querySelector(`#entries button[data-entry="${invoker.dataset.entry}"]`) : null; | |
| const landmark = invoker?.dataset.destination ? document.querySelector(`.world-label[data-destination="${invoker.dataset.destination}"]`) : null; | |
| const target = invoker?.isConnected && invoker.getClientRects().length ? invoker : entryButton || (landmark?.getClientRects().length ? landmark : null) || byId('inspect-figure'); | |
| const action = returnedAction; | |
| item = null; | |
| returning = false; | |
| returnedAction = null; | |
| invoker = null; | |
| document.body.dataset.inspection = 'false'; | |
| if (dialog.open) dialog.close(); | |
| if (action) action(); | |
| else target.focus({ preventScroll: true }); | |
| } | |
| function syncMotion() { | |
| const { paused, reduced, ready: sceneReady } = motionState(); | |
| const ready = sceneReady && !graphicsUnavailable; | |
| const button = byId('passage-motion'); | |
| button.hidden = !ready; | |
| button.disabled = reduced; | |
| button.textContent = reduced ? 'Reduced motion' : paused ? 'Resume motion' : 'Pause motion'; | |
| button.setAttribute('aria-pressed', String(paused || reduced)); | |
| dialog.dataset.motion = paused || reduced ? 'static' : 'moving'; | |
| byId('passage-fallback').hidden = ready && textureReady; | |
| } | |
| function renderRecord() { | |
| byId('passage-title').textContent = item.title; | |
| byId('passage-origin').textContent = `${item.status === 'example' ? 'Illustrative demo · ' : ''}${item.kind}${item.status && item.status !== 'example' ? ` / ${item.status}` : ''}`; | |
| byId('passage-summary').textContent = item.summary; | |
| byId('passage-body').textContent = item.body; | |
| byId('passage-caption').textContent = item.figure.caption; | |
| const links = item.links.map(link => { | |
| const anchor = document.createElement('a'); | |
| anchor.textContent = `${link.label} ↗`; | |
| anchor.href = link.url; | |
| anchor.target = '_blank'; anchor.rel = 'noopener noreferrer'; | |
| return anchor; | |
| }); | |
| byId('passage-links').replaceChildren(...links); | |
| byId('passage-index').textContent = `Browse all ${collectionNames[item.collection] || item.collection} ↗`; | |
| byId('passage-stages').replaceChildren(...item.figure.stages.map((stage, index) => { | |
| const button = document.createElement('button'); | |
| button.type = 'button'; | |
| button.dataset.stage = String(index); | |
| button.setAttribute('aria-label', `Inspect view ${index + 1}: ${stage.label}`); | |
| const number = document.createElement('span'); | |
| number.className = 'passage-view-number'; | |
| number.textContent = String(index + 1).padStart(2, '0'); | |
| button.append(number, document.createTextNode(stage.label)); | |
| button.addEventListener('click', () => selectStage(index)); | |
| return button; | |
| })); | |
| } | |
| function renderStage() { | |
| const stage = item.figure.stages[stageIndex]; | |
| const seen = visited.get(item.id); | |
| byId('passage-stage-number').textContent = `${String(stageIndex + 1).padStart(2, '0')} / ${String(item.figure.stages.length).padStart(2, '0')}`; | |
| byId('passage-stage-label').textContent = stage.label; | |
| byId('passage-stage-description').textContent = stage.description; | |
| byId('passage-reader-stage').textContent = `${stage.label}: ${stage.description}`; | |
| byId('passage-fallback').src = stageUrl(stage); | |
| byId('passage-fallback').alt = `${item.figure.alt} — ${stage.label}`; | |
| byId('passage-flat-image').src = stageUrl(stage); | |
| byId('passage-flat-image').alt = `${item.figure.alt} — ${stage.label}`; | |
| byId('passage-source').href = stageUrl(stage); | |
| byId('passage-progress').textContent = seen.size === item.figure.stages.length ? 'Every view explored. Return whenever you like.' : 'Explore the supplied views'; | |
| for (const button of byId('passage-stages').querySelectorAll('button')) { | |
| const index = Number(button.dataset.stage); | |
| button.setAttribute('aria-pressed', String(index === stageIndex)); | |
| button.classList.toggle('is-visited', seen.has(item.figure.stages[index].id)); | |
| } | |
| byId('passage-status').textContent = `View ${stageIndex + 1} of ${item.figure.stages.length}: ${stage.label}.`; | |
| } | |
| function selectStage(index, { repaint = true } = {}) { | |
| if (!item || returning || !Number.isInteger(index) || index < 0 || index >= item.figure.stages.length) return; | |
| stageIndex = index; | |
| textureReady = false; | |
| if (!visited.has(item.id)) visited.set(item.id, new Set()); | |
| visited.get(item.id).add(item.figure.stages[index].id); | |
| renderStage(); | |
| if (repaint) world.setInspectionStage({ imageUrl: stageUrl(item.figure.stages[index]), stage: index }); | |
| } | |
| function open(record, source = document.activeElement) { | |
| if (!record?.published || !record.figure || returning || typeof dialog.showModal !== 'function') return false; | |
| item = record; | |
| textureReady = false; | |
| graphicsUnavailable = !motionState().ready; | |
| invoker = source; | |
| returning = false; | |
| returnedAction = null; | |
| byId('passage-flat-image').closest('details').open = false; | |
| renderRecord(); | |
| syncTheme(); | |
| dialog.querySelector('.passage-reader').scrollTop = 0; | |
| selectStage(0, { repaint: false }); | |
| onSelect(record.collection); | |
| syncMotion(); | |
| // Preserve the inline camera snapshot before ResizeObserver handles the | |
| // fullscreen frame; no await is allowed between these two operations. | |
| document.body.dataset.inspection = 'true'; | |
| world.setInspection({ active: true, imageUrl: stageUrl(record.figure.stages[0]), stage: 0, collection: record.collection }); | |
| if (!dialog.open) dialog.showModal(); | |
| byId('passage-title').focus({ preventScroll: true }); | |
| // Reset after modal layout and focus as well: changing to the phone's | |
| // stacked layout can otherwise preserve a previous scroll anchor. | |
| dialog.querySelector('.passage-reader').scrollTop = 0; | |
| return true; | |
| } | |
| function close(afterReturn = null) { | |
| if (!item) { afterReturn?.(); return; } | |
| if (afterReturn) returnedAction = afterReturn; | |
| if (dialog.open) { dialog.close(); return; } | |
| if (returning) return; | |
| returning = true; | |
| syncTheme(); | |
| world.setInspection({ active: false }); | |
| if (!motionState().ready || graphicsUnavailable) finishReturn(); | |
| } | |
| dialog.addEventListener('close', () => close()); | |
| byId('passage-close').addEventListener('click', () => close()); | |
| byId('passage-index').addEventListener('click', () => { | |
| const collection = item?.collection; | |
| close(() => onReadIndex(collection)); | |
| }); | |
| byId('passage-motion').addEventListener('click', () => { onMotion(); syncMotion(); }); | |
| for (const button of byId('passage-worlds').querySelectorAll('button')) { | |
| button.addEventListener('click', () => { | |
| if (!item || returning) return; | |
| onTheme(button.dataset.inspectionTheme); | |
| syncTheme(); | |
| byId('passage-status').textContent = `${instruments[sceneState().theme]}. Same entry, view ${stageIndex + 1} of ${item.figure.stages.length}.`; | |
| }); | |
| } | |
| dialog.addEventListener('keydown', event => { | |
| if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || !item || returning) return; | |
| if (['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName?.toUpperCase()) || event.target.isContentEditable) return; | |
| const button = event.target.closest('button[data-stage]'); | |
| if (!['ArrowRight', 'ArrowLeft', 'Home', 'End'].includes(event.key)) return; | |
| if (!button && ['Home', 'End'].includes(event.key)) return; | |
| event.preventDefault(); | |
| const current = button ? Number(button.dataset.stage) : stageIndex; | |
| const next = event.key === 'Home' ? 0 : event.key === 'End' ? item.figure.stages.length - 1 | |
| : (current + (event.key === 'ArrowRight' ? 1 : -1) + item.figure.stages.length) % item.figure.stages.length; | |
| selectStage(next); | |
| byId('passage-stages').querySelector(`[data-stage="${next}"]`).focus({ preventScroll: true }); | |
| }); | |
| for (const id of ['passage-flat-image', 'passage-fallback']) { | |
| byId(id).addEventListener('error', () => { | |
| if (item) byId('passage-status').textContent = 'The figure image could not load. The entry and caption are still available; check the figure source.'; | |
| }); | |
| } | |
| return { | |
| open, close, syncMotion, syncTheme, | |
| get active() { return Boolean(item); }, | |
| onSceneState({ phase }) { | |
| dialog.dataset.phase = phase; | |
| if (phase === 'unavailable') { | |
| graphicsUnavailable = true; | |
| textureReady = false; | |
| syncMotion(); | |
| byId('passage-status').textContent = '3D is unavailable. The figure and entry remain open in the flat reading view.'; | |
| } | |
| if (phase === 'closed') finishReturn(); | |
| }, | |
| onSceneImage({ stage, ready, error }) { | |
| if (!item || returning || stage !== stageIndex) return; | |
| textureReady = ready; | |
| syncMotion(); | |
| byId('passage-status').textContent = error | |
| ? 'The 3D figure could not load. Use the flat figure and readable entry; check the source image.' | |
| : ready ? `View ${stageIndex + 1} of ${item.figure.stages.length}: ${item.figure.stages[stageIndex].label}.` | |
| : 'Loading the figure image. The entry is ready to read.'; | |
| }, | |
| refresh(records) { | |
| if (!item || returning) return; | |
| const next = records.find(record => record.id === item.id && record.published && record.figure); | |
| if (!next) { close(); return; } | |
| if (next.collection !== item.collection) { | |
| close(() => onReadIndex(next.collection)); | |
| return; | |
| } | |
| const previousStage = item.figure.stages[stageIndex].id; | |
| const stageHadFocus = Boolean(document.activeElement.closest?.('#passage-stages button')); | |
| item = next; | |
| const seen = visited.get(item.id) || new Set(); | |
| visited.set(item.id, new Set([...seen].filter(id => item.figure.stages.some(stage => stage.id === id)))); | |
| renderRecord(); | |
| selectStage(Math.max(0, item.figure.stages.findIndex(stage => stage.id === previousStage))); | |
| if (stageHadFocus) byId('passage-stages').querySelector(`[data-stage="${stageIndex}"]`).focus({ preventScroll: true }); | |
| }, | |
| }; | |
| } | |