1046 lines
42 KiB
HTML
1046 lines
42 KiB
HTML
<!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>
|
||
|
||
<!-- 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>
|
||
|
||
<!-- 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="8" 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="15" 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="10" 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>
|
||
</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('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) {
|
||
logoImg = img;
|
||
$('logoThumb').src = img.src;
|
||
$('logoFileName').textContent = name;
|
||
$('logoThumbWrap').style.display = 'flex';
|
||
$('logoDropZone').style.display = 'none';
|
||
drawGauge();
|
||
}
|
||
|
||
function clearLogo() {
|
||
logoImg = null;
|
||
$('logoThumb').src = '';
|
||
$('logoThumbWrap').style.display = 'none';
|
||
$('logoDropZone').style.display = 'block';
|
||
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);
|
||
|
||
// ── 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 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);
|
||
|
||
// face fill
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
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);
|
||
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.restore();
|
||
|
||
// border ring
|
||
if (cfg.borderWidth > 0) {
|
||
const bw = r * cfg.borderWidth;
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, r - bw / 2, 0, Math.PI * 2);
|
||
ctx.strokeStyle = cfg.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');
|
||
} else {
|
||
renderStandard(ctx, cfg, 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}"`;
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(cfg.gaugeTitle, cx, cy + r * (cfg.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}"`;
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(cfg.gaugeSubtitle, cx, cy + r * (cfg.subtitleY - 0.5) * 2);
|
||
}
|
||
|
||
// logo
|
||
if (cfg.logoImg) {
|
||
const lw = size * 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;
|
||
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();
|
||
ctx.drawImage(cfg.logoImg, lx, ly, lw, lh);
|
||
ctx.restore();
|
||
}
|
||
|
||
// center dot
|
||
if (cfg.centerDotSize > 0) {
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, r * cfg.centerDotSize, 0, Math.PI * 2);
|
||
ctx.fillStyle = cfg.centerDotColor;
|
||
ctx.fill();
|
||
}
|
||
}
|
||
|
||
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`;
|
||
}
|
||
|
||
// listen to all sidebar inputs
|
||
document.querySelectorAll('#sidebar input, #sidebar select').forEach(el => {
|
||
el.addEventListener('input', drawGauge);
|
||
el.addEventListener('change', drawGauge);
|
||
});
|
||
|
||
// ── export PDF ────────────────────────────────────────────────────────────────
|
||
$('exportPdfBtn').addEventListener('click', () => {
|
||
const cfg = getConfig();
|
||
const fullCanvas = document.createElement('canvas');
|
||
renderGaugeToCanvas(fullCanvas, cfg, 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
|
||
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]) => {
|
||
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`);
|
||
});
|
||
|
||
$('exportPngBtn').addEventListener('click', () => {
|
||
const cfg = getConfig();
|
||
const link = document.createElement('a');
|
||
link.download = `${cfg.gaugeTitle || 'gauge'}-preview.png`;
|
||
link.href = $('gaugeCanvas').toDataURL('image/png');
|
||
link.click();
|
||
});
|
||
|
||
// ── 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'));
|
||
|
||
drawGauge();
|
||
</script>
|
||
</body>
|
||
</html>
|