Last active
December 2, 2025 11:30
-
-
Save ArthurDelannoyazerty/8685727a77fb2d0a901d561428e2d337 to your computer and use it in GitHub Desktop.
tinling & stride & overlap image viz / calculator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Sentinel-2 Vector Planner v2</title> | |
| <style> | |
| :root { | |
| --bg: #121212; | |
| --panel: #1e1e1e; | |
| --text: #e0e0e0; | |
| --accent: #00d4aa; /* Teal for safe */ | |
| --warn: #ffb74d; /* Orange for heavy */ | |
| --danger: #ff5252; /* Red for overload */ | |
| --grid: #333; | |
| } | |
| body { | |
| font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; | |
| background-color: var(--bg); | |
| color: var(--text); | |
| margin: 0; | |
| display: flex; | |
| height: 100vh; | |
| overflow: hidden; | |
| } | |
| /* Sidebar */ | |
| .controls { | |
| width: 340px; | |
| background-color: var(--panel); | |
| padding: 25px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 25px; | |
| box-shadow: 4px 0 15px rgba(0,0,0,0.4); | |
| overflow-y: auto; | |
| border-right: 1px solid #333; | |
| z-index: 10; | |
| } | |
| h2 { margin: 0 0 10px 0; font-size: 1.1rem; color: #fff; letter-spacing: 0.5px; } | |
| h3 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 1px; color: #777; margin-bottom: 8px; border-bottom: 1px solid #333; padding-bottom: 4px;} | |
| .input-group { margin-bottom: 12px; } | |
| label { font-size: 0.8rem; color: #aaa; display: flex; justify-content: space-between; margin-bottom: 4px;} | |
| input[type="number"], input[type="range"] { | |
| background: #2a2a2a; | |
| border: 1px solid #444; | |
| color: white; | |
| padding: 8px; | |
| border-radius: 4px; | |
| width: 100%; | |
| font-family: monospace; | |
| box-sizing: border-box; | |
| } | |
| input[type="number"]:focus { outline: none; border-color: var(--accent); } | |
| /* Charts Section */ | |
| .chart-container { | |
| background: #252525; | |
| padding: 15px; | |
| border-radius: 8px; | |
| border: 1px solid #333; | |
| } | |
| .chart-label { display: flex; justify-content: space-between; font-size: 0.85rem; margin-bottom: 5px; } | |
| .chart-val { font-weight: bold; font-family: monospace; } | |
| .bar-bg { | |
| background: #111; | |
| height: 10px; | |
| border-radius: 5px; | |
| overflow: hidden; | |
| position: relative; | |
| } | |
| .bar-fill { | |
| height: 100%; | |
| background: var(--accent); | |
| width: 0%; | |
| transition: width 0.3s ease, background-color 0.3s ease; | |
| } | |
| /* Legend */ | |
| .info-box { | |
| font-size: 0.75rem; | |
| color: #888; | |
| line-height: 1.4; | |
| background: rgba(0,0,0,0.2); | |
| padding: 10px; | |
| border-radius: 4px; | |
| } | |
| /* Canvas Area */ | |
| .viz-container { | |
| flex-grow: 1; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| background-image: | |
| radial-gradient(#333 1px, transparent 1px); | |
| background-size: 20px 20px; | |
| background-color: var(--bg); | |
| position: relative; | |
| } | |
| canvas { | |
| box-shadow: 0 0 50px rgba(0,0,0,0.5); | |
| border: 1px solid #444; | |
| } | |
| .overlay-stats { | |
| position: absolute; | |
| top: 20px; | |
| right: 20px; | |
| text-align: right; | |
| pointer-events: none; | |
| } | |
| .big-stat { font-size: 2rem; font-weight: bold; color: var(--text); text-shadow: 0 2px 4px rgba(0,0,0,0.8); } | |
| .sub-stat { font-size: 0.9rem; color: #aaa; text-shadow: 0 1px 2px rgba(0,0,0,0.8); } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="controls"> | |
| <div> | |
| <h2>Vector Architect</h2> | |
| <div style="font-size: 0.75rem; color: #666;">Sentinel-2 Optimization Tool</div> | |
| </div> | |
| <div> | |
| <h3>1. Sentinel Product (Source)</h3> | |
| <div class="input-group"> | |
| <label>Width (px)</label> | |
| <input type="number" id="imgW" value="10980"> | |
| </div> | |
| <!-- Hidden Height for simplicity, assumed square usually, but code handles rect --> | |
| <input type="hidden" id="imgH" value="10980"> | |
| </div> | |
| <div> | |
| <h3>2. Model Input (Target)</h3> | |
| <div class="input-group"> | |
| <label>Tile Size (px)</label> | |
| <input type="number" id="tileS" value="224"> | |
| </div> | |
| </div> | |
| <div> | |
| <h3>3. Tiling Strategy</h3> | |
| <div class="input-group"> | |
| <label> | |
| <span>Stride (Step Size)</span> | |
| <span id="strideVal" style="color:var(--accent)">610 px</span> | |
| </label> | |
| <input type="range" id="strideRange" min="50" max="2000" value="610"> | |
| </div> | |
| <div class="input-group"> | |
| <label> | |
| <span>Overlap (px)</span> | |
| <span style="font-size:0.7em; opacity:0.7;">(Negative = Gap)</span> | |
| </label> | |
| <input type="number" id="overlapInput" value="-386"> | |
| </div> | |
| </div> | |
| <div> | |
| <h3>4. Live Metrics</h3> | |
| <div class="chart-container"> | |
| <div class="chart-label"> | |
| <span>Vector Count</span> | |
| <span class="chart-val" id="txtCount">324</span> | |
| </div> | |
| <div class="bar-bg"> | |
| <div class="bar-fill" id="barCount"></div> | |
| </div> | |
| <div style="font-size:0.7rem; color:#666; margin-top:4px; text-align:right;">Target: < 500</div> | |
| </div> | |
| <div class="chart-container" style="margin-top:10px;"> | |
| <div class="chart-label"> | |
| <span>Pixel Coverage</span> | |
| <span class="chart-val" id="txtCov">13%</span> | |
| </div> | |
| <div class="bar-bg"> | |
| <div class="bar-fill" id="barCov"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="info-box"> | |
| <strong style="color:var(--text)">Logic Check:</strong><br> | |
| <span id="logicMsg">Sampling mode active. High efficiency.</span> | |
| </div> | |
| </div> | |
| <div class="viz-container"> | |
| <canvas id="canvas"></canvas> | |
| <div class="overlay-stats"> | |
| <div class="big-stat" id="bigTotal">324</div> | |
| <div class="sub-stat">Total Vectors</div> | |
| </div> | |
| </div> | |
| <script> | |
| const canvas = document.getElementById('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| // UI Elements | |
| const ui = { | |
| imgW: document.getElementById('imgW'), | |
| imgH: document.getElementById('imgH'), | |
| tileS: document.getElementById('tileS'), | |
| strideRange: document.getElementById('strideRange'), | |
| strideVal: document.getElementById('strideVal'), | |
| overlapInput: document.getElementById('overlapInput'), | |
| // Charts | |
| txtCount: document.getElementById('txtCount'), | |
| barCount: document.getElementById('barCount'), | |
| txtCov: document.getElementById('txtCov'), | |
| barCov: document.getElementById('barCov'), | |
| // Msgs | |
| logicMsg: document.getElementById('logicMsg'), | |
| bigTotal: document.getElementById('bigTotal') | |
| }; | |
| // --- Interaction Logic --- | |
| // 1. When Slider moves (Stride changes) -> Update Overlap | |
| ui.strideRange.addEventListener('input', () => { | |
| const S = parseInt(ui.strideRange.value); | |
| const T = parseInt(ui.tileS.value); | |
| ui.strideVal.textContent = S + " px"; | |
| // Formula: Overlap = Tile - Stride | |
| ui.overlapInput.value = T - S; | |
| draw(); | |
| }); | |
| // 2. When Overlap Input changes -> Update Stride | |
| ui.overlapInput.addEventListener('input', () => { | |
| const O = parseInt(ui.overlapInput.value); | |
| const T = parseInt(ui.tileS.value); | |
| // Formula: Stride = Tile - Overlap | |
| let newStride = T - O; | |
| if(newStride < 1) newStride = 1; // Safety | |
| ui.strideRange.value = newStride; | |
| ui.strideVal.textContent = newStride + " px"; | |
| draw(); | |
| }); | |
| // 3. Other inputs | |
| [ui.imgW, ui.tileS].forEach(el => el.addEventListener('input', () => { | |
| // If Tile changes, we need to recalc relationship based on current slider | |
| const S = parseInt(ui.strideRange.value); | |
| const T = parseInt(ui.tileS.value); | |
| ui.overlapInput.value = T - S; | |
| draw(); | |
| })); | |
| function draw() { | |
| // --- Calculations --- | |
| const W = parseInt(ui.imgW.value); | |
| const H = parseInt(ui.imgH.value); | |
| const T = parseInt(ui.tileS.value); | |
| const S = parseInt(ui.strideRange.value); | |
| const Overlap = parseInt(ui.overlapInput.value); | |
| // Count (Convolution formula) | |
| const countX = Math.floor((W - T) / S) + 1; | |
| const countY = Math.floor((H - T) / S) + 1; | |
| const total = countX * countY; | |
| // Coverage % | |
| // Simple approximation: (Vectors * TileArea) / TotalArea | |
| // Note: If overlapping, this goes > 100% which indicates redundancy | |
| const totalArea = W * H; | |
| const vectorArea = total * (T * T); | |
| let covPct = (vectorArea / totalArea) * 100; | |
| // --- Update Charts & text --- | |
| // 1. Vector Count Chart | |
| ui.txtCount.textContent = total.toLocaleString(); | |
| ui.bigTotal.textContent = total.toLocaleString(); | |
| // Logic for scaling the bar (Assuming 2000 is "Too Many") | |
| let countFill = (total / 2000) * 100; | |
| if(countFill > 100) countFill = 100; | |
| ui.barCount.style.width = countFill + "%"; | |
| // Color Coding | |
| if(total <= 400) { | |
| ui.barCount.style.backgroundColor = "var(--accent)"; // Green | |
| ui.logicMsg.innerHTML = "Sampling Mode.<br>Storage Optimized."; | |
| ui.logicMsg.style.color = "var(--accent)"; | |
| } else if (total <= 1000) { | |
| ui.barCount.style.backgroundColor = "var(--warn)"; // Orange | |
| ui.logicMsg.innerHTML = "High Density.<br>Heavy Storage Cost."; | |
| ui.logicMsg.style.color = "var(--warn)"; | |
| } else { | |
| ui.barCount.style.backgroundColor = "var(--danger)"; // Red | |
| ui.logicMsg.innerHTML = "Extremely Heavy.<br>Not recommended for DB."; | |
| ui.logicMsg.style.color = "var(--danger)"; | |
| } | |
| // 2. Coverage Chart | |
| ui.txtCov.textContent = covPct.toFixed(1) + "%"; | |
| let covFill = covPct; | |
| if(covFill > 100) covFill = 100; | |
| ui.barCov.style.width = covFill + "%"; | |
| if(Overlap > 0) { | |
| ui.txtCov.textContent += " (Redundant)"; | |
| ui.barCov.style.backgroundColor = "#aaa"; | |
| } else { | |
| ui.barCov.style.backgroundColor = "var(--accent)"; | |
| } | |
| // --- Canvas Drawing --- | |
| // Fit canvas to container | |
| const padding = 60; | |
| const containerW = canvas.parentElement.clientWidth - padding; | |
| const containerH = canvas.parentElement.clientHeight - padding; | |
| const scale = Math.min(containerW / W, containerH / H); | |
| canvas.width = W * scale; | |
| canvas.height = H * scale; | |
| // Background (Gray, not red) | |
| ctx.fillStyle = '#2d2d2d'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| // Border | |
| ctx.strokeStyle = '#555'; | |
| ctx.lineWidth = 2; | |
| ctx.strokeRect(0, 0, canvas.width, canvas.height); | |
| // Draw Vectors | |
| // We use a slight opacity to visualize overlap if it exists | |
| ctx.fillStyle = 'rgba(0, 212, 170, 0.3)'; // Teal translucent | |
| ctx.strokeStyle = 'rgba(0, 212, 170, 0.8)'; // Teal border | |
| // Performance: Don't render borders if thousands of tiles | |
| if(total > 3000) ctx.strokeStyle = 'rgba(0,0,0,0)'; | |
| for (let x = 0; x < countX; x++) { | |
| for (let y = 0; y < countY; y++) { | |
| const screenX = (x * S) * scale; | |
| const screenY = (y * S) * scale; | |
| const screenT = T * scale; | |
| ctx.fillRect(screenX, screenY, screenT, screenT); | |
| ctx.strokeRect(screenX, screenY, screenT, screenT); | |
| } | |
| } | |
| } | |
| // Init | |
| draw(); | |
| window.addEventListener('resize', draw); | |
| </script> | |
| </body> | |
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Universal Tiling Planner v2</title> | |
| <style> | |
| :root { | |
| --bg: #121212; | |
| --panel: #1e1e1e; | |
| --text: #e0e0e0; | |
| --accent: #3b82f6; /* Blue */ | |
| --tile: #10b981; /* Emerald */ | |
| --input-bg: #2a2a2a; | |
| --border: #333; | |
| } | |
| body { | |
| font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; | |
| background-color: var(--bg); | |
| color: var(--text); | |
| margin: 0; | |
| display: flex; | |
| height: 100vh; | |
| overflow: hidden; | |
| } | |
| /* Sidebar */ | |
| .controls { | |
| width: 380px; | |
| background-color: var(--panel); | |
| padding: 20px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 20px; | |
| box-shadow: 4px 0 15px rgba(0,0,0,0.4); | |
| overflow-y: auto; | |
| border-right: 1px solid var(--border); | |
| z-index: 10; | |
| } | |
| h2 { margin: 0 0 5px 0; font-size: 1.2rem; color: #fff; } | |
| .subtitle { font-size: 0.75rem; color: #777; margin-bottom: 10px; } | |
| h3 { | |
| font-size: 0.75rem; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| color: #888; | |
| margin-bottom: 10px; | |
| border-bottom: 1px solid var(--border); | |
| padding-bottom: 4px; | |
| } | |
| .input-group { margin-bottom: 15px; } | |
| .label-row { | |
| display: flex; | |
| justify-content: space-between; | |
| margin-bottom: 5px; | |
| font-size: 0.85rem; | |
| color: #ccc; | |
| } | |
| /* Flex container for Slider + Number Box */ | |
| .control-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| input[type="range"] { | |
| flex-grow: 1; | |
| cursor: pointer; | |
| height: 6px; | |
| background: #444; | |
| border-radius: 3px; | |
| -webkit-appearance: none; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 16px; | |
| height: 16px; | |
| background: var(--accent); | |
| border-radius: 50%; | |
| cursor: pointer; | |
| } | |
| input[type="number"] { | |
| background: var(--input-bg); | |
| border: 1px solid #444; | |
| color: var(--accent); | |
| padding: 5px; | |
| border-radius: 4px; | |
| width: 70px; /* Fixed width for precision box */ | |
| font-family: monospace; | |
| text-align: right; | |
| } | |
| input[type="number"]:focus { | |
| outline: none; | |
| border-color: var(--accent); | |
| } | |
| /* Canvas Area */ | |
| .viz-container { | |
| flex-grow: 1; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| background-image: | |
| linear-gradient(#222 1px, transparent 1px), | |
| linear-gradient(90deg, #222 1px, transparent 1px); | |
| background-size: 40px 40px; | |
| background-color: var(--bg); | |
| position: relative; | |
| } | |
| #mainCanvas { | |
| box-shadow: 0 0 50px rgba(0,0,0,0.5); | |
| border: 1px solid #444; | |
| } | |
| .overlay-stats { | |
| position: absolute; | |
| top: 20px; | |
| right: 20px; | |
| text-align: right; | |
| pointer-events: none; | |
| background: rgba(0,0,0,0.7); | |
| padding: 15px; | |
| border-radius: 8px; | |
| backdrop-filter: blur(4px); | |
| border: 1px solid #444; | |
| } | |
| .big-stat { font-size: 2.5rem; font-weight: bold; color: white; line-height: 1; } | |
| .sub-stat { font-size: 0.9rem; color: #bbb; margin-top: 5px;} | |
| /* Analysis Chart Container */ | |
| .analysis-box { | |
| background: #252525; | |
| padding: 10px; | |
| border-radius: 8px; | |
| border: 1px solid #333; | |
| height: 200px; | |
| display: flex; | |
| flex-direction: column; | |
| position: relative; | |
| } | |
| #chartCanvas { | |
| width: 100%; | |
| height: 100%; | |
| cursor: crosshair; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="controls"> | |
| <div> | |
| <h2>Vector Architect</h2> | |
| <div class="subtitle">Universal Tiling & Padding Planner</div> | |
| </div> | |
| <!-- 1. Source Dimensions --> | |
| <div> | |
| <h3>1. Source Dimensions (px)</h3> | |
| <div class="input-group"> | |
| <div class="label-row"><span>Width</span></div> | |
| <div class="control-row"> | |
| <input type="range" id="slW" min="100" max="10000" step="10" value="1000"> | |
| <input type="number" id="numW" value="1000"> | |
| </div> | |
| </div> | |
| <div class="input-group"> | |
| <div class="label-row"><span>Height</span></div> | |
| <div class="control-row"> | |
| <input type="range" id="slH" min="100" max="10000" step="10" value="1000"> | |
| <input type="number" id="numH" value="1000"> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 2. Configuration --> | |
| <div> | |
| <h3>2. Tiling Configuration</h3> | |
| <div class="input-group"> | |
| <div class="label-row"><span>Tile Size (Kernel)</span></div> | |
| <div class="control-row"> | |
| <!-- Note: Tile size typically doesn't need a massive slider, usually 64-1024 --> | |
| <input type="range" id="slTile" min="32" max="1024" step="8" value="224"> | |
| <input type="number" id="numTile" value="224"> | |
| </div> | |
| </div> | |
| <div class="input-group"> | |
| <div class="label-row"><span>Padding (Zero-pad)</span></div> | |
| <div class="control-row"> | |
| <input type="range" id="slPad" min="0" max="500" value="0"> | |
| <input type="number" id="numPad" value="0"> | |
| </div> | |
| </div> | |
| <div class="input-group"> | |
| <div class="label-row"><span>Stride</span></div> | |
| <div class="control-row"> | |
| <input type="range" id="slStride" min="10" max="1024" value="100"> | |
| <input type="number" id="numStride" value="100"> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 3. Analysis --> | |
| <div style="flex-grow:1; display:flex; flex-direction:column;"> | |
| <h3>3. Impact Analysis (Log Scale)</h3> | |
| <div class="analysis-box"> | |
| <canvas id="chartCanvas"></canvas> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="viz-container"> | |
| <canvas id="mainCanvas"></canvas> | |
| <div class="overlay-stats"> | |
| <div class="big-stat" id="statTotal">0</div> | |
| <div class="sub-stat">Total Vectors</div> | |
| <div class="sub-stat" id="statGrid" style="font-size:0.8rem; margin-top:2px;">0 x 0 grid</div> | |
| </div> | |
| </div> | |
| <script> | |
| // --- UI State Management --- | |
| // Helper to bind slider and number box together | |
| function bindInputs(sliderId, numberId, onChange) { | |
| const s = document.getElementById(sliderId); | |
| const n = document.getElementById(numberId); | |
| s.addEventListener('input', () => { | |
| n.value = s.value; | |
| onChange(); | |
| }); | |
| n.addEventListener('input', () => { | |
| // Validation limits based on slider attributes | |
| let val = parseInt(n.value); | |
| if(isNaN(val)) return; | |
| // Optional: Clamp logic (commented out to allow free typing, but slider visual might cap) | |
| // if(val < parseInt(s.min)) val = parseInt(s.min); | |
| // if(val > parseInt(s.max)) val = parseInt(s.max); | |
| s.value = val; | |
| onChange(); | |
| }); | |
| return { s, n }; | |
| } | |
| const app = { | |
| w: bindInputs('slW', 'numW', updateAll), | |
| h: bindInputs('slH', 'numH', updateAll), | |
| t: bindInputs('slTile', 'numTile', updateAll), | |
| p: bindInputs('slPad', 'numPad', updateAll), | |
| str: bindInputs('slStride', 'numStride', updateAll), | |
| stats: { | |
| total: document.getElementById('statTotal'), | |
| grid: document.getElementById('statGrid') | |
| } | |
| }; | |
| const mainCanvas = document.getElementById('mainCanvas'); | |
| const mainCtx = mainCanvas.getContext('2d'); | |
| const chartCanvas = document.getElementById('chartCanvas'); | |
| const chartCtx = chartCanvas.getContext('2d'); | |
| // Store chart data for hover effects | |
| let chartDataPoints = []; | |
| let mouseX = -1; | |
| let isHoveringChart = false; | |
| function getState() { | |
| return { | |
| w: parseInt(app.w.n.value) || 100, | |
| h: parseInt(app.h.n.value) || 100, | |
| t: parseInt(app.t.n.value) || 64, | |
| p: parseInt(app.p.n.value) || 0, | |
| s: parseInt(app.str.n.value) || 10 | |
| }; | |
| } | |
| function calculateVectors(w, h, t, p, s) { | |
| if (s < 1) s = 1; | |
| const dimW = w + (p * 2); | |
| const dimH = h + (p * 2); | |
| // Convolution floor logic | |
| const xCount = Math.floor((dimW - t) / s) + 1; | |
| const yCount = Math.floor((dimH - t) / s) + 1; | |
| const x = Math.max(0, xCount); | |
| const y = Math.max(0, yCount); | |
| return { x, y, total: x * y }; | |
| } | |
| function updateAll() { | |
| drawViz(); | |
| prepareChartData(); // Calculate the curve | |
| drawChart(); // Render the curve + hover state | |
| } | |
| // --- Visualization (Main Canvas) --- | |
| function drawViz() { | |
| const s = getState(); | |
| const calcs = calculateVectors(s.w, s.h, s.t, s.p, s.s); | |
| // Update Text Stats | |
| app.stats.total.innerText = calcs.total.toLocaleString(); | |
| app.stats.grid.innerText = `${calcs.x} cols x ${calcs.y} rows`; | |
| // Fit logic | |
| const totalDrawW = s.w + (s.p * 2); | |
| const totalDrawH = s.h + (s.p * 2); | |
| const paddingMargin = 50; | |
| const containerW = mainCanvas.parentElement.clientWidth - paddingMargin; | |
| const containerH = mainCanvas.parentElement.clientHeight - paddingMargin; | |
| const scale = Math.min(containerW / totalDrawW, containerH / totalDrawH); | |
| mainCanvas.width = totalDrawW * scale; | |
| mainCanvas.height = totalDrawH * scale; | |
| mainCtx.clearRect(0,0, mainCanvas.width, mainCanvas.height); | |
| // 1. Padding Zone (Dashed) | |
| mainCtx.strokeStyle = '#555'; | |
| mainCtx.setLineDash([5, 5]); | |
| mainCtx.lineWidth = 1; | |
| mainCtx.strokeRect(0, 0, mainCanvas.width, mainCanvas.height); | |
| // 2. Source Image (Blue) | |
| const imgX = s.p * scale; | |
| const imgY = s.p * scale; | |
| const imgW = s.w * scale; | |
| const imgH = s.h * scale; | |
| mainCtx.fillStyle = 'rgba(59, 130, 246, 0.1)'; | |
| mainCtx.fillRect(imgX, imgY, imgW, imgH); | |
| mainCtx.setLineDash([]); | |
| mainCtx.lineWidth = 2; | |
| mainCtx.strokeStyle = '#3b82f6'; | |
| mainCtx.strokeRect(imgX, imgY, imgW, imgH); | |
| // 3. Tiles (Green) | |
| mainCtx.strokeStyle = 'rgba(16, 185, 129, 0.8)'; | |
| mainCtx.fillStyle = 'rgba(16, 185, 129, 0.2)'; | |
| // Performance mode | |
| const highCount = calcs.total > 3000; | |
| if(highCount) { | |
| mainCtx.lineWidth = 0.5; | |
| mainCtx.strokeStyle = 'rgba(16, 185, 129, 0.5)'; | |
| mainCtx.fillStyle = 'transparent'; | |
| } | |
| const tileScreenS = s.t * scale; | |
| const strideScreen = s.s * scale; | |
| // Limit drawing loop to prevent freeze on insane inputs | |
| if(calcs.total < 50000) { | |
| mainCtx.beginPath(); | |
| for(let x = 0; x < calcs.x; x++) { | |
| for(let y = 0; y < calcs.y; y++) { | |
| const drawX = x * strideScreen; | |
| const drawY = y * strideScreen; | |
| mainCtx.rect(drawX, drawY, tileScreenS, tileScreenS); | |
| } | |
| } | |
| if(!highCount) mainCtx.fill(); | |
| mainCtx.stroke(); | |
| } else { | |
| // Too many to draw | |
| mainCtx.fillStyle = 'rgba(255, 100, 100, 0.5)'; | |
| mainCtx.font = "20px Arial"; | |
| mainCtx.fillText("Rendering Limit Exceeded (>50k)", 20, 30); | |
| } | |
| } | |
| // --- Chart Logic --- | |
| // 1. Calculate Data Points (Separated from render for performance) | |
| function prepareChartData() { | |
| const s = getState(); | |
| chartDataPoints = []; | |
| // Graph range: Stride 10 -> TileSize * 1.5 | |
| const startStride = 10; | |
| const endStride = Math.floor(s.t * 1.5); | |
| const step = Math.max(1, Math.floor((endStride - startStride) / 100)); // ~100 points | |
| for (let st = startStride; st <= endStride; st += step) { | |
| const res = calculateVectors(s.w, s.h, s.t, s.p, st); | |
| chartDataPoints.push({ stride: st, count: res.total }); | |
| } | |
| } | |
| // 2. Render Chart | |
| function drawChart() { | |
| const cw = chartCanvas.parentElement.clientWidth; | |
| const ch = chartCanvas.parentElement.clientHeight; | |
| chartCanvas.width = cw; | |
| chartCanvas.height = ch; | |
| const ctx = chartCtx; | |
| ctx.clearRect(0, 0, cw, ch); | |
| if (chartDataPoints.length === 0) return; | |
| // Find min/max for scaling | |
| // Y-Axis: Logarithmic | |
| // X-Axis: Linear | |
| let maxCount = 0; | |
| let minCount = Infinity; | |
| chartDataPoints.forEach(p => { | |
| if(p.count > maxCount) maxCount = p.count; | |
| if(p.count < minCount) minCount = p.count; | |
| }); | |
| if(maxCount === 0) maxCount = 1; | |
| // Log(0) is undefined, so ensure min is at least 1 for log math | |
| const logMin = Math.log10(Math.max(1, minCount)); | |
| const logMax = Math.log10(Math.max(10, maxCount)); // Ensure some height | |
| const xMin = chartDataPoints[0].stride; | |
| const xMax = chartDataPoints[chartDataPoints.length - 1].stride; | |
| const pad = 30; | |
| const graphW = cw - pad * 2; | |
| const graphH = ch - pad * 2; | |
| // Helper to map coordinates | |
| const getX = (stride) => pad + ((stride - xMin) / (xMax - xMin)) * graphW; | |
| const getY = (count) => { | |
| // Log Scale Normalization | |
| const val = Math.log10(Math.max(1, count)); | |
| const pct = (val - logMin) / (logMax - logMin || 1); | |
| return (ch - pad) - (pct * graphH); | |
| }; | |
| // Draw Line | |
| ctx.beginPath(); | |
| ctx.strokeStyle = '#3b82f6'; | |
| ctx.lineWidth = 2; | |
| chartDataPoints.forEach((pt, i) => { | |
| const x = getX(pt.stride); | |
| const y = getY(pt.count); | |
| if(i === 0) ctx.moveTo(x, y); | |
| else ctx.lineTo(x, y); | |
| }); | |
| ctx.stroke(); | |
| // Draw Current Position Dot (Real State) | |
| const currentS = getState().s; | |
| const currentTotal = calculateVectors(getState().w, getState().h, getState().t, getState().p, currentS).total; | |
| if (currentS >= xMin && currentS <= xMax) { | |
| const cx = getX(currentS); | |
| const cy = getY(currentTotal); | |
| ctx.fillStyle = '#fff'; | |
| ctx.beginPath(); | |
| ctx.arc(cx, cy, 4, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| // --- Interaction: Hover Overlay --- | |
| if (isHoveringChart && mouseX >= pad && mouseX <= cw - pad) { | |
| // 1. Find nearest data point based on mouseX | |
| // Inverse Map X to Stride | |
| const ratio = (mouseX - pad) / graphW; | |
| const targetStride = xMin + (ratio * (xMax - xMin)); | |
| // Find closest point in array | |
| const closest = chartDataPoints.reduce((prev, curr) => { | |
| return (Math.abs(curr.stride - targetStride) < Math.abs(prev.stride - targetStride) ? curr : prev); | |
| }); | |
| const cx = getX(closest.stride); | |
| const cy = getY(closest.count); | |
| // 2. Draw Crosshair | |
| ctx.beginPath(); | |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; | |
| ctx.lineWidth = 1; | |
| ctx.setLineDash([4, 4]); | |
| // Vertical line | |
| ctx.moveTo(cx, pad); | |
| ctx.lineTo(cx, ch - pad); | |
| // Horizontal line | |
| ctx.moveTo(pad, cy); | |
| ctx.lineTo(cw - pad, cy); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| // 3. Draw Tooltip Box | |
| const text = `Stride: ${closest.stride} | Count: ${closest.count.toLocaleString()}`; | |
| ctx.font = "12px monospace"; | |
| const tw = ctx.measureText(text).width + 10; | |
| const th = 24; | |
| let tx = cx + 10; | |
| let ty = cy - 30; | |
| // Boundary checks | |
| if(tx + tw > cw) tx = cx - tw - 10; | |
| if(ty < 0) ty = cy + 10; | |
| ctx.fillStyle = "rgba(0,0,0,0.8)"; | |
| ctx.fillRect(tx, ty, tw, th); | |
| ctx.strokeStyle = "#555"; | |
| ctx.strokeRect(tx, ty, tw, th); | |
| ctx.fillStyle = "#fff"; | |
| ctx.fillText(text, tx + 5, ty + 16); | |
| // Highlight dot | |
| ctx.fillStyle = "var(--accent)"; | |
| ctx.beginPath(); | |
| ctx.arc(cx, cy, 5, 0, Math.PI*2); | |
| ctx.fill(); | |
| } | |
| } | |
| // --- Chart Event Listeners --- | |
| chartCanvas.addEventListener('mousemove', (e) => { | |
| const rect = chartCanvas.getBoundingClientRect(); | |
| mouseX = e.clientX - rect.left; | |
| isHoveringChart = true; | |
| drawChart(); // Redraw to show overlay | |
| }); | |
| chartCanvas.addEventListener('mouseleave', () => { | |
| isHoveringChart = false; | |
| drawChart(); // Redraw to clear overlay | |
| }); | |
| window.addEventListener('resize', updateAll); | |
| // Init | |
| updateAll(); | |
| </script> | |
| </body> | |
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Precision Tiling Planner</title> | |
| <style> | |
| :root { | |
| --bg: #121212; | |
| --panel: #1e1e1e; | |
| --border: #333; | |
| --text: #e0e0e0; | |
| --accent: #3b82f6; /* Blue */ | |
| --tile: #10b981; /* Emerald */ | |
| --chart-line: #f59e0b; /* Amber */ | |
| --grid: #333; | |
| } | |
| body { | |
| font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; | |
| background-color: var(--bg); | |
| color: var(--text); | |
| margin: 0; | |
| display: flex; | |
| flex-direction: column; | |
| height: 100vh; | |
| overflow: hidden; | |
| } | |
| /* --- Top Section: Controls & Viz --- */ | |
| .top-section { | |
| display: flex; | |
| flex: 1; | |
| min-height: 0; /* allows flex shrink */ | |
| } | |
| /* Sidebar Controls */ | |
| .controls { | |
| width: 320px; | |
| background-color: var(--panel); | |
| padding: 20px; | |
| overflow-y: auto; | |
| border-right: 1px solid var(--border); | |
| display: flex; | |
| flex-direction: column; | |
| gap: 20px; | |
| box-shadow: 2px 0 10px rgba(0,0,0,0.3); | |
| z-index: 10; | |
| } | |
| h2 { margin: 0 0 5px 0; font-size: 1.1rem; color: #fff; } | |
| .subtitle { font-size: 0.75rem; color: #777; margin-bottom: 5px; } | |
| h3 { font-size: 0.75rem; text-transform: uppercase; color: #888; border-bottom: 1px solid #333; padding-bottom: 5px; margin-bottom: 10px; } | |
| .control-group { margin-bottom: 15px; } | |
| .label-row { display: flex; justify-content: space-between; font-size: 0.8rem; color: #ccc; margin-bottom: 5px; } | |
| /* Dual Input Styling */ | |
| .dual-input { display: flex; align-items: center; gap: 8px; } | |
| input[type="range"] { flex-grow: 1; cursor: pointer; } | |
| input[type="number"] { | |
| width: 70px; | |
| background: #2a2a2a; | |
| border: 1px solid #444; | |
| color: var(--accent); | |
| padding: 4px 6px; | |
| border-radius: 4px; | |
| font-family: monospace; | |
| text-align: right; | |
| } | |
| input[type="number"]:focus { outline: none; border-color: var(--accent); } | |
| /* Visualization Area */ | |
| .viz-container { | |
| flex-grow: 1; | |
| position: relative; | |
| background-image: radial-gradient(#2d2d2d 1px, transparent 1px); | |
| background-size: 20px 20px; | |
| background-color: #181818; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| overflow: hidden; | |
| } | |
| #mainCanvas { | |
| box-shadow: 0 0 30px rgba(0,0,0,0.5); | |
| border: 1px solid #444; | |
| } | |
| /* --- Bottom Section: Analytics --- */ | |
| .bottom-panel { | |
| height: 220px; | |
| background-color: var(--panel); | |
| border-top: 1px solid var(--border); | |
| display: flex; | |
| padding: 0; | |
| box-shadow: 0 -4px 15px rgba(0,0,0,0.2); | |
| z-index: 20; | |
| } | |
| /* Stats Grid (Left of Bottom) */ | |
| .stats-container { | |
| width: 320px; /* Match sidebar width */ | |
| padding: 20px; | |
| border-right: 1px solid var(--border); | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| grid-template-rows: 1fr 1fr; | |
| gap: 15px; | |
| } | |
| .stat-box { display: flex; flex-direction: column; justify-content: center; } | |
| .stat-label { font-size: 0.75rem; color: #888; text-transform: uppercase; margin-bottom: 4px; } | |
| .stat-val { font-size: 1.1rem; font-weight: bold; color: #fff; font-family: monospace; } | |
| .stat-sub { font-size: 0.7rem; color: #666; } | |
| /* Chart Area (Right of Bottom) */ | |
| .chart-container { | |
| flex-grow: 1; | |
| padding: 15px 20px; | |
| display: flex; | |
| flex-direction: column; | |
| position: relative; | |
| } | |
| .chart-header { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 0.8rem; | |
| color: #aaa; | |
| margin-bottom: 5px; | |
| } | |
| #chartCanvas { | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0,0,0,0.2); | |
| border-radius: 4px; | |
| cursor: crosshair; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- TOP SECTION: Controls + Viz --> | |
| <div class="top-section"> | |
| <!-- Controls Sidebar --> | |
| <div class="controls"> | |
| <div> | |
| <h2>Vector Architect</h2> | |
| <div class="subtitle">Precision Tiling Tool</div> | |
| </div> | |
| <div> | |
| <h3>Source Dimensions</h3> | |
| <div class="control-group"> | |
| <div class="label-row"><span>Width (px)</span></div> | |
| <div class="dual-input"> | |
| <input type="range" id="sl_W" min="100" max="8000" value="1000"> | |
| <input type="number" id="nb_W" value="1000"> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <div class="label-row"><span>Height (px)</span></div> | |
| <div class="dual-input"> | |
| <input type="range" id="sl_H" min="100" max="8000" value="1000"> | |
| <input type="number" id="nb_H" value="1000"> | |
| </div> | |
| </div> | |
| </div> | |
| <div> | |
| <h3>Configuration</h3> | |
| <div class="control-group"> | |
| <div class="label-row"><span>Tile Size (px)</span></div> | |
| <div class="dual-input"> | |
| <input type="range" id="sl_T" min="32" max="1024" value="224"> | |
| <input type="number" id="nb_T" value="224"> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <div class="label-row"><span>Padding (px)</span></div> | |
| <div class="dual-input"> | |
| <input type="range" id="sl_P" min="0" max="500" value="0"> | |
| <input type="number" id="nb_P" value="0"> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <div class="label-row"><span>Stride (px)</span></div> | |
| <div class="dual-input"> | |
| <input type="range" id="sl_S" min="10" max="1024" value="100"> | |
| <input type="number" id="nb_S" value="100"> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Canvas Viz --> | |
| <div class="viz-container"> | |
| <canvas id="mainCanvas"></canvas> | |
| </div> | |
| </div> | |
| <!-- BOTTOM SECTION: Stats & Chart --> | |
| <div class="bottom-panel"> | |
| <!-- Stats Grid --> | |
| <div class="stats-container"> | |
| <div class="stat-box"> | |
| <div class="stat-label">Total Tiles</div> | |
| <div class="stat-val" id="valTotal" style="color: var(--accent)">0</div> | |
| <div class="stat-sub">Vectors Generated</div> | |
| </div> | |
| <div class="stat-box"> | |
| <div class="stat-label">Grid Layout</div> | |
| <div class="stat-val" id="valGrid">0 x 0</div> | |
| <div class="stat-sub">Cols x Rows</div> | |
| </div> | |
| <div class="stat-box"> | |
| <div class="stat-label">Abs. Coverage</div> | |
| <div class="stat-val" id="valAbsCov">0</div> | |
| <div class="stat-sub">Total Pixels (Sq)</div> | |
| </div> | |
| <div class="stat-box"> | |
| <div class="stat-label">Coverage %</div> | |
| <div class="stat-val" id="valPctCov">0%</div> | |
| <div class="stat-sub" id="subPctCov">of source area</div> | |
| </div> | |
| </div> | |
| <!-- Interactive Log Chart --> | |
| <div class="chart-container"> | |
| <div class="chart-header"> | |
| <strong>Impact Analysis</strong> | |
| <span id="chart-tooltip" style="color:var(--chart-line); font-family:monospace;">Hover chart for details</span> | |
| </div> | |
| <canvas id="chartCanvas"></canvas> | |
| </div> | |
| </div> | |
| <script> | |
| // --- Elements & Inputs --- | |
| // Helper to get sync'd elements | |
| const getEl = (id) => document.getElementById(id); | |
| const pairs = [ | |
| { sl: getEl('sl_W'), nb: getEl('nb_W'), key: 'w' }, | |
| { sl: getEl('sl_H'), nb: getEl('nb_H'), key: 'h' }, | |
| { sl: getEl('sl_T'), nb: getEl('nb_T'), key: 't' }, | |
| { sl: getEl('sl_P'), nb: getEl('nb_P'), key: 'p' }, | |
| { sl: getEl('sl_S'), nb: getEl('nb_S'), key: 's' }, | |
| ]; | |
| const ui = { | |
| valTotal: getEl('valTotal'), | |
| valGrid: getEl('valGrid'), | |
| valAbsCov: getEl('valAbsCov'), | |
| valPctCov: getEl('valPctCov'), | |
| subPctCov: getEl('subPctCov'), | |
| chartTip: getEl('chart-tooltip') | |
| }; | |
| const mainCanvas = getEl('mainCanvas'); | |
| const mainCtx = mainCanvas.getContext('2d'); | |
| const chartCanvas = getEl('chartCanvas'); | |
| const chartCtx = chartCanvas.getContext('2d'); | |
| // Chart Data Cache | |
| let chartDataPoints = []; | |
| let chartMaxY = 0; | |
| let chartMinY = 0; | |
| // --- Synchronization Logic --- | |
| pairs.forEach(pair => { | |
| // Slider changes Number | |
| pair.sl.addEventListener('input', () => { | |
| pair.nb.value = pair.sl.value; | |
| update(); | |
| }); | |
| // Number changes Slider | |
| pair.nb.addEventListener('input', () => { | |
| pair.sl.value = pair.nb.value; | |
| update(); | |
| }); | |
| }); | |
| function getState() { | |
| return { | |
| w: parseInt(getEl('nb_W').value) || 100, | |
| h: parseInt(getEl('nb_H').value) || 100, | |
| t: parseInt(getEl('nb_T').value) || 64, | |
| p: parseInt(getEl('nb_P').value) || 0, | |
| s: parseInt(getEl('nb_S').value) || 50 | |
| }; | |
| } | |
| // --- Core Math --- | |
| function calculateStats(w, h, t, p, s) { | |
| if (s < 1) s = 1; | |
| // Padded Dimensions | |
| const effW = w + (p * 2); | |
| const effH = h + (p * 2); | |
| // Grid calculation | |
| // Formula: floor((W - K) / S) + 1 | |
| // Note: If K > W, count is usually 0, but standard sliding window might take 1 if padded. | |
| // Here we assume simple sliding window logic. | |
| const cols = effW < t ? 0 : Math.floor((effW - t) / s) + 1; | |
| const rows = effH < t ? 0 : Math.floor((effH - t) / s) + 1; | |
| const total = cols * rows; | |
| // Coverage Stats | |
| const areaTile = t * t; | |
| const totalCoveredPx = total * areaTile; | |
| const sourceArea = w * h; | |
| const pct = sourceArea > 0 ? (totalCoveredPx / sourceArea) * 100 : 0; | |
| return { cols, rows, total, totalCoveredPx, pct }; | |
| } | |
| // --- Main Update Loop --- | |
| function update() { | |
| const state = getState(); | |
| const stats = calculateStats(state.w, state.h, state.t, state.p, state.s); | |
| // 1. Update Text Stats | |
| ui.valTotal.innerText = stats.total.toLocaleString(); | |
| ui.valGrid.innerText = `${stats.cols} x ${stats.rows}`; | |
| // Intl.NumberFormat for nice comma separation | |
| ui.valAbsCov.innerText = new Intl.NumberFormat().format(stats.totalCoveredPx) + " px²"; | |
| ui.valPctCov.innerText = stats.pct.toFixed(1) + "%"; | |
| if(stats.pct > 100) { | |
| ui.valPctCov.style.color = "#f59e0b"; // Warn color for heavy overlap | |
| ui.subPctCov.innerText = "High Overlap (>100%)"; | |
| } else { | |
| ui.valPctCov.style.color = "#fff"; | |
| ui.subPctCov.innerText = "of source area"; | |
| } | |
| // 2. Draw Viz | |
| drawVisualization(state, stats); | |
| // 3. Prepare Chart Data (but don't draw on mouse move, only on input change) | |
| prepareChartData(state); | |
| drawChart(); // Initial draw without mouse info | |
| } | |
| // --- Visualization --- | |
| function drawVisualization(st, stats) { | |
| // Setup Canvas Size | |
| const padDraw = 40; | |
| const containerW = mainCanvas.parentElement.clientWidth - padDraw; | |
| const containerH = mainCanvas.parentElement.clientHeight - padDraw; | |
| const totalW = st.w + (st.p * 2); | |
| const totalH = st.h + (st.p * 2); | |
| // Scale | |
| const scale = Math.min(containerW / totalW, containerH / totalH); | |
| mainCanvas.width = totalW * scale; | |
| mainCanvas.height = totalH * scale; | |
| mainCtx.clearRect(0,0, mainCanvas.width, mainCanvas.height); | |
| // 1. Padding Area (Dashed Box) | |
| mainCtx.strokeStyle = '#555'; | |
| mainCtx.setLineDash([5, 5]); | |
| mainCtx.lineWidth = 1; | |
| mainCtx.strokeRect(0, 0, mainCanvas.width, mainCanvas.height); | |
| // 2. Source Image (Blue Fill) | |
| const imgX = st.p * scale; | |
| const imgY = st.p * scale; | |
| const imgW = st.w * scale; | |
| const imgH = st.h * scale; | |
| mainCtx.fillStyle = 'rgba(59, 130, 246, 0.15)'; | |
| mainCtx.fillRect(imgX, imgY, imgW, imgH); | |
| mainCtx.setLineDash([]); | |
| mainCtx.strokeStyle = '#3b82f6'; | |
| mainCtx.lineWidth = 2; | |
| mainCtx.strokeRect(imgX, imgY, imgW, imgH); | |
| // 3. Tiles (Green) | |
| mainCtx.strokeStyle = 'rgba(16, 185, 129, 0.6)'; | |
| // Optimize fill if too many | |
| mainCtx.fillStyle = stats.total > 1500 ? 'transparent' : 'rgba(16, 185, 129, 0.15)'; | |
| // If extremely high count, reduce stroke opacity | |
| if(stats.total > 5000) mainCtx.strokeStyle = 'rgba(16, 185, 129, 0.2)'; | |
| const tSize = st.t * scale; | |
| const stride = st.s * scale; | |
| mainCtx.lineWidth = 1; | |
| // Prevent browser freeze on massive numbers | |
| if(stats.total < 20000) { | |
| for(let c=0; c<stats.cols; c++) { | |
| for(let r=0; r<stats.rows; r++) { | |
| mainCtx.fillRect(c*stride, r*stride, tSize, tSize); | |
| mainCtx.strokeRect(c*stride, r*stride, tSize, tSize); | |
| } | |
| } | |
| } | |
| } | |
| // --- Chart Logic --- | |
| function prepareChartData(st) { | |
| chartDataPoints = []; | |
| // Generate points for Stride = 10 to Stride = TileSize * 1.5 | |
| const maxStride = Math.floor(st.t * 1.5); | |
| const startStride = 10; | |
| // To make graph performant, don't calc every integer if range is huge | |
| const step = maxStride > 500 ? 5 : 2; | |
| for(let s = startStride; s <= maxStride; s += step) { | |
| const res = calculateStats(st.w, st.h, st.t, st.p, s); | |
| chartDataPoints.push({ s: s, count: res.total }); | |
| } | |
| // Find Min/Max for Log scaling | |
| const counts = chartDataPoints.map(p => p.count); | |
| // Avoid log(0) | |
| chartMinY = Math.max(1, Math.min(...counts)); | |
| chartMaxY = Math.max(10, Math.max(...counts)); | |
| } | |
| function drawChart(mouseX = -1) { | |
| const cw = chartCanvas.parentElement.clientWidth - 40; // padding inside container | |
| const ch = chartCanvas.parentElement.clientHeight - 30; // header space | |
| chartCanvas.width = cw; | |
| chartCanvas.height = ch; | |
| const ctx = chartCtx; | |
| ctx.clearRect(0, 0, cw, ch); | |
| if(chartDataPoints.length === 0) return; | |
| // Log Math Helpers | |
| const logMin = Math.log10(chartMinY); | |
| const logMax = Math.log10(chartMaxY); | |
| const logRange = logMax - logMin; | |
| const getY = (count) => { | |
| const val = Math.max(1, count); | |
| const logVal = Math.log10(val); | |
| const pct = (logVal - logMin) / (logRange || 1); // avoid div 0 | |
| return ch - (pct * ch); // Flip Y | |
| }; | |
| const getX = (strideIndex) => { | |
| return (strideIndex / (chartDataPoints.length - 1)) * cw; | |
| }; | |
| // Draw Line | |
| ctx.beginPath(); | |
| ctx.strokeStyle = '#f59e0b'; | |
| ctx.lineWidth = 2; | |
| chartDataPoints.forEach((pt, i) => { | |
| const x = getX(i); | |
| const y = getY(pt.count); | |
| if(i===0) ctx.moveTo(x, y); | |
| else ctx.lineTo(x, y); | |
| }); | |
| ctx.stroke(); | |
| // Draw Current Stride Position Dot | |
| const state = getState(); | |
| const currentPt = chartDataPoints.find(p => Math.abs(p.s - state.s) < 3) | |
| || chartDataPoints.reduce((prev, curr) => Math.abs(curr.s - state.s) < Math.abs(prev.s - state.s) ? curr : prev); | |
| if(currentPt) { | |
| const idx = chartDataPoints.indexOf(currentPt); | |
| const cx = getX(idx); | |
| const cy = getY(currentPt.count); | |
| ctx.fillStyle = '#fff'; | |
| ctx.beginPath(); | |
| ctx.arc(cx, cy, 5, 0, Math.PI*2); | |
| ctx.fill(); | |
| } | |
| // --- Mouse Interaction --- | |
| if(mouseX >= 0) { | |
| // Find closest index to mouseX | |
| const pct = mouseX / cw; | |
| let idx = Math.floor(pct * (chartDataPoints.length - 1)); | |
| if(idx < 0) idx = 0; | |
| if(idx >= chartDataPoints.length) idx = chartDataPoints.length - 1; | |
| const hoverPt = chartDataPoints[idx]; | |
| const hx = getX(idx); | |
| const hy = getY(hoverPt.count); | |
| // Draw Crosshair | |
| ctx.strokeStyle = 'rgba(255,255,255,0.3)'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(hx, 0); ctx.lineTo(hx, ch); | |
| ctx.moveTo(0, hy); ctx.lineTo(cw, hy); | |
| ctx.stroke(); | |
| // Draw Tooltip text on UI (not canvas text for crispness, but here we update DOM) | |
| ui.chartTip.innerHTML = `Stride: <span style="color:#fff">${hoverPt.s}px</span> | Count: <span style="color:#fff">${hoverPt.count.toLocaleString()}</span>`; | |
| // Draw circle at hover | |
| ctx.fillStyle = 'rgba(255,255,255,0.8)'; | |
| ctx.beginPath(); | |
| ctx.arc(hx, hy, 4, 0, Math.PI*2); | |
| ctx.fill(); | |
| } else { | |
| // Reset tooltip | |
| ui.chartTip.innerHTML = "Hover chart for details"; | |
| } | |
| } | |
| // Chart Mouse Events | |
| chartCanvas.addEventListener('mousemove', (e) => { | |
| const rect = chartCanvas.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| drawChart(x); | |
| }); | |
| chartCanvas.addEventListener('mouseleave', () => { | |
| drawChart(-1); | |
| }); | |
| // Init | |
| update(); | |
| window.addEventListener('resize', () => { | |
| update(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment