Files
altium-render-engine/frontend/viewer.js
T
2026-11-09 14:55:36 +03:30

104 خطوط
5.3 KiB
JavaScript

import { mountShare, viewerURL } from './share.js?v=1';
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.classList.remove('loading');
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.
mountShare(document.querySelector('#viewer-share'),viewerURL(location.pathname,token));
function expire() {
if (result.expires_at === -1) return;
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 => {
state.classList.remove('loading');
state.textContent = e.name === 'AbortError' ? '' : e.message;
});