Skip to content

Commit 3372345

Browse files
punnerudclaude
andcommitted
Dra i notebåndet for å spole
Båndet er en vannrett tidslinje, så den naturlige bevegelsen er å dra sidelengs i den — men den tok ikke imot noe. I fallende modus boblet trykk videre til kortstripen, som bare forstår loddrett dra, så et sveip på notelinja gjorde ingenting; i vannrett modus lå båndet helt utenfor draområdet. Eneste vei tilbake til begynnelsen var mange loddrette sveip på tangentkortene, ett hakk om gangen. Nå kan båndet dras i begge moduser: det klemmes til første og siste tone, så et langt dra lander nøyaktig på start, og den andre banen følger med — den fallende banen jevnt, kortstripen tone for tone. Ved slipp hopper begge til nærmeste tone, og et dra teller ikke som trykk på tonen man slapp over. Musehjulet spoler på samme måte, med et lite opphold før det snapper. indexAtBeat er den inverse av beatAtIndexF, så de to banene kan følge hverandre uansett hvilken av dem man drar i. Rundturen er verifisert eksakt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 23645d2 commit 3372345

6 files changed

Lines changed: 199 additions & 7 deletions

File tree

‎00_head.html‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,13 @@
105105
under kortene i vannrett modus, og som et bånd tvers over ved landingslinja
106106
i fallende modus, der kortene faller bak det. */
107107
.sband{
108-
position:relative;height:94px;overflow:visible;
108+
position:relative;height:94px;overflow:visible;touch-action:none;cursor:grab;
109109
background:var(--paper);
110110
border-top:3px solid var(--gold);border-bottom:1px solid rgba(28,23,18,.25);
111111
}
112112
.sband.inflow{margin:0 0 10px}
113+
.sband.grabbing{cursor:grabbing}
114+
.sband.grabbing .sseg{cursor:grabbing}
113115
.sband.overlay{position:absolute;left:0;right:0;z-index:2;box-shadow:0 10px 26px rgba(0,0,0,.45)}
114116
.swin{position:absolute;inset:0;overflow:hidden}
115117
.slane{position:absolute;left:0;top:8px;height:78px;display:flex;will-change:transform}

‎08_ui.js‎

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ function go(i, instant) {
158158
bevegelse, slik at tonene faller nedover i takt med musikken. */
159159
const V_PLAYHEAD = 0.62; // landingslinja, andel ned i vinduet
160160
const S_HEAD = 13; // notehodets avstand fra segmentets venstrekant
161-
let vY = 0, sSegs = [], beatStart = [], beatTotal = 0;
161+
let vY = 0, sX = 0, sSegs = [], beatStart = [], beatTotal = 0;
162162
/* Alle kortene er like høye. Varigheten leses av notebåndet under, og like
163163
høye kort gjør at man ser flere av de kommende grepene. */
164164
function cardH() { return 86 + state.air * 24; }
@@ -193,9 +193,31 @@ function sOffset(beatPos) {
193193
function sLaneStyle(transition, x) {
194194
const lane = $('slane');
195195
if (!lane) return;
196+
sX = x;
196197
lane.style.transition = transition;
197198
lane.style.transform = 'translateX(' + x.toFixed(1) + 'px)';
198199
}
200+
/* Hvilket taktslag som ligger på spillehodet ved en gitt forskyvning */
201+
function beatAtBandOffset(x) {
202+
const w = ($('sband') && $('sband').clientWidth) || 1;
203+
return (w / 2 - S_HEAD - x) / ppbS();
204+
}
205+
/* Invers av beatAtIndexF: fra taktslag til (brøkdels) tonenummer */
206+
function indexAtBeat(beat) {
207+
const n = state.events.length;
208+
if (!n) return 0;
209+
if (beat <= 0) return 0;
210+
for (let i = 0; i < n; i++) {
211+
const len = state.events[i].beats;
212+
if (beat < beatStart[i] + len) return i + (beat - beatStart[i]) / len;
213+
}
214+
return n - 1;
215+
}
216+
/* Flytter begge banene til samme punkt i sangen */
217+
function setLanesFromBeat(beat, transition) {
218+
sLaneStyle(transition, sOffset(beat));
219+
if (curDir() === 'v') vLaneStyle(transition, vOffset(indexAtBeat(beat)));
220+
}
199221
function paintBand() {
200222
sSegs.forEach((el, j) => {
201223
el.classList.toggle('on', j === state.cur);
@@ -265,7 +287,8 @@ function freezeOne(id) {
265287
function freezeLanes() {
266288
const v = freezeOne('vlane');
267289
if (v) vY = parseFloat(v[5]);
268-
freezeOne('slane');
290+
const b = freezeOne('slane');
291+
if (b) sX = parseFloat(b[4]);
269292
}
270293
/* Etter dra eller hjul: hopp til tonen banen står på */
271294
function snapStripLane() {
@@ -315,6 +338,75 @@ function renderLegend() {
315338
$('hint').textContent = T('ui.hint');
316339
}
317340

341+
/* Notebåndet kan dras sidelengs — det er den naturlige bevegelsen på en
342+
vannrett tidslinje, og den korteste veien tilbake til begynnelsen.
343+
Kortene følger med, og ved slipp hopper begge til nærmeste tone. */
344+
function setupBandDrag(el) {
345+
let down = false, moved = false, pid = null, startX = 0, base = 0;
346+
let justDragged = false, lastIdx = -1, snapTimer = null;
347+
const beatAt = x => clamp(beatAtBandOffset(x), 0, beatTotal);
348+
const follow = beat => {
349+
setLanesFromBeat(beat, 'none');
350+
if (curDir() !== 'v') { // kortstripen hopper til nærmeste tone
351+
const i = clamp(Math.round(indexAtBeat(beat)), 0, state.events.length - 1);
352+
if (i !== lastIdx) { lastIdx = i; centerCard(cards[i], true); }
353+
}
354+
};
355+
const snap = () => {
356+
if (!state.events.length) return;
357+
go(clamp(Math.round(indexAtBeat(beatAt(sX))), 0, state.events.length - 1));
358+
};
359+
el.addEventListener('pointerdown', e => {
360+
if (e.pointerType === 'mouse' && e.button !== 0) return;
361+
down = true; moved = false; pid = e.pointerId;
362+
startX = e.clientX; base = sX; lastIdx = -1;
363+
e.stopPropagation();
364+
});
365+
el.addEventListener('pointermove', e => {
366+
if (!down || e.pointerId !== pid) return;
367+
e.stopPropagation();
368+
const dx = e.clientX - startX;
369+
if (!moved) {
370+
if (Math.abs(dx) < 5) return;
371+
moved = true;
372+
el.classList.add('grabbing');
373+
stopIfPlaying();
374+
try { el.setPointerCapture(pid); } catch (err) { /* ignorer */ }
375+
}
376+
follow(beatAt(base + dx));
377+
e.preventDefault();
378+
});
379+
const end = e => {
380+
if (!down) return;
381+
down = false;
382+
el.classList.remove('grabbing');
383+
try { el.releasePointerCapture(pid); } catch (err) { /* ignorer */ }
384+
if (e) e.stopPropagation();
385+
justDragged = moved;
386+
if (moved) snap();
387+
moved = false;
388+
};
389+
el.addEventListener('pointerup', end);
390+
el.addEventListener('pointercancel', end);
391+
el.addEventListener('lostpointercapture', end);
392+
el.addEventListener('click', e => {
393+
if (!justDragged) return;
394+
justDragged = false;
395+
e.stopPropagation();
396+
e.preventDefault();
397+
}, true);
398+
el.addEventListener('wheel', e => {
399+
const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
400+
stopIfPlaying();
401+
follow(beatAt(sX - d));
402+
clearTimeout(snapTimer);
403+
snapTimer = setTimeout(snap, 140);
404+
e.preventDefault();
405+
e.stopPropagation();
406+
}, { passive: false });
407+
el.addEventListener('dragstart', e => e.preventDefault());
408+
}
409+
318410
function setupStripDrag(el) {
319411
let down = false, moved = false, pid = null, start = 0, base = 0;
320412
let justDragged = false, vertical = false, snapTimer = null;

‎09_init.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ function init() {
321321
positionBand(true);
322322
});
323323
setupStripDrag($('strip'));
324+
setupBandDrag($('sband'));
324325

325326
rebuildAll();
326327
updateTempoBar();

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ bredden på hver tone er nøyaktig proporsjonal med hvor lenge den varer — så
3535
er før du kommer dit. Under avspilling glir båndet jevnt mot venstre gjennom hele sangen, mens
3636
spillehodet står stille på midten, rett under kortet som spilles. Kortene med grep hopper én tone om
3737
gangen, så du ser tydelig når neste grep treffer, mens notebildet flyter som musikken. Spilte toner
38-
dempes, og du kan trykke på en tone i båndet for å hoppe dit.
38+
dempes. Du kan trykke på en tone i båndet for å hoppe dit, eller **dra i båndet** for å spole — det er
39+
den korteste veien tilbake til begynnelsen, og kortene følger med. Musehjulet spoler på samme måte.
3940

4041
### Retning
4142

‎index.html‎

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,13 @@
105105
under kortene i vannrett modus, og som et bånd tvers over ved landingslinja
106106
i fallende modus, der kortene faller bak det. */
107107
.sband{
108-
position:relative;height:94px;overflow:visible;
108+
position:relative;height:94px;overflow:visible;touch-action:none;cursor:grab;
109109
background:var(--paper);
110110
border-top:3px solid var(--gold);border-bottom:1px solid rgba(28,23,18,.25);
111111
}
112112
.sband.inflow{margin:0 0 10px}
113+
.sband.grabbing{cursor:grabbing}
114+
.sband.grabbing .sseg{cursor:grabbing}
113115
.sband.overlay{position:absolute;left:0;right:0;z-index:2;box-shadow:0 10px 26px rgba(0,0,0,.45)}
114116
.swin{position:absolute;inset:0;overflow:hidden}
115117
.slane{position:absolute;left:0;top:8px;height:78px;display:flex;will-change:transform}
@@ -1809,7 +1811,7 @@ <h2 class="ptitle" id="ptitle">♪</h2>
18091811
bevegelse, slik at tonene faller nedover i takt med musikken. */
18101812
const V_PLAYHEAD = 0.62; // landingslinja, andel ned i vinduet
18111813
const S_HEAD = 13; // notehodets avstand fra segmentets venstrekant
1812-
let vY = 0, sSegs = [], beatStart = [], beatTotal = 0;
1814+
let vY = 0, sX = 0, sSegs = [], beatStart = [], beatTotal = 0;
18131815
/* Alle kortene er like høye. Varigheten leses av notebåndet under, og like
18141816
høye kort gjør at man ser flere av de kommende grepene. */
18151817
function cardH() { return 86 + state.air * 24; }
@@ -1844,9 +1846,31 @@ <h2 class="ptitle" id="ptitle">♪</h2>
18441846
function sLaneStyle(transition, x) {
18451847
const lane = $('slane');
18461848
if (!lane) return;
1849+
sX = x;
18471850
lane.style.transition = transition;
18481851
lane.style.transform = 'translateX(' + x.toFixed(1) + 'px)';
18491852
}
1853+
/* Hvilket taktslag som ligger på spillehodet ved en gitt forskyvning */
1854+
function beatAtBandOffset(x) {
1855+
const w = ($('sband') && $('sband').clientWidth) || 1;
1856+
return (w / 2 - S_HEAD - x) / ppbS();
1857+
}
1858+
/* Invers av beatAtIndexF: fra taktslag til (brøkdels) tonenummer */
1859+
function indexAtBeat(beat) {
1860+
const n = state.events.length;
1861+
if (!n) return 0;
1862+
if (beat <= 0) return 0;
1863+
for (let i = 0; i < n; i++) {
1864+
const len = state.events[i].beats;
1865+
if (beat < beatStart[i] + len) return i + (beat - beatStart[i]) / len;
1866+
}
1867+
return n - 1;
1868+
}
1869+
/* Flytter begge banene til samme punkt i sangen */
1870+
function setLanesFromBeat(beat, transition) {
1871+
sLaneStyle(transition, sOffset(beat));
1872+
if (curDir() === 'v') vLaneStyle(transition, vOffset(indexAtBeat(beat)));
1873+
}
18501874
function paintBand() {
18511875
sSegs.forEach((el, j) => {
18521876
el.classList.toggle('on', j === state.cur);
@@ -1916,7 +1940,8 @@ <h2 class="ptitle" id="ptitle">♪</h2>
19161940
function freezeLanes() {
19171941
const v = freezeOne('vlane');
19181942
if (v) vY = parseFloat(v[5]);
1919-
freezeOne('slane');
1943+
const b = freezeOne('slane');
1944+
if (b) sX = parseFloat(b[4]);
19201945
}
19211946
/* Etter dra eller hjul: hopp til tonen banen står på */
19221947
function snapStripLane() {
@@ -1966,6 +1991,75 @@ <h2 class="ptitle" id="ptitle">♪</h2>
19661991
$('hint').textContent = T('ui.hint');
19671992
}
19681993

1994+
/* Notebåndet kan dras sidelengs — det er den naturlige bevegelsen på en
1995+
vannrett tidslinje, og den korteste veien tilbake til begynnelsen.
1996+
Kortene følger med, og ved slipp hopper begge til nærmeste tone. */
1997+
function setupBandDrag(el) {
1998+
let down = false, moved = false, pid = null, startX = 0, base = 0;
1999+
let justDragged = false, lastIdx = -1, snapTimer = null;
2000+
const beatAt = x => clamp(beatAtBandOffset(x), 0, beatTotal);
2001+
const follow = beat => {
2002+
setLanesFromBeat(beat, 'none');
2003+
if (curDir() !== 'v') { // kortstripen hopper til nærmeste tone
2004+
const i = clamp(Math.round(indexAtBeat(beat)), 0, state.events.length - 1);
2005+
if (i !== lastIdx) { lastIdx = i; centerCard(cards[i], true); }
2006+
}
2007+
};
2008+
const snap = () => {
2009+
if (!state.events.length) return;
2010+
go(clamp(Math.round(indexAtBeat(beatAt(sX))), 0, state.events.length - 1));
2011+
};
2012+
el.addEventListener('pointerdown', e => {
2013+
if (e.pointerType === 'mouse' && e.button !== 0) return;
2014+
down = true; moved = false; pid = e.pointerId;
2015+
startX = e.clientX; base = sX; lastIdx = -1;
2016+
e.stopPropagation();
2017+
});
2018+
el.addEventListener('pointermove', e => {
2019+
if (!down || e.pointerId !== pid) return;
2020+
e.stopPropagation();
2021+
const dx = e.clientX - startX;
2022+
if (!moved) {
2023+
if (Math.abs(dx) < 5) return;
2024+
moved = true;
2025+
el.classList.add('grabbing');
2026+
stopIfPlaying();
2027+
try { el.setPointerCapture(pid); } catch (err) { /* ignorer */ }
2028+
}
2029+
follow(beatAt(base + dx));
2030+
e.preventDefault();
2031+
});
2032+
const end = e => {
2033+
if (!down) return;
2034+
down = false;
2035+
el.classList.remove('grabbing');
2036+
try { el.releasePointerCapture(pid); } catch (err) { /* ignorer */ }
2037+
if (e) e.stopPropagation();
2038+
justDragged = moved;
2039+
if (moved) snap();
2040+
moved = false;
2041+
};
2042+
el.addEventListener('pointerup', end);
2043+
el.addEventListener('pointercancel', end);
2044+
el.addEventListener('lostpointercapture', end);
2045+
el.addEventListener('click', e => {
2046+
if (!justDragged) return;
2047+
justDragged = false;
2048+
e.stopPropagation();
2049+
e.preventDefault();
2050+
}, true);
2051+
el.addEventListener('wheel', e => {
2052+
const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
2053+
stopIfPlaying();
2054+
follow(beatAt(sX - d));
2055+
clearTimeout(snapTimer);
2056+
snapTimer = setTimeout(snap, 140);
2057+
e.preventDefault();
2058+
e.stopPropagation();
2059+
}, { passive: false });
2060+
el.addEventListener('dragstart', e => e.preventDefault());
2061+
}
2062+
19692063
function setupStripDrag(el) {
19702064
let down = false, moved = false, pid = null, start = 0, base = 0;
19712065
let justDragged = false, vertical = false, snapTimer = null;
@@ -2843,6 +2937,7 @@ <h2 class="ptitle" id="ptitle">♪</h2>
28432937
positionBand(true);
28442938
});
28452939
setupStripDrag($('strip'));
2940+
setupBandDrag($('sband'));
28462941

28472942
rebuildAll();
28482943
updateTempoBar();

‎test_bundle.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ const REQUIRED = ['$', 'esc', 'clamp', 'T', 'openSheet', 'closeSheet', 'switchRo
106106
'renderStaffBand', 'renderLegend', 'instrName', 'updateHeader', 'paintBand', 'positionBand',
107107
'glideBand', 'makeCard', 'go', 'init', 'buildUrl', 'setSource', 'loadSong',
108108
'outputLatency', 'startCalibration', 'stopCalibration', 'calibratorRow', 'applyAudioOffset',
109+
'setupBandDrag', 'indexAtBeat', 'beatAtBandOffset', 'setLanesFromBeat',
109110
'setInstrument', 'playPause', 'applyTempo', 'updateTempoBar', 'setupStripDrag', 'buildPrompt'];
110111
REQUIRED.forEach(f => {
111112
const n = f.replace('$', '\\$');

0 commit comments

Comments
 (0)