Files
Gauge-designer/gauge-designer.html
T

1341 lines
54 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EV Gauge Face Designer</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<style>
@font-face {
font-family: 'Skudak Rover';
src: url('Skudak Rover.otf') format('opentype'),
url('Skudak Rover.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, sans-serif; background: #1a1a2e; color: #e0e0e0; display: flex; height: 100vh; overflow: hidden; }
#sidebar {
width: 340px; min-width: 300px; background: #16213e; overflow-y: auto;
border-right: 2px solid #0f3460; padding: 12px; flex-shrink: 0;
}
#sidebar h1 { font-size: 16px; color: #e94560; margin-bottom: 12px; text-align: center; letter-spacing: 1px; }
.section { background: #0f3460; border-radius: 6px; padding: 10px; margin-bottom: 10px; }
.section h2 { font-size: 12px; color: #e94560; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 1px; }
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; gap: 8px; }
.row label { font-size: 12px; color: #a0a0c0; white-space: nowrap; min-width: 110px; }
.row input[type=number], .row input[type=text], .row select {
flex: 1; background: #1a1a2e; border: 1px solid #0f3460; color: #e0e0e0;
padding: 4px 6px; border-radius: 4px; font-size: 12px; width: 0;
}
.row input[type=color] {
width: 36px; height: 26px; border: none; background: none; cursor: pointer; padding: 0; border-radius: 4px;
}
.row input[type=range] { flex: 1; accent-color: #e94560; }
.row span.val { font-size: 11px; color: #e94560; min-width: 36px; text-align: right; }
.row input[type=checkbox] { width: 16px; height: 16px; accent-color: #e94560; cursor: pointer; }
button { width: 100%; padding: 10px; background: #e94560; color: white; border: none;
border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: bold; margin-top: 4px; }
button:hover { background: #c73652; }
button.secondary { background: #0f3460; margin-top: 6px; font-size: 12px; padding: 7px; }
button.secondary:hover { background: #1a4a80; }
#preview-area {
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 16px; background: #111; overflow: auto; padding: 20px;
}
#preview-area h2 { font-size: 13px; color: #666; }
#gaugeCanvas { border-radius: 50%; box-shadow: 0 0 40px rgba(233,69,96,0.3); }
#info { font-size: 12px; color: #555; text-align: center; }
input[type=number]::-webkit-inner-spin-button { opacity: 1; }
/* mode toggle */
.mode-toggle { display: flex; gap: 4px; flex: 1; }
.mode-btn { flex: 1; padding: 5px 4px; font-size: 11px; background: #1a1a2e; border: 1px solid #333;
color: #777; border-radius: 4px; cursor: pointer; margin-top: 0; font-weight: normal; }
.mode-btn.active { background: #e94560; color: white; border-color: #e94560; }
/* preset buttons */
.preset-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; margin-bottom: 8px; }
.preset-btn { padding: 5px 2px; font-size: 10px; background: #1a1a2e; border: 1px solid #333;
color: #a0a0c0; border-radius: 4px; cursor: pointer; margin-top: 0; font-weight: normal; line-height: 1.3; }
.preset-btn:hover { background: #1a3a60; color: white; border-color: #4a7aaa; }
/* direction toggle */
.dir-toggle { display: flex; gap: 4px; flex: 1; }
.dir-btn { flex: 1; padding: 4px; font-size: 11px; background: #1a1a2e; border: 1px solid #333;
color: #777; border-radius: 4px; cursor: pointer; margin-top: 0; font-weight: normal; }
.dir-btn.active { background: #0f6040; color: #7fffcc; border-color: #0f6040; }
/* center mode split layout */
.ctr-sides { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 6px; }
.ctr-side { background: #0a1e30; border-radius: 4px; padding: 6px; }
.ctr-side-label { font-size: 11px; font-weight: bold; margin-bottom: 5px; text-align: center; padding: 2px; border-radius: 3px; }
.ctr-side .row { margin-bottom: 4px; }
.ctr-side .row label { min-width: 50px; font-size: 11px; }
.ctr-side .row input[type=number] { font-size: 11px; padding: 3px 4px; }
.divider { border: none; border-top: 1px solid #1a2a4a; margin: 6px 0; }
.tick-entry { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; font-size: 11px; }
.tick-entry input[type=number], .tick-entry input[type=text] {
flex: 1; background: #1a1a2e; border: 1px solid #0f3460; color: #e0e0e0;
padding: 3px 5px; border-radius: 4px; font-size: 11px;
}
.tick-entry button { width: auto; padding: 2px 8px; font-size: 11px; background: #600; margin-top: 0; }
#addZoneBtn { background: #0f6040; font-size: 11px; padding: 5px; margin-top: 4px; }
#addZoneBtn:hover { background: #0a8050; }
.hint { font-size: 10px; color: #556; margin-top: 2px; line-height: 1.4; }
/* preview bg toggle */
#bgToggle { display: flex; gap: 6px; align-items: center; }
#bgToggle span { font-size: 11px; color: #555; }
.bg-swatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer;
border: 2px solid #333; transition: border-color 0.15s, transform 0.15s; flex-shrink: 0; }
.bg-swatch:hover { transform: scale(1.15); }
.bg-swatch.active { border-color: #e94560; box-shadow: 0 0 0 2px #e94560; }
#bgCustomSwatch { width: 22px; height: 22px; border-radius: 50%; cursor: pointer;
border: 2px solid #333; padding: 0; overflow: hidden; position: relative; flex-shrink: 0; }
#bgCustomSwatch.active { border-color: #e94560; box-shadow: 0 0 0 2px #e94560; }
#bgCustomInput { position: absolute; inset: -4px; opacity: 0; cursor: pointer; width: 200%; height: 200%; }
/* logo upload */
#logoDropZone {
border: 2px dashed #2a4a6a; border-radius: 6px; padding: 10px;
text-align: center; cursor: pointer; transition: border-color 0.2s;
margin-bottom: 8px; position: relative;
}
#logoDropZone:hover, #logoDropZone.drag-over { border-color: #e94560; }
#logoDropZone input[type=file] {
position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%;
}
#logoDropText { font-size: 11px; color: #556; pointer-events: none; }
#logoThumbWrap { display: none; align-items: center; gap: 8px; margin-bottom: 8px; }
#logoThumb { width: 48px; height: 48px; object-fit: contain; border-radius: 4px;
background: repeating-conic-gradient(#222 0% 25%, #333 0% 50%) 0 0 / 8px 8px; }
#logoFileName { font-size: 10px; color: #888; flex: 1; word-break: break-all; }
#clearLogoBtn { background: #600; padding: 3px 8px; font-size: 10px;
width: auto; margin-top: 0; font-weight: normal; border-radius: 4px; }
#clearLogoBtn:hover { background: #900; }
</style>
</head>
<body>
<div id="sidebar">
<h1>⚡ EV GAUGE DESIGNER</h1>
<!-- DIALS -->
<div class="section">
<h2>Dials on this Gauge</h2>
<div class="row">
<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="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>
<!-- PHYSICAL SIZE -->
<div class="section">
<h2>Physical Size</h2>
<div class="row">
<label>Diameter</label>
<input type="number" id="diameter" value="3.5" step="0.125" min="0.5" max="12">
<select id="diamUnit" style="width:60px;flex:none;">
<option value="in">in</option>
<option value="mm">mm</option>
</select>
</div>
<div class="row">
<label>Print DPI</label>
<input type="number" id="printDpi" value="300" step="50" min="72" max="600">
</div>
<div class="row">
<label>Preview scale</label>
<input type="range" id="previewScale" min="20" max="100" value="50">
<span class="val" id="previewScaleVal">50%</span>
</div>
</div>
<!-- SCALE / ARC -->
<div class="section">
<h2>Scale Arc</h2>
<!-- Mode toggle -->
<div class="row" style="margin-bottom:10px">
<label>Mode</label>
<div class="mode-toggle">
<button class="mode-btn active" id="modeStd">Standard</button>
<button class="mode-btn" id="modeCtr">Center-Zero</button>
</div>
</div>
<!-- ═══ STANDARD MODE ═══ -->
<div id="stdFields">
<div style="font-size:11px;color:#a0a0c0;margin-bottom:5px">Quick presets:</div>
<div class="preset-grid">
<button class="preset-btn" data-start="225" data-sweep="270" data-dir="cw" title="Typical car speedo — gap at bottom">Speedo<br>225° 270°</button>
<button class="preset-btn" data-start="200" data-sweep="300" data-dir="cw" title="Wide sweep — gap at bottom">Wide<br>200° 300°</button>
<button class="preset-btn" data-start="135" data-sweep="270" data-dir="cw" title="3/4 circle — gap at top-left">¾ Circle<br>135° 270°</button>
<button class="preset-btn" data-start="180" data-sweep="180" data-dir="cw" title="Start at bottom, sweep right (CW)">Btm→Right<br>180° CW</button>
<button class="preset-btn" data-start="180" data-sweep="180" data-dir="ccw" title="Start at bottom, sweep left (CCW)">Btm→Left<br>180° CCW</button>
<button class="preset-btn" data-start="270" data-sweep="180" data-dir="cw" title="Left side to right via top">Left→Right<br>270° CW</button>
<button class="preset-btn" data-start="90" data-sweep="180" data-dir="ccw" title="Right side to left via top">Right→Left<br>90° CCW</button>
<button class="preset-btn" data-start="0" data-sweep="90" data-dir="cw" title="Quarter circle top-right">Quarter<br>0° 90°</button>
<button class="preset-btn" data-start="0" data-sweep="359" data-dir="cw" title="Full circle">Full<br>Circle</button>
</div>
<div class="row">
<label>Start angle °</label>
<input type="number" id="startAngle" value="225" min="0" max="359">
<span style="font-size:10px;color:#556;white-space:nowrap">0=top, CW</span>
</div>
<div class="row">
<label>Sweep °</label>
<input type="number" id="sweepAngle" value="270" min="1" max="359">
</div>
<div class="row">
<label>Direction</label>
<div class="dir-toggle">
<button class="dir-btn active" id="dirCW">↻ Clockwise</button>
<button class="dir-btn" id="dirCCW">↺ Counter-CW</button>
</div>
</div>
<hr class="divider">
<div class="row">
<label>Min value</label>
<input type="number" id="scaleMin" value="0">
</div>
<div class="row">
<label>Max value</label>
<input type="number" id="scaleMax" value="100">
</div>
</div>
<!-- ═══ CENTER-ZERO MODE ═══ -->
<div id="ctrFields" style="display:none">
<div class="row">
<label>Center angle °</label>
<input type="number" id="centerAngle" value="0" min="0" max="359">
<span style="font-size:10px;color:#556;white-space:nowrap">0=top</span>
</div>
<div class="row">
<label>Center value</label>
<input type="number" id="centerValue" value="0">
</div>
<p class="hint" style="margin-bottom:6px">Each side has its own sweep angle, max value, and major tick interval. Sides sweep independently from center.</p>
<div class="ctr-sides">
<div class="ctr-side">
<div class="ctr-side-label" style="background:#1a2a5a;color:#88aaff">← LEFT (CCW)</div>
<div class="row"><label>Sweep °</label><input type="number" id="leftSweep" value="120" min="1" max="179"></div>
<div class="row"><label>End value</label><input type="number" id="leftMax" value="-60"></div>
<div class="row"><label>Interval</label><input type="number" id="leftInterval" value="20" min="1"></div>
</div>
<div class="ctr-side">
<div class="ctr-side-label" style="background:#1a3a1a;color:#88ff88">RIGHT (CW) →</div>
<div class="row"><label>Sweep °</label><input type="number" id="rightSweep" value="120" min="1" max="179"></div>
<div class="row"><label>End value</label><input type="number" id="rightMax" value="60"></div>
<div class="row"><label>Interval</label><input type="number" id="rightInterval" value="20" min="1"></div>
</div>
</div>
</div>
<hr class="divider">
<div class="row">
<label>Arc radius %</label>
<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 -->
<div class="section">
<h2>Tick Marks</h2>
<div id="stdTickNote" class="hint" style="margin-bottom:6px">Interval and minor divisions apply to the full scale.</div>
<div id="ctrTickNote" class="hint" style="margin-bottom:6px;display:none">Minor divisions apply to both sides. Per-side intervals are set in Scale Arc above.</div>
<div id="stdMajorInterval" class="row">
<label>Major interval</label>
<input type="number" id="majorInterval" value="10" min="1">
</div>
<div class="row">
<label>Minor per major</label>
<input type="number" id="minorDivisions" value="4" min="0" max="20">
</div>
<div class="row">
<label>Major length %</label>
<input type="range" id="majorLen" min="3" max="25" value="10">
<span class="val" id="majorLenVal">10%</span>
</div>
<div class="row">
<label>Minor length %</label>
<input type="range" id="minorLen" min="1" max="15" value="5">
<span class="val" id="minorLenVal">5%</span>
</div>
<div class="row">
<label>Major width px</label>
<input type="number" id="majorWidth" value="2" min="1" max="20" step="0.5">
</div>
<div class="row">
<label>Minor width px</label>
<input type="number" id="minorWidth" value="1" min="0.5" max="10" step="0.5">
</div>
<div class="row">
<label>Tick color</label>
<input type="color" id="tickColor" value="#ffffff">
</div>
<div class="row">
<label>Show numbers</label>
<input type="checkbox" id="showNumbers" checked>
</div>
<div class="row">
<label>Number offset %</label>
<input type="range" id="numOffset" min="1" max="30" value="15">
<span class="val" id="numOffsetVal">15%</span>
</div>
<div class="row">
<label>Number font size</label>
<input type="range" id="numFontSize" min="1" max="25" value="3" step="0.5">
<span class="val" id="numFontSizeVal">3%</span>
</div>
</div>
<!-- COLORED ZONES -->
<div class="section">
<h2>Arc Zones (optional)</h2>
<p class="hint" style="margin-bottom:6px">Define by value range. Use actual scale values.</p>
<div id="zoneList"></div>
<button id="addZoneBtn">+ Add Zone</button>
</div>
<!-- LOGO -->
<div class="section">
<h2>Logo / Image</h2>
<div id="logoDropZone">
<input type="file" id="logoFileInput" accept="image/*">
<div id="logoDropText">Click or drag an image here<br><span style="color:#334;font-size:10px">PNG, JPG, SVG, WebP</span></div>
</div>
<div id="logoThumbWrap">
<img id="logoThumb" alt="logo">
<span id="logoFileName"></span>
<button id="clearLogoBtn">✕ Remove</button>
</div>
<div class="row">
<label>Center X %</label>
<input type="range" id="logoX" min="0" max="100" value="50">
<span class="val" id="logoXVal">50%</span>
</div>
<div class="row">
<label>Center Y %</label>
<input type="range" id="logoY" min="0" max="100" value="38">
<span class="val" id="logoYVal">38%</span>
</div>
<div class="row">
<label>Width %</label>
<input type="range" id="logoW" min="2" max="80" value="25">
<span class="val" id="logoWVal">25%</span>
</div>
<div class="row">
<label>Opacity</label>
<input type="range" id="logoOpacity" min="5" max="100" value="100">
<span class="val" id="logoOpacityVal">100%</span>
</div>
<div class="row">
<label>Blend mode</label>
<select id="logoBlend">
<option value="source-over">Normal</option>
<option value="screen">Screen</option>
<option value="multiply">Multiply</option>
<option value="overlay">Overlay</option>
<option value="lighten">Lighten</option>
<option value="color-dodge">Color Dodge</option>
</select>
</div>
</div>
<!-- LABELS -->
<div class="section">
<h2>Labels</h2>
<div class="row">
<label>Gauge title</label>
<input type="text" id="gaugeTitle" value="MPH">
</div>
<div class="row">
<label>Title Y pos %</label>
<input type="range" id="titleY" min="30" max="85" value="65">
<span class="val" id="titleYVal">65%</span>
</div>
<div class="row">
<label>Title size %</label>
<input type="range" id="titleSize" min="2" max="30" value="6">
<span class="val" id="titleSizeVal">6%</span>
</div>
<div class="row">
<label>Subtitle</label>
<input type="text" id="gaugeSubtitle" value="">
</div>
<div class="row">
<label>Subtitle Y %</label>
<input type="range" id="subtitleY" min="30" max="90" value="75">
<span class="val" id="subtitleYVal">75%</span>
</div>
<div class="row">
<label>Subtitle size %</label>
<input type="range" id="subtitleSize" min="1" max="25" value="3">
<span class="val" id="subtitleSizeVal">3%</span>
</div>
<div class="row">
<label>Label color</label>
<input type="color" id="labelColor" value="#ffffff">
</div>
</div>
<!-- FONTS -->
<div class="section">
<h2>Fonts</h2>
<div class="row">
<label>Number font</label>
<select id="numFont">
<option>Arial</option>
<option>Arial Narrow</option>
<option selected>Helvetica</option>
<option>Verdana</option>
<option>Tahoma</option>
<option>Trebuchet MS</option>
<option>Georgia</option>
<option>Times New Roman</option>
<option>Courier New</option>
<option>Impact</option>
<option>Skudak Rover</option>
</select>
</div>
<div class="row">
<label>Number weight</label>
<select id="numWeight">
<option value="300">Light</option>
<option value="400" selected>Normal</option>
<option value="700">Bold</option>
<option value="900">Black</option>
</select>
</div>
<div class="row">
<label>Label font</label>
<select id="labelFont">
<option>Arial</option>
<option>Arial Narrow</option>
<option selected>Helvetica</option>
<option>Verdana</option>
<option>Tahoma</option>
<option>Trebuchet MS</option>
<option>Georgia</option>
<option>Times New Roman</option>
<option>Courier New</option>
<option>Impact</option>
<option>Skudak Rover</option>
</select>
</div>
<div class="row">
<label>Label weight</label>
<select id="labelWeight">
<option value="300">Light</option>
<option value="400">Normal</option>
<option value="700" selected>Bold</option>
<option value="900">Black</option>
</select>
</div>
</div>
<!-- COLORS -->
<div class="section">
<h2>Colors</h2>
<div class="row">
<label>Face background</label>
<input type="color" id="faceColor" value="#000000">
</div>
<div class="row">
<label>Face gradient</label>
<input type="checkbox" id="faceGradient" checked>
</div>
<div class="row">
<label>Gradient color</label>
<input type="color" id="gradientColor" value="#1a1a3e">
</div>
<div class="row">
<label>Border color</label>
<input type="color" id="borderColor" value="#888888">
</div>
<div class="row">
<label>Border width %</label>
<input type="range" id="borderWidth" min="0" max="5" value="1" step="0.1">
<span class="val" id="borderWidthVal">1%</span>
</div>
<div class="row">
<label>Center dot color</label>
<input type="color" id="centerDotColor" value="#e94560">
</div>
<div class="row">
<label>Center dot size %</label>
<input type="range" id="centerDotSize" min="0" max="8" value="2" step="0.5">
<span class="val" id="centerDotSizeVal">2%</span>
</div>
</div>
<!-- EXPORT -->
<div class="section">
<h2>Export</h2>
<button id="exportPdfBtn">Export to PDF</button>
<button id="exportPngBtn" class="secondary">Export PNG (screen res)</button>
<div class="hint" style="margin-top:6px;text-align:center">
PDF prints at exact physical size.<br>Print at 100% — no scaling.
</div>
<hr class="divider" style="margin-top:10px">
<button id="saveDesignBtn" class="secondary">Save Design (.json)</button>
<button id="loadDesignBtn" class="secondary">Load Design (.json)</button>
<input type="file" id="loadDesignInput" accept=".json" style="display:none">
</div>
</div>
<div id="preview-area">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:center">
<h2>LIVE PREVIEW</h2>
<div id="bgToggle">
<span>BG:</span>
<div class="bg-swatch active" id="bgBlack" style="background:#111" title="Black" data-bg="#111111"></div>
<div class="bg-swatch" id="bgGrey" style="background:#888" title="Grey" data-bg="#888888"></div>
<div class="bg-swatch" id="bgWhite" style="background:#fff" title="White" data-bg="#ffffff"></div>
<div class="bg-swatch" id="bgDkGrey" style="background:#333" title="Dark grey" data-bg="#333333"></div>
<div id="bgCustomSwatch" title="Custom color" class="bg-swatch" style="background:#1a6a3a">
<input type="color" id="bgCustomInput" value="#1a6a3a">
</div>
</div>
</div>
<canvas id="gaugeCanvas"></canvas>
<div id="info">Loading...</div>
</div>
<script>
const { jsPDF } = window.jspdf;
// ── helpers ───────────────────────────────────────────────────────────────────
function $(id) { return document.getElementById(id); }
function degToRad(d) { return d * Math.PI / 180; }
// 0° = top (12 o'clock), increases CW → canvas radians
function gaugeAngle(deg) { return degToRad(deg - 90); }
function bindRange(id, valId, suffix = '%') {
const el = $(id), vl = $(valId);
const update = () => { if (vl) vl.textContent = el.value + suffix; };
el.addEventListener('input', update);
update();
}
bindRange('arcRadius', 'arcRadiusVal');
bindRange('dialOffsetX', 'dialOffsetXVal');
bindRange('dialOffsetY', 'dialOffsetYVal');
bindRange('majorLen', 'majorLenVal');
bindRange('minorLen', 'minorLenVal');
bindRange('numOffset', 'numOffsetVal');
bindRange('numFontSize', 'numFontSizeVal');
bindRange('titleY', 'titleYVal');
bindRange('titleSize', 'titleSizeVal');
bindRange('subtitleY', 'subtitleYVal');
bindRange('subtitleSize', 'subtitleSizeVal');
bindRange('borderWidth', 'borderWidthVal');
bindRange('centerDotSize', 'centerDotSizeVal');
$('previewScale').addEventListener('input', () => {
$('previewScaleVal').textContent = $('previewScale').value + '%';
drawGauge();
});
// ── mode toggle ───────────────────────────────────────────────────────────────
let gaugeMode = 'standard'; // 'standard' | 'center'
$('modeStd').addEventListener('click', () => {
gaugeMode = 'standard';
$('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 = '';
drawGauge();
});
$('modeCtr').addEventListener('click', () => {
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';
drawGauge();
});
// ── direction toggle ──────────────────────────────────────────────────────────
let sweepDir = 'cw';
$('dirCW').addEventListener('click', () => {
sweepDir = 'cw';
$('dirCW').classList.add('active'); $('dirCCW').classList.remove('active');
drawGauge();
});
$('dirCCW').addEventListener('click', () => {
sweepDir = 'ccw';
$('dirCCW').classList.add('active'); $('dirCW').classList.remove('active');
drawGauge();
});
// ── preset buttons ────────────────────────────────────────────────────────────
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.addEventListener('click', () => {
$('startAngle').value = btn.dataset.start;
$('sweepAngle').value = btn.dataset.sweep;
const dir = btn.dataset.dir;
sweepDir = dir;
if (dir === 'cw') {
$('dirCW').classList.add('active'); $('dirCCW').classList.remove('active');
} else {
$('dirCCW').classList.add('active'); $('dirCW').classList.remove('active');
}
drawGauge();
});
});
// ── zones ─────────────────────────────────────────────────────────────────────
let zones = [];
function renderZones() {
const list = $('zoneList');
list.innerHTML = '';
zones.forEach((z, i) => {
const div = document.createElement('div');
div.className = 'tick-entry';
div.innerHTML = `
<input type="number" value="${z.from}" placeholder="from" style="width:46px" data-i="${i}" data-f="from">
<input type="number" value="${z.to}" placeholder="to" style="width:46px" data-i="${i}" data-f="to">
<input type="color" value="${z.color}" data-i="${i}" data-f="color" style="width:28px;height:22px;border:none;background:none;cursor:pointer;padding:0">
<input type="range" min="1" max="15" value="${z.width}" data-i="${i}" data-f="width" style="width:44px">
<button data-del="${i}" style="padding:2px 6px;font-size:11px;background:#600;width:auto;margin-top:0">✕</button>
`;
div.querySelectorAll('[data-i]').forEach(el => {
['input','change'].forEach(ev => el.addEventListener(ev, () => {
zones[+el.dataset.i][el.dataset.f] = el.dataset.f === 'color' ? el.value : Number(el.value);
drawGauge();
}));
});
div.querySelector('[data-del]').addEventListener('click', e => {
zones.splice(+e.target.dataset.del, 1);
renderZones(); drawGauge();
});
list.appendChild(div);
});
}
$('addZoneBtn').addEventListener('click', () => {
zones.push({ from: 80, to: 100, color: '#e94560', width: 8 });
renderZones(); drawGauge();
});
renderZones();
// ── logo ──────────────────────────────────────────────────────────────────────
let logoImg = null;
bindRange('logoX', 'logoXVal');
bindRange('logoY', 'logoYVal');
bindRange('logoW', 'logoWVal');
bindRange('logoOpacity', 'logoOpacityVal');
function setLogo(img, name, silent = false) {
logoImg = img;
$('logoThumb').src = img.src;
$('logoFileName').textContent = name;
$('logoThumbWrap').style.display = 'flex';
$('logoDropZone').style.display = 'none';
if (!silent) drawGauge();
}
function clearLogo(silent = false) {
logoImg = null;
$('logoThumb').src = '';
$('logoThumbWrap').style.display = 'none';
$('logoDropZone').style.display = 'block';
if (!silent) drawGauge();
}
$('logoFileInput').addEventListener('change', e => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
const img = new Image();
img.onload = () => setLogo(img, file.name);
img.src = ev.target.result;
};
reader.readAsDataURL(file);
e.target.value = ''; // allow re-selecting same file
});
// drag-and-drop onto the drop zone
const dz = $('logoDropZone');
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('drag-over'); });
dz.addEventListener('dragleave', () => dz.classList.remove('drag-over'));
dz.addEventListener('drop', e => {
e.preventDefault();
dz.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = ev => {
const img = new Image();
img.onload = () => setLogo(img, file.name);
img.src = ev.target.result;
};
reader.readAsDataURL(file);
});
$('clearLogoBtn').addEventListener('click', clearLogo);
// ── 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'
]);
let numDials = 1;
let activeDialIdx = 0;
const dialStates = [null, null, null];
function buildDialConfig(s) {
if (!s) return null;
return {
mode: s.gaugeMode || 'standard',
sweepDir: s.sweepDir || 'cw',
startAngle: parseFloat(s.startAngle) || 0,
sweepAngle: parseFloat(s.sweepAngle) || 270,
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',
zones: s.zones || [],
offsetX: parseFloat(s.dialOffsetX) / 100 || 0,
offsetY: parseFloat(s.dialOffsetY) / 100 || 0
};
}
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' || FACE_INPUTS.has(el.id)) return;
state[el.id] = el.type === 'checkbox' ? el.checked : el.value;
});
dialStates[idx] = state;
}
function restoreDialState(idx) {
const data = dialStates[idx];
if (!data) return;
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'));
});
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();
}
function switchDial(newIdx) {
captureDialState(activeDialIdx);
activeDialIdx = newIdx;
restoreDialState(activeDialIdx);
updateDialTabs();
drawGauge();
}
function updateDialTabs() {
[0, 1, 2].forEach(i => {
const tab = $(`dialTab${i}`);
if (!tab) return;
tab.classList.toggle('active', i === activeDialIdx);
tab.style.display = i < numDials ? '' : 'none';
});
}
function updateDialUI() {
$('dialTabsRow').style.display = numDials > 1 ? '' : 'none';
updateDialTabs();
}
// ── getConfig ─────────────────────────────────────────────────────────────────
function getConfig() {
const diamVal = parseFloat($('diameter').value) || 3.5;
const diamUnit = $('diamUnit').value;
const diamIn = diamUnit === 'mm' ? diamVal / 25.4 : diamVal;
const dpi = parseInt($('printDpi').value) || 300;
const px = Math.round(diamIn * dpi);
return {
diamIn, dpi, px,
mode: gaugeMode,
// standard mode
startAngle: parseFloat($('startAngle').value) || 0,
sweepAngle: parseFloat($('sweepAngle').value) || 270,
sweepDir,
scaleMin: parseFloat($('scaleMin').value),
scaleMax: parseFloat($('scaleMax').value),
// center mode
centerAngle: parseFloat($('centerAngle').value) || 0,
centerValue: parseFloat($('centerValue').value) || 0,
leftSweep: parseFloat($('leftSweep').value) || 120,
rightSweep: parseFloat($('rightSweep').value) || 120,
leftMax: parseFloat($('leftMax').value),
rightMax: parseFloat($('rightMax').value),
leftInterval: parseFloat($('leftInterval').value) || 20,
rightInterval: parseFloat($('rightInterval').value) || 20,
// shared
arcRadius: parseFloat($('arcRadius').value) / 100,
majorInterval: parseFloat($('majorInterval').value) || 10,
minorDivisions: parseInt($('minorDivisions').value),
majorLen: parseFloat($('majorLen').value) / 100,
minorLen: parseFloat($('minorLen').value) / 100,
majorWidth: parseFloat($('majorWidth').value),
minorWidth: parseFloat($('minorWidth').value),
tickColor: $('tickColor').value,
showNumbers: $('showNumbers').checked,
numOffset: parseFloat($('numOffset').value) / 100,
numFontSize: parseFloat($('numFontSize').value) / 100,
numFont: $('numFont').value,
numWeight: $('numWeight').value,
labelFont: $('labelFont').value,
labelWeight: $('labelWeight').value,
gaugeTitle: $('gaugeTitle').value,
titleY: parseFloat($('titleY').value) / 100,
titleSize: parseFloat($('titleSize').value) / 100,
gaugeSubtitle: $('gaugeSubtitle').value,
subtitleY: parseFloat($('subtitleY').value) / 100,
subtitleSize: parseFloat($('subtitleSize').value) / 100,
labelColor: $('labelColor').value,
faceColor: $('faceColor').value,
faceGradient: $('faceGradient').checked,
gradientColor: $('gradientColor').value,
borderColor: $('borderColor').value,
borderWidth: parseFloat($('borderWidth').value) / 100,
centerDotColor: $('centerDotColor').value,
centerDotSize: parseFloat($('centerDotSize').value) / 100,
zones,
logoImg,
logoX: parseFloat($('logoX').value) / 100,
logoY: parseFloat($('logoY').value) / 100,
logoW: parseFloat($('logoW').value) / 100,
logoOpacity: parseFloat($('logoOpacity').value) / 100,
logoBlend: $('logoBlend').value
};
}
// ── drawing helpers ───────────────────────────────────────────────────────────
function drawTick(ctx, cx, cy, tickR, majorLenPx, minorLenPx, angleDeg, isMajor, majorW, minorW, color) {
const rad = gaugeAngle(angleDeg);
const len = isMajor ? majorLenPx : minorLenPx;
ctx.beginPath();
ctx.moveTo(cx + tickR * Math.cos(rad), cy + tickR * Math.sin(rad));
ctx.lineTo(cx + (tickR - len) * Math.cos(rad), cy + (tickR - len) * Math.sin(rad));
ctx.strokeStyle = color;
ctx.lineWidth = isMajor ? majorW : minorW;
ctx.stroke();
}
function drawNumber(ctx, cx, cy, numR, angleDeg, text) {
const rad = gaugeAngle(angleDeg);
ctx.fillText(text, cx + numR * Math.cos(rad), cy + numR * Math.sin(rad));
}
// ── render standard mode ──────────────────────────────────────────────────────
function renderStandard(ctx, cfg, cx, cy, r, scalePx) {
const startDeg = cfg.startAngle;
const effectiveSweep = cfg.sweepDir === 'ccw' ? -cfg.sweepAngle : cfg.sweepAngle;
const range = cfg.scaleMax - cfg.scaleMin;
if (range === 0) return;
const ccw = cfg.sweepDir === 'ccw';
function valueToAngle(v) {
return startDeg + ((v - cfg.scaleMin) / range) * effectiveSweep;
}
const tickR = r * cfg.arcRadius;
const majorLenPx = r * cfg.majorLen;
const minorLenPx = r * cfg.minorLen;
const majorW = cfg.majorWidth * scalePx;
const minorW = cfg.minorWidth * scalePx;
// zones
cfg.zones.forEach(z => {
const a1 = gaugeAngle(valueToAngle(z.from));
const a2 = gaugeAngle(valueToAngle(z.to));
ctx.beginPath();
ctx.arc(cx, cy, tickR, a1, a2, ccw);
ctx.strokeStyle = z.color;
ctx.lineWidth = r * (z.width / 100);
ctx.stroke();
});
// ticks
const majorCount = range / cfg.majorInterval;
const totalTicks = majorCount * (cfg.minorDivisions + 1);
const tickStep = effectiveSweep / totalTicks;
for (let i = 0; i <= totalTicks; i++) {
const isMajor = i % (cfg.minorDivisions + 1) === 0;
drawTick(ctx, cx, cy, tickR, majorLenPx, minorLenPx,
startDeg + i * tickStep, isMajor, majorW, minorW, cfg.tickColor);
}
// numbers
if (cfg.showNumbers) {
const numR = r * (cfg.arcRadius - cfg.numOffset);
const fontSize = Math.max(1, Math.round(r * cfg.numFontSize));
ctx.fillStyle = cfg.labelColor;
ctx.font = `${cfg.numWeight} ${fontSize}px "${cfg.numFont}"`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let i = 0; i <= majorCount; i++) {
const val = cfg.scaleMin + i * cfg.majorInterval;
drawNumber(ctx, cx, cy, numR, valueToAngle(val), val.toString());
}
}
}
// ── render one side of center-zero mode ───────────────────────────────────────
function renderCenterSide(ctx, cfg, cx, cy, r, scalePx, side) {
const isLeft = side === 'left';
const sweepDeg = isLeft ? -cfg.leftSweep : cfg.rightSweep; // negative = CCW
const endVal = isLeft ? cfg.leftMax : cfg.rightMax;
const interval = isLeft ? cfg.leftInterval : cfg.rightInterval;
const centerDeg = cfg.centerAngle;
const centerVal = cfg.centerValue;
const valRange = endVal - centerVal;
if (valRange === 0 || interval <= 0) return;
const tickR = r * cfg.arcRadius;
const majorLenPx = r * cfg.majorLen;
const minorLenPx = r * cfg.minorLen;
const majorW = cfg.majorWidth * scalePx;
const minorW = cfg.minorWidth * scalePx;
const ccw = isLeft; // left side is CCW on canvas
function sideValueToAngle(v) {
return centerDeg + ((v - centerVal) / valRange) * sweepDeg;
}
// zones that fall on this side
cfg.zones.forEach(z => {
const fromOnSide = isLeft ? z.from <= centerVal : z.from >= centerVal;
const toOnSide = isLeft ? z.to <= centerVal : z.to >= centerVal;
if (!fromOnSide && !toOnSide) return;
const clampedFrom = isLeft ? Math.min(z.from, centerVal) : Math.max(z.from, centerVal);
const clampedTo = isLeft ? Math.min(z.to, centerVal) : Math.max(z.to, centerVal);
const a1 = gaugeAngle(sideValueToAngle(clampedFrom));
const a2 = gaugeAngle(sideValueToAngle(clampedTo));
ctx.beginPath();
ctx.arc(cx, cy, tickR, a1, a2, ccw);
ctx.strokeStyle = z.color;
ctx.lineWidth = r * (z.width / 100);
ctx.stroke();
});
// ticks
const majorCount = Math.abs(valRange / interval);
const totalTicks = majorCount * (cfg.minorDivisions + 1);
const tickStep = sweepDeg / totalTicks;
for (let i = 0; i <= totalTicks; i++) {
// skip tick 0 on second pass so center isn't doubled
if (i === 0 && side === 'right') continue;
const isMajor = i % (cfg.minorDivisions + 1) === 0;
drawTick(ctx, cx, cy, tickR, majorLenPx, minorLenPx,
centerDeg + i * tickStep, isMajor, majorW, minorW, cfg.tickColor);
}
// numbers
if (cfg.showNumbers) {
const numR = r * (cfg.arcRadius - cfg.numOffset);
const fontSize = Math.max(1, Math.round(r * cfg.numFontSize));
ctx.fillStyle = cfg.labelColor;
ctx.font = `${cfg.numWeight} ${fontSize}px "${cfg.numFont}"`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let i = 0; i <= majorCount; i++) {
if (i === 0 && side === 'right') continue; // avoid duplicate center label
const val = centerVal + i * (isLeft ? -interval : interval);
drawNumber(ctx, cx, cy, numR, sideValueToAngle(val), val.toString());
}
}
}
// ── main render ───────────────────────────────────────────────────────────────
function drawFaceBackground(ctx, faceCfg, cx, cy, r) {
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
if (faceCfg.faceGradient) {
const grad = ctx.createRadialGradient(cx, cy - r * 0.4, 0, cx, cy, r);
grad.addColorStop(0, faceCfg.gradientColor);
grad.addColorStop(1, faceCfg.faceColor);
ctx.fillStyle = grad;
} else {
ctx.fillStyle = faceCfg.faceColor;
}
ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
ctx.restore();
if (faceCfg.borderWidth > 0) {
const bw = r * faceCfg.borderWidth;
ctx.beginPath();
ctx.arc(cx, cy, r - bw / 2, 0, Math.PI * 2);
ctx.strokeStyle = faceCfg.borderColor;
ctx.lineWidth = bw;
ctx.stroke();
}
}
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, dialCfg, cx, cy, r, scalePx);
}
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(dialCfg.gaugeTitle, cx, cy + r * (dialCfg.titleY - 0.5) * 2);
}
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(dialCfg.gaugeSubtitle, cx, cy + r * (dialCfg.subtitleY - 0.5) * 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 = faceCfg.logoOpacity;
ctx.globalCompositeOperation = faceCfg.logoBlend;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(faceCfg.logoImg, lx, ly, lw, lh);
ctx.restore();
}
if (faceCfg.centerDotSize > 0) {
ctx.beginPath();
ctx.arc(cx, cy, r * faceCfg.centerDotSize, 0, Math.PI * 2);
ctx.fillStyle = faceCfg.centerDotColor;
ctx.fill();
}
}
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);
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;
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
document.querySelectorAll('#sidebar input, #sidebar select').forEach(el => {
el.addEventListener('input', drawGauge);
el.addEventListener('change', 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 => {
$(`dialTab${i}`).addEventListener('click', () => switchDial(i));
});
// ── export PDF ────────────────────────────────────────────────────────────────
$('exportPdfBtn').addEventListener('click', () => {
captureDialState(activeDialIdx);
const faceCfg = getConfig();
const dialCfgs = dialStates.slice(0, numDials).map(buildDialConfig);
const fullCanvas = document.createElement('canvas');
renderToCanvas(fullCanvas, faceCfg, dialCfgs, 1);
const imgData = fullCanvas.toDataURL('image/png');
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],[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(`${faceCfg.gaugeTitle || 'gauge'}-${dIn.toFixed(2)}in.pdf`);
});
$('exportPngBtn').addEventListener('click', () => {
captureDialState(activeDialIdx);
const faceCfg = getConfig();
const dialCfgs = dialStates.slice(0, numDials).map(buildDialConfig);
const fullCanvas = document.createElement('canvas');
renderToCanvas(fullCanvas, faceCfg, dialCfgs, 1);
const link = document.createElement('a');
link.download = `${faceCfg.gaugeTitle || 'gauge'}-preview.png`;
link.href = fullCanvas.toDataURL('image/png');
link.click();
});
// ── save / load design ────────────────────────────────────────────────────────
$('saveDesignBtn').addEventListener('click', () => {
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: 3,
numDials,
faceInputs,
logoDataUrl: logoImg ? logoImg.src : null,
logoFileName: logoImg ? $('logoFileName').textContent : '',
dialStates: dialStates.map(s => s || null)
};
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);
a.download = `${name}-design.json`;
a.click();
URL.revokeObjectURL(a.href);
});
$('loadDesignBtn').addEventListener('click', () => $('loadDesignInput').click());
$('loadDesignInput').addEventListener('change', e => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
let data;
try { data = JSON.parse(ev.target.result); } catch { alert('Invalid design file.'); return; }
applyLoadedDesign(data);
};
reader.readAsText(file);
e.target.value = '';
});
function applyLoadedDesign(data) {
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;
});
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 = () => { setLogo(img, data.logoFileName || 'logo', true); restoreAndDraw(); };
img.src = data.logoDataUrl;
} 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(); }
}
}
// ── preview background toggle ─────────────────────────────────────────────────
const previewArea = $('preview-area');
function setBg(color, activeSwatch) {
previewArea.style.background = color;
document.querySelectorAll('.bg-swatch, #bgCustomSwatch').forEach(s => s.classList.remove('active'));
activeSwatch.classList.add('active');
}
document.querySelectorAll('.bg-swatch[data-bg]').forEach(swatch => {
swatch.addEventListener('click', () => setBg(swatch.dataset.bg, swatch));
});
const bgCustomInput = $('bgCustomInput');
const bgCustomSwatch = $('bgCustomSwatch');
bgCustomInput.addEventListener('input', () => {
bgCustomSwatch.style.background = bgCustomInput.value;
setBg(bgCustomInput.value, bgCustomSwatch);
});
bgCustomSwatch.addEventListener('click', () => bgCustomSwatch.classList.add('active'));
// 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>
</html>