Multi-dial on one face: per-dial X/Y offset, independent settings per dial
This commit is contained in:
+216
-251
@@ -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>
|
||||
<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 id="gaugeTabsRow" style="display:none">
|
||||
</div>
|
||||
<div id="dialTabsRow" style="display:none">
|
||||
<div class="row" style="margin-top:4px">
|
||||
<label>Editing gauge</label>
|
||||
<label>Editing dial</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>
|
||||
<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>
|
||||
</div>
|
||||
<div id="panelBgRow" class="row" style="display:none">
|
||||
<label>Panel background</label>
|
||||
<input type="color" id="panelBgColor" value="#1a1a1a">
|
||||
<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);
|
||||
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: ${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`;
|
||||
}
|
||||
`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);
|
||||
// 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);
|
||||
}
|
||||
updatePanelUI();
|
||||
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;
|
||||
});
|
||||
})).then(() => {
|
||||
activeGaugeIdx = 0;
|
||||
updatePanelUI();
|
||||
restoreGaugeState(0);
|
||||
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;
|
||||
});
|
||||
} 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>
|
||||
|
||||
Reference in New Issue
Block a user