<tmf-viewer>: clamp fit*8, viewer-error, etichette autonome, pagina di prova

Refs #122

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 22:05:54 +01:00
parent 20c0a47c00
commit d48a8a1076
3 changed files with 155 additions and 6 deletions
@@ -30,9 +30,9 @@
var f = Math.min(boxW / w, boxH / h); var f = Math.min(boxW / w, boxH / h);
return isFinite(f) && f > 0 ? f : 0; return isFinite(f) && f > 0 ? f : 0;
}, },
// zoom ristretto a [fit * 0.5, max(8, fit)] (come #115) // zoom ristretto a [fit * 0.5, max(8, fit * 8)]: il massimo segue il fit, cosi' un DXF in mm (fit ~10) si ingrandisce ancora
clampZoom: function (z, fit) { clampZoom: function (z, fit) {
var lo = fit * 0.5, hi = Math.max(8, fit); var lo = fit * 0.5, hi = Math.max(8, fit * 8);
return Math.min(hi, Math.max(lo, z)); return Math.min(hi, Math.max(lo, z));
}, },
// nuovo scroll che tiene fermo il punto (px, py) del viewport passando dallo zoom z0 a z1 // nuovo scroll che tiene fermo il punto (px, py) del viewport passando dallo zoom z0 a z1
@@ -54,8 +54,10 @@
'tmf-viewer .tmfv-stage>svg:not(.tmfv-ov){display:block;width:100%;height:100%}', 'tmf-viewer .tmfv-stage>svg:not(.tmfv-ov){display:block;width:100%;height:100%}',
'tmf-viewer .tmfv-ov{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;overflow:visible}', 'tmf-viewer .tmfv-ov{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;overflow:visible}',
'tmf-viewer .tmfv-labels{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none}', 'tmf-viewer .tmfv-labels{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none}',
'tmf-viewer .tmfv-label{position:absolute;transform:translate(8px,-50%);white-space:nowrap;font:600 12px/1.3 system-ui,sans-serif;', // #122: aspetto completo dell'etichetta; le classi `cls` della pagina (es. bg-red-600 text-white) cambiano solo i colori
'color:#fff;background:rgba(0,0,0,.6);padding:1px 5px;border-radius:3px;pointer-events:none}', 'tmf-viewer .tmfv-label{position:absolute;transform:translate(8px,-50%);white-space:nowrap;font:700 12px/1.3 system-ui,sans-serif;',
'padding:2px 6px;border-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,.4);pointer-events:none}',
':where(tmf-viewer .tmfv-label){color:#fff;background:rgba(0,0,0,.6)}',
'tmf-viewer .tmfv-bar{position:absolute;top:6px;right:6px;z-index:3;display:flex;align-items:center;gap:2px;padding:2px;', 'tmf-viewer .tmfv-bar{position:absolute;top:6px;right:6px;z-index:3;display:flex;align-items:center;gap:2px;padding:2px;',
'background:rgba(0,0,0,.55);color:#fff;border-radius:6px;font:12px/1.4 system-ui,sans-serif}', 'background:rgba(0,0,0,.55);color:#fff;border-radius:6px;font:12px/1.4 system-ui,sans-serif}',
'tmf-viewer .tmfv-bar button{appearance:none;border:0;border-radius:4px;background:transparent;color:#fff;', 'tmf-viewer .tmfv-bar button{appearance:none;border:0;border-radius:4px;background:transparent;color:#fff;',
@@ -135,6 +137,11 @@
this._bFit.onclick = function () { self.fit(); }; this._bFit.onclick = function () { self.fit(); };
this._bSave.onclick = function () { self._save(); }; this._bSave.onclick = function () { self._save(); };
img.addEventListener('load', function () { self._onLoad(); }); img.addEventListener('load', function () { self._onLoad(); });
// #122: errore di caricamento -> evento viewer-error (bubbling) con l'URL che non si e' caricato
img.addEventListener('error', function () {
if (!self._hasSrc()) return; // l'errore di un src appena tolto non conta
self.dispatchEvent(new CustomEvent('viewer-error', { bubbles: true, detail: { src: self.getAttribute('src') } }));
});
// Ctrl+rotella = zoom intorno al puntatore; senza Ctrl la pagina scorre come sempre // Ctrl+rotella = zoom intorno al puntatore; senza Ctrl la pagina scorre come sempre
vp.addEventListener('wheel', function (ev) { self._wheel(ev); }, { passive: false }); vp.addEventListener('wheel', function (ev) { self._wheel(ev); }, { passive: false });
vp.addEventListener('pointerdown', function (ev) { self._panDown(ev); }); vp.addEventListener('pointerdown', function (ev) { self._panDown(ev); });
@@ -143,7 +150,12 @@
vp.addEventListener('pointercancel', function () { self._panEnd(); }); vp.addEventListener('pointercancel', function () { self._panEnd(); });
// resize del viewport: in stato «adatta» rifa Adatta; altrimenti aggiorna solo il fit // resize del viewport: in stato «adatta» rifa Adatta; altrimenti aggiorna solo il fit
if (typeof ResizeObserver !== 'undefined') { if (typeof ResizeObserver !== 'undefined') {
this._ro = new ResizeObserver(function () { self._onResize(); }); // #122: la callback cambia l'altezza del viewport (fit-height): rimando a un task dopo, senza «ResizeObserver loop»
this._ro = new ResizeObserver(function () {
if (self._raf) return;
var run = function () { self._raf = 0; self._onResize(); };
self._raf = setTimeout(run, 0);
});
this._ro.observe(vp); this._ro.observe(vp);
} }
// contenuto aggiunto dopo (x-html, template): lo adotto // contenuto aggiunto dopo (x-html, template): lo adotto
@@ -247,6 +259,7 @@
if (this._content) this._content.style.display = this._hasSrc() ? 'none' : ''; if (this._content) this._content.style.display = this._hasSrc() ? 'none' : '';
if (!w) { this._z = 0; this._fit = 0; this._auto = true; this._apply(); return; } if (!w) { this._z = 0; this._fit = 0; this._auto = true; this._apply(); return; }
if (changed || this._auto || !this._z) this.fit(); else this._apply(); if (changed || this._auto || !this._z) this.fit(); else this._apply();
if (!this._z) this._apply(true); // riquadro nascosto: Adatta rimandata, ma overlay ed etichette (in %) si disegnano gia'
} }
// #122: area utile del viewport: offset meno i bordi (client* escludono le barre che spariranno dopo l'Adatta) // #122: area utile del viewport: offset meno i bordi (client* escludono le barre che spariranno dopo l'Adatta)
@@ -0,0 +1,135 @@
<!doctype html>
<!-- #122: pagina di prova di <tmf-viewer> da aprire in Chrome (file:// va bene): immagine generata col canvas,
overlay vs-tools finto, etichette e modo contenuto (svg). Il riquadro #log riporta i controlli automatici. -->
<html lang="it">
<head>
<meta charset="utf-8">
<title>Prova tmf-viewer</title>
<style>
body { font: 14px system-ui, sans-serif; margin: 16px; background: #f3f4f6; }
.griglia { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.box { height: 320px; }
h2 { font-size: 13px; margin: 12px 0 4px; }
#log { white-space: pre; font: 12px ui-monospace, monospace; background: #fff; padding: 8px; margin-top: 12px; }
</style>
</head>
<body>
<h1>Prova di &lt;tmf-viewer&gt;</h1>
<div class="griglia">
<div><h2>Immagine 800x500 + overlay + etichette (box fisso, Adatta nel box)</h2>
<tmf-viewer id="a" class="box" save-name="demo_immagine" width="800" height="500"></tmf-viewer></div>
<div><h2>fit-height, no-save: alto quanto l'immagine</h2>
<tmf-viewer id="b" fit-height no-save label-fit="Intera"></tmf-viewer></div>
<div><h2>Modo contenuto: svg (DXF) senza src</h2>
<tmf-viewer id="c" class="box" save-name="demo_dxf">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><rect x="5" y="5" width="190" height="90" fill="none" stroke="#fff" stroke-width="1"/><circle cx="100" cy="50" r="30" fill="none" stroke="#0ff" stroke-width="1"/></svg>
</tmf-viewer></div>
<div><h2>Vuoto: barra nascosta</h2><tmf-viewer id="d" class="box"></tmf-viewer></div>
</div>
<div id="log">in corso...</div>
<script src="../../../static/js/vision_overlay.js"></script>
<script src="../../../static/js/save_image.js"></script>
<script src="../../../static/js/image_viewer.js"></script>
<script>
window.onerror = function (m, u, l) { document.getElementById('log').textContent += '\nERRORE ' + m + ' riga ' + l; }; // errori visibili nel riquadro
(function () {
var out = [], fails = 0;
function check(l, c, x) { if (!c) fails++; out.push((c ? 'ok - ' : 'FAIL - ') + l + (c || x === undefined ? '' : ' :: ' + x)); document.getElementById('log').textContent = out.join('\n'); } // log aggiornato a ogni controllo
function img(w, h, col) {
var c = document.createElement('canvas'); c.width = w; c.height = h;
var g = c.getContext('2d'); g.fillStyle = col; g.fillRect(0, 0, w, h);
g.fillStyle = '#fff'; for (var x = 0; x < w; x += 100) g.fillRect(x, 0, 2, h);
for (var y = 0; y < h; y += 100) g.fillRect(0, y, w, 2);
return c.toDataURL('image/png');
}
var a = document.getElementById('a'), b = document.getElementById('b'), c = document.getElementById('c'), d = document.getElementById('d');
var ev = { load: 0, zoom: 0 };
a.addEventListener('viewer-load', function (e) { ev.load++; ev.det = e.detail; });
a.addEventListener('viewer-zoom', function () { ev.zoom++; });
a.overlay = [{ type: 'segment', role: 'found', row1: 50, col1: 50, row2: 450, col2: 750 },
{ type: 'points', role: 'reference', rows: [100, 200], cols: [100, 300] }];
a.labels = [{ key: 'q1', x: 400, y: 250, text: 'Quota 1 12.345 mm', cls: 'ok' }];
a.setAttribute('src', img(800, 500, '#334155'));
b.setAttribute('src', img(1000, 400, '#475569'));
function btn(v, i) { return v.querySelectorAll('.tmfv-bar button')[i]; }
setTimeout(function () {
var vpa = a.querySelector('.tmfv-vp'), st = a.querySelector('.tmfv-stage');
var fitA = function () { return Math.min(vpa.clientWidth / 800, vpa.clientHeight / 500); };
check('a: viewer-load una volta con 800x500', ev.load === 1 && ev.det && ev.det.width === 800 && ev.det.height === 500, JSON.stringify(ev));
check('a: Adatta = min(box/img), stage entra nel box', Math.abs(a.zoom - fitA()) < 0.01 && st.offsetWidth <= vpa.clientWidth + 1 && st.offsetHeight <= vpa.clientHeight + 1,
a.zoom + ' ' + st.offsetWidth + 'x' + st.offsetHeight + ' vp ' + vpa.clientWidth + 'x' + vpa.clientHeight);
check('a: percentuale mostrata', /^\d+ %$/.test(a.querySelector('.tmfv-pct').textContent), a.querySelector('.tmfv-pct').textContent);
check('a: overlay con 2 forme e viewBox 0 0 800 500', a.querySelector('.tmfv-ov').children.length >= 2 && a.querySelector('.tmfv-ov').getAttribute('viewBox') === '0 0 800 500', a.querySelector('.tmfv-ov').innerHTML.slice(0, 80));
var lab = a.querySelector('.tmfv-label');
check('a: etichetta al 50 % / 50 %, testo, classe', lab && lab.style.left === '50%' && lab.style.top === '50%' && lab.textContent === 'Quota 1 12.345 mm' && lab.classList.contains('ok'));
check('a: barra visibile con -, +, Adatta, Salva', !a.querySelector('.tmfv-bar').hidden && a.querySelectorAll('.tmfv-bar button').length === 4 && btn(a, 3).textContent === 'Salva');
var z0 = a.zoom; btn(a, 1).click();
check('a: + ingrandisce di 1.25', Math.abs(a.zoom / z0 - 1.25) < 1e-9, a.zoom + '/' + z0);
for (var i = 0; i < 6; i++) a.zoomBy(1.25);
check('a: ingrandito -> scorrimento possibile e cursore grab', vpa.scrollWidth > vpa.clientWidth && vpa.style.cursor === 'grab', vpa.style.cursor);
var rect = vpa.getBoundingClientRect(), px = 120, py = 90;
var zb = a.zoom, lx = (vpa.scrollLeft + px) / zb, ly = (vpa.scrollTop + py) / zb;
vpa.dispatchEvent(new WheelEvent('wheel', { ctrlKey: true, deltaY: -100, clientX: rect.left + vpa.clientLeft + px, clientY: rect.top + vpa.clientTop + py, bubbles: true, cancelable: true }));
var za = a.zoom, lx2 = (vpa.scrollLeft + px) / za, ly2 = (vpa.scrollTop + py) / za;
check('a: Ctrl+rotella zooma e tiene fermo il punto', za > zb && Math.abs(lx - lx2) < 1 && Math.abs(ly - ly2) < 1, [zb, za, lx, lx2, ly, ly2].join(' '));
for (i = 0; i < 40; i++) a.zoomBy(1.25);
check('a: zoom limitato a max(8, fit)', a.zoom <= 8.0000001, a.zoom);
var we = new WheelEvent('wheel', { deltaY: 100, bubbles: true, cancelable: true }); vpa.dispatchEvent(we);
check('a: rotella senza Ctrl non intercettata', we.defaultPrevented === false);
btn(a, 2).click();
check('a: Adatta torna al fit e scroll a 0', Math.abs(a.zoom - fitA()) < 0.01 && vpa.scrollLeft === 0, a.zoom);
check('a: viewer-zoom emesso a ogni cambio', ev.zoom > 3, ev.zoom);
a.zoomBy(1.25); var zz = a.zoom; ev.load = 0;
a.setAttribute('src', img(800, 500, '#7f1d1d'));
setTimeout(function () {
check('a: nuovo src, stesse dimensioni -> zoom mantenuto', ev.load === 1 && Math.abs(a.zoom - zz) < 1e-9, a.zoom + ' vs ' + zz);
var vpb = b.querySelector('.tmfv-vp');
check('b: fit-height: viewport alto quanto l\'immagine adattata alla larghezza', Math.abs(vpb.offsetHeight - 400 * b.zoom) < 1.5 && Math.abs(b.zoom - vpb.clientWidth / 1000) < 0.01,
vpb.offsetHeight + ' vs ' + 400 * b.zoom + ' zoom ' + b.zoom);
check('b: no-save nasconde Salva, label-fit "Intera"', btn(b, 3).hidden && btn(b, 2).textContent === 'Intera');
b.style.width = '300px';
// il resize e' consegnato dal ResizeObserver al prossimo frame: in una scheda in background puo' tardare, quindi attendo (max 5 s)
var t0 = Date.now();
(function attendi() { if (b.zoom < 0.5 || Date.now() - t0 > 5000) dopo(); else setTimeout(attendi, 100); })();
function dopo() {
check('b: resize in stato adatta -> rifa Adatta (300 px)', Math.abs(b.zoom - vpb.clientWidth / 1000) < 0.01 && Math.abs(vpb.offsetHeight - 400 * b.zoom) < 1.5, b.zoom + ' ' + vpb.offsetHeight);
var stc = c.querySelector('.tmfv-stage');
check('c: svg nello stage, dimensioni dal viewBox 200x100, Adatta', stc.querySelector('svg:not(.tmfv-ov)') && !c.querySelector(':scope > svg') && c.zoom > 0 && Math.abs(stc.offsetWidth / stc.offsetHeight - 2) < 0.05, c.zoom + ' ' + stc.offsetWidth + 'x' + stc.offsetHeight);
// #122: DXF in mm (fit ~ 10): si ingrandisce oltre Adatta; width/height cambiati dopo il connect rifanno Adatta
c.setAttribute('width', '50'); c.setAttribute('height', '30'); // contenuto 50 x 30 mm nel box ~ 560 x 320
var zc = c.zoom; c.zoomBy(1.25);
check('c: width/height 50x30 -> fit > 8 e zoom oltre Adatta', zc > 8 && c.zoom > zc * 1.2, zc + ' -> ' + c.zoom);
// #122: viewer-error su immagine non caricabile; src tolto -> componente vuoto senza errori
var e = document.createElement('tmf-viewer'); e.style.height = '100px'; document.body.appendChild(e);
var gotErr = null; e.addEventListener('viewer-error', function (x) { gotErr = x.detail; });
e.overlay = [{ type: 'segment', role: 'found', row1: 1, col1: 1, row2: 5, col2: 9 }]; e.labels = [{ x: 5, y: 5, text: 'x' }]; // prima del load
e.setAttribute('src', 'inesistente.png');
setTimeout(function () {
check('e: viewer-error con src', gotErr && gotErr.src === 'inesistente.png', JSON.stringify(gotErr));
e.setAttribute('src', img(100, 50, '#000'));
setTimeout(function () {
check('e: overlay ed etichette impostati prima del load disegnati dopo', e.querySelector('.tmfv-ov').children.length === 1 && e.querySelectorAll('.tmfv-label').length === 1);
var lb = getComputedStyle(e.querySelector('.tmfv-label'));
check('e: etichetta con aspetto proprio (absolute, bold, padding)', lb.position === 'absolute' && lb.fontWeight === '700' && lb.paddingLeft === '6px' && lb.pointerEvents === 'none');
e.removeAttribute('src');
check('e: src tolto -> vuoto, barra nascosta', e.querySelector('.tmfv-bar').hidden === true && e.zoom === 0);
}, 300);
}, 300);
check('d: vuoto -> barra nascosta', d.querySelector('.tmfv-bar').hidden === true);
check('stile iniettato una sola volta', document.querySelectorAll('#tmf-viewer-css').length === 1);
tmfSvgToPngBlob(c.querySelector('.tmfv-stage > svg:not(.tmfv-ov)'), 200, 100).then(function (bl) {
check('c: tmfSvgToPngBlob produce un PNG', bl && bl.type === 'image/png' && bl.size > 100, bl && (bl.type + ' ' + bl.size));
return createImageBitmap(bl);
}).then(function (bm) {
check('c: PNG 1600x800', bm.width === 1600 && bm.height === 800, bm.width + 'x' + bm.height);
}).catch(function (er) { check('c: rasterizzazione', false, String(er)); }).then(function () { return new Promise(function (r) { setTimeout(r, 900); }); }).then(function () {
document.getElementById('log').textContent = out.join('\n') + '\n\n' + (out.length - fails) + '/' + out.length + ' controlli ok';
document.title = fails ? 'FALLITO' : 'TUTTO OK';
});
}
}, 400);
}, 600);
})();
</script>
</body>
</html>
@@ -44,7 +44,8 @@ check('fitZoom: 0 con NaN, negativi, undefined',
check('clampZoom: dentro i limiti resta uguale', near(M.clampZoom(1, 0.5), 1)); check('clampZoom: dentro i limiti resta uguale', near(M.clampZoom(1, 0.5), 1));
check('clampZoom: sotto fit*0.5 -> fit*0.5', near(M.clampZoom(0.01, 0.5), 0.25)); check('clampZoom: sotto fit*0.5 -> fit*0.5', near(M.clampZoom(0.01, 0.5), 0.25));
check('clampZoom: sopra 8 -> 8', near(M.clampZoom(20, 0.5), 8)); check('clampZoom: sopra 8 -> 8', near(M.clampZoom(20, 0.5), 8));
check('clampZoom: fit sopra 8 alza il massimo a fit', near(M.clampZoom(20, 12), 12) && near(M.clampZoom(11, 12), 11)); check('clampZoom: fit sopra 1 alza il massimo a fit*8 (DXF in mm, fit ~10)', near(M.clampZoom(20, 12), 20) && near(M.clampZoom(200, 12), 96) && near(M.clampZoom(11, 12), 11));
check('clampZoom: con fit piccolo il massimo resta 8', near(M.clampZoom(20, 0.5), 8) && near(M.clampZoom(20, 1), 8) && near(M.clampZoom(100, 2), 16));
check('clampZoom: limiti esatti', near(M.clampZoom(0.25, 0.5), 0.25) && near(M.clampZoom(8, 0.5), 8)); check('clampZoom: limiti esatti', near(M.clampZoom(0.25, 0.5), 0.25) && near(M.clampZoom(8, 0.5), 8));
// --- zoomAround --- // --- zoomAround ---