diff --git a/src/control.html b/src/control.html
index c61c032..b0cf856 100644
--- a/src/control.html
+++ b/src/control.html
@@ -213,7 +213,14 @@
Mappatura proiezione
@@ -234,10 +241,14 @@
nella zona un
preset indipendente (ogni zona il suo effetto, tutti a tempo
con la stessa musica — ma ogni zona costa un rendering in più);
➕ Zona
Immagine incolla un'immagine fissa su una superficie (corner-pin).
-
3. Posiziona — spunta Modifica zone : sull'output compaiono le
- maniglie. Trascina i 4 angoli per adattare la zona alla superficie reale
- (guardando la proiezione!), oppure clicca dentro la zona per spostarla
- intera. La zona selezionata ha le maniglie azzurre. Finito? Togli la spunta.
+
3. Posiziona — usa l'editor qui sopra : è una replica in scala
+ dell'output, così non devi lavorare sullo schermo grande. Trascina un angolo
+ per deformare la zona, o clicca dentro per spostarla intera; il risultato si
+ vede subito sul ledwall/proiettore. Per la rifinitura usa la tastiera
+ (clicca prima sull'editor): frecce = 1 pixel, Shift+frecce = 10 pixel,
+ Tab = passa all'angolo successivo, Esc = torna a muovere la zona intera.
+ La riga sotto l'editor mostra la posizione in pixel. Se vuoi vedere i contorni anche
+ sullo schermo, spunta Mostra maniglie anche sull'output .
Opzioni per zona :
· Nome — etichetta libera ("Pannello SX", "Cubo"…)
· Porzione (solo Visual) — quale ritaglio del visual mostrare:
diff --git a/src/control.js b/src/control.js
index 0fa1342..d3fa7ef 100644
--- a/src/control.js
+++ b/src/control.js
@@ -1552,11 +1552,15 @@ function mapAddZone(src) {
mapCfg.zones.push({ id: mapNextId++, name: 'Zona ' + mapNextId,
src, corners: [[0.25+off, 0.25+off], [0.75+off, 0.25+off], [0.75+off, 0.75+off], [0.25+off, 0.75+off]],
srcRect: [0, 0, 1, 1], opacity: 1 });
+ mapSel = mapCfg.zones.length - 1; mapSelC = -1;
mapSave(); mapSendZones(); renderMapZones();
+ send({ type: 'mapSel', index: mapSel });
}
function renderMapZones() {
const box = $('#map-zones');
box.innerHTML = '';
+ if (mapSel >= mapCfg.zones.length) { mapSel = mapCfg.zones.length - 1; mapSelC = -1; }
+ mapEdDraw();
if (!mapCfg.zones.length) {
box.innerHTML = '
Nessuna zona: aggiungine una qui sotto.
';
return;
@@ -1631,7 +1635,15 @@ function renderMapZones() {
z.opacity = parseFloat(e.target.value); mapSave(); mapSendZones();
});
row.querySelector('.mz-del').addEventListener('click', () => {
- mapCfg.zones.splice(i, 1); mapSave(); mapSendZones(); renderMapZones();
+ mapCfg.zones.splice(i, 1);
+ if (mapSel === i) mapSel = -1; else if (mapSel > i) mapSel--;
+ mapSave(); mapSendZones(); renderMapZones();
+ });
+ if (i === mapSel) row.classList.add('sel');
+ // Clicking the row (not its controls) selects the zone in the editor.
+ row.addEventListener('mousedown', (e) => {
+ if (e.target.closest('input, select, button')) return;
+ mapSelect(i, -1);
});
box.appendChild(row);
});
@@ -1653,6 +1665,196 @@ $('#map-img-input').addEventListener('change', (e) => {
}
e.target.value = '';
});
+
+// ---- Mapping mini-editor: edit zones on a scaled replica of the output ----
+// On a huge LED wall the on-output handles are unusable; here the hand stays
+// on the laptop and the wall only shows the result. Coordinates are the same
+// normalized 0..1 (y-down) corners the output uses.
+let mapSel = -1; // selected zone index
+let mapSelC = -1; // selected corner (0 TL,1 TR,2 BR,3 BL) or -1 = whole zone
+let mapEdDrag = null; // { ci, lx, ly }
+let outBounds = null; // main output display size (set by refreshDisplays)
+const MAP_CORNER = ['alto-sx', 'alto-dx', 'basso-dx', 'basso-sx'];
+
+// Size the editor works in = the area the map canvas covers on the output.
+function mapEdSize() {
+ // ledCfg is declared further down: guard the first draw at startup (TDZ).
+ try { if (ledCfg.on && ledCfg.w > 0 && ledCfg.h > 0) return [ledCfg.w, ledCfg.h]; } catch (e) {}
+ if (outBounds) return [outBounds.width, outBounds.height];
+ return [1920, 1080];
+}
+
+let mapEdSendPending = false;
+function mapEdSync() {
+ // Coalesce drag/nudge bursts to one IPC per frame.
+ if (mapEdSendPending) return;
+ mapEdSendPending = true;
+ requestAnimationFrame(() => { mapEdSendPending = false; mapSendZones(); });
+}
+let mapEdSaveT = null;
+function mapEdSaveSoon() { clearTimeout(mapEdSaveT); mapEdSaveT = setTimeout(mapSave, 300); }
+
+function mapSelect(zi, ci) {
+ mapSel = zi; mapSelC = zi < 0 ? -1 : ci;
+ send({ type: 'mapSel', index: mapSel });
+ document.querySelectorAll('#map-zones .map-zone').forEach((r, k) => r.classList.toggle('sel', k === mapSel));
+ mapEdDraw();
+}
+
+function mapEdDraw() {
+ const cv = $('#map-editor');
+ if (!cv || !cv.clientWidth) return;
+ const [ow, oh] = mapEdSize();
+ const dpr = window.devicePixelRatio || 1;
+ const cssW = cv.clientWidth;
+ const cssH = Math.min(440, cssW * oh / ow);
+ // Keep the output aspect even when max-height clamps the box.
+ const drawW = cssH * ow / oh < cssW ? cssH * ow / oh : cssW;
+ cv.style.height = cssH + 'px';
+ if (cv.width !== Math.round(cssW * dpr) || cv.height !== Math.round(cssH * dpr)) {
+ cv.width = Math.round(cssW * dpr); cv.height = Math.round(cssH * dpr);
+ }
+ const ctx = cv.getContext('2d');
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.clearRect(0, 0, cssW, cssH);
+ const ox = (cssW - drawW) / 2, W = drawW, H = cssH;
+ cv._geo = { ox, W, H };
+ // output frame + 10% grid
+ ctx.fillStyle = '#000'; ctx.fillRect(ox, 0, W, H);
+ ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1;
+ for (let k = 1; k < 10; k++) {
+ ctx.beginPath(); ctx.moveTo(ox + W * k / 10, 0); ctx.lineTo(ox + W * k / 10, H); ctx.stroke();
+ ctx.beginPath(); ctx.moveTo(ox, H * k / 10); ctx.lineTo(ox + W, H * k / 10); ctx.stroke();
+ }
+ ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.strokeRect(ox + 0.5, 0.5, W - 1, H - 1);
+ const P = (c) => [ox + c[0] * W, c[1] * H];
+ mapCfg.zones.forEach((z, zi) => {
+ const sel = zi === mapSel;
+ const pts = z.corners.map(P);
+ ctx.beginPath();
+ pts.forEach((p, k) => k ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]));
+ ctx.closePath();
+ const t = z.src && z.src.type;
+ ctx.fillStyle = sel ? 'rgba(95,140,255,0.28)'
+ : (t === 'image' ? 'rgba(255,190,90,0.14)' : t === 'effect' ? 'rgba(190,120,255,0.14)' : 'rgba(120,220,200,0.14)');
+ ctx.fill();
+ ctx.lineWidth = sel ? 2 : 1.2;
+ ctx.strokeStyle = sel ? '#7fa8ff' : 'rgba(255,255,255,0.55)';
+ ctx.stroke();
+ // label at the centroid
+ const cx = pts.reduce((s, p) => s + p[0], 0) / 4, cy = pts.reduce((s, p) => s + p[1], 0) / 4;
+ ctx.font = (sel ? 'bold ' : '') + '11px -apple-system, sans-serif';
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
+ ctx.fillStyle = sel ? '#fff' : 'rgba(255,255,255,0.75)';
+ ctx.fillText(z.name || 'Zona', cx, cy);
+ pts.forEach((p, k) => {
+ const hot = sel && (mapSelC === k);
+ ctx.beginPath();
+ ctx.arc(p[0], p[1], hot ? 7 : (sel ? 5 : 3.5), 0, Math.PI * 2);
+ ctx.fillStyle = hot ? '#ffd24a' : (sel ? '#7fa8ff' : 'rgba(255,255,255,0.7)');
+ ctx.fill();
+ });
+ });
+ // status line
+ const st = $('#map-ed-status');
+ if (mapSel < 0 || !mapCfg.zones[mapSel]) { st.textContent = 'Clicca una zona o un angolo per selezionarlo'; return; }
+ const z = mapCfg.zones[mapSel];
+ const fmt = (c) => Math.round(c[0] * ow) + ', ' + Math.round(c[1] * oh) + ' px';
+ st.textContent = mapSelC >= 0
+ ? '● ' + z.name + ' · angolo ' + MAP_CORNER[mapSelC] + ': ' + fmt(z.corners[mapSelC])
+ : '● ' + z.name + ' · zona intera · angolo alto-sx ' + fmt(z.corners[0]);
+}
+
+(function mapEdInit() {
+ const cv = $('#map-editor');
+ const toN = (e) => {
+ const r = cv.getBoundingClientRect(), g = cv._geo || { ox: 0, W: r.width, H: r.height };
+ return [(e.clientX - r.left - g.ox) / g.W, (e.clientY - r.top) / g.H];
+ };
+ const clampC = (v) => Math.min(1.2, Math.max(-0.2, v));
+ const inside = (c, x, y) => { // even-odd point-in-polygon
+ let inn = false;
+ for (let i = 0, j = 3; i < 4; j = i++) {
+ const xi = c[i][0], yi = c[i][1], xj = c[j][0], yj = c[j][1];
+ if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / ((yj - yi) || 1e-9) + xi)) inn = !inn;
+ }
+ return inn;
+ };
+ cv.addEventListener('mousedown', (e) => {
+ cv.focus();
+ const [x, y] = toN(e);
+ const g = cv._geo || { W: cv.clientWidth, H: cv.clientHeight };
+ // nearest corner within 10 css px — the selected zone wins ties
+ let best = null, bd = 10;
+ const order = mapCfg.zones.map((_, i) => i).sort((a, b) => (b === mapSel) - (a === mapSel));
+ for (const zi of order) {
+ mapCfg.zones[zi].corners.forEach((c, ci) => {
+ const d = Math.hypot((c[0] - x) * g.W, (c[1] - y) * g.H);
+ if (d < bd) { bd = d; best = { zi, ci }; }
+ });
+ }
+ if (best) {
+ mapSelect(best.zi, best.ci);
+ mapEdDrag = { ci: best.ci, lx: x, ly: y };
+ return;
+ }
+ for (let zi = mapCfg.zones.length - 1; zi >= 0; zi--) {
+ if (inside(mapCfg.zones[zi].corners, x, y)) {
+ mapSelect(zi, -1);
+ mapEdDrag = { ci: -1, lx: x, ly: y };
+ return;
+ }
+ }
+ mapSelect(-1, -1);
+ });
+ window.addEventListener('mousemove', (e) => {
+ if (!mapEdDrag || mapSel < 0) return;
+ const z = mapCfg.zones[mapSel];
+ if (!z) { mapEdDrag = null; return; }
+ const [x, y] = toN(e);
+ if (mapEdDrag.ci >= 0) {
+ z.corners[mapEdDrag.ci] = [clampC(x), clampC(y)];
+ } else {
+ const dx = x - mapEdDrag.lx, dy = y - mapEdDrag.ly;
+ z.corners.forEach(c => { c[0] = clampC(c[0] + dx); c[1] = clampC(c[1] + dy); });
+ mapEdDrag.lx = x; mapEdDrag.ly = y;
+ }
+ mapEdDraw(); mapEdSync();
+ });
+ window.addEventListener('mouseup', () => {
+ if (mapEdDrag) { mapEdDrag = null; mapSave(); }
+ });
+ cv.addEventListener('keydown', (e) => {
+ // Keys typed while the editor has focus never reach the global hotkeys
+ // (arrows = images, 1-9 = effects, F, space…).
+ e.stopPropagation();
+ const z = mapCfg.zones[mapSel];
+ if (e.key === 'Tab') {
+ if (!z) return;
+ e.preventDefault();
+ // TL → TR → BR → BL → whole zone → TL …
+ const nxt = mapSelC >= 3 ? -1 : mapSelC + 1;
+ mapSelect(mapSel, e.shiftKey ? (mapSelC <= -1 ? 3 : mapSelC - 1) : nxt);
+ return;
+ }
+ if (e.key === 'Escape') { if (z) mapSelect(mapSel, -1); return; }
+ const dir = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
+ if (!dir || !z) return;
+ e.preventDefault();
+ const [ow, oh] = mapEdSize();
+ const step = e.shiftKey ? 10 : 1; // output pixels
+ const dx = dir[0] * step / ow, dy = dir[1] * step / oh;
+ const pts = mapSelC >= 0 ? [z.corners[mapSelC]] : z.corners;
+ pts.forEach(c => { c[0] = clampC(c[0] + dx); c[1] = clampC(c[1] + dy); });
+ mapEdDraw(); mapEdSync(); mapEdSaveSoon();
+ });
+ window.addEventListener('resize', mapEdDraw);
+ // The canvas has no width while its tab is hidden: draw when shown.
+ document.querySelectorAll('#tabs button').forEach(b => b.addEventListener('click', () => {
+ if (b.dataset.tab === 'mappa') requestAnimationFrame(mapEdDraw);
+ }));
+})();
+
renderMapZones();
// ---------------------------------------------------------------- displays
@@ -1668,6 +1870,9 @@ async function refreshDisplays() {
});
const ext = list.find(d => !d.isPrimary);
if (ext) sel.value = ext.id;
+ const outD = list.find(d => d.hasOutput);
+ outBounds = outD ? outD.bounds : null;
+ mapEdDraw();
// Aux candidates: every display that hosts neither the panel nor the output.
auxDisplays = list.filter(d => !d.isPrimary && !d.hasOutput);
renderAuxList();
@@ -1843,7 +2048,7 @@ $('#btn-fullscreen').addEventListener('click', () => djv.toggleOutputFullscreen(
let ledCfg = { on: false, w: 1024, h: 576, x: 0, y: 0 };
try { ledCfg = Object.assign(ledCfg, JSON.parse(localStorage.getItem('ledarea') || '{}')); } catch (e) {}
function ledSend() { send({ type: 'ledArea', on: ledCfg.on, w: ledCfg.w, h: ledCfg.h, x: ledCfg.x, y: ledCfg.y }); }
-function ledSave() { localStorage.setItem('ledarea', JSON.stringify(ledCfg)); ledSend(); }
+function ledSave() { localStorage.setItem('ledarea', JSON.stringify(ledCfg)); ledSend(); if (window.mapEdDraw) mapEdDraw(); }
(function ledInit() {
$('#led-on').checked = ledCfg.on;
const fields = [['#led-w', 'w'], ['#led-h', 'h'], ['#led-x', 'x'], ['#led-y', 'y']];
diff --git a/src/output.js b/src/output.js
index 675ddfd..687d220 100644
--- a/src/output.js
+++ b/src/output.js
@@ -393,6 +393,7 @@ djv.onControl(async (m) => {
if (mapping) mapping.editOn = !!m.on;
$('#map-canvas').classList.toggle('edit', !!m.on);
break;
+ case 'mapSel': if (mapping) mapping.selected = m.index; break;
case 'auxActive': auxActive = !!m.on; break;
case 'glbAnims': viz.setClipFilter(m.names || []); break;
case 'modelBpm': viz.setManualBpm(m.bpm || 0); break;
diff --git a/src/style.css b/src/style.css
index a2c6df5..a42fe66 100644
--- a/src/style.css
+++ b/src/style.css
@@ -944,6 +944,19 @@ kbd {
padding: 0 3px;
}
+/* Mapping mini-editor (control panel): scaled replica of the output */
+.map-ed-wrap { margin: 8px 0 4px; display: flex; justify-content: center; }
+#map-editor {
+ display: block; width: 100%; max-height: 440px;
+ background: #07070c; border: 1px solid rgba(255,255,255,0.14); border-radius: 8px;
+ cursor: crosshair; outline: none;
+}
+#map-editor:focus { border-color: #5f7fe8; box-shadow: 0 0 0 2px rgba(95,127,232,0.35); }
+.map-ed-info { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; font-size: 11px; margin-bottom: 6px; }
+.map-ed-info #map-ed-status { color: #9fc0ff; }
+.map-ed-info .map-ed-keys { opacity: 0.55; }
+.map-zone.sel { border-color: #5f7fe8; background: rgba(95,127,232,0.10); }
+
/* Recording crop guide on the output (overlay only, never recorded) */
#rec-guide {
position: fixed;