import { interpolate as culoriInterpolate, oklch, wcagContrast } from 'culori' import _ from 'lodash' import { Difficulty, Instrument } from 'scan-chart' import { ChartData } from './interfaces/search.interface' import { ThemeColors } from './interfaces/theme.interface' // WARNING: do not import anything related to Electron; the code will not compile correctly. // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AnyFunction = (...args: any) => any /** Overwrites the type of a nested property in `T` with `U`. */ export type Overwrite = U extends object ? ( T extends object ? { [K in keyof T]: K extends keyof U ? Overwrite : T[K]; } : U ) : U export type RequireMatchingProps = T & { [P in K]-?: NonNullable } /** * @returns `https://drive.google.com/open?id=${fileID}` */ export function driveLink(fileId: string) { return `https://drive.google.com/open?id=${fileId}` } /** * @returns `text` converted to lower case. */ export function lower(text: string) { return text.toLowerCase() } /** * Converts `val` from the range (`fromStart`, `fromEnd`) to the range (`toStart`, `toEnd`). */ export function interpolate(val: number, fromStart: number, fromEnd: number, toStart: number, toEnd: number) { return ((val - fromStart) / (fromEnd - fromStart)) * (toEnd - toStart) + toStart } /** * @returns `objectList` split into multiple groups, where each group contains objects where every one of its values in `keys` matches. */ export function groupBy(objectList: T[], ...keys: (keyof T)[]) { const results: T[][] = [] for (const object of objectList) { const matchingGroup = results.find(result => keys.every(key => result[0][key] === object[key])) if (matchingGroup !== undefined) { matchingGroup.push(object) } else { results.push([object]) } } return results } export const instruments = [ 'guitar', 'guitarcoop', 'rhythm', 'bass', 'drums', 'keys', 'guitarghl', 'guitarcoopghl', 'rhythmghl', 'bassghl', ] as const satisfies Readonly export const difficulties = ['expert', 'hard', 'medium', 'easy'] as const satisfies Readonly export function instrumentDisplay(instrument: Instrument | null) { switch (instrument) { case 'guitar': return 'Lead Guitar' case 'guitarcoop': return 'Co-op Guitar' case 'rhythm': return 'Rhythm Guitar' case 'bass': return 'Bass Guitar' case 'drums': return 'Drums' case 'keys': return 'Keys' case 'guitarghl': return 'GHL (6-fret) Lead Guitar' case 'guitarcoopghl': return 'GHL (6-fret) Co-op Guitar' case 'rhythmghl': return 'GHL (6-fret) Rhythm Guitar' case 'bassghl': return 'GHL (6-fret) Bass Guitar' case null: return 'Any Instrument' } } export function shortInstrumentDisplay(instrument: Instrument | null) { switch (instrument) { case 'guitar': return 'Guitar' case 'guitarcoop': return 'Co-op' case 'rhythm': return 'Rhythm' case 'bass': return 'Bass' case 'drums': return 'Drums' case 'keys': return 'Keys' case 'guitarghl': return 'GHL Guitar' case 'guitarcoopghl': return 'GHL Co-op' case 'rhythmghl': return 'GHL Rhythm' case 'bassghl': return 'GHL Bass' case null: return 'Any Instrument' } } export function difficultyDisplay(difficulty: Difficulty | null) { switch (difficulty) { case 'expert': return 'Expert' case 'hard': return 'Hard' case 'medium': return 'Medium' case 'easy': return 'Easy' case null: return 'Any Difficulty' } } export function instrumentToDiff(instrument: Instrument | 'vocals') { switch (instrument) { case 'guitar': return 'diff_guitar' case 'guitarcoop': return 'diff_guitar_coop' case 'rhythm': return 'diff_rhythm' case 'bass': return 'diff_bass' case 'drums': return 'diff_drums' case 'keys': return 'diff_keys' case 'guitarghl': return 'diff_guitarghl' case 'guitarcoopghl': return 'diff_guitar_coop_ghl' case 'rhythmghl': return 'diff_rhythm_ghl' case 'bassghl': return 'diff_bassghl' case 'vocals': return 'diff_vocals' } } /** * @returns a string representation of `ms` that looks like HH:MM:SS */ export function msToRoughTime(ms: number) { const seconds = _.floor((ms / 1000) % 60) const minutes = _.floor((ms / 1000 / 60) % 60) const hours = _.floor((ms / 1000 / 60 / 60) % 24) return `${hours ? `${hours}:` : ''}${minutes}:${_.padStart(String(seconds), 2, '0')}` } const allowedTags = [ 'align', 'allcaps', 'alpha', 'b', 'br', 'color', 'cspace', 'font', 'font-weight', 'gradient', 'i', 'indent', 'line-height', 'line-indent', 'link', 'lowercase', 'margin', 'mark', 'mspace', 'nobr', 'noparse', 'page', 'pos', 'rotate', 's', 'size', 'smallcaps', 'space', 'sprite', 'strikethrough', 'style', 'sub', 'sup', 'u', 'uppercase', 'voffset', 'width', '#', ] const tagPattern = allowedTags.map(tag => `\\b${tag}\\b`).join('|') /** * @returns `text` with all style tags removed. (e.g. "Aren Eternal & Geo" -> "Aren Eternal & Geo") */ export function removeStyleTags(text: string) { let oldText = text let newText = text do { oldText = newText newText = newText.replace(new RegExp(`<\\s*\\/?\\s*(?:${tagPattern})[^>]*>`, 'gi'), '').trim() } while (newText !== oldText) return newText } export function hasIssues(chart: Pick) { if (chart.metadataIssues.length > 0) { return true } for (const folderIssue of chart.folderIssues) { if (!['albumArtSize', 'invalidIni', 'multipleVideo', 'badIniLine'].includes(folderIssue.folderIssue)) { return true } } for (const chartIssue of chart.notesData?.chartIssues ?? []) { if (chartIssue !== 'isDefaultBPM') { return true } } for (const trackIssue of chart.notesData?.trackIssues ?? []) { for (const ti of trackIssue.trackIssues) { if (ti !== 'noNotesOnNonemptyTrack') { return true } } } for (const noteIssue of chart.notesData?.noteIssues ?? []) { for (const ni of noteIssue.noteIssues) { if (ni.issueType !== 'babySustain') { return true } } } return false } /* eslint-disable @typescript-eslint/naming-convention */ export const colorNames = { "primary": "--p", "primary-content": "--pc", "secondary": "--s", "secondary-content": "--sc", "accent": "--a", "accent-content": "--ac", "neutral": "--n", "neutral-content": "--nc", "base-100": "--b1", "base-200": "--b2", "base-300": "--b3", "base-content": "--bc", "info": "--in", "info-content": "--inc", "success": "--su", "success-content": "--suc", "warning": "--wa", "warning-content": "--wac", "error": "--er", "error-content": "--erc", } as { [colorName: string]: string } const defaultVariables = { "--rounded-box": "1rem", "--rounded-btn": "0.5rem", "--rounded-badge": "1.9rem", "--animation-btn": "0.25s", "--animation-input": ".2s", "--btn-focus-scale": "0.95", "--border-btn": "1px", "--tab-border": "1px", "--tab-radius": "0.5rem", } /* eslint-enable @typescript-eslint/naming-convention */ export function convertColorFormat(input: ThemeColors) { if (typeof input !== "object" || input === null) { return input } const resultObj: { [cssKey: string]: string } = {} for (const [rule, value] of Object.entries(input)) { if (Object.hasOwn(colorNames, rule)) { const colorObj = oklch(value) resultObj[colorNames[rule]] = colorObjToString(colorObj!) } else { resultObj[rule] = value } // auto generate base colors if (!Object.hasOwn(input, "base-100")) { resultObj["--b1"] = "100% 0 0" } if (!Object.hasOwn(input, "base-200")) { resultObj["--b2"] = generateDarkenColorFrom(input["base-100"], 0.07) } if (!Object.hasOwn(input, "base-300")) { if (Object.hasOwn(input, "base-200")) { resultObj["--b3"] = generateDarkenColorFrom(input["base-200"], 0.07) } else { resultObj["--b3"] = generateDarkenColorFrom(input["base-100"], 0.14) } } // auto generate state colors if (!Object.hasOwn(input, "info")) { resultObj["--in"] = "72.06% 0.191 231.6" } if (!Object.hasOwn(input, "success")) { resultObj["--su"] = "64.8% 0.150 160" } if (!Object.hasOwn(input, "warning")) { resultObj["--wa"] = "84.71% 0.199 83.87" } if (!Object.hasOwn(input, "error")) { resultObj["--er"] = "71.76% 0.221 22.18" } // auto generate content colors if (!Object.hasOwn(input, "base-content")) { resultObj["--bc"] = generateForegroundColorFrom(input["base-100"], 0.8) } if (!Object.hasOwn(input, "primary-content")) { resultObj["--pc"] = generateForegroundColorFrom(input.primary, 0.8) } if (!Object.hasOwn(input, "secondary-content")) { resultObj["--sc"] = generateForegroundColorFrom(input.secondary, 0.8) } if (!Object.hasOwn(input, "accent-content")) { resultObj["--ac"] = generateForegroundColorFrom(input.accent, 0.8) } if (!Object.hasOwn(input, "neutral-content")) { resultObj["--nc"] = generateForegroundColorFrom(input.neutral, 0.8) } if (!Object.hasOwn(input, "info-content")) { if (Object.hasOwn(input, "info")) { resultObj["--inc"] = generateForegroundColorFrom(input.info, 0.8) } else { resultObj["--inc"] = "0% 0 0" } } if (!Object.hasOwn(input, "success-content")) { if (Object.hasOwn(input, "success")) { resultObj["--suc"] = generateForegroundColorFrom(input.success, 0.8) } else { resultObj["--suc"] = "0% 0 0" } } if (!Object.hasOwn(input, "warning-content")) { if (Object.hasOwn(input, "warning")) { resultObj["--wac"] = generateForegroundColorFrom(input.warning, 0.8) } else { resultObj["--wac"] = "0% 0 0" } } if (!Object.hasOwn(input, "error-content")) { if (Object.hasOwn(input, "error")) { resultObj["--erc"] = generateForegroundColorFrom(input.error, 0.8) } else { resultObj["--erc"] = "0% 0 0" } } // add css variables if not exist for (const item of Object.entries(defaultVariables)) { const [variable, value] = item if (!Object.hasOwn(input, variable)) { resultObj[variable] = value } } // add other custom styles if (!Object.hasOwn(colorNames, rule)) { resultObj[rule] = value } } return resultObj } function generateForegroundColorFrom(input: string, percentage = 0.8) { const result = culoriInterpolate([input, isDark(input) ? "white" : "black"], "oklch")(percentage) return colorObjToString(result) } function generateDarkenColorFrom(input: string, percentage = 0.07) { const result = culoriInterpolate([input, "black"], "oklch")(percentage) return colorObjToString(result) } function colorObjToString(input: { l: number; c: number; h?: number }) { const { l, c, h } = input return `${Number.parseFloat((cutNumber(l) * 100).toFixed(6))}% ${cutNumber(c)} ${cutNumber(h ?? 0)}` } function cutNumber(number: number) { if (number) { return +number.toFixed(6) } return 0 } function isDark(color: string) { return wcagContrast(color, "black") < wcagContrast(color, "white") }