|
56 | 56 | return `calc(100% / ${count})`; |
57 | 57 | }); |
58 | 58 |
|
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; |
61 | 65 |
|
62 | | - const matches: ContrastMatch[] = []; |
63 | 66 | const increment = direction === 'lighter' ? 1 : -1; |
64 | 67 | const baseTone = props.tones[state.activeIndex]; |
65 | 68 |
|
| 69 | + // Search outwards from active index |
66 | 70 | for ( |
67 | 71 | let cursor = state.activeIndex + increment; |
68 | 72 | cursor >= 0 && cursor < props.tones.length; |
|
72 | 76 | const ratioValue = getContrastRatio(baseTone.hex, candidate.hex); |
73 | 77 |
|
74 | 78 | if (ratioValue >= ratio) { |
75 | | - matches.push({ tone: candidate, ratio: ratioValue }); |
| 79 | + return { tone: candidate, ratio: ratioValue }; |
76 | 80 | } |
77 | 81 | } |
78 | 82 |
|
79 | | - return matches; |
| 83 | + return null; |
80 | 84 | }; |
81 | 85 |
|
82 | | - const matchBuckets = computed(() => { |
| 86 | + const anchors = computed(() => { |
83 | 87 | if (state.activeIndex === null) return null; |
84 | 88 |
|
85 | 89 | 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), |
90 | 94 | }; |
91 | 95 | }); |
92 | 96 |
|
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 | + }; |
97 | 138 |
|
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 | + }; |
99 | 145 | }); |
100 | 146 |
|
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 | + }; |
106 | 213 |
|
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); |
109 | 218 |
|
110 | 219 | 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, |
115 | 222 | }; |
116 | 223 | }); |
117 | 224 |
|
| 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 | +
|
118 | 236 | const helperDots = computed(() => { |
119 | 237 | const dots = new Map< |
120 | 238 | number, |
|
193 | 311 | } |
194 | 312 | }); |
195 | 313 |
|
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 | | -
|
202 | 314 | const handleWheel = (event: WheelEvent) => { |
203 | 315 | if (state.activeIndex === null) return; |
204 | 316 | event.preventDefault(); |
|
225 | 337 | } |
226 | 338 | }; |
227 | 339 |
|
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; |
232 | 346 | }); |
233 | 347 |
|
234 | 348 | watch([resolvedMatches, () => state.activeIndex, () => state.offset], emitSelection); |
|
0 commit comments