@rafters/color-utils 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/package.json +44 -0
- package/src/accessibility.ts +277 -0
- package/src/analysis.ts +166 -0
- package/src/apca-w3.d.ts +28 -0
- package/src/builder.ts +164 -0
- package/src/color-wheel.ts +235 -0
- package/src/colorjs.d.ts +36 -0
- package/src/conversion.ts +92 -0
- package/src/gamut.ts +117 -0
- package/src/harmony.ts +194 -0
- package/src/index.ts +110 -0
- package/src/internal/math.ts +35 -0
- package/src/manipulation.ts +16 -0
- package/src/naming/generator.ts +161 -0
- package/src/naming/hue-hubs.ts +2452 -0
- package/src/naming/index.ts +50 -0
- package/src/naming/quantize.ts +85 -0
- package/src/naming/word-banks.ts +154 -0
- package/src/scale-positions.ts +31 -0
- package/src/semantic.ts +397 -0
package/src/harmony.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harmony generation for design systems using pure OKLCH hue rotation.
|
|
3
|
+
*
|
|
4
|
+
* Rule: harmonies are PURE HUE ROTATIONS. L and C are never mutated.
|
|
5
|
+
* Only H changes. Every output goes through toNearestGamut() then roundOKLCH().
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ColorHarmonies, OKLCH } from '@rafters/shared';
|
|
9
|
+
import { roundOKLCH } from './conversion.js';
|
|
10
|
+
import { clampColor } from './gamut.js';
|
|
11
|
+
import { adjustHue } from './manipulation.js';
|
|
12
|
+
import { POSITION_TO_INDEX, SCALE_POSITIONS } from './scale-positions.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generate pure OKLCH harmony arrays from a base color.
|
|
16
|
+
*
|
|
17
|
+
* Counts (per the spec):
|
|
18
|
+
* - complementary: 1 color (+180)
|
|
19
|
+
* - triadic: 3 colors (base, +120, +240)
|
|
20
|
+
* - analogous: 6 colors (-45, -30, -15, +15, +30, +45)
|
|
21
|
+
* - tetradic: 4 colors (base, +90, +180, +270)
|
|
22
|
+
* - splitComplementary: 3 colors (base, +150, +210)
|
|
23
|
+
* - monochromatic: 6 colors (same hue, L steps at 0.15/0.30/0.45/0.60/0.75/0.90,
|
|
24
|
+
* chroma reduced at extremes)
|
|
25
|
+
*
|
|
26
|
+
* L and C are preserved exactly from the base color in all hue-rotation harmonies.
|
|
27
|
+
*/
|
|
28
|
+
export function generateHarmony(baseColor: OKLCH): ColorHarmonies {
|
|
29
|
+
const base = clampColor(baseColor);
|
|
30
|
+
|
|
31
|
+
// complementary: 1 color at +180
|
|
32
|
+
const complementary = clampColor(adjustHue(baseColor, 180));
|
|
33
|
+
|
|
34
|
+
// triadic: base, +120, +240
|
|
35
|
+
const triadic: OKLCH[] = [
|
|
36
|
+
base,
|
|
37
|
+
clampColor(adjustHue(baseColor, 120)),
|
|
38
|
+
clampColor(adjustHue(baseColor, 240)),
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
// analogous: -45, -30, -15, +15, +30, +45
|
|
42
|
+
const analogous: OKLCH[] = [
|
|
43
|
+
clampColor(adjustHue(baseColor, -45)),
|
|
44
|
+
clampColor(adjustHue(baseColor, -30)),
|
|
45
|
+
clampColor(adjustHue(baseColor, -15)),
|
|
46
|
+
clampColor(adjustHue(baseColor, 15)),
|
|
47
|
+
clampColor(adjustHue(baseColor, 30)),
|
|
48
|
+
clampColor(adjustHue(baseColor, 45)),
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// tetradic: base, +90, +180, +270
|
|
52
|
+
const tetradic: OKLCH[] = [
|
|
53
|
+
base,
|
|
54
|
+
clampColor(adjustHue(baseColor, 90)),
|
|
55
|
+
clampColor(adjustHue(baseColor, 180)),
|
|
56
|
+
clampColor(adjustHue(baseColor, 270)),
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
// splitComplementary: base, +150, +210
|
|
60
|
+
const splitComplementary: OKLCH[] = [
|
|
61
|
+
base,
|
|
62
|
+
clampColor(adjustHue(baseColor, 150)),
|
|
63
|
+
clampColor(adjustHue(baseColor, 210)),
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
// monochromatic: 6 steps at fixed L values, same H, chroma reduced at extremes
|
|
67
|
+
const MONO_LIGHTNESS = [0.15, 0.3, 0.45, 0.6, 0.75, 0.9] as const;
|
|
68
|
+
const monochromatic: OKLCH[] = MONO_LIGHTNESS.map((l) => {
|
|
69
|
+
// Reduce chroma at extreme ends to avoid gamut clipping artifacts
|
|
70
|
+
const c = l <= 0.15 || l >= 0.9 ? baseColor.c * 0.5 : baseColor.c;
|
|
71
|
+
return clampColor({ ...baseColor, l, c });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
complementary,
|
|
76
|
+
triadic,
|
|
77
|
+
analogous,
|
|
78
|
+
tetradic,
|
|
79
|
+
splitComplementary,
|
|
80
|
+
monochromatic,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validate if a color is suitable for scale generation
|
|
86
|
+
* Too light or too dark colors don't generate useful scales
|
|
87
|
+
*/
|
|
88
|
+
function validateScaleGeneration(baseColor: OKLCH): {
|
|
89
|
+
isValid: boolean;
|
|
90
|
+
reason?: string;
|
|
91
|
+
suggestedLightness?: number;
|
|
92
|
+
} {
|
|
93
|
+
const l = baseColor.l;
|
|
94
|
+
|
|
95
|
+
if (l > 0.85) {
|
|
96
|
+
return {
|
|
97
|
+
isValid: false,
|
|
98
|
+
reason: 'Color too light for scale generation',
|
|
99
|
+
suggestedLightness: 0.7,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (l < 0.15) {
|
|
104
|
+
return {
|
|
105
|
+
isValid: false,
|
|
106
|
+
reason: 'Color too dark for scale generation',
|
|
107
|
+
suggestedLightness: 0.4,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { isValid: true };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Generate lightness progression using mathematical functions.
|
|
116
|
+
* Base color positioned at 600, with 6 lighter steps above + 4 darker + 950.
|
|
117
|
+
* Power curve (t^0.8) for tints creates natural spacing weighted toward white.
|
|
118
|
+
* Linear progression for shades keeps darkening predictable.
|
|
119
|
+
*/
|
|
120
|
+
function generateLightnessProgression(baseLightness: number): Record<string, number> {
|
|
121
|
+
const MAX_LIGHT = 0.95;
|
|
122
|
+
const MIN_DARK = 0.05;
|
|
123
|
+
|
|
124
|
+
const positions = SCALE_POSITIONS;
|
|
125
|
+
const baseIndex = POSITION_TO_INDEX['600'] ?? 6;
|
|
126
|
+
|
|
127
|
+
const lightness: Record<string, number> = {};
|
|
128
|
+
|
|
129
|
+
for (let i = 0; i < baseIndex; i++) {
|
|
130
|
+
const stepsFromBase = baseIndex - i;
|
|
131
|
+
const totalLighterSteps = baseIndex;
|
|
132
|
+
|
|
133
|
+
const t = (stepsFromBase / totalLighterSteps) ** 0.8;
|
|
134
|
+
const calculatedL = baseLightness + (MAX_LIGHT - baseLightness) * t;
|
|
135
|
+
|
|
136
|
+
const pos = positions[i];
|
|
137
|
+
if (pos !== undefined) lightness[pos] = Math.min(MAX_LIGHT, calculatedL);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
lightness['600'] = baseLightness;
|
|
141
|
+
|
|
142
|
+
for (let i = baseIndex + 1; i < positions.length; i++) {
|
|
143
|
+
const stepsFromBase = i - baseIndex;
|
|
144
|
+
const totalDarkerSteps = positions.length - 1 - baseIndex;
|
|
145
|
+
|
|
146
|
+
const t = stepsFromBase / totalDarkerSteps;
|
|
147
|
+
const darkenAmount = (baseLightness - MIN_DARK) * t;
|
|
148
|
+
const calculatedL = Math.max(MIN_DARK, baseLightness - darkenAmount);
|
|
149
|
+
|
|
150
|
+
const pos = positions[i];
|
|
151
|
+
if (pos !== undefined) lightness[pos] = calculatedL;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return lightness;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Generate OKLCH color scale from base color.
|
|
159
|
+
* Creates 50-950 scale with mathematical lightness progression anchored at 600.
|
|
160
|
+
* Chroma reduced only at extreme lightness to avoid perceptual artifacts.
|
|
161
|
+
*/
|
|
162
|
+
export function generateOKLCHScale(baseColor: OKLCH): Record<string, OKLCH> {
|
|
163
|
+
const validation = validateScaleGeneration(baseColor);
|
|
164
|
+
|
|
165
|
+
if (!validation.isValid) {
|
|
166
|
+
const adjustedColor = validation.suggestedLightness
|
|
167
|
+
? { ...baseColor, l: validation.suggestedLightness }
|
|
168
|
+
: baseColor;
|
|
169
|
+
return generateOKLCHScale(adjustedColor);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const lightnessSteps = generateLightnessProgression(baseColor.l);
|
|
173
|
+
|
|
174
|
+
const scale: Record<string, OKLCH> = {};
|
|
175
|
+
|
|
176
|
+
for (const [step, lightness] of Object.entries(lightnessSteps)) {
|
|
177
|
+
let adjustedChroma = baseColor.c;
|
|
178
|
+
|
|
179
|
+
if (lightness > 0.9) {
|
|
180
|
+
adjustedChroma *= 0.3;
|
|
181
|
+
} else if (lightness < 0.15) {
|
|
182
|
+
adjustedChroma *= 0.6;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
scale[step] = roundOKLCH({
|
|
186
|
+
l: lightness,
|
|
187
|
+
c: adjustedChroma,
|
|
188
|
+
h: baseColor.h,
|
|
189
|
+
alpha: baseColor.alpha,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return scale;
|
|
194
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @rafters/color-utils
|
|
3
|
+
* OKLCH color manipulation utilities for Rafters design system
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Accessibility
|
|
7
|
+
export {
|
|
8
|
+
type AccessibilityMetadata,
|
|
9
|
+
calculateAPCAContrast,
|
|
10
|
+
calculateWCAGContrast,
|
|
11
|
+
generateAccessibilityMetadata,
|
|
12
|
+
rebakeAccessibility,
|
|
13
|
+
} from './accessibility.js';
|
|
14
|
+
|
|
15
|
+
// Analysis
|
|
16
|
+
export {
|
|
17
|
+
calculateAtmosphericWeight,
|
|
18
|
+
calculatePerceptualWeight,
|
|
19
|
+
getColorTemperature,
|
|
20
|
+
isLightColor,
|
|
21
|
+
} from './analysis.js';
|
|
22
|
+
|
|
23
|
+
// Builder
|
|
24
|
+
export { type BuildColorValueOptions, buildColorValue } from './builder.js';
|
|
25
|
+
|
|
26
|
+
// Color Wheel
|
|
27
|
+
export {
|
|
28
|
+
type ColorWheelOptions,
|
|
29
|
+
colorWheel,
|
|
30
|
+
type HarmonyType,
|
|
31
|
+
type SemanticColorSystem,
|
|
32
|
+
} from './color-wheel.js';
|
|
33
|
+
|
|
34
|
+
// Conversion
|
|
35
|
+
export {
|
|
36
|
+
hexToOKLCH,
|
|
37
|
+
type OklchToCSSOptions,
|
|
38
|
+
oklchToCSS,
|
|
39
|
+
roundOKLCH,
|
|
40
|
+
tryParseColor,
|
|
41
|
+
} from './conversion.js';
|
|
42
|
+
|
|
43
|
+
// Gamut
|
|
44
|
+
export {
|
|
45
|
+
computeGamutBoundaries,
|
|
46
|
+
type GamutBoundaryPoint,
|
|
47
|
+
type GamutTier,
|
|
48
|
+
getGamutTier,
|
|
49
|
+
isInP3Gamut,
|
|
50
|
+
isInSRGBGamut,
|
|
51
|
+
toNearestGamut,
|
|
52
|
+
} from './gamut.js';
|
|
53
|
+
|
|
54
|
+
// Harmony
|
|
55
|
+
export { generateHarmony, generateOKLCHScale } from './harmony.js';
|
|
56
|
+
|
|
57
|
+
// Manipulation
|
|
58
|
+
export { adjustHue } from './manipulation.js';
|
|
59
|
+
// Naming
|
|
60
|
+
export {
|
|
61
|
+
BLUE_HUB,
|
|
62
|
+
C_BUCKET_COUNT,
|
|
63
|
+
type ChromaBand,
|
|
64
|
+
GREEN_HUB,
|
|
65
|
+
generateColorName,
|
|
66
|
+
generateColorNameWithMetadata,
|
|
67
|
+
getAllBuckets,
|
|
68
|
+
getCBucket,
|
|
69
|
+
getChromaBand,
|
|
70
|
+
getExpandedMaterialWord,
|
|
71
|
+
getHBucket,
|
|
72
|
+
getLBucket,
|
|
73
|
+
getLightnessBand,
|
|
74
|
+
getSubIndex,
|
|
75
|
+
H_BUCKET_COUNT,
|
|
76
|
+
HUE_HUBS,
|
|
77
|
+
type HueCell,
|
|
78
|
+
type HueHub,
|
|
79
|
+
type HueMatrix,
|
|
80
|
+
hasExpandedHub,
|
|
81
|
+
INTENSITY_WORDS,
|
|
82
|
+
type IntensityWord,
|
|
83
|
+
L_BUCKET_COUNT,
|
|
84
|
+
type LightnessBand,
|
|
85
|
+
LUMINOSITY_WORDS,
|
|
86
|
+
type LuminosityWord,
|
|
87
|
+
MATERIAL_WORDS,
|
|
88
|
+
type MaterialWord,
|
|
89
|
+
RED_HUB,
|
|
90
|
+
TOTAL_COMBINATIONS,
|
|
91
|
+
} from './naming/index.js';
|
|
92
|
+
// Scale positions (vocabulary only; selection lives in semantic.ts)
|
|
93
|
+
export { POSITION_TO_INDEX, SCALE_POSITIONS } from './scale-positions.js';
|
|
94
|
+
|
|
95
|
+
// Semantic selection (purpose-driven pair finding + seed-derived suggestions, #1636)
|
|
96
|
+
export {
|
|
97
|
+
generateSemanticColorSuggestions,
|
|
98
|
+
type Pair,
|
|
99
|
+
type PairLeg,
|
|
100
|
+
type PairRequest,
|
|
101
|
+
type PairStandard,
|
|
102
|
+
type PairTier,
|
|
103
|
+
type PairUse,
|
|
104
|
+
type SemanticContext,
|
|
105
|
+
SemanticSelectionError,
|
|
106
|
+
STATE_USES,
|
|
107
|
+
type StateUse,
|
|
108
|
+
semanticFor,
|
|
109
|
+
statusAnchor,
|
|
110
|
+
} from './semantic.js';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal numeric helpers shared across the package.
|
|
3
|
+
*
|
|
4
|
+
* These are the idioms that were repeated inline in half a dozen modules:
|
|
5
|
+
* clamping a channel, normalizing a hue angle, and the two chroma ceilings.
|
|
6
|
+
* Not part of the public surface -- nothing here is re-exported from index.ts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Clamp a value into [min, max]. */
|
|
10
|
+
export function clamp(value: number, min: number, max: number): number {
|
|
11
|
+
return Math.max(min, Math.min(max, value));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Clamp a value into [0, 1] -- the range every OKLCH lightness and alpha lives in. */
|
|
15
|
+
export function clamp01(value: number): number {
|
|
16
|
+
return clamp(value, 0, 1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Normalize a hue angle into [0, 360), handling negative and over-wrapped input. */
|
|
20
|
+
export function normalizeHue(hue: number): number {
|
|
21
|
+
return ((hue % 360) + 360) % 360;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Upper bound for a chroma search: no OKLCH color in any display gamut we
|
|
26
|
+
* care about exceeds this, so binary searches and bucket clamps start here.
|
|
27
|
+
*/
|
|
28
|
+
export const MAX_CHROMA = 0.4;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Chroma ceiling for "as vivid as a generated color may get". Deliberately
|
|
32
|
+
* lower than MAX_CHROMA and deliberately a separate constant: this is a design
|
|
33
|
+
* cap on generated output, not a bound on what OKLCH can represent.
|
|
34
|
+
*/
|
|
35
|
+
export const VIVID_CHROMA_CAP = 0.3;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OKLCH color manipulation functions
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { OKLCH } from '@rafters/shared';
|
|
6
|
+
import { normalizeHue } from './internal/math.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Adjust the hue of a color by degrees
|
|
10
|
+
*/
|
|
11
|
+
export function adjustHue(color: OKLCH, degrees: number): OKLCH {
|
|
12
|
+
return {
|
|
13
|
+
...color,
|
|
14
|
+
h: normalizeHue(color.h + degrees),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic Color Name Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates unique, semantically meaningful color names from OKLCH values.
|
|
5
|
+
* Uses temperature and perceptual weight to select appropriate word variants.
|
|
6
|
+
*
|
|
7
|
+
* Format: {luminosity}-{intensity}-{material}
|
|
8
|
+
* Examples: "deep-bold-cobalt", "faint-soft-sage", "shadow-whisper"
|
|
9
|
+
*
|
|
10
|
+
* Two-tier system:
|
|
11
|
+
* 1. Expanded hubs: Rich semantic word banks for Red, Green, Blue (more coming)
|
|
12
|
+
* - 5 lightness bands × 4 chroma bands × 10 words = 200 words per temp variant
|
|
13
|
+
* - Sub-selection uses fractional L/C for maximum uniqueness
|
|
14
|
+
* 2. Fallback: Original word banks for hues without expanded hubs
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { OKLCH } from '@rafters/shared';
|
|
18
|
+
import { calculatePerceptualWeight, getColorTemperature } from '../analysis.js';
|
|
19
|
+
import { getExpandedMaterialWord, hasExpandedHub } from './hue-hubs.js';
|
|
20
|
+
import { getCBucket, getHBucket, getLBucket } from './quantize.js';
|
|
21
|
+
import { INTENSITY_WORDS, LUMINOSITY_WORDS, MATERIAL_WORDS } from './word-banks.js';
|
|
22
|
+
|
|
23
|
+
// Threshold for considering a color achromatic (no hue word)
|
|
24
|
+
const ACHROMATIC_THRESHOLD = 0.02;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Generate a deterministic color name from OKLCH values
|
|
28
|
+
*
|
|
29
|
+
* The name is composed of up to three hyphenated words:
|
|
30
|
+
* - Luminosity: describes the lightness (shadow, deep, balanced, pale, brilliant, etc.)
|
|
31
|
+
* - Intensity: describes the chroma/saturation (whisper, bold, fierce, etc.)
|
|
32
|
+
* - Material: describes the hue (ember, sapphire, sage, etc.)
|
|
33
|
+
*
|
|
34
|
+
* Achromatic colors (chroma < 0.02) omit the material word.
|
|
35
|
+
*
|
|
36
|
+
* @param oklch - The color in OKLCH format
|
|
37
|
+
* @returns A hyphenated color name like "luminous-clear-sapphire"
|
|
38
|
+
*/
|
|
39
|
+
export function generateColorName(oklch: OKLCH): string {
|
|
40
|
+
// Get computed properties for semantic word selection
|
|
41
|
+
const temperature = getColorTemperature(oklch);
|
|
42
|
+
const { density } = calculatePerceptualWeight(oklch);
|
|
43
|
+
|
|
44
|
+
// Get bucket indices (guaranteed to be within valid ranges by quantize functions)
|
|
45
|
+
const lBucket = getLBucket(oklch.l);
|
|
46
|
+
const cBucket = getCBucket(oklch.c);
|
|
47
|
+
const hBucket = getHBucket(oklch.h);
|
|
48
|
+
|
|
49
|
+
// Select words deterministically
|
|
50
|
+
// Type assertions are safe because bucket functions guarantee valid indices
|
|
51
|
+
const luminosity = LUMINOSITY_WORDS[lBucket] as string;
|
|
52
|
+
const intensityBank = INTENSITY_WORDS[density];
|
|
53
|
+
const intensity = intensityBank[cBucket] as string;
|
|
54
|
+
|
|
55
|
+
// Achromatic colors skip the hue/material word
|
|
56
|
+
if (oklch.c < ACHROMATIC_THRESHOLD) {
|
|
57
|
+
return `${luminosity}-${intensity}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Try expanded hub first (rich semantic word banks with sub-selection)
|
|
61
|
+
// Falls back to original word banks for hues without expanded hubs
|
|
62
|
+
let material: string;
|
|
63
|
+
if (hasExpandedHub(hBucket)) {
|
|
64
|
+
material = getExpandedMaterialWord(
|
|
65
|
+
hBucket,
|
|
66
|
+
lBucket,
|
|
67
|
+
cBucket,
|
|
68
|
+
temperature,
|
|
69
|
+
oklch.l,
|
|
70
|
+
oklch.c,
|
|
71
|
+
) as string;
|
|
72
|
+
} else {
|
|
73
|
+
// Fallback to original word banks
|
|
74
|
+
const materialBank = MATERIAL_WORDS[temperature];
|
|
75
|
+
material = materialBank[hBucket] as string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return `${luminosity}-${intensity}-${material}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Generate a color name with metadata about the generation
|
|
83
|
+
*
|
|
84
|
+
* Useful for debugging and understanding why a particular name was chosen.
|
|
85
|
+
*/
|
|
86
|
+
export function generateColorNameWithMetadata(oklch: OKLCH): {
|
|
87
|
+
name: string;
|
|
88
|
+
components: {
|
|
89
|
+
luminosity: string;
|
|
90
|
+
intensity: string;
|
|
91
|
+
material: string | null;
|
|
92
|
+
};
|
|
93
|
+
buckets: {
|
|
94
|
+
l: number;
|
|
95
|
+
c: number;
|
|
96
|
+
h: number;
|
|
97
|
+
};
|
|
98
|
+
modifiers: {
|
|
99
|
+
temperature: 'warm' | 'cool' | 'neutral';
|
|
100
|
+
density: 'light' | 'medium' | 'heavy';
|
|
101
|
+
isAchromatic: boolean;
|
|
102
|
+
usedExpandedHub: boolean;
|
|
103
|
+
};
|
|
104
|
+
} {
|
|
105
|
+
const temperature = getColorTemperature(oklch);
|
|
106
|
+
const { density } = calculatePerceptualWeight(oklch);
|
|
107
|
+
|
|
108
|
+
const lBucket = getLBucket(oklch.l);
|
|
109
|
+
const cBucket = getCBucket(oklch.c);
|
|
110
|
+
const hBucket = getHBucket(oklch.h);
|
|
111
|
+
|
|
112
|
+
// Type assertions are safe because bucket functions guarantee valid indices
|
|
113
|
+
const luminosity = LUMINOSITY_WORDS[lBucket] as string;
|
|
114
|
+
const intensityBank = INTENSITY_WORDS[density];
|
|
115
|
+
const intensity = intensityBank[cBucket] as string;
|
|
116
|
+
const isAchromatic = oklch.c < ACHROMATIC_THRESHOLD;
|
|
117
|
+
|
|
118
|
+
// Get material word (expanded hub or fallback)
|
|
119
|
+
let material: string | null = null;
|
|
120
|
+
let usedExpandedHub = false;
|
|
121
|
+
if (!isAchromatic) {
|
|
122
|
+
if (hasExpandedHub(hBucket)) {
|
|
123
|
+
material = getExpandedMaterialWord(
|
|
124
|
+
hBucket,
|
|
125
|
+
lBucket,
|
|
126
|
+
cBucket,
|
|
127
|
+
temperature,
|
|
128
|
+
oklch.l,
|
|
129
|
+
oklch.c,
|
|
130
|
+
) as string;
|
|
131
|
+
usedExpandedHub = true;
|
|
132
|
+
} else {
|
|
133
|
+
const materialBank = MATERIAL_WORDS[temperature];
|
|
134
|
+
material = materialBank[hBucket] as string;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const name = isAchromatic
|
|
139
|
+
? `${luminosity}-${intensity}`
|
|
140
|
+
: `${luminosity}-${intensity}-${material}`;
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
name,
|
|
144
|
+
components: {
|
|
145
|
+
luminosity,
|
|
146
|
+
intensity,
|
|
147
|
+
material,
|
|
148
|
+
},
|
|
149
|
+
buckets: {
|
|
150
|
+
l: lBucket,
|
|
151
|
+
c: cBucket,
|
|
152
|
+
h: hBucket,
|
|
153
|
+
},
|
|
154
|
+
modifiers: {
|
|
155
|
+
temperature,
|
|
156
|
+
density,
|
|
157
|
+
isAchromatic,
|
|
158
|
+
usedExpandedHub,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|