Files
community-rule/lib/create/previewSurfaceColor.ts

190 lines
5.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const QUANTIZE_SHIFT = 4;
/** Ignore only fully clear pixels so a translucent logo fill still counts. */
const MIN_ALPHA = 16;
/** Black card type needs a light fill. Dark logo colors are lifted to this. */
const CARD_FILL_LIGHTNESS = 0.82;
const CARD_FILL_MAX_SATURATION = 0.48;
function rgbToHsl(
r: number,
g: number,
b: number,
): { h: number; s: number; l: number } {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
if (max === min) return { h: 0, s: 0, l };
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h = 0;
if (max === rn) h = (gn - bn) / d + (gn < bn ? 6 : 0);
else if (max === gn) h = (bn - rn) / d + 2;
else h = (rn - gn) / d + 4;
return { h: h / 6, s, l };
}
function hueChannel(p: number, q: number, t: number): number {
let channel = t;
if (channel < 0) channel += 1;
if (channel > 1) channel -= 1;
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
if (channel < 1 / 2) return q;
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
return p;
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
if (s === 0) {
const value = Math.round(l * 255);
return [value, value, value];
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return [
Math.round(hueChannel(p, q, h + 1 / 3) * 255),
Math.round(hueChannel(p, q, h) * 255),
Math.round(hueChannel(p, q, h - 1 / 3) * 255),
];
}
/**
* Keep an already-light logo color. Lift a dark one to a pale tint of the
* same hue so the card’s black type stays readable.
*/
export function readableCardFill(r: number, g: number, b: number): string {
const { h, s, l } = rgbToHsl(r, g, b);
if (l >= 0.7) return `rgb(${r}, ${g}, ${b})`;
const [rr, gg, bb] = hslToRgb(
h,
Math.min(s, CARD_FILL_MAX_SATURATION),
CARD_FILL_LIGHTNESS,
);
return `rgb(${rr}, ${gg}, ${bb})`;
}
/**
* Most common opaque color in `data` (RGBA). Nearby shades share a bucket
* so a logo’s main fill wins over anti-aliased edges. Returns `null` when
* every pixel is transparent.
*/
export function mostCommonOpaqueColor(data: Uint8ClampedArray): string | null {
const buckets = new Map<
number,
{ count: number; r: number; g: number; b: number }
>();
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3] ?? 0;
if (alpha < MIN_ALPHA) continue;
const r = data[i] ?? 0;
const g = data[i + 1] ?? 0;
const b = data[i + 2] ?? 0;
const key =
((r >> QUANTIZE_SHIFT) << 8) |
((g >> QUANTIZE_SHIFT) << 4) |
(b >> QUANTIZE_SHIFT);
const bucket = buckets.get(key);
if (bucket) {
bucket.count += 1;
bucket.r += r;
bucket.g += g;
bucket.b += b;
} else {
buckets.set(key, { count: 1, r, g, b });
}
}
let best: { count: number; r: number; g: number; b: number } | null = null;
for (const bucket of buckets.values()) {
if (!best || bucket.count > best.count) best = bucket;
}
if (!best || best.count === 0) return null;
const r = Math.round(best.r / best.count);
const g = Math.round(best.g / best.count);
const b = Math.round(best.b / best.count);
return `rgb(${r}, ${g}, ${b})`;
}
/**
* Light random fill. Hue is free; lightness stays high so the card’s black
* type remains readable.
*/
export function randomPreviewSurfaceColor(
random: () => number = Math.random,
): string {
const hue = Math.floor(random() * 360);
const saturation = 50 + Math.floor(random() * 35);
const lightness = 76 + Math.floor(random() * 12);
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}
const STORED_PREVIEW_SURFACE_COLOR =
/^hsl\(\d{1,3}, \d{1,3}%, \d{1,3}%\)$/;
/** True when `value` is a draft-stored review-card fallback fill. */
export function isPreviewSurfaceColor(value: string): boolean {
return STORED_PREVIEW_SURFACE_COLOR.test(value);
}
function imageNeedsCors(url: string): boolean {
if (
url.startsWith("/") ||
url.startsWith("blob:") ||
url.startsWith("data:")
) {
return false;
}
try {
return new URL(url, window.location.href).origin !== window.location.origin;
} catch {
return false;
}
}
function loadImage(url: string): Promise<HTMLImageElement | null> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
if (imageNeedsCors(url)) img.crossOrigin = "anonymous";
img.src = url;
});
}
/** Downscale `url` and return its most common opaque color, or `null`. */
export async function dominantColorFromImageUrl(
url: string,
): Promise<string | null> {
if (typeof window === "undefined") return null;
const img = await loadImage(url);
if (!img) return null;
const size = 32;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return null;
context.drawImage(img, 0, 0, size, size);
try {
const raw = mostCommonOpaqueColor(
context.getImageData(0, 0, size, size).data,
);
if (!raw) return null;
const match = raw.match(/^rgb\((\d+), (\d+), (\d+)\)$/);
if (!match) return raw;
return readableCardFill(
Number(match[1]),
Number(match[2]),
Number(match[3]),
);
} catch {
return null;
}
}