diff --git a/apps/frontend/src/app/Components/DropdownMenu/DropdownButton.tsx b/apps/frontend/src/app/Components/DropdownMenu/DropdownButton.tsx index 4253676525..083936f8a3 100644 --- a/apps/frontend/src/app/Components/DropdownMenu/DropdownButton.tsx +++ b/apps/frontend/src/app/Components/DropdownMenu/DropdownButton.tsx @@ -16,6 +16,12 @@ export type DropdownButtonProps = Omit & { id?: string children: React.ReactNode } + +/** + * @deprecated use @go/ui-common + * @param param0 + * @returns + */ export default function DropdownButton({ title, children, diff --git a/libs/consts/src/artifact.ts b/libs/consts/src/artifact.ts index 3f8db6d697..d4bc10de24 100644 --- a/libs/consts/src/artifact.ts +++ b/libs/consts/src/artifact.ts @@ -114,6 +114,10 @@ export const artifactCircletStatKeys = [ 'heal_', ] as const export type ArtifactCircletStatKey = (typeof artifactCircletStatKeys)[number] + +/** + * @deprecated use artSlotMainKeys + */ export const artSlotsData = { flower: { name: 'Flower of Life', stats: ['hp'] as readonly MainStatKey[] }, plume: { name: 'Plume of Death', stats: ['atk'] as readonly MainStatKey[] }, @@ -131,6 +135,14 @@ export const artSlotsData = { }, } as const +export const artSlotMainKeys = { + flower: ['hp'] as readonly MainStatKey[], + plume: ['atk'] as readonly MainStatKey[], + sands: artifactSandsStatKeys as readonly MainStatKey[], + goblet: artifactGobletStatKeys as readonly MainStatKey[], + circlet: artifactCircletStatKeys as readonly MainStatKey[], +} as const + export const allMainStatKeys = [ 'hp', 'hp_', diff --git a/libs/gi-art-scanner/.babelrc b/libs/gi-art-scanner/.babelrc new file mode 100644 index 0000000000..ca85798cd5 --- /dev/null +++ b/libs/gi-art-scanner/.babelrc @@ -0,0 +1,13 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage", + "importSource": "@emotion/react" + } + ] + ], + "plugins": ["@emotion/babel-plugin"] +} diff --git a/libs/gi-art-scanner/.eslintrc.json b/libs/gi-art-scanner/.eslintrc.json new file mode 100644 index 0000000000..a39ac5d057 --- /dev/null +++ b/libs/gi-art-scanner/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/gi-art-scanner/README.md b/libs/gi-art-scanner/README.md new file mode 100644 index 0000000000..975fe68c60 --- /dev/null +++ b/libs/gi-art-scanner/README.md @@ -0,0 +1,7 @@ +# gi-art-scanner + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test gi-art-scanner` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/gi-art-scanner/project.json b/libs/gi-art-scanner/project.json new file mode 100644 index 0000000000..498524a436 --- /dev/null +++ b/libs/gi-art-scanner/project.json @@ -0,0 +1,16 @@ +{ + "name": "gi-art-scanner", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/gi-art-scanner/src", + "projectType": "library", + "tags": [], + "targets": { + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/gi-art-scanner/**/*.{ts,tsx,js,jsx}"] + } + } + } +} diff --git a/libs/gi-art-scanner/src/index.ts b/libs/gi-art-scanner/src/index.ts new file mode 100644 index 0000000000..77a7d326f0 --- /dev/null +++ b/libs/gi-art-scanner/src/index.ts @@ -0,0 +1 @@ +export * from './lib/ScanningQueue' diff --git a/libs/gi-art-scanner/src/lib/ScanningQueue.tsx b/libs/gi-art-scanner/src/lib/ScanningQueue.tsx new file mode 100644 index 0000000000..aed1d6a7c7 --- /dev/null +++ b/libs/gi-art-scanner/src/lib/ScanningQueue.tsx @@ -0,0 +1,73 @@ +import type { Outstanding, Processed } from './processImg' +import { processEntry } from './processImg' + +export type ScanningData = { + processedNum: number + outstandingNum: number + scanningNum: number +} +export type { Processed } + +const maxProcessingCount = 3, + maxProcessedCount = 16 + +type textsFromImageFunc = ( + imageData: ImageData, + options?: object | undefined +) => Promise + +export class ScanningQueue { + private debug: boolean + private textsFromImage: textsFromImageFunc + constructor(textsFromImage: textsFromImageFunc, debug = false) { + this.textsFromImage = textsFromImage + this.debug = debug + } + private processed = [] as Processed[] + private outstanding = [] as Outstanding[] + private scanning = [] as Promise[] + callback = (() => {}) as (data: ScanningData) => void + + addFiles(files: Outstanding[]) { + this.outstanding.push(...files) + this.processQueue() + } + processQueue() { + const numProcessing = Math.min( + maxProcessedCount - this.processed.length - this.scanning.length, + maxProcessingCount - this.scanning.length, + this.outstanding.length + ) + numProcessing && + this.outstanding.splice(0, numProcessing).map((p) => { + const prom = processEntry(p, this.textsFromImage, this.debug) + this.scanning.push(prom) + prom.then((procesResult) => { + const index = this.scanning.indexOf(prom) + if (index === -1) return // probably because the queue has been cleared. + this.scanning.splice(index, 1) + this.processed.push(procesResult) + this.processQueue() + }) + }) + this.callCB() + } + private callCB() { + this.callback({ + processedNum: this.processed.length, + outstandingNum: this.outstanding.length, + scanningNum: this.scanning.length, + }) + } + shiftProcessed(): Processed | undefined { + const procesesd = this.processed.shift() + this.processQueue() + return procesesd + } + clearQueue() { + this.processed = [] + this.outstanding = [] + this.scanning = [] + this.callCB() + } +} diff --git a/libs/gi-art-scanner/src/lib/consts.ts b/libs/gi-art-scanner/src/lib/consts.ts new file mode 100644 index 0000000000..50d2422f94 --- /dev/null +++ b/libs/gi-art-scanner/src/lib/consts.ts @@ -0,0 +1,45 @@ +import type { Color } from '@genshin-optimizer/img-util' + +/** + * "golden" title + * light: bc6832 rgb(188, 104, 50) + * dark: 915128 rgb(145, 81, 40) + */ +export const goldenTitleDarkerColor: Color = { + r: 145, + g: 81, + b: 40, +} +export const goldenTitleLighterColor: Color = { + r: 188, + g: 104, + b: 50, +} + +// The "baige" background of artifact card, ebe4d8 rgb(235, 228, 216) +export const cardWhite: Color = { + r: 235, + g: 228, + b: 216, +} + +// the yellow bottom for equipment color rgb(255, 231, 186) +export const equipColor: Color = { + r: 255, + g: 231, + b: 186, +} + +// Greentext color +// greenest rgb(93, 178, 87) +// lightgreen rgb(185, 210, 170) +export const greenTextColor: Color = { + r: 93, + g: 178, + b: 87, +} + +export const starColor = { r: 255, g: 204, b: 50 } // #FFCC32 + +export const textColorLight = { r: 131, g: 131, b: 130 } // rgb(131, 131, 130) +export const textColorDark = { r: 74, g: 81, b: 102 } // rgb(74, 81, 102) diff --git a/libs/gi-art-scanner/src/lib/enStringMap.ts b/libs/gi-art-scanner/src/lib/enStringMap.ts new file mode 100644 index 0000000000..9713ed881f --- /dev/null +++ b/libs/gi-art-scanner/src/lib/enStringMap.ts @@ -0,0 +1,40 @@ +import type { ElementWithPhyKey } from '@genshin-optimizer/consts' + +// Store the english translations to use with artifact scanner + +const elementalData: Record = { + physical: 'Physical', + anemo: 'Anemo', + geo: 'Geo', + electro: 'Electro', + hydro: 'Hydro', + pyro: 'Pyro', + cryo: 'Cryo', + dendro: 'Dendro', +} as const + +export const statMap = { + hp: 'HP', + hp_: 'HP', + atk: 'ATK', + atk_: 'ATK', + def: 'DEF', + def_: 'DEF', + eleMas: 'Elemental Mastery', + enerRech_: 'Energy Recharge', + critRate_: 'Crit Rate', + critDMG_: 'Crit DMG', + heal_: 'Healing Bonus', +} as Record + +Object.entries(elementalData).forEach(([e, name]) => { + statMap[`${e}_dmg_`] = `${name} DMG Bonus` +}) + +export const artSlotNames = { + flower: 'Flower of Life', + plume: 'Plume of Death', + sands: 'Sands of Eon', + goblet: 'Goblet of Eonothem', + circlet: 'Circlet of Logos', +} diff --git a/libs/gi-art-scanner/src/lib/findBestArtifact.tsx b/libs/gi-art-scanner/src/lib/findBestArtifact.tsx new file mode 100644 index 0000000000..c0b86891e8 --- /dev/null +++ b/libs/gi-art-scanner/src/lib/findBestArtifact.tsx @@ -0,0 +1,334 @@ +import type { + ArtifactRarity, + ArtifactSetKey, + ArtifactSlotKey, + LocationCharacterKey, + MainStatKey, + SubstatKey, +} from '@genshin-optimizer/consts' +import { + allArtifactRarityKeys, + allArtifactSlotKeys, + artSlotsData, +} from '@genshin-optimizer/consts' +import type { IArtifact, ISubstat } from '@genshin-optimizer/gi-good' +import { allStats } from '@genshin-optimizer/gi-stats' +import { ArtifactSetName, ArtifactSlotName } from '@genshin-optimizer/gi-ui' +import { + artDisplayValue, + getMainStatDisplayValue, + getMainStatDisplayValues, + getSubstatRolls, +} from '@genshin-optimizer/gi-util' +import { ColorText } from '@genshin-optimizer/ui-common' +import { objKeyMap, unit } from '@genshin-optimizer/util' +import type { ReactNode } from 'react' +import { statMap } from './enStringMap' + +export type TextKey = + | 'slotKey' + | 'mainStatKey' + | 'mainStatVal' + | 'rarity' + | 'level' + | 'substats' + | 'setKey' + +export function findBestArtifact( + rarities: Set, + textSetKeys: Set, + slotKeys: Set, + substats: ISubstat[], + mainStatKeys: Set, + mainStatValues: { mainStatValue: number; unit?: string }[], + location: LocationCharacterKey | null +): [IArtifact, Partial>] { + const relevantSetKey = [ + ...new Set([...textSetKeys, 'Adventurer']), + ] + + let bestScore = -1, + bestArtifacts: IArtifact[] = [ + { + setKey: 'Adventurer', + rarity: 3, + level: 0, + slotKey: 'flower', + mainStatKey: 'hp', + substats: [], + location: location ?? '', + lock: false, + }, + ] + + // Rate each rarity + const rarityRates = objKeyMap(allArtifactRarityKeys, (rarity) => { + let score = 0 + if (textSetKeys.size) { + const count = [...textSetKeys].reduce( + (count, set) => + count + (allStats.art.data[set].rarities.includes(rarity) ? 1 : 0), + 0 + ) + score += count / textSetKeys.size + } + if (substats.length) { + const count = substats.reduce( + (count, substat) => + count + + (getSubstatRolls(substat.key as SubstatKey, substat.value, rarity) + .length + ? 1 + : 0), + 0 + ) + score += (count / substats.length) * 2 + } + return score + }) + + // Test all *probable* combinations + for (const slotKey of allArtifactSlotKeys) { + for (const mainStatKey of artSlotsData[slotKey].stats) { + const mainStatScore = + (slotKeys.has(slotKey) ? 1 : 0) + + (mainStatKeys.has(mainStatKey) ? 1 : 0) + const relevantMainStatValues = mainStatValues + .filter((value) => value.unit !== '%' || unit(mainStatKey) === '%') // Ignore "%" text if key isn't "%" + .map((value) => value.mainStatValue) + + for (const [rarityString, rarityIndividualScore] of Object.entries( + rarityRates + )) { + const rarity = parseInt(rarityString) as ArtifactRarity + const setKeys = relevantSetKey.filter((setKey) => + allStats.art.data[setKey].rarities.includes(rarity) + ) + const rarityScore = mainStatScore + rarityIndividualScore + + if (rarityScore + 2 < bestScore) continue // Early bail out + + for (const minimumMainStatValue of relevantMainStatValues) { + const values = getMainStatDisplayValues(rarity, mainStatKey) + const level = Math.max( + 0, + values.findIndex((level) => level >= minimumMainStatValue) + ) + const mainStatVal = values[level] + const mainStatValScore = + rarityScore + (mainStatVal === minimumMainStatValue ? 1 : 0) + + for (const setKey of setKeys) { + const score = mainStatValScore + (textSetKeys.has(setKey) ? 1 : 0) + if (score >= bestScore) { + if (score > bestScore) bestArtifacts = [] + bestScore = score + bestArtifacts.push({ + setKey, + rarity, + level, + slotKey, + mainStatKey, + substats: [], + location: location ?? '', + lock: false, + }) + } + } + } + if (rarityScore >= bestScore) { + const level = 0 + for (const setKey of setKeys) { + const score = rarityScore + (textSetKeys.has(setKey) ? 1 : 0) + + if (score > bestScore) bestArtifacts = [] + bestScore = score + bestArtifacts.push({ + setKey, + rarity, + level, + slotKey, + mainStatKey, + substats: [], + location: location ?? '', + lock: false, + }) + } + } + } + } + } + + const texts = {} as Partial> + const chosen = { + setKey: new Set(), + rarity: new Set(), + level: new Set(), + slotKey: new Set(), + mainStatKey: new Set(), + mainStatVal: new Set(), + } as Partial>> + + const result = bestArtifacts[0], + resultMainStatVal = getMainStatDisplayValue( + result.mainStatKey, + result.rarity, + result.level + )! + result.substats = substats.filter( + (substat, i) => + substat.key !== result.mainStatKey && + substats.slice(0, i).every((other) => other.key !== substat.key) + ) + for (let i = result.substats.length; i < 4; i++) + result.substats.push({ key: '', value: 0 }) + + for (const other of bestArtifacts) { + chosen.setKey!.add(other.setKey) + chosen.rarity!.add(other.rarity as any) + chosen.level!.add(other.level as any) + chosen.slotKey!.add(other.slotKey) + chosen.mainStatKey!.add(other.mainStatKey) + } + + function unknownText( + value: T, + name: ReactNode, + text: (arg: T) => ReactNode + ) { + return ( + <> + Unknown {name} : Set to{' '} + {text(value)} + + ) + } + function ambiguousText( + value: T, + available: T[], + name: ReactNode, + text: (arg: T) => ReactNode + ) { + return ( + <> + Ambiguous {name} {text(value)} : + May also be{' '} + {available + .filter((v) => v !== value) + .map((value, index) => ( + <> + {index > 0 ? '/' : ''} + {text(value)} + + ))} + + ) + } + function detectedText( + value: T, + name: ReactNode, + text: (arg: T) => ReactNode + ) { + return ( + <> + Detected {name} {text(value)} + + ) + } + function inferredText( + value: T, + name: ReactNode, + text: (arg: T) => ReactNode + ) { + return ( + <> + Inferred {name} {text(value)} + + ) + } + + function addText( + key: TextKey, + available: Set, + name: ReactNode, + text: (value: unknown) => ReactNode + ) { + const recommended = new Set( + [...chosen[key]!].filter((value) => available.has(value)) + ) + const resKey = result[key as keyof IArtifact] + if (recommended.size > 1) + texts[key] = ambiguousText(resKey, [...available], name, text) + else if (recommended.size === 1) + texts[key] = detectedText(resKey, name, text) + else if (chosen[key]!.size > 1) texts[key] = unknownText(resKey, name, text) + else texts[key] = inferredText(resKey, name, text) + } + + addText('setKey', textSetKeys, 'Set', (value) => ( + + )) + addText('rarity', rarities, 'Rarity', (value) => ( + <> + {value} {value !== 1 ? 'Stars' : 'Star'} + + )) + addText('slotKey', slotKeys, 'Slot', (value) => ( + + )) + addText('mainStatKey', mainStatKeys, 'Main Stat', (value) => ( + {statMap[value as string]} + )) + texts.substats = ( + <> + {result.substats + .filter((substat) => substat.key !== '') + .map((substat, i) => ( +
+ {detectedText(substat, 'Sub Stat', (value) => ( + <> + {statMap[value.key]}+ + {artDisplayValue(value.value, unit(value.key))} + {unit(value.key)} + + ))} +
+ ))} + + ) + + const valueStrFunc = (value: number) => ( + <> + {artDisplayValue(value, unit(result.mainStatKey))} + {unit(result.mainStatKey)} + + ) + if ( + mainStatValues.find((value) => value.mainStatValue === resultMainStatVal) + ) { + if (mainStatKeys.has(result.mainStatKey)) { + texts.level = detectedText(result.level, 'Level', (value) => '+' + value) + texts.mainStatVal = detectedText( + resultMainStatVal, + 'Main Stat value', + valueStrFunc + ) + } else { + texts.level = inferredText(result.level, 'Level', (value) => '+' + value) + texts.mainStatVal = inferredText( + resultMainStatVal, + 'Main Stat value', + valueStrFunc + ) + } + } else { + texts.level = unknownText(result.level, 'Level', (value) => '+' + value) + texts.mainStatVal = unknownText( + resultMainStatVal, + 'Main Stat value', + valueStrFunc + ) + } + + return [result, texts] +} diff --git a/libs/gi-art-scanner/src/lib/parse.ts b/libs/gi-art-scanner/src/lib/parse.ts new file mode 100644 index 0000000000..c11f01f4a6 --- /dev/null +++ b/libs/gi-art-scanner/src/lib/parse.ts @@ -0,0 +1,130 @@ +import type { + ArtifactSetKey, + ArtifactSlotKey, + LocationCharacterKey, + MainStatKey, +} from '@genshin-optimizer/consts' +import { + allArtifactSetKeys, + allArtifactSlotKeys, + allLocationCharacterKeys, + allMainStatKeys, + allSubstatKeys, +} from '@genshin-optimizer/consts' +import type { ISubstat } from '@genshin-optimizer/gi-good' +import { hammingDistance, unit } from '@genshin-optimizer/util' +import { artSlotNames, statMap } from './enStringMap' + +export function parseSetKeys(texts: string[]): Set { + const results = new Set([]) + for (const text of texts) + for (const key of allArtifactSetKeys) + if ( + hammingDistance( + text.replace(/\W/g, ''), + key //TODO: use the translated set name? + ) <= 2 + ) + results.add(key) + return results +} + +export function parseSlotKeys(texts: string[]): Set { + const results = new Set() + for (const text of texts) + for (const key of allArtifactSlotKeys) + if ( + hammingDistance( + text.replace(/\W/g, ''), + artSlotNames[key].replace(/\W/g, '') + ) <= 2 + ) + results.add(key) + return results +} +export function parseMainStatKeys(texts: string[]): Set { + const results = new Set([]) + for (const text of texts) + for (const key of allMainStatKeys) { + if (text.toLowerCase().includes(statMap[key]?.toLowerCase() ?? '')) + results.add(key) + //use fuzzy compare on the ... Bonus texts. heal_ is included. + if ( + key.includes('_bonu') && + hammingDistance( + text.replace(/\W/g, ''), + (statMap[key] ?? '').replace(/\W/g, '') + ) <= 1 + ) + results.add(key) + } + return results +} +export function parseMainStatValues( + texts: string[] +): { mainStatValue: number; unit?: string }[] { + const results: { mainStatValue: number; unit?: string }[] = [] + for (const text of texts) { + let regex = /(\d+[,|\\.]+\d)%/ + let match = regex.exec(text) + if (match) + results.push({ + mainStatValue: parseFloat( + match[1].replace(/,/g, '.').replace(/\.{2,}/g, '.') + ), + unit: '%', + }) + regex = /(\d+[,|\\.]\d{3}|\d{2,3})/ + match = regex.exec(text) + if (match) + results.push({ + mainStatValue: parseInt(match[1].replace(/[,|\\.]+/g, '')), + }) + } + return results +} + +export function parseSubstats(texts: string[]): ISubstat[] { + const matches: ISubstat[] = [] + for (let text of texts) { + text = text.replace(/^[\W]+/, '').replace(/\n/, '') + //parse substats + allSubstatKeys.forEach((key) => { + const name = statMap[key] + const regex = + unit(key) === '%' + ? new RegExp(name + '\\s*\\+\\s*(\\d+[\\.|,]+\\d)%', 'im') + : new RegExp(name + '\\s*\\+\\s*(\\d+,\\d+|\\d+)($|\\s)', 'im') + const match = regex.exec(text) + if (match) + matches.push({ + key, + value: parseFloat( + match[1].replace(/,/g, '.').replace(/\.{2,}/g, '.') + ), + }) + }) + } + return matches.slice(0, 4) +} + +export function parseLocation(texts: string[]): LocationCharacterKey | null { + for (let text of texts) { + if (!text) continue + const colonInd = text.indexOf(':') + if (colonInd !== -1) text = text.slice(colonInd + 1) + if (!text) continue + + for (const key of allLocationCharacterKeys) { + if ( + hammingDistance( + text.replace(/\W/g, ''), + key //TODO: use the translated character name? + ) <= 2 + ) + return key + } + return 'Traveler' // just assume its traveler when we don't recognize the name + } + return null +} diff --git a/libs/gi-art-scanner/src/lib/processImg.ts b/libs/gi-art-scanner/src/lib/processImg.ts new file mode 100644 index 0000000000..9687e01c95 --- /dev/null +++ b/libs/gi-art-scanner/src/lib/processImg.ts @@ -0,0 +1,352 @@ +import type { IArtifact } from '@genshin-optimizer/gi-good' +import { clamp } from '@genshin-optimizer/util' +import type { ReactNode } from 'react' + +import { + bandPass, + cropHorizontal, + cropImageData, + darkerColor, + drawHistogram, + drawline, + fileToURL, + findHistogramRange, + histogramAnalysis, + histogramContAnalysis, + imageDataToCanvas, + lighterColor, + urlToImageData, +} from '@genshin-optimizer/img-util' +import { + cardWhite, + equipColor, + goldenTitleDarkerColor, + goldenTitleLighterColor, + greenTextColor, + starColor, + textColorDark, + textColorLight, +} from './consts' +import type { TextKey } from './findBestArtifact' +import { findBestArtifact } from './findBestArtifact' +import { + parseLocation, + parseMainStatKeys, + parseMainStatValues, + parseSetKeys, + parseSlotKeys, + parseSubstats, +} from './parse' + +export type Processed = { + fileName: string + imageURL: string + artifact: IArtifact + texts: Partial> + debugImgs?: Record | undefined +} +export type Outstanding = { + f: File + fName: string +} + +export async function processEntry( + entry: Outstanding, + textsFromImage: ( + imageData: ImageData, + options?: object | undefined + ) => Promise, + debug = false +): Promise { + const { f, fName } = entry + const imageURL = await fileToURL(f) + const imageData = await urlToImageData(imageURL) + + const debugImgs = debug ? ({} as Record) : undefined + const artifactCardImageData = horizontallyCropArtifactCard( + imageData, + debugImgs + ) + const artifactCardCanvas = imageDataToCanvas(artifactCardImageData) + + const goldTitleHistogram = histogramContAnalysis( + artifactCardImageData, + darkerColor(goldenTitleDarkerColor), + lighterColor(goldenTitleLighterColor), + false + ) + const [goldTitleTop, goldTitleBot] = findHistogramRange(goldTitleHistogram) + + const whiteCardHistogram = histogramContAnalysis( + imageData, + darkerColor(cardWhite), + lighterColor(cardWhite), + false + ) + const [whiteCardTop, whiteCardBot] = findHistogramRange(whiteCardHistogram) + + const equipHistogram = histogramContAnalysis( + imageData, + darkerColor(equipColor), + lighterColor(equipColor), + false + ) + + const hasEquip = equipHistogram.some( + (i) => i > artifactCardImageData.width * 0.5 + ) + const [equipTop, equipBot] = findHistogramRange(equipHistogram) + if (debugImgs) { + const canvas = imageDataToCanvas(artifactCardImageData) + drawHistogram( + canvas, + goldTitleHistogram, + { + r: 0, + g: 150, + b: 150, + a: 100, + }, + false + ) + drawHistogram( + canvas, + whiteCardHistogram, + { r: 150, g: 0, b: 0, a: 100 }, + false + ) + drawHistogram( + canvas, + equipHistogram, + { r: 0, g: 100, b: 100, a: 100 }, + false + ) + debugImgs['artifactCardAnalysis'] = canvas.toDataURL() + } + const artifactCardCropped = cropHorizontal( + artifactCardCanvas, + goldTitleTop, + hasEquip ? equipBot : whiteCardBot + ) + + const equippedCropped = hasEquip + ? cropHorizontal(artifactCardCanvas, equipTop, equipBot) + : undefined + /** + * Technically this is a way to get both the set+slot + */ + // const goldenTitleCropped = cropHorizontal( + // artifactCardCanvas, + // goldTitleTop, + // goldTitleBot + // ) + + // if (debug) + // debugImgs['goldenTitlecropped'] = + // imageDataToCanvas(goldenTitleCropped).toDataURL() + + const headerCropped = cropHorizontal( + artifactCardCanvas, + goldTitleBot, + whiteCardTop + ) + + if (debugImgs) + debugImgs['headerCropped'] = imageDataToCanvas(headerCropped).toDataURL() + + const whiteCardCropped = cropHorizontal( + artifactCardCanvas, + whiteCardTop, + whiteCardBot + ) + + const greentextHisto = histogramAnalysis( + whiteCardCropped, + darkerColor(greenTextColor), + lighterColor(greenTextColor), + false + ) + + const [greenTextTop, greenTextBot] = findHistogramRange(greentextHisto, 0.2) + + if (debugImgs) { + const canvas = imageDataToCanvas(whiteCardCropped) + drawHistogram(canvas, greentextHisto, { r: 100, g: 0, b: 0, a: 100 }, false) + drawline(canvas, greenTextTop, { r: 0, g: 255, b: 0, a: 200 }, false) + drawline(canvas, greenTextBot, { r: 0, g: 0, b: 255, a: 200 }, false) + debugImgs['whiteCardAnalysis'] = canvas.toDataURL() + } + + const greenTextBuffer = greenTextBot - greenTextTop + + const greenTextCropped = cropHorizontal( + imageDataToCanvas(whiteCardCropped), + greenTextTop - greenTextBuffer, + greenTextBot + greenTextBuffer + ) + + const substatsCardCropped = cropHorizontal( + imageDataToCanvas(whiteCardCropped), + 0, + greenTextTop + ) + + if (debugImgs) + debugImgs['substatsCardCropped'] = + imageDataToCanvas(substatsCardCropped).toDataURL() + + const bwHeader = bandPass( + headerCropped, + { r: 140, g: 140, b: 140 }, + { r: 255, g: 255, b: 255 }, + 'bw' + ) + const bwGreenText = bandPass( + greenTextCropped, + { r: 30, g: 160, b: 30 }, + { r: 200, g: 255, b: 200 }, + 'bw' + ) + const bwEquipped = + equippedCropped && + bandPass( + equippedCropped, + darkerColor(textColorDark), + lighterColor(textColorLight), + 'bw' + ) + if (debugImgs) { + debugImgs['bwHeader'] = imageDataToCanvas(bwHeader).toDataURL() + debugImgs['bwGreenText'] = imageDataToCanvas(bwGreenText).toDataURL() + if (bwEquipped) + debugImgs['bwEquipped'] = imageDataToCanvas(bwEquipped).toDataURL() + } + + const [whiteTexts, substatTexts, artifactSetTexts, equippedTexts] = + await Promise.all([ + // slotkey, mainStatValue, level + textsFromImage(bwHeader, { + // only the left half is worth scanning + rectangle: { + top: 0, + left: 0, + width: Math.floor(bwHeader.width * 0.7), + height: bwHeader.height, + }, + }), + // substats + textsFromImage(substatsCardCropped), + // artifact set, look for greenish texts + textsFromImage(bwGreenText), + // equipment + bwEquipped ? textsFromImage(bwEquipped) : [''], + ]) + + const rarity = parseRarity(headerCropped, debugImgs) + + console.log({ whiteTexts, substatTexts, artifactSetTexts }) + const [artifact, texts] = findBestArtifact( + new Set([rarity]), + parseSetKeys(artifactSetTexts), + parseSlotKeys(whiteTexts), + parseSubstats(substatTexts), + parseMainStatKeys(whiteTexts), + parseMainStatValues(whiteTexts), + parseLocation(equippedTexts) + ) + + return { + fileName: fName, + imageURL: imageDataToCanvas(artifactCardCropped).toDataURL(), + artifact, + texts, + debugImgs, + } +} +function horizontallyCropArtifactCard( + imageData: ImageData, + debugImgs?: Record +) { + const histogram = histogramContAnalysis( + imageData, + darkerColor(cardWhite), + lighterColor(cardWhite) + ) + + const [a, b] = findHistogramRange(histogram) + + const cropped = cropImageData( + imageDataToCanvas(imageData), + a, + 0, + b - a, + imageData.height + ) + + if (debugImgs) { + const canvas = imageDataToCanvas(imageData) + + drawHistogram(canvas, histogram, { + r: 255, + g: 0, + b: 0, + a: 100, + }) + drawline(canvas, a, { r: 0, g: 255, b: 0, a: 150 }) + drawline(canvas, b, { r: 0, g: 0, b: 255, a: 150 }) + + debugImgs['fullAnalysis'] = canvas.toDataURL() + + // debugImgs['horicropped'] = imageDataToCanvas(cropped).toDataURL() + } + + return cropped +} + +function parseRarity( + headerData: ImageData, + debugImgs?: Record +) { + const hist = histogramContAnalysis( + headerData, + darkerColor(starColor), + lighterColor(starColor), + false + ) + const [starTop, starBot] = findHistogramRange(hist, 0.3) + + const stars = cropImageData( + imageDataToCanvas(headerData), + 0, + starTop, + headerData.width, + starBot - starTop + ) + + const starsHistogram = histogramContAnalysis( + stars, + darkerColor(starColor), + lighterColor(starColor) + ) + if (debugImgs) { + const canvas = imageDataToCanvas(stars) + drawHistogram(canvas, starsHistogram, { r: 100, g: 0, b: 0, a: 100 }) + debugImgs['rarity'] = canvas.toDataURL() + } + const maxThresh = Math.max(...starsHistogram) * 0.5 + let count = 0 + let onStar = false + for (let i = 0; i < starsHistogram.length; i++) { + if (starsHistogram[i] > maxThresh) { + if (!onStar) { + count++ + onStar = true + } + } else { + if (onStar) { + onStar = false + } + } + } + return clamp(count, 1, 5) +} diff --git a/libs/gi-art-scanner/tsconfig.json b/libs/gi-art-scanner/tsconfig.json new file mode 100644 index 0000000000..af77ba7980 --- /dev/null +++ b/libs/gi-art-scanner/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "jsxImportSource": "@emotion/react" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "extends": "../../tsconfig.base.json" +} diff --git a/libs/gi-art-scanner/tsconfig.lib.json b/libs/gi-art-scanner/tsconfig.lib.json new file mode 100644 index 0000000000..cfc4843293 --- /dev/null +++ b/libs/gi-art-scanner/tsconfig.lib.json @@ -0,0 +1,24 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [ + "node", + + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts" + ] + }, + "exclude": [ + "jest.config.ts", + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.spec.tsx", + "src/**/*.test.tsx", + "src/**/*.spec.js", + "src/**/*.test.js", + "src/**/*.spec.jsx", + "src/**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/libs/gi-formula-ui/.babelrc b/libs/gi-formula-ui/.babelrc new file mode 100644 index 0000000000..ca85798cd5 --- /dev/null +++ b/libs/gi-formula-ui/.babelrc @@ -0,0 +1,13 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage", + "importSource": "@emotion/react" + } + ] + ], + "plugins": ["@emotion/babel-plugin"] +} diff --git a/libs/gi-formula-ui/.eslintrc.json b/libs/gi-formula-ui/.eslintrc.json new file mode 100644 index 0000000000..a39ac5d057 --- /dev/null +++ b/libs/gi-formula-ui/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/gi-formula-ui/README.md b/libs/gi-formula-ui/README.md new file mode 100644 index 0000000000..737dd3e19d --- /dev/null +++ b/libs/gi-formula-ui/README.md @@ -0,0 +1,7 @@ +# gi-formula-ui + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test gi-formula-ui` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/gi-formula-ui/project.json b/libs/gi-formula-ui/project.json new file mode 100644 index 0000000000..a71fed71cf --- /dev/null +++ b/libs/gi-formula-ui/project.json @@ -0,0 +1,16 @@ +{ + "name": "gi-formula-ui", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/gi-formula-ui/src", + "projectType": "library", + "tags": [], + "targets": { + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/gi-formula-ui/**/*.{ts,tsx,js,jsx}"] + } + } + } +} diff --git a/libs/gi-formula-ui/src/components/FieldDisplay.tsx b/libs/gi-formula-ui/src/components/FieldDisplay.tsx new file mode 100644 index 0000000000..4b18be5fac --- /dev/null +++ b/libs/gi-formula-ui/src/components/FieldDisplay.tsx @@ -0,0 +1,93 @@ +import { Box, Typography, Skeleton } from '@mui/material' +import { Suspense } from 'react' +import { Help } from '@mui/icons-material' +import type { CalcResult } from '@genshin-optimizer/pando' +import { translate, type CalcMeta } from '@genshin-optimizer/gi-formula' +import { BootstrapTooltip } from '@genshin-optimizer/ui-common' + +export function NodeFieldDisplay({ + calcResult, + emphasize, + component = 'div', +}: { + calcResult: CalcResult + component?: React.ElementType + emphasize?: boolean +}) { + const formula = translate(calcResult).formula + return ( + + + + + {/* TODO: Add back */} + {/* {multiDisplay} */} + {!!formula && ( + + + } + > + {/* TODO: Add back */} + {/* {allAmpReactionKeys.includes(node.info.variant as any) && ( + + + + + + + )} */} + {formula} + + + } + > + {/* TODO: Add back */} + + + )} + + + ) +} + +function NodeFieldDisplayText({ + calcResult, +}: { + calcResult: CalcResult +}) { + return ( + + {translate(calcResult).name} + + ) +} diff --git a/libs/gi-formula-ui/src/components/index.ts b/libs/gi-formula-ui/src/components/index.ts new file mode 100644 index 0000000000..90e09cb7d9 --- /dev/null +++ b/libs/gi-formula-ui/src/components/index.ts @@ -0,0 +1 @@ +export * from './FieldDisplay' diff --git a/libs/gi-formula-ui/src/context/CalcContext.tsx b/libs/gi-formula-ui/src/context/CalcContext.tsx new file mode 100644 index 0000000000..fdf4378b77 --- /dev/null +++ b/libs/gi-formula-ui/src/context/CalcContext.tsx @@ -0,0 +1,12 @@ +import type { Calculator } from '@genshin-optimizer/gi-formula' +import { createContext } from 'react' + +export type CalcContextObj = { + calc: Calculator | undefined + setCalc: (calc: Calculator | undefined) => void +} + +export const CalcContext = createContext({ + calc: undefined, + setCalc: () => {}, +} as CalcContextObj) diff --git a/libs/gi-formula-ui/src/context/index.ts b/libs/gi-formula-ui/src/context/index.ts new file mode 100644 index 0000000000..fcbf0a1487 --- /dev/null +++ b/libs/gi-formula-ui/src/context/index.ts @@ -0,0 +1 @@ +export * from './CalcContext' diff --git a/libs/gi-formula-ui/src/index.ts b/libs/gi-formula-ui/src/index.ts new file mode 100644 index 0000000000..71e89d851b --- /dev/null +++ b/libs/gi-formula-ui/src/index.ts @@ -0,0 +1,2 @@ +export * from './context' +export * from './components' diff --git a/libs/gi-formula-ui/tsconfig.json b/libs/gi-formula-ui/tsconfig.json new file mode 100644 index 0000000000..af77ba7980 --- /dev/null +++ b/libs/gi-formula-ui/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "jsxImportSource": "@emotion/react" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "extends": "../../tsconfig.base.json" +} diff --git a/libs/gi-formula-ui/tsconfig.lib.json b/libs/gi-formula-ui/tsconfig.lib.json new file mode 100644 index 0000000000..cfc4843293 --- /dev/null +++ b/libs/gi-formula-ui/tsconfig.lib.json @@ -0,0 +1,24 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [ + "node", + + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts" + ] + }, + "exclude": [ + "jest.config.ts", + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.spec.tsx", + "src/**/*.test.tsx", + "src/**/*.spec.js", + "src/**/*.test.js", + "src/**/*.spec.jsx", + "src/**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/libs/gi-localization/assets/locales/en/page_weapon.json b/libs/gi-localization/assets/locales/en/page_weapon.json index 5fc93996c8..1c50d0615d 100644 --- a/libs/gi-localization/assets/locales/en/page_weapon.json +++ b/libs/gi-localization/assets/locales/en/page_weapon.json @@ -4,5 +4,6 @@ "edit": "Edit Weapon", "weaponName": "Weapon Name", "removeWeapon": "Are you sure you want to remove {{value}}?", - "addWeapon": "Add New Weapon" + "addWeapon": "Add New Weapon", + "cantDeleteLock": "Cannot delete weapon because it is locked" } diff --git a/libs/gi-stats/src/index.ts b/libs/gi-stats/src/index.ts index a2132e801c..7f7cf915ef 100644 --- a/libs/gi-stats/src/index.ts +++ b/libs/gi-stats/src/index.ts @@ -1,5 +1,7 @@ import type { CharacterKey } from '@genshin-optimizer/consts' +import { charKeyToLocCharKey } from '@genshin-optimizer/consts' import * as allStat_gen from './allStat_gen.json' +import type { AllStats } from './executors/gen-stats/executor' // Make sure these are type-only imports/exports. // Importing the executor is quite costly. @@ -8,7 +10,6 @@ export type { WeaponData, WeaponDataGen, } from './executors/gen-stats/executor' -import type { AllStats } from './executors/gen-stats/executor' const allStats = allStat_gen as AllStats @@ -30,3 +31,8 @@ export function getCharEle(ck: CharacterKey) { return allStats.char.data[ck].ele } } + +export function getCharData(ck: CharacterKey) { + const locCharKey = charKeyToLocCharKey(ck) + return allStats.char.data[locCharKey] +} diff --git a/libs/gi-ui/src/Artifact/PercentBadge.tsx b/libs/gi-ui/src/Artifact/PercentBadge.tsx index 90461c884b..05c2d9e8f0 100644 --- a/libs/gi-ui/src/Artifact/PercentBadge.tsx +++ b/libs/gi-ui/src/Artifact/PercentBadge.tsx @@ -3,7 +3,7 @@ import { clamp } from '@genshin-optimizer/util' import type { ButtonProps } from '@mui/material' import type { RollColorKey } from './util' -export default function PercentBadge({ +export function PercentBadge({ value, max = 1, valid, diff --git a/libs/gi-ui/src/Artifact/Trans.tsx b/libs/gi-ui/src/Artifact/Trans.tsx new file mode 100644 index 0000000000..0b83bd1fd2 --- /dev/null +++ b/libs/gi-ui/src/Artifact/Trans.tsx @@ -0,0 +1,34 @@ +import type { ArtifactSetKey, ArtifactSlotKey } from '@genshin-optimizer/consts' +import { Translate } from '../Translate' + +export function ArtifactSetSlotName({ + setKey, + slotKey, +}: { + setKey: ArtifactSetKey + slotKey: ArtifactSlotKey +}) { + return ( + + ) +} + +export function ArtifactSetSlotDesc({ + setKey, + slotKey, +}: { + setKey: ArtifactSetKey + slotKey: ArtifactSlotKey +}) { + return ( + + ) +} + +export function ArtifactSetName({ setKey }: { setKey: ArtifactSetKey }) { + return +} + +export function ArtifactSlotName({ slotKey }: { slotKey: ArtifactSlotKey }) { + return +} diff --git a/libs/gi-ui/src/Artifact/index.ts b/libs/gi-ui/src/Artifact/index.ts index 03d061d43a..da7eeec587 100644 --- a/libs/gi-ui/src/Artifact/index.ts +++ b/libs/gi-ui/src/Artifact/index.ts @@ -1,2 +1,3 @@ export * from './PercentBadge' export * from './util' +export * from './Trans' diff --git a/libs/gi-ui/src/Character/Trans.tsx b/libs/gi-ui/src/Character/Trans.tsx new file mode 100644 index 0000000000..1b08709a41 --- /dev/null +++ b/libs/gi-ui/src/Character/Trans.tsx @@ -0,0 +1,18 @@ +import type { + CharacterKey, + GenderKey, + LocationGenderedCharacterKey, +} from '@genshin-optimizer/consts' +import { Translate } from '../Translate' + +export function CharacterName({ + characterKey, + gender = 'F', +}: { + characterKey: CharacterKey + gender: GenderKey +}) { + let cKey = characterKey as LocationGenderedCharacterKey + if (characterKey.startsWith('Traveler')) cKey = `Traveler${gender}` + return +} diff --git a/libs/gi-ui/src/Character/index.ts b/libs/gi-ui/src/Character/index.ts new file mode 100644 index 0000000000..2ddd72de49 --- /dev/null +++ b/libs/gi-ui/src/Character/index.ts @@ -0,0 +1 @@ +export * from './Trans' diff --git a/libs/gi-ui/src/Translate.tsx b/libs/gi-ui/src/Translate.tsx new file mode 100644 index 0000000000..a8aff64ecb --- /dev/null +++ b/libs/gi-ui/src/Translate.tsx @@ -0,0 +1,188 @@ +import { ColorText, SqBadge } from '@genshin-optimizer/ui-common' +import { Skeleton, Typography } from '@mui/material' +import type { TFunction } from 'i18next' +import type { ReactNode } from 'react' +import { Suspense } from 'react' +import { Trans, useTranslation } from 'react-i18next' +const textComponents = { + anemo: , + geo: , + cryo: , + hydro: , + pyro: , + electro: , + dendro: , + heal: , + vaporize: , + spread: , + aggravate: , + overloaded: , + superconduct: , + electrocharged: , + shattered: , + bloom: , + burgeon: , + hyperbloom: , +} +const badgeComponents = { + anemo: , + geo: , + cryo: , + hydro: , + pyro: , + electro: , + dendro: , + heal: , + vaporize: , + spread: , + aggravate: , + overloaded: , + superconduct: , + electrocharged: , + shattered: , + bloom: , + burgeon: , + hyperbloom: , +} + +/** + * Note: Trans.values & Trans.components wont work together... + */ +export function Translate({ + ns, + key18, + values, + children, + useBadge, +}: { + ns: string + key18: string + values?: Record + children?: ReactNode + useBadge?: boolean +}) { + const { t } = useTranslation(ns) + const textKey = `${ns}:${key18}` + const textObj = values + ? t(textKey, { returnObjects: true, ...values }) + : t(textKey, { returnObjects: true }) + if (typeof textObj === 'string') + return ( + + {children} + + ) + + return ( + {children}}> + + + ) +} +/**this is used cause the `components` prop mess with tag interpolation. */ +export function TransWrapper({ + ns, + key18, + values, + children, +}: { + ns: string + key18: string + values?: any + children?: any +}) { + const { t } = useTranslation(ns) + const textKey = `${ns}:${key18}` + return ( + {children}}> + + {children} + + + ) +} +function Para({ children }: { children?: JSX.Element }) { + return {children} +} + +function T({ + key18, + obj, + li, + t, + values, + useBadge, +}: { + key18: string + obj: any + li?: boolean + t: TFunction + values?: any + useBadge?: boolean +}) { + if (typeof obj === 'string') + return ( + + ) + if (Array.isArray(obj)) + return ( + +
    + +
+
+ ) + return Object.entries(obj).map(([key, val]) => { + if (val === '
') return null + + if (typeof val === 'object') + return ( + + ) + if (typeof val === 'string') { + const trans = ( + + ) + return li ?
  • {trans}
  • : trans + } + return null + }) as any +} diff --git a/libs/gi-ui/src/Weapon/Trans.tsx b/libs/gi-ui/src/Weapon/Trans.tsx new file mode 100644 index 0000000000..5e50d6e416 --- /dev/null +++ b/libs/gi-ui/src/Weapon/Trans.tsx @@ -0,0 +1,6 @@ +import type { WeaponKey } from '@genshin-optimizer/consts' +import { Translate } from '../Translate' + +export function WeaponName({ weaponKey }: { weaponKey: WeaponKey }) { + return +} diff --git a/libs/gi-ui/src/Weapon/index.ts b/libs/gi-ui/src/Weapon/index.ts new file mode 100644 index 0000000000..2ddd72de49 --- /dev/null +++ b/libs/gi-ui/src/Weapon/index.ts @@ -0,0 +1 @@ +export * from './Trans' diff --git a/libs/gi-ui/src/getVariant.ts b/libs/gi-ui/src/getVariant.ts new file mode 100644 index 0000000000..b337e10a63 --- /dev/null +++ b/libs/gi-ui/src/getVariant.ts @@ -0,0 +1,18 @@ +import { allElementWithPhyKeys } from '@genshin-optimizer/consts' +import type { Palette } from '@mui/material' + +export function getVariant(key = ''): keyof Palette | undefined { + //TODO: variants for other strings? + // const trans = Object.keys(transformativeReactions).find((e) => + // key.startsWith(e) + // ) + // if (trans) return trans + // const amp = Object.keys(amplifyingReactions).find((e) => key.startsWith(e)) + // if (amp) return amp + // const add = Object.keys(additiveReactions).find((e) => key.startsWith(e)) + // if (add) return add + if (key.includes('heal')) return 'heal' + const ele = allElementWithPhyKeys.find((e) => key.startsWith(e)) + if (ele) return ele + return undefined +} diff --git a/libs/gi-ui/src/index.ts b/libs/gi-ui/src/index.ts index ba047dac70..1fbc77c228 100644 --- a/libs/gi-ui/src/index.ts +++ b/libs/gi-ui/src/index.ts @@ -1,3 +1,6 @@ import './App.scss' export * from './Artifact' export * from './Theme' +export * from './Weapon' +export * from './Character' +export * from './getVariant' diff --git a/libs/gi-util/src/artifact.test.ts b/libs/gi-util/src/artifact/artifact.test.ts similarity index 100% rename from libs/gi-util/src/artifact.test.ts rename to libs/gi-util/src/artifact/artifact.test.ts diff --git a/libs/gi-util/src/artifact.ts b/libs/gi-util/src/artifact/artifact.ts similarity index 57% rename from libs/gi-util/src/artifact.ts rename to libs/gi-util/src/artifact/artifact.ts index 5328454c91..437b11f293 100644 --- a/libs/gi-util/src/artifact.ts +++ b/libs/gi-util/src/artifact/artifact.ts @@ -1,28 +1,31 @@ import type { - ArtifactRarity, - ArtifactSlotKey, MainStatKey, RarityKey, SubstatKey, } from '@genshin-optimizer/consts' import { - allArtifactRarityKeys, - allArtifactSetKeys, + allRarityKeys, allSubstatKeys, artMaxLevel, - artSlotsData, artSubstatRollData, } from '@genshin-optimizer/consts' -import type { IArtifact, ISubstat } from '@genshin-optimizer/gi-good' +import type { IArtifact } from '@genshin-optimizer/gi-good' import { allStats } from '@genshin-optimizer/gi-stats' import type { Unit } from '@genshin-optimizer/util' import { clampPercent, - getRandomElementFromArray, - getRandomIntInclusive, + objKeyMap, toPercent, unit, } from '@genshin-optimizer/util' +import type { ArtifactMeta } from './artifactMeta' + +const showPercentKeys = ['hp_', 'def_', 'atk_'] as const +export function artStatPercent(statkey: MainStatKey | SubstatKey) { + return showPercentKeys.includes(statkey as (typeof showPercentKeys)[number]) + ? '%' + : '' +} export function artDisplayValue(value: number, unit: Unit): string { switch (unit) { @@ -141,62 +144,45 @@ export function getTotalPossibleRolls(rarity: RarityKey) { artSubstatRollData[rarity].high + artSubstatRollData[rarity].numUpgrades ) } - -// do not randomize Prayers since they don't have all slots -const artSets = allArtifactSetKeys.filter((k) => !k.startsWith('Prayers')) -export function randomizeArtifact(base: Partial = {}): IArtifact { - const setKey = base.setKey ?? getRandomElementFromArray(artSets) - const data = allStats.art.data[setKey] - - const rarity = (base.rarity ?? - getRandomElementFromArray( - data.rarities.filter((r: number) => - // GO only supports artifacts from 3 to 5 stars - allArtifactRarityKeys.includes(r as ArtifactRarity) - ) - )) as ArtifactRarity - const slot: ArtifactSlotKey = - base.slotKey ?? getRandomElementFromArray(data.slots) - const mainStatKey: MainStatKey = - base.mainStatKey ?? getRandomElementFromArray(artSlotsData[slot].stats) - const level = - base.level ?? getRandomIntInclusive(0, artMaxLevel[rarity as RarityKey]) - const substats: ISubstat[] = [0, 1, 2, 3].map(() => ({ key: '', value: 0 })) - - const { low, high } = artSubstatRollData[rarity] - const totRolls = Math.floor(level / 4) + getRandomIntInclusive(low, high) - const numOfInitialSubstats = Math.min(totRolls, 4) - const numUpgradesOrUnlocks = totRolls - numOfInitialSubstats - - const RollStat = (substat: SubstatKey): number => - getRandomElementFromArray(getSubstatValuesPercent(substat, rarity)) - - let remainingSubstats = allSubstatKeys.filter((key) => mainStatKey !== key) - for (const substat of substats.slice(0, numOfInitialSubstats)) { - substat.key = getRandomElementFromArray(remainingSubstats) - substat.value = RollStat(substat.key as SubstatKey) - remainingSubstats = remainingSubstats.filter((key) => key !== substat.key) - } - for (let i = 0; i < numUpgradesOrUnlocks; i++) { - const substat = getRandomElementFromArray(substats) - substat.value += RollStat(substat.key as any) - } - for (const substat of substats) - if (substat.key) { - const value = artDisplayValue(substat.value, unit(substat.key)) - substat.value = parseFloat( - allStats.art.subRollCorrection[rarity]?.[substat.key]?.[value] ?? value - ) - } - - return { - setKey, - rarity, - slotKey: slot, - mainStatKey, - level, - substats, - location: base.location ?? '', - lock: false, - } +const maxSubstatRollEfficiency = objKeyMap(allRarityKeys, (rarity) => + Math.max( + ...allSubstatKeys.map( + (substat) => getSubstatValue(substat, rarity) / getSubstatValue(substat) + ) + ) +) + +export function getArtifactEfficiency( + artifact: IArtifact, + artifactMeta: ArtifactMeta, + filter: Set = new Set(allSubstatKeys) +): { currentEfficiency: number; maxEfficiency: number } { + const { substats, rarity, level } = artifact + // Relative to max star, so comparison between different * makes sense. + const currentEfficiency = artifact.substats + .filter(({ key }) => key && filter.has(key)) + .reduce((sum, _, i) => sum + (artifactMeta.substats[i]?.efficiency ?? 0), 0) + + const rollsRemaining = getRollsRemaining(level, rarity) + const emptySlotCount = substats.filter((s) => !s.key).length + const matchedSlotCount = substats.filter( + (s) => s.key && filter.has(s.key) + ).length + const unusedFilterCount = + filter.size - + matchedSlotCount - + (filter.has(artifact.mainStatKey as any) ? 1 : 0) + let maxEfficiency + if (emptySlotCount && unusedFilterCount) + maxEfficiency = + currentEfficiency + maxSubstatRollEfficiency[rarity] * rollsRemaining + // Rolls into good empty slot + else if (matchedSlotCount) + maxEfficiency = + currentEfficiency + + maxSubstatRollEfficiency[rarity] * (rollsRemaining - emptySlotCount) + // Rolls into existing matched slot + else maxEfficiency = currentEfficiency // No possible roll + + return { currentEfficiency, maxEfficiency } } diff --git a/libs/gi-util/src/artifact/artifactMeta.ts b/libs/gi-util/src/artifact/artifactMeta.ts new file mode 100644 index 0000000000..72b0fba234 --- /dev/null +++ b/libs/gi-util/src/artifact/artifactMeta.ts @@ -0,0 +1,155 @@ +import type { SubstatKey } from '@genshin-optimizer/consts' +import { artSubstatRollData } from '@genshin-optimizer/consts' +import type { IArtifact } from '@genshin-optimizer/gi-good' +import { + getMainStatDisplayValue, + getSubstatRolls, + getSubstatValue, +} from './artifact' + +export interface ArtifactMeta { + mainStatVal: number + substats: SubstatMeta[] +} + +export interface SubstatMeta { + rolls: number[] + efficiency: number + accurateValue: number +} + +const defSubMeta = () => ({ + rolls: [], + efficiency: 0, + accurateValue: 0, +}) +/** + * Generate meta data for artifacts, and also output some errors during meta generation + * @param flex + * @param id + * @returns + */ +export function getArtifactMeta(flex: IArtifact): { + artifactMeta: ArtifactMeta + errors: string[] +} { + const { rarity, mainStatKey, level } = flex + const mainStatVal = getMainStatDisplayValue(mainStatKey, rarity, level) + + const errors: string[] = [] + + const allPossibleRolls: { index: number; substatRolls: number[][] }[] = [] + let totalUnambiguousRolls = 0 + + const substats = flex.substats.map((substat, index): SubstatMeta => { + const { key, value } = substat + if (!key || !value) return defSubMeta() + const max5Value = getSubstatValue(key) + let efficiency = value / max5Value + + const possibleRolls = getSubstatRolls(key, value, rarity) + + if (possibleRolls.length) { + // Valid Substat + const possibleLengths = new Set(possibleRolls.map((roll) => roll.length)) + + if (possibleLengths.size !== 1) { + // Ambiguous Rolls + allPossibleRolls.push({ index, substatRolls: possibleRolls }) + } else { + // Unambiguous Rolls + totalUnambiguousRolls += possibleRolls[0].length + } + + const rolls = possibleRolls.reduce((best, current) => + best.length < current.length ? best : current + ) + const accurateValue = rolls.reduce((a, b) => a + b, 0) + efficiency = accurateValue / max5Value + return { + rolls, + efficiency, + accurateValue, + } + } else { + // Invalid Substat + errors.push(`Invalid substat ${substat.key}`) + return defSubMeta() + } + }) + + const validated = { + mainStatVal, + substats, + } + + if (errors.length) return { artifactMeta: validated, errors } + + const { low, high } = artSubstatRollData[rarity], + lowerBound = low + Math.floor(level / 4), + upperBound = high + Math.floor(level / 4) + + let highestScore = -Infinity // -Max(substats.rolls[i].length) over ambiguous rolls + const tryAllSubstats = ( + rolls: { index: number; roll: number[] }[], + currentScore: number, + total: number + ) => { + if (rolls.length === allPossibleRolls.length) { + if ( + total <= upperBound && + total >= lowerBound && + highestScore < currentScore + ) { + highestScore = currentScore + for (const { index, roll } of rolls) { + const key = flex.substats[index].key as SubstatKey + const accurateValue = roll.reduce((a, b) => a + b, 0) + substats[index].rolls = roll + substats[index].accurateValue = accurateValue + substats[index].efficiency = accurateValue / getSubstatValue(key) + } + } + + return + } + + const { index, substatRolls } = allPossibleRolls[rolls.length] + for (const roll of substatRolls) { + rolls.push({ index, roll }) + const newScore = Math.min(currentScore, -roll.length) + if (newScore >= highestScore) + // Scores won't get better, so we can skip. + tryAllSubstats(rolls, newScore, total + roll.length) + rolls.pop() + } + } + + tryAllSubstats([], Infinity, totalUnambiguousRolls) + + const totalRolls = substats.reduce( + (accu, { rolls }) => accu + rolls.length, + 0 + ) + + if (totalRolls > upperBound) + errors.push( + `${rarity}-star artifact (level ${level}) should have no more than ${upperBound} rolls. It currently has ${totalRolls} rolls.` + ) + else if (totalRolls < lowerBound) + errors.push( + `${rarity}-star artifact (level ${level}) should have at least ${lowerBound} rolls. It currently has ${totalRolls} rolls.` + ) + + if (substats.length < 4 || flex.substats.some(({ key }) => !key)) { + const index = substats.findIndex( + (substat) => (substat.rolls?.length ?? 0) > 1 + ) + if (index !== -1) + errors.push( + `Substat ${flex.substats[index].key} has > 1 roll, but not all substats are unlocked.` + ) + } + + return { artifactMeta: validated, errors } +} diff --git a/libs/gi-util/src/artifact/index.ts b/libs/gi-util/src/artifact/index.ts new file mode 100644 index 0000000000..7e762a34b0 --- /dev/null +++ b/libs/gi-util/src/artifact/index.ts @@ -0,0 +1,3 @@ +export * from './artifact' +export * from './randomizeArtifact' +export * from './artifactMeta' diff --git a/libs/gi-util/src/artifact/randomizeArtifact.ts b/libs/gi-util/src/artifact/randomizeArtifact.ts new file mode 100644 index 0000000000..64875ebf6d --- /dev/null +++ b/libs/gi-util/src/artifact/randomizeArtifact.ts @@ -0,0 +1,82 @@ +import type { + ArtifactRarity, + ArtifactSlotKey, + MainStatKey, + RarityKey, + SubstatKey, +} from '@genshin-optimizer/consts' +import { + allArtifactRarityKeys, + allArtifactSetKeys, + allSubstatKeys, + artMaxLevel, + artSlotsData, + artSubstatRollData, +} from '@genshin-optimizer/consts' +import type { IArtifact, ISubstat } from '@genshin-optimizer/gi-good' +import { allStats } from '@genshin-optimizer/gi-stats' +import { + getRandomElementFromArray, + getRandomIntInclusive, + unit, +} from '@genshin-optimizer/util' +import { artDisplayValue, getSubstatValuesPercent } from './artifact' + +// do not randomize Prayers since they don't have all slots +const artSets = allArtifactSetKeys.filter((k) => !k.startsWith('Prayers')) +export function randomizeArtifact(base: Partial = {}): IArtifact { + const setKey = base.setKey ?? getRandomElementFromArray(artSets) + const data = allStats.art.data[setKey] + + const rarity = (base.rarity ?? + getRandomElementFromArray( + data.rarities.filter((r: number) => + // GO only supports artifacts from 3 to 5 stars + allArtifactRarityKeys.includes(r as ArtifactRarity) + ) + )) as ArtifactRarity + const slot: ArtifactSlotKey = + base.slotKey ?? getRandomElementFromArray(data.slots) + const mainStatKey: MainStatKey = + base.mainStatKey ?? getRandomElementFromArray(artSlotsData[slot].stats) + const level = + base.level ?? getRandomIntInclusive(0, artMaxLevel[rarity as RarityKey]) + const substats: ISubstat[] = [0, 1, 2, 3].map(() => ({ key: '', value: 0 })) + + const { low, high } = artSubstatRollData[rarity] + const totRolls = Math.floor(level / 4) + getRandomIntInclusive(low, high) + const numOfInitialSubstats = Math.min(totRolls, 4) + const numUpgradesOrUnlocks = totRolls - numOfInitialSubstats + + const RollStat = (substat: SubstatKey): number => + getRandomElementFromArray(getSubstatValuesPercent(substat, rarity)) + + let remainingSubstats = allSubstatKeys.filter((key) => mainStatKey !== key) + for (const substat of substats.slice(0, numOfInitialSubstats)) { + substat.key = getRandomElementFromArray(remainingSubstats) + substat.value = RollStat(substat.key as SubstatKey) + remainingSubstats = remainingSubstats.filter((key) => key !== substat.key) + } + for (let i = 0; i < numUpgradesOrUnlocks; i++) { + const substat = getRandomElementFromArray(substats) + substat.value += RollStat(substat.key as any) + } + for (const substat of substats) + if (substat.key) { + const value = artDisplayValue(substat.value, unit(substat.key)) + substat.value = parseFloat( + allStats.art.subRollCorrection[rarity]?.[substat.key]?.[value] ?? value + ) + } + + return { + setKey, + rarity, + slotKey: slot, + mainStatKey, + level, + substats, + location: base.location ?? '', + lock: false, + } +} diff --git a/libs/gi-util/src/character/index.ts b/libs/gi-util/src/character/index.ts new file mode 100644 index 0000000000..67e72eb57d --- /dev/null +++ b/libs/gi-util/src/character/index.ts @@ -0,0 +1 @@ +export * from './randomizeCharacter' diff --git a/libs/gi-util/src/character/randomizeCharacter.ts b/libs/gi-util/src/character/randomizeCharacter.ts new file mode 100644 index 0000000000..5dba48e139 --- /dev/null +++ b/libs/gi-util/src/character/randomizeCharacter.ts @@ -0,0 +1,30 @@ +import { allCharacterKeys } from '@genshin-optimizer/consts' +import type { ICharacter } from '@genshin-optimizer/gi-good' +import { + getRandomElementFromArray, + getRandomIntInclusive, +} from '@genshin-optimizer/util' +import { validateLevelAsc } from '../level' + +export function randomizeCharacter(base: Partial = {}): ICharacter { + const key = + base.key ?? + getRandomElementFromArray(allCharacterKeys.filter((c) => c !== 'Somnia')) // Do not return somnia + const level = base.level ?? getRandomIntInclusive(1, 90) + const { ascension } = validateLevelAsc(level, base.ascension ?? 0) + const constellation = base.constellation ?? getRandomIntInclusive(0, 6) + const auto = base.talent?.auto ?? getRandomIntInclusive(0, 10) + const skill = base.talent?.skill ?? getRandomIntInclusive(0, 10) + const burst = base.talent?.burst ?? getRandomIntInclusive(0, 10) + return { + key, + level, + ascension, + constellation, + talent: { + auto, + skill, + burst, + }, + } +} diff --git a/libs/gi-util/src/index.ts b/libs/gi-util/src/index.ts index 2c7dfabb85..ff2233d82a 100644 --- a/libs/gi-util/src/index.ts +++ b/libs/gi-util/src/index.ts @@ -1,2 +1,4 @@ export * from './artifact' export * from './level' +export * from './character' +export * from './weapon' diff --git a/libs/gi-util/src/weapon/index.ts b/libs/gi-util/src/weapon/index.ts new file mode 100644 index 0000000000..7eb0fa3353 --- /dev/null +++ b/libs/gi-util/src/weapon/index.ts @@ -0,0 +1 @@ +export * from './randomizeWeapon' diff --git a/libs/gi-util/src/weapon/randomizeWeapon.ts b/libs/gi-util/src/weapon/randomizeWeapon.ts new file mode 100644 index 0000000000..a82cf009db --- /dev/null +++ b/libs/gi-util/src/weapon/randomizeWeapon.ts @@ -0,0 +1,41 @@ +import type { RefinementKey } from '@genshin-optimizer/consts' +import { allWeaponKeys } from '@genshin-optimizer/consts' +import type { IWeapon } from '@genshin-optimizer/gi-good' +import { + getRandBool, + getRandomElementFromArray, + getRandomIntInclusive, +} from '@genshin-optimizer/util' +import { validateLevelAsc } from '../level' + +const weaponKeys = allWeaponKeys.filter( + (k) => + ![ + 'DullBlade', + 'SilverSword', + 'WasterGreatsword', + 'OldMercsPal', + 'BeginnersProtector', + 'IronPoint', + 'ApprenticesNotes', + 'PocketGrimoire', + 'HuntersBow', + 'SeasonedHuntersBow', + ].includes(k) +) +export function randomizeWeapon(base: Partial = {}): IWeapon { + const key = base.key ?? getRandomElementFromArray(weaponKeys) + const level = base.level ?? getRandomIntInclusive(1, 90) + const { ascension } = validateLevelAsc(level, base.ascension ?? 0) + const refinement = + base.refinement ?? (getRandomIntInclusive(1, 5) as RefinementKey) + const lock = base.lock ?? getRandBool() + return { + key, + level, + ascension, + refinement, + location: base.location ?? '', + lock, + } +} diff --git a/libs/img-util/.eslintrc.json b/libs/img-util/.eslintrc.json new file mode 100644 index 0000000000..9d9c0db55b --- /dev/null +++ b/libs/img-util/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/img-util/README.md b/libs/img-util/README.md new file mode 100644 index 0000000000..15151ba7f0 --- /dev/null +++ b/libs/img-util/README.md @@ -0,0 +1,7 @@ +# img-util + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test img-util` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/img-util/jest.config.ts b/libs/img-util/jest.config.ts new file mode 100644 index 0000000000..6a6a46d369 --- /dev/null +++ b/libs/img-util/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'img-util', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/libs/img-util', +} diff --git a/libs/img-util/project.json b/libs/img-util/project.json new file mode 100644 index 0000000000..1f91fa23aa --- /dev/null +++ b/libs/img-util/project.json @@ -0,0 +1,30 @@ +{ + "name": "img-util", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/img-util/src", + "projectType": "library", + "targets": { + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/img-util/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/img-util/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + } + }, + "tags": [] +} diff --git a/libs/img-util/src/canvas.ts b/libs/img-util/src/canvas.ts new file mode 100644 index 0000000000..f56585f9fc --- /dev/null +++ b/libs/img-util/src/canvas.ts @@ -0,0 +1,35 @@ +import type { Color } from './color' + +export function drawline( + canvas: HTMLCanvasElement, + a: number, + color: Color, + xaxis = true +) { + const width = canvas.width + const height = canvas.height + const ctx = canvas.getContext('2d')! + ctx.fillStyle = `rgba(${color.r},${color.g},${color.b},${ + color.a ? color.a / 255 : 1 + })` + xaxis ? ctx.fillRect(a, 0, 1, height) : ctx.fillRect(0, a, width, 1) + + return canvas +} + +export function drawHistogram( + canvas: HTMLCanvasElement, + histogram: number[], + color: Color, + xaxis = true +): HTMLCanvasElement { + const ctx = canvas.getContext('2d') + if (!ctx) return canvas + ctx.fillStyle = `rgba(${color.r},${color.g},${color.b},${ + color.a ? color.a / 255 : 1 + })` + histogram.forEach((val, i) => + xaxis ? ctx.fillRect(i, 0, 1, val) : ctx.fillRect(0, i, val, 1) + ) + return canvas +} diff --git a/libs/img-util/src/color.ts b/libs/img-util/src/color.ts new file mode 100644 index 0000000000..52180edcca --- /dev/null +++ b/libs/img-util/src/color.ts @@ -0,0 +1,33 @@ +import { within } from '@genshin-optimizer/util' + +export type Color = { + r: number + g: number + b: number + a?: number | undefined +} + +export function lighterColor(color: Color, value = 10) { + return modColor(color, value) +} + +export function darkerColor(color: Color, value = 10) { + return modColor(color, -value) +} +export function modColor(color: Color, value = 10) { + const { r, g, b, a } = color + return { + r: r + value, + g: g + value, + b: b + value, + a, + } +} + +export function colorWithin(color: Color, colorDark: Color, colorLight: Color) { + return ( + within(color.r, colorDark.r, colorLight.r) && + within(color.g, colorDark.g, colorLight.g) && + within(color.b, colorDark.b, colorLight.b) + ) +} diff --git a/libs/img-util/src/imageData.ts b/libs/img-util/src/imageData.ts new file mode 100644 index 0000000000..bc222723d7 --- /dev/null +++ b/libs/img-util/src/imageData.ts @@ -0,0 +1,73 @@ +export function cropCanvas( + srcCanvas: HTMLCanvasElement, + x: number, + y: number, + w: number, + h: number +) { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d')! + canvas.width = w + canvas.height = h + ctx.drawImage(srcCanvas, x, y, w, h, 0, 0, w, h) + return canvas +} + +export function cropImageData( + srcCanvas: HTMLCanvasElement, + x: number, + y: number, + w: number, + h: number +) { + const ctx = srcCanvas.getContext('2d', { willReadFrequently: true })! + return ctx.getImageData(x, y, w, h) +} +export function cropVertical( + srcCanvas: HTMLCanvasElement, + x1: number, + x2: number +) { + return cropImageData(srcCanvas, x1, 0, x2 - x1, srcCanvas.height) +} +export function cropHorizontal( + srcCanvas: HTMLCanvasElement, + y1: number, + y2: number +) { + return cropImageData(srcCanvas, 0, y1, srcCanvas.width, y2 - y1) +} + +export const fileToURL = (file: File): Promise => + new Promise((resolve) => { + const reader = new FileReader() + reader.onloadend = ({ target }) => resolve(target!.result as string) + reader.readAsDataURL(file) + }) +export const urlToImageData = (urlFile: string): Promise => + new Promise((resolve) => { + const img = new Image() + img.onload = ({ target }) => + resolve(imageToImageData(target as HTMLImageElement)) + img.src = urlFile + }) + +function imageToImageData(image: HTMLImageElement): ImageData { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d', { willReadFrequently: true })! + canvas.width = image.width + canvas.height = image.height + ctx.drawImage(image, 0, 0, image.width, image.height) + return ctx.getImageData(0, 0, image.width, image.height) +} + +export function imageDataToCanvas(imageData: ImageData): HTMLCanvasElement { + // create off-screen canvas element + const canvas = document.createElement('canvas') + canvas.width = imageData.width + canvas.height = imageData.height + + // update canvas with new data + canvas.getContext('2d')!.putImageData(imageData, 0, 0) + return canvas // produces a PNG file +} diff --git a/libs/img-util/src/index.ts b/libs/img-util/src/index.ts new file mode 100644 index 0000000000..8088cdaf45 --- /dev/null +++ b/libs/img-util/src/index.ts @@ -0,0 +1,4 @@ +export * from './color' +export * from './imageData' +export * from './canvas' +export * from './processing' diff --git a/libs/img-util/src/processing.ts b/libs/img-util/src/processing.ts new file mode 100644 index 0000000000..865d08be3d --- /dev/null +++ b/libs/img-util/src/processing.ts @@ -0,0 +1,120 @@ +import { within } from '@genshin-optimizer/util' +import type { Color } from './color' +import { colorWithin } from './color' + +export function bandPass( + pixelData: ImageData, + color1: Color, + color2: Color, + mode = 'color' as 'bw' | 'color' | 'invert' +) { + const d = Uint8ClampedArray.from(pixelData.data) + + const bw = mode === 'bw', + invert = mode === 'invert' + for (let i = 0; i < d.length; i += 4) { + const r = d[i], + g = d[i + 1], + b = d[i + 2] + if (colorWithin({ r, g, b }, color1, color2)) { + if (bw) d[i] = d[i + 1] = d[i + 2] = 0 + else if (invert) { + d[i] = 255 - r + d[i + 1] = 255 - g + d[i + 2] = 255 - b + } // else orignal color + } else { + d[i] = d[i + 1] = d[i + 2] = 255 + } + } + return new ImageData(d, pixelData.width, pixelData.height) +} + +export function histogramAnalysis( + imageData: ImageData, + color1: Color, + color2: Color, + hori = true +): number[] { + const height = imageData.height + const width = imageData.width + const p = imageData.data + return Array.from({ length: hori ? width : height }, (v, i) => { + let num = 0 + for (let j = 0; j < (hori ? height : width); j++) { + const pixelIndex = hori + ? getPixelIndex(i, j, width) + : getPixelIndex(j, i, width) + const [r, g, b] = [p[pixelIndex], p[pixelIndex + 1], p[pixelIndex + 2]] + if (colorWithin({ r, g, b }, color1, color2)) num++ + } + return num + }) +} + +// find the longest "line" in an axis. +export function histogramContAnalysis( + imageData: ImageData, + color1: Color, + color2: Color, + hori = true +): number[] { + const height = imageData.height + const width = imageData.width + const p = imageData.data + return Array.from({ length: hori ? width : height }, (v, i) => { + let longest = 0 + let num = 0 + for (let j = 0; j < (hori ? height : width); j++) { + const pixelIndex = hori + ? getPixelIndex(i, j, width) + : getPixelIndex(j, i, width) + const [r, g, b] = [p[pixelIndex], p[pixelIndex + 1], p[pixelIndex + 2]] + if ( + within(r, color1.r, color2.r) && + within(g, color1.g, color2.g) && + within(b, color1.b, color2.b) + ) + num++ + else { + if (num > longest) longest = num + num = 0 + } + } + if (num > longest) longest = num + return longest + }) +} + +export function getPixelIndex(x: number, y: number, width: number) { + return y * (width * 4) + x * 4 +} + +// find the left most and right most peak of the histogram +export function findHistogramRange( + histogram: number[], + threshold = 0.7, + window = 5 +) { + const max = Math.max(...histogram) + const hMax = max * threshold + const length = histogram.length + let a = -window + for (let i = 0; i < histogram.length; i++) { + const maxed = histogram[i] > hMax + if (!maxed) a = -window + else if (maxed && a < 0) a = i + else if (maxed && i - a > window) break + } + if (a < 0) a = 0 + + let b = length - 1 + window + for (let i = length - 1; i >= 0; i--) { + const maxed = histogram[i] > hMax + if (!maxed) b = length - 1 + window + else if (maxed && b > length - 1) b = i + else if (maxed && b - i > window) break + } + if (b > length - 1) b = length - 1 + return [a, b] +} diff --git a/libs/img-util/tsconfig.json b/libs/img-util/tsconfig.json new file mode 100644 index 0000000000..f5b85657a8 --- /dev/null +++ b/libs/img-util/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/img-util/tsconfig.lib.json b/libs/img-util/tsconfig.lib.json new file mode 100644 index 0000000000..33eca2c2cd --- /dev/null +++ b/libs/img-util/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/img-util/tsconfig.spec.json b/libs/img-util/tsconfig.spec.json new file mode 100644 index 0000000000..9b2a121d11 --- /dev/null +++ b/libs/img-util/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/react-util/src/Components/ReadOnlyTextArea.tsx b/libs/react-util/src/Components/ReadOnlyTextArea.tsx index 98943cdf7d..2f957a5c19 100644 --- a/libs/react-util/src/Components/ReadOnlyTextArea.tsx +++ b/libs/react-util/src/Components/ReadOnlyTextArea.tsx @@ -1,4 +1,4 @@ -import { styled } from '@mui/material/styles' +import { styled } from '@mui/material' const TextArea = styled('textarea')({ width: '100%', diff --git a/libs/ui-common/src/components/BootstrapTooltip.tsx b/libs/ui-common/src/components/BootstrapTooltip.tsx index 3ffb61255a..3db66d3538 100644 --- a/libs/ui-common/src/components/BootstrapTooltip.tsx +++ b/libs/ui-common/src/components/BootstrapTooltip.tsx @@ -1,6 +1,5 @@ import type { TooltipProps } from '@mui/material' -import { styled } from '@mui/material/styles' -import { Tooltip, tooltipClasses } from '@mui/material' +import { Tooltip, tooltipClasses, styled } from '@mui/material' export const BootstrapTooltip = styled( ({ className, ...props }: TooltipProps) => ( diff --git a/libs/ui-common/src/components/ColorText.tsx b/libs/ui-common/src/components/ColorText.tsx index 43a5d9a011..ab17e39676 100644 --- a/libs/ui-common/src/components/ColorText.tsx +++ b/libs/ui-common/src/components/ColorText.tsx @@ -1,6 +1,6 @@ import type { Palette, PaletteColor } from '@mui/material' -import { styled } from '@mui/material/styles' import type { HTMLAttributes } from 'react' +import { styled } from '@mui/material' interface ColorTextProps extends HTMLAttributes { color?: keyof Palette diff --git a/libs/ui-common/src/components/DropdownButton.tsx b/libs/ui-common/src/components/DropdownButton.tsx new file mode 100644 index 0000000000..dd2d0b8c5b --- /dev/null +++ b/libs/ui-common/src/components/DropdownButton.tsx @@ -0,0 +1,99 @@ +import { KeyboardArrowDown } from '@mui/icons-material' +import type { ButtonProps } from '@mui/material' +import { + Button, + ClickAwayListener, + Grow, + MenuList, + Paper, + Popper, + Skeleton, +} from '@mui/material' +import { Suspense, useCallback, useState } from 'react' + +export type DropdownButtonProps = Omit & { + title: React.ReactNode + id?: string + children: React.ReactNode +} +export function DropdownButton({ + title, + children, + id = 'dropdownbtn', + ...props +}: DropdownButtonProps) { + const [anchorEl, setAnchorEl] = useState(null) + const open = Boolean(anchorEl) + const handleClick = useCallback( + (event: React.MouseEvent) => + setAnchorEl(event.currentTarget), + [setAnchorEl] + ) + const handleClose = useCallback(() => setAnchorEl(null), [setAnchorEl]) + + return ( + } {...props}> + + + } + > + + + {({ TransitionProps, placement }) => ( + + {/* Replicating previous menu paper */} + + +
    + {' '} + {/* div needed for ClickAwayListener to function */} + {/* set Skeleton to be really high so the taller dropdowns can still be placed properly... */} + }> + {children} + +
    +
    +
    +
    + )} +
    +
    + ) +} diff --git a/libs/ui-common/src/components/GeneralAutocomplete.tsx b/libs/ui-common/src/components/GeneralAutocomplete.tsx new file mode 100644 index 0000000000..39949a51b5 --- /dev/null +++ b/libs/ui-common/src/components/GeneralAutocomplete.tsx @@ -0,0 +1,250 @@ +import { Favorite } from '@mui/icons-material' +import type { + AutocompleteProps, + ChipProps, + ChipPropsColorOverrides, + Palette, + PaletteColor, + TextFieldProps, +} from '@mui/material' +import { + Autocomplete, + Chip, + ListItemIcon, + ListItemText, + MenuItem, + Skeleton, + TextField, + useTheme, +} from '@mui/material' +import type { ReactNode } from 'react' +import { Suspense, useMemo } from 'react' +import { ColorText } from './ColorText' +/** + * NOTE: the rationale behind toImg/toExlabel/toExItemLabel, is because `options` needs to be serializable, and having JSX in there will disrupt seralizability. + */ +export type GeneralAutocompleteOption = { + key: T + label: string + grouper?: string | number + variant?: keyof Palette + favorite?: boolean + alternateNames?: string[] +} +type GeneralAutocompletePropsBase = { + label?: string + toImg: (v: T) => JSX.Element | undefined + toExItemLabel?: (v: T) => ReactNode + toExLabel?: (v: T) => ReactNode + chipProps?: Partial + textFieldProps?: Partial +} +export type GeneralAutocompleteProps = + GeneralAutocompletePropsBase & { + valueKey: T | null + onChange: (v: T | null) => void + } & Omit< + AutocompleteProps, false, false, false>, + | 'renderInput' + | 'isOptionEqualToValue' + | 'renderOption' + | 'onChange' + | 'value' + > +export function GeneralAutocomplete({ + options, + valueKey, + label, + onChange, + toImg, + toExItemLabel, + toExLabel, + textFieldProps, + ...acProps +}: GeneralAutocompleteProps) { + const value = options.find((o) => o.key === valueKey) + const theme = useTheme() + return ( + onChange(newValue?.key ?? null)} + isOptionEqualToValue={(option, value) => option.key === value?.key} + renderInput={(params) => { + const variant = value?.variant + const color = variant + ? (theme.palette[variant] as PaletteColor | undefined)?.main + : undefined + const { InputLabelProps, InputProps, inputProps, ...restParams } = + params + return ( + + ) + }} + renderOption={(props, option) => { + // https://stackoverflow.com/a/75968316 + const { key, ...rest } = + props as React.HTMLAttributes & { key: string } + return ( + + {toImg(option.key)} + + }> + + {option.key === value?.key ? ( + {option.label} + ) : ( + {option.label} + )} + {toExItemLabel?.(option.key)} + + + + {!!option.favorite && } + + ) + }} + filterOptions={(options, { inputValue }) => + options.filter( + (opt) => + opt.label.toLowerCase().includes(inputValue.toLowerCase()) || + opt.alternateNames?.some((name) => + name.toLowerCase().includes(inputValue.toLowerCase()) + ) + ) + } + {...acProps} + /> + ) +} +export type GeneralAutocompleteMultiProps = + GeneralAutocompletePropsBase & { + valueKeys: T[] + onChange: (v: T[]) => void + } & Omit< + AutocompleteProps, true, true, false>, + | 'renderInput' + | 'isOptionEqualToValue' + | 'renderOption' + | 'onChange' + | 'value' + > +export function GeneralAutocompleteMulti({ + options, + valueKeys: keys, + label, + onChange, + toImg, + toExItemLabel, + toExLabel, + chipProps, + ...acProps +}: GeneralAutocompleteMultiProps) { + const value = useMemo( + () => + keys + .map((k) => options.find((o) => o.key === k)) + .filter((o) => o) as unknown as GeneralAutocompleteOption[], + [options, keys] + ) + return ( + { + if (reason === 'clear') return onChange([]) + return newValue !== null && onChange(newValue.map((v) => v.key)) + }} + isOptionEqualToValue={(option, value) => option.key === value.key} + renderInput={(params) => ( + + )} + renderOption={(props, option) => ( + + {toImg(option.key)} + + }> + + {keys.includes(option.key) ? ( + {option.label} + ) : ( + {option.label} + )} + {toExItemLabel?.(option.key)} + + + + {!!option.favorite && } + + )} + renderTags={(selected, getTagProps) => + selected.map(({ key, label, variant }, index) => ( + + {label} {toExLabel(key)} + + ) : ( + label + ) + } + color={variant as keyof ChipPropsColorOverrides} + /> + )) + } + filterOptions={(options, { inputValue }) => + options.filter( + (opt) => + opt.label.toLowerCase().includes(inputValue.toLowerCase()) || + opt.alternateNames?.some((name) => + name.toLowerCase().includes(inputValue.toLowerCase()) + ) + ) + } + {...acProps} + /> + ) +} diff --git a/libs/ui-common/src/components/ModalWrapper.tsx b/libs/ui-common/src/components/ModalWrapper.tsx new file mode 100644 index 0000000000..18e4cabb00 --- /dev/null +++ b/libs/ui-common/src/components/ModalWrapper.tsx @@ -0,0 +1,39 @@ +import type { ContainerProps, ModalProps } from '@mui/material' +import { Box, Container, Modal, styled } from '@mui/material' + +const ModalContainer = styled(Container)(() => ({ + display: 'flex', + flexDirection: 'column', + minHeight: '100vh', + justifyContent: 'center', + ':focus': { + outline: 'None', + }, + // Allow clicking on the Container to exit modal + pointerEvents: 'none', + '& > *': { + pointerEvents: 'auto', + }, +})) + +type ModalWrapperProps = ModalProps & { + containerProps?: ContainerProps +} +export function ModalWrapper({ + children, + containerProps, + ...props +}: ModalWrapperProps) { + return ( + + {children} + + ) +} diff --git a/libs/ui-common/src/components/SqBadge.tsx b/libs/ui-common/src/components/SqBadge.tsx index 2cf41b69ef..d79cc1f838 100644 --- a/libs/ui-common/src/components/SqBadge.tsx +++ b/libs/ui-common/src/components/SqBadge.tsx @@ -1,6 +1,6 @@ import type { Palette, PaletteColor } from '@mui/material' -import { styled } from '@mui/material/styles' import type { HTMLAttributes } from 'react' +import { styled } from '@mui/material' interface ColorTextProps extends HTMLAttributes { color?: keyof Palette diff --git a/libs/ui-common/src/components/TextButton.tsx b/libs/ui-common/src/components/TextButton.tsx new file mode 100644 index 0000000000..1e1f5084bd --- /dev/null +++ b/libs/ui-common/src/components/TextButton.tsx @@ -0,0 +1,17 @@ +import type { ButtonProps } from '@mui/material' +import { Button, styled } from '@mui/material' + +const DisabledButton = styled(Button)(({ theme }) => ({ + '&.Mui-disabled': { + backgroundColor: theme.palette.primary.dark, + color: theme.palette.text.secondary, + }, +})) + +export function TextButton({ children, disabled, ...props }: ButtonProps) { + return ( + + {children} + + ) +} diff --git a/libs/ui-common/src/components/index.ts b/libs/ui-common/src/components/index.ts index 8c512f7c1a..ddc29eae5a 100644 --- a/libs/ui-common/src/components/index.ts +++ b/libs/ui-common/src/components/index.ts @@ -6,3 +6,7 @@ export * from './SqBadge' export * from './ConditionalWrapper' export * from './InfoTooltip' export * from './StarDisplay' +export * from './GeneralAutocomplete' +export * from './ModalWrapper' +export * from './DropdownButton' +export * from './TextButton' diff --git a/libs/util/src/index.ts b/libs/util/src/index.ts index e40f49448f..bdf3d30c54 100644 --- a/libs/util/src/index.ts +++ b/libs/util/src/index.ts @@ -9,3 +9,4 @@ export * from './lib/array' export * from './lib/time' export * from './lib/sortFilters' export * from './lib/validation' +export * from './lib/BorrowManager' diff --git a/libs/util/src/lib/BorrowManager.ts b/libs/util/src/lib/BorrowManager.ts new file mode 100644 index 0000000000..720dbb7c3b --- /dev/null +++ b/libs/util/src/lib/BorrowManager.ts @@ -0,0 +1,37 @@ +export class BorrowManager { + data: Record = {} + init: (key: string) => T + deinit: (key: string, value: T) => void + + constructor( + init: (key: string) => T, + deinit: (key: string, value: T) => void + ) { + this.init = init + this.deinit = deinit + } + + /** + * Borrow the object corresponding to `key`, creating the object as necessary. + * The borrowing ends when `callback`'s promise is fulfilled. + * When the last borrowing ends, `deinit` the object. + * + * Do not use `arg` after the `callback`'s promise is fulfilled. + */ + async borrow(key: string, callback: (arg: T) => Promise): Promise { + if (!this.data[key]) { + this.data[key] = { value: this.init(key), refCount: 0 } + } + + const box = this.data[key]! + box.refCount += 1 + const result = await callback(box.value) + box.refCount -= 1 + if (!box.refCount) { + // Last user. Cleaning up + delete this.data[key] + this.deinit(key, box.value) + } + return result + } +} diff --git a/libs/util/src/lib/array.spec.ts b/libs/util/src/lib/array.spec.ts index bcde89b7b4..67d2b56e10 100644 --- a/libs/util/src/lib/array.spec.ts +++ b/libs/util/src/lib/array.spec.ts @@ -1,7 +1,11 @@ -import { linspace } from './array' +import { linspace, range } from './array' describe('test @genshin_optimizer/util/array', function () { it('test linspace', () => { expect(linspace(1, 2, 3)).toEqual([1, 1.5, 2]) expect(linspace(1, 5, 4, false)).toEqual([1, 2, 3, 4]) }) + it('test range', () => { + expect(range(0, 0)).toEqual([0]) + expect(range(0, 10, 4)).toEqual([0, 4, 8]) + }) }) diff --git a/libs/util/src/lib/number.ts b/libs/util/src/lib/number.ts index 59d06f4e48..4e69a6df86 100644 --- a/libs/util/src/lib/number.ts +++ b/libs/util/src/lib/number.ts @@ -10,3 +10,7 @@ export function toPercent(number: number, statKey: string) { if (statKey.endsWith('_')) return number * 100 return number } +export function within(val: number, a: number, b: number, inclusive = true) { + if (inclusive) return val >= a && val <= b + return val > a && val < b +} diff --git a/libs/util/src/lib/random.ts b/libs/util/src/lib/random.ts index 8cba909ad5..e394e864b7 100644 --- a/libs/util/src/lib/random.ts +++ b/libs/util/src/lib/random.ts @@ -13,3 +13,7 @@ export function getRandomIntInclusive(min: number, max: number) { export function getRandomArbitrary(min: number, max: number) { return Math.random() * (max - min) + min } + +export function getRandBool() { + return !Math.round(Math.random()) +} diff --git a/tsconfig.base.json b/tsconfig.base.json index ffb8469c0b..82592d92ef 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -30,11 +30,13 @@ "@genshin-optimizer/consts": ["libs/consts/src/index.ts"], "@genshin-optimizer/database": ["libs/database/src/index.ts"], "@genshin-optimizer/dm": ["libs/dm/src/index.ts"], + "@genshin-optimizer/gi-art-scanner": ["libs/gi-art-scanner/src/index.ts"], "@genshin-optimizer/gi-assets": ["libs/gi-assets/src/index.ts"], "@genshin-optimizer/gi-dm-localization": [ "libs/gi-dm-localization/src/index.ts" ], "@genshin-optimizer/gi-formula": ["libs/gi-formula/src/index.ts"], + "@genshin-optimizer/gi-formula-ui": ["libs/gi-formula-ui/src/index.ts"], "@genshin-optimizer/gi-good": ["libs/gi-good/src/index.ts"], "@genshin-optimizer/gi-localization": [ "libs/gi-localization/src/index.ts" @@ -43,6 +45,7 @@ "@genshin-optimizer/gi-svgicons": ["libs/gi-svgicons/src/index.ts"], "@genshin-optimizer/gi-ui": ["libs/gi-ui/src/index.ts"], "@genshin-optimizer/gi-util": ["libs/gi-util/src/index.ts"], + "@genshin-optimizer/img-util": ["libs/img-util/src/index.ts"], "@genshin-optimizer/pando": ["libs/pando/src/index.ts"], "@genshin-optimizer/pipeline": ["libs/pipeline/src/index.ts"], "@genshin-optimizer/plugin": ["libs/plugin/src/index.ts"],