Skip to content

Commit ee5565b

Browse files
tmck-codeclaude
andcommitted
pikachu companion: 'stitched up to here' mode + route-aware mark block
- Up-to-here toolbar mode (shift-click on desktop): tap a cell and every route cell from START through it is marked; the colour's start point advances to the next route cell so the re-plan continues from the needle. - Mark block now marks only the route's current visit to the block; cells the path returns for later stay unmarked and the button/celebration say how many. Falls back to geometric marking when no route is planned. - Undo restores an advanced start point. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d076270 commit ee5565b

5 files changed

Lines changed: 162 additions & 29 deletions

File tree

pages/pikachu-stitch-companion/js/input.js

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@ import { N } from './pattern.js';
66
import { state, colourAt, isStitched, setStitched, hasTieOff, setTieOff, logEvent } from './state.js';
77
import { markDirty } from './persistence.js';
88
import { refreshUI, getBlockIdx, setBlockIdx, updatePosReadout } from './ui.js';
9-
import { invalidateRoute, requestRoute } from './planner.js';
9+
import { invalidateRoute, requestRoute, getRoute, routeCells, markRoutePrefix } from './planner.js';
1010
import { blockOf, blockOrderList, blockIsCompleteForColour, gotoBlock } from './blocks.js';
1111

1212
/* ---------- marking mode toggle ---------- */
1313
let markMode = false;
1414
const markToggle = document.getElementById('markToggle');
1515
markToggle.addEventListener('click', ()=>{
1616
markMode = !markMode;
17-
if (markMode) disarmSetStart();
17+
if (markMode){ disarmSetStart(); disarmUpToHere(); }
1818
markToggle.classList.toggle('on', markMode);
1919
markToggle.setAttribute('aria-pressed', String(markMode));
2020
});
@@ -30,11 +30,53 @@ function disarmSetStart(){
3030
setStartBtn.addEventListener('click', ()=>{
3131
if (state.selected==null) return;
3232
setStartArmed = !setStartArmed;
33-
if (setStartArmed){ markMode = false; markToggle.classList.remove('on'); markToggle.setAttribute('aria-pressed','false'); }
33+
if (setStartArmed){ markMode = false; markToggle.classList.remove('on'); markToggle.setAttribute('aria-pressed','false'); disarmUpToHere(); }
3434
setStartBtn.classList.toggle('on', setStartArmed);
3535
setStartBtn.setAttribute('aria-pressed', String(setStartArmed));
3636
});
3737

38+
/* ---------- "stitched up to here" (armed mode, or shift-click on desktop) ---------- */
39+
let upToHereArmed = false;
40+
const upToHereBtn = document.getElementById('upToHereBtn');
41+
function disarmUpToHere(){
42+
upToHereArmed = false;
43+
upToHereBtn.classList.remove('on');
44+
upToHereBtn.setAttribute('aria-pressed','false');
45+
}
46+
upToHereBtn.addEventListener('click', ()=>{
47+
if (state.selected==null) return;
48+
upToHereArmed = !upToHereArmed;
49+
if (upToHereArmed){
50+
markMode = false; markToggle.classList.remove('on'); markToggle.setAttribute('aria-pressed','false');
51+
disarmSetStart();
52+
}
53+
upToHereBtn.classList.toggle('on', upToHereArmed);
54+
upToHereBtn.setAttribute('aria-pressed', String(upToHereArmed));
55+
});
56+
/**
57+
* markUpToCell(i) - mark every route cell from START through cell i (route
58+
* order) as stitched and move the start to the next route cell. Returns
59+
* false if i isn't on the selected colour's current route.
60+
*/
61+
function markUpToCell(i){
62+
const v = state.selected;
63+
if (v==null) return false;
64+
const route = getRoute(v);
65+
if (!route) return false;
66+
const cells = routeCells(route);
67+
const k = cells.indexOf(i);
68+
if (k<0) return false;
69+
const prevStart = state.startPoints[v];
70+
const toggled = markRoutePrefix(v, cells, k, setStitched);
71+
if (toggled.length) logEvent({kind:'bulk', c:v, cells:toggled, unmark:false, prevStart: prevStart==null ? null : prevStart});
72+
markDirty();
73+
invalidateRoute(v); requestRoute(v);
74+
refreshUI(); draw();
75+
celebrate(toggled.length ? `Marked ${toggled.length} stitch${toggled.length===1?'':'es'} up to here` : 'Already stitched up to here');
76+
if (toggled.length) checkBlockComplete(toggled, v);
77+
return true;
78+
}
79+
3880
/* ---------- flip (front/back view) + symbol overlay toggles ---------- */
3981
const flipBtn = document.getElementById('flipBtn');
4082
const viewLabel = document.getElementById('viewLabel');
@@ -103,7 +145,7 @@ symbolsBtn.addEventListener('click', ()=>{
103145
const zoomHint = document.getElementById('zoomHint');
104146
if (isDesktop){
105147
if (hint) hint.innerHTML = 'click a colour to isolate it<br>scroll to zoom';
106-
if (zoomHint) zoomHint.textContent = 'drag to pan · double-click to zoom · F flips around the column under the cursor';
148+
if (zoomHint) zoomHint.textContent = 'drag to pan · double-click to zoom · F flips · shift-click a cell = stitched up to here';
107149
} else {
108150
if (hint) hint.innerHTML = 'tap a colour to isolate it<br>pinch to zoom';
109151
if (zoomHint) zoomHint.textContent = 'drag to pan · double-tap to zoom · hold a column to pin it for flipping';
@@ -214,7 +256,13 @@ stage.addEventListener('pointerdown',e=>{
214256
lastDist=Math.hypot(a.x-b.x,a.y-b.y);
215257
lastMid={x:(a.x+b.x)/2,y:(a.y+b.y)/2};
216258
} else if (pts.size===1){
217-
if (setStartArmed && state.selected!=null){
259+
if ((upToHereArmed || (e.shiftKey && isDesktop)) && state.selected!=null){
260+
const i = cellAtClient(e.clientX, e.clientY);
261+
if (i>=0 && colourAt(i)===state.selected){
262+
if (!markUpToCell(i)) celebrate('That cell is not on the current route');
263+
disarmUpToHere();
264+
}
265+
} else if (setStartArmed && state.selected!=null){
218266
const i = cellAtClient(e.clientX, e.clientY);
219267
if (i>=0 && colourAt(i)===state.selected){
220268
state.startPoints[state.selected] = i;

pages/pikachu-stitch-companion/js/planner.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,3 +550,28 @@ export function requestRoute(v){
550550
});
551551
return undefined;
552552
}
553+
554+
/* ---------------- route-order helpers (mark up-to-here / mark block) ---------------- */
555+
556+
/** routeCells(route) -> all route cells in stitching order (legs concatenated). */
557+
export function routeCells(route){
558+
return route ? route.legs.reduce((acc, leg) => acc.concat(leg.cells), []) : [];
559+
}
560+
561+
/**
562+
* markRoutePrefix(v, cells, endIdx) - the shared "I've stitched up to here"
563+
* primitive: marks cells[0..endIdx] of colour v as stitched, and moves the
564+
* colour's start point to the next route cell so the re-planned route
565+
* continues from exactly where the needle is (otherwise suggestStart would
566+
* pick a fresh start elsewhere once the old one is stitched). Returns the
567+
* cells it toggled (for the journey log). Does NOT refresh UI / redraw.
568+
*/
569+
export function markRoutePrefix(v, cells, endIdx, setStitchedFn){
570+
const toggled = [];
571+
for (let k=0; k<=endIdx && k<cells.length; k++){
572+
if (!isStitched(cells[k])){ setStitchedFn(cells[k], true); toggled.push(cells[k]); }
573+
}
574+
const next = cells[endIdx+1];
575+
if (next!=null) state.startPoints[v] = next;
576+
return toggled;
577+
}

pages/pikachu-stitch-companion/js/state.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ export function undoLast(){
7777
} else if (ev.kind==='bulk'){
7878
// bulk marks stitched cells on; undo restores the opposite state
7979
(ev.cells||[]).forEach(i=>setStitched(i, !!ev.unmark));
80+
// "up to here" / route-aware block marks advance the colour's start point;
81+
// put it back so the re-plan resumes from where it was before
82+
if ('prevStart' in ev){
83+
if (ev.prevStart==null) delete state.startPoints[ev.c];
84+
else state.startPoints[ev.c] = ev.prevStart;
85+
invalidateRoute(ev.c);
86+
}
8087
}
8188
markDirty();
8289
refreshUI(); draw();

pages/pikachu-stitch-companion/js/ui.js

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { COLORS, TOTAL, CELL_COUNT, N } from './pattern.js';
55
import { state, colourAt, isStitched, isColourComplete, setStitched, logEvent, undoLast, rowOf, colOf } from './state.js';
66
import { draw, drawMiniMap, setConfettiHeatOn, confettiHeatOn } from './render.js';
77
import { resetProgress, markDirty } from './persistence.js';
8-
import { getRoute, requestRoute, getPlanningColour, setOnRouteReady, invalidateAllRoutes, stitchesPerLength } from './planner.js';
8+
import { getRoute, requestRoute, getPlanningColour, setOnRouteReady, invalidateAllRoutes, stitchesPerLength, routeCells, markRoutePrefix, invalidateRoute } from './planner.js';
99
import { blockOrderList, gotoBlock, blockOf, centreOnCell, blockSize, BLOCK_SIZES, blockAtViewCentre, blockCells } from './blocks.js';
1010
import { stitchesPerHour, estimatedFinish, RAILROADING_SLOWDOWN, LENGTHS_PER_SKEIN, skeinsForLengths } from './insights.js';
1111

@@ -60,34 +60,80 @@ function blockTargetCells(){
6060
const v = state.selected;
6161
return { br, bc, cells: blockCells(br, bc).filter(i => { const c = colourAt(i); return c!==0 && (v==null || c===v); }) };
6262
}
63+
/**
64+
* blockRouteVisit(v, br, bc) - route-aware view of the block: the route's
65+
* *first contiguous visit* to this block (cells the needle reaches before
66+
* the path leaves the block), plus how many of the block's cells the route
67+
* only comes back for later. null when no route is planned yet.
68+
*/
69+
function blockRouteVisit(v, br, bc){
70+
const route = getRoute(v);
71+
if (!route) return null;
72+
const inBlock = new Set(blockCells(br, bc));
73+
const cells = routeCells(route);
74+
let i0 = cells.findIndex(i => inBlock.has(i));
75+
if (i0<0) return { cells, i0:-1, j:-1, visit:[], later:0 };
76+
let j = i0;
77+
while (j+1 < cells.length && inBlock.has(cells[j+1])) j++;
78+
const visit = cells.slice(i0, j+1);
79+
let later = 0;
80+
for (let k=j+1; k<cells.length; k++) if (inBlock.has(cells[k])) later++;
81+
return { cells, i0, j, visit, later };
82+
}
6383
markBlockBtn.addEventListener('click', ()=>{
6484
const { br, bc, cells } = blockTargetCells();
6585
if (!cells.length) { celebrateBulk('Nothing of this colour in this block'); return; }
86+
const v = state.selected;
6687
const allDone = cells.every(isStitched);
67-
const on = !allDone;
68-
const toggled = cells.filter(i => isStitched(i)!==on);
69-
toggled.forEach(i => setStitched(i, on));
70-
logEvent({kind:'bulk', c: state.selected ?? 0, cells: toggled, unmark: !on});
88+
if (allDone){
89+
// toggle off: unmark the whole block for this colour (geometric)
90+
cells.forEach(i => setStitched(i, false));
91+
logEvent({kind:'bulk', c: v ?? 0, cells, unmark: true});
92+
refreshUI(); draw();
93+
return;
94+
}
95+
const rv = v!=null ? blockRouteVisit(v, br, bc) : null;
96+
const prevStart = v!=null ? state.startPoints[v] : undefined;
97+
let toggled;
98+
if (rv && rv.visit.length){
99+
// route-aware: only the path's current visit to this block; cells the
100+
// route returns for later stay unstitched so the plan isn't corrupted.
101+
// If the route *starts* in this block, also move the start forward so
102+
// the re-plan continues from where the needle is.
103+
if (rv.i0===0) toggled = markRoutePrefix(v, rv.cells, rv.j, setStitched);
104+
else { toggled = rv.visit.filter(i=>!isStitched(i)); toggled.forEach(i=>setStitched(i,true)); }
105+
invalidateRoute(v); requestRoute(v);
106+
} else {
107+
toggled = cells.filter(i => !isStitched(i));
108+
toggled.forEach(i => setStitched(i, true));
109+
}
110+
const ev = {kind:'bulk', c: v ?? 0, cells: toggled, unmark: false};
111+
if (rv && rv.i0===0) ev.prevStart = prevStart==null ? null : prevStart; // start was advanced; undo restores it
112+
logEvent(ev);
71113
refreshUI(); draw();
72-
if (!on) return;
73-
const v = state.selected;
74114
if (v!=null && isColourComplete(v)) { celebrateBulk('Colour finished!'); return; }
75-
if (v!=null){
76-
// same auto-advance as a drag-mark finishing a block: the done block has
77-
// dropped out of the list, so the next one sits at the same index
115+
const skipped = rv ? rv.later : 0;
116+
const blockDone = cells.every(isStitched);
117+
if (v!=null && blockDone){
78118
const list = blockOrderList(v);
79119
const next = list[Math.min(blockIdx, list.length-1)];
80120
if (next){ celebrateBulk('Block done! Moving on…'); blockIdx = Math.min(blockIdx, list.length-1); gotoBlock(next.br, next.bc); refreshBlockNav(); }
81121
else celebrateBulk('Block done!');
82-
} else celebrateBulk('Block done!');
122+
} else if (skipped){
123+
celebrateBulk(`Marked ${toggled.length} · ${skipped} left for a later pass`);
124+
} else celebrateBulk(`Marked ${toggled.length} stitch${toggled.length===1?'':'es'}`);
83125
});
84126
function refreshMarkBlockBtn(){
85-
const { cells } = blockTargetCells();
127+
const { br, bc, cells } = blockTargetCells();
86128
const v = state.selected;
87129
const what = v!=null ? `DMC ${COLORS[v-1][0]}` : 'all colours';
88130
const done = cells.length && cells.every(isStitched);
89131
const B = blockSize();
90-
const lbl = done ? `Unmark this ${B}×${B} block (${what})` : `Mark this ${B}×${B} block as stitched (${what})`;
132+
let lbl = done ? `Unmark this ${B}×${B} block (${what})` : `Mark this ${B}×${B} block as stitched (${what})`;
133+
if (!done && v!=null){
134+
const rv = blockRouteVisit(v, br, bc);
135+
if (rv && rv.later) lbl += ` — ${rv.later} cell${rv.later===1?'':'s'} belong to later legs and stay unmarked`;
136+
}
91137
markBlockBtn.title = lbl; markBlockBtn.setAttribute('aria-label', lbl);
92138
markBlockBtn.classList.toggle('on', !!done);
93139
markBlockBtn.setAttribute('aria-pressed', String(!!done));
@@ -147,6 +193,8 @@ export function refreshUI(){
147193
: `DMC ${COLORS[state.selected-1][0]} · ${state.stitchedCount[state.selected]} / ${COLORS[state.selected-1][3]}`;
148194

149195
setStartBtn.disabled = state.selected==null;
196+
upToHereBtn.disabled = state.selected==null;
197+
if (state.selected==null){ upToHereBtn.classList.remove('on'); upToHereBtn.setAttribute('aria-pressed','false'); }
150198
refreshBlockSizeUI();
151199
refreshMarkBlockBtn();
152200
refreshRoutePanel();
@@ -161,6 +209,7 @@ const markToggle = document.getElementById('markToggle');
161209
const undoBtn = document.getElementById('undoBtn');
162210
const readout = document.getElementById('colourReadout');
163211
const setStartBtn = document.getElementById('setStartBtn');
212+
const upToHereBtn = document.getElementById('upToHereBtn');
164213
undoBtn.addEventListener('click', undoLast);
165214

166215
/* ---------- route summary readout (3.13) ---------- */

pages/pikachu-stitch-companion/pikachu-stitch-companion.html

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ <h3>Cluster anchors</h3>
5656
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/><polyline points="8,12.5 10.5,15 16.5,9" stroke="#fde949" stroke-width="3"/></svg>
5757
<span class="lbl">Mark block</span>
5858
</button>
59+
<button id="upToHereBtn" class="tbtn" type="button" disabled aria-label="Stitched up to here: tap a cell" title="Stitched up to here: tap a cell" aria-pressed="false">
60+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h5l2-5 3 10 2-5h6"/><circle cx="21" cy="12" r="2" fill="currentColor"/></svg>
61+
<span class="lbl">Up to here</span>
62+
</button>
5963
<button id="setStartBtn" class="tbtn" type="button" disabled aria-label="Set start point" title="Set start point" aria-pressed="false">
6064
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="7"/><line x1="12" y1="1" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="1" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="23" y2="12"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>
6165
<span class="lbl">Set start</span>
@@ -140,18 +144,18 @@ <h3>Cluster anchors</h3>
140144
stale module mix. -->
141145
<script type="importmap">
142146
{"imports":{
143-
"./js/main.js":"./js/main.js?v=20260822153512",
144-
"./js/render.js":"./js/render.js?v=20260822153512",
145-
"./js/input.js":"./js/input.js?v=20260822153512",
146-
"./js/ui.js":"./js/ui.js?v=20260822153512",
147-
"./js/blocks.js":"./js/blocks.js?v=20260822153512",
148-
"./js/state.js":"./js/state.js?v=20260822153512",
149-
"./js/planner.js":"./js/planner.js?v=20260822153512",
150-
"./js/persistence.js":"./js/persistence.js?v=20260822153512",
151-
"./js/pattern.js":"./js/pattern.js?v=20260822153512",
152-
"./js/insights.js":"./js/insights.js?v=20260822153512"
147+
"./js/main.js":"./js/main.js?v=20260822154149",
148+
"./js/render.js":"./js/render.js?v=20260822154149",
149+
"./js/input.js":"./js/input.js?v=20260822154149",
150+
"./js/ui.js":"./js/ui.js?v=20260822154149",
151+
"./js/blocks.js":"./js/blocks.js?v=20260822154149",
152+
"./js/state.js":"./js/state.js?v=20260822154149",
153+
"./js/planner.js":"./js/planner.js?v=20260822154149",
154+
"./js/persistence.js":"./js/persistence.js?v=20260822154149",
155+
"./js/pattern.js":"./js/pattern.js?v=20260822154149",
156+
"./js/insights.js":"./js/insights.js?v=20260822154149"
153157
}}
154158
</script>
155-
<script type="module" src="js/main.js?v=20260822153512"></script>
159+
<script type="module" src="js/main.js?v=20260822154149"></script>
156160
</body>
157161
</html>

0 commit comments

Comments
 (0)