اضافه شدن قابلیت مقایسه شماتیک

This commit is contained in:
2026-11-07 15:42:53 +03:30
والد f5da7ee41b
کامیت d2b8ae96c9
28فایلهای تغییر یافته به همراه1012 افزوده شده و 33 حذف شده
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Compare Altium schematics</title><link rel="stylesheet" href="/assets/upload.css"><script type="module" src="/assets/compare-upload.js"></script></head><body><main><p class="eyebrow">NATIVE ALTIUM · STRUCTURAL DIFF</p><h1>Compare schematics.</h1><p>Select the original and modified SchDoc files.</p><form id="compare-upload"><label for="key">API key</label><input id="key" type="password" autocomplete="off" placeholder="Configured service key"><label for="old-file">Original schematic</label><input id="old-file" type="file" accept=".SchDoc" required><label for="new-file">Modified schematic</label><input id="new-file" type="file" accept=".SchDoc" required><button type="submit">Compare schematics <span aria-hidden="true">↗</span></button></form><output id="result" role="status"></output><p class="note">Comparison uses native objects and saved geometry. Connection graphics are compared without a compiled electrical netlist.</p><a href="/">Single schematic viewer</a></main></body></html>
@@ -0,0 +1,12 @@
const form=document.querySelector('#compare-upload'),result=document.querySelector('#result');
form.addEventListener('submit',async e=>{
e.preventDefault();const button=form.querySelector('button');button.disabled=true;result.textContent='Uploading both schematics…';
const headers={},key=document.querySelector('#key').value;
if(key)headers['X-API-Key']=key;
const body=new FormData();body.append('old_file',document.querySelector('#old-file').files[0]);body.append('new_file',document.querySelector('#new-file').files[0]);
try {
const response=await fetch('/api/v1/comparisons',{method:'POST',headers,body});const data=await response.json();
if(!response.ok)throw new Error(data.error?.message||'Comparison upload failed');
result.textContent='Queued. ';const link=document.createElement('a');link.href=data.url;link.textContent='Open comparison';result.append(link);
}catch(error){result.textContent=error.message;}finally{button.disabled=false;}
});
+34
مشاهده پرونده
@@ -0,0 +1,34 @@
body { display: flex; flex-direction: column; font-family: system-ui,sans-serif; color: #233431; background: #f4f6f5; }
* { box-sizing: border-box; }
header { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; padding: 12px 16px; border-bottom: 1px solid #d6dfda; }
header > a { color: #244d49; font-size: 15px; font-weight: 650; text-decoration: none; }
#summary, .controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; font-size: 12px; }
.controls { margin-left: auto; }
label { display: inline-flex; gap: 4px; align-items: center; }
button, select { border: 1px solid #cbd5ce; border-radius: 4px; padding: 5px 9px; background: white; color: inherit; font: inherit; cursor: pointer; }
button:focus-visible, select:focus-visible, .surface:focus-visible { outline: 2px solid #2676c5; outline-offset: -2px; }
#scope { margin: 0; padding: 7px 16px; font-size: 11px; color: #60716b; border-bottom: 1px solid #d6dfda; }
.workspace { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr) 270px; }
.pane { min-width: 0; min-height: 0; display: flex; flex-direction: column; border-right: 1px solid #d6dfda; }
h2 { margin: 0; padding: 9px 12px; font-size: 12px; font-weight: 650; background: #edf1ee; display: flex; gap: 10px; }
h2 span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: normal; color: #61746b; }
.surface { flex: 1; min-height: 0; width: 100%; overflow: hidden; background: white; touch-action: none; user-select: none; cursor: grab; }
.surface:active { cursor: grabbing; }
.surface > svg { display: block; width: 100%; height: 100%; }
aside { display: flex; flex-direction: column; min-height: 0; overflow: hidden; background: #fafcfb; }
.change-controls { display: flex; align-items: center; gap: 6px; padding: 10px; font-size: 12px; }
.change-controls label { margin-right: auto; }
#changes { flex: 1; min-height: 0; overflow: auto; padding: 0 8px; }
#changes button { display: flex; text-align: left; align-items: center; gap: 8px; width: 100%; margin-bottom: 5px; border-left: 3px solid currentColor; font-size: 12px; padding: 10px; }
#changes button small { margin-left: auto; color: #64756d; font-size: 10px; }
#changes button[aria-current="true"] { background: #e5eee9; box-shadow: inset 0 0 0 1px #8daba0; }
.added { color: #16854b; }.removed { color: #cf3545; }.modified { color: #b87b09; }.moved { color: #2676c5; }
#details { max-height: 35%; overflow: auto; margin: 0; padding: 10px; white-space: pre-wrap; overflow-wrap: anywhere; border-top: 1px solid #d6dfda; font-size: 11px; }
#details:empty { display: none; }
[data-diff-hidden] { display: none; }
.no-highlights [data-diff-box] { display: none; }
[data-diff-box][data-selected] { stroke-width: 3; fill-opacity: .18; }
@media (max-width: 900px) { .workspace { grid-template-columns: minmax(0,1fr) minmax(0,1fr); grid-template-rows: minmax(0,1fr) 180px; } aside { grid-column: 1/-1; } #details { max-height: 40%; } }
@media (max-width: 550px) { .workspace { grid-template-columns: 1fr; grid-template-rows: minmax(0,1fr) minmax(0,1fr) 140px; } aside { grid-column: auto; } header { gap: 8px; }.controls { margin-left: 0; } }
#changes button[hidden] { display: none; }
+6
مشاهده پرونده
@@ -0,0 +1,6 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Schematic comparison</title><link rel="stylesheet" href="/fonts.css"><link rel="stylesheet" href="/assets/viewer.css"><link rel="stylesheet" href="/assets/compare.css"><script type="module" src="/assets/compare.js"></script></head><body>
<header><a href="/compare">Schematic Diff</a><div id="summary" aria-live="polite"></div><div class="controls"><label><input id="sync" type="checkbox" checked> Sync views</label><label><input id="highlights" type="checkbox" checked> Highlights</label><label><input id="only-changes" type="checkbox"> Changes only</label><button id="fit" type="button">Fit</button></div></header>
<p id="scope">Native object comparison · Connection graphics, not a compiled electrical netlist.</p>
<main class="workspace"><section class="pane"><h2>Original <span id="old-name"></span></h2><div id="old-surface" class="surface" tabindex="0" aria-label="Original schematic"></div></section><section class="pane"><h2>Modified <span id="new-name"></span></h2><div id="new-surface" class="surface" tabindex="0" aria-label="Modified schematic"></div></section><aside><div class="change-controls"><label for="filter">Changes</label><select id="filter"><option value="all">All</option><option value="added">Added</option><option value="removed">Removed</option><option value="modified">Modified</option><option value="moved">Moved</option></select><button id="previous" aria-label="Previous change">↑</button><button id="next" aria-label="Next change">↓</button></div><nav id="changes" aria-label="Schematic changes"></nav><pre id="details" aria-live="polite"></pre></aside></main>
<output id="state" class="loading" role="status">Loading comparison…</output></body></html>
+87
مشاهده پرونده
@@ -0,0 +1,87 @@
import { bindCamera } from './interactions.js';
const state=document.querySelector('#state'),id=location.pathname.split('/').pop();
const token=new URLSearchParams(location.hash.slice(1)).get('token')||sessionStorage.getItem(`comparison:${id}`);
if(token)sessionStorage.setItem(`comparison:${id}`,token);
if(location.hash)history.replaceState(null,'',location.pathname);
const headers=token?{'X-Viewer-Token':token}:{},base=`/api/v1/comparisons/${id}`;
const controller=new AbortController(),views=[];
addEventListener('pagehide',()=>{controller.abort();for(const view of views)view.destroy();},{once:true});
async function request(path='',text=false){
const response=await fetch(base+path,{headers,signal:controller.signal,cache:'no-store'});
if(!response.ok){const body=await response.json();throw new Error(body.error?.message||'Comparison unavailable');}
return text?response.text():response.json();
}
function union(boxes){
const x=Math.min(...boxes.map(b=>b.x)),y=Math.min(...boxes.map(b=>b.y));
return {x,y,width:Math.max(...boxes.map(b=>b.x+b.width))-x,height:Math.max(...boxes.map(b=>b.y+b.height))-y};
}
async function start(){
let result;
for(;;){
result=await request();if(result.status==='completed')break;
if(result.status==='failed')throw new Error(result.error?.message||'Comparison failed');
await new Promise(resolve=>setTimeout(resolve,1000));
}
const texts=await Promise.all(['old','new'].map(side=>request('/svg/'+side,true)));
const svgs=texts.map(text=>{
const parsed=new DOMParser().parseFromString(text,'image/svg+xml');
if(parsed.querySelector('parsererror')||parsed.documentElement.localName!=='svg')throw new Error('Invalid rendering artifact');
return document.importNode(parsed.documentElement,true);
});
const content=[result.old.bounds,result.new.bounds].filter(b=>b&&b.width>0&&b.height>0);
const bounds=union(content.length?content:[result.old.sheet,result.new.sheet]);
for(const [index,side] of ['old','new'].entries()){
document.querySelector(`#${side}-name`).textContent=result[side].filename;
const surface=document.querySelector(`#${side}-surface`);surface.append(svgs[index]);
surface.style.backgroundColor=svgs[index].querySelector('rect')?.getAttribute('fill')||'#ffffff';
views.push(bindCamera(surface,svgs[index],bounds,source=>{
if(document.querySelector('#sync').checked)for(const other of views)if(other!==source)other.apply(source.pose());
}));
}
await document.fonts.ready;
state.classList.remove('loading');state.textContent='';
const changes=result.changes,byId=new Map(changes.map(c=>[c.id,c])),rows=new Map(),nodes=svgs.flatMap(svg=>[...svg.querySelectorAll('[data-change-id]')]);
const list=document.querySelector('#changes'),filter=document.querySelector('#filter');
let selected=null;
for(const [status,count]of Object.entries(result.summary)){
const badge=document.createElement('span');badge.className=status;badge.textContent=`${count} ${status}`;document.querySelector('#summary').append(badge);
}
if(!changes.length)list.textContent='No structural changes detected.';
function visibleChanges(){return changes.filter(c=>filter.value==='all'||c.status===filter.value);}
function select(change){
selected=change.id;
for(const [id,row]of rows)row.setAttribute('aria-current',String(id===selected));
for(const node of nodes){if(node.dataset.changeId===selected)node.setAttribute('data-selected','');else node.removeAttribute('data-selected');}
const boxes=[change.old_bounds,change.new_bounds].filter(Boolean);
if(boxes.length)for(const view of views)view.focus(union(boxes));
const details={kind:change.kind,match:change.match||'unmatched'};
if(change.moved)details.movement=change.movement;
if(change.fields.length)details.fields=change.fields;
document.querySelector('#details').textContent=JSON.stringify(details,null,2);
rows.get(selected)?.scrollIntoView({block:'nearest'});
}
for(const change of changes){
const button=document.createElement('button');button.type='button';button.className=change.status;
const label=document.createElement('span');label.textContent=change.label;
const kind=document.createElement('small');kind.textContent=change.status+(change.moved&&change.status==='modified'?', moved':'')+' · '+change.kind;
button.append(label,kind);button.addEventListener('click',()=>select(change));list.append(button);rows.set(change.id,button);
}
function applyFilters(){
for(const node of nodes){
const change=byId.get(node.dataset.changeId);
const hidden=change?(filter.value!=='all'&&change.status!==filter.value):(filter.value!=='all'||document.querySelector('#only-changes').checked);
node.toggleAttribute('data-diff-hidden',hidden);
}
for(const change of changes)rows.get(change.id).hidden=filter.value!=='all'&&change.status!==filter.value;
document.body.classList.toggle('no-highlights',!document.querySelector('#highlights').checked);
}
for(const element of [filter,document.querySelector('#only-changes'),document.querySelector('#highlights')])element.addEventListener('change',applyFilters);
document.querySelector('#fit').addEventListener('click',()=>{for(const view of views)view.fit();});
document.querySelector('#sync').addEventListener('change',()=>{if(document.querySelector('#sync').checked&&views[0])views[1].apply(views[0].pose());});
function next(delta){const candidates=visibleChanges();if(!candidates.length)return;const index=candidates.findIndex(c=>c.id===selected);select(candidates[(index+delta+candidates.length)%candidates.length]);}
document.querySelector('#previous').addEventListener('click',()=>next(-1));document.querySelector('#next').addEventListener('click',()=>next(1));
for(const svg of svgs)svg.addEventListener('click',event=>{const id=event.target.closest('[data-change-id]')?.dataset.changeId;if(byId.has(id))select(byId.get(id));});
applyFilters();
function expire(){const remaining=result.expires_at*1000-Date.now();if(remaining<=0){for(const view of views)view.destroy();document.querySelector('.workspace').replaceChildren();state.textContent='This comparison link has expired';}else setTimeout(expire,Math.min(2147483647,remaining));}expire();
}
start().catch(error=>{state.classList.remove('loading');state.textContent=error.name==='AbortError'?'':error.message;});
+1 -1
مشاهده پرونده
@@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Schematic API reference</title><link rel="stylesheet" href="/assets/upload.css"></head><body><main><p class="eyebrow">API V1</p><h1>Schematic rendering API</h1><p>Authenticate uploads with <code>X-API-Key</code>. Native binary OLE and ASCII SchDoc format 5.0 are accepted. Files are parsed asynchronously; HTTP 202 confirms admission, not rendering fidelity.</p><h2>Upload</h2><p><code>POST /api/v1/schematics/render</code></p><p>Multipart fields: <code>file</code> (required), <code>access</code> (private or unlisted; defaults to private), <code>expires_in</code> (optional seconds, bounded by server policy).</p><h2>Poll</h2><p><code>GET /api/v1/schematics/{id}/status</code></p><p>States: queued, processing, completed, failed. Private reads require <code>X-Viewer-Token</code> from the upload response, or the service API key. Completed responses include compatibility and diagnostics.</p><h2>Retrieve</h2><p><code>GET /api/v1/schematics/{id}/data</code> returns the versioned structured document. <code>GET /api/v1/schematics/{id}/svg</code> returns the generated vector drawing.</p><h2>View</h2><p>Open the exact <code>viewer_url</code> from the upload response. The private token is in its fragment. It is stored for this browser tab and sent only as a request header. The viewer has no panels or toolbar.</p><h2>Errors</h2><p>JSON errors include <code>success: false</code> and <code>error.code</code> / <code>error.message</code>. Common HTTP codes: 401 invalid service key; 404 missing or inaccessible schematic; 409 still processing; 410 expired; 413 too large; 415 wrong extension; 422 invalid schematic; 503 storage unavailable or queue full.</p><p><a href="/openapi.json">Download OpenAPI schema</a> · <a href="/">Upload a schematic</a></p></main></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Schematic API reference</title><link rel="stylesheet" href="/assets/upload.css"></head><body><main><p class="eyebrow">API V1</p><h1>Schematic rendering API</h1><p>Authenticate uploads with <code>X-API-Key</code>. Native binary OLE and ASCII SchDoc format 5.0 are accepted. Files are parsed asynchronously; HTTP 202 confirms admission, not rendering fidelity.</p><h2>Upload</h2><p><code>POST /api/v1/schematics/render</code></p><p>Multipart fields: <code>file</code> (required), <code>access</code> (private or unlisted; defaults to private), <code>expires_in</code> (optional seconds, bounded by server policy).</p><h2>Poll</h2><p><code>GET /api/v1/schematics/{id}/status</code></p><p>States: queued, processing, completed, failed. Private reads require <code>X-Viewer-Token</code> from the upload response, or the service API key. Completed responses include compatibility and diagnostics.</p><h2>Retrieve</h2><p><code>GET /api/v1/schematics/{id}/data</code> returns the versioned structured document. <code>GET /api/v1/schematics/{id}/svg</code> returns the generated vector drawing.</p><h2>View</h2><p>Open the exact <code>viewer_url</code> from the upload response. The private token is in its fragment. It is stored for this browser tab and sent only as a request header. The viewer has no panels or toolbar.</p><h2>Compare two schematics</h2><p><code>POST /api/v1/comparisons</code> (alias <code>/api/compare</code>) accepts multipart <code>old_file</code> and <code>new_file</code> with the same API key. It returns HTTP 202 and a private comparison URL. Poll <code>GET /api/v1/comparisons/{id}</code> using the returned read token for the completed summary and structural changes. <a href="/compare">Open comparison upload</a>.</p><h2>Errors</h2><p>JSON errors include <code>success: false</code> and <code>error.code</code> / <code>error.message</code>. Common HTTP codes: 401 invalid service key; 404 missing or inaccessible schematic; 409 still processing; 410 expired; 413 too large; 415 wrong extension; 422 invalid schematic; 503 storage unavailable or queue full.</p><p><a href="/openapi.json">Download OpenAPI schema</a> · <a href="/">Upload a schematic</a></p></main></body></html>
+1 -1
مشاهده پرونده
@@ -1,2 +1,2 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Altium schematic upload</title><link rel="stylesheet" href="/assets/upload.css"><script type="module" src="/assets/upload.js"></script></head><body><main><p class="eyebrow">NATIVE ALTIUM · SCHDOC</p><h1>Open your schematic.</h1><p>Upload a native schematic to create a private, expiring viewer link.</p><form id="upload"><label for="key">API key</label><input id="key" type="password" autocomplete="off" placeholder="Configured service key"><label for="file">Schematic file</label><input id="file" type="file" accept=".SchDoc" required><button type="submit">Render schematic <span aria-hidden="true">↗</span></button></form><output id="result" role="status"></output><p class="note">Compatibility diagnostics are returned by the API. Review them before relying on visual fidelity.</p><a href="/docs">API reference</a></main></body></html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Altium schematic upload</title><link rel="stylesheet" href="/assets/upload.css"><script type="module" src="/assets/upload.js"></script></head><body><main><p class="eyebrow">NATIVE ALTIUM · SCHDOC</p><h1>Open your schematic.</h1><p>Upload a native schematic to create a private, expiring viewer link.</p><form id="upload"><label for="key">API key</label><input id="key" type="password" autocomplete="off" placeholder="Configured service key"><label for="file">Schematic file</label><input id="file" type="file" accept=".SchDoc" required><button type="submit">Render schematic <span aria-hidden="true">↗</span></button></form><output id="result" role="status"></output><p class="note">Compatibility diagnostics are returned by the API. Review them before relying on visual fidelity.</p><a href="/compare">Compare two schematics</a> · <a href="/docs">API reference</a></main></body></html>
+56
مشاهده پرونده
@@ -0,0 +1,56 @@
import { Camera } from './camera.js';
/** Pointer/touch camera adapter shared by both comparison panes. */
export function bindCamera(surface, svg, bounds, onChange = () => {}) {
const camera = new Camera(bounds, surface.clientWidth, surface.clientHeight);
let frame = 0;
const pointers = new Map();
function paint(notify = true) {
if (!frame) frame = requestAnimationFrame(() => { frame = 0; svg.setAttribute('viewBox', camera.viewBox); });
if (notify) onChange(view);
}
const view = { camera, paint,
pose() { return { center: camera.world(camera.width/2,camera.height/2), scale: camera.scale }; },
apply({center,scale}) { camera.scale=scale; camera.x=camera.width/2-center.x*scale; camera.y=camera.height/2-center.y*scale; paint(false); },
fit() { camera.fit(); paint(); },
focus(box) {
const scale=Math.min(camera.width/Math.max(20,box.width+30),camera.height/Math.max(20,box.height+30),camera.fitScale*100);
view.apply({center:{x:box.x+box.width/2,y:box.y+box.height/2},scale}); onChange(view);
},
destroy() { cancelAnimationFrame(frame); observer.disconnect(); events.abort(); }
};
const events = new AbortController(); const options={ signal:events.signal };
surface.addEventListener('wheel',e => {
e.preventDefault();const rect=surface.getBoundingClientRect();
const delta=e.deltaY*(e.deltaMode===1?16:e.deltaMode===2?camera.height:1);
camera.zoom(Math.exp(-Math.max(-400,Math.min(400,delta))*.002),e.clientX-rect.left,e.clientY-rect.top);paint();
},{...options,passive:false});
surface.addEventListener('pointerdown',e => {
if(e.button!==0)return;surface.focus();surface.setPointerCapture(e.pointerId);
pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});
},options);
function metrics() {
const p=[...pointers.values()], rect=surface.getBoundingClientRect();
return { x:p.reduce((s,p)=>s+p.x,0)/p.length-rect.left,y:p.reduce((s,p)=>s+p.y,0)/p.length-rect.top,
distance:p.length>1?Math.hypot(p[0].x-p[1].x,p[0].y-p[1].y):0 };
}
surface.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&&after.distance)camera.zoom(after.distance/before.distance,before.x,before.y);
camera.pan(after.x-before.x,after.y-before.y);paint();
},options);
for(const type of ['pointerup','pointercancel','lostpointercapture'])surface.addEventListener(type,e=>pointers.delete(e.pointerId),options);
surface.addEventListener('dblclick',()=>view.fit(),options);
surface.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();
},options);
const observer=new ResizeObserver(()=>{
const pose=view.pose();camera.resize(surface.clientWidth,surface.clientHeight);
camera.fitScale=Math.min(camera.width/bounds.width,camera.height/bounds.height);view.apply(pose);
});observer.observe(surface);paint(false);return view;
}
@@ -0,0 +1,46 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { bindCamera } from './interactions.js';
const close=(a,b)=>assert.ok(Math.abs(a-b)<1e-8,`${a} != ${b}`);
function setup(t) {
const saved={raf:globalThis.requestAnimationFrame,caf:globalThis.cancelAnimationFrame,observer:globalThis.ResizeObserver};
const frames=[];
globalThis.requestAnimationFrame=fn=>{frames.push(fn);return frames.length;};globalThis.cancelAnimationFrame=()=>{};
globalThis.ResizeObserver=class {observe(){}disconnect(){}};
t.after(()=>{globalThis.requestAnimationFrame=saved.raf;globalThis.cancelAnimationFrame=saved.caf;globalThis.ResizeObserver=saved.observer;});
function surface(left=100) {
const surface=new EventTarget();Object.assign(surface,{clientWidth:800,clientHeight:600,
getBoundingClientRect:()=>({left,top:20}),focus(){},setPointerCapture(){}});
const attributes={};return {surface,svg:{setAttribute:(k,v)=>attributes[k]=v},attributes};
}
return {surface,flush:()=>{for(const fn of frames.splice(0))fn();}};
}
function send(surface,type,values){const event=new Event(type,{cancelable:true});Object.assign(event,values);surface.dispatchEvent(event);return event;}
test('wheel zoom uses the pane offset and synchronizes world cameras',t=>{
const env=setup(t),a=env.surface(),b=env.surface(1000),bounds={x:0,y:-760,width:1150,height:760};
let second;const first=bindCamera(a.surface,a.svg,bounds,source=>second.apply(source.pose()));second=bindCamera(b.surface,b.svg,bounds);
const anchor=first.camera.world(250,200);
const event=send(a.surface,'wheel',{deltaY:-120,deltaMode:0,clientX:350,clientY:220});
assert.ok(event.defaultPrevented);
close(first.camera.world(250,200).x,anchor.x);close(first.camera.world(250,200).y,anchor.y);
close(first.pose().center.x,second.pose().center.x);close(first.camera.scale,second.camera.scale);
env.flush();assert.equal(a.attributes.viewBox,b.attributes.viewBox);first.destroy();second.destroy();
});
test('two-pointer pinch preserves the moving center without rasterizing',t=>{
const env=setup(t),{surface,svg}=env.surface();const view=bindCamera(surface,svg,{x:0,y:0,width:100,height:100});
send(surface,'pointerdown',{pointerId:1,button:0,clientX:200,clientY:200});
send(surface,'pointerdown',{pointerId:2,button:0,clientX:300,clientY:200});
const anchor=view.camera.world(150,180),scale=view.camera.scale;
send(surface,'pointermove',{pointerId:2,clientX:400,clientY:200});
close(view.camera.scale,scale*2);close(view.camera.world(200,180).x,anchor.x);close(view.camera.world(200,180).y,anchor.y);
view.destroy();
});
test('focus fits change bounds and detaching stops interaction',t=>{
const env=setup(t),{surface,svg}=env.surface();const view=bindCamera(surface,svg,{x:0,y:0,width:1000,height:1000});
view.focus({x:100,y:200,width:20,height:40});const center=view.pose().center;
close(center.x,110);close(center.y,220);view.destroy();const scale=view.camera.scale;
send(surface,'wheel',{deltaY:-120,deltaMode:0,clientX:200,clientY:200});close(view.camera.scale,scale);
});