08616aa1fa
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
508 lines
24 KiB
JavaScript
508 lines
24 KiB
JavaScript
// «Crea ricetta da DXF» dell'editor task (#83): da un'immagine della camera e un DXF
|
||
// genera la ricetta dxf_compare (POST /maker/api/vision/tasks/<id>/recipe/dxf-compare).
|
||
// «Prova» (save=false) mostra overlay e quattro valori, «Salva ricetta» (save=true)
|
||
// la scrive e avvisa il componente visionRecipe con l'evento 'dxf-recipe-saved'.
|
||
// Uso: x-data="dxfRecipeWizard(task.id, csrfFn, msgs)". Dipende da Alpine.js e station_calibration.js.
|
||
function dxfRecipeWizard(taskId, csrf, msgs) {
|
||
// Colori overlay per ruolo, uguali a vision_execute.js (reference, roi, found)
|
||
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
|
||
const VALUE_UNITS = { 'dxf.points_in_pct': '%', 'dxf.distance_avg': 'mm', 'dxf.distance_min': 'mm', 'dxf.distance_max': 'mm' }; // #104
|
||
const VALUE_KEYS = ['dxf.points_in_pct', 'dxf.distance_avg', 'dxf.distance_min', 'dxf.distance_max'];
|
||
|
||
return {
|
||
taskId: taskId,
|
||
msgs: msgs || {},
|
||
open: false,
|
||
stations: [],
|
||
stationId: '',
|
||
calState: '', // '' | calibrated | uncalibrated | unreachable
|
||
calLabel: '',
|
||
calUrl: '',
|
||
imageFile: null, // immagine caricata o acquisita
|
||
imageName: '',
|
||
imageSrc: '',
|
||
imagePx: null, // #102: {w, h} in pixel, letto dall'<img> dell'anteprima
|
||
imageBroken: false, // #102: il browser non sa mostrare il formato (il file resta valido per l'invio)
|
||
calPxMm: null, // #102: px/mm della calibrazione della stazione
|
||
dxfFile: null,
|
||
dxfPreview: null, // #101: risposta di /dxf-preview {contorni, bbox, larghezza_mm, altezza_mm, entita, punti}
|
||
dxfPreviewBusy: false,
|
||
dxfPreviewError: '',
|
||
_dxfPreviewSeq: 0, // #101: una risposta arrivata dopo un cambio di file si scarta
|
||
mmPx: '', // #98: mm/px a mano (immagine da file), stringa con virgola o punto
|
||
bandMm: 0.1,
|
||
minPointsPct: 95,
|
||
dark: true, // spuntato = pezzo scuro su fondo chiaro (controluce)
|
||
busy: false,
|
||
grabbing: false,
|
||
error: '',
|
||
notice: '',
|
||
result: null, // corpo B3 {result, saved, recipe_dir, outputs}
|
||
imageSource: '', // #104: '' | 'file' | 'camera'; il campo mm/px vale solo per 'file'
|
||
tested: false, // #104: true dopo una Prova (anche fallita): mostra la sezione del risultato
|
||
zoom: 1, // #104: scala dell'immagine di prova (1 = pixel nativi)
|
||
fitZoom: 1, // #104: zoom di Adatta (sotto o pari a questo il viewport segue l'immagine)
|
||
panning: false, // #104: trascinamento in corso
|
||
_pan: null, // #104: {x, y, left, top} al pointerdown
|
||
resPx: null, // #104: {w, h} in pixel dell'immagine di prova (per la dimensione del wrapper)
|
||
runPxPerMm: null, // #104: scala usata nell'ultima Prova (null = calibrazione stazione)
|
||
runBandMm: null, // #104: banda usata nell'ultima Prova
|
||
runMinPointsPct: null, // #104: soglia usata nell'ultima Prova
|
||
existing: null, // #105: risposta di /source (ricetta DXF gia' salvata sul task); null = nessuna
|
||
existingInfo: false, // #105: «Ricetta esistente caricata...» dopo aver compilato il wizard
|
||
existingWarn: '', // #105: avviso (immagine non salvata, file illeggibili)
|
||
dxfFromRecipe: false, // #105: il DXF viene dalla ricetta (l'<input type=file> non puo' mostrarne il nome)
|
||
_existingApplied: false, // #105: il wizard e' gia' stato compilato dalla ricetta (non sovrascrivere le modifiche)
|
||
_existingReady: null, // #105: promessa della prima lettura di /source
|
||
_srcSeq: 0, // #105: una risposta di /source arrivata dopo una lettura piu' recente si scarta
|
||
_dxfPickSeq: 0, // #105: sale a ogni scelta manuale del DXF; i file della ricetta arrivati dopo si scartano
|
||
_imgPickSeq: 0, // #105: idem per l'immagine (_setImage)
|
||
|
||
// #105: all'avvio legge se il task ha gia' una ricetta da DXF (serve per l'etichetta del bottone)
|
||
init() {
|
||
if (this.taskId) this._existingReady = this.loadExisting();
|
||
},
|
||
|
||
async toggle() {
|
||
this.open = !this.open;
|
||
if (this.open) {
|
||
this.openExisting(); // #105: compila il wizard dalla ricetta esistente, in parallelo alle stazioni
|
||
if (!this.stations.length) await this.loadStations();
|
||
}
|
||
},
|
||
|
||
// #105: GET /source -> this.existing (null se 404 o errore)
|
||
async loadExisting() {
|
||
const seq = ++this._srcSeq;
|
||
let found = null;
|
||
try {
|
||
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/dxf-compare/source`);
|
||
if (resp.ok) {
|
||
const body = await resp.json().catch(() => null);
|
||
if (body && typeof body === 'object' && !body.error) found = body;
|
||
}
|
||
} catch (e) { console.error('dxfRecipeWizard source', e); }
|
||
if (seq !== this._srcSeq) return; // lettura piu' recente in corso
|
||
this.existing = found;
|
||
},
|
||
|
||
// #105: all'apertura, una sola volta, riempie il wizard con la ricetta esistente
|
||
async openExisting() {
|
||
if (this._existingReady) await this._existingReady;
|
||
if (!this.existing || this._existingApplied) return;
|
||
await this.applyExisting();
|
||
},
|
||
|
||
// #105: mm/px da px/mm, fino a 6 cifre significative e virgola decimale; '' se non valido
|
||
_mmPxFrom(pxPerMm) {
|
||
const v = Number(pxPerMm);
|
||
if (!Number.isFinite(v) || v <= 0) return '';
|
||
return String(Number((1 / v).toPrecision(6))).replace('.', ',');
|
||
},
|
||
|
||
// #105: un file della cartella ricetta -> File; null se assente o illeggibile (in quel caso avvisa)
|
||
async _fetchRecipeFile(name) {
|
||
try {
|
||
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/files/${encodeURIComponent(name)}`);
|
||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||
const blob = await resp.blob();
|
||
return new File([blob], name, { type: blob.type || '' });
|
||
} catch (e) {
|
||
console.error('dxfRecipeWizard file', name, e);
|
||
this.existingWarn = this.msgs.existingFilesFailed || 'Cannot read the recipe files';
|
||
return null;
|
||
}
|
||
},
|
||
|
||
// #105: valori salvati nei campi, poi DXF e immagine di riferimento dalla cartella ricetta
|
||
async applyExisting() {
|
||
const e = this.existing;
|
||
if (!e) return;
|
||
this._existingApplied = true;
|
||
this.error = '';
|
||
this.notice = '';
|
||
this.existingWarn = '';
|
||
const band = Number(e.band_mm), pct = Number(e.min_points_pct);
|
||
if (Number.isFinite(band) && band > 0) this.bandMm = band;
|
||
if (Number.isFinite(pct) && pct > 0) this.minPointsPct = pct;
|
||
this.dark = e.polarity === 'dark';
|
||
const mm = this._mmPxFrom(e.px_per_mm);
|
||
if (mm) this.mmPx = mm; // il campo compare quando l'immagine e' da file
|
||
this.existingInfo = true;
|
||
// Sequenze lette prima di scaricare: se l'utente sceglie un file a mano nel frattempo, vince lui
|
||
const dxfSeq = this._dxfPickSeq, imgSeq = this._imgPickSeq;
|
||
const [dxf, img] = await Promise.all([
|
||
e.dxf ? this._fetchRecipeFile(e.dxf) : null,
|
||
e.image ? this._fetchRecipeFile(e.image) : null,
|
||
]);
|
||
if (dxf && dxfSeq === this._dxfPickSeq) {
|
||
this.dxfFile = dxf;
|
||
this.dxfFromRecipe = true;
|
||
this.result = null;
|
||
this.tested = false;
|
||
this.loadDxfPreview();
|
||
}
|
||
if (img && imgSeq === this._imgPickSeq) this._setImage(img, 'file'); // 'file': il campo mm/px compare gia' compilato
|
||
if (!e.image && !this.existingWarn) this.existingWarn = this.msgs.noReferenceImage || 'Reference image not saved';
|
||
},
|
||
|
||
async loadStations() {
|
||
try {
|
||
const resp = await fetch('/maker/api/stations');
|
||
if (resp.ok) this.stations = await resp.json();
|
||
} catch (e) { console.error('dxfRecipeWizard stations', e); }
|
||
// Una sola stazione con camera: preselezionata
|
||
if (this.stations.length === 1) {
|
||
this.stationId = String(this.stations[0].id);
|
||
await this.onStation();
|
||
}
|
||
},
|
||
|
||
// Cambio stazione: rilegge lo stato di calibrazione dall'acquisitore
|
||
async onStation() {
|
||
this.calState = ''; this.calLabel = ''; this.calUrl = ''; this.calPxMm = null;
|
||
this.result = null;
|
||
this.tested = false; // #104
|
||
if (!this.stationId) return;
|
||
const r = await fetchStationCalibration(this.stationId);
|
||
this.calState = r.state || 'unreachable';
|
||
this.calUrl = r.calibration_url || '';
|
||
if (this.calState === 'calibrated') {
|
||
this.calLabel = formatCalibration(r.calibration || {});
|
||
const c = Number((r.calibration || {}).px_per_mm); // #102: scala per il campo in mm dell'anteprima
|
||
this.calPxMm = Number.isFinite(c) && c > 0 ? c : null;
|
||
}
|
||
},
|
||
|
||
get calibrated() { return this.calState === 'calibrated'; },
|
||
// #98: mm/px inserito a mano -> px/mm; null se vuoto o fuori da 0.00001..100 (limiti di /camera/calibrazioni/manuale, #95)
|
||
get manualPxMm() {
|
||
const raw = String(this.mmPx || '').trim().replace(',', '.');
|
||
if (!raw) return null;
|
||
const v = Number(raw);
|
||
if (!Number.isFinite(v) || v < 0.00001 || v > 100) return null;
|
||
return 1 / v;
|
||
},
|
||
// #98: compilato ma non valido -> errore in pagina
|
||
get mmPxInvalid() { return String(this.mmPx || '').trim() !== '' && this.manualPxMm === null; },
|
||
// #104: la scala a mano vale solo per un'immagine da file; con la camera vale la calibrazione della stazione
|
||
get useManualScale() { return this.imageSource === 'file' && this.manualPxMm !== null; },
|
||
get canRun() {
|
||
// #98: la scala a mano sostituisce la calibrazione della stazione (#104: solo per immagini da file)
|
||
return !!(this.stationId && (this.calibrated || this.useManualScale) && this.imageFile && this.dxfFile) && !this.busy;
|
||
},
|
||
|
||
_setImage(file, source) {
|
||
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
|
||
// #102: con file null (immagine tolta) si azzera anche l'anteprima
|
||
this.imageFile = file || null;
|
||
this.imageName = (file && file.name) || '';
|
||
this.imageSrc = file ? URL.createObjectURL(file) : '';
|
||
this.imagePx = null;
|
||
this.imageBroken = false;
|
||
this.result = null;
|
||
this.tested = false; // #104
|
||
this.resPx = null; // #104
|
||
this.imageSource = file ? (source || '') : ''; // #104
|
||
this._imgPickSeq++; // #105: una scelta piu' recente batte i file della ricetta ancora in arrivo
|
||
},
|
||
|
||
// #102: l'<img> dell'anteprima e' caricata: legge i pixel; se non si carica il formato non e' mostrabile
|
||
onImagePreviewLoad(el) {
|
||
this.imageBroken = false;
|
||
this.imagePx = el && el.naturalWidth ? { w: el.naturalWidth, h: el.naturalHeight } : null;
|
||
},
|
||
onImagePreviewError() {
|
||
this.imageBroken = !!this.imageSrc;
|
||
this.imagePx = null;
|
||
},
|
||
// #102: nome usato dal template (stesso valore dell'URL gia' usato per l'overlay dei risultati)
|
||
get imagePreviewUrl() { return this.imageSrc; },
|
||
// #102: «W × H px · campo ≈ L × H mm»; scala = mm/px a mano se valido, altrimenti calibrazione della stazione
|
||
get imagePreviewSummary() {
|
||
const p = this.imagePx;
|
||
if (!p) return '';
|
||
const lang = document.documentElement.lang || 'it';
|
||
const f = (v, d) => Number(v).toLocaleString(lang, { minimumFractionDigits: d, maximumFractionDigits: d });
|
||
let text = `${f(p.w, 0)} × ${f(p.h, 0)} px`;
|
||
const pxMm = this.useManualScale ? this.manualPxMm : (this.calibrated ? this.calPxMm : null); // #104
|
||
if (pxMm) text += ` · ${this.msgs.imageField || 'field'} ≈ ${f(p.w / pxMm, 2)} × ${f(p.h / pxMm, 2)} mm`;
|
||
return text;
|
||
},
|
||
|
||
onImage(ev) {
|
||
const f = ev.target.files && ev.target.files[0];
|
||
if (f) this._setImage(f, 'file'); // #104
|
||
},
|
||
onDxf(ev) {
|
||
this.dxfFile = (ev.target.files && ev.target.files[0]) || null;
|
||
this._dxfPickSeq++; // #105: scelta a mano: il DXF della ricetta in arrivo si scarta
|
||
this.dxfFromRecipe = false; // #105
|
||
this.result = null;
|
||
this.tested = false; // #104
|
||
this.loadDxfPreview(); // #101: anteprima appena si sceglie il file
|
||
},
|
||
|
||
// #101: legge il DXF sul vision worker (via Flask e backend) e ne mostra i contorni
|
||
async loadDxfPreview() {
|
||
const seq = ++this._dxfPreviewSeq;
|
||
this.dxfPreview = null;
|
||
this.dxfPreviewError = '';
|
||
this.dxfPreviewBusy = false;
|
||
if (!this.dxfFile) return; // file tolto: tutto azzerato
|
||
this.dxfPreviewBusy = true;
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('dxf', this.dxfFile, this.dxfFile.name);
|
||
const resp = await fetch('/maker/api/vision/dxf-preview', {
|
||
method: 'POST', headers: { 'X-CSRFToken': this._csrf() }, body: fd,
|
||
});
|
||
const body = await resp.json().catch(() => ({}));
|
||
if (seq !== this._dxfPreviewSeq) return; // file cambiato nel frattempo
|
||
if (!resp.ok || body.error) this.dxfPreviewError = this._errorText(body);
|
||
else this.dxfPreview = body;
|
||
} catch (e) {
|
||
if (seq !== this._dxfPreviewSeq) return;
|
||
this.dxfPreviewError = this.msgs.requestFailed || String(e);
|
||
}
|
||
if (seq === this._dxfPreviewSeq) this.dxfPreviewBusy = false;
|
||
},
|
||
|
||
// #101: una stringa `points` per ogni contorno, per <polyline>
|
||
dxfPreviewPaths() {
|
||
const contorni = (this.dxfPreview && this.dxfPreview.contorni) || [];
|
||
return contorni.filter(c => c && c.length > 1).map(c => c.map(p => p[0] + ',' + p[1]).join(' '));
|
||
},
|
||
|
||
// #101: tutti i contorni in un solo attributo `d` ("M x y L x y ..."): x-for non funziona dentro <svg>
|
||
dxfPreviewD() {
|
||
const contorni = (this.dxfPreview && this.dxfPreview.contorni) || [];
|
||
return contorni.filter(c => c && c.length > 1)
|
||
.map(c => c.map((p, i) => (i ? 'L ' : 'M ') + p[0] + ' ' + p[1]).join(' ')).join(' ');
|
||
},
|
||
|
||
// #101: viewBox sul bbox con margine 5% (minimo 0.5 mm per lato)
|
||
dxfPreviewViewBox() {
|
||
const b = this.dxfPreview && this.dxfPreview.bbox;
|
||
if (!b) return '0 0 1 1';
|
||
const w = b.xmax - b.xmin, h = b.ymax - b.ymin;
|
||
const m = Math.max(0.5, 0.05 * Math.max(w, h));
|
||
return [b.xmin - m, b.ymin - m, w + 2 * m, h + 2 * m].map(v => +v.toFixed(3)).join(' ');
|
||
},
|
||
|
||
// #101: «L × H mm · N entità», decimale con virgola (2 decimali) secondo la lingua pagina
|
||
get dxfPreviewSummary() {
|
||
const d = this.dxfPreview;
|
||
if (!d) return '';
|
||
const f = v => Number(v).toLocaleString(document.documentElement.lang || 'it',
|
||
{ minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
return `${f(d.larghezza_mm)} × ${f(d.altezza_mm)} mm · ${d.entita} ${this.msgs.dxfEntities || 'entities'}`;
|
||
},
|
||
|
||
// Frame dalla camera della stazione scelta
|
||
async grab() {
|
||
if (!this.stationId || this.grabbing) return;
|
||
this.grabbing = true;
|
||
this.error = '';
|
||
try {
|
||
const resp = await fetch(`/maker/api/stations/${this.stationId}/camera/grab`, {
|
||
method: 'POST', headers: { 'X-CSRFToken': this._csrf() },
|
||
});
|
||
if (!resp.ok) {
|
||
const body = await resp.json().catch(() => ({}));
|
||
this.error = body.detail || this.msgs.requestFailed || 'Error';
|
||
} else {
|
||
const blob = await resp.blob();
|
||
this._setImage(new File([blob], 'camera.png', { type: blob.type || 'image/png' }), 'camera'); // #104
|
||
}
|
||
} catch (e) {
|
||
this.error = this.msgs.requestFailed || String(e);
|
||
}
|
||
this.grabbing = false;
|
||
},
|
||
|
||
_csrf() { return typeof csrf === 'function' ? csrf() : csrf; },
|
||
|
||
// Il 422 porta `detail` (stringa o lista) oppure `errors`
|
||
_errorText(body) {
|
||
if (Array.isArray(body.errors) && body.errors.length) return body.errors.map(String).join('; ');
|
||
const d = body.detail;
|
||
if (Array.isArray(d)) return d.map(x => (typeof x === 'string' ? x : (x.msg || JSON.stringify(x)))).join('; ');
|
||
if (d && typeof d === 'object') return JSON.stringify(d);
|
||
return d || this.msgs.requestFailed || 'Error';
|
||
},
|
||
|
||
async run(save) {
|
||
if (!this.canRun) return;
|
||
this.busy = true;
|
||
this.error = '';
|
||
this.notice = '';
|
||
// #104: parametri usati, mostrati accanto ai risultati
|
||
this.runPxPerMm = this.useManualScale ? this.manualPxMm : (this.calibrated ? this.calPxMm : null);
|
||
this.runBandMm = this.bandMm;
|
||
this.runMinPointsPct = this.minPointsPct;
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('image', this.imageFile, this.imageFile.name || 'capture.png');
|
||
fd.append('dxf', this.dxfFile, this.dxfFile.name);
|
||
fd.append('station_id', this.stationId);
|
||
// #98: scala a mano; il backend la preferisce alla calibrazione della stazione (#104: solo con immagine da file)
|
||
if (this.useManualScale) fd.append('px_per_mm', String(this.manualPxMm));
|
||
fd.append('band_mm', String(this.bandMm));
|
||
fd.append('min_points_pct', String(this.minPointsPct));
|
||
fd.append('polarity', this.dark ? 'dark' : 'light');
|
||
fd.append('save', save ? 'true' : 'false');
|
||
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/dxf-compare`, {
|
||
method: 'POST', headers: { 'X-CSRFToken': this._csrf() }, body: fd,
|
||
});
|
||
const body = await resp.json().catch(() => ({}));
|
||
if (!resp.ok || body.error) {
|
||
this.error = this._errorText(body);
|
||
} else {
|
||
this.result = body;
|
||
this.$nextTick(() => this.drawOverlay());
|
||
if (save && body.saved) {
|
||
this.notice = this.msgs.saved || 'Saved';
|
||
// Il componente visionRecipe ricarica cartella, file e uscite delle quote
|
||
this.$dispatch('dxf-recipe-saved', { taskId: this.taskId });
|
||
// #105: il task ha ora una ricetta (etichetta «Modifica»); il wizard e' gia' allineato, non si ricompila
|
||
this._existingApplied = true;
|
||
this.existingInfo = false;
|
||
this.existingWarn = ''; // #105: l'immagine di riferimento ora e' salvata con la ricetta
|
||
await this.loadExisting();
|
||
}
|
||
}
|
||
} catch (e) {
|
||
this.error = this.msgs.requestFailed || String(e);
|
||
}
|
||
this.busy = false;
|
||
// #104: la sezione del risultato compare anche con Prova fallita (immagine a sinistra, errore a destra)
|
||
this.tested = true;
|
||
this.$nextTick(() => this.fit());
|
||
},
|
||
|
||
// Le quattro uscite del confronto, nell'ordine fisso
|
||
get values() {
|
||
const out = (this.result && this.result.result && this.result.result.outputs) || {};
|
||
return VALUE_KEYS.map(k => ({ key: k, value: out[k], label: this._valueLabel(k), unit: VALUE_UNITS[k] })); // #104
|
||
},
|
||
|
||
// #104: etichette leggibili (testi passati da msgs), chiave grezza come ripiego
|
||
_valueLabel(k) {
|
||
const m = { 'dxf.points_in_pct': this.msgs.pointsInBand, 'dxf.distance_avg': this.msgs.distAvg,
|
||
'dxf.distance_min': this.msgs.distMin, 'dxf.distance_max': this.msgs.distMax };
|
||
return m[k] || k;
|
||
},
|
||
|
||
// #104: esito della ricetta sul riferimento: pass / warning / fail (src/vision/runner.py); altro = valore grezzo
|
||
get outcome() { return (this.result && this.result.result && this.result.result.outcome) || ''; },
|
||
get outcomeLabel() {
|
||
const m = { pass: this.msgs.outcomePass, warning: this.msgs.outcomeWarning, fail: this.msgs.outcomeFail };
|
||
return m[this.outcome] || this.outcome;
|
||
},
|
||
get outcomeClass() {
|
||
return { pass: 'text-emerald-600', warning: 'text-amber-600', fail: 'text-red-500' }[this.outcome] || 'text-[var(--text-muted)]';
|
||
},
|
||
// #104: scala usata nell'ultima Prova, con la fonte
|
||
get scaleSummary() {
|
||
if (!this.runPxPerMm) return this.msgs.scaleStation || 'station calibration';
|
||
const lang = document.documentElement.lang || 'it';
|
||
const mm = (1 / this.runPxPerMm).toLocaleString(lang, { maximumFractionDigits: 6 });
|
||
return `${mm} mm/px (${this.imageSource === 'file' ? (this.msgs.scaleManual || 'manual') : (this.msgs.scaleStation || 'station calibration')})`;
|
||
},
|
||
|
||
// #104: zoom dell'immagine di prova. Il wrapper e' largo naturalWidth*zoom; il canvas resta in pixel immagine e lo scala il CSS
|
||
get zoomPct() { return Math.round(this.zoom * 100) + ' %'; },
|
||
get zoomW() { return this.resPx ? this.resPx.w * this.zoom : 0; },
|
||
get zoomH() { return this.resPx ? this.resPx.h * this.zoom : 0; },
|
||
// #104: altezza massima del viewport = min(70vh, 520px)
|
||
_maxVpH() { return Math.min(0.7 * (typeof window !== 'undefined' && window.innerHeight ? window.innerHeight : 800), 520); },
|
||
// #104: con zoom <= Adatta il viewport e' alto come l'immagine scalata (min 200px), altrimenti al massimo
|
||
get vpHeight() {
|
||
const max = this._maxVpH();
|
||
if (!this.resPx || this.zoom > this.fitZoom * 1.0001) return max;
|
||
return Math.min(max, Math.max(200, this.zoomH));
|
||
},
|
||
// #104: l'immagine e' piu' grande dello spazio: si puo' trascinare
|
||
get canPan() { return !!this.resPx && this.zoom > this.fitZoom * 1.0001; },
|
||
get panCursor() { return this.canPan ? (this.panning ? 'grabbing' : 'grab') : 'default'; },
|
||
// #104: pan col puntatore (mouse e touch): scroll del viewport di quanto si muove il puntatore
|
||
onPanDown(ev) {
|
||
const vp = this.$refs.viewport;
|
||
if (!vp || (ev.pointerType === 'mouse' && ev.button)) return;
|
||
// #104: senza preventDefault Chromium manda pointercancel al primo movimento del mouse e il pan si ferma
|
||
if (ev.preventDefault) ev.preventDefault();
|
||
this._pan = { x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop };
|
||
this.panning = true;
|
||
try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
|
||
},
|
||
onPanMove(ev) {
|
||
const vp = this.$refs.viewport;
|
||
if (!this._pan || !vp) return;
|
||
vp.scrollLeft = this._pan.left - (ev.clientX - this._pan.x);
|
||
vp.scrollTop = this._pan.top - (ev.clientY - this._pan.y);
|
||
},
|
||
onPanEnd() { this._pan = null; this.panning = false; },
|
||
zoomBy(f) {
|
||
const vp = this.$refs.viewport;
|
||
const old = this.zoom;
|
||
const z = Math.min(8, Math.max(0.05, old * f));
|
||
if (z === old) return;
|
||
// centro visibile invariato: coordinate immagine del centro prima, scroll dopo il nuovo layout
|
||
let cx = 0, cy = 0;
|
||
if (vp) { cx = (vp.scrollLeft + vp.clientWidth / 2) / old; cy = (vp.scrollTop + vp.clientHeight / 2) / old; }
|
||
this.zoom = z;
|
||
if (vp) this.$nextTick(() => { vp.scrollLeft = cx * z - vp.clientWidth / 2; vp.scrollTop = cy * z - vp.clientHeight / 2; });
|
||
},
|
||
// #104: Fit = tutta l'immagine nel viewport (altezza massima, non quella corrente che segue l'immagine)
|
||
fit() {
|
||
const vp = this.$refs.viewport, img = this.$refs.img;
|
||
if (!vp || !img || !img.naturalWidth || !vp.clientWidth) return;
|
||
const z = Math.min(8, Math.max(0.05, Math.min(vp.clientWidth / img.naturalWidth, this._maxVpH() / img.naturalHeight)));
|
||
this.zoom = z;
|
||
this.fitZoom = z;
|
||
vp.scrollLeft = 0; vp.scrollTop = 0;
|
||
},
|
||
// #104: Ctrl+rotella = zoom
|
||
onWheel(ev) {
|
||
if (!ev.ctrlKey) return;
|
||
ev.preventDefault();
|
||
this.zoomBy(ev.deltaY < 0 ? 1.25 : 1 / 1.25);
|
||
},
|
||
// #104: load dell'immagine di prova: dimensioni, overlay e adattamento allo spazio
|
||
onResultImgLoad() {
|
||
const img = this.$refs.img;
|
||
this.resPx = img && img.naturalWidth ? { w: img.naturalWidth, h: img.naturalHeight } : null;
|
||
this.drawOverlay();
|
||
this.$nextTick(() => this.fit());
|
||
},
|
||
|
||
fmt(v) {
|
||
return (typeof v === 'number') ? v.toLocaleString(document.documentElement.lang || 'it',
|
||
{ maximumFractionDigits: 3 }) : '-';
|
||
},
|
||
|
||
// Come vision_execute.js: canvas della dimensione dell'immagine, coordinate in pixel immagine
|
||
drawOverlay() {
|
||
const img = this.$refs.img, cv = this.$refs.overlay;
|
||
if (!img || !cv || !img.naturalWidth) return;
|
||
cv.width = img.naturalWidth;
|
||
cv.height = img.naturalHeight;
|
||
const g = cv.getContext('2d');
|
||
g.clearRect(0, 0, cv.width, cv.height);
|
||
g.lineWidth = Math.max(2, cv.width / 400);
|
||
g.lineJoin = 'round';
|
||
const shapes = (this.result && this.result.result && this.result.result.overlay) || [];
|
||
for (const item of shapes) {
|
||
const rows = item.rows || [], cols = item.cols || [];
|
||
if (!rows.length || rows.length !== cols.length) continue;
|
||
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
|
||
g.beginPath();
|
||
g.moveTo(cols[0], rows[0]);
|
||
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
|
||
g.stroke();
|
||
}
|
||
},
|
||
};
|
||
}
|