From 4d74491f4c3e2096c798d39ddb6739552b653d57 Mon Sep 17 00:00:00 2001 From: frzyc Date: Sat, 18 Jan 2025 09:51:03 -0500 Subject: [PATCH] ZO disc scanner (#2598) * zo disc scanner * fix lint + feedback * refactor substat key detection --- libs/common/img-util/src/processing.ts | 8 +- libs/zzz/consts/src/disc.ts | 7 +- .../Database/DataManagers/DiscDataManager.ts | 49 +-- libs/zzz/db/src/Interfaces/IDbDisc.ts | 9 +- libs/zzz/db/src/Interfaces/IDisc.ts | 4 +- libs/zzz/disc-scanner/.babelrc | 13 + libs/zzz/disc-scanner/.eslintrc.json | 21 ++ libs/zzz/disc-scanner/README.md | 7 + libs/zzz/disc-scanner/project.json | 8 + libs/zzz/disc-scanner/src/index.ts | 1 + .../disc-scanner/src/lib/ScanningQueue.tsx | 73 ++++ libs/zzz/disc-scanner/src/lib/consts.ts | 14 + libs/zzz/disc-scanner/src/lib/enStringMap.ts | 31 ++ libs/zzz/disc-scanner/src/lib/parse.ts | 120 +++++++ libs/zzz/disc-scanner/src/lib/processImg.ts | 233 +++++++++++++ libs/zzz/disc-scanner/tsconfig.eslint.json | 8 + libs/zzz/disc-scanner/tsconfig.json | 18 + libs/zzz/disc-scanner/tsconfig.lib.json | 24 ++ libs/zzz/page-discs/src/index.tsx | 9 +- libs/zzz/solver/src/common.ts | 8 +- libs/zzz/ui/src/Disc/DiscCard.tsx | 29 +- .../zzz/ui/src/Disc/DiscEditor/DiscEditor.tsx | 252 +++++++++++++- .../ui/src/Disc/DiscEditor/ScanningUtil.tsx | 39 +++ .../ui/src/Disc/DiscEditor/SubstatInput.tsx | 320 ++++++------------ libs/zzz/ui/src/Disc/DiscMainStatDropdown.tsx | 31 +- libs/zzz/ui/src/util/isDev.ts | 7 + tsconfig.base.json | 3 + 27 files changed, 1066 insertions(+), 280 deletions(-) create mode 100644 libs/zzz/disc-scanner/.babelrc create mode 100644 libs/zzz/disc-scanner/.eslintrc.json create mode 100644 libs/zzz/disc-scanner/README.md create mode 100644 libs/zzz/disc-scanner/project.json create mode 100644 libs/zzz/disc-scanner/src/index.ts create mode 100644 libs/zzz/disc-scanner/src/lib/ScanningQueue.tsx create mode 100644 libs/zzz/disc-scanner/src/lib/consts.ts create mode 100644 libs/zzz/disc-scanner/src/lib/enStringMap.ts create mode 100644 libs/zzz/disc-scanner/src/lib/parse.ts create mode 100644 libs/zzz/disc-scanner/src/lib/processImg.ts create mode 100644 libs/zzz/disc-scanner/tsconfig.eslint.json create mode 100644 libs/zzz/disc-scanner/tsconfig.json create mode 100644 libs/zzz/disc-scanner/tsconfig.lib.json create mode 100644 libs/zzz/ui/src/Disc/DiscEditor/ScanningUtil.tsx create mode 100644 libs/zzz/ui/src/util/isDev.ts diff --git a/libs/common/img-util/src/processing.ts b/libs/common/img-util/src/processing.ts index ecc615a871..6a5aedfd92 100644 --- a/libs/common/img-util/src/processing.ts +++ b/libs/common/img-util/src/processing.ts @@ -39,7 +39,7 @@ export function histogramAnalysis( const height = imageData.height const width = imageData.width const p = imageData.data - return Array.from({ length: hori ? width : height }, (v, i) => { + return Array.from({ length: hori ? width : height }, (_, i) => { let num = 0 for (let j = 0; j < (hori ? height : width); j++) { const pixelIndex = hori @@ -68,7 +68,7 @@ export function histogramContAnalysis( const left = max * leftRange const right = max * rightRange - return Array.from({ length: max }, (v, i) => { + return Array.from({ length: max }, (_, i) => { if (i < left || i > right) return 0 let longest = 0 let num = 0 @@ -142,7 +142,7 @@ export function preprocessImage(pixelData: ImageData) { } // from https://github.com/processing/p5.js/blob/main/src/image/filters.js -function thresholdFilter(pixels: Uint8ClampedArray, level: number) { +export function thresholdFilter(pixels: Uint8ClampedArray, level: number) { if (level === undefined) { level = 0.5 } @@ -162,7 +162,7 @@ function thresholdFilter(pixels: Uint8ClampedArray, level: number) { } } // from https://css-tricks.com/manipulating-pixels-using-canvas/ -function invertColors(pixels: Uint8ClampedArray) { +export function invertColors(pixels: Uint8ClampedArray) { for (let i = 0; i < pixels.length; i += 4) { pixels[i] = pixels[i] ^ 255 // Invert Red pixels[i + 1] = pixels[i + 1] ^ 255 // Invert Green diff --git a/libs/zzz/consts/src/disc.ts b/libs/zzz/consts/src/disc.ts index 10dc8be479..335c1b6fa5 100644 --- a/libs/zzz/consts/src/disc.ts +++ b/libs/zzz/consts/src/disc.ts @@ -111,7 +111,7 @@ const subData = { crit_dmg_: { B: 0.016, A: 0.032, S: 0.048 }, anomProf: { B: 3, A: 6, S: 9 }, } as const -export function getSubStatBaseVal( +export function getDiscSubStatBaseVal( statKey: DiscSubStatKey, rarity: DiscRarityKey ) { @@ -140,9 +140,12 @@ const mainData = { ether_dmg_: m123, anomMas_: m123, enerRegen_: { B: 0.2, A: 0.4, S: 0.6 }, - impact: { B: 0.06, A: 0.12, S: 0.18 }, + impact_: { B: 0.06, A: 0.12, S: 0.18 }, } as const +/** + * WARN: this only works for fully leveled discs + */ export function getDiscMainStatVal( rarity: DiscRarityKey, mainStatKey: DiscMainStatKey, diff --git a/libs/zzz/db/src/Database/DataManagers/DiscDataManager.ts b/libs/zzz/db/src/Database/DataManagers/DiscDataManager.ts index 2a95d812fb..03ae3ddae2 100644 --- a/libs/zzz/db/src/Database/DataManagers/DiscDataManager.ts +++ b/libs/zzz/db/src/Database/DataManagers/DiscDataManager.ts @@ -93,9 +93,9 @@ export class DiscDataManager extends DataManager< level, slotKey, mainStatKey, - substats: substats.map((substat) => ({ - key: substat.key, - value: substat.value, + substats: substats.map(({ key, upgrades }) => ({ + key, + upgrades, })), location, lock, @@ -238,7 +238,7 @@ export class DiscDataManager extends DataManager< (substat, i) => !candidate.substats[i].key || // Candidate doesn't have anything on this slot (substat.key === candidate.substats[i].key && // Or editor simply has better substat - substat.value >= candidate.substats[i].value) + substat.upgrades >= candidate.substats[i].upgrades) ) ) @@ -254,7 +254,7 @@ export class DiscDataManager extends DataManager< i // Has no extra roll ) => substat.key === candidate.substats[i].key && - substat.value === candidate.substats[i].value + substat.upgrades === candidate.substats[i].upgrades ) : substats.some( ( @@ -262,7 +262,7 @@ export class DiscDataManager extends DataManager< i // Has extra rolls ) => candidate.substats[i].key - ? substat.value > candidate.substats[i].value // Extra roll to existing substat + ? substat.upgrades > candidate.substats[i].upgrades // Extra roll to existing substat : substat.key // Extra roll to new substat )) ) @@ -280,7 +280,7 @@ export class DiscDataManager extends DataManager< candidate.substats.some( (candidateSubstat) => substat.key === candidateSubstat.key && // Or same slot - substat.value === candidateSubstat.value + substat.upgrades === candidateSubstat.upgrades ) ) ) @@ -297,11 +297,19 @@ export function validateDisc( sortSubs = true ): IDisc | undefined { if (!obj || typeof obj !== 'object') return undefined - const { setKey } = obj as IDisc - let { rarity, slotKey, level, mainStatKey, substats, location, lock, trash } = - obj as IDisc + let { + setKey, + rarity, + slotKey, + level, + mainStatKey, + substats, + location, + lock, + trash, + } = obj as IDisc - if (!allDiscSetKeys.includes(setKey)) return undefined // non-recoverable + if (!allDiscSetKeys.includes(setKey)) setKey = allDiscSetKeys[0] if (!allDiscSlotKeys.includes(slotKey)) slotKey = '1' if (!discSlotToMainStatKeys[slotKey].includes(mainStatKey)) mainStatKey = discSlotToMainStatKeys[slotKey][0] @@ -344,23 +352,18 @@ function parseSubstats( ): ISubstat[] { if (!Array.isArray(obj)) return [] const substats = (obj as ISubstat[]) - .map(({ key = '', value = 0 }) => { + .map(({ key, upgrades = 0 }) => { if (!key) return null if ( !allDiscSubStatKeys.includes(key as DiscSubStatKey) || - typeof value !== 'number' || - !isFinite(value) + typeof upgrades !== 'number' || + !Number.isFinite(upgrades) ) return null - if (key) { - value = key.endsWith('_') - ? Math.round(value * 1000) / 1000 - : Math.round(value) - // TODO: - // const { low, high } = getSubstatRange(rarity, key) - // value = clamp(value, allowZeroSub ? 0 : low, high) - } else value = 0 - return { key, value } + + upgrades = Math.round(upgrades) + + return { key, upgrades } }) .filter(notEmpty) as ISubstat[] diff --git a/libs/zzz/db/src/Interfaces/IDbDisc.ts b/libs/zzz/db/src/Interfaces/IDbDisc.ts index 85f178c259..da5affc7ab 100644 --- a/libs/zzz/db/src/Interfaces/IDbDisc.ts +++ b/libs/zzz/db/src/Interfaces/IDbDisc.ts @@ -1,12 +1,5 @@ -import type { IDisc, ISubstat } from './IDisc' +import type { IDisc } from './IDisc' export interface ICachedDisc extends IDisc { id: string - mainStatVal: number - substats: ICachedSubstat[] -} - -export interface ICachedSubstat extends ISubstat { - rolls: number - accurateValue: number } diff --git a/libs/zzz/db/src/Interfaces/IDisc.ts b/libs/zzz/db/src/Interfaces/IDisc.ts index 538fe28f73..1d2e700b80 100644 --- a/libs/zzz/db/src/Interfaces/IDisc.ts +++ b/libs/zzz/db/src/Interfaces/IDisc.ts @@ -8,12 +8,12 @@ import type { export interface ISubstat { key: DiscSubStatKey - value: number // TODO: should this be the # of rolls? + upgrades: number // This is the number of upgrades this sub receives. } export interface IDisc { setKey: DiscSetKey slotKey: DiscSlotKey - level: number + level: number // 0-15 rarity: DiscRarityKey mainStatKey: DiscMainStatKey location: string // TODO: CharacterKey diff --git a/libs/zzz/disc-scanner/.babelrc b/libs/zzz/disc-scanner/.babelrc new file mode 100644 index 0000000000..ca85798cd5 --- /dev/null +++ b/libs/zzz/disc-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/zzz/disc-scanner/.eslintrc.json b/libs/zzz/disc-scanner/.eslintrc.json new file mode 100644 index 0000000000..423abdac3c --- /dev/null +++ b/libs/zzz/disc-scanner/.eslintrc.json @@ -0,0 +1,21 @@ +{ + "extends": ["plugin:@nx/react", "../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "parserOptions": { + "project": "libs/zzz/disc-scanner/tsconfig.eslint.json" + }, + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/zzz/disc-scanner/README.md b/libs/zzz/disc-scanner/README.md new file mode 100644 index 0000000000..077bab693d --- /dev/null +++ b/libs/zzz/disc-scanner/README.md @@ -0,0 +1,7 @@ +# zzz-disc-scanner + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test zzz-disc-scanner` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/zzz/disc-scanner/project.json b/libs/zzz/disc-scanner/project.json new file mode 100644 index 0000000000..05eb74d5dc --- /dev/null +++ b/libs/zzz/disc-scanner/project.json @@ -0,0 +1,8 @@ +{ + "name": "zzz-disc-scanner", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/zzz/disc-scanner/src", + "projectType": "library", + "tags": [], + "targets": {} +} diff --git a/libs/zzz/disc-scanner/src/index.ts b/libs/zzz/disc-scanner/src/index.ts new file mode 100644 index 0000000000..77a7d326f0 --- /dev/null +++ b/libs/zzz/disc-scanner/src/index.ts @@ -0,0 +1 @@ +export * from './lib/ScanningQueue' diff --git a/libs/zzz/disc-scanner/src/lib/ScanningQueue.tsx b/libs/zzz/disc-scanner/src/lib/ScanningQueue.tsx new file mode 100644 index 0000000000..8958f998a4 --- /dev/null +++ b/libs/zzz/disc-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() + if (procesesd) this.processQueue() + return procesesd + } + clearQueue() { + this.processed = [] + this.outstanding = [] + this.scanning = [] + this.callCB() + } +} diff --git a/libs/zzz/disc-scanner/src/lib/consts.ts b/libs/zzz/disc-scanner/src/lib/consts.ts new file mode 100644 index 0000000000..d5f43c7669 --- /dev/null +++ b/libs/zzz/disc-scanner/src/lib/consts.ts @@ -0,0 +1,14 @@ +import type { Color } from '@genshin-optimizer/common/img-util' + +export const misreadCharactersInSubstatMap = [ + { + pattern: /#/, + replacement: '+', + }, +] + +export const blackColor: Color = { + r: 0, + g: 0, + b: 0, +} diff --git a/libs/zzz/disc-scanner/src/lib/enStringMap.ts b/libs/zzz/disc-scanner/src/lib/enStringMap.ts new file mode 100644 index 0000000000..1588dd9e9c --- /dev/null +++ b/libs/zzz/disc-scanner/src/lib/enStringMap.ts @@ -0,0 +1,31 @@ +// Store the english translations to use with scanner + +const elementalData: Record = { + electric: 'Electric', + fire: 'Fire', + ice: 'Ice', + frost: 'Frost', + physical: 'Physical', + ether: 'Ether', +} as const + +export const statMapEngMap = { + hp: 'HP', + hp_: 'HP', + atk: 'ATK', + atk_: 'ATK', + def: 'DEF', + def_: 'DEF', + pen: 'PEN', + pen_: 'PEN Ratio', + crit_: 'CRIT Rate', + crit_dmg_: 'CRIT DMG', + enerRegen_: 'Energy Regen', + impact_: 'Impact', + anomMas_: 'Anomaly Mastery', + anomProf: 'Anomaly Proficiency', +} as Record + +Object.entries(elementalData).forEach(([e, name]) => { + statMapEngMap[`${e}_dmg_`] = `${name} DMG Bonus` +}) diff --git a/libs/zzz/disc-scanner/src/lib/parse.ts b/libs/zzz/disc-scanner/src/lib/parse.ts new file mode 100644 index 0000000000..00780b531b --- /dev/null +++ b/libs/zzz/disc-scanner/src/lib/parse.ts @@ -0,0 +1,120 @@ +import { levenshteinDistance } from '@genshin-optimizer/common/util' +import type { DiscRarityKey, DiscSlotKey } from '@genshin-optimizer/zzz/consts' +import { + allDiscMainStatKeys, + allDiscSetKeys, + allDiscSubStatKeys, + discMaxLevel, + discSlotToMainStatKeys, + type DiscSetKey, +} from '@genshin-optimizer/zzz/consts' +import type { ISubstat } from '@genshin-optimizer/zzz/db' +import { misreadCharactersInSubstatMap } from './consts' +import { statMapEngMap } from './enStringMap' + +/** small utility function used by most string parsing functions below */ +type KeyDist = [T, number] +function getBestKeyDist(hams: Array>) { + const minHam = Math.min(...hams.map(([, ham]) => ham)) + const keys = hams.filter(([, ham]) => ham === minHam).map(([key]) => key) + return keys +} +function findBestKeyDist( + str: string, + keys: readonly T[] +): T | undefined { + if (!keys.length) return undefined + if (keys.length === 1) return keys[0] + const kdist: Array> = [] + for (const key of keys) + kdist.push([key, levenshteinDistance(str, statMapEngMap[key] ?? key)]) + return getBestKeyDist(kdist)[0] +} + +export function parseSetSlot(texts: string[]) { + let setKeyStr = '', + slotKey = '' + for (const text of texts) { + const match = /(.+)\s+\[([123456])\]/.exec(text) + + if (match) { + setKeyStr = match[1] + slotKey = match[2] + break + } + } + if (!setKeyStr || !slotKey) return { setKey: undefined, slotKey: undefined } + const setKeyStrTrimmed = setKeyStr.replace(/\W/g, '') + const setKey = findBestKeyDist(setKeyStrTrimmed, allDiscSetKeys) + + if (!setKey) return { setKey: undefined, slotKey: undefined } + return { setKey, slotKey: slotKey as DiscSlotKey } +} +export function parseSet(texts: string[]) { + const kdist: Array> = [] + for (const text of texts) { + for (const key of allDiscSetKeys) { + const setKeyStrTrimmed = text.replace(/\W/g, '') + kdist.push([key, levenshteinDistance(setKeyStrTrimmed, key)]) + } + } + + const setKeys = getBestKeyDist(kdist) + return setKeys[0] +} +export function parseLvlRarity(texts: string[]) { + let level = -1, + maxLvl = -1 + for (const text of texts) { + const match = /Lv.\s*([01][0-9])\/([01][0-9])/.exec(text) + if (match) { + level = parseInt(match[1]) + maxLvl = parseInt(match[2]) + break + } + } + if (level === -1 || maxLvl === -1) + return { level: undefined, rarity: undefined } + const rarity = Object.entries(discMaxLevel).find( + ([, max]) => maxLvl === max + )?.[0] as DiscRarityKey + if (!rarity) return { level: undefined, rarity: undefined } + return { level, rarity } +} +export function parseMainStatKeys(texts: string[], slotKey?: DiscSlotKey) { + const keys = slotKey ? discSlotToMainStatKeys[slotKey] : allDiscMainStatKeys + if (keys.length === 1) return keys[0] + for (const text of texts) { + const isPercent = text.includes('%') + const filteredKeys = keys.filter((key) => isPercent === key.endsWith('_')) + for (const key of filteredKeys) { + if (text.includes(statMapEngMap[key])) return key + } + } + return undefined +} + +export function parseSubstats(texts: string[]): ISubstat[] { + const substats: ISubstat[] = [] + for (let text of texts) { + // Apply OCR character corrections (e.g., '#' → '+') before parsing substats + for (const { pattern, replacement } of misreadCharactersInSubstatMap) { + text = text.replace(pattern, replacement) + } + const isPercent = text.includes('%') + const match = /([a-zA-Z\s]+)\s*(\+(\d))?/.exec(text) + if (match) { + const statStr = match[1].trim() + const key = findBestKeyDist( + statStr, + allDiscSubStatKeys.filter((key) => isPercent === key.endsWith('_')) + ) + if (!key) continue + substats.push({ + key, + upgrades: match[3] ? parseInt(match[3]) + 1 : 1, + }) + } + } + return substats.slice(0, 4) +} diff --git a/libs/zzz/disc-scanner/src/lib/processImg.ts b/libs/zzz/disc-scanner/src/lib/processImg.ts new file mode 100644 index 0000000000..6301b7382e --- /dev/null +++ b/libs/zzz/disc-scanner/src/lib/processImg.ts @@ -0,0 +1,233 @@ +import { + crop, + darkerColor, + drawHistogram, + drawline, + fileToURL, + findHistogramRange, + histogramContAnalysis, + imageDataToCanvas, + invertColors, + lighterColor, + thresholdFilter, + urlToImageData, +} from '@genshin-optimizer/common/img-util' +import type { DiscSlotKey } from '@genshin-optimizer/zzz/consts' +import { discSlotToMainStatKeys } from '@genshin-optimizer/zzz/consts' +import type { IDisc } from '@genshin-optimizer/zzz/db' +import type { ReactNode } from 'react' +import { blackColor } from './consts' +import { statMapEngMap } from './enStringMap' +import { + parseLvlRarity, + parseMainStatKeys, + parseSet, + parseSetSlot, + parseSubstats, +} from './parse' + +export type Processed = { + fileName: string + imageURL: string + disc?: IDisc + texts: ReactNode[] + debugImgs?: Record | undefined +} +export type Outstanding = { + f: File + fName: string +} + +// since ZZZ discs have relatively high contrast, only invert and threshold is needed. +export function zzzPreprocessImage(pixelData: ImageData) { + const imageClone = Uint8ClampedArray.from(pixelData.data) + invertColors(imageClone) + thresholdFilter(imageClone, 0.5) + return new ImageData(imageClone, pixelData.width, pixelData.height) +} + +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 discCardImageData = cropDiscCard(imageData, debugImgs) + + const retProcessed: Processed = { + fileName: fName, + imageURL: imageDataToCanvas(discCardImageData).toDataURL(), + texts: [], + debugImgs, + } + + const bwHeader = zzzPreprocessImage(discCardImageData) + + if (debugImgs) { + debugImgs['bwHeader'] = imageDataToCanvas(bwHeader).toDataURL() + } + + const whiteTexts = (await textsFromImage(bwHeader)).map((t) => t.trim()) + + const mainStatTextIndex = whiteTexts.findIndex((t) => + t.toLowerCase().includes('main stat') + ) + const substatTextIndex = whiteTexts.findIndex((t) => + t.toLowerCase().includes('sub-stats') + ) + const setEffectTextIndex = whiteTexts.findIndex((t) => + t.toLowerCase().includes('set effect') + ) + if ( + mainStatTextIndex === -1 || + substatTextIndex === -1 || + setEffectTextIndex === -1 + ) { + retProcessed.texts.push( + 'Could not detect main stat, substats or set effect.' + ) + return retProcessed + } + const setLvlTexts = whiteTexts.slice(0, mainStatTextIndex) + const mainStatTexts = whiteTexts.slice( + mainStatTextIndex + 1, + substatTextIndex + ) + const substatTexts = whiteTexts.slice( + substatTextIndex + 1, + setEffectTextIndex + ) + const setEffectTexts = whiteTexts.slice(setEffectTextIndex + 1) + if ( + setLvlTexts.length === 0 || + mainStatTexts.length === 0 || + substatTexts.length === 0 || + setEffectTexts.length === 0 + ) { + retProcessed.texts.push( + 'Could not detect main stat, substats or set effect.' + ) + return retProcessed + } + + // Join all text above the "Main Stat" text due to set text wrapping + let { slotKey, setKey } = parseSetSlot([setLvlTexts.join('')]) + if (!setKey) { + setKey = parseSet(setEffectTexts) + if (!setKey) { + setKey = 'AstralVoice' + retProcessed.texts.push( + 'Could not detect set key. Assuming Astral Voice.' + ) + } + } + let { level, rarity } = parseLvlRarity(setLvlTexts) + if (!rarity || !level) { + retProcessed.texts.push( + 'Could not detect rarity + level, assuming S Lv.15/15' + ) + rarity = 'S' + level = 15 + } + let mainStatKey = parseMainStatKeys(mainStatTexts, slotKey) + if (!mainStatKey) { + mainStatKey = discSlotToMainStatKeys[slotKey!][0] + retProcessed.texts.push( + `Could not detect main stat key, defaulting to ${statMapEngMap[mainStatKey]}` + ) + } else if (!slotKey && mainStatKey) { + slotKey = Object.entries(discSlotToMainStatKeys).find(([_, v]) => + v.includes(mainStatKey as any) + )?.[0] as DiscSlotKey + if (slotKey) + retProcessed.texts.push( + `Could not detect slot key. inferring it to be ${slotKey}.` + ) + } + if (!slotKey) { + slotKey = '1' + retProcessed.texts.push('Could not detect slot key. assuming it to be 1.') + } + const substats = parseSubstats(substatTexts) + const disc: IDisc = { + setKey, + slotKey: slotKey!, + level, + rarity, + mainStatKey, + location: '', + lock: false, + trash: false, + substats, + } + retProcessed.disc = disc + return retProcessed +} +function cropDiscCard( + imageData: ImageData, + debugImgs?: Record +) { + const histogram = histogramContAnalysis( + imageData, + darkerColor(blackColor), + lighterColor(blackColor) + ) + + // look for the black line outside the card outline. This will likely be only a pixel wide + const [a, b] = findHistogramRange(histogram, 0.9, 1) + + const cropped = crop(imageDataToCanvas(imageData), { x1: a, x2: b }) + + 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() + } + const horihistogram = histogramContAnalysis( + cropped, + darkerColor(blackColor), + lighterColor(blackColor), + false + ) + // look for the black line outside the card outline. This will likely be only a pixel wide + const [bot, top] = findHistogramRange(horihistogram, 0.9, 1) + const cropped2 = crop(imageDataToCanvas(cropped), { y1: bot, y2: top }) + + if (debugImgs) { + const canvas = imageDataToCanvas(cropped) + + drawHistogram( + canvas, + horihistogram, + { + r: 255, + g: 0, + b: 0, + a: 100, + }, + false + ) + drawline(canvas, a, { r: 0, g: 255, b: 0, a: 150 }, false) + drawline(canvas, b, { r: 0, g: 0, b: 255, a: 150 }, false) + + debugImgs['fullAnalysis horizontal'] = canvas.toDataURL() + } + + return cropped2 +} diff --git a/libs/zzz/disc-scanner/tsconfig.eslint.json b/libs/zzz/disc-scanner/tsconfig.eslint.json new file mode 100644 index 0000000000..36a5010d67 --- /dev/null +++ b/libs/zzz/disc-scanner/tsconfig.eslint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": ["node"] + }, + "include": ["**/*.ts", "**/*.tsx"] +} diff --git a/libs/zzz/disc-scanner/tsconfig.json b/libs/zzz/disc-scanner/tsconfig.json new file mode 100644 index 0000000000..4d5514a259 --- /dev/null +++ b/libs/zzz/disc-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/zzz/disc-scanner/tsconfig.lib.json b/libs/zzz/disc-scanner/tsconfig.lib.json new file mode 100644 index 0000000000..21799b3e6b --- /dev/null +++ b/libs/zzz/disc-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/zzz/page-discs/src/index.tsx b/libs/zzz/page-discs/src/index.tsx index 556bf61d28..e8db3fa63d 100644 --- a/libs/zzz/page-discs/src/index.tsx +++ b/libs/zzz/page-discs/src/index.tsx @@ -27,7 +27,14 @@ export default function PageDiscs() { return ( - + ) diff --git a/libs/zzz/solver/src/common.ts b/libs/zzz/solver/src/common.ts index 0896b8e90a..d2d6e6644f 100644 --- a/libs/zzz/solver/src/common.ts +++ b/libs/zzz/solver/src/common.ts @@ -1,5 +1,6 @@ import { getDiscMainStatVal, + getDiscSubStatBaseVal, type DiscSlotKey, } from '@genshin-optimizer/zzz/consts' import type { ICachedDisc } from '@genshin-optimizer/zzz/db' @@ -28,9 +29,10 @@ export function convertDiscToStats(disc: ICachedDisc): DiscStats { stats: { [mainStatKey]: getDiscMainStatVal(rarity, mainStatKey, level), ...Object.fromEntries( - substats - .filter(({ key, value }) => key && value) - .map(({ key, value }) => [key, value]) + substats.map(({ key, upgrades }) => [ + key, + getDiscSubStatBaseVal(key, rarity) * upgrades, + ]) ), [setKey]: 1, }, diff --git a/libs/zzz/ui/src/Disc/DiscCard.tsx b/libs/zzz/ui/src/Disc/DiscCard.tsx index 03b93f70e8..d8cfadc22e 100644 --- a/libs/zzz/ui/src/Disc/DiscCard.tsx +++ b/libs/zzz/ui/src/Disc/DiscCard.tsx @@ -9,7 +9,11 @@ import { type LocationKey, } from '@genshin-optimizer/sr/consts' import { StatIcon } from '@genshin-optimizer/sr/svgicons' -import { getDiscMainStatVal } from '@genshin-optimizer/zzz/consts' +import type { DiscRarityKey } from '@genshin-optimizer/zzz/consts' +import { + getDiscMainStatVal, + getDiscSubStatBaseVal, +} from '@genshin-optimizer/zzz/consts' import type { IDisc, ISubstat } from '@genshin-optimizer/zzz/db' import { DeleteForever, @@ -200,7 +204,11 @@ export function DiscCard({ {substats.map( (substat) => substat.key && ( - + ) )} @@ -274,10 +282,19 @@ export function DiscCard({ ) } -function SubstatDisplay({ substat }: { substat: ISubstat }) { - const { key, value } = substat - if (!value || !key) return null - const displayValue = toPercent(value, key).toFixed(statKeyToFixed(key)) +function SubstatDisplay({ + substat, + rarity, +}: { + substat: ISubstat + rarity: DiscRarityKey +}) { + const { key, upgrades } = substat + if (!upgrades || !key) return null + const displayValue = toPercent( + getDiscSubStatBaseVal(key, rarity) * upgrades, + key + ).toFixed(statKeyToFixed(key)) return ( {/* */} diff --git a/libs/zzz/ui/src/Disc/DiscEditor/DiscEditor.tsx b/libs/zzz/ui/src/Disc/DiscEditor/DiscEditor.tsx index b979e41244..09077bf99d 100644 --- a/libs/zzz/ui/src/Disc/DiscEditor/DiscEditor.tsx +++ b/libs/zzz/ui/src/Disc/DiscEditor/DiscEditor.tsx @@ -2,6 +2,7 @@ import { CardThemed, DropdownButton, ModalWrapper, + NextImage, } from '@genshin-optimizer/common/ui' import { range, @@ -18,16 +19,18 @@ import type { IDisc } from '@genshin-optimizer/zzz/db' import { validateDisc, type ICachedDisc, - type ICachedSubstat, type ISubstat, } from '@genshin-optimizer/zzz/db' import { useDatabaseContext } from '@genshin-optimizer/zzz/db-ui' +import type { Processed } from '@genshin-optimizer/zzz/disc-scanner' +import { ScanningQueue } from '@genshin-optimizer/zzz/disc-scanner' import AddIcon from '@mui/icons-material/Add' import ChevronRightIcon from '@mui/icons-material/ChevronRight' import CloseIcon from '@mui/icons-material/Close' import DeleteForeverIcon from '@mui/icons-material/DeleteForever' import LockIcon from '@mui/icons-material/Lock' import LockOpenIcon from '@mui/icons-material/LockOpen' +import PhotoCameraIcon from '@mui/icons-material/PhotoCamera' import ReplayIcon from '@mui/icons-material/Replay' import UpdateIcon from '@mui/icons-material/Update' import { @@ -36,8 +39,10 @@ import { ButtonGroup, CardContent, CardHeader, + CircularProgress, Grid, IconButton, + LinearProgress, MenuItem, Skeleton, TextField, @@ -45,19 +50,40 @@ import { useMediaQuery, useTheme, } from '@mui/material' -import type { MouseEvent } from 'react' -import { Suspense, useCallback, useEffect, useMemo, useReducer } from 'react' +import { Stack, styled } from '@mui/system' +import type { ChangeEvent, MouseEvent } from 'react' +import { + Suspense, + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'react' import { useTranslation } from 'react-i18next' +import { shouldShowDevComponents } from '../../util/isDev' import { DiscCard } from '../DiscCard' import { DiscMainStatDropdown } from '../DiscMainStatDropdown' import { DiscRarityDropdown } from '../DiscRarityDropdown' import { DiscSetAutocomplete } from '../DiscSetAutocomplete' +import { textsFromImage } from './ScanningUtil' import SubstatInput from './SubstatInput' + +// for pasting in screenshots +const InputInvis = styled('input')({ + display: 'none', +}) + interface DiscReducerState { disc: Partial validatedDisc?: IDisc } function reducer(state: DiscReducerState, action: Partial) { + if (!action || Object.keys(action).length === 0) + return { + disc: {} as Partial, + } const disc = { ...state.disc, ...action } const validatedDisc = validateDisc(disc) @@ -76,10 +102,11 @@ function useDiscValidation(discFromProp: Partial) { return { disc, validatedDisc, setDisc } } - export function DiscEditor({ disc: discFromProp, show, + allowUpload, + onShow, onClose, fixedSlotKey, allowEmpty = false, @@ -87,6 +114,8 @@ export function DiscEditor({ }: { disc: Partial show: boolean + allowUpload?: boolean + onShow: () => void onClose: () => void allowEmpty?: boolean disableSet?: boolean @@ -128,13 +157,13 @@ export function DiscEditor({ const reset = useCallback(() => { setDisc({}) - if (!allowEmpty) onClose() - }, [allowEmpty, onClose, setDisc]) + setScannedData(undefined) + }, [setDisc]) const setSubstat = useCallback( (index: number, substat?: ISubstat) => { const substats = [...(disc.substats || [])] - if (substat) substats[index] = substat as ICachedSubstat + if (substat) substats[index] = substat else substats.filter((_, i) => i !== index) setDisc({ substats }) }, @@ -161,6 +190,80 @@ export function DiscEditor({ const canClearDisc = (): boolean => window.confirm(t('editor.clearPrompt') as string) + // Scanning stuff + const queueRef = useRef( + new ScanningQueue(textsFromImage, shouldShowDevComponents) + ) + const queue = queueRef.current + const [{ processedNum, outstandingNum, scanningNum }, setScanningData] = + useState({ processedNum: 0, outstandingNum: 0, scanningNum: 0 }) + + const [scannedData, setScannedData] = useState( + undefined as undefined | Omit + ) + + const { fileName, imageURL, debugImgs, texts } = scannedData ?? {} + const queueTotal = processedNum + outstandingNum + scanningNum + + const uploadFiles = useCallback( + (files?: FileList | null) => { + if (!files) return + onShow() + queue.addFiles(Array.from(files).map((f) => ({ f, fName: f.name }))) + }, + [onShow, queue] + ) + const clearQueue = useCallback(() => { + queue.clearQueue() + }, [queue]) + + const onUpload = useCallback( + (e: ChangeEvent) => { + if (!e.target) return + uploadFiles(e.target.files) + e.target.value = '' // reset the value so the same file can be uploaded again... + }, + [uploadFiles] + ) + + // When there is scanned artifacts and no artifact in editor, put latest scanned artifact in editor + useEffect(() => { + if (!processedNum || scannedData) return + const processed = queue.shiftProcessed() + if (!processed) return + const { disc: scannedDisc, ...rest } = processed + setScannedData(rest) + setDisc((scannedDisc ?? {}) as Partial) + }, [queue, processedNum, scannedData, setDisc]) + + useEffect(() => { + const pasteFunc = (e: Event) => { + // Don't handle paste if targetting the edit team modal + const target = e.target as HTMLElement + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement + ) { + return + } + + uploadFiles((e as ClipboardEvent).clipboardData?.files) + } + + allowUpload && window.addEventListener('paste', pasteFunc) + return () => { + if (allowUpload) window.removeEventListener('paste', pasteFunc) + } + }, [uploadFiles, allowUpload]) + + // register callback to scanning queue + useEffect(() => { + queue.callback = setScanningData + return () => { + queue.callback = () => {} + } + }, [queue]) + return ( @@ -311,6 +414,100 @@ export function DiscEditor({ locKey={cDisc?.location ?? ''} setLocKey={(charKey) => setDisc({ location: charKey })} /> */} + {/* Image OCR */} + {allowUpload && ( + + + {/* TODO: artifactDispatch not overwrite */} + } + > + + + + + {shouldShowDevComponents && debugImgs && ( + + + + )} + {/* + + */} + + + {imageURL && ( + + + + )} + {!!queueTotal && ( + + )} + {!!queueTotal && ( + + + {!!scanningNum && } + + + Screenshots in file-queue: + {queueTotal} + {/* {process.env.NODE_ENV === "development" && ` (Debug: Processed ${processed.length}/${maxProcessedCount}, Processing: ${outstanding.filter(entry => entry.result).length}/${maxProcessingCount}, Outstanding: ${outstanding.length})`} */} + + + + + + + )} + + + + )} {/* right column */} @@ -318,12 +515,22 @@ export function DiscEditor({ {/* substat selections */} {[0, 1, 2, 3].map((index) => ( ))} + {!!texts?.length && ( + + + {texts.map((text, i) => ( + {text} + ))} + + + )} @@ -455,3 +662,34 @@ export function DiscEditor({ ) } + +function DebugModal({ imgs }: { imgs: Record }) { + const [show, setshow] = useState(false) + const onOpen = () => setshow(true) + const onClose = () => setshow(false) + return ( + <> + + + + + + {Object.entries(imgs).map(([key, url]) => ( + + {key} + + + ))} + + + + + + ) +} diff --git a/libs/zzz/ui/src/Disc/DiscEditor/ScanningUtil.tsx b/libs/zzz/ui/src/Disc/DiscEditor/ScanningUtil.tsx new file mode 100644 index 0000000000..cd170be2f1 --- /dev/null +++ b/libs/zzz/ui/src/Disc/DiscEditor/ScanningUtil.tsx @@ -0,0 +1,39 @@ +import { imageDataToCanvas } from '@genshin-optimizer/common/img-util' +import { BorrowManager } from '@genshin-optimizer/common/util' +import type { RecognizeResult, Scheduler } from 'tesseract.js' +import { createScheduler, createWorker } from 'tesseract.js' + +const workerCount = 2 + +const schedulers = new BorrowManager( + async (language): Promise => { + const scheduler = createScheduler() + const promises = Array(workerCount) + .fill(0) + .map(async (_) => { + const worker = await createWorker(language) + scheduler.addWorker(worker) + }) + + await Promise.any(promises) + return scheduler + }, + (_language, value) => { + value.then((value) => value.terminate()) + } +) + +export async function textsFromImage( + imageData: ImageData, + options: object | undefined = undefined +): Promise { + const canvas = imageDataToCanvas(imageData) + const rec = await schedulers.borrow( + 'eng', + async (scheduler) => + (await ( + await scheduler + ).addJob('recognize', canvas, options)) as RecognizeResult + ) + return rec.data.lines.map((line) => line.text) +} diff --git a/libs/zzz/ui/src/Disc/DiscEditor/SubstatInput.tsx b/libs/zzz/ui/src/Disc/DiscEditor/SubstatInput.tsx index 4acda6b348..3dea3052cf 100644 --- a/libs/zzz/ui/src/Disc/DiscEditor/SubstatInput.tsx +++ b/libs/zzz/ui/src/Disc/DiscEditor/SubstatInput.tsx @@ -1,249 +1,149 @@ -import { - CardThemed, - CustomNumberInput, - CustomNumberInputButtonGroupWrapper, - DropdownButton, - SqBadge, - TextButton, -} from '@genshin-optimizer/common/ui' -import { - getUnitStr, - roundStat, - toPercent, -} from '@genshin-optimizer/common/util' -import type { DiscSubStatKey } from '@genshin-optimizer/zzz/consts' +import { CardThemed, DropdownButton } from '@genshin-optimizer/common/ui' +import { getUnitStr, range, valueString } from '@genshin-optimizer/common/util' +import type { DiscRarityKey } from '@genshin-optimizer/zzz/consts' import { allDiscSubStatKeys, discSubstatRollData, + getDiscSubStatBaseVal, } from '@genshin-optimizer/zzz/consts' import type { ICachedDisc, ISubstat } from '@genshin-optimizer/zzz/db' +import type { SliderProps } from '@mui/material' import { - Box, - ButtonGroup, - Grid, ListItemText, MenuItem, Slider, + Stack, + Typography, } from '@mui/material' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { StatDisplay } from '../../Character' -// TODO: validation, roll table, roll values, efficiency, display text, icons, ... +// TODO: validation, roll validation across the disc, display text, icons, ... export default function SubstatInput({ + rarity, index, disc, setSubstat, }: { + rarity: DiscRarityKey index: number disc: Partial setSubstat: (index: number, substat?: ISubstat) => void }) { const { t } = useTranslation('disc') const { mainStatKey = '' } = disc ?? {} - const { key = '', value = 0, rolls = 0 } = disc?.substats?.[index] ?? {} - // const accurateValue = rolls.reduce((a, b) => a + b, 0) - const rollNum = rolls + const { key, upgrades = 0 } = disc?.substats?.[index] ?? {} - let error = '', - rollData = 0, - allowedRolls = 0 + // let error = '', + // allowedRolls = 0 - if (disc?.rarity) { - // Account for the rolls it will need to fill all 4 substates, +1 for its base roll - const rarity = disc.rarity - const { numUpgrades, high } = discSubstatRollData[rarity] - const maxRollNum = numUpgrades + high - 3 - allowedRolls = maxRollNum - rollNum - rollData = 0 //key ? getSubstatValuesPercent(key, rarity) : [] - } + // if (disc?.rarity) { + // // Account for the rolls it will need to fill all 4 substates, +1 for its base roll + // const rarity = disc.rarity + // const { numUpgrades, high } = discSubstatRollData[rarity] + // const maxRollNum = numUpgrades + high - 3 + // allowedRolls = maxRollNum - value + // } - // if (!rollNum && key && value) error = error || t('editor.substat.error.noCalc') - if (allowedRolls < 0) - error = - error || - t('editor.substat.error.noOverRoll', { value: allowedRolls + rollNum }) + // if (allowedRolls < 0) + // error = + // error || + // t('editor.substat.error.noOverRoll', { value: allowedRolls + value }) const marks = useMemo( () => - key - ? [ - { value: 0 }, - //...getSubstatSummedRolls(rarity, key).map((v) => ({ value: v })), - ] - : [{ value: 0 }], - [key] + range(1, discSubstatRollData[rarity].numUpgrades).map((i) => ({ + value: i, + })), + [rarity] ) - return ( - - - - : undefined} - title={ - key ? ( - - ) : ( - t('editor.substat.substatFormat', { value: index + 1 }) - ) - } - disabled={!disc?.mainStatKey} - color={key ? 'success' : 'primary'} - sx={{ whiteSpace: 'nowrap' }} - > - {key && ( - setSubstat(index)}> - {t('editor.substat.noSubstat')} - - )} - {allDiscSubStatKeys - .filter((key) => mainStatKey !== key) - .map((k) => ( - setSubstat(index, { key: k, value: 0 })} - > - {/* + + : undefined} + title={ + key ? ( + + ) : ( + t('editor.substat.substatFormat', { value: index + 1 }) + ) + } + disabled={!disc?.mainStatKey} + color={key ? 'success' : 'primary'} + sx={{ whiteSpace: 'nowrap' }} + > + {key && ( + setSubstat(index)}> + {t('editor.substat.noSubstat')} + + )} + {allDiscSubStatKeys + .filter((key) => mainStatKey !== key) + .map((k) => ( + setSubstat(index, { key: k, upgrades: 0 })} + > + {/* */} - - - - - ))} - - - { - let value = (v as number) ?? 0 - if (getUnitStr(key) === '%') { - value = value / 100 - } - key && setSubstat(index, { key, value }) - }} - disabled={!key} - error={!!error} - sx={{ - px: 1, - }} - inputProps={{ - sx: { textAlign: 'right' }, - }} - /> - - {!!rollData && ( - {t('editor.substat.nextRolls')} - )} - {/* {rollData.map((v, i) => { - let newValue = artDisplayValue(accurateValue + v, unit) - newValue = - allStats.art.subRollCorrection[rarity]?.[key]?.[newValue] ?? - newValue - return ( - - ) - })} */} - - - + + + + + ))} + + { - let value = (v as number) ?? 0 - if (getUnitStr(key) === '%') { - value = value / 100 - } - key && setSubstat(index, { key, value }) + key && setSubstat(index, { key, upgrades: v }) }} disabled={!key} + valueLabelFormat={(v) => + `${ + key && + valueString( + v * getDiscSubStatBaseVal(key, rarity), + getUnitStr(key) + ) + }` + } /> - - - {error ? ( - {t('ui:error')} - ) : ( - - {/* - - {rollNum - ? t('editor.substat.RollCount', { count: rollNum }) - : t('editor.substat.noRoll')} - - */} - {/* - {!!rolls.length && - [...rolls].sort().map((val, i) => ( - - {artDisplayValue(val, unit)} - {val} - - ))} - */} - {/* - - - {'Efficiency: '} - - {efficiency} - - - */} - - )} - - + + {key && ( + + + {valueString( + upgrades * getDiscSubStatBaseVal(key, rarity), + getUnitStr(key) + )} + + + )} + ) } function SliderWrapper({ @@ -251,26 +151,28 @@ function SliderWrapper({ setValue, marks, disabled = false, + valueLabelFormat, }: { value: number setValue: (v: number) => void marks: Array<{ value: number }> disabled: boolean + valueLabelFormat: SliderProps['valueLabelFormat'] }) { const [innerValue, setinnerValue] = useState(value) useEffect(() => setinnerValue(value), [value]) return ( setinnerValue(v as number)} onChangeCommitted={(_e, v) => setValue(v as number)} valueLabelDisplay="auto" + valueLabelFormat={valueLabelFormat} /> ) } diff --git a/libs/zzz/ui/src/Disc/DiscMainStatDropdown.tsx b/libs/zzz/ui/src/Disc/DiscMainStatDropdown.tsx index e92a8d91fc..91a85dbd75 100644 --- a/libs/zzz/ui/src/Disc/DiscMainStatDropdown.tsx +++ b/libs/zzz/ui/src/Disc/DiscMainStatDropdown.tsx @@ -18,12 +18,12 @@ export function DiscMainStatDropdown({ dropdownButtonProps = {}, }: { statKey?: DiscMainStatKey - slotKey: DiscSlotKey + slotKey?: DiscSlotKey setStatKey: (statKey: DiscMainStatKey) => void defText?: ReactNode dropdownButtonProps?: Omit }) { - if (statKey && slotKey in ['1', '2', '3']) + if (statKey && slotKey && ['1', '2', '3'].includes(slotKey)) return ( @@ -43,19 +43,20 @@ export function DiscMainStatDropdown({ } {...dropdownButtonProps} > - {discSlotToMainStatKeys[slotKey].map((mk) => ( - setStatKey(mk)} - > - - - - - - ))} + {slotKey && + discSlotToMainStatKeys[slotKey].map((mk) => ( + setStatKey(mk)} + > + + + + + + ))} ) } diff --git a/libs/zzz/ui/src/util/isDev.ts b/libs/zzz/ui/src/util/isDev.ts new file mode 100644 index 0000000000..32151c4af4 --- /dev/null +++ b/libs/zzz/ui/src/util/isDev.ts @@ -0,0 +1,7 @@ +export const isDev = process.env['NODE_ENV'] === 'development' + +/** + * Boolean indicating if dev components should be shown + */ +export const shouldShowDevComponents = + isDev || process.env['NX_SHOW_DEV_COMPONENTS'] === 'true' diff --git a/tsconfig.base.json b/tsconfig.base.json index 758bf590ab..ecb639c38e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -155,6 +155,9 @@ "@genshin-optimizer/zzz/consts": ["libs/zzz/consts/src/index.ts"], "@genshin-optimizer/zzz/db": ["libs/zzz/db/src/index.ts"], "@genshin-optimizer/zzz/db-ui": ["libs/zzz/db-ui/src/index.ts"], + "@genshin-optimizer/zzz/disc-scanner": [ + "libs/zzz/disc-scanner/src/index.ts" + ], "@genshin-optimizer/zzz/page-discs": [ "libs/zzz/page-discs/src/index.tsx" ],