Skip to content

Commit 7bbfc7b

Browse files
tmck-codeclaude
andcommitted
add colour journey to pikachu stitch companion
Per-cell stitch state with localStorage persistence, route planner (clustering, serpentine, carry-vs-tie-off costing, thread-length segments), block leafing, front/back flip, symbol overlay, heatmap, stats and settings. Split into html + css + js modules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 94882a8 commit 7bbfc7b

12 files changed

Lines changed: 2125 additions & 375 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// blocks.js - 10x10 block navigation helpers: block field, colour-scoped
2+
// block ordering, and camera framing for a given block.
3+
4+
import { N } from './pattern.js';
5+
import { state, idxOf, rowOf, colOf, colourAt, isStitched } from './state.js';
6+
import { view, clampView, draw, stage } from './render.js';
7+
8+
export const BLOCK_SIZE = 10;
9+
export const BLOCKS = N / BLOCK_SIZE; // 15 blocks per axis (150/10)
10+
11+
/** blockOf(idx) -> {br, bc} block row/col containing cell idx. */
12+
export function blockOf(idx){
13+
return { br: Math.floor(rowOf(idx)/BLOCK_SIZE), bc: Math.floor(colOf(idx)/BLOCK_SIZE) };
14+
}
15+
16+
/** blockCells(br, bc) -> array of cell indices (row-major) in that 10x10 block. */
17+
export function blockCells(br, bc){
18+
const out = [];
19+
const r0 = br*BLOCK_SIZE, c0 = bc*BLOCK_SIZE;
20+
for(let r=r0; r<r0+BLOCK_SIZE; r++){
21+
for(let c=c0; c<c0+BLOCK_SIZE; c++) out.push(idxOf(r,c));
22+
}
23+
return out;
24+
}
25+
26+
/**
27+
* blocksForColour(v) -> [{br, bc}, ...] blocks (in row-major scan order,
28+
* br then bc ascending) that still contain at least one unstitched cell of
29+
* colour v. Use blockOrderList(v) to get these in navigation order.
30+
*/
31+
export function blocksForColour(v){
32+
const out = [];
33+
for(let br=0; br<BLOCKS; br++){
34+
for(let bc=0; bc<BLOCKS; bc++){
35+
if(blockCells(br,bc).some(i => colourAt(i)===v && !isStitched(i))) out.push({br,bc});
36+
}
37+
}
38+
return out;
39+
}
40+
41+
/**
42+
* blockIsCompleteForColour(br, bc, v) -> true if the block has no
43+
* unstitched cells of colour v left (i.e. that colour is fully done in
44+
* this block). Exported for the later block-complete-celebration wave
45+
* (task 4.6): call this after a stitch toggle to detect completion.
46+
*/
47+
export function blockIsCompleteForColour(br, bc, v){
48+
return !blockCells(br,bc).some(i => colourAt(i)===v && !isStitched(i));
49+
}
50+
51+
/**
52+
* blockOrderList(v) -> blocksForColour(v) reordered per
53+
* state.settings.blockOrder:
54+
* - 'row-major': left-to-right, top-to-bottom throughout (bc ascending
55+
* every row).
56+
* - 'serpentine' (default): left-to-right on even block-rows, right-to-
57+
* left on odd block-rows (boustrophedon), so consecutive blocks are
58+
* always adjacent.
59+
*/
60+
export function blockOrderList(v){
61+
const blocks = blocksForColour(v); // already row-major by br,bc
62+
if(state.settings.blockOrder === 'row-major') return blocks;
63+
const byRow = new Map();
64+
for(const b of blocks){
65+
if(!byRow.has(b.br)) byRow.set(b.br, []);
66+
byRow.get(b.br).push(b);
67+
}
68+
const out = [];
69+
for(const br of [...byRow.keys()].sort((a,b)=>a-b)){
70+
const row = byRow.get(br);
71+
if(br % 2 === 1) row.reverse();
72+
out.push(...row);
73+
}
74+
return out;
75+
}
76+
77+
/**
78+
* gotoBlock(br, bc) - pans/zooms the view so the 10x10 block (br,bc) fills
79+
* the stage, then clamps and redraws.
80+
*/
81+
export function gotoBlock(br, bc){
82+
const w = stage.clientWidth, h = stage.clientHeight;
83+
const blockPx = BLOCK_SIZE * view.base; // css px spanned by the block at scale 1
84+
const fit = Math.min(w,h) * 0.96 / blockPx;
85+
view.scale = fit;
86+
const s = view.base * view.scale; // effective cell size at the new scale
87+
const cx = (bc*BLOCK_SIZE + BLOCK_SIZE/2) * s;
88+
const cy = (br*BLOCK_SIZE + BLOCK_SIZE/2) * s;
89+
view.tx = w/2 - cx;
90+
view.ty = h/2 - cy;
91+
clampView();
92+
draw();
93+
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
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

Comments
 (0)