|
76 | 76 | selectedItemCircleColor = "#1d70b8", // Default color when not using palette
|
77 | 77 | selectedItemCircleColorPalette = [
|
78 | 78 | // Complete GOV.UK Design System palette (19 colors)
|
| 79 | + // Maximum selections = 19 before colors start cycling |
79 | 80 | "#1d70b8", // Blue (primary)
|
80 | 81 | "#d4351c", // Red
|
81 | 82 | "#00703c", // Green
|
|
94 | 95 | "#626a6e", // Mid grey
|
95 | 96 | "#b1b4b6", // Light grey
|
96 | 97 | "#0b0c0c", // Black
|
97 |
| - "#ffffff", // White (with border for visibility) |
98 |
| - ], // Complete GOV.UK Design System palette |
| 98 | + ], // Complete GOV.UK Design System palette (19 colors) |
99 | 99 | ...attributes
|
100 | 100 | }: {
|
101 | 101 | id: string;
|
|
167 | 167 | return div.innerHTML;
|
168 | 168 | }
|
169 | 169 |
|
170 |
| - // Extended color palette using proven data visualization algorithms |
171 |
| - function generateExtendedColorPalette(count: number): string[] { |
172 |
| - if (count === 0) return []; |
173 |
| -
|
174 |
| - // Start with the predefined GOV.UK palette |
175 |
| - const baseColors = [...selectedItemCircleColorPalette]; |
176 |
| -
|
177 |
| - if (count <= baseColors.length) { |
178 |
| - return baseColors.slice(0, count); |
| 170 | + // Sequential color mapping: each selected item gets a unique color based on selection order |
| 171 | + // This ensures perfect visual distinction and predictable color assignment |
| 172 | + // 1st selection = color[0], 2nd selection = color[1], 3rd selection = color[2], etc. |
| 173 | + let selectedItemIndexMap = new Map<string, number>(); // Maps item value to selection index |
| 174 | + let nextSelectionIndex = 0; // Tracks the next available color index |
| 175 | +
|
| 176 | + // Get the maximum number of selections allowed (limited by palette size) |
| 177 | + // With 19 GOV.UK colors, maximum selections = 19 before cycling begins |
| 178 | + const maxSelections = selectedItemCircleColorPalette.length; |
| 179 | +
|
| 180 | + // Function to get color for a selected item based on its selection order |
| 181 | + function getColorForSelectedItem(itemValue: string | number): string { |
| 182 | + const valueKey = String(itemValue); |
| 183 | +
|
| 184 | + // If this item already has an index, use it |
| 185 | + if (selectedItemIndexMap.has(valueKey)) { |
| 186 | + const index = selectedItemIndexMap.get(valueKey)!; |
| 187 | + console.log("🎨 Color index map hit (existing item):", { |
| 188 | + itemValue: valueKey, |
| 189 | + existingColorIndex: index, |
| 190 | + existingColor: selectedItemCircleColorPalette[index], |
| 191 | + totalMappedItems: selectedItemIndexMap.size, |
| 192 | + }); |
| 193 | + return selectedItemCircleColorPalette[index]; |
179 | 194 | }
|
180 | 195 |
|
181 |
| - // For more colors, generate using Plotly.js-style algorithm |
182 |
| - const extendedColors = [...baseColors]; |
183 |
| - const needed = count - baseColors.length; |
184 |
| -
|
185 |
| - // Use golden ratio and HSL for optimal color distribution |
186 |
| - const goldenRatio = 0.618033988749895; |
187 |
| - let hue = 0; |
188 |
| -
|
189 |
| - for (let i = 0; i < needed; i++) { |
190 |
| - // Vary saturation and lightness for accessibility |
191 |
| - const saturation = 65 + (i % 3) * 10; // 65%, 75%, 85% |
192 |
| - const lightness = 45 + (i % 4) * 10; // 45%, 55%, 65%, 75% |
193 |
| -
|
194 |
| - // Use golden ratio for optimal hue distribution |
195 |
| - hue = (hue + goldenRatio) % 1; |
196 |
| - const hslHue = Math.floor(hue * 360); |
197 |
| -
|
198 |
| - const color = `hsl(${hslHue}, ${saturation}%, ${lightness}%)`; |
199 |
| - // Avoid pure white/very light colors |
200 |
| - if (lightness < 90) { |
201 |
| - extendedColors.push(color); |
202 |
| - } |
| 196 | + // If we've reached the palette limit, cycle back to the beginning |
| 197 | + // This means the 20th selection will get color[0], 21st will get color[1], etc. |
| 198 | + if (nextSelectionIndex >= maxSelections) { |
| 199 | + nextSelectionIndex = 0; |
203 | 200 | }
|
204 | 201 |
|
205 |
| - return extendedColors; |
206 |
| - } |
| 202 | + // Assign the next available color index to this item |
| 203 | + const colorIndex = nextSelectionIndex; |
| 204 | + selectedItemIndexMap.set(valueKey, colorIndex); |
| 205 | + nextSelectionIndex++; |
| 206 | +
|
| 207 | + // Log the index map update for debugging |
| 208 | + console.log("🎨 Color index map updated:", { |
| 209 | + itemValue: valueKey, |
| 210 | + assignedColorIndex: colorIndex, |
| 211 | + assignedColor: selectedItemCircleColorPalette[colorIndex], |
| 212 | + nextSelectionIndex, |
| 213 | + totalMappedItems: selectedItemIndexMap.size, |
| 214 | + currentMap: Object.fromEntries(selectedItemIndexMap), |
| 215 | + }); |
207 | 216 |
|
208 |
| - // Helper function to get optimized color palette based on number of selected items |
209 |
| - function getOptimizedColorPalette(selectedCount: number): string[] { |
210 |
| - return generateExtendedColorPalette(selectedCount); |
| 217 | + return selectedItemCircleColorPalette[colorIndex]; |
211 | 218 | }
|
212 | 219 |
|
213 |
| - // Precompute stable palette once instead of regenerating per call |
214 |
| - const stablePalette = (() => { |
215 |
| - const base = [...selectedItemCircleColorPalette]; |
216 |
| - // Filter out pure white to avoid visibility issues |
217 |
| - const filtered = base.filter(color => color !== "#ffffff"); |
218 |
| - // extend once if needed: |
219 |
| - return filtered.length >= 64 ? filtered : generateExtendedColorPalette(64); |
220 |
| - })(); |
| 220 | + // Function to reset the selection index when items are removed |
| 221 | + // Call this when you want to clear all selections and start fresh |
| 222 | + function resetSelectionIndexes() { |
| 223 | + console.log("🔄 Color index map reset:", { |
| 224 | + previousMapSize: selectedItemIndexMap.size, |
| 225 | + previousNextIndex: nextSelectionIndex, |
| 226 | + }); |
| 227 | + selectedItemIndexMap.clear(); |
| 228 | + nextSelectionIndex = 0; |
| 229 | + console.log("✅ Color index map reset complete"); |
| 230 | + } |
221 | 231 |
|
222 | 232 | // Color cache to ensure consistent colors for the same values
|
223 | 233 | const colorCache = new Map<string, string>();
|
224 | 234 |
|
225 | 235 | /**
|
226 | 236 | * Generate a consistent, deterministic color for a given value.
|
227 |
| - * Uses FNV-1a hash with proper unsigned handling to avoid color duplication. |
228 |
| - * Caches results for performance and consistency. |
| 237 | + * Uses sequential index-based mapping for perfect visual distinction. |
| 238 | + * Each selected item gets a unique color based on selection order. |
229 | 239 | */
|
230 | 240 | function colorForValue(val: unknown): string {
|
231 | 241 | const key = String(val).toLowerCase().trim();
|
232 | 242 | const cached = colorCache.get(key);
|
233 | 243 | if (cached) return cached;
|
234 | 244 |
|
235 |
| - // FNV-1a (32-bit) -> force unsigned before modulo to avoid negative array indices |
236 |
| - let h = 2166136261; |
237 |
| - for (let i = 0; i < key.length; i++) { |
238 |
| - h ^= key.charCodeAt(i); |
239 |
| - h = Math.imul(h, 16777619); |
240 |
| - } |
241 |
| - h >>>= 0; // ✅ make it unsigned |
242 |
| -
|
243 |
| - const idx = h % stablePalette.length; // 0..palette-1 |
244 |
| - const color = stablePalette[idx] || selectedItemCircleColor; |
| 245 | + // Use sequential color mapping instead of hash-based approach |
| 246 | + const color = getColorForSelectedItem(String(val)); |
245 | 247 | colorCache.set(key, color);
|
246 | 248 | return color;
|
247 | 249 | }
|
|
0 commit comments