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">
<h1>⚡ EV GAUGE DESIGNER</h1>
<!-- PANEL LAYOUT -->
<!-- DIALS -->
<div class="section">
<h2>Panel Layout</h2>
<h2>Dials on this Gauge</h2>
<div class="row">
<label>Layout</label>
<select id="panelLayout">
<option value="single">Single gauge</option>
<option value="2h">2 gauges — side by side</option>
<option value="3triangle">3 gauges — L / R / Bottom</option>
<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>
<label>Number of dials</label>
<div class="mode-toggle">
<button class="mode-btn active" id="numDialsBtn1">1</button>
<button class="mode-btn" id="numDialsBtn2">2</button>
<button class="mode-btn" id="numDialsBtn3">3</button>
</div>
</div>
<div id="panelBgRow" class="row" style="display:none">
<label>Panel background</label>
<input type="color" id="panelBgColor" value="#1a1a1a">
<div id="dialTabsRow" style="display:none">
<div class="row" style="margin-top:4px">
<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>
@@ -271,6 +267,16 @@
<input type="range" id="arcRadius" min="40" max="95" value="80">
<span class="val" id="arcRadiusVal">80%</span>
</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>
<!-- TICKS -->
@@ -554,6 +560,8 @@ function bindRange(id, valId, suffix = '%') {
}
bindRange('arcRadius', 'arcRadiusVal');
bindRange('dialOffsetX', 'dialOffsetXVal');
bindRange('dialOffsetY', 'dialOffsetYVal');
bindRange('majorLen', 'majorLenVal');
bindRange('minorLen', 'minorLenVal');
bindRange('numOffset', 'numOffsetVal');
@@ -711,25 +719,27 @@ dz.addEventListener('drop', e => {
$('clearLogoBtn').addEventListener('click', clearLogo);
// ── multi-gauge state ─────────────────────────────────────────────────────────
let numGauges = 1;
let gaugeLayout = 'single';
let activeGaugeIdx = 0;
const gaugeStates = [null, null, null];
// ── multi-dial state ─────────────────────────────────────────────────────────
// Inputs that belong to the face (shared across all dials)
const FACE_INPUTS = new Set([
'diameter','diamUnit','printDpi','previewScale',
'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;
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 {
diamIn, dpi, px,
mode: s.gaugeMode || 'standard',
sweepDir: s.sweepDir || 'cw',
startAngle: parseFloat(s.startAngle) || 0,
sweepAngle: parseFloat(s.sweepAngle) || 270,
sweepDir: s.sweepDir || 'cw',
scaleMin: parseFloat(s.scaleMin) || 0,
scaleMax: parseFloat(s.scaleMax) || 100,
centerAngle: parseFloat(s.centerAngle) || 0,
@@ -762,43 +772,32 @@ function buildConfigFromState(s) {
subtitleY: parseFloat(s.subtitleY) / 100,
subtitleSize: parseFloat(s.subtitleSize) / 100,
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 || [],
logoImg: s._logoImg || null,
logoX: parseFloat(s.logoX) / 100,
logoY: parseFloat(s.logoY) / 100,
logoW: parseFloat(s.logoW) / 100,
logoOpacity: parseFloat(s.logoOpacity) / 100,
logoBlend: s.logoBlend || 'source-over'
offsetX: parseFloat(s.dialOffsetX) / 100 || 0,
offsetY: parseFloat(s.dialOffsetY) / 100 || 0
};
}
function captureGaugeState(idx) {
const state = { gaugeMode, sweepDir, zones: JSON.parse(JSON.stringify(zones)), _logoImg: logoImg };
function captureDialState(idx) {
const state = { gaugeMode, sweepDir, zones: JSON.parse(JSON.stringify(zones)) };
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;
});
if (logoImg) { state.logoDataUrl = logoImg.src; state.logoFileName = $('logoFileName').textContent; }
else { state.logoDataUrl = null; state.logoFileName = ''; }
gaugeStates[idx] = state;
dialStates[idx] = state;
}
function restoreGaugeState(idx) {
const data = gaugeStates[idx];
function restoreDialState(idx) {
const data = dialStates[idx];
if (!data) return;
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];
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';
if (gaugeMode === 'center') {
$('modeCtr').classList.add('active'); $('modeStd').classList.remove('active');
@@ -819,59 +818,28 @@ function restoreGaugeState(idx) {
}
zones = data.zones || [];
renderZones();
logoImg = data._logoImg || null;
if (logoImg) setLogo(logoImg, data.logoFileName || 'logo', true);
else clearLogo(true);
}
function switchGauge(newIdx) {
captureGaugeState(activeGaugeIdx);
activeGaugeIdx = newIdx;
restoreGaugeState(activeGaugeIdx);
updateGaugeTabs();
function switchDial(newIdx) {
captureDialState(activeDialIdx);
activeDialIdx = newIdx;
restoreDialState(activeDialIdx);
updateDialTabs();
drawGauge();
}
function updateGaugeTabs() {
function updateDialTabs() {
[0, 1, 2].forEach(i => {
const tab = $(`gaugeTab${i}`);
const tab = $(`dialTab${i}`);
if (!tab) return;
tab.classList.toggle('active', i === activeGaugeIdx);
tab.style.display = i < numGauges ? '' : 'none';
tab.classList.toggle('active', i === activeDialIdx);
tab.style.display = i < numDials ? '' : 'none';
});
}
function updatePanelUI() {
const multi = numGauges > 1;
$('gaugeTabsRow').style.display = multi ? '' : 'none';
$('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 }] };
}
function updateDialUI() {
$('dialTabsRow').style.display = numDials > 1 ? '' : 'none';
updateDialTabs();
}
// ── getConfig ─────────────────────────────────────────────────────────────────
@@ -1080,132 +1048,112 @@ function renderCenterSide(ctx, cfg, cx, cy, r, scalePx, side) {
}
// ── main render ───────────────────────────────────────────────────────────────
function renderGaugeFace(ctx, cfg, cx, cy, r) {
const d = r * 2;
const scalePx = d / cfg.px;
// face fill
function drawFaceBackground(ctx, faceCfg, cx, cy, r) {
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
if (cfg.faceGradient) {
if (faceCfg.faceGradient) {
const grad = ctx.createRadialGradient(cx, cy - r * 0.4, 0, cx, cy, r);
grad.addColorStop(0, cfg.gradientColor);
grad.addColorStop(1, cfg.faceColor);
grad.addColorStop(0, faceCfg.gradientColor);
grad.addColorStop(1, faceCfg.faceColor);
ctx.fillStyle = grad;
} 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();
// border ring
if (cfg.borderWidth > 0) {
const bw = r * cfg.borderWidth;
if (faceCfg.borderWidth > 0) {
const bw = r * faceCfg.borderWidth;
ctx.beginPath();
ctx.arc(cx, cy, r - bw / 2, 0, Math.PI * 2);
ctx.strokeStyle = cfg.borderColor;
ctx.strokeStyle = faceCfg.borderColor;
ctx.lineWidth = bw;
ctx.stroke();
}
}
// scale + ticks
if (cfg.mode === 'center') {
renderCenterSide(ctx, cfg, cx, cy, r, scalePx, 'left');
renderCenterSide(ctx, cfg, cx, cy, r, scalePx, 'right');
function drawDialOnFace(ctx, dialCfg, cx, cy, r, scalePx) {
if (dialCfg.mode === 'center') {
renderCenterSide(ctx, dialCfg, cx, cy, r, scalePx, 'left');
renderCenterSide(ctx, dialCfg, cx, cy, r, scalePx, 'right');
} else {
renderStandard(ctx, cfg, cx, cy, r, scalePx);
renderStandard(ctx, dialCfg, cx, cy, r, scalePx);
}
// gauge title
if (cfg.gaugeTitle) {
const fontSize = Math.max(1, Math.round(r * cfg.titleSize));
ctx.fillStyle = cfg.labelColor;
ctx.font = `${cfg.labelWeight} ${fontSize}px "${cfg.labelFont}"`;
if (dialCfg.gaugeTitle) {
const fontSize = Math.max(1, Math.round(r * dialCfg.titleSize));
ctx.fillStyle = dialCfg.labelColor;
ctx.font = `${dialCfg.labelWeight} ${fontSize}px "${dialCfg.labelFont}"`;
ctx.textAlign = 'center';
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);
}
// subtitle
if (cfg.gaugeSubtitle) {
const fontSize = Math.max(1, Math.round(r * cfg.subtitleSize));
ctx.fillStyle = cfg.labelColor;
ctx.font = `${cfg.labelWeight} ${fontSize}px "${cfg.labelFont}"`;
if (dialCfg.gaugeSubtitle) {
const fontSize = Math.max(1, Math.round(r * dialCfg.subtitleSize));
ctx.fillStyle = dialCfg.labelColor;
ctx.font = `${dialCfg.labelWeight} ${fontSize}px "${dialCfg.labelFont}"`;
ctx.textAlign = 'center';
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
if (cfg.logoImg) {
const lw = d * cfg.logoW;
const lh = lw * (cfg.logoImg.naturalHeight / cfg.logoImg.naturalWidth);
const lx = (cx - r) + d * cfg.logoX - lw / 2;
const ly = (cy - r) + d * cfg.logoY - lh / 2;
function drawFaceOverlay(ctx, faceCfg, cx, cy, r) {
if (faceCfg.logoImg) {
const d = r * 2;
const lw = d * faceCfg.logoW;
const lh = lw * (faceCfg.logoImg.naturalHeight / faceCfg.logoImg.naturalWidth);
const lx = (cx - r) + d * faceCfg.logoX - lw / 2;
const ly = (cy - r) + d * faceCfg.logoY - lh / 2;
ctx.save();
ctx.globalAlpha = cfg.logoOpacity;
ctx.globalCompositeOperation = cfg.logoBlend;
ctx.globalAlpha = faceCfg.logoOpacity;
ctx.globalCompositeOperation = faceCfg.logoBlend;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(cfg.logoImg, lx, ly, lw, lh);
ctx.drawImage(faceCfg.logoImg, lx, ly, lw, lh);
ctx.restore();
}
// center dot
if (cfg.centerDotSize > 0) {
if (faceCfg.centerDotSize > 0) {
ctx.beginPath();
ctx.arc(cx, cy, r * cfg.centerDotSize, 0, Math.PI * 2);
ctx.fillStyle = cfg.centerDotColor;
ctx.arc(cx, cy, r * faceCfg.centerDotSize, 0, Math.PI * 2);
ctx.fillStyle = faceCfg.centerDotColor;
ctx.fill();
}
}
function renderGaugeToCanvas(canvas, cfg, scale = 1) {
const size = Math.round(cfg.px * scale);
function renderToCanvas(canvas, faceCfg, dialCfgArray, scale) {
const size = Math.round(faceCfg.px * scale);
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, size, size);
renderGaugeFace(ctx, cfg, size / 2, size / 2, size / 2);
}
function renderPanelToCanvas(canvas, scale = 1) {
captureGaugeState(activeGaugeIdx);
if (numGauges === 1) {
renderGaugeToCanvas(canvas, buildConfigFromState(gaugeStates[0]), scale);
return;
}
const cfg0 = buildConfigFromState(gaugeStates[0]);
const layout = computeLayout(cfg0.px, gaugeLayout, scale);
canvas.width = layout.w;
canvas.height = layout.h;
const ctx = canvas.getContext('2d');
ctx.fillStyle = $('panelBgColor').value;
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);
}
const cx = size / 2, cy = size / 2, r = size / 2;
const scalePx = scale;
drawFaceBackground(ctx, faceCfg, cx, cy, r);
dialCfgArray.forEach(dc => {
if (!dc) return;
const dcx = cx + r * dc.offsetX;
const dcy = cy + r * dc.offsetY;
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
drawDialOnFace(ctx, dc, dcx, dcy, r, scalePx);
ctx.restore();
});
drawFaceOverlay(ctx, faceCfg, cx, cy, r);
}
function drawGauge() {
captureDialState(activeDialIdx);
const faceCfg = getConfig(); // reads face inputs from DOM
const scaleF = parseInt($('previewScale').value) / 100;
renderPanelToCanvas($('gaugeCanvas'), scaleF);
const cfg0 = buildConfigFromState(gaugeStates[0]);
if (!cfg0) return;
if (numGauges === 1) {
const displayPx = Math.round(cfg0.px * scaleF);
$('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`;
}
const dialCfgs = dialStates.slice(0, numDials).map(buildDialConfig);
renderToCanvas($('gaugeCanvas'), faceCfg, dialCfgs, scaleF);
const displayPx = Math.round(faceCfg.px * scaleF);
$('info').textContent =
`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`;
}
// listen to all sidebar inputs
@@ -1214,78 +1162,77 @@ document.querySelectorAll('#sidebar input, #sidebar select').forEach(el => {
el.addEventListener('change', drawGauge);
});
// panel layout + gauge tabs
$('panelLayout').addEventListener('change', () => {
captureGaugeState(activeGaugeIdx);
gaugeLayout = $('panelLayout').value;
numGauges = gaugeLayout === 'single' ? 1 : gaugeLayout === '2h' ? 2 : 3;
if (activeGaugeIdx >= numGauges) {
activeGaugeIdx = 0;
restoreGaugeState(0);
}
updatePanelUI();
drawGauge();
// dial count buttons + dial tabs
[1, 2, 3].forEach(n => {
$(`numDialsBtn${n}`).addEventListener('click', () => {
captureDialState(activeDialIdx);
numDials = n;
[1,2,3].forEach(i => $(`numDialsBtn${i}`).classList.toggle('active', i === n));
if (activeDialIdx >= numDials) {
activeDialIdx = 0;
restoreDialState(0);
}
updateDialUI();
drawGauge();
});
});
[0, 1, 2].forEach(i => {
$(`gaugeTab${i}`).addEventListener('click', () => switchGauge(i));
$(`dialTab${i}`).addEventListener('click', () => switchDial(i));
});
// ── export PDF ────────────────────────────────────────────────────────────────
$('exportPdfBtn').addEventListener('click', () => {
captureGaugeState(activeGaugeIdx);
const cfg0 = buildConfigFromState(gaugeStates[0]);
captureDialState(activeDialIdx);
const faceCfg = getConfig();
const dialCfgs = dialStates.slice(0, numDials).map(buildDialConfig);
const fullCanvas = document.createElement('canvas');
renderPanelToCanvas(fullCanvas, 1);
renderToCanvas(fullCanvas, faceCfg, dialCfgs, 1);
const imgData = fullCanvas.toDataURL('image/png');
const dIn = cfg0.diamIn;
let pdfW, pdfH;
if (numGauges === 1) {
pdfW = dIn + 0.5; pdfH = dIn + 0.5;
} 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);
const dIn = faceCfg.diamIn;
const pdfSz = dIn + 0.5;
const pdf = new jsPDF({ orientation: 'portrait', unit: 'in', format: [pdfSz, pdfSz] });
pdf.addImage(imgData, 'PNG', 0.25, 0.25, dIn, dIn);
pdf.setDrawColor(180, 180, 180);
pdf.setLineWidth(0.01);
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, 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', () => {
captureGaugeState(activeGaugeIdx);
const cfg0 = buildConfigFromState(gaugeStates[0]);
captureDialState(activeDialIdx);
const faceCfg = getConfig();
const dialCfgs = dialStates.slice(0, numDials).map(buildDialConfig);
const fullCanvas = document.createElement('canvas');
renderPanelToCanvas(fullCanvas, 1);
renderToCanvas(fullCanvas, faceCfg, dialCfgs, 1);
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.click();
});
// ── save / load design ────────────────────────────────────────────────────────
$('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 = {
version: 2,
numGauges,
gaugeLayout,
panelBgColor: $('panelBgColor').value,
gaugeStates: gaugeStates.map(s => {
if (!s) return null;
const copy = Object.assign({}, s);
delete copy._logoImg;
return copy;
})
version: 3,
numDials,
faceInputs,
logoDataUrl: logoImg ? logoImg.src : null,
logoFileName: logoImg ? $('logoFileName').textContent : '',
dialStates: dialStates.map(s => s || null)
};
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 a = document.createElement('a');
a.href = URL.createObjectURL(blob);
@@ -1310,37 +1257,55 @@ $('loadDesignInput').addEventListener('change', e => {
});
function applyLoadedDesign(data) {
if (data.version >= 2) {
numGauges = data.numGauges || 1;
gaugeLayout = data.gaugeLayout || 'single';
$('panelLayout').value = gaugeLayout;
if (data.panelBgColor) $('panelBgColor').value = data.panelBgColor;
const states = data.gaugeStates || [];
states.forEach((s, i) => { if (s) gaugeStates[i] = Object.assign({}, s, { _logoImg: null }); });
Promise.all(states.map((s, i) => {
if (!s || !s.logoDataUrl) return Promise.resolve();
return new Promise(res => {
const img = new Image();
img.onload = () => { gaugeStates[i]._logoImg = img; res(); };
img.src = s.logoDataUrl;
const restoreAndDraw = () => {
activeDialIdx = 0;
updateDialUI();
restoreDialState(0);
drawGauge();
};
if (data.version >= 3) {
numDials = data.numDials || 1;
[1,2,3].forEach(i => $(`numDialsBtn${i}`).classList.toggle('active', i === numDials));
// restore face inputs
if (data.faceInputs) {
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(() => {
activeGaugeIdx = 0;
updatePanelUI();
restoreGaugeState(0);
drawGauge();
});
} else {
// v1 single-gauge format (backwards compatible)
numGauges = 1; gaugeLayout = 'single';
$('panelLayout').value = 'single';
gaugeStates[0] = Object.assign({}, data, { _logoImg: null });
updatePanelUI(); activeGaugeIdx = 0;
document.querySelectorAll('#sidebar input[type=range]').forEach(el => {
if (FACE_INPUTS.has(el.id)) el.dispatchEvent(new Event('input'));
});
}
(data.dialStates || []).forEach((s, i) => { dialStates[i] = s; });
if (data.logoDataUrl) {
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;
} 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'));
// initialize all 3 gauge states from current DOM defaults, then draw
captureGaugeState(0);
gaugeStates[1] = Object.assign({}, gaugeStates[0], { _logoImg: null, logoDataUrl: null, logoFileName: '' });
gaugeStates[2] = Object.assign({}, gaugeStates[0], { _logoImg: null, logoDataUrl: null, logoFileName: '' });
// initialize all 3 dial states from current DOM defaults, then draw
captureDialState(0);
dialStates[1] = Object.assign({}, dialStates[0]);
dialStates[2] = Object.assign({}, dialStates[0]);
drawGauge();
</script>
</body>