Visore <tmf-viewer> di TieVision-Station con no-layers su ogni immagine di TMF (#126)
Rotella senza Ctrl = zoom sul punto del mouse su tutte le immagini. image_viewer.js e vision_overlay.js copiati byte per byte da TieVision-Station (lavoro/daitarn-nolayers b1af911); attributo no-layers su ogni <tmf-viewer> (task_editor 3, task_execute 1, calibrazione_pagina 4). Test JS e pagina demo allineati. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
This commit is contained in:
@@ -170,7 +170,7 @@ PAGINA = r"""<!doctype html>
|
||||
<div class="titolo">Immagine<span>grezza, senza correzione</span></div>
|
||||
<div class="palco"> <!-- #122: tmf-viewer al posto del visore #111; width/height = risoluzione della camera, messe da tornaAdatta() -->
|
||||
<div id="vuoto">Anteprima ferma. Premere «Avvia anteprima» o «Scatta».</div>
|
||||
<tmf-viewer id="immagine" save-name="grezza" title-save="Salva l'immagine grezza" hidden></tmf-viewer>
|
||||
<tmf-viewer id="immagine" save-name="grezza" title-save="Salva l'immagine grezza" no-layers hidden></tmf-viewer> <!-- #126: no-layers = senza menu dei livelli -->
|
||||
<div id="mirino" class="mirino" hidden></div> <!-- #122: spostato nello stage del componente da creaVisore() -->
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,7 +178,7 @@ PAGINA = r"""<!doctype html>
|
||||
<div class="titolo">Calibrata<span id="nomeCalibrata"></span></div>
|
||||
<div class="palco"> <!-- #122 -->
|
||||
<div id="vuotoCal">Premere «Avvia anteprima» o «Scatta».</div>
|
||||
<tmf-viewer id="immagineCal" save-name="calibrata" title-save="Salva l'immagine calibrata" hidden></tmf-viewer>
|
||||
<tmf-viewer id="immagineCal" save-name="calibrata" title-save="Salva l'immagine calibrata" no-layers hidden></tmf-viewer> <!-- #126: no-layers = senza menu dei livelli -->
|
||||
<div id="mirinoCal" class="mirino" hidden></div>
|
||||
</div>
|
||||
<!-- #93: scelta della calibrazione da attivare sulla sorgente, con CONFERMA -->
|
||||
@@ -241,7 +241,7 @@ PAGINA = r"""<!doctype html>
|
||||
<!-- #94: immagine viva (stesso frame dell'anteprima) e «Blocca immagine» per calcolare su quella -->
|
||||
<div class="palco" style="margin-top:0"> <!-- #122 -->
|
||||
<div id="vuotoCalTab">Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione</div>
|
||||
<tmf-viewer id="immagineCalTab" save-name="calibrazione" title-save="Salva l'immagine della calibrazione" hidden></tmf-viewer>
|
||||
<tmf-viewer id="immagineCalTab" save-name="calibrazione" title-save="Salva l'immagine della calibrazione" no-layers hidden></tmf-viewer> <!-- #126: no-layers = senza menu dei livelli -->
|
||||
</div>
|
||||
<div class="riga" style="margin-top:8px">
|
||||
<button id="scattaCal" class="secondario" type="button">Scatta</button>
|
||||
@@ -268,7 +268,7 @@ PAGINA = r"""<!doctype html>
|
||||
<button id="salva">Salva</button>
|
||||
</div>
|
||||
<div id="msgSalva" class="messaggio"></div>
|
||||
<tmf-viewer id="overlay" class="angoli" fit-height save-name="angoli" title-save="Salva l'immagine degli angoli"></tmf-viewer> <!-- #122: era un <img> senza zoom -->
|
||||
<tmf-viewer id="overlay" class="angoli" fit-height no-layers save-name="angoli" title-save="Salva l'immagine degli angoli"></tmf-viewer> <!-- #122: era un <img> senza zoom; #126: no-layers -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* image_viewer.js - #122: <tmf-viewer>, il visualizzatore immagine standard di TieMeasureFlow.
|
||||
*
|
||||
* Cosa fa: Custom Element in JS puro (senza Alpine ne' Tailwind) con zoom -/+, Adatta, Salva, percentuale,
|
||||
* Ctrl+rotella intorno al puntatore, pan col puntatore, overlay SVG a tratto costante ed etichette.
|
||||
* rotella (e Ctrl+rotella) intorno al puntatore, pan col puntatore, overlay SVG a tratto costante ed etichette,
|
||||
* menu dei livelli in alto a sinistra (#12).
|
||||
* Sostituisce le quattro implementazioni di zoom di prima (pannello operatore #115, wizard DXF #113 e #104,
|
||||
* pagina camera #111). Light DOM, stile iniettato una volta nel <head> (<style id="tmf-viewer-css">).
|
||||
* Come si usa: <script> vision_overlay.js e save_image.js, poi image_viewer.js; poi
|
||||
@@ -12,6 +13,16 @@
|
||||
* Senza src, un figlio <svg> (DXF) va nello stage al 100 % (modo contenuto; Salva lo rasterizza in PNG).
|
||||
* Proprieta': overlay (forme vs-tools -> VisionOverlay.svg), labels ([{key,x,y,text,cls}] in px logici),
|
||||
* zoom (sola lettura); metodi fit(), zoomBy(f). Eventi (bubbling): viewer-load {width,height}, viewer-zoom {zoom,fit}.
|
||||
* #12 livelli: la rotella senza Ctrl zooma sul punto del mouse. L'overlay sta in tre gruppi SVG
|
||||
* (<g data-layer>): ricerca, costruzioni (opacity 0.5), quote; un menu a caselle (.tmfv-layers) accende
|
||||
* Immagine, Costruzioni, Ricerca (spenta di default), Quote, Nomi. #126 attributo no-layers = viewer semplice, come
|
||||
* prima dei livelli: niente menu, un solo disegno (VisionOverlay.svg di tutte le forme, poi le quote), nessun <g data-layer>
|
||||
* ne' opacity, forme e immagine sempre visibili, etichette sempre accese (text) e non trascinabili (niente quota-spostata);
|
||||
* zoom con rotella, pan, Adatta e Salva restano. Lo stato dei livelli non conta con no-layers. Proprieta'
|
||||
* layers get/set {immagine,costruzioni,ricerca,quote,nomi}, evento viewer-layers {layers}; stato in localStorage
|
||||
* (chiave tmf-viewer-layers[:save-name]). Etichette estese: {key,x,y,text,breve,cls,nome,camera,quota,testo};
|
||||
* con quota stanno sul punto della scritta (VisionOverlay.quotaPunto) e si trascinano: evento quota-spostata
|
||||
* {key,nome,camera,testo:[du,dv]|null} (doppio clic = testo null, posizione di default).
|
||||
* In Alpine: :src="...", x-effect="$el.overlay = ...; $el.labels = ...".
|
||||
* Dipende da: window.VisionOverlay (facoltativo, per l'overlay), tmfSaveImage/tmfSaveBlob/tmfSvgToPngBlob di save_image.js
|
||||
* (per Salva). Il modulo si carica anche in node (require): esporta solo TmfViewerMath, funzioni pure.
|
||||
@@ -21,6 +32,12 @@
|
||||
|
||||
var TAG = 'tmf-viewer';
|
||||
var STEP = 1.25; // #122: passo dei pulsanti +/- e della rotella (come #111 e #115)
|
||||
// #12: livelli del menu, titoli, valori di default e colore delle quote per esito (classi cls di stazione.js)
|
||||
var LAYER_NAMES = ['immagine', 'costruzioni', 'ricerca', 'quote', 'nomi'];
|
||||
var LAYER_TITLES = { immagine: 'Immagine', costruzioni: 'Costruzioni', ricerca: 'Ricerca', quote: 'Quote', nomi: 'Nomi' };
|
||||
var LAYER_DEFAULT = { immagine: true, costruzioni: true, ricerca: false, quote: true, nomi: true };
|
||||
var QUOTA_COLORS = { 'stz-ok': '#16a34a', 'stz-avviso': '#d97706', 'stz-ko': '#dc2626' };
|
||||
var QUOTA_COLOR_DEFAULT = '#ef4444';
|
||||
|
||||
// #122: matematica pura dello zoom, testata in node senza browser
|
||||
var TmfViewerMath = {
|
||||
@@ -66,6 +83,14 @@
|
||||
'padding:2px 8px;font:inherit;cursor:pointer;min-width:26px}',
|
||||
'tmf-viewer .tmfv-bar button:hover{background:rgba(255,255,255,.22)}',
|
||||
'tmf-viewer .tmfv-pct{min-width:46px;text-align:center;font-variant-numeric:tabular-nums}',
|
||||
// #12: etichette con key trascinabili; menu dei livelli in alto a sinistra, stile della barra
|
||||
'tmf-viewer .tmfv-label[data-key]{pointer-events:auto;cursor:move;touch-action:none}',
|
||||
// #126: con no-layers le etichette non si trascinano (stessa specificita' della regola sopra, viene dopo)
|
||||
'tmf-viewer[no-layers] .tmfv-label,tmf-viewer[no-layers] .tmfv-label[data-key]{pointer-events:none;cursor:default}',
|
||||
'tmf-viewer .tmfv-layers{position:absolute;top:6px;left:6px;z-index:3;display:flex;flex-wrap:wrap;align-items:center;gap:2px 10px;',
|
||||
'padding:3px 8px;max-width:calc(100% - 12px);background:rgba(0,0,0,.55);color:#fff;border-radius:6px;font:12px/1.4 system-ui,sans-serif}',
|
||||
'tmf-viewer .tmfv-layers label{display:inline-flex;align-items:center;gap:4px;cursor:pointer;white-space:nowrap}',
|
||||
'tmf-viewer .tmfv-layers input{margin:0;cursor:pointer}',
|
||||
].join('\n');
|
||||
|
||||
// #122: stile iniettato una volta sola nel <head>
|
||||
@@ -90,7 +115,7 @@
|
||||
|
||||
function defineViewer() {
|
||||
var OBSERVED = ['src', 'width', 'height', 'fit-height', 'no-save', 'save-name',
|
||||
'label-fit', 'label-save', 'title-in', 'title-out', 'title-save'];
|
||||
'label-fit', 'label-save', 'title-in', 'title-out', 'title-save', 'no-layers'];
|
||||
|
||||
class TmfViewer extends HTMLElement {
|
||||
static get observedAttributes() { return OBSERVED; }
|
||||
@@ -105,6 +130,8 @@
|
||||
this._pan = null;
|
||||
this._content = null; // modo contenuto: l'<svg> adottato
|
||||
this._lastEvt = '';
|
||||
this._lay = Object.assign({}, LAYER_DEFAULT); // #12: livelli visibili
|
||||
this._ldrag = null; // #12: trascinamento di un'etichetta in corso
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
@@ -113,7 +140,8 @@
|
||||
this._built = true;
|
||||
// proprieta' impostate prima dell'upgrade dell'elemento: le riporto sugli accessor
|
||||
var self = this;
|
||||
['overlay', 'labels'].forEach(function (k) {
|
||||
this._layersLoad(); // #12: stato salvato, prima di eventuali proprieta' impostate dalla pagina
|
||||
['overlay', 'labels', 'layers'].forEach(function (k) {
|
||||
if (Object.prototype.hasOwnProperty.call(self, k)) { var v = self[k]; delete self[k]; self[k] = v; }
|
||||
});
|
||||
var kids = Array.prototype.slice.call(this.children);
|
||||
@@ -123,6 +151,17 @@
|
||||
this._pctEl = document.createElement('span'); this._pctEl.className = 'tmfv-pct';
|
||||
bar.appendChild(this._bOut); bar.appendChild(this._bIn); bar.appendChild(this._bFit);
|
||||
bar.appendChild(this._bSave); bar.appendChild(this._pctEl);
|
||||
// #12: menu dei livelli (caselle) in alto a sinistra
|
||||
var lay = document.createElement('div'); lay.className = 'tmfv-layers'; lay.hidden = true;
|
||||
this._layBoxes = {};
|
||||
LAYER_NAMES.forEach(function (name) {
|
||||
var lb = document.createElement('label'), cb = document.createElement('input'), tx = document.createElement('span');
|
||||
cb.type = 'checkbox'; cb.setAttribute('data-layer', name); cb.checked = self._lay[name];
|
||||
tx.textContent = LAYER_TITLES[name];
|
||||
cb.addEventListener('change', function () { var o = {}; o[name] = cb.checked; self.layers = o; });
|
||||
lb.appendChild(cb); lb.appendChild(tx); lay.appendChild(lb);
|
||||
self._layBoxes[name] = cb;
|
||||
});
|
||||
var vp = document.createElement('div'); vp.className = 'tmfv-vp';
|
||||
var stage = document.createElement('div'); stage.className = 'tmfv-stage';
|
||||
var img = document.createElement('img'); img.className = 'tmfv-img'; img.draggable = false; img.alt = ''; img.hidden = true;
|
||||
@@ -131,8 +170,8 @@
|
||||
var labels = document.createElement('div'); labels.className = 'tmfv-labels';
|
||||
stage.appendChild(img); stage.appendChild(ov); stage.appendChild(labels);
|
||||
vp.appendChild(stage);
|
||||
this._bar = bar; this._vp = vp; this._stage = stage; this._img = img; this._ovEl = ov; this._lbEl = labels;
|
||||
this.appendChild(bar); this.appendChild(vp);
|
||||
this._layBar = lay; this._bar = bar; this._vp = vp; this._stage = stage; this._img = img; this._ovEl = ov; this._lbEl = labels;
|
||||
this.appendChild(bar); this.appendChild(lay); this.appendChild(vp);
|
||||
|
||||
this._bOut.onclick = function () { self.zoomBy(1 / STEP); };
|
||||
this._bIn.onclick = function () { self.zoomBy(STEP); };
|
||||
@@ -181,9 +220,13 @@
|
||||
if (name === 'src') this._loadSrc();
|
||||
else if (name === 'width' || name === 'height') this._refresh();
|
||||
else if (name === 'fit-height') { if (!this.hasAttribute('fit-height')) this._vp.style.height = ''; this._onResize(true); }
|
||||
else if (name === 'no-layers') { this._syncLayers(); this._drawOverlay(); this._drawLabels(); } // #12 #126: ridisegna al cambio
|
||||
else this._texts();
|
||||
}
|
||||
|
||||
// #126: viewer senza livelli (attributo no-layers): disegno semplice, etichette fisse
|
||||
_noLay() { return this.hasAttribute('no-layers'); }
|
||||
|
||||
_btn(txt) {
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button'; b.textContent = txt;
|
||||
@@ -203,7 +246,7 @@
|
||||
|
||||
// #122: adotta un figlio <svg> come contenuto (modo contenuto, DXF)
|
||||
_take(el) {
|
||||
if (!el || el === this._bar || el === this._vp || el.parentNode !== this) return;
|
||||
if (!el || el === this._bar || el === this._layBar || el === this._vp || el.parentNode !== this) return;
|
||||
if (String(el.tagName).toLowerCase() !== 'svg') return;
|
||||
if (this._content && this._content !== el && this._content.parentNode) this._content.parentNode.removeChild(this._content);
|
||||
this._content = el;
|
||||
@@ -213,7 +256,7 @@
|
||||
|
||||
_adopt() {
|
||||
var kids = Array.prototype.slice.call(this.children), self = this, found = false;
|
||||
kids.forEach(function (k) { if (k !== self._bar && k !== self._vp) { self._take(k); found = true; } });
|
||||
kids.forEach(function (k) { if (k !== self._bar && k !== self._layBar && k !== self._vp) { self._take(k); found = true; } });
|
||||
if (found) this._refresh();
|
||||
}
|
||||
|
||||
@@ -315,17 +358,30 @@
|
||||
}
|
||||
|
||||
// #122: zoom di un fattore f tenendo fermo il punto (px, py) del viewport (default: il centro)
|
||||
zoomBy(f, px, py) {
|
||||
zoomBy(f, px, py, ancora) { // `ancora` {ix, iy} (interna, #12): punto immagine da tenere sotto (px, py)
|
||||
if (!this._built || !this._w || !this._z) return;
|
||||
var vp = this._vp, z0 = this._z;
|
||||
var z1 = TmfViewerMath.clampZoom(z0 * f, this._fit);
|
||||
if (z1 === z0) return;
|
||||
if (px == null) px = vp.clientWidth / 2;
|
||||
if (py == null) py = vp.clientHeight / 2;
|
||||
var s = TmfViewerMath.zoomAround(z0, z1, vp.scrollLeft, vp.scrollTop, px, py);
|
||||
// #12: punto immagine sotto (px, py) misurato sullo stage reale, cosi' resta fermo anche quando lo stage e' centrato
|
||||
// dal margin:auto (immagine piu' piccola del viewport prima dello zoom); zoomAround copre il caso senza geometria
|
||||
var sr = this._stage.getBoundingClientRect(), vr = vp.getBoundingClientRect();
|
||||
var ix = (px - (sr.left - vr.left - vp.clientLeft)) / z0, iy = (py - (sr.top - vr.top - vp.clientTop)) / z0;
|
||||
// #12: con l'ancora di una sequenza di rotella si riporta sotto il mouse il punto di partenza, non quello corrente
|
||||
if (ancora && isFinite(ancora.ix) && isFinite(ancora.iy)) { ix = ancora.ix; iy = ancora.iy; }
|
||||
this._zpt = { ix: ix, iy: iy };
|
||||
this._z = z1; this._auto = false;
|
||||
this._apply(); // la dimensione dello stage cambia subito, poi si puo' scorrere
|
||||
var sr2 = this._stage.getBoundingClientRect(), vr2 = vp.getBoundingClientRect();
|
||||
if (sr.width > 0 && isFinite(ix) && isFinite(iy)) {
|
||||
vp.scrollLeft += (sr2.left - vr2.left - vp.clientLeft + ix * z1) - px;
|
||||
vp.scrollTop += (sr2.top - vr2.top - vp.clientTop + iy * z1) - py;
|
||||
} else {
|
||||
var s = TmfViewerMath.zoomAround(z0, z1, vp.scrollLeft, vp.scrollTop, px, py);
|
||||
vp.scrollLeft = s.scrollLeft; vp.scrollTop = s.scrollTop;
|
||||
}
|
||||
this._apply(true);
|
||||
}
|
||||
|
||||
@@ -341,6 +397,7 @@
|
||||
this._pctEl.textContent = TmfViewerMath.pct(!this._hasSrc() && this._content && this._fit ? z / this._fit : z);
|
||||
this._drawOverlay();
|
||||
this._drawLabels();
|
||||
this._syncLayers(); // #12
|
||||
this._cursor();
|
||||
if (quiet) return;
|
||||
var key = z + '|' + this._fit;
|
||||
@@ -361,17 +418,26 @@
|
||||
this._vp.style.touchAction = can ? 'none' : 'auto'; // il dito trascina solo se c'e' qualcosa da scorrere
|
||||
}
|
||||
|
||||
_wheel(ev) {
|
||||
if (!ev.ctrlKey) return;
|
||||
_wheel(ev) { // Ctrl+rotella (anche il pinch del trackpad) fa lo stesso
|
||||
// #12: la rotella zooma sempre sul punto del mouse (prima solo con Ctrl); senza immagine la pagina scorre
|
||||
if (!this._w) return;
|
||||
ev.preventDefault();
|
||||
var r = this._vp.getBoundingClientRect();
|
||||
this.zoomBy(ev.deltaY < 0 ? STEP : 1 / STEP, ev.clientX - r.left - this._vp.clientLeft, ev.clientY - r.top - this._vp.clientTop);
|
||||
var r = this._vp.getBoundingClientRect(), now = Date.now(), wa = this._wa;
|
||||
// #12: sequenza di scatti a mouse fermo (<= 2 px, < 1500 ms) = stessa ancora; altrimenti la nuova ancora si misura in zoomBy.
|
||||
// Cosi' lo scarto accumulato mentre lo stage e' piu' stretto del viewport (centrato, scroll impossibile) si recupera dopo.
|
||||
var riusa = wa && Math.abs(ev.clientX - wa.cx) <= 2 && Math.abs(ev.clientY - wa.cy) <= 2 && now - wa.t < 1500;
|
||||
this._zpt = null;
|
||||
this.zoomBy(ev.deltaY < 0 ? STEP : 1 / STEP, ev.clientX - r.left - this._vp.clientLeft, ev.clientY - r.top - this._vp.clientTop,
|
||||
riusa ? wa : null);
|
||||
var pt = this._zpt || (riusa ? wa : null);
|
||||
this._wa = pt ? { cx: ev.clientX, cy: ev.clientY, t: now, ix: pt.ix, iy: pt.iy } : null;
|
||||
}
|
||||
|
||||
_panDown(ev) {
|
||||
var vp = this._vp;
|
||||
if (!this._canPan() || (ev.pointerType === 'mouse' && ev.button)) return;
|
||||
ev.preventDefault(); // senza, Chromium manda pointercancel al primo movimento
|
||||
this._wa = null; // #12: il pan cambia il punto sotto il mouse: niente ancora della rotella
|
||||
this._pan = { x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop };
|
||||
this._cursor();
|
||||
try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
|
||||
@@ -386,36 +452,178 @@
|
||||
|
||||
_panEnd() { this._pan = null; this._cursor(); }
|
||||
|
||||
// #122: overlay SVG a tratto costante; ridisegnato a ogni zoom perche' le croci dei points restano a dimensione fissa
|
||||
// #122: overlay SVG a tratto costante; ridisegnato a ogni zoom perche' le croci dei points restano a dimensione fissa.
|
||||
// #12: tre gruppi <g data-layer> (ricerca, costruzioni a opacity 0.5, quote); un livello spento ha display none.
|
||||
_drawOverlay() {
|
||||
var ov = this._ovEl, VO = typeof window !== 'undefined' ? window.VisionOverlay : null;
|
||||
if (!this._w) { ov.innerHTML = ''; return; }
|
||||
ov.setAttribute('viewBox', '0 0 ' + this._w + ' ' + this._h);
|
||||
ov.innerHTML = VO && this._ov && this._ov.length ? VO.svg(this._ov, { zoom: this._z || 1 }) : '';
|
||||
if (!VO) { ov.innerHTML = ''; return; }
|
||||
var z = this._z || 1, lay = this._lay, noLay = this._noLay(); // #126
|
||||
var by = { ricerca: [], costruzioni: [], quote: [] };
|
||||
(this._ov || []).forEach(function (o) {
|
||||
var k = VO.layerOf ? VO.layerOf(o) : (o && o.role === 'found' ? 'costruzioni' : 'ricerca');
|
||||
by[k].push(o);
|
||||
});
|
||||
var quote = noLay ? '' : VO.svg(by.quote, { zoom: z }); // #126: con no-layers le forme vanno tutte nel disegno unico sotto
|
||||
(this._lb || []).forEach(function (l) {
|
||||
if (!l) return;
|
||||
var col = QUOTA_COLOR_DEFAULT;
|
||||
String(l.cls || '').split(/\s+/).forEach(function (c) { if (QUOTA_COLORS[c]) col = QUOTA_COLORS[c]; });
|
||||
if (l.quota && VO.quotaSvg) { quote += VO.quotaSvg(l.quota, { zoom: z, color: col }); return; }
|
||||
var t = l.testo;
|
||||
if (Array.isArray(t) && t.length === 2 && isFinite(t[0]) && isFinite(t[1]) && isFinite(l.x) && isFinite(l.y)) {
|
||||
// senza quota ma spostata: linea sottile dal punto originale alla scritta
|
||||
var r2 = function (v) { return String(Math.round(v * 100) / 100); };
|
||||
quote += '<line x1="' + r2(l.x) + '" y1="' + r2(l.y) + '" x2="' + r2(l.x + t[0]) + '" y2="' + r2(l.y + t[1]) +
|
||||
'" stroke="' + col + '" stroke-width="1" vector-effect="non-scaling-stroke"/>';
|
||||
}
|
||||
});
|
||||
// #126: no-layers = disegno di prima dei livelli: tutte le forme nell'ordine dato, poi le quote; niente <g>, niente opacity
|
||||
if (noLay) { ov.innerHTML = VO.svg(this._ov || [], { zoom: z }) + quote; return; }
|
||||
var g = function (name, body, extra) {
|
||||
return '<g data-layer="' + name + '"' + (extra || '') + (lay[name] ? '' : ' display="none"') + '>' + body + '</g>';
|
||||
};
|
||||
ov.innerHTML = g('ricerca', VO.svg(by.ricerca, { zoom: z })) +
|
||||
g('costruzioni', VO.svg(by.costruzioni, { zoom: z }), ' opacity="0.5"') + g('quote', quote);
|
||||
}
|
||||
|
||||
// #12: punto della scritta di un'etichetta in px logici: sulla quota, oppure (x,y) + testo [dx,dy], oppure (x,y)
|
||||
_labelPos(l) {
|
||||
var VO = typeof window !== 'undefined' ? window.VisionOverlay : null;
|
||||
if (l.quota && VO && VO.quotaPunto) { var q = VO.quotaPunto(l.quota); if (q) return { x: q.x, y: q.y, quota: true }; }
|
||||
var t = l.testo;
|
||||
if (Array.isArray(t) && t.length === 2 && isFinite(t[0]) && isFinite(t[1])) return { x: l.x + t[0], y: l.y + t[1], quota: false };
|
||||
return { x: l.x, y: l.y, quota: false };
|
||||
}
|
||||
|
||||
// #122: etichette in px logici -> % dello stage, cosi' seguono lo zoom senza ingrandirsi
|
||||
// #12: testo per «Nomi» (text) o solo valore (breve); il livello Quote nasconde tutte le etichette
|
||||
_drawLabels() {
|
||||
var box = this._lbEl;
|
||||
while (box.firstChild) box.removeChild(box.firstChild);
|
||||
var noLay = this._noLay(); // #126
|
||||
box.style.display = noLay || this._lay.quote ? '' : 'none'; // #126: con no-layers le etichette sono sempre visibili
|
||||
if (!this._w) return;
|
||||
var self = this;
|
||||
var self = this, nomi = noLay || this._lay.nomi; // #126: con no-layers testo = text
|
||||
(this._lb || []).forEach(function (l) {
|
||||
if (!l || !isFinite(l.x) || !isFinite(l.y)) return;
|
||||
var d = document.createElement('div');
|
||||
d.className = 'tmfv-label' + (l.cls ? ' ' + l.cls : '');
|
||||
var p = self._labelPos(l);
|
||||
if (l.key != null) d.setAttribute('data-key', l.key);
|
||||
d.style.left = (l.x / self._w * 100) + '%';
|
||||
d.style.top = (l.y / self._h * 100) + '%';
|
||||
d.textContent = l.text == null ? '' : String(l.text);
|
||||
d.style.left = (p.x / self._w * 100) + '%';
|
||||
d.style.top = (p.y / self._h * 100) + '%';
|
||||
if (p.quota) d.style.transform = 'translate(-50%,-50%)'; // la scritta di una quota e' centrata sul suo punto
|
||||
var txt = nomi ? l.text : (l.breve != null ? l.breve : l.text);
|
||||
d.textContent = txt == null ? '' : String(txt);
|
||||
if (l.key != null && !noLay) { // #126: con no-layers nessun trascinamento
|
||||
d.addEventListener('pointerdown', function (ev) { self._labelDown(ev, l); });
|
||||
d.addEventListener('dblclick', function (ev) { self._labelReset(ev, l); });
|
||||
}
|
||||
box.appendChild(d);
|
||||
});
|
||||
}
|
||||
|
||||
// #12: sposta la scritta dell'etichetta nel punto (x, y) in px logici (testo [du,dv] per le quote, [dx,dy] per le altre)
|
||||
_labelSet(l, x, y) {
|
||||
var VO = typeof window !== 'undefined' ? window.VisionOverlay : null;
|
||||
var t = null;
|
||||
if (l.quota && VO && VO.quotaTesto) t = VO.quotaTesto(l.quota, x, y);
|
||||
if (t) l.quota = Object.assign({}, l.quota, { testo: t });
|
||||
else t = [x - l.x, y - l.y];
|
||||
l.testo = t;
|
||||
}
|
||||
|
||||
_labelEmit(l, testo) {
|
||||
this.dispatchEvent(new CustomEvent('quota-spostata', { bubbles: true,
|
||||
detail: { key: l.key, nome: l.nome == null ? null : l.nome, camera: l.camera == null ? null : l.camera, testo: testo } }));
|
||||
}
|
||||
|
||||
// #12: trascinamento di un'etichetta: niente pan (la propagazione si ferma), movimento sul documento
|
||||
_labelDown(ev, l) {
|
||||
if (ev.pointerType === 'mouse' && ev.button) return;
|
||||
ev.stopPropagation(); ev.preventDefault();
|
||||
var self = this, z = this._z || 1, p0 = this._labelPos(l);
|
||||
var st = { cx: ev.clientX, cy: ev.clientY, moved: false };
|
||||
var move = function (e) {
|
||||
if (!st.moved && Math.abs(e.clientX - st.cx) + Math.abs(e.clientY - st.cy) < 3) return; // soglia: un clic non sposta
|
||||
st.moved = true;
|
||||
self._labelSet(l, p0.x + (e.clientX - st.cx) / z, p0.y + (e.clientY - st.cy) / z);
|
||||
self._drawOverlay(); self._drawLabels();
|
||||
};
|
||||
var up = function () {
|
||||
document.removeEventListener('pointermove', move);
|
||||
document.removeEventListener('pointerup', up);
|
||||
document.removeEventListener('pointercancel', up);
|
||||
self._ldrag = null;
|
||||
if (!st.moved) return;
|
||||
var t = [Math.round(l.testo[0] * 10) / 10, Math.round(l.testo[1] * 10) / 10]; // 0.1 px, come il contratto
|
||||
if (l.quota) l.quota = Object.assign({}, l.quota, { testo: t });
|
||||
l.testo = t;
|
||||
self._drawOverlay(); self._drawLabels();
|
||||
self._labelEmit(l, t);
|
||||
};
|
||||
this._ldrag = st;
|
||||
document.addEventListener('pointermove', move);
|
||||
document.addEventListener('pointerup', up);
|
||||
document.addEventListener('pointercancel', up);
|
||||
}
|
||||
|
||||
// #12: doppio clic = la scritta torna alla posizione di default (testo null)
|
||||
_labelReset(ev, l) {
|
||||
ev.stopPropagation(); ev.preventDefault();
|
||||
if (l.quota) l.quota = Object.assign({}, l.quota, { testo: null });
|
||||
l.testo = null;
|
||||
this._drawOverlay(); this._drawLabels();
|
||||
this._labelEmit(l, null);
|
||||
}
|
||||
|
||||
// #12: livelli. Stato in localStorage (try/catch: puo' essere bloccato), chiave per save-name
|
||||
_layKey() {
|
||||
var n = this.getAttribute('save-name');
|
||||
return 'tmf-viewer-layers' + (n ? ':' + n : '');
|
||||
}
|
||||
|
||||
_layersLoad() {
|
||||
try {
|
||||
var raw = window.localStorage.getItem(this._layKey()), o = raw ? JSON.parse(raw) : null, lay = this._lay;
|
||||
if (o && typeof o === 'object') LAYER_NAMES.forEach(function (k) { if (typeof o[k] === 'boolean') lay[k] = o[k]; });
|
||||
} catch (e) { /* storage non disponibile: restano i default */ }
|
||||
}
|
||||
|
||||
_layersSave() {
|
||||
try { window.localStorage.setItem(this._layKey(), JSON.stringify(this._lay)); } catch (e) { /* storage non disponibile */ }
|
||||
}
|
||||
|
||||
// #12: menu visibile con immagine o contenuto e almeno una forma o un'etichetta; immagine nascosta ma stage invariato
|
||||
_syncLayers() {
|
||||
if (!this._built || !this._layBar) return;
|
||||
var has = (this._ov && this._ov.length) || (this._lb && this._lb.length);
|
||||
this._layBar.hidden = this.hasAttribute('no-layers') || !this._w || !has;
|
||||
this._img.style.visibility = this._noLay() || this._lay.immagine ? '' : 'hidden'; // #126: con no-layers l'immagine resta visibile
|
||||
var self = this;
|
||||
LAYER_NAMES.forEach(function (k) { self._layBoxes[k].checked = !!self._lay[k]; });
|
||||
}
|
||||
|
||||
get layers() { return Object.assign({}, this._lay); }
|
||||
set layers(v) {
|
||||
var changed = false, lay = this._lay;
|
||||
if (v && typeof v === 'object') {
|
||||
LAYER_NAMES.forEach(function (k) {
|
||||
if (k in v && !!v[k] !== lay[k]) { lay[k] = !!v[k]; changed = true; }
|
||||
});
|
||||
}
|
||||
if (!changed || !this._layBar) return; // prima della costruzione del DOM resta solo lo stato
|
||||
this._syncLayers(); this._drawOverlay(); this._drawLabels();
|
||||
this._layersSave();
|
||||
this.dispatchEvent(new CustomEvent('viewer-layers', { bubbles: true, detail: { layers: this.layers } }));
|
||||
}
|
||||
|
||||
get overlay() { return this._ov; }
|
||||
set overlay(v) { this._ov = Array.isArray(v) ? v : []; if (this._built) this._drawOverlay(); }
|
||||
set overlay(v) { this._ov = Array.isArray(v) ? v : []; if (this._built) { this._drawOverlay(); this._syncLayers(); } }
|
||||
get labels() { return this._lb; }
|
||||
set labels(v) { this._lb = Array.isArray(v) ? v : []; if (this._built) this._drawLabels(); }
|
||||
set labels(v) { this._lb = Array.isArray(v) ? v : []; if (this._built) { this._drawOverlay(); this._drawLabels(); this._syncLayers(); } } // #12: le quote stanno nell'overlay
|
||||
|
||||
// #122: Salva = immagine grezza (src o save-src) o, in modo contenuto, l'SVG rasterizzato
|
||||
_save() {
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
*
|
||||
* API: draw(g, overlay, lw) -> {drawn, skipped}; anchor(item) -> {x, y} | null;
|
||||
* labels(rows, overlay) -> [{key, x, y, text, status}];
|
||||
* svg(overlay, opts) -> stringa di figli SVG (#121, vedi sotto).
|
||||
* svg(overlay, opts) -> stringa di figli SVG (#121, vedi sotto);
|
||||
* layerOf(o), quotaPunto(q), quotaTesto(q, x, y), quotaSvg(q, opts): livelli e quote CAD (#12).
|
||||
* Convenzioni (vs-tools): row = y, col = x; angoli in gradi positivi antiorari a video.
|
||||
* Porting da TieVision-Studio frontend/src/comune/overlay.js (verticiRect2, forma)
|
||||
* e frontend/src/editor/misure.js (ancoraOverlay).
|
||||
@@ -264,7 +265,159 @@ var VisionOverlay = (function () {
|
||||
return out;
|
||||
}
|
||||
|
||||
return { draw: draw, anchor: anchor, labels: labels, svg: svg };
|
||||
// ---- #12: livelli e quote stile CAD ------------------------------------------------------------
|
||||
// Coordinate in px immagine; angoli in gradi antiorari a video, versore u(a) = (cos a, -sin a),
|
||||
// normale n = (-u.y, u.x). Contratto: gitea:Adriano/TieVision-Station#12.
|
||||
|
||||
// #12: colori ammessi per le quote (esiti del visore + rosso di default); mai una stringa dei dati nel markup
|
||||
var QUOTA_COLORS = ['#16a34a', '#d97706', '#dc2626', '#ef4444'];
|
||||
var QUOTA_ARROW = 9, QUOTA_GAP = 3, QUOTA_OVER = 6, QUOTA_LW = 1.5; // px a video
|
||||
|
||||
// #12: livello di una forma: campo "layer" se valido, altrimenti found = costruzioni, il resto = ricerca
|
||||
function layerOf(o) {
|
||||
var l = o && o.layer;
|
||||
if (l === 'costruzioni' || l === 'ricerca' || l === 'quote') return l;
|
||||
return o && o.role === 'found' ? 'costruzioni' : 'ricerca';
|
||||
}
|
||||
|
||||
function pt2(p) { return Array.isArray(p) && p.length === 2 && num(p[0]) && num(p[1]); }
|
||||
function uVec(deg) { var a = rad(deg); return [Math.cos(a), -Math.sin(a)]; }
|
||||
|
||||
// #12: riferimento locale della quota {b, u, n, td, ...} o null se malformata
|
||||
function quotaFrame(q) {
|
||||
if (!q || typeof q !== 'object') return null;
|
||||
var f = { tipo: q.tipo };
|
||||
switch (q.tipo) {
|
||||
case 'lineare': {
|
||||
if (!pt2(q.p1) || !pt2(q.p2)) return null;
|
||||
var dx = q.p2[0] - q.p1[0], dy = q.p2[1] - q.p1[1], L = Math.sqrt(dx * dx + dy * dy);
|
||||
f.u = L > 1e-12 ? [dx / L, dy / L] : [1, 0];
|
||||
f.b = [(q.p1[0] + q.p2[0]) / 2, (q.p1[1] + q.p2[1]) / 2];
|
||||
f.L = L; f.dflt = [0, 0];
|
||||
break;
|
||||
}
|
||||
case 'raggio':
|
||||
if (!pt2(q.centro) || !num(q.r) || !num(q.dir_deg)) return null;
|
||||
f.u = uVec(q.dir_deg); f.b = [q.centro[0], q.centro[1]]; f.dflt = [2 * q.r, 0];
|
||||
break;
|
||||
case 'angolo':
|
||||
if (!pt2(q.centro) || !num(q.r) || !num(q.start_deg) || !num(q.extent_deg)) return null;
|
||||
f.u = uVec(q.start_deg + q.extent_deg / 2); f.b = [q.centro[0], q.centro[1]]; f.dflt = [1.5 * q.r, 0];
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
f.n = [-f.u[1], f.u[0]];
|
||||
f.td = pt2(q.testo) ? [q.testo[0], q.testo[1]] : f.dflt; // testo null o malformato = posizione di default
|
||||
return f;
|
||||
}
|
||||
|
||||
// #12: punto T della scritta (px immagine) = B + du*u + dv*n; null se la quota e' malformata
|
||||
function quotaPunto(q) {
|
||||
var f = quotaFrame(q);
|
||||
if (!f) return null;
|
||||
return { x: f.b[0] + f.td[0] * f.u[0] + f.td[1] * f.n[0], y: f.b[1] + f.td[0] * f.u[1] + f.td[1] * f.n[1] };
|
||||
}
|
||||
|
||||
// #12: inverso di quotaPunto: [du, dv] che porta la scritta nel punto (x, y); serve al trascinamento
|
||||
function quotaTesto(q, x, y) {
|
||||
var f = quotaFrame(q);
|
||||
if (!f || !num(x) || !num(y)) return null;
|
||||
var rx = x - f.b[0], ry = y - f.b[1];
|
||||
return [rx * f.u[0] + ry * f.u[1], rx * f.n[0] + ry * f.n[1]];
|
||||
}
|
||||
|
||||
function qLine(x1, y1, x2, y2, color) {
|
||||
return '<line x1="' + n2(x1) + '" y1="' + n2(y1) + '" x2="' + n2(x2) + '" y2="' + n2(y2) + '" stroke="' + color +
|
||||
'" stroke-width="' + QUOTA_LW + '" vector-effect="non-scaling-stroke"/>';
|
||||
}
|
||||
// #12: freccia piena con la punta in (x, y) rivolta verso (dx, dy) (versore); lunghezza fissa a video
|
||||
function qArrow(x, y, dx, dy, zoom, color) {
|
||||
var len = QUOTA_ARROW / zoom, w = len / 3;
|
||||
var bx = x - dx * len, by = y - dy * len;
|
||||
return '<polygon points="' + n2(x) + ',' + n2(y) + ' ' + n2(bx - dy * w) + ',' + n2(by + dx * w) + ' ' +
|
||||
n2(bx + dy * w) + ',' + n2(by - dx * w) + '" fill="' + color + '" stroke="none"/>';
|
||||
}
|
||||
|
||||
function quotaSvgLineare(q, f, zoom, color) {
|
||||
var out = [], u = f.u, n = f.n, dv = f.td[1], du = f.td[0], i;
|
||||
var sg = dv >= 0 ? 1 : -1, gap = QUOTA_GAP / zoom, over = QUOTA_OVER / zoom;
|
||||
var ps = [q.p1, q.p2];
|
||||
if (Math.abs(dv) * zoom >= 3) { // linee di estensione: assenti se la linea di misura sta sul pezzo
|
||||
for (i = 0; i < 2; i++) {
|
||||
out.push(qLine(ps[i][0] + sg * gap * n[0], ps[i][1] + sg * gap * n[1],
|
||||
ps[i][0] + (dv + sg * over) * n[0], ps[i][1] + (dv + sg * over) * n[1], color));
|
||||
}
|
||||
}
|
||||
var m0 = [f.b[0] + dv * n[0], f.b[1] + dv * n[1]]; // punto medio della linea di misura
|
||||
var h = f.L / 2, inside = f.L * zoom >= 2.5 * QUOTA_ARROW;
|
||||
var lo = Math.min(-h, du), hi = Math.max(h, du); // la linea arriva fino alla scritta se sta oltre A o B
|
||||
if (!inside) { lo = Math.min(lo, -h - 12 / zoom); hi = Math.max(hi, h + 12 / zoom); }
|
||||
out.push(qLine(m0[0] + lo * u[0], m0[1] + lo * u[1], m0[0] + hi * u[0], m0[1] + hi * u[1], color));
|
||||
var k = inside ? 1 : -1; // frecce corte: fuori, rivolte verso l'interno
|
||||
out.push(qArrow(m0[0] - h * u[0], m0[1] - h * u[1], -k * u[0], -k * u[1], zoom, color));
|
||||
out.push(qArrow(m0[0] + h * u[0], m0[1] + h * u[1], k * u[0], k * u[1], zoom, color));
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
function quotaSvgRaggio(q, f, zoom, color) {
|
||||
var out = [], c = q.centro, T = quotaPunto(q);
|
||||
var vx = T.x - c[0], vy = T.y - c[1], d = Math.sqrt(vx * vx + vy * vy);
|
||||
var w = d > 1e-9 ? [vx / d, vy / d] : uVec(q.dir_deg);
|
||||
var px = c[0] + q.r * w[0], py = c[1] + q.r * w[1], cr = 4 / zoom;
|
||||
out.push(qLine(c[0] - cr, c[1], c[0] + cr, c[1], color)); // crocetta sul centro
|
||||
out.push(qLine(c[0], c[1] - cr, c[0], c[1] + cr, color));
|
||||
if (d > q.r) { // scritta fuori dal cerchio: da P a T, freccia in P verso il centro
|
||||
out.push(qLine(px, py, T.x, T.y, color));
|
||||
out.push(qArrow(px, py, -w[0], -w[1], zoom, color));
|
||||
} else { // scritta dentro: dal centro a P, freccia in P verso fuori
|
||||
out.push(qLine(c[0], c[1], px, py, color));
|
||||
out.push(qArrow(px, py, w[0], w[1], zoom, color));
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
function quotaSvgAngolo(q, f, zoom, color) {
|
||||
var out = [], c = q.centro, T = quotaPunto(q), i;
|
||||
var rho = Math.sqrt((T.x - c[0]) * (T.x - c[0]) + (T.y - c[1]) * (T.y - c[1]));
|
||||
if (!(rho > 1e-9)) rho = q.r > 0 ? 1.5 * q.r : 1;
|
||||
var gap = QUOTA_GAP / zoom, over = QUOTA_OVER / zoom;
|
||||
var ends = [q.start_deg, q.start_deg + q.extent_deg];
|
||||
var sgn = q.extent_deg >= 0 ? 1 : -1;
|
||||
var inside = rho * rad(Math.abs(q.extent_deg)) * zoom >= 2.5 * QUOTA_ARROW;
|
||||
for (i = 0; i < 2; i++) {
|
||||
var u = uVec(ends[i]);
|
||||
var a = rho >= q.r ? q.r + gap : rho - over, b = rho >= q.r ? rho + over : q.r - gap;
|
||||
out.push(qLine(c[0] + a * u[0], c[1] + a * u[1], c[0] + b * u[0], c[1] + b * u[1], color)); // estensione radiale
|
||||
}
|
||||
out.push('<path d="' + arcPath({ col: c[0], row: c[1], radius: rho, start_deg: q.start_deg, extent_deg: q.extent_deg }) +
|
||||
'" fill="none" stroke="' + color + '" stroke-width="' + QUOTA_LW + '" vector-effect="non-scaling-stroke"/>');
|
||||
for (i = 0; i < 2; i++) {
|
||||
var ang = rad(ends[i]), uu = uVec(ends[i]);
|
||||
var tg = [-Math.sin(ang), -Math.cos(ang)]; // tangente nel verso degli angoli crescenti
|
||||
var dir = (i === 0 ? -1 : 1) * sgn * (inside ? 1 : -1); // dentro: verso fuori dall'arco; corte: verso l'interno
|
||||
out.push(qArrow(c[0] + rho * uu[0], c[1] + rho * uu[1], dir * tg[0], dir * tg[1], zoom, color));
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
// #12: quota -> figli SVG (px immagine). opts = {zoom, color}; misure fisse a video divise per lo zoom.
|
||||
// Solo numeri e colori da lista fissa nel markup.
|
||||
function quotaSvg(q, opts) {
|
||||
opts = opts || {};
|
||||
var zoom = pos(opts.zoom, 1);
|
||||
var color = QUOTA_COLORS.indexOf(opts.color) >= 0 ? opts.color : COLOR_OTHER;
|
||||
try {
|
||||
var f = quotaFrame(q);
|
||||
if (!f) return '';
|
||||
if (f.tipo === 'lineare') return quotaSvgLineare(q, f, zoom, color);
|
||||
if (f.tipo === 'raggio') return quotaSvgRaggio(q, f, zoom, color);
|
||||
return quotaSvgAngolo(q, f, zoom, color);
|
||||
} catch (e) { return ''; } // una quota rotta non ferma le altre
|
||||
}
|
||||
|
||||
return { draw: draw, anchor: anchor, labels: labels, svg: svg,
|
||||
layerOf: layerOf, quotaPunto: quotaPunto, quotaTesto: quotaTesto, quotaSvg: quotaSvg };
|
||||
})();
|
||||
|
||||
if (typeof window !== 'undefined') window.VisionOverlay = VisionOverlay;
|
||||
|
||||
@@ -733,7 +733,8 @@
|
||||
{# #122: <tmf-viewer> standard (zoom, Adatta, Salva, pan) al posto della barra e del viewport scritti a mano (#113); altezza fissa 240px #}
|
||||
<div x-show="!imageBroken">{# #122: x-show sul contenitore, mai sul <tmf-viewer> #}
|
||||
{# #122: formato non mostrabile -> viewer-error del componente -> avviso imageBroken (al posto della sonda <img hidden>) #}
|
||||
<tmf-viewer :src="imagePreviewUrl" save-name="immagine"
|
||||
{# #126: no-layers = viewer semplice, senza menu dei livelli e senza trascinamento delle etichette #}
|
||||
<tmf-viewer :src="imagePreviewUrl" save-name="immagine" no-layers
|
||||
label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
|
||||
title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
|
||||
@viewer-load="onImagePreviewLoad($event.detail)" @viewer-error="onImagePreviewError()"
|
||||
@@ -745,7 +746,8 @@
|
||||
</div>
|
||||
<div x-show="dxfPreview" class="sm:col-start-2">{# #104: resta a destra anche senza anteprima immagine #}
|
||||
{# #122: <tmf-viewer> in modo contenuto: lo <svg> (viewBox in mm) va nello stage; width/height = misura del viewBox, ora anche Salva (PNG) #}
|
||||
<tmf-viewer save-name="dxf"
|
||||
{# #126: no-layers = viewer semplice, senza menu dei livelli e senza trascinamento delle etichette #}
|
||||
<tmf-viewer save-name="dxf" no-layers
|
||||
:width="dxfPreviewViewBox().split(' ')[2]" :height="dxfPreviewViewBox().split(' ')[3]"
|
||||
label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
|
||||
title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
|
||||
@@ -790,7 +792,8 @@
|
||||
<div x-show="tested" class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">{# #104: risultati a destra gia' da 640px, come le altre griglie del wizard #}
|
||||
<div>
|
||||
{# #122: <tmf-viewer fit-height> al posto di barra, viewport, pan e overlay della Prova (#104 #121); overlay dalle forme del risultato #}
|
||||
<tmf-viewer fit-height :src="imageSrc" save-name="prova"
|
||||
{# #126: no-layers = viewer semplice, senza menu dei livelli e senza trascinamento delle etichette #}
|
||||
<tmf-viewer fit-height :src="imageSrc" save-name="prova" no-layers
|
||||
x-effect="$el.overlay = (result && result.result && result.result.overlay) || []"
|
||||
label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
|
||||
title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
|
||||
|
||||
@@ -581,7 +581,8 @@
|
||||
|
||||
{# #96: riquadro flex-1 min-h-0; #122: dentro, <tmf-viewer> (zoom, pan, Salva, overlay SVG a tratto costante ed etichette quota) riempie il riquadro; overlay e labels arrivano da x-effect (px dell'immagine, #121 #116); x-show sul contenitore, mai sul viewer #}
|
||||
<div x-show="shownImage" class="relative flex-1 min-h-0 border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
|
||||
<tmf-viewer class="block w-full h-full" :src="shownImage" :save-name="saveName"
|
||||
{# #126: no-layers = viewer semplice, senza menu dei livelli e senza trascinamento delle etichette #}
|
||||
<tmf-viewer class="block w-full h-full" no-layers :src="shownImage" :save-name="saveName"
|
||||
label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
|
||||
title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
|
||||
x-effect="$el.overlay = (result && result.overlay) || []; $el.labels = viewerLabels"></tmf-viewer>
|
||||
|
||||
@@ -25,8 +25,17 @@
|
||||
<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>
|
||||
<!-- #126: no-layers (overlay misto, etichette con key) e, per confronto, lo stesso senza no-layers -->
|
||||
<div><h2>#126 no-layers: overlay misto, etichette fisse</h2>
|
||||
<tmf-viewer id="f" class="box" no-layers width="400" height="250" save-name="demo_nolayers"></tmf-viewer></div>
|
||||
<div><h2>#126 senza no-layers (confronto): livelli e trascinamento</h2>
|
||||
<tmf-viewer id="g" class="box" width="400" height="250" save-name="demo_conlayers"></tmf-viewer></div>
|
||||
</div>
|
||||
<div id="log">in corso...</div>
|
||||
<script>
|
||||
// #126: stato dei livelli salvato prima del caricamento del visore (ricerca spenta): con no-layers non deve contare
|
||||
try { localStorage.setItem('tmf-viewer-layers', JSON.stringify({ ricerca: false })); } catch (e) { /* storage bloccato */ }
|
||||
</script>
|
||||
<script src="../../../static/js/vision_overlay.js"></script>
|
||||
<script src="../../../static/js/save_image.js"></script>
|
||||
<script src="../../../static/js/image_viewer.js"></script>
|
||||
@@ -35,6 +44,7 @@ window.onerror = function (m, u, l) { document.getElementById('log').textContent
|
||||
(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
|
||||
window.tmfCheck = check; // #126: il blocco no-layers in fondo alla pagina scrive nello stesso #log
|
||||
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);
|
||||
@@ -75,7 +85,7 @@ window.onerror = function (m, u, l) { document.getElementById('log').textContent
|
||||
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);
|
||||
check('a: rotella senza Ctrl zooma sul punto del mouse (intercettata, #12 #126)', we.defaultPrevented === true); // #126: controllo aggiornato al contratto #12
|
||||
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);
|
||||
@@ -108,7 +118,7 @@ window.onerror = function (m, u, l) { document.getElementById('log').textContent
|
||||
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);
|
||||
check('e: overlay ed etichette impostati prima del load disegnati dopo', e.querySelectorAll('.tmfv-ov g[data-layer="costruzioni"] > *').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');
|
||||
@@ -131,5 +141,64 @@ window.onerror = function (m, u, l) { document.getElementById('log').textContent
|
||||
}, 600);
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// #126: controlli del viewer no-layers (f) contro lo stesso overlay senza no-layers (g)
|
||||
(function () {
|
||||
var check = window.tmfCheck;
|
||||
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);
|
||||
return c.toDataURL('image/png');
|
||||
}
|
||||
var f = document.getElementById('f'), g = document.getElementById('g');
|
||||
function dati() { // overlay misto: found (costruzioni), non found (ricerca), layer 'ricerca' esplicito; due etichette con key
|
||||
return {
|
||||
ov: [{ type: 'segment', role: 'found', row1: 20, col1: 20, row2: 220, col2: 380 },
|
||||
{ type: 'segment', role: 'reference', row1: 20, col1: 380, row2: 220, col2: 20 },
|
||||
{ type: 'points', role: 'reference', layer: 'ricerca', rows: [60, 120], cols: [100, 300] }],
|
||||
lb: [{ key: 'q1', nome: 'Larghezza', camera: 1, x: 200, y: 120, text: 'Larghezza 12.345', breve: '12.345', cls: 'stz-ok',
|
||||
quota: { tipo: 'lineare', p1: [60, 200], p2: [340, 200], testo: [0, 20] } },
|
||||
{ key: 'q2', nome: 'Punto', camera: 1, x: 100, y: 60, text: 'Punto', breve: 'P' }]
|
||||
};
|
||||
}
|
||||
var df = dati(), dg = dati(), movf = 0, movg = 0;
|
||||
f.addEventListener('quota-spostata', function () { movf++; });
|
||||
g.addEventListener('quota-spostata', function () { movg++; });
|
||||
f.overlay = df.ov; f.labels = df.lb; g.overlay = dg.ov; g.labels = dg.lb;
|
||||
f.setAttribute('src', img(400, 250, '#1e293b')); g.setAttribute('src', img(400, 250, '#1e293b'));
|
||||
function drag(el) { // trascinamento simulato di un'etichetta: down sull'etichetta, move e up sul documento
|
||||
var r = el.getBoundingClientRect(), x = r.left + r.width / 2, y = r.top + r.height / 2;
|
||||
var o = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', button: 0, buttons: 1 };
|
||||
el.dispatchEvent(new PointerEvent('pointerdown', Object.assign({ clientX: x, clientY: y }, o)));
|
||||
document.dispatchEvent(new PointerEvent('pointermove', Object.assign({ clientX: x + 30, clientY: y + 20 }, o)));
|
||||
document.dispatchEvent(new PointerEvent('pointerup', Object.assign({ clientX: x + 30, clientY: y + 20 }, o)));
|
||||
}
|
||||
setTimeout(function () {
|
||||
var ovf = f.querySelector('.tmfv-ov'), ovg = g.querySelector('.tmfv-ov');
|
||||
check('f no-layers: localStorage ricerca=false, ma nessun g[data-layer], nessun opacity, nessun display none', ovf.querySelectorAll('g[data-layer]').length === 0 && ovf.querySelectorAll('[opacity]').length === 0 && ovf.innerHTML.indexOf('display="none"') < 0, ovf.innerHTML.slice(0, 120));
|
||||
check('f no-layers: forme found, non found e layer ricerca tutte nel disegno (>= 5 elementi)', ovf.children.length >= 5, ovf.children.length);
|
||||
check('f no-layers: menu dei livelli nascosto', f.querySelector('.tmfv-layers').hidden === true);
|
||||
check('f no-layers: immagine visibile, etichette visibili con text', f.querySelector('.tmfv-img').style.visibility === '' && f.querySelector('.tmfv-labels').style.display !== 'none' &&
|
||||
f.querySelectorAll('.tmfv-label')[0].textContent === 'Larghezza 12.345' && f.querySelectorAll('.tmfv-label')[1].textContent === 'Punto');
|
||||
check('g senza no-layers (confronto): tre gruppi g[data-layer] e menu visibile', ovg.querySelectorAll('g[data-layer]').length === 3 && g.querySelector('.tmfv-layers').hidden === false);
|
||||
var lf = f.querySelector('.tmfv-label[data-key="q1"]');
|
||||
var pf = lf.style.left + '|' + lf.style.top;
|
||||
check('f no-layers: etichetta con pointer-events none e cursore default', getComputedStyle(lf).pointerEvents === 'none' && getComputedStyle(lf).cursor === 'default', getComputedStyle(lf).pointerEvents);
|
||||
drag(lf); drag(g.querySelector('.tmfv-label[data-key="q1"]'));
|
||||
var lf2 = f.querySelector('.tmfv-label[data-key="q1"]');
|
||||
check('f no-layers: trascinamento simulato -> nessun quota-spostata, posizione invariata', movf === 0 && lf2.style.left + '|' + lf2.style.top === pf, movf + ' ' + lf2.style.left);
|
||||
check('g senza no-layers (controllo del test): lo stesso trascinamento emette quota-spostata', movg === 1, movg);
|
||||
var z0 = f.zoom, r = f.querySelector('.tmfv-vp').getBoundingClientRect();
|
||||
var we = new WheelEvent('wheel', { deltaY: -100, clientX: r.left + 100, clientY: r.top + 100, bubbles: true, cancelable: true });
|
||||
f.querySelector('.tmfv-vp').dispatchEvent(we);
|
||||
check('f no-layers: rotella senza Ctrl -> zoom cambiato', f.zoom > z0 && we.defaultPrevented === true, z0 + ' -> ' + f.zoom);
|
||||
f.removeAttribute('no-layers');
|
||||
check('f: no-layers tolto a runtime -> gruppi g[data-layer], menu e trascinamento', ovf.querySelectorAll('g[data-layer]').length === 3 && f.querySelector('.tmfv-layers').hidden === false &&
|
||||
getComputedStyle(f.querySelector('.tmfv-label[data-key="q1"]')).pointerEvents === 'auto');
|
||||
f.setAttribute('no-layers', '');
|
||||
check('f: no-layers rimesso a runtime -> di nuovo disegno unico, menu nascosto', ovf.querySelectorAll('g[data-layer]').length === 0 && f.querySelector('.tmfv-layers').hidden === true && ovf.children.length >= 5);
|
||||
}, 1000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -361,5 +361,93 @@ const SHAPE_RE = /<(polygon|line|circle|path|polyline) /g;
|
||||
check('svg: coordinata stringa -> forma saltata', strNum === '');
|
||||
}
|
||||
|
||||
// --- #12: livelli e quote CAD ---
|
||||
{
|
||||
const VO = VisionOverlay;
|
||||
check('layerOf: layer esplicito vince sul ruolo',
|
||||
VO.layerOf({ role: 'found', layer: 'quote' }) === 'quote' && VO.layerOf({ role: 'roi', layer: 'costruzioni' }) === 'costruzioni');
|
||||
check('layerOf: senza campo found = costruzioni, altro = ricerca',
|
||||
VO.layerOf({ role: 'found' }) === 'costruzioni' && VO.layerOf({ role: 'roi' }) === 'ricerca' &&
|
||||
VO.layerOf({ role: 'reference' }) === 'ricerca' && VO.layerOf({}) === 'ricerca' && VO.layerOf(null) === 'ricerca');
|
||||
check('layerOf: layer ignoto -> secondo il ruolo', VO.layerOf({ role: 'found', layer: 'boh' }) === 'costruzioni');
|
||||
|
||||
const lin = { tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: null };
|
||||
const rag = { tipo: 'raggio', centro: [50, 50], r: 10, dir_deg: 90, testo: null };
|
||||
const ang = { tipo: 'angolo', centro: [50, 50], r: 20, start_deg: 0, extent_deg: 90, testo: null };
|
||||
// default
|
||||
let p = VO.quotaPunto(lin);
|
||||
check('quotaPunto lineare default = punto medio', near(p.x, 50) && near(p.y, 0), JSON.stringify(p));
|
||||
p = VO.quotaPunto(rag);
|
||||
check('quotaPunto raggio default = centro + 2r lungo dir (90 gradi = verso l\'alto a video)', near(p.x, 50) && near(p.y, 30), JSON.stringify(p));
|
||||
p = VO.quotaPunto(ang);
|
||||
{ // u a 45 gradi = (cos45, -sin45); T = centro + 1.5r u
|
||||
const e = 30 * Math.SQRT1_2;
|
||||
check('quotaPunto angolo default = centro + 1.5r a meta arco', near(p.x, 50 + e) && near(p.y, 50 - e), JSON.stringify(p));
|
||||
}
|
||||
// con testo: lineare p1->p2 lungo +x, n = (0, 1): dv positivo = verso il basso a video
|
||||
p = VO.quotaPunto({ tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: [10, 20] });
|
||||
check('quotaPunto lineare con testo [du,dv]', near(p.x, 60) && near(p.y, 20), JSON.stringify(p));
|
||||
// inverse
|
||||
[lin, rag, ang].forEach((q) => {
|
||||
[[3, -7], [-12.5, 40], [0, 0]].forEach((t) => {
|
||||
const q2 = Object.assign({}, q, { testo: t });
|
||||
const pt = VO.quotaPunto(q2);
|
||||
const back = VO.quotaTesto(q2, pt.x, pt.y);
|
||||
check('quotaTesto inverso di quotaPunto (' + q.tipo + ' ' + t + ')', near(back[0], t[0]) && near(back[1], t[1]), JSON.stringify(back));
|
||||
});
|
||||
// dal punto di default si torna a testo di default
|
||||
const d = VO.quotaPunto(q), back = VO.quotaTesto(q, d.x, d.y), p2 = VO.quotaPunto(Object.assign({}, q, { testo: back }));
|
||||
check('quotaTesto dal punto di default riproduce il default (' + q.tipo + ')', near(p2.x, d.x) && near(p2.y, d.y));
|
||||
});
|
||||
check('quota malformata: quotaPunto/quotaTesto null, quotaSvg stringa vuota',
|
||||
VO.quotaPunto(null) === null && VO.quotaPunto({ tipo: 'boh' }) === null &&
|
||||
VO.quotaPunto({ tipo: 'lineare', p1: [0, 'a'], p2: [1, 1] }) === null &&
|
||||
VO.quotaTesto({ tipo: 'raggio' }, 1, 1) === null &&
|
||||
VO.quotaSvg({ tipo: 'angolo', centro: [0, 0] }) === '' && VO.quotaSvg(undefined) === '');
|
||||
check('quotaPunto: testo malformato = default', near(VO.quotaPunto({ tipo: 'lineare', p1: [0, 0], p2: [10, 0], testo: ['x', 1] }).x, 5));
|
||||
|
||||
// quotaSvg lineare: linee di estensione solo con dv != 0
|
||||
const conDv = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [200, 0], testo: [0, 30] }, { zoom: 1, color: '#16a34a' });
|
||||
const senzaDv = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [200, 0], testo: [0, 0] }, { zoom: 1, color: '#16a34a' });
|
||||
check('quotaSvg lineare dv=30: 2 estensioni + 1 linea di misura, 2 frecce', count(conDv, /<line /g) === 3 && count(conDv, /<polygon /g) === 2, conDv);
|
||||
check('quotaSvg lineare dv=0: nessuna estensione (solo linea di misura), 2 frecce', count(senzaDv, /<line /g) === 1 && count(senzaDv, /<polygon /g) === 2, senzaDv);
|
||||
check('quotaSvg: tratto non-scaling 1.5 e colore passato', /stroke-width="1.5" vector-effect="non-scaling-stroke"/.test(conDv) && conDv.includes('#16a34a'));
|
||||
// frecce dentro/fuori: lineare lunga 200 a zoom 1 -> la punta sta in A (0, 30): primo vertice del poligono
|
||||
{ // dentro: punta in A, coda verso +u (x crescente) -> il secondo vertice ha x > 0? no: dentro la freccia punta verso fuori, coda verso l'interno (x>0)
|
||||
const m = conDv.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
|
||||
check('freccia dentro: punta in A, coda verso l\'interno della misura', near(m[0][0], 0) && near(m[0][1], 30) && m[1][0] > 0, m.join('|'));
|
||||
const corta = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [10, 0], testo: [0, 30] }, { zoom: 1 });
|
||||
const mc = corta.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
|
||||
check('freccia fuori (misura corta): punta in A, coda fuori (x < 0)', near(mc[0][0], 0) && mc[1][0] < 0, mc.join('|'));
|
||||
const zz = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [200, 0], testo: [0, 30] }, { zoom: 4 });
|
||||
const mz = zz.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
|
||||
check('freccia: lunghezza fissa a video (9 px / zoom)', near(mz[1][0], 9 / 4) && count(zz, /<line /g) === 3, mz.join('|'));
|
||||
}
|
||||
// T oltre B: la linea di misura si allunga
|
||||
const oltre = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: [150, 30] }, { zoom: 1 });
|
||||
check('quotaSvg lineare: scritta oltre B allunga la linea di misura fino a lei', oltre.includes('x2="200"'), oltre);
|
||||
// raggio
|
||||
const rFuori = VO.quotaSvg({ tipo: 'raggio', centro: [50, 50], r: 10, dir_deg: 0, testo: [30, 0] }, { zoom: 1 });
|
||||
const rDentro = VO.quotaSvg({ tipo: 'raggio', centro: [50, 50], r: 10, dir_deg: 0, testo: [5, 0] }, { zoom: 1 });
|
||||
check('quotaSvg raggio: crocetta + linea + freccia (fuori)', count(rFuori, /<line /g) === 3 && count(rFuori, /<polygon /g) === 1, rFuori);
|
||||
{ // fuori: P = (60, 50), freccia verso il centro: coda a x > 60
|
||||
const m = rFuori.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
|
||||
check('raggio fuori: punta in P sul cerchio, coda verso fuori', near(m[0][0], 60) && near(m[0][1], 50) && m[1][0] > 60, m.join('|'));
|
||||
const d = rDentro.match(/<polygon points="([^"]+)"/)[1].split(' ').map((s) => s.split(',').map(Number));
|
||||
check('raggio dentro: punta in P, coda verso il centro (x < 60)', near(d[0][0], 60) && d[1][0] < 60, d.join('|'));
|
||||
}
|
||||
// angolo
|
||||
const a1 = VO.quotaSvg(ang, { zoom: 1 });
|
||||
check('quotaSvg angolo: 2 estensioni radiali + arco + 2 frecce', count(a1, /<line /g) === 2 && count(a1, /<path /g) === 1 && count(a1, /<polygon /g) === 2, a1);
|
||||
// markup senza stringhe dei dati
|
||||
const evil = '<script>alert(1)</script>"onload="x';
|
||||
const s1 = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: [0, 20], nome: evil, extra: evil }, { zoom: 1, color: evil });
|
||||
const s2 = VO.quotaSvg({ tipo: 'lineare', p1: [0, 0], p2: [100, 0], testo: [0, evil] }, { zoom: 1 });
|
||||
check('quotaSvg: nessuna stringa dei dati nel markup, colore non in lista -> rosso',
|
||||
!/script|onload|alert/.test(s1) && s1.includes('#ef4444') && !/script|onload|alert/.test(s2), s1);
|
||||
check('quotaSvg: colori ammessi (esiti)', ['#16a34a', '#d97706', '#dc2626'].every((c) => VO.quotaSvg(lin, { color: c }).includes(c)));
|
||||
check('svg() invariato: uscita di un segmento', VO.svg([{ type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 }]).startsWith('<line x1="0"'));
|
||||
}
|
||||
|
||||
console.log('\n' + (total - failures) + '/' + total + ' controlli ok');
|
||||
process.exit(failures ? 1 : 0);
|
||||
|
||||
Reference in New Issue
Block a user