Add multi-gauge panel layouts (2 side-by-side, 3 triangle, 3 row)

This commit is contained in:
2026-04-25 23:17:02 -04:00
parent 60efc1c12e
commit c239fb5213
+340 -95
View File
@@ -130,6 +130,34 @@
<div id="sidebar">
<h1>⚡ EV GAUGE DESIGNER</h1>
<!-- PANEL LAYOUT -->
<div class="section">
<h2>Panel Layout</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>
</div>
</div>
<div id="panelBgRow" class="row" style="display:none">
<label>Panel background</label>
<input type="color" id="panelBgColor" value="#1a1a1a">
</div>
</div>
<!-- PHYSICAL SIZE -->
<div class="section">
<h2>Physical Size</h2>
@@ -633,21 +661,21 @@ bindRange('logoY', 'logoYVal');
bindRange('logoW', 'logoWVal');
bindRange('logoOpacity', 'logoOpacityVal');
function setLogo(img, name) {
function setLogo(img, name, silent = false) {
logoImg = img;
$('logoThumb').src = img.src;
$('logoFileName').textContent = name;
$('logoThumbWrap').style.display = 'flex';
$('logoDropZone').style.display = 'none';
drawGauge();
if (!silent) drawGauge();
}
function clearLogo() {
function clearLogo(silent = false) {
logoImg = null;
$('logoThumb').src = '';
$('logoThumbWrap').style.display = 'none';
$('logoDropZone').style.display = 'block';
drawGauge();
if (!silent) drawGauge();
}
$('logoFileInput').addEventListener('change', e => {
@@ -683,6 +711,169 @@ 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];
function buildConfigFromState(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',
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,
centerValue: parseFloat(s.centerValue) || 0,
leftSweep: parseFloat(s.leftSweep) || 120,
rightSweep: parseFloat(s.rightSweep) || 120,
leftMax: parseFloat(s.leftMax) || -60,
rightMax: parseFloat(s.rightMax) || 60,
leftInterval: parseFloat(s.leftInterval) || 20,
rightInterval: parseFloat(s.rightInterval) || 20,
arcRadius: parseFloat(s.arcRadius) / 100,
majorInterval: parseFloat(s.majorInterval) || 10,
minorDivisions: parseInt(s.minorDivisions) || 4,
majorLen: parseFloat(s.majorLen) / 100,
minorLen: parseFloat(s.minorLen) / 100,
majorWidth: parseFloat(s.majorWidth) || 1,
minorWidth: parseFloat(s.minorWidth) || 0.5,
tickColor: s.tickColor || '#ffffff',
showNumbers: s.showNumbers === true || s.showNumbers === 'true',
numOffset: parseFloat(s.numOffset) / 100,
numFontSize: parseFloat(s.numFontSize) / 100,
numFont: s.numFont || 'Arial',
numWeight: s.numWeight || '400',
labelFont: s.labelFont || 'Arial',
labelWeight: s.labelWeight || '700',
gaugeTitle: s.gaugeTitle || '',
titleY: parseFloat(s.titleY) / 100,
titleSize: parseFloat(s.titleSize) / 100,
gaugeSubtitle: s.gaugeSubtitle || '',
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'
};
}
function captureGaugeState(idx) {
const state = { gaugeMode, sweepDir, zones: JSON.parse(JSON.stringify(zones)), _logoImg: logoImg };
document.querySelectorAll('#sidebar input, #sidebar select').forEach(el => {
if (!el.id || el.type === 'file') 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;
}
function restoreGaugeState(idx) {
const data = gaugeStates[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.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')));
gaugeMode = data.gaugeMode || 'standard';
if (gaugeMode === 'center') {
$('modeCtr').classList.add('active'); $('modeStd').classList.remove('active');
$('ctrFields').style.display = ''; $('stdFields').style.display = 'none';
$('ctrTickNote').style.display = ''; $('stdTickNote').style.display = 'none';
$('stdMajorInterval').style.display = 'none';
} else {
$('modeStd').classList.add('active'); $('modeCtr').classList.remove('active');
$('stdFields').style.display = ''; $('ctrFields').style.display = 'none';
$('stdTickNote').style.display = ''; $('ctrTickNote').style.display = 'none';
$('stdMajorInterval').style.display = '';
}
sweepDir = data.sweepDir || 'cw';
if (sweepDir === 'ccw') {
$('dirCCW').classList.add('active'); $('dirCW').classList.remove('active');
} else {
$('dirCW').classList.add('active'); $('dirCCW').classList.remove('active');
}
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();
drawGauge();
}
function updateGaugeTabs() {
[0, 1, 2].forEach(i => {
const tab = $(`gaugeTab${i}`);
if (!tab) return;
tab.classList.toggle('active', i === activeGaugeIdx);
tab.style.display = i < numGauges ? '' : '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 }] };
}
}
// ── getConfig ─────────────────────────────────────────────────────────────────
function getConfig() {
const diamVal = parseFloat($('diameter').value) || 3.5;
@@ -889,15 +1080,9 @@ function renderCenterSide(ctx, cfg, cx, cy, r, scalePx, side) {
}
// ── main render ───────────────────────────────────────────────────────────────
function renderGaugeToCanvas(canvas, cfg, scale = 1) {
const size = Math.round(cfg.px * scale);
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
const cx = size / 2, cy = size / 2, r = size / 2;
const scalePx = size / cfg.px;
ctx.clearRect(0, 0, size, size);
function renderGaugeFace(ctx, cfg, cx, cy, r) {
const d = r * 2;
const scalePx = d / cfg.px;
// face fill
ctx.save();
@@ -905,14 +1090,14 @@ function renderGaugeToCanvas(canvas, cfg, scale = 1) {
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
if (cfg.faceGradient) {
const grad = ctx.createRadialGradient(cx, cy * 0.6, 0, cx, cy, r);
const grad = ctx.createRadialGradient(cx, cy - r * 0.4, 0, cx, cy, r);
grad.addColorStop(0, cfg.gradientColor);
grad.addColorStop(1, cfg.faceColor);
ctx.fillStyle = grad;
} else {
ctx.fillStyle = cfg.faceColor;
}
ctx.fillRect(0, 0, size, size);
ctx.fillRect(cx - r, cy - r, d, d);
ctx.restore();
// border ring
@@ -955,14 +1140,13 @@ function renderGaugeToCanvas(canvas, cfg, scale = 1) {
// logo
if (cfg.logoImg) {
const lw = size * cfg.logoW;
const lw = d * cfg.logoW;
const lh = lw * (cfg.logoImg.naturalHeight / cfg.logoImg.naturalWidth);
const lx = size * cfg.logoX - lw / 2;
const ly = size * cfg.logoY - lh / 2;
const lx = (cx - r) + d * cfg.logoX - lw / 2;
const ly = (cy - r) + d * cfg.logoY - lh / 2;
ctx.save();
ctx.globalAlpha = cfg.logoOpacity;
ctx.globalCompositeOperation = cfg.logoBlend;
// clip to circle so logo can't bleed outside face
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
@@ -979,13 +1163,49 @@ function renderGaugeToCanvas(canvas, cfg, scale = 1) {
}
}
function renderGaugeToCanvas(canvas, cfg, scale = 1) {
const size = Math.round(cfg.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);
}
}
function drawGauge() {
const cfg = getConfig();
const scaleF = parseInt($('previewScale').value) / 100;
renderGaugeToCanvas($('gaugeCanvas'), cfg, scaleF);
const displayPx = Math.round(cfg.px * scaleF);
$('info').textContent =
`Preview: ${displayPx}×${displayPx}px | Print size: ${cfg.diamIn.toFixed(3)}" (${(cfg.diamIn*25.4).toFixed(1)}mm) | Full res: ${cfg.px}×${cfg.px}px @ ${cfg.dpi}dpi`;
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`;
}
}
// listen to all sidebar inputs
@@ -994,49 +1214,82 @@ 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();
});
[0, 1, 2].forEach(i => {
$(`gaugeTab${i}`).addEventListener('click', () => switchGauge(i));
});
// ── export PDF ────────────────────────────────────────────────────────────────
$('exportPdfBtn').addEventListener('click', () => {
const cfg = getConfig();
captureGaugeState(activeGaugeIdx);
const cfg0 = buildConfigFromState(gaugeStates[0]);
const fullCanvas = document.createElement('canvas');
renderGaugeToCanvas(fullCanvas, cfg, 1);
renderPanelToCanvas(fullCanvas, 1);
const imgData = fullCanvas.toDataURL('image/png');
const dIn = cfg.diamIn;
const pdf = new jsPDF({ orientation: 'portrait', unit: 'in', format: [dIn + 0.5, dIn + 0.5] });
pdf.addImage(imgData, 'PNG', 0.25, 0.25, dIn, dIn);
// registration marks
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);
pdf.setDrawColor(180, 180, 180);
pdf.setLineWidth(0.01);
const m = 0.05, bl = 0.15, pw = dIn + 0.5, ph = dIn + 0.5;
[[m,m],[pw-m,m],[m,ph-m],[pw-m,ph-m]].forEach(([x,y]) => {
const m = 0.05, bl = 0.15;
[[m,m],[pdfW-m,m],[m,pdfH-m],[pdfW-m,pdfH-m]].forEach(([x,y]) => {
pdf.line(x-bl, y, x+bl, y);
pdf.line(x, y-bl, x, y+bl);
});
pdf.save(`${cfg.gaugeTitle || 'gauge'}-${dIn.toFixed(2)}in.pdf`);
pdf.save(`${cfg0.gaugeTitle || 'gauge-panel'}-${dIn.toFixed(2)}in.pdf`);
});
$('exportPngBtn').addEventListener('click', () => {
const cfg = getConfig();
captureGaugeState(activeGaugeIdx);
const cfg0 = buildConfigFromState(gaugeStates[0]);
const fullCanvas = document.createElement('canvas');
renderPanelToCanvas(fullCanvas, 1);
const link = document.createElement('a');
link.download = `${cfg.gaugeTitle || 'gauge'}-preview.png`;
link.href = $('gaugeCanvas').toDataURL('image/png');
link.download = `${cfg0.gaugeTitle || 'gauge'}-panel.png`;
link.href = fullCanvas.toDataURL('image/png');
link.click();
});
// ── save / load design ────────────────────────────────────────────────────────
$('saveDesignBtn').addEventListener('click', () => {
const data = { version: 1, gaugeMode, sweepDir, zones: JSON.parse(JSON.stringify(zones)) };
document.querySelectorAll('#sidebar input, #sidebar select').forEach(el => {
if (!el.id || el.type === 'file') return;
data[el.id] = el.type === 'checkbox' ? el.checked : el.value;
});
if (logoImg) {
data.logoDataUrl = logoImg.src;
data.logoFileName = $('logoFileName').textContent;
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
captureGaugeState(activeGaugeIdx);
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;
})
};
const name = (gaugeStates[0] && gaugeStates[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);
a.download = `${$('gaugeTitle').value || 'gauge'}-design.json`;
a.download = `${name}-design.json`;
a.click();
URL.revokeObjectURL(a.href);
});
@@ -1050,59 +1303,47 @@ $('loadDesignInput').addEventListener('change', e => {
reader.onload = ev => {
let data;
try { data = JSON.parse(ev.target.result); } catch { alert('Invalid design file.'); return; }
// restore all inputs silently (no events yet)
document.querySelectorAll('#sidebar input, #sidebar select').forEach(el => {
if (!el.id || el.type === 'file' || !(el.id in data)) return;
if (el.type === 'checkbox') el.checked = data[el.id];
else el.value = data[el.id];
});
// update range display spans by firing input events on ranges
document.querySelectorAll('#sidebar input[type=range]').forEach(el => {
el.dispatchEvent(new Event('input'));
});
// restore gauge mode UI
gaugeMode = data.gaugeMode || 'standard';
if (gaugeMode === 'center') {
$('modeCtr').classList.add('active'); $('modeStd').classList.remove('active');
$('ctrFields').style.display = ''; $('stdFields').style.display = 'none';
$('ctrTickNote').style.display = ''; $('stdTickNote').style.display = 'none';
$('stdMajorInterval').style.display = 'none';
} else {
$('modeStd').classList.add('active'); $('modeCtr').classList.remove('active');
$('stdFields').style.display = ''; $('ctrFields').style.display = 'none';
$('stdTickNote').style.display = ''; $('ctrTickNote').style.display = 'none';
$('stdMajorInterval').style.display = '';
}
// restore sweep direction UI
sweepDir = data.sweepDir || 'cw';
if (sweepDir === 'ccw') {
$('dirCCW').classList.add('active'); $('dirCW').classList.remove('active');
} else {
$('dirCW').classList.add('active'); $('dirCCW').classList.remove('active');
}
// restore zones
zones = data.zones || [];
renderZones();
// restore logo
if (data.logoDataUrl) {
const img = new Image();
img.onload = () => { setLogo(img, data.logoFileName || 'logo'); drawGauge(); };
img.src = data.logoDataUrl;
} else {
clearLogo();
drawGauge();
}
applyLoadedDesign(data);
};
reader.readAsText(file);
e.target.value = '';
});
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;
});
})).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;
if (data.logoDataUrl) {
const img = new Image();
img.onload = () => { gaugeStates[0]._logoImg = img; restoreGaugeState(0); drawGauge(); };
img.src = data.logoDataUrl;
} else { restoreGaugeState(0); drawGauge(); }
}
}
// ── preview background toggle ─────────────────────────────────────────────────
const previewArea = $('preview-area');
@@ -1124,6 +1365,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: '' });
drawGauge();
</script>
</body>