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:
2026-10-29 13:49:39 +01:00
parent 5897a9e5df
commit 8c361c14ae
7 changed files with 556 additions and 34 deletions
+230 -22
View File
@@ -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
vp.scrollLeft = s.scrollLeft; vp.scrollTop = s.scrollTop;
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;