import { fittedSize, cameraAt } from './geometry.mjs'; const $ = selector => document.querySelector(selector); const stage = $('#stage'), scene = $('#scene'), layer = $('#imageLayer'); const manifest = await fetch('./views.json').then(response => { if (!response.ok) throw new Error('View list unavailable'); return response.json(); }).catch(() => null); if (!manifest) { $('#loading').hidden = true; $('#error').hidden = false; $('#retry').onclick = () => location.reload(); } else { const views = new Map(manifest.views.map(view => [view.id, view])); const groups = new Map(manifest.groups.map(group => [group.id, group])); let current = null, pendingView = null, revision = 0, ready = false; let camera = { zoom: 1, x: 0, y: 0 }, fit = { width: 1, height: 1 }; let imageSize = { width: 1672, height: 941 }, drag = null, pinch = null; const pointers = new Map(), images = new Map(); function load(view) { if (!images.has(view.id)) { const image = new Image(); image.decoding = 'async'; const promise = new Promise((resolve, reject) => { image.onload = () => resolve(image); image.onerror = () => { images.delete(view.id); reject(new Error('Image unavailable')); }; }); image.src = view.file; images.set(view.id, promise); } return images.get(view.id); } function stageSize() { return { width: stage.clientWidth, height: stage.clientHeight }; } function paint() { const size = stageSize(); fit = fittedSize(size.width, size.height, imageSize.width, imageSize.height); camera = cameraAt(size, fit, camera.zoom, camera.x, camera.y); layer.style.width = fit.width + 'px'; layer.style.height = fit.height + 'px'; layer.style.transform = `translate(-50%,-50%) translate(${camera.x}px,${camera.y}px) scale(${camera.zoom})`; for (const marker of $('#hotspots').children) { const marginX = Math.min(fit.width / 2, marker.offsetWidth / 2 + 8); const marginY = Math.min(fit.height / 2, marker.offsetHeight / 2 + 8); marker.style.left = Math.max(marginX, Math.min(fit.width - marginX, Number(marker.dataset.x) * fit.width)) + 'px'; marker.style.top = Math.max(marginY, Math.min(fit.height - marginY, Number(marker.dataset.y) * fit.height)) + 'px'; } stage.classList.toggle('zoomed', camera.zoom > 1.01); $('#zoomOut').disabled = camera.zoom <= 1; $('#zoomIn').disabled = camera.zoom >= 1.8; $('#fit').textContent = camera.zoom <= 1.01 ? 'Full view' : Math.round(camera.zoom * 100) + '% · Reset'; } function reset() { camera = { zoom: 1, x: 0, y: 0 }; paint(); } function zoomTo(zoom, atX = 0, atY = 0) { const next = Math.max(1, Math.min(1.8, zoom)); const scale = next / camera.zoom; camera = { zoom: next, x: atX - (atX - camera.x) * scale, y: atY - (atY - camera.y) * scale }; paint(); } function button(text, callback, className = '') { const result = document.createElement('button'); result.type = 'button'; result.textContent = text; result.className = className; result.addEventListener('click', callback); return result; } function renderControls() { const group = groups.get(current.group); $('#roomTitle').textContent = group.title; $('#angleName').textContent = current.title; const number = manifest.views.findIndex(view => view.id === current.id) + 1; $('#viewCount').textContent = String(number).padStart(2, '0') + ' / ' + manifest.views.length; for (const control of document.querySelectorAll('[data-region]')) control.setAttribute('aria-pressed', String(control.dataset.region === group.region)); $('#rooms').replaceChildren(...manifest.groups.filter(item => item.region === group.region).map(item => { const control = button(item.title, () => navigate(item.views[0])); control.setAttribute('aria-current', String(item.id === group.id)); return control; })); $('#angles').replaceChildren(...group.views.map(id => { const view = views.get(id), control = button('', () => navigate(id), 'angle'); control.setAttribute('aria-label', 'View ' + view.title.toLowerCase()); control.setAttribute('aria-current', String(id === current.id)); const thumbnail = document.createElement('img'); thumbnail.src = view.thumbnail; thumbnail.alt = ''; thumbnail.draggable = false; const caption = document.createElement('span'); caption.textContent = view.title; control.append(thumbnail, caption); return control; })); $('#previous').disabled = group.views.length < 2; $('#next').disabled = group.views.length < 2; $('#hotspots').replaceChildren(...current.hotspots.map(([target, x, y, label]) => { const control = button(label, () => navigate(groups.get(target).views[0]), 'hotspot'); control.setAttribute('aria-label', 'Go to ' + label.toLowerCase()); control.dataset.x = String(x); control.dataset.y = String(y); control.style.left = x * 100 + '%'; control.style.top = y * 100 + '%'; return control; })); } async function navigate(id) { if (!views.has(id)) throw new Error('Unknown photographic viewpoint'); const request = ++revision, view = views.get(id); pendingView = id; $('#loading').textContent = 'Opening ' + groups.get(view.group).title.toLowerCase() + '…'; $('#loading').hidden = false; $('#error').hidden = true; try { const image = await load(view); if (request !== revision) return { cancelled: true }; scene.src = image.src; scene.alt = view.alt; await scene.decode(); if (request !== revision) return { cancelled: true }; current = view; imageSize = { width: image.naturalWidth, height: image.naturalHeight }; ready = true; pointers.clear(); drag = pinch = null; stage.classList.remove('dragging'); renderControls(); reset(); $('#loading').hidden = true; const url = new URL(location.href); url.searchParams.set('view', id); history.replaceState(null, '', url); const group = groups.get(current.group); for (const adjacent of group.views) load(views.get(adjacent)).catch(() => {}); return { view: current.id, zoom: camera.zoom }; } catch (error) { if (request === revision) { $('#loading').hidden = true; $('#error').hidden = false; } return { error: error.message }; } } function step(direction) { if (!current) return; const ids = groups.get(current.group).views; return navigate(ids[(ids.indexOf(current.id) + direction + ids.length) % ids.length]); } $('#zoomIn').onclick = () => zoomTo(camera.zoom + .15); $('#zoomOut').onclick = () => zoomTo(camera.zoom - .15); $('#fit').onclick = reset; $('#previous').onclick = () => step(-1); $('#next').onclick = () => step(1); $('#retry').onclick = () => navigate(pendingView || 'living-1'); for (const control of document.querySelectorAll('[data-region]')) control.onclick = () => { if (current && groups.get(current.group).region === control.dataset.region) return; navigate(manifest.groups.find(group => group.region === control.dataset.region).views[0]); }; $('#help').onclick = () => $('#helpDialog').showModal(); $('#closeHelp').onclick = $('#startTour').onclick = () => $('#helpDialog').close(); $('#helpDialog').addEventListener('click', event => { if (event.target !== $('#helpDialog')) return; const rect = event.target.getBoundingClientRect(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) event.target.close(); }); $('#fullscreen').onclick = async () => { try { if (document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); } catch { $('#fullscreen').hidden = true; } }; document.addEventListener('fullscreenchange', () => { $('#fullscreen').setAttribute('aria-label', document.fullscreenElement ? 'Exit fullscreen' : 'Enter fullscreen'); paint(); }); if (!document.documentElement.requestFullscreen) $('#fullscreen').hidden = true; function pointerDistance() { const points = [...pointers.values()]; return points.length < 2 ? 0 : Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y); } stage.addEventListener('pointerdown', event => { if (event.target.closest('button') || !ready) return; stage.focus({ preventScroll: true }); stage.setPointerCapture(event.pointerId); pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); if (pointers.size === 1) drag = { x: event.clientX, y: event.clientY, cx: camera.x, cy: camera.y }; else { drag = null; pinch = { distance: pointerDistance(), zoom: camera.zoom }; } stage.classList.add('dragging'); }); stage.addEventListener('pointermove', event => { if (!pointers.has(event.pointerId)) return; pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); if (pointers.size >= 2 && pinch?.distance) zoomTo(pinch.zoom * pointerDistance() / pinch.distance); else if (drag) { camera.x = drag.cx + event.clientX - drag.x; camera.y = drag.cy + event.clientY - drag.y; paint(); } }); function endPointer(event) { pointers.delete(event.pointerId); drag = pinch = null; if (pointers.size === 1) { const point = [...pointers.values()][0]; drag = { x: point.x, y: point.y, cx: camera.x, cy: camera.y }; } if (!pointers.size) stage.classList.remove('dragging'); } stage.addEventListener('pointerup', endPointer); stage.addEventListener('pointercancel', endPointer); window.addEventListener('blur', () => { pointers.clear(); drag = pinch = null; stage.classList.remove('dragging'); }); stage.addEventListener('wheel', event => { if (event.target.closest('button') || !ready) return; event.preventDefault(); const rect = stage.getBoundingClientRect(); zoomTo(camera.zoom * Math.exp(-event.deltaY * .001), event.clientX - rect.left - rect.width / 2, event.clientY - rect.top - rect.height / 2); }, { passive: false }); stage.addEventListener('keydown', event => { if (event.target !== stage || !ready) return; if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', '+', '=', '-', 'Home'].includes(event.key)) return; event.preventDefault(); if (event.key === 'Home') reset(); else if (['+', '='].includes(event.key)) zoomTo(camera.zoom + .15); else if (event.key === '-') zoomTo(camera.zoom - .15); else if (camera.zoom > 1) { camera.x += event.key === 'ArrowLeft' ? 40 : event.key === 'ArrowRight' ? -40 : 0; camera.y += event.key === 'ArrowUp' ? 40 : event.key === 'ArrowDown' ? -40 : 0; paint(); } else if (event.key === 'ArrowLeft') step(-1); else if (event.key === 'ArrowRight') step(1); }); new ResizeObserver(paint).observe(stage); const requested = new URLSearchParams(location.search).get('view'); window.__photoTour = { navigate, step, reset, zoomTo, manifest, get state() { return { view: current?.id, ...camera, ready, fit, imageSize }; } }; await navigate(views.has(requested) ? requested : 'living-1'); }