Multi-dial on one face: per-dial X/Y offset, independent settings per dial

This commit is contained in:
2026-04-25 23:36:29 -04:00
parent c239fb5213
commit 77593847f8
+223 -258
View File
@@ -130,31 +130,27 @@
<div id="sidebar"> <div id="sidebar">
<h1>⚡ EV GAUGE DESIGNER</h1> <h1>⚡ EV GAUGE DESIGNER</h1>
<!-- PANEL LAYOUT --> <!-- DIALS -->
<div class="section"> <div class="section">
<h2>Panel Layout</h2> <h2>Dials on this Gauge</h2>
<div class="row"> <div class="row">
<label>Layout</label> <label>Number of dials</label>
<select id="panelLayout"> <div class="mode-toggle">
<option value="single">Single gauge</option> <button class="mode-btn active" id="numDialsBtn1">1</button>
<option value="2h">2 gauges — side by side</option> <button class="mode-btn" id="numDialsBtn2">2</button>
<option value="3triangle">3 gauges — L / R / Bottom</option> <button class="mode-btn" id="numDialsBtn3">3</button>
<option value="3row">3 gauges — row</option>
</select>
</div>
<div id="gaugeTabsRow" style="display:none">
<div class="row" style="margin-top:4px">
<label>Editing gauge</label>
<div style="display:flex;gap:4px;flex:1">
<button class="mode-btn active" id="gaugeTab0">G 1</button>
<button class="mode-btn" id="gaugeTab1">G 2</button>
<button class="mode-btn" id="gaugeTab2">G 3</button>
</div>
</div> </div>
</div> </div>
<div id="panelBgRow" class="row" style="display:none"> <div id="dialTabsRow" style="display:none">
<label>Panel background</label> <div class="row" style="margin-top:4px">
<input type="color" id="panelBgColor" value="#1a1a1a"> <label>Editing dial</label>
<div style="display:flex;gap:4px;flex:1">
<button class="mode-btn active" id="dialTab0">Dial 1</button>
<button class="mode-btn" id="dialTab1">Dial 2</button>
<button class="mode-btn" id="dialTab2">Dial 3</button>
</div>
</div>
<p class="hint" style="margin-top:4px">All settings below (scale, ticks, labels, fonts, zones) apply to the selected dial. Face background, border, logo, and size are shared.</p>
</div> </div>
</div> </div>
@@ -271,6 +267,16 @@
<input type="range" id="arcRadius" min="40" max="95" value="80"> <input type="range" id="arcRadius" min="40" max="95" value="80">
<span class="val" id="arcRadiusVal">80%</span> <span class="val" id="arcRadiusVal">80%</span>
</div> </div>
<div class="row">
<label>Center X offset %</label>
<input type="range" id="dialOffsetX" min="-80" max="80" value="0">
<span class="val" id="dialOffsetXVal">0%</span>
</div>
<div class="row">
<label>Center Y offset %</label>
<input type="range" id="dialOffsetY" min="-80" max="80" value="0">
<span class="val" id="dialOffsetYVal">0%</span>
</div>
</div> </div>
<!-- TICKS --> <!-- TICKS -->
@@ -554,6 +560,8 @@ function bindRange(id, valId, suffix = '%') {
} }
bindRange('arcRadius', 'arcRadiusVal'); bindRange('arcRadius', 'arcRadiusVal');
bindRange('dialOffsetX', 'dialOffsetXVal');
bindRange('dialOffsetY', 'dialOffsetYVal');
bindRange('majorLen', 'majorLenVal'); bindRange('majorLen', 'majorLenVal');
bindRange('minorLen', 'minorLenVal'); bindRange('minorLen', 'minorLenVal');
bindRange('numOffset', 'numOffsetVal'); bindRange('numOffset', 'numOffsetVal');
@@ -711,25 +719,27 @@ dz.addEventListener('drop', e => {
$('clearLogoBtn').addEventListener('click', clearLogo); $('clearLogoBtn').addEventListener('click', clearLogo);
// ── multi-gauge state ───────────────────────────────────────────────────────── // ── multi-dial state ─────────────────────────────────────────────────────────
let numGauges = 1; // Inputs that belong to the face (shared across all dials)
let gaugeLayout = 'single'; const FACE_INPUTS = new Set([
let activeGaugeIdx = 0; 'diameter','diamUnit','printDpi','previewScale',
const gaugeStates = [null, null, null]; 'faceColor','faceGradient','gradientColor',
'borderColor','borderWidth',
'centerDotColor','centerDotSize',
'logoX','logoY','logoW','logoOpacity','logoBlend'
]);
function buildConfigFromState(s) { let numDials = 1;
let activeDialIdx = 0;
const dialStates = [null, null, null];
function buildDialConfig(s) {
if (!s) return null; if (!s) return null;
const diamVal = parseFloat(s.diameter) || 3.5;
const diamUnit = s.diamUnit || 'in';
const diamIn = diamUnit === 'mm' ? diamVal / 25.4 : diamVal;
const dpi = parseInt(s.printDpi) || 300;
const px = Math.round(diamIn * dpi);
return { return {
diamIn, dpi, px,
mode: s.gaugeMode || 'standard', mode: s.gaugeMode || 'standard',
sweepDir: s.sweepDir || 'cw',
startAngle: parseFloat(s.startAngle) || 0, startAngle: parseFloat(s.startAngle) || 0,
sweepAngle: parseFloat(s.sweepAngle) || 270, sweepAngle: parseFloat(s.sweepAngle) || 270,
sweepDir: s.sweepDir || 'cw',
scaleMin: parseFloat(s.scaleMin) || 0, scaleMin: parseFloat(s.scaleMin) || 0,
scaleMax: parseFloat(s.scaleMax) || 100, scaleMax: parseFloat(s.scaleMax) || 100,
centerAngle: parseFloat(s.centerAngle) || 0, centerAngle: parseFloat(s.centerAngle) || 0,
@@ -762,43 +772,32 @@ function buildConfigFromState(s) {
subtitleY: parseFloat(s.subtitleY) / 100, subtitleY: parseFloat(s.subtitleY) / 100,
subtitleSize: parseFloat(s.subtitleSize) / 100, subtitleSize: parseFloat(s.subtitleSize) / 100,
labelColor: s.labelColor || '#ffffff', labelColor: s.labelColor || '#ffffff',
faceColor: s.faceColor || '#000000',
faceGradient: s.faceGradient === true || s.faceGradient === 'true',
gradientColor: s.gradientColor || '#1a1a3e',
borderColor: s.borderColor || '#888888',
borderWidth: parseFloat(s.borderWidth) / 100,
centerDotColor: s.centerDotColor || '#e94560',
centerDotSize: parseFloat(s.centerDotSize) / 100,
zones: s.zones || [], zones: s.zones || [],
logoImg: s._logoImg || null, offsetX: parseFloat(s.dialOffsetX) / 100 || 0,
logoX: parseFloat(s.logoX) / 100, offsetY: parseFloat(s.dialOffsetY) / 100 || 0
logoY: parseFloat(s.logoY) / 100,
logoW: parseFloat(s.logoW) / 100,
logoOpacity: parseFloat(s.logoOpacity) / 100,
logoBlend: s.logoBlend || 'source-over'
}; };
} }
function captureGaugeState(idx) { function captureDialState(idx) {
const state = { gaugeMode, sweepDir, zones: JSON.parse(JSON.stringify(zones)), _logoImg: logoImg }; const state = { gaugeMode, sweepDir, zones: JSON.parse(JSON.stringify(zones)) };
document.querySelectorAll('#sidebar input, #sidebar select').forEach(el => { document.querySelectorAll('#sidebar input, #sidebar select').forEach(el => {
if (!el.id || el.type === 'file') return; if (!el.id || el.type === 'file' || FACE_INPUTS.has(el.id)) return;
state[el.id] = el.type === 'checkbox' ? el.checked : el.value; state[el.id] = el.type === 'checkbox' ? el.checked : el.value;
}); });
if (logoImg) { state.logoDataUrl = logoImg.src; state.logoFileName = $('logoFileName').textContent; } dialStates[idx] = state;
else { state.logoDataUrl = null; state.logoFileName = ''; }
gaugeStates[idx] = state;
} }
function restoreGaugeState(idx) { function restoreDialState(idx) {
const data = gaugeStates[idx]; const data = dialStates[idx];
if (!data) return; if (!data) return;
document.querySelectorAll('#sidebar input, #sidebar select').forEach(el => { document.querySelectorAll('#sidebar input, #sidebar select').forEach(el => {
if (!el.id || el.type === 'file' || !(el.id in data)) return; if (!el.id || el.type === 'file' || FACE_INPUTS.has(el.id) || !(el.id in data)) return;
if (el.type === 'checkbox') el.checked = data[el.id]; if (el.type === 'checkbox') el.checked = data[el.id];
else el.value = data[el.id]; else el.value = data[el.id];
}); });
document.querySelectorAll('#sidebar input[type=range]').forEach(el => el.dispatchEvent(new Event('input'))); document.querySelectorAll('#sidebar input[type=range]').forEach(el => {
if (!FACE_INPUTS.has(el.id)) el.dispatchEvent(new Event('input'));
});
gaugeMode = data.gaugeMode || 'standard'; gaugeMode = data.gaugeMode || 'standard';
if (gaugeMode === 'center') { if (gaugeMode === 'center') {
$('modeCtr').classList.add('active'); $('modeStd').classList.remove('active'); $('modeCtr').classList.add('active'); $('modeStd').classList.remove('active');
@@ -819,59 +818,28 @@ function restoreGaugeState(idx) {
} }
zones = data.zones || []; zones = data.zones || [];
renderZones(); renderZones();
logoImg = data._logoImg || null;
if (logoImg) setLogo(logoImg, data.logoFileName || 'logo', true);
else clearLogo(true);
} }
function switchGauge(newIdx) { function switchDial(newIdx) {
captureGaugeState(activeGaugeIdx); captureDialState(activeDialIdx);
activeGaugeIdx = newIdx; activeDialIdx = newIdx;
restoreGaugeState(activeGaugeIdx); restoreDialState(activeDialIdx);
updateGaugeTabs(); updateDialTabs();
drawGauge(); drawGauge();
} }
function updateGaugeTabs() { function updateDialTabs() {
[0, 1, 2].forEach(i => { [0, 1, 2].forEach(i => {
const tab = $(`gaugeTab${i}`); const tab = $(`dialTab${i}`);
if (!tab) return; if (!tab) return;
tab.classList.toggle('active', i === activeGaugeIdx); tab.classList.toggle('active', i === activeDialIdx);
tab.style.display = i < numGauges ? '' : 'none'; tab.style.display = i < numDials ? '' : 'none';
}); });
} }
function updatePanelUI() { function updateDialUI() {
const multi = numGauges > 1; $('dialTabsRow').style.display = numDials > 1 ? '' : 'none';
$('gaugeTabsRow').style.display = multi ? '' : 'none'; updateDialTabs();
$('panelBgRow').style.display = multi ? '' : 'none';
updateGaugeTabs();
}
function computeLayout(px, layout, scale) {
const r = Math.round(px * scale / 2);
const pad = Math.max(4, Math.round(r * 0.08));
switch (layout) {
case '2h':
return { w: r * 4 + pad, h: r * 2,
pos: [{ cx: r, cy: r }, { cx: r * 3 + pad, cy: r }] };
case '3triangle':
return { w: r * 4 + pad, h: r * 4 + pad,
pos: [
{ cx: r, cy: r },
{ cx: r * 3 + pad, cy: r },
{ cx: r * 2 + Math.round(pad / 2), cy: r * 3 + pad }
]};
case '3row':
return { w: r * 6 + pad * 2, h: r * 2,
pos: [
{ cx: r, cy: r },
{ cx: r * 3 + pad, cy: r },
{ cx: r * 5 + pad * 2, cy: r }
]};
default:
return { w: r * 2, h: r * 2, pos: [{ cx: r, cy: r }] };
}
} }
// ── getConfig ───────────────────────────────────────────────────────────────── // ── getConfig ─────────────────────────────────────────────────────────────────
@@ -1080,132 +1048,112 @@ function renderCenterSide(ctx, cfg, cx, cy, r, scalePx, side) {
} }
// ── main render ─────────────────────────────────────────────────────────────── // ── main render ───────────────────────────────────────────────────────────────
function renderGaugeFace(ctx, cfg, cx, cy, r) { function drawFaceBackground(ctx, faceCfg, cx, cy, r) {
const d = r * 2;
const scalePx = d / cfg.px;
// face fill
ctx.save(); ctx.save();
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip(); ctx.clip();
if (cfg.faceGradient) { if (faceCfg.faceGradient) {
const grad = ctx.createRadialGradient(cx, cy - r * 0.4, 0, cx, cy, r); const grad = ctx.createRadialGradient(cx, cy - r * 0.4, 0, cx, cy, r);
grad.addColorStop(0, cfg.gradientColor); grad.addColorStop(0, faceCfg.gradientColor);
grad.addColorStop(1, cfg.faceColor); grad.addColorStop(1, faceCfg.faceColor);
ctx.fillStyle = grad; ctx.fillStyle = grad;
} else { } else {
ctx.fillStyle = cfg.faceColor; ctx.fillStyle = faceCfg.faceColor;
} }
ctx.fillRect(cx - r, cy - r, d, d); ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
ctx.restore(); ctx.restore();
if (faceCfg.borderWidth > 0) {
// border ring const bw = r * faceCfg.borderWidth;
if (cfg.borderWidth > 0) {
const bw = r * cfg.borderWidth;
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, r - bw / 2, 0, Math.PI * 2); ctx.arc(cx, cy, r - bw / 2, 0, Math.PI * 2);
ctx.strokeStyle = cfg.borderColor; ctx.strokeStyle = faceCfg.borderColor;
ctx.lineWidth = bw; ctx.lineWidth = bw;
ctx.stroke(); ctx.stroke();
} }
}
// scale + ticks function drawDialOnFace(ctx, dialCfg, cx, cy, r, scalePx) {
if (cfg.mode === 'center') { if (dialCfg.mode === 'center') {
renderCenterSide(ctx, cfg, cx, cy, r, scalePx, 'left'); renderCenterSide(ctx, dialCfg, cx, cy, r, scalePx, 'left');
renderCenterSide(ctx, cfg, cx, cy, r, scalePx, 'right'); renderCenterSide(ctx, dialCfg, cx, cy, r, scalePx, 'right');
} else { } else {
renderStandard(ctx, cfg, cx, cy, r, scalePx); renderStandard(ctx, dialCfg, cx, cy, r, scalePx);
} }
if (dialCfg.gaugeTitle) {
// gauge title const fontSize = Math.max(1, Math.round(r * dialCfg.titleSize));
if (cfg.gaugeTitle) { ctx.fillStyle = dialCfg.labelColor;
const fontSize = Math.max(1, Math.round(r * cfg.titleSize)); ctx.font = `${dialCfg.labelWeight} ${fontSize}px "${dialCfg.labelFont}"`;
ctx.fillStyle = cfg.labelColor;
ctx.font = `${cfg.labelWeight} ${fontSize}px "${cfg.labelFont}"`;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.fillText(cfg.gaugeTitle, cx, cy + r * (cfg.titleY - 0.5) * 2); ctx.fillText(dialCfg.gaugeTitle, cx, cy + r * (dialCfg.titleY - 0.5) * 2);
} }
if (dialCfg.gaugeSubtitle) {
// subtitle const fontSize = Math.max(1, Math.round(r * dialCfg.subtitleSize));
if (cfg.gaugeSubtitle) { ctx.fillStyle = dialCfg.labelColor;
const fontSize = Math.max(1, Math.round(r * cfg.subtitleSize)); ctx.font = `${dialCfg.labelWeight} ${fontSize}px "${dialCfg.labelFont}"`;
ctx.fillStyle = cfg.labelColor;
ctx.font = `${cfg.labelWeight} ${fontSize}px "${cfg.labelFont}"`;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.fillText(cfg.gaugeSubtitle, cx, cy + r * (cfg.subtitleY - 0.5) * 2); ctx.fillText(dialCfg.gaugeSubtitle, cx, cy + r * (dialCfg.subtitleY - 0.5) * 2);
} }
}
// logo function drawFaceOverlay(ctx, faceCfg, cx, cy, r) {
if (cfg.logoImg) { if (faceCfg.logoImg) {
const lw = d * cfg.logoW; const d = r * 2;
const lh = lw * (cfg.logoImg.naturalHeight / cfg.logoImg.naturalWidth); const lw = d * faceCfg.logoW;
const lx = (cx - r) + d * cfg.logoX - lw / 2; const lh = lw * (faceCfg.logoImg.naturalHeight / faceCfg.logoImg.naturalWidth);
const ly = (cy - r) + d * cfg.logoY - lh / 2; const lx = (cx - r) + d * faceCfg.logoX - lw / 2;
const ly = (cy - r) + d * faceCfg.logoY - lh / 2;
ctx.save(); ctx.save();
ctx.globalAlpha = cfg.logoOpacity; ctx.globalAlpha = faceCfg.logoOpacity;
ctx.globalCompositeOperation = cfg.logoBlend; ctx.globalCompositeOperation = faceCfg.logoBlend;
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip(); ctx.clip();
ctx.drawImage(cfg.logoImg, lx, ly, lw, lh); ctx.drawImage(faceCfg.logoImg, lx, ly, lw, lh);
ctx.restore(); ctx.restore();
} }
if (faceCfg.centerDotSize > 0) {
// center dot
if (cfg.centerDotSize > 0) {
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, r * cfg.centerDotSize, 0, Math.PI * 2); ctx.arc(cx, cy, r * faceCfg.centerDotSize, 0, Math.PI * 2);
ctx.fillStyle = cfg.centerDotColor; ctx.fillStyle = faceCfg.centerDotColor;
ctx.fill(); ctx.fill();
} }
} }
function renderGaugeToCanvas(canvas, cfg, scale = 1) { function renderToCanvas(canvas, faceCfg, dialCfgArray, scale) {
const size = Math.round(cfg.px * scale); const size = Math.round(faceCfg.px * scale);
canvas.width = size; canvas.width = size;
canvas.height = size; canvas.height = size;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, size, size); ctx.clearRect(0, 0, size, size);
renderGaugeFace(ctx, cfg, size / 2, size / 2, size / 2); const cx = size / 2, cy = size / 2, r = size / 2;
} const scalePx = scale;
drawFaceBackground(ctx, faceCfg, cx, cy, r);
function renderPanelToCanvas(canvas, scale = 1) { dialCfgArray.forEach(dc => {
captureGaugeState(activeGaugeIdx); if (!dc) return;
if (numGauges === 1) { const dcx = cx + r * dc.offsetX;
renderGaugeToCanvas(canvas, buildConfigFromState(gaugeStates[0]), scale); const dcy = cy + r * dc.offsetY;
return; ctx.save();
} ctx.beginPath();
const cfg0 = buildConfigFromState(gaugeStates[0]); ctx.arc(cx, cy, r, 0, Math.PI * 2);
const layout = computeLayout(cfg0.px, gaugeLayout, scale); ctx.clip();
canvas.width = layout.w; drawDialOnFace(ctx, dc, dcx, dcy, r, scalePx);
canvas.height = layout.h; ctx.restore();
const ctx = canvas.getContext('2d'); });
ctx.fillStyle = $('panelBgColor').value; drawFaceOverlay(ctx, faceCfg, cx, cy, r);
ctx.fillRect(0, 0, layout.w, layout.h);
const r = Math.round(cfg0.px * scale / 2);
for (let i = 0; i < numGauges; i++) {
const cfg = buildConfigFromState(gaugeStates[i]);
if (!cfg) continue;
renderGaugeFace(ctx, cfg, layout.pos[i].cx, layout.pos[i].cy, r);
}
} }
function drawGauge() { function drawGauge() {
captureDialState(activeDialIdx);
const faceCfg = getConfig(); // reads face inputs from DOM
const scaleF = parseInt($('previewScale').value) / 100; const scaleF = parseInt($('previewScale').value) / 100;
renderPanelToCanvas($('gaugeCanvas'), scaleF); const dialCfgs = dialStates.slice(0, numDials).map(buildDialConfig);
const cfg0 = buildConfigFromState(gaugeStates[0]); renderToCanvas($('gaugeCanvas'), faceCfg, dialCfgs, scaleF);
if (!cfg0) return; const displayPx = Math.round(faceCfg.px * scaleF);
if (numGauges === 1) { $('info').textContent =
const displayPx = Math.round(cfg0.px * scaleF); `Preview: ${displayPx}×${displayPx}px | Print size: ${faceCfg.diamIn.toFixed(3)}" (${(faceCfg.diamIn*25.4).toFixed(1)}mm) | Full res: ${faceCfg.px}×${faceCfg.px}px @ ${faceCfg.dpi}dpi`;
$('info').textContent =
`Preview: ${displayPx}×${displayPx}px | Print size: ${cfg0.diamIn.toFixed(3)}" (${(cfg0.diamIn*25.4).toFixed(1)}mm) | Full res: ${cfg0.px}×${cfg0.px}px @ ${cfg0.dpi}dpi`;
} else {
$('info').textContent =
`${numGauges} gauges — each ${cfg0.diamIn.toFixed(3)}" dia (${(cfg0.diamIn*25.4).toFixed(1)}mm) @ ${cfg0.dpi}dpi`;
}
} }
// listen to all sidebar inputs // listen to all sidebar inputs
@@ -1214,78 +1162,77 @@ document.querySelectorAll('#sidebar input, #sidebar select').forEach(el => {
el.addEventListener('change', drawGauge); el.addEventListener('change', drawGauge);
}); });
// panel layout + gauge tabs // dial count buttons + dial tabs
$('panelLayout').addEventListener('change', () => { [1, 2, 3].forEach(n => {
captureGaugeState(activeGaugeIdx); $(`numDialsBtn${n}`).addEventListener('click', () => {
gaugeLayout = $('panelLayout').value; captureDialState(activeDialIdx);
numGauges = gaugeLayout === 'single' ? 1 : gaugeLayout === '2h' ? 2 : 3; numDials = n;
if (activeGaugeIdx >= numGauges) { [1,2,3].forEach(i => $(`numDialsBtn${i}`).classList.toggle('active', i === n));
activeGaugeIdx = 0; if (activeDialIdx >= numDials) {
restoreGaugeState(0); activeDialIdx = 0;
} restoreDialState(0);
updatePanelUI(); }
drawGauge(); updateDialUI();
drawGauge();
});
}); });
[0, 1, 2].forEach(i => { [0, 1, 2].forEach(i => {
$(`gaugeTab${i}`).addEventListener('click', () => switchGauge(i)); $(`dialTab${i}`).addEventListener('click', () => switchDial(i));
}); });
// ── export PDF ──────────────────────────────────────────────────────────────── // ── export PDF ────────────────────────────────────────────────────────────────
$('exportPdfBtn').addEventListener('click', () => { $('exportPdfBtn').addEventListener('click', () => {
captureGaugeState(activeGaugeIdx); captureDialState(activeDialIdx);
const cfg0 = buildConfigFromState(gaugeStates[0]); const faceCfg = getConfig();
const dialCfgs = dialStates.slice(0, numDials).map(buildDialConfig);
const fullCanvas = document.createElement('canvas'); const fullCanvas = document.createElement('canvas');
renderPanelToCanvas(fullCanvas, 1); renderToCanvas(fullCanvas, faceCfg, dialCfgs, 1);
const imgData = fullCanvas.toDataURL('image/png'); const imgData = fullCanvas.toDataURL('image/png');
const dIn = cfg0.diamIn; const dIn = faceCfg.diamIn;
let pdfW, pdfH; const pdfSz = dIn + 0.5;
if (numGauges === 1) { const pdf = new jsPDF({ orientation: 'portrait', unit: 'in', format: [pdfSz, pdfSz] });
pdfW = dIn + 0.5; pdfH = dIn + 0.5; pdf.addImage(imgData, 'PNG', 0.25, 0.25, dIn, dIn);
} else {
const lay = computeLayout(cfg0.px, gaugeLayout, 1);
pdfW = (lay.w / cfg0.px) * dIn + 0.5;
pdfH = (lay.h / cfg0.px) * dIn + 0.5;
}
const pdf = new jsPDF({ orientation: pdfW >= pdfH ? 'landscape' : 'portrait', unit: 'in', format: [pdfW, pdfH] });
pdf.addImage(imgData, 'PNG', 0.25, 0.25, pdfW - 0.5, pdfH - 0.5);
pdf.setDrawColor(180, 180, 180); pdf.setDrawColor(180, 180, 180);
pdf.setLineWidth(0.01); pdf.setLineWidth(0.01);
const m = 0.05, bl = 0.15; const m = 0.05, bl = 0.15;
[[m,m],[pdfW-m,m],[m,pdfH-m],[pdfW-m,pdfH-m]].forEach(([x,y]) => { [[m,m],[pdfSz-m,m],[m,pdfSz-m],[pdfSz-m,pdfSz-m]].forEach(([x,y]) => {
pdf.line(x-bl, y, x+bl, y); pdf.line(x-bl, y, x+bl, y);
pdf.line(x, y-bl, x, y+bl); pdf.line(x, y-bl, x, y+bl);
}); });
pdf.save(`${cfg0.gaugeTitle || 'gauge-panel'}-${dIn.toFixed(2)}in.pdf`); pdf.save(`${faceCfg.gaugeTitle || 'gauge'}-${dIn.toFixed(2)}in.pdf`);
}); });
$('exportPngBtn').addEventListener('click', () => { $('exportPngBtn').addEventListener('click', () => {
captureGaugeState(activeGaugeIdx); captureDialState(activeDialIdx);
const cfg0 = buildConfigFromState(gaugeStates[0]); const faceCfg = getConfig();
const dialCfgs = dialStates.slice(0, numDials).map(buildDialConfig);
const fullCanvas = document.createElement('canvas'); const fullCanvas = document.createElement('canvas');
renderPanelToCanvas(fullCanvas, 1); renderToCanvas(fullCanvas, faceCfg, dialCfgs, 1);
const link = document.createElement('a'); const link = document.createElement('a');
link.download = `${cfg0.gaugeTitle || 'gauge'}-panel.png`; link.download = `${faceCfg.gaugeTitle || 'gauge'}-preview.png`;
link.href = fullCanvas.toDataURL('image/png'); link.href = fullCanvas.toDataURL('image/png');
link.click(); link.click();
}); });
// ── save / load design ──────────────────────────────────────────────────────── // ── save / load design ────────────────────────────────────────────────────────
$('saveDesignBtn').addEventListener('click', () => { $('saveDesignBtn').addEventListener('click', () => {
captureGaugeState(activeGaugeIdx); captureDialState(activeDialIdx);
// capture face inputs separately
const faceInputs = {};
document.querySelectorAll('#sidebar input, #sidebar select').forEach(el => {
if (!el.id || el.type === 'file' || !FACE_INPUTS.has(el.id)) return;
faceInputs[el.id] = el.type === 'checkbox' ? el.checked : el.value;
});
const saveData = { const saveData = {
version: 2, version: 3,
numGauges, numDials,
gaugeLayout, faceInputs,
panelBgColor: $('panelBgColor').value, logoDataUrl: logoImg ? logoImg.src : null,
gaugeStates: gaugeStates.map(s => { logoFileName: logoImg ? $('logoFileName').textContent : '',
if (!s) return null; dialStates: dialStates.map(s => s || null)
const copy = Object.assign({}, s);
delete copy._logoImg;
return copy;
})
}; };
const name = (gaugeStates[0] && gaugeStates[0].gaugeTitle) || 'gauge'; const name = (dialStates[0] && dialStates[0].gaugeTitle) || 'gauge';
const blob = new Blob([JSON.stringify(saveData, null, 2)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(saveData, null, 2)], { type: 'application/json' });
const a = document.createElement('a'); const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.href = URL.createObjectURL(blob);
@@ -1310,37 +1257,55 @@ $('loadDesignInput').addEventListener('change', e => {
}); });
function applyLoadedDesign(data) { function applyLoadedDesign(data) {
if (data.version >= 2) { const restoreAndDraw = () => {
numGauges = data.numGauges || 1; activeDialIdx = 0;
gaugeLayout = data.gaugeLayout || 'single'; updateDialUI();
$('panelLayout').value = gaugeLayout; restoreDialState(0);
if (data.panelBgColor) $('panelBgColor').value = data.panelBgColor; drawGauge();
const states = data.gaugeStates || []; };
states.forEach((s, i) => { if (s) gaugeStates[i] = Object.assign({}, s, { _logoImg: null }); });
Promise.all(states.map((s, i) => { if (data.version >= 3) {
if (!s || !s.logoDataUrl) return Promise.resolve(); numDials = data.numDials || 1;
return new Promise(res => { [1,2,3].forEach(i => $(`numDialsBtn${i}`).classList.toggle('active', i === numDials));
const img = new Image(); // restore face inputs
img.onload = () => { gaugeStates[i]._logoImg = img; res(); }; if (data.faceInputs) {
img.src = s.logoDataUrl; Object.entries(data.faceInputs).forEach(([id, val]) => {
const el = $(id);
if (!el) return;
if (el.type === 'checkbox') el.checked = val;
else el.value = val;
}); });
})).then(() => { document.querySelectorAll('#sidebar input[type=range]').forEach(el => {
activeGaugeIdx = 0; if (FACE_INPUTS.has(el.id)) el.dispatchEvent(new Event('input'));
updatePanelUI(); });
restoreGaugeState(0); }
drawGauge(); (data.dialStates || []).forEach((s, i) => { dialStates[i] = s; });
});
} else {
// v1 single-gauge format (backwards compatible)
numGauges = 1; gaugeLayout = 'single';
$('panelLayout').value = 'single';
gaugeStates[0] = Object.assign({}, data, { _logoImg: null });
updatePanelUI(); activeGaugeIdx = 0;
if (data.logoDataUrl) { if (data.logoDataUrl) {
const img = new Image(); const img = new Image();
img.onload = () => { gaugeStates[0]._logoImg = img; restoreGaugeState(0); drawGauge(); }; img.onload = () => { setLogo(img, data.logoFileName || 'logo', true); restoreAndDraw(); };
img.src = data.logoDataUrl; img.src = data.logoDataUrl;
} else { restoreGaugeState(0); drawGauge(); } } else { clearLogo(true); restoreAndDraw(); }
} else {
// v1/v2: treat as single dial, restore everything to dial 0
numDials = 1;
$('numDialsBtn1').classList.add('active');
$('numDialsBtn2').classList.remove('active');
$('numDialsBtn3').classList.remove('active');
dialStates[0] = Object.assign({}, data);
// restore face inputs that may be in the old flat structure
document.querySelectorAll('#sidebar input, #sidebar select').forEach(el => {
if (!el.id || el.type === 'file' || !FACE_INPUTS.has(el.id) || !(el.id in data)) return;
if (el.type === 'checkbox') el.checked = data[el.id];
else el.value = data[el.id];
});
document.querySelectorAll('#sidebar input[type=range]').forEach(el => {
if (FACE_INPUTS.has(el.id)) el.dispatchEvent(new Event('input'));
});
if (data.logoDataUrl) {
const img = new Image();
img.onload = () => { setLogo(img, data.logoFileName || 'logo', true); restoreAndDraw(); };
img.src = data.logoDataUrl;
} else { clearLogo(true); restoreAndDraw(); }
} }
} }
@@ -1365,10 +1330,10 @@ bgCustomInput.addEventListener('input', () => {
}); });
bgCustomSwatch.addEventListener('click', () => bgCustomSwatch.classList.add('active')); bgCustomSwatch.addEventListener('click', () => bgCustomSwatch.classList.add('active'));
// initialize all 3 gauge states from current DOM defaults, then draw // initialize all 3 dial states from current DOM defaults, then draw
captureGaugeState(0); captureDialState(0);
gaugeStates[1] = Object.assign({}, gaugeStates[0], { _logoImg: null, logoDataUrl: null, logoFileName: '' }); dialStates[1] = Object.assign({}, dialStates[0]);
gaugeStates[2] = Object.assign({}, gaugeStates[0], { _logoImg: null, logoDataUrl: null, logoFileName: '' }); dialStates[2] = Object.assign({}, dialStates[0]);
drawGauge(); drawGauge();
</script> </script>
</body> </body>