|
| 1 | +// input.js - pointer/wheel pan-zoom handlers, colour marking gestures, and |
| 2 | +// legend drag-to-scroll. |
| 3 | + |
| 4 | +import { stage, view, applyZoom, clampView, draw, fit, cellAtClient, setSymbolsOn, symbolsOn } from './render.js'; |
| 5 | +import { state, colourAt, isStitched, setStitched, hasTieOff, setTieOff, logEvent } from './state.js'; |
| 6 | +import { markDirty } from './persistence.js'; |
| 7 | +import { refreshUI, getBlockIdx, setBlockIdx } from './ui.js'; |
| 8 | +import { invalidateRoute, requestRoute } from './planner.js'; |
| 9 | +import { blockOf, blockOrderList, blockIsCompleteForColour, gotoBlock } from './blocks.js'; |
| 10 | + |
| 11 | +/* ---------- marking mode toggle ---------- */ |
| 12 | +let markMode = false; |
| 13 | +const markToggle = document.getElementById('markToggle'); |
| 14 | +markToggle.addEventListener('click', ()=>{ |
| 15 | + markMode = !markMode; |
| 16 | + if (markMode) disarmSetStart(); |
| 17 | + markToggle.classList.toggle('on', markMode); |
| 18 | +}); |
| 19 | + |
| 20 | +/* ---------- set-start mode (3.11) ---------- */ |
| 21 | +let setStartArmed = false; |
| 22 | +const setStartBtn = document.getElementById('setStartBtn'); |
| 23 | +function disarmSetStart(){ setStartArmed = false; setStartBtn.classList.remove('on'); } |
| 24 | +setStartBtn.addEventListener('click', ()=>{ |
| 25 | + if (state.selected==null) return; |
| 26 | + setStartArmed = !setStartArmed; |
| 27 | + if (setStartArmed){ markMode = false; markToggle.classList.remove('on'); } |
| 28 | + setStartBtn.classList.toggle('on', setStartArmed); |
| 29 | +}); |
| 30 | + |
| 31 | +/* ---------- flip (front/back view) + symbol overlay toggles ---------- */ |
| 32 | +const flipBtn = document.getElementById('flipBtn'); |
| 33 | +const viewLabel = document.getElementById('viewLabel'); |
| 34 | +flipBtn.addEventListener('click', ()=>{ |
| 35 | + view.backView = !view.backView; |
| 36 | + flipBtn.classList.toggle('on', view.backView); |
| 37 | + flipBtn.firstChild.textContent = view.backView ? 'Back ' : 'Front '; |
| 38 | + viewLabel.textContent = view.backView ? '(stitching side)' : '(tap to flip)'; |
| 39 | + draw(); |
| 40 | +}); |
| 41 | + |
| 42 | +const symbolsBtn = document.getElementById('symbolsBtn'); |
| 43 | +symbolsBtn.addEventListener('click', ()=>{ |
| 44 | + setSymbolsOn(!symbolsOn); |
| 45 | + symbolsBtn.classList.toggle('on', symbolsOn); |
| 46 | + draw(); |
| 47 | +}); |
| 48 | + |
| 49 | +/* ---------- marking gesture state ---------- */ |
| 50 | +let gesture = null; // {startCell, dir, touched:Set, longPressTimer, moved} |
| 51 | +const LONG_PRESS_MS = 500; |
| 52 | +const MOVE_THRESH = 8; |
| 53 | + |
| 54 | +function beginGesture(e){ |
| 55 | + const i = cellAtClient(e.clientX, e.clientY); |
| 56 | + if (i<0 || colourAt(i)!==state.selected) return; |
| 57 | + const dir = !isStitched(i); // mark unless already stitched |
| 58 | + gesture = { |
| 59 | + startX:e.clientX, startY:e.clientY, |
| 60 | + startCell:i, dir, touched:new Set(), moved:false, |
| 61 | + }; |
| 62 | + toggleCell(i, dir); |
| 63 | + gesture.longPressTimer = setTimeout(()=>{ |
| 64 | + if (!gesture || gesture.moved) return; |
| 65 | + fireTieOff(gesture.startCell); |
| 66 | + }, LONG_PRESS_MS); |
| 67 | +} |
| 68 | +function toggleCell(i, dir){ |
| 69 | + if (gesture.touched.has(i)) return; |
| 70 | + gesture.touched.add(i); |
| 71 | + setStitched(i, dir); |
| 72 | +} |
| 73 | +function fireTieOff(i){ |
| 74 | + setTieOff(i, !hasTieOff(i)); |
| 75 | + logEvent({kind:'tieoff', c:colourAt(i), tieOff:i}); |
| 76 | + // setTieOff already invalidates the colour's cached route internally. |
| 77 | + refreshUI(); draw(); |
| 78 | +} |
| 79 | +function commitGesture(){ |
| 80 | + if (!gesture) return; |
| 81 | + clearTimeout(gesture.longPressTimer); |
| 82 | + const touched = [...gesture.touched]; |
| 83 | + const dir = gesture.dir; |
| 84 | + if (touched.length){ |
| 85 | + logEvent({ kind: dir ? 'mark' : 'unmark', c: state.selected, cells: touched }); |
| 86 | + } |
| 87 | + gesture = null; |
| 88 | + markDirty(); refreshUI(); draw(); |
| 89 | + if (dir && touched.length) checkBlockComplete(touched, state.selected); |
| 90 | +} |
| 91 | + |
| 92 | +/* ---------- block-complete celebration + auto-advance (4.6) ---------- */ |
| 93 | +const celebrateEl = document.getElementById('blockCelebrate'); |
| 94 | +let celebrateTimer = null; |
| 95 | +function checkBlockComplete(touchedCells, v){ |
| 96 | + if (v==null) return; |
| 97 | + const seen = new Set(); |
| 98 | + for (const i of touchedCells){ |
| 99 | + const {br,bc} = blockOf(i); |
| 100 | + const key = br+','+bc; |
| 101 | + if (seen.has(key)) continue; |
| 102 | + seen.add(key); |
| 103 | + if (!blockIsCompleteForColour(br,bc,v)) continue; |
| 104 | + const list = blockOrderList(v); |
| 105 | + const idx = getBlockIdx(); |
| 106 | + const next = list[idx+1]; |
| 107 | + if (next){ |
| 108 | + celebrate('Block done! Moving on…'); |
| 109 | + setBlockIdx(idx+1); |
| 110 | + gotoBlock(next.br, next.bc); |
| 111 | + } else { |
| 112 | + celebrate(list.length ? 'Block done!' : 'Colour finished!'); |
| 113 | + } |
| 114 | + return; // one celebration per gesture is enough |
| 115 | + } |
| 116 | +} |
| 117 | +function celebrate(msg){ |
| 118 | + celebrateEl.textContent = msg; |
| 119 | + celebrateEl.classList.add('show'); |
| 120 | + clearTimeout(celebrateTimer); |
| 121 | + celebrateTimer = setTimeout(()=>celebrateEl.classList.remove('show'), 1800); |
| 122 | +} |
| 123 | +function abortGesture(){ |
| 124 | + if (!gesture) return; |
| 125 | + clearTimeout(gesture.longPressTimer); |
| 126 | + // revert cells already toggled by this gesture, in reverse order |
| 127 | + [...gesture.touched].reverse().forEach(i=>setStitched(i, !gesture.dir)); |
| 128 | + gesture = null; |
| 129 | + refreshUI(); draw(); |
| 130 | +} |
| 131 | + |
| 132 | +/* ---------- pan / zoom (pointer events) ---------- */ |
| 133 | +const pts=new Map(); |
| 134 | +let lastDist=0,lastMid=null,lastTap=0; |
| 135 | +stage.addEventListener('pointerdown',e=>{ |
| 136 | + stage.setPointerCapture(e.pointerId); |
| 137 | + pts.set(e.pointerId,{x:e.clientX,y:e.clientY}); |
| 138 | + if(pts.size===2){ |
| 139 | + if (gesture) abortGesture(); |
| 140 | + const [a,b]=[...pts.values()]; |
| 141 | + lastDist=Math.hypot(a.x-b.x,a.y-b.y); |
| 142 | + lastMid={x:(a.x+b.x)/2,y:(a.y+b.y)/2}; |
| 143 | + } else if (pts.size===1){ |
| 144 | + if (setStartArmed && state.selected!=null){ |
| 145 | + const i = cellAtClient(e.clientX, e.clientY); |
| 146 | + if (i>=0 && colourAt(i)===state.selected){ |
| 147 | + state.startPoints[state.selected] = i; |
| 148 | + markDirty(); |
| 149 | + invalidateRoute(state.selected); |
| 150 | + requestRoute(state.selected); |
| 151 | + disarmSetStart(); |
| 152 | + refreshUI(); draw(); |
| 153 | + } |
| 154 | + } else if (markMode && state.selected!=null){ |
| 155 | + beginGesture(e); |
| 156 | + } else { |
| 157 | + const now=Date.now(); |
| 158 | + if(now-lastTap<300){ zoomAt(e.clientX,e.clientY, view.scale<4?2:0.25); } |
| 159 | + lastTap=now; |
| 160 | + } |
| 161 | + } |
| 162 | +}); |
| 163 | +stage.addEventListener('pointermove',e=>{ |
| 164 | + if(!pts.has(e.pointerId))return; |
| 165 | + const prev=pts.get(e.pointerId); |
| 166 | + pts.set(e.pointerId,{x:e.clientX,y:e.clientY}); |
| 167 | + if(pts.size===1){ |
| 168 | + if (gesture){ |
| 169 | + const dx=e.clientX-gesture.startX, dy=e.clientY-gesture.startY; |
| 170 | + if (!gesture.moved && Math.hypot(dx,dy)>MOVE_THRESH){ |
| 171 | + gesture.moved = true; |
| 172 | + clearTimeout(gesture.longPressTimer); |
| 173 | + } |
| 174 | + const i = cellAtClient(e.clientX, e.clientY); |
| 175 | + if (i>=0 && colourAt(i)===state.selected) toggleCell(i, gesture.dir); |
| 176 | + return; // suppress the pan branch while a marking gesture is active |
| 177 | + } |
| 178 | + view.tx+=e.clientX-prev.x; view.ty+=e.clientY-prev.y; |
| 179 | + clampView(); draw(); |
| 180 | + } else if(pts.size===2){ |
| 181 | + const [a,b]=[...pts.values()]; |
| 182 | + const dist=Math.hypot(a.x-b.x,a.y-b.y); |
| 183 | + const mid={x:(a.x+b.x)/2,y:(a.y+b.y)/2}; |
| 184 | + if(lastDist>0){ |
| 185 | + const f=dist/lastDist; |
| 186 | + applyZoom(mid.x,mid.y,f); |
| 187 | + view.tx+=mid.x-lastMid.x; view.ty+=mid.y-lastMid.y; |
| 188 | + clampView(); draw(); |
| 189 | + } |
| 190 | + lastDist=dist; lastMid=mid; |
| 191 | + } |
| 192 | +}); |
| 193 | +function endPt(e){ |
| 194 | + pts.delete(e.pointerId); |
| 195 | + lastDist=0; |
| 196 | + if (gesture) commitGesture(); |
| 197 | +} |
| 198 | +stage.addEventListener('pointerup',endPt); |
| 199 | +stage.addEventListener('pointercancel',endPt); |
| 200 | +stage.addEventListener('wheel',e=>{ |
| 201 | + e.preventDefault(); |
| 202 | + applyZoom(e.clientX,e.clientY, Math.exp(-e.deltaY*0.0018)); |
| 203 | + clampView(); draw(); |
| 204 | +},{passive:false}); |
| 205 | +function zoomAt(cx,cy,f){ applyZoom(cx,cy,f); clampView(); draw(); } |
| 206 | + |
| 207 | +stage.addEventListener('touchmove',e=>e.preventDefault(),{passive:false}); |
| 208 | +window.addEventListener('resize',fit); |
| 209 | + |
| 210 | +/* legend drag-to-scroll (mouse) */ |
| 211 | +const legend = document.getElementById('legend'); |
| 212 | +let lgDown=false,lgX=0,lgScroll=0,lgMoved=false; |
| 213 | +legend.addEventListener('pointerdown',e=>{ |
| 214 | + if(e.pointerType!=='mouse')return; |
| 215 | + lgDown=true;lgMoved=false;lgX=e.clientX;lgScroll=legend.scrollLeft; |
| 216 | +}); |
| 217 | +legend.addEventListener('pointermove',e=>{ |
| 218 | + if(!lgDown)return; |
| 219 | + const dx=e.clientX-lgX; |
| 220 | + if(Math.abs(dx)>4){lgMoved=true;legend.classList.add('dragging');} |
| 221 | + legend.scrollLeft=lgScroll-dx; |
| 222 | +}); |
| 223 | +['pointerup','pointerleave'].forEach(ev=>legend.addEventListener(ev,()=>{ |
| 224 | + lgDown=false; |
| 225 | + setTimeout(()=>legend.classList.remove('dragging'),0); |
| 226 | +})); |
0 commit comments