|
1 | 1 | /* ============================================================ |
2 | 2 | PLOTFLOW · Live Plot component |
3 | | - Animates a suit's single continuous path being drawn by a |
4 | | - virtual AxiDraw, with a moving pen head + live readouts. |
| 3 | + Progressively strokes a suit's path onto a <canvas> the way an |
| 4 | + AxiDraw would — walking the geometry with getPointAtLength and |
| 5 | + lifting the pen at subpath breaks — with a moving pen head + HUD. |
| 6 | + (A CSS stroke-dashoffset reveal is unreliable here: each suit's |
| 7 | + path has thousands of M-command subpaths, which the dash trick |
| 8 | + renders all at once.) |
5 | 9 | Depends on: window.PLOTFLOW (data/editions.js) |
6 | 10 | Exposes: window.PlotflowPlotter.load(key) |
7 | 11 | ============================================================ */ |
|
11 | 15 |
|
12 | 16 | var $ = function (id) { return document.getElementById(id); }; |
13 | 17 | var stage = $('stage'), ppath = $('ppath'), pen = $('pen'), penC = $('pen-c'), penX = $('pen-x'); |
14 | | - if (!stage || !ppath) return; // plotter markup not on this page |
| 18 | + var canvas = $('pcanvas'); |
| 19 | + if (!stage || !ppath || !canvas) return; // plotter markup not on this page |
| 20 | + var ctx = canvas.getContext('2d'); |
15 | 21 |
|
16 | 22 | var pbar = $('pbar'), pct = $('pct'), cx = $('cx'), cy = $('cy'); |
17 | 23 | var ink = $('ink'), inktot = $('inktot'), elapsed = $('elapsed'), total = $('total'); |
18 | 24 | var bed = $('bed'), bedlabel = $('bedlabel'), selSuit = $('suit'), playBtn = $('play'), replay = $('replay'); |
19 | 25 |
|
20 | 26 | var FEED = 1100; // mm/min, shown in HUD + used for plot-time |
21 | 27 | var BASE = 22; // seconds for a full plot at 1x speed |
22 | | - var cur, len = 1, drawn = 0, playing = true, speed = 1, mmPerUnit = 1, totalMin = 1; |
| 28 | + var INK = '#e8351f'; |
| 29 | + var cur, len = 1, drawn = 0, painted = 0, playing = true, speed = 1, mmPerUnit = 1, totalMin = 1; |
| 30 | + var vb = { x: 0, y: 0, w: 1, h: 1 }; |
| 31 | + var tf = { s: 1, ox: 0, oy: 0, dpr: 1, ready: false }; |
| 32 | + var prevPt = null; |
23 | 33 |
|
24 | 34 | if (selSuit) { |
25 | 35 | ORDER.forEach(function (k) { |
|
34 | 44 | return String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0'); |
35 | 45 | } |
36 | 46 |
|
| 47 | + // Map a point in suit/user coordinates to canvas device pixels, matching the |
| 48 | + // SVG stage's preserveAspectRatio="xMidYMid meet" so the pen lines up. |
| 49 | + function mapX(ux) { return (tf.ox + (ux - vb.x) * tf.s) * tf.dpr; } |
| 50 | + function mapY(uy) { return (tf.oy + (uy - vb.y) * tf.s) * tf.dpr; } |
| 51 | + |
| 52 | + // Size the canvas backing store to the bed and (re)derive the meet transform. |
| 53 | + // Resizing the backing store clears it, so this also resets the ink. |
| 54 | + function resetCanvas() { |
| 55 | + var w = canvas.clientWidth, h = canvas.clientHeight; |
| 56 | + tf.ready = w > 0 && h > 0; |
| 57 | + if (!tf.ready) return; |
| 58 | + tf.dpr = window.devicePixelRatio || 1; |
| 59 | + canvas.width = Math.round(w * tf.dpr); |
| 60 | + canvas.height = Math.round(h * tf.dpr); |
| 61 | + tf.s = Math.min(w / vb.w, h / vb.h); |
| 62 | + tf.ox = (w - vb.w * tf.s) / 2; |
| 63 | + tf.oy = (h - vb.h * tf.s) / 2; |
| 64 | + ctx.setTransform(1, 0, 0, 1, 0, 0); |
| 65 | + ctx.clearRect(0, 0, canvas.width, canvas.height); |
| 66 | + ctx.strokeStyle = INK; |
| 67 | + ctx.lineWidth = Math.max(1, 1.2 * tf.dpr); |
| 68 | + ctx.lineJoin = 'round'; |
| 69 | + ctx.lineCap = 'round'; |
| 70 | + painted = 0; |
| 71 | + prevPt = null; |
| 72 | + } |
| 73 | + |
| 74 | + // Stroke ink from `painted` up to `target` length, sampling the path and |
| 75 | + // lifting the pen (moveTo) wherever the geometry jumps between subpaths. |
| 76 | + function paintTo(target) { |
| 77 | + if (!tf.ready || target <= painted) return; |
| 78 | + var step = Math.max(0.5, 2 / tf.s); // ~2 CSS px between samples |
| 79 | + var liftSq = (step * 3) * (step * 3); // a jump this big ⇒ pen lift |
| 80 | + ctx.beginPath(); |
| 81 | + if (prevPt) ctx.moveTo(mapX(prevPt.x), mapY(prevPt.y)); |
| 82 | + var L = painted; |
| 83 | + while (L < target) { |
| 84 | + L = Math.min(target, L + step); |
| 85 | + var p = ppath.getPointAtLength(L); |
| 86 | + if (!prevPt) { ctx.moveTo(mapX(p.x), mapY(p.y)); } |
| 87 | + else { |
| 88 | + var dx = p.x - prevPt.x, dy = p.y - prevPt.y; |
| 89 | + if (dx * dx + dy * dy > liftSq) ctx.moveTo(mapX(p.x), mapY(p.y)); |
| 90 | + else ctx.lineTo(mapX(p.x), mapY(p.y)); |
| 91 | + } |
| 92 | + prevPt = p; |
| 93 | + } |
| 94 | + ctx.stroke(); |
| 95 | + painted = target; |
| 96 | + } |
| 97 | + |
37 | 98 | function load(key) { |
38 | 99 | cur = DATA[key]; if (!cur) return; |
39 | 100 | if (selSuit) selSuit.value = key; |
40 | | - stage.setAttribute('viewBox', '0 0 ' + cur.w + ' ' + cur.h); |
41 | | - bed.style.aspectRatio = cur.w + '/' + cur.h; |
42 | 101 | ppath.setAttribute('d', cur.d); |
| 102 | + |
| 103 | + // Crop the stage to the suit's bounding box (+ padding) so every suit is |
| 104 | + // drawn large and centred on the same sheet, instead of being letterboxed |
| 105 | + // inside the mostly-empty original canvas. The bed keeps its fixed CSS |
| 106 | + // shape so the paper is consistent across all editions. |
| 107 | + vb = { x: 0, y: 0, w: cur.w, h: cur.h }; |
| 108 | + try { |
| 109 | + var bb = ppath.getBBox(); |
| 110 | + var pad = Math.max(bb.width, bb.height) * 0.06; |
| 111 | + vb = { x: bb.x - pad, y: bb.y - pad, w: bb.width + pad * 2, h: bb.height + pad * 2 }; |
| 112 | + } catch (e) { /* getBBox unavailable — keep full-canvas viewBox */ } |
| 113 | + stage.setAttribute('viewBox', vb.x + ' ' + vb.y + ' ' + vb.w + ' ' + vb.h); |
| 114 | + |
43 | 115 | len = ppath.getTotalLength(); if (!len || !isFinite(len)) len = 1; |
44 | | - var md = Math.max(cur.w, cur.h); |
| 116 | + var md = Math.max(vb.w, vb.h); |
45 | 117 | mmPerUnit = 420 / md; // longest side ≈ 420mm |
46 | 118 | totalMin = (len * mmPerUnit) / FEED; |
47 | 119 | penC.setAttribute('r', md * 0.014); |
48 | 120 | penX.setAttribute('d', 'M ' + (-md * 0.032) + ' 0 H ' + (md * 0.032) + ' M 0 ' + (-md * 0.032) + ' V ' + (md * 0.032)); |
49 | | - ppath.style.strokeDasharray = len; |
50 | | - ppath.style.strokeDashoffset = len; // hidden until plotting begins |
51 | 121 | pen.setAttribute('opacity', '0'); |
52 | 122 | if (inktot) inktot.textContent = (len * mmPerUnit / 1000).toFixed(2); |
53 | 123 | if (total) total.textContent = fmt(totalMin); |
54 | 124 | if (bedlabel) bedlabel.textContent = 'BED 01 · ' + cur.code; |
55 | | - drawn = 0; playing = true; |
| 125 | + drawn = 0; playing = true; last = null; // reset the clock → always start from a blank sheet |
| 126 | + resetCanvas(); |
56 | 127 | if (replay) replay.classList.remove('show'); |
57 | 128 | if (playBtn) playBtn.textContent = 'Pause'; |
58 | 129 | render(); |
59 | 130 | } |
60 | 131 |
|
61 | 132 | function render() { |
| 133 | + if (!tf.ready) resetCanvas(); // self-heal if the bed wasn't laid out yet |
62 | 134 | var p = drawn / len; |
63 | | - ppath.style.strokeDashoffset = (len - drawn); |
| 135 | + paintTo(drawn); |
64 | 136 | if (pbar) pbar.style.width = (p * 100) + '%'; |
65 | 137 | if (pct) pct.textContent = Math.round(p * 100); |
66 | 138 | if (ink) ink.textContent = (drawn * mmPerUnit / 1000).toFixed(2); |
|
76 | 148 | } |
77 | 149 | } |
78 | 150 |
|
| 151 | + // Re-fit + repaint the current progress when the bed changes size. |
| 152 | + function refit() { resetCanvas(); paintTo(drawn); } |
| 153 | + window.addEventListener('resize', refit); |
| 154 | + |
| 155 | + function restart() { |
| 156 | + drawn = 0; playing = true; last = null; |
| 157 | + resetCanvas(); render(); |
| 158 | + if (replay) replay.classList.remove('show'); |
| 159 | + if (playBtn) playBtn.textContent = 'Pause'; |
| 160 | + } |
| 161 | + |
79 | 162 | var last = null; |
80 | 163 | function frame(t) { |
81 | 164 | if (last == null) last = t; |
|
93 | 176 | } |
94 | 177 |
|
95 | 178 | function toggle() { |
96 | | - if (drawn >= len) drawn = 0; |
| 179 | + if (drawn >= len) { restart(); return; } |
97 | 180 | playing = !playing; |
98 | 181 | if (playBtn) playBtn.textContent = playing ? 'Pause' : 'Resume'; |
99 | 182 | if (replay) replay.classList.remove('show'); |
100 | 183 | } |
101 | 184 | if (playBtn) playBtn.onclick = toggle; |
102 | | - if ($('restart')) $('restart').onclick = function () { drawn = 0; playing = true; if (replay) replay.classList.remove('show'); if (playBtn) playBtn.textContent = 'Pause'; }; |
| 185 | + if ($('restart')) $('restart').onclick = restart; |
103 | 186 | if ($('skip')) $('skip').onclick = function () { drawn = len; playing = false; render(); if (replay) replay.classList.add('show'); if (playBtn) playBtn.textContent = 'Replay'; }; |
104 | 187 | if ($('speed')) $('speed').addEventListener('click', function (e) { |
105 | 188 | var b = e.target.closest('button'); if (!b) return; |
106 | 189 | [].forEach.call(e.currentTarget.children, function (x) { x.classList.remove('on'); }); |
107 | 190 | b.classList.add('on'); speed = parseFloat(b.dataset.s); |
108 | 191 | }); |
109 | 192 | if (selSuit) selSuit.addEventListener('change', function (e) { load(e.target.value); }); |
110 | | - if (replay) replay.addEventListener('click', function () { drawn = 0; playing = true; replay.classList.remove('show'); if (playBtn) playBtn.textContent = 'Pause'; }); |
| 193 | + if (replay) replay.addEventListener('click', restart); |
111 | 194 |
|
112 | 195 | // public API (used by the shop "▶ Plot" buttons) |
113 | 196 | window.PlotflowPlotter = { load: load }; |
|
0 commit comments