Files
buzz-sheet/lib/overview-image.ts
gunshiz 2808a27406
CI / Verify (push) Successful in 1m21s
CI / Build immutable images and deploy (push) Successful in 2m2s
fix : too high fade
2026-09-06 03:06:03 +07:00

223 lines
7.2 KiB
TypeScript

export const overviewRoles = ["Main DPS", "Sub DPS", "Support", "Custom"] as const;
export type OverviewRole = (typeof overviewRoles)[number];
export const overviewBlendModes = [
{ value: "normal", label: "Normal" },
{ value: "multiply", label: "Multiply" },
{ value: "screen", label: "Screen" },
{ value: "overlay", label: "Overlay" },
{ value: "darken", label: "Darken" },
{ value: "lighten", label: "Lighten" },
{ value: "color-dodge", label: "Color Dodge" },
{ value: "color-burn", label: "Color Burn" },
{ value: "hard-light", label: "Hard Light" },
{ value: "soft-light", label: "Soft Light" },
{ value: "difference", label: "Difference" },
{ value: "exclusion", label: "Exclusion" },
{ value: "hue", label: "Hue" },
{ value: "saturation", label: "Saturation" },
{ value: "color", label: "Color" },
{ value: "luminosity", label: "Luminosity" },
{ value: "plus-darker", label: "Plus Darker" },
{ value: "plus-lighter", label: "Plus Lighter" },
] as const;
export type OverviewBlendMode = (typeof overviewBlendModes)[number]["value"];
export const elementOrder = [
"Pyro",
"Hydro",
"Anemo",
"Electro",
"Dendro",
"Cryo",
"Geo",
] as const;
export type OverviewElement = (typeof elementOrder)[number];
const elementGradientPresets: Record<
OverviewElement,
readonly [string, string]
> = {
Pyro: ["#FF1E00", "#FFA200"],
Hydro: ["#147BFF", "#14D0FF"],
Anemo: ["#20BFA4", "#8CFFE0"],
Electro: ["#7B3FC6", "#D29BFF"],
Dendro: ["#5B9E21", "#C2E84A"],
Cryo: ["#5CBBD7", "#C3F7FF"],
Geo: ["#C87913", "#FFE06A"],
};
const elementGlowColors: Record<OverviewElement, string> = {
Pyro: "#FF1414",
Hydro: "#49C5F1",
Anemo: "#63C9AD",
Electro: "#BF86DD",
Dendro: "#9CBD33",
Cryo: "#9ACDE1",
Geo: "#F0AF2D",
};
function isOverviewElement(value: string | null | undefined): value is OverviewElement {
return elementOrder.some((element) => element === value);
}
export function elementGradientColors(
element: string | null | undefined,
): readonly [string, string] {
return isOverviewElement(element)
? elementGradientPresets[element]
: ["#FFFFFF", "#FFFFFF"];
}
export function elementGlowColor(element: string | null | undefined): string {
return isOverviewElement(element) ? elementGlowColors[element] : "#FFFFFF";
}
const weaponIconNames: Record<string, string> = {
WEAPON_SWORD_ONE_HAND: "sword.png",
WEAPON_CLAYMORE: "claymore.png",
WEAPON_POLE: "polearm.png",
WEAPON_BOW: "bow.png",
WEAPON_CATALYST: "catalyst.png",
};
export function normalizeHex(value: string): string | null {
const trimmed = value.trim();
if (!/^#[\da-f]{6}$/iu.test(trimmed)) return null;
return trimmed.toUpperCase();
}
type ThemeColorBin = {
red: number;
green: number;
blue: number;
weight: number;
};
function colorDistance(left: ThemeColorBin, right: ThemeColorBin): number {
return Math.sqrt(
(left.red - right.red) ** 2 +
(left.green - right.green) ** 2 +
(left.blue - right.blue) ** 2,
);
}
function binColor(red: number, green: number, blue: number): string {
return [red, green, blue]
.map((channel) => Math.round(channel / 32) * 32)
.join(",");
}
function regionThemeColors(
pixels: ArrayLike<number>,
width: number,
height: number,
startY: number,
endY: number,
): [string, string] {
const bins = new Map<string, ThemeColorBin>();
const step = Math.max(1, Math.floor(Math.max(width, height) / 48));
for (let y = startY; y < endY; y += step) {
for (let x = 0; x < width; x += step) {
const offset = (y * width + x) * 4;
const alpha = (pixels[offset + 3] ?? 0) / 255;
if (alpha < 0.15) continue;
const red = pixels[offset] ?? 0;
const green = pixels[offset + 1] ?? 0;
const blue = pixels[offset + 2] ?? 0;
const brightness = Math.max(red, green, blue) / 255;
const minimum = Math.min(red, green, blue) / 255;
const saturation = brightness === 0 ? 0 : (brightness - minimum) / brightness;
const weight = alpha * (0.35 + saturation * 0.65) * (0.5 + brightness * 0.5);
const key = binColor(red, green, blue);
const current = bins.get(key) ?? { red: 0, green: 0, blue: 0, weight: 0 };
current.red += red * weight;
current.green += green * weight;
current.blue += blue * weight;
current.weight += weight;
bins.set(key, current);
}
}
const colors = [...bins.values()]
.map((color) => ({
...color,
red: color.red / color.weight,
green: color.green / color.weight,
blue: color.blue / color.weight,
}))
.sort((left, right) => right.weight - left.weight);
const primary = colors[0] ?? { red: 233, green: 165, blue: 26, weight: 0 };
const secondary =
colors.find((color) => colorDistance(primary, color) >= 56) ?? primary;
const toHex = (color: ThemeColorBin) =>
`#${[color.red, color.green, color.blue]
.map((channel) => Math.round(channel).toString(16).padStart(2, "0"))
.join("")}`.toUpperCase();
return [toHex(primary), toHex(secondary)];
}
export function detectThemeColorsFromPixels(
pixels: ArrayLike<number>,
width: number,
height: number,
): { top: [string, string]; bottom: [string, string] } {
const midpoint = Math.max(1, Math.floor(height / 2));
return {
top: regionThemeColors(pixels, width, height, 0, midpoint),
bottom: regionThemeColors(pixels, width, height, midpoint, height),
};
}
function mixHex(hex: string, target: number, amount: number): string {
const normalized = normalizeHex(hex);
if (!normalized) return hex;
const channels = [1, 3, 5].map((offset) =>
Number.parseInt(normalized.slice(offset, offset + 2), 16),
);
return `#${channels
.map((channel) =>
Math.round(channel + (target - channel) * amount)
.toString(16)
.padStart(2, "0"),
)
.join("")}`.toUpperCase();
}
function mixColors(left: string, right: string): string {
const leftHex = normalizeHex(left) ?? "#E9A51A";
const rightHex = normalizeHex(right) ?? leftHex;
const channels = [1, 3, 5].map((offset) =>
Math.round(
(Number.parseInt(leftHex.slice(offset, offset + 2), 16) +
Number.parseInt(rightHex.slice(offset, offset + 2), 16)) /
2,
),
);
return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`.toUpperCase();
}
export function accentGradient(start: string, end = start): string {
const normalizedStart = normalizeHex(start) ?? "#E9A51A";
const normalizedEnd = normalizeHex(end) ?? normalizedStart;
const startEdge = mixHex(normalizedStart, 0, 0.12);
const endEdge = mixHex(normalizedEnd, 0, 0.12);
const highlight = mixHex(mixColors(normalizedStart, normalizedEnd), 255, 0.35);
return `linear-gradient(90deg, ${startEdge} 0%, ${normalizedStart} 25%, ${highlight} 50%, ${normalizedEnd} 75%, ${endEdge} 100%)`;
}
export function weaponTypeIconPath(weaponType: string | null): string | null {
const fileName = weaponType ? weaponIconNames[weaponType] : undefined;
return fileName ? `/icon/weapon-types/${fileName}` : null;
}
export function overviewFileName(characterKey: string, role: string): string {
const safeKey = characterKey.replace(/[^\p{L}\p{N}._-]+/gu, "-");
const safeRole = role.toLowerCase().replaceAll(" ", "-");
return `overview-${safeKey}-${safeRole}.png`;
}