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 = { 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 = { 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, width: number, height: number, startY: number, endY: number, ): [string, string] { const bins = new Map(); 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, 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`; }