Skip to content

Commit acfb62b

Browse files
committed
feat: refactor contrast markers to support free spatial movement
1 parent 4de4219 commit acfb62b

File tree

1 file changed

+150
-36
lines changed

1 file changed

+150
-36
lines changed

src/components/tonal-builder/TonalStrip.vue

Lines changed: 150 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,17 @@
5656
return `calc(100% / ${count})`;
5757
});
5858
59-
const findMatches = (direction: ContrastDirection, ratio: number): ContrastMatch[] => {
60-
if (state.activeIndex === null) return [];
59+
/*
60+
* Returns the closest tone index (to the active index) that satisfies the contrast ratio.
61+
* "closest" means the first match found when searching outwards from the base.
62+
*/
63+
const findClosestMatch = (direction: ContrastDirection, ratio: number): ContrastMatch | null => {
64+
if (state.activeIndex === null) return null;
6165
62-
const matches: ContrastMatch[] = [];
6366
const increment = direction === 'lighter' ? 1 : -1;
6467
const baseTone = props.tones[state.activeIndex];
6568
69+
// Search outwards from active index
6670
for (
6771
let cursor = state.activeIndex + increment;
6872
cursor >= 0 && cursor < props.tones.length;
@@ -72,49 +76,163 @@
7276
const ratioValue = getContrastRatio(baseTone.hex, candidate.hex);
7377
7478
if (ratioValue >= ratio) {
75-
matches.push({ tone: candidate, ratio: ratioValue });
79+
return { tone: candidate, ratio: ratioValue };
7680
}
7781
}
7882
79-
return matches;
83+
return null;
8084
};
8185
82-
const matchBuckets = computed(() => {
86+
const anchors = computed(() => {
8387
if (state.activeIndex === null) return null;
8488
8589
return {
86-
darker3: findMatches('darker', 3),
87-
darker45: findMatches('darker', 4.5),
88-
lighter3: findMatches('lighter', 3),
89-
lighter45: findMatches('lighter', 4.5),
90+
darker3: findClosestMatch('darker', 3),
91+
darker45: findClosestMatch('darker', 4.5),
92+
lighter3: findClosestMatch('lighter', 3),
93+
lighter45: findClosestMatch('lighter', 4.5),
9094
};
9195
});
9296
93-
const maxOffset = computed(() => {
94-
const lengths = Object.values(matchBuckets.value ?? {}).map((bucket) =>
95-
Math.max(0, bucket.length - 1),
96-
);
97+
// Helper to safely get tone at index
98+
const getToneAtIndex = (index: number): ContrastMatch | null => {
99+
if (index < 0 || index >= props.tones.length) return null;
100+
const tone = props.tones[index];
101+
// Re-calculate ratio for this strictly spatial match
102+
if (state.activeIndex === null) return null;
103+
const baseTone = props.tones[state.activeIndex];
104+
const ratio = getContrastRatio(baseTone.hex, tone.hex);
105+
return { tone, ratio };
106+
};
107+
108+
const resolvedMatches = computed(() => {
109+
if (!anchors.value || state.activeIndex === null) return null;
110+
const { darker3, darker45, lighter3, lighter45 } = anchors.value;
111+
112+
// For darker matches, increasing offset moves AWAY from base (lower index)
113+
// For lighter matches, increasing offset moves AWAY from base (higher index)
114+
const resolve = (anchor: ContrastMatch | null, direction: ContrastDirection) => {
115+
if (!anchor) return null;
116+
// Index delta: if darker, we substract offset. If lighter, we add offset.
117+
// But we must also ensure we don't cross the base or go out of bounds.
118+
// Actually, wait. The offset is always positive in the UI (it's a magnitude).
119+
// Let's look at index.js: `closestIndex - offset` (darker) and `closestIndex + offset` (lighter).
120+
// And offset is a positive integer derived from scroll.
121+
122+
const sign = direction === 'lighter' ? 1 : -1;
123+
const targetIndex = props.tones.indexOf(anchor.tone) + sign * state.offset;
124+
125+
// Now clamp to bounds.
126+
// Darker must be < activeIndex and >= 0
127+
// Lighter must be > activeIndex and < tones.length
128+
if (direction === 'darker') {
129+
// Clamp between 0 and activeIndex - 1
130+
const clampedIndex = clamp(targetIndex, 0, state.activeIndex! - 1);
131+
return getToneAtIndex(clampedIndex);
132+
} else {
133+
// Clamp between activeIndex + 1 and length - 1
134+
const clampedIndex = clamp(targetIndex, state.activeIndex! + 1, props.tones.length - 1);
135+
return getToneAtIndex(clampedIndex);
136+
}
137+
};
97138
98-
return lengths.length ? Math.max(...lengths) : 0;
139+
return {
140+
darker3: resolve(darker3, 'darker'),
141+
darker45: resolve(darker45, 'darker'),
142+
lighter3: resolve(lighter3, 'lighter'),
143+
lighter45: resolve(lighter45, 'lighter'),
144+
};
99145
});
100146
101-
const resolveWithOffset = (matches: ContrastMatch[]): ContrastMatch | null => {
102-
if (!matches.length) return null;
103-
const targetIndex = clamp(state.offset, 0, matches.length - 1);
104-
return matches[targetIndex];
105-
};
147+
// Calculate limits for the offset
148+
const offsetLimits = computed(() => {
149+
if (state.activeIndex === null || !anchors.value) return { min: 0, max: 0 };
150+
151+
const { darker3, darker45, lighter3, lighter45 } = anchors.value;
152+
const baseIndex = state.activeIndex;
153+
const totalTones = props.tones.length;
154+
155+
// We need to find the most restrictive limits that still allow movement.
156+
// Actually, we want the most permissive limits that stay within bounds?
157+
// No, "offset" is global. If we set offset to -10, ALL markers shift by -10.
158+
// We must ensure that correctly shifting BY -10 keeps ALL active markers in bounds?
159+
// Or do we stop them individually?
160+
// The previous `resolve` logic clamps individually.
161+
// So `state.offset` can technically go as far as we want, and markers just pile up at the edge.
162+
// BUT to prevent "dead scrolling" (scrolling where nothing moves), we should limit `state.offset`
163+
// to the range where AT LEAST ONE marker is still moving.
164+
//
165+
// For Min Offset (contraction towards base):
166+
// We can decrease offset until the "furthest" marker hits the base neighbor?
167+
// Or until the "closest" marker hits the base neighbor?
168+
// If we have markers at 40 and 20 (Base 50).
169+
// offset 0: 40, 20.
170+
// offset -5: 45, 25.
171+
// offset -9: 49, 29.
172+
// offset -10: 50 (CLASH), 30.
173+
// The previous clamping logic handles the clash.
174+
// So we can let offset go to -Infinity and they just stick.
175+
// But for UX, we probably want to stop scrolling when the *last* moving thing stops.
176+
// Or just pick a reasonable range.
177+
// Let's calculate the theoretical max range for each anchor.
178+
179+
// Darker anchors (index < base):
180+
// Move towards base (negative offset): max move is (baseIndex - 1) - anchorIndex.
181+
// Move away (positive offset): max move is anchorIndex - 0.
182+
183+
// Lighter anchors (index > base):
184+
// Move towards base (negative offset): max move is anchorIndex - (baseIndex + 1).
185+
// Move away (positive offset): max move is (length - 1) - anchorIndex.
186+
187+
// We want the range [min, max] where min is negative.
188+
// Min limit is the negative of the maximum possible contraction.
189+
// Max limit is the maximum possible expansion.
190+
191+
let maxContraction = 0; // Absolute value
192+
let maxExpansion = 0;
193+
194+
const check = (anchorIndex: number, isDarker: boolean) => {
195+
if (isDarker) {
196+
// Anchor < Base
197+
// Contraction (move to Base-1): Distance = (baseIndex - 1) - anchorIndex
198+
maxContraction = Math.max(maxContraction, Math.max(0, baseIndex - 1 - anchorIndex));
199+
// Expansion (move to 0): Distance = anchorIndex
200+
// For darker, expansion (positive offset) moves to 0?
201+
// Wait, `target = anchor - offset`. Positive offset reduces index.
202+
// Yes. So max positive offset = anchorIndex.
203+
maxExpansion = Math.max(maxExpansion, anchorIndex);
204+
} else {
205+
// Anchor > Base
206+
// Contraction (move to Base+1): Distance = anchorIndex - (baseIndex + 1)
207+
maxContraction = Math.max(maxContraction, Math.max(0, anchorIndex - (baseIndex + 1)));
208+
// Expansion (move to End): Distance = (total - 1) - anchorIndex.
209+
// `target = anchor + offset`. Positive offset increases index.
210+
maxExpansion = Math.max(maxExpansion, totalTones - 1 - anchorIndex);
211+
}
212+
};
106213
107-
const resolvedMatches = computed(() => {
108-
if (!matchBuckets.value || state.activeIndex === null) return null;
214+
if (darker3) check(darker3.tone.index, true);
215+
if (darker45) check(darker45.tone.index, true);
216+
if (lighter3) check(lighter3.tone.index, false);
217+
if (lighter45) check(lighter45.tone.index, false);
109218
110219
return {
111-
darker3: resolveWithOffset(matchBuckets.value.darker3),
112-
darker45: resolveWithOffset(matchBuckets.value.darker45),
113-
lighter3: resolveWithOffset(matchBuckets.value.lighter3),
114-
lighter45: resolveWithOffset(matchBuckets.value.lighter45),
220+
min: -maxContraction,
221+
max: maxExpansion,
115222
};
116223
});
117224
225+
const maxOffset = computed(() => offsetLimits.value.max); // Kept for compat if needed, but we use limits now
226+
227+
const adjustOffset = (delta: number) => {
228+
// Current logic: state.offset
229+
// New logic: clamp between min and max
230+
const { min, max } = offsetLimits.value;
231+
const next = clamp(state.offset + delta, min, max);
232+
if (next === state.offset) return;
233+
state.offset = next;
234+
};
235+
118236
const helperDots = computed(() => {
119237
const dots = new Map<
120238
number,
@@ -193,12 +311,6 @@
193311
}
194312
});
195313
196-
const adjustOffset = (delta: number) => {
197-
const next = clamp(state.offset + delta, 0, maxOffset.value);
198-
if (next === state.offset) return;
199-
state.offset = next;
200-
};
201-
202314
const handleWheel = (event: WheelEvent) => {
203315
if (state.activeIndex === null) return;
204316
event.preventDefault();
@@ -225,10 +337,12 @@
225337
}
226338
};
227339
228-
watch(matchBuckets, () => {
229-
if (state.offset > maxOffset.value) {
230-
state.offset = maxOffset.value;
231-
}
340+
watch(state, (newState) => {
341+
if (state.activeIndex === null) return;
342+
// Ensure offset stays within dynamic limits if context changes
343+
const { min, max } = offsetLimits.value;
344+
if (newState.offset < min) state.offset = min;
345+
if (newState.offset > max) state.offset = max;
232346
});
233347
234348
watch([resolvedMatches, () => state.activeIndex, () => state.offset], emitSelection);

0 commit comments

Comments
 (0)