import { Camera } from './camera.js'; const viewport = document.querySelector('#viewport'); const state = document.querySelector('#state'); const id = location.pathname.split('/').pop(); const fragment = new URLSearchParams(location.hash.slice(1)); const token = fragment.get('token') || sessionStorage.getItem(`schematic:${id}`); if (token) sessionStorage.setItem(`schematic:${id}`, token); if (location.hash) history.replaceState(null, '', location.pathname); const headers = token ? { 'X-Viewer-Token': token } : {}; const controller = new AbortController(); addEventListener('pagehide', () => controller.abort(), { once: true }); const base = `/api/v1/schematics/${id}`; async function request(path, raw = false) { const r = await fetch(base + path, { headers, signal: controller.signal, cache: 'no-store' }); if (!r.ok) { const body = await r.json(); throw new Error(body.error?.message || 'Schematic unavailable'); } return raw ? r.text() : r.json(); } async function start() { state.textContent = 'Rendering…'; let result; for (;;) { result = await request('/status'); if (result.status === 'completed') break; if (result.status === 'failed') throw new Error(result.error?.message || 'Rendering failed'); await new Promise(resolve => setTimeout(resolve, 1000)); } const text = await request('/svg', true); const parsed = new DOMParser().parseFromString(text, 'image/svg+xml'); if (parsed.querySelector('parsererror') || parsed.documentElement.localName !== 'svg') throw new Error('Invalid rendering artifact'); const svg = document.importNode(parsed.documentElement, true); const b = svg.viewBox.baseVal; const camera = new Camera({ x: b.x, y: b.y, width: b.width, height: b.height }, viewport.clientWidth, viewport.clientHeight); const background = svg.querySelector('rect')?.getAttribute('fill') || '#ffffff'; // A rect spanning the camera is the original sheet color, not an added grid/UI. const backdrop = svg.querySelector('rect'); let frame = 0; function paint() { if (frame) return; frame = requestAnimationFrame(() => { frame = 0; svg.setAttribute('viewBox', camera.viewBox); const [x,y,w,h] = camera.viewBox.split(' ').map(Number); if (backdrop) for (const [key,value] of Object.entries({ x,y,width:w,height:h })) backdrop.setAttribute(key,value); }); } viewport.append(svg); await document.fonts.ready; viewport.style.backgroundColor = background; state.textContent = ''; paint(); viewport.addEventListener('wheel', e => { e.preventDefault(); const delta = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? viewport.clientHeight : 1); camera.zoom(Math.exp(-Math.max(-400, Math.min(400, delta)) * .002), e.clientX, e.clientY); paint(); }, { passive: false }); const pointers = new Map(); const metrics = () => { const p = [...pointers.values()]; const x = p.reduce((s,p) => s+p.x,0)/p.length, y = p.reduce((s,p) => s+p.y,0)/p.length; return { x, y, distance: p.length > 1 ? Math.hypot(p[0].x-p[1].x,p[0].y-p[1].y) : 0 }; }; viewport.addEventListener('pointerdown', e => { if (e.button !== 0) return; viewport.setPointerCapture(e.pointerId); pointers.set(e.pointerId, { x:e.clientX,y:e.clientY }); }); viewport.addEventListener('pointermove', e => { if (!pointers.has(e.pointerId)) return; const before = metrics(); pointers.set(e.pointerId,{ x:e.clientX,y:e.clientY }); const after = metrics(); if (before.distance > 0 && after.distance > 0) camera.zoom(after.distance/before.distance,before.x,before.y); camera.pan(after.x-before.x,after.y-before.y); paint(); }); for (const type of ['pointerup','pointercancel','lostpointercapture']) viewport.addEventListener(type,e => pointers.delete(e.pointerId)); viewport.addEventListener('dblclick', () => { camera.fit(); paint(); }); viewport.addEventListener('keydown', e => { if (e.key === 'Home' || e.key === '0') camera.fit(); else if (e.key === '+' || e.key === '=') camera.zoom(1.25,camera.width/2,camera.height/2); else if (e.key === '-') camera.zoom(.8,camera.width/2,camera.height/2); else if (e.key.startsWith('Arrow')) camera.pan(e.key==='ArrowLeft'?40:e.key==='ArrowRight'?-40:0,e.key==='ArrowUp'?40:e.key==='ArrowDown'?-40:0); else return; e.preventDefault(); paint(); }); new ResizeObserver(() => { const center = camera.world(camera.width/2,camera.height/2); camera.resize(viewport.clientWidth,viewport.clientHeight); camera.fitScale = Math.min(camera.width/camera.bounds.width,camera.height/camera.bounds.height); camera.x = camera.width/2-center.x*camera.scale; camera.y = camera.height/2-center.y*camera.scale; paint(); }).observe(viewport); // Enforce configured expiry even when a viewer remains open. function expire() { const remaining = result.expires_at*1000-Date.now(); if (remaining <= 0) { viewport.replaceChildren(); state.textContent = 'This schematic link has expired'; } else setTimeout(expire,Math.min(2147483647,remaining)); } expire(); } start().catch(e => { if (e.name !== 'AbortError') state.textContent = e.message; });