Files
HMDesign/packages/org-chart/src/core/contrast.ts
T

94 lines
5.5 KiB
TypeScript

/** Internal contrast arithmetic. Results model opaque legacy sRGB colors;
* they are not pixel measurements or a claim about every rendered instance. */
export type Rgb = readonly [number, number, number];
export interface VerticalGradient { stops: readonly { color: Rgb; offset: number }[] }
export interface ContrastModel {
status: 'MODEL' | 'NOT VERIFIED';
reason?: string;
stopMinimum?: number;
fullGradientMinimum?: number;
textRegionMinimum?: number;
}
export function parseOpaqueColor(value: string): Rgb | null {
const text = value.trim();
if (/^#[\da-f]{6}$/i.test(text)) return [1, 3, 5].map(index => parseInt(text.slice(index, index + 2), 16)) as unknown as Rgb;
if (/^#[\da-f]{3}$/i.test(text)) return [...text.slice(1)].map(part => parseInt(part + part, 16)) as unknown as Rgb;
const match = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+))?\s*\)$/i.exec(text);
if (!match || (match[4] !== undefined && Number(match[4]) !== 1)) return null;
const channels = match.slice(1, 4).map(Number);
return channels.every(channel => Number.isFinite(channel) && channel >= 0 && channel <= 255) ? channels as unknown as Rgb : null;
}
export function relativeLuminance(color: Rgb): number {
const channels = color.map(channel => {
const encoded = channel / 255;
return encoded <= 0.04045 ? encoded / 12.92 : ((encoded + 0.055) / 1.055) ** 2.4;
});
return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722;
}
export function contrastRatio(foreground: Rgb, background: Rgb): number {
const first = relativeLuminance(foreground), second = relativeLuminance(background);
return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
}
/** Restricted intentionally: no alpha, images, multiple layers, omitted stops,
* other angles, color spaces, relative units, or repeating gradients.
* Computed CSS may omit the default 180deg angle; that is equivalent. */
export function parseVerticalGradient(value: string): VerticalGradient | null {
const outer = /^linear-gradient\(\s*(?:180deg\s*,\s*)?(.*)\)$/i.exec(value.trim());
if (!outer) return null;
const parts = outer[1]!.split(/,(?![^()]*\))/);
const stops: { color: Rgb; offset: number }[] = [];
for (const part of parts) {
const match = /^\s*(#[\da-f]+|rgba?\([^()]*\))\s+([\d.]+)%\s*$/i.exec(part);
const color = match && parseOpaqueColor(match[1]!);
const offset = match ? Number(match[2]) / 100 : NaN;
if (!color || !Number.isFinite(offset) || offset < 0 || offset > 1 || (stops.length > 0 && offset <= stops.at(-1)!.offset)) return null;
stops.push({ color, offset });
}
return stops.length >= 2 && stops[0]!.offset === 0 && stops.at(-1)!.offset === 1 ? { stops } : null;
}
function gradientColor(gradient: VerticalGradient, offset: number): Rgb {
let index = 1;
while (index < gradient.stops.length - 1 && offset > gradient.stops[index]!.offset) index++;
const before = gradient.stops[index - 1]!, after = gradient.stops[index]!;
const amount = (offset - before.offset) / (after.offset - before.offset);
return before.color.map((channel, channelIndex) => channel + (after.color[channelIndex]! - channel) * amount) as unknown as Rgb;
}
export function textContrastThreshold(fontSizePx: number, fontWeight: number): 3 | 4.5 {
return fontSizePx >= 24 || (fontWeight >= 700 && fontSizePx >= 14 * 96 / 72) ? 3 : 4.5;
}
/** Ranges are actual text line rectangles in CSS px relative to the gradient
* painting box, not the padded heading rectangle. The whole-gradient sample
* and stop sample are reported separately; neither substitutes for text area. */
export function evaluateGradientContrast(input: {
foreground: string;
backgroundImage: string;
ranges: readonly { top: number; bottom: number }[];
backgroundHeight: number;
sampleStep?: number;
}): ContrastModel {
const foreground = parseOpaqueColor(input.foreground), gradient = parseVerticalGradient(input.backgroundImage);
if (!foreground || !gradient) return { status: 'NOT VERIFIED', reason: '지원하지 않는 색상/gradient: 불투명 sRGB 180deg 및 명시적 % stop만 지원' };
const height = input.backgroundHeight, step = input.sampleStep ?? 1;
if (!Number.isFinite(height) || height <= 0 || !Number.isFinite(step) || step <= 0 || height / step > 10000 || input.ranges.length === 0 || input.ranges.some(range => !Number.isFinite(range.top) || !Number.isFinite(range.bottom) || range.top < 0 || range.bottom > height || range.bottom <= range.top)) {
return { status: 'NOT VERIFIED', reason: '텍스트 범위/배경 높이/샘플 간격 확인 불가' };
}
const ratioAt = (offset: number) => contrastRatio(foreground, gradientColor(gradient, offset));
const stopMinimum = Math.min(...gradient.stops.map(stop => contrastRatio(foreground, stop.color)));
let fullGradientMinimum = stopMinimum;
for (let index = 0; index <= 256; index++) fullGradientMinimum = Math.min(fullGradientMinimum, ratioAt(index / 256));
let textRegionMinimum = Infinity;
for (const range of input.ranges) {
textRegionMinimum = Math.min(textRegionMinimum, ratioAt(range.top / height), ratioAt(range.bottom / height));
for (let y = range.top + step; y < range.bottom; y += step) textRegionMinimum = Math.min(textRegionMinimum, ratioAt(y / height));
for (const stop of gradient.stops) if (stop.offset * height >= range.top && stop.offset * height <= range.bottom) textRegionMinimum = Math.min(textRegionMinimum, ratioAt(stop.offset));
}
return { status: 'MODEL', stopMinimum, fullGradientMinimum, textRegionMinimum };
}