|
9 | 9 | ANSI_HUE_ARRAY, |
10 | 10 | BRIGHT_COLOR_LIGHTNESS_BOOST, |
11 | 11 | BRIGHT_COLOR_SATURATION_BOOST, |
| 12 | + SYNTHESIS_SCORE_THRESHOLD, |
| 13 | + ANSI_MIN_SATURATION_FOR_MATCH, |
12 | 14 | } from './constants.js'; |
13 | 15 |
|
14 | 16 | /** |
@@ -164,20 +166,33 @@ export function findForegroundColor(colors, lightMode, usedIndices) { |
164 | 166 |
|
165 | 167 | /** |
166 | 168 | * Calculates color quality score for ANSI color selection |
167 | | - * Prioritizes hue accuracy, then favors more saturated colors |
| 169 | + * Balances hue accuracy, saturation preference, and lightness suitability |
| 170 | + * Lower score = better match |
168 | 171 | * |
169 | 172 | * @param {{h: number, s: number, l: number}} hsl - HSL color values |
170 | 173 | * @param {number} targetHue - Target hue (0-360) |
171 | 174 | * @returns {number} Score (lower is better) |
172 | 175 | */ |
173 | 176 | export function calculateColorScore(hsl, targetHue) { |
174 | | - const hueDiff = calculateHueDistance(hsl.h, targetHue) * 3; |
175 | | - const saturationPenalty = hsl.s < MIN_CHROMATIC_SATURATION ? 50 : 0; |
176 | | - const saturationReward = (100 - hsl.s) / 2; |
177 | | - const lightnessPenalty = |
178 | | - hsl.l < TOO_DARK_THRESHOLD || hsl.l > TOO_BRIGHT_THRESHOLD ? 10 : 0; |
| 177 | + // Hue accuracy - primary factor |
| 178 | + const hueScore = calculateHueDistance(hsl.h, targetHue) * 2.5; |
179 | 179 |
|
180 | | - return hueDiff + saturationPenalty + saturationReward + lightnessPenalty; |
| 180 | + // Saturation preference - strongly prefer chromatic colors |
| 181 | + let satScore; |
| 182 | + if (hsl.s < ANSI_MIN_SATURATION_FOR_MATCH) satScore = 80; |
| 183 | + else if (hsl.s < 20) satScore = 40; |
| 184 | + else if (hsl.s < 30) satScore = 15; |
| 185 | + else satScore = Math.max(0, (50 - hsl.s) * 0.3); |
| 186 | + |
| 187 | + // Lightness suitability - prefer mid-range, penalize extremes |
| 188 | + let lightnessScore; |
| 189 | + if (hsl.l < TOO_DARK_THRESHOLD) |
| 190 | + lightnessScore = (TOO_DARK_THRESHOLD - hsl.l) * 2.5; |
| 191 | + else if (hsl.l > TOO_BRIGHT_THRESHOLD) |
| 192 | + lightnessScore = (hsl.l - TOO_BRIGHT_THRESHOLD) * 2; |
| 193 | + else lightnessScore = Math.abs(hsl.l - 55) * 0.2; |
| 194 | + |
| 195 | + return hueScore + satScore + lightnessScore; |
181 | 196 | } |
182 | 197 |
|
183 | 198 | /** |
@@ -208,12 +223,19 @@ export function findBestColorMatch(targetHue, colorPool, usedIndices) { |
208 | 223 |
|
209 | 224 | /** |
210 | 225 | * Generates a lighter version of a color for bright ANSI slots |
| 226 | + * Scales the boost based on available headroom to avoid washing out bright colors |
211 | 227 | * @param {string} hexColor - Base hex color |
212 | 228 | * @returns {string} Lightened hex color |
213 | 229 | */ |
214 | 230 | export function generateBrightVersion(hexColor) { |
215 | 231 | const hsl = getColorHSL(hexColor); |
216 | | - const newLightness = Math.min(100, hsl.l + BRIGHT_COLOR_LIGHTNESS_BOOST); |
| 232 | + // Scale boost based on available headroom so bright colors don't wash out |
| 233 | + const headroom = 90 - hsl.l; |
| 234 | + const boost = Math.max( |
| 235 | + 5, |
| 236 | + Math.min(BRIGHT_COLOR_LIGHTNESS_BOOST, headroom * 0.6) |
| 237 | + ); |
| 238 | + const newLightness = Math.min(90, hsl.l + boost); |
217 | 239 | const newSaturation = Math.min(100, hsl.s * BRIGHT_COLOR_SATURATION_BOOST); |
218 | 240 | return hslToHex(hsl.h, newSaturation, newLightness); |
219 | 241 | } |
@@ -242,3 +264,92 @@ export function sortColorsByLightness(colors) { |
242 | 264 | }) |
243 | 265 | .sort((a, b) => a.lightness - b.lightness); |
244 | 266 | } |
| 267 | + |
| 268 | +/** |
| 269 | + * Synthesizes an ANSI color when no good match exists in the image |
| 270 | + * Uses the average saturation and lightness of already-assigned colors |
| 271 | + * to create a color that fits the palette's visual mood |
| 272 | + * |
| 273 | + * @param {number} targetHue - Target ANSI hue (0-360) |
| 274 | + * @param {string[]} existingColors - Already-assigned ANSI colors |
| 275 | + * @returns {string} Synthesized hex color |
| 276 | + */ |
| 277 | +export function synthesizeAnsiColor(targetHue, existingColors) { |
| 278 | + let totalS = 0, |
| 279 | + totalL = 0, |
| 280 | + count = 0; |
| 281 | + |
| 282 | + for (const color of existingColors) { |
| 283 | + if (!color) continue; |
| 284 | + const hsl = getColorHSL(color); |
| 285 | + if (hsl.s >= ANSI_MIN_SATURATION_FOR_MATCH) { |
| 286 | + totalS += hsl.s; |
| 287 | + totalL += hsl.l; |
| 288 | + count++; |
| 289 | + } |
| 290 | + } |
| 291 | + |
| 292 | + // Fall back to reasonable defaults if no reference colors |
| 293 | + const avgS = count > 0 ? totalS / count : 50; |
| 294 | + const avgL = count > 0 ? totalL / count : 55; |
| 295 | + |
| 296 | + // Clamp to ensure the synthesized color is visually clear |
| 297 | + const synS = Math.max(35, Math.min(75, avgS)); |
| 298 | + const synL = Math.max(40, Math.min(70, avgL)); |
| 299 | + |
| 300 | + return hslToHex(targetHue, synS, synL); |
| 301 | +} |
| 302 | + |
| 303 | +/** |
| 304 | + * Finds optimal ANSI color assignments using global greedy matching |
| 305 | + * Instead of assigning colors sequentially (red first, then green, etc.), |
| 306 | + * this finds the globally best (ANSI slot, color) pair at each step, |
| 307 | + * preventing earlier slots from stealing good matches from later ones |
| 308 | + * |
| 309 | + * @param {string[]} colorPool - Available colors to choose from |
| 310 | + * @param {Set<number>} usedIndices - Already used color indices |
| 311 | + * @returns {Array<{poolIndex: number, score: number}|null>} Assignment for each ANSI slot (0-5) |
| 312 | + */ |
| 313 | +export function findOptimalAnsiAssignment(colorPool, usedIndices) { |
| 314 | + // Pre-compute and sort scores for all (ANSI slot, color) pairs |
| 315 | + const allScores = ANSI_HUE_ARRAY.map(targetHue => { |
| 316 | + return colorPool |
| 317 | + .map((color, poolIndex) => { |
| 318 | + if (usedIndices.has(poolIndex)) |
| 319 | + return {poolIndex, score: Infinity}; |
| 320 | + const hsl = getColorHSL(color); |
| 321 | + return {poolIndex, score: calculateColorScore(hsl, targetHue)}; |
| 322 | + }) |
| 323 | + .sort((a, b) => a.score - b.score); |
| 324 | + }); |
| 325 | + |
| 326 | + const assignments = new Array(6).fill(null); |
| 327 | + const assignedPoolIndices = new Set(usedIndices); |
| 328 | + |
| 329 | + // Iteratively assign the globally best pair |
| 330 | + for (let round = 0; round < 6; round++) { |
| 331 | + let bestAnsi = -1; |
| 332 | + let bestPoolIndex = -1; |
| 333 | + let bestScore = Infinity; |
| 334 | + |
| 335 | + for (let a = 0; a < 6; a++) { |
| 336 | + if (assignments[a] !== null) continue; |
| 337 | + // Find best unassigned candidate for this slot |
| 338 | + for (const candidate of allScores[a]) { |
| 339 | + if (assignedPoolIndices.has(candidate.poolIndex)) continue; |
| 340 | + if (candidate.score < bestScore) { |
| 341 | + bestScore = candidate.score; |
| 342 | + bestAnsi = a; |
| 343 | + bestPoolIndex = candidate.poolIndex; |
| 344 | + } |
| 345 | + break; // First unassigned is best (list is sorted) |
| 346 | + } |
| 347 | + } |
| 348 | + |
| 349 | + if (bestAnsi === -1) break; |
| 350 | + assignments[bestAnsi] = {poolIndex: bestPoolIndex, score: bestScore}; |
| 351 | + assignedPoolIndices.add(bestPoolIndex); |
| 352 | + } |
| 353 | + |
| 354 | + return assignments; |
| 355 | +} |
0 commit comments