From ad1a8b5b2e3b97b8cc116c3bc52ee7a7c5f22810 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Fri, 20 Jan 2023 21:57:11 +0200 Subject: [PATCH] Build Analyzer: Comparison initial --- app/components/Tabs.tsx | 42 +++ app/features/build-analyzer/analyzer-hooks.ts | 66 ++++- app/features/build-analyzer/analyzer.css | 5 + .../build-analyzer/routes/analyzer.tsx | 257 +++++++++++++----- app/styles/common.css | 2 +- public/locales/en/analyzer.json | 2 + 6 files changed, 295 insertions(+), 79 deletions(-) create mode 100644 app/components/Tabs.tsx diff --git a/app/components/Tabs.tsx b/app/components/Tabs.tsx new file mode 100644 index 000000000..dd20d168f --- /dev/null +++ b/app/components/Tabs.tsx @@ -0,0 +1,42 @@ +import clsx from "clsx"; +import type * as React from "react"; + +// shares styles with SubNav.tsx + +export function Tabs({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
{children}
+ ); +} + +export function Tab({ + children, + className, + active, + onClick, +}: { + children: React.ReactNode; + className?: string; + active: boolean; + onClick: () => void; +}) { + // TODO: improve semantic html here, maybe could use tab component from Headless UI? + return ( +
+
{children}
+
+
+ ); +} diff --git a/app/features/build-analyzer/analyzer-hooks.ts b/app/features/build-analyzer/analyzer-hooks.ts index 402874f07..0303a0745 100644 --- a/app/features/build-analyzer/analyzer-hooks.ts +++ b/app/features/build-analyzer/analyzer-hooks.ts @@ -25,61 +25,92 @@ export function useAnalyzeBuild() { const mainWeaponId = validatedWeaponIdFromSearchParams(searchParams); const build = validatedBuildFromSearchParams(searchParams); + const build2 = validatedBuildFromSearchParams(searchParams, "build2"); const ldeIntensity = validatedLdeIntensityFromSearchParams(searchParams); const effects = validatedEffectsFromSearchParams({ searchParams, build }); + const effects2 = validatedEffectsFromSearchParams({ + searchParams, + build: build2, + }); + const focused = validatedFocusedFromSearchParams({ searchParams }); const handleChange = ({ newMainWeaponId = mainWeaponId, newBuild = build, + newBuild2 = build2, newLdeIntensity = ldeIntensity, newEffects = effects, + newFocused = focused, }: { newMainWeaponId?: MainWeaponId; newBuild?: BuildAbilitiesTupleWithUnknown; + newBuild2?: BuildAbilitiesTupleWithUnknown; newLdeIntensity?: number; newEffects?: Array; + newFocused?: 1 | 2; }) => { setSearchParams( { weapon: String(newMainWeaponId), build: serializeBuild(newBuild), + build2: serializeBuild(newBuild2), lde: String(newLdeIntensity), effect: newEffects, + focused: String(newFocused), }, { replace: true, state: { scroll: false } } ); }; - const buildsAbilityPoints = buildToAbilityPoints(build); - + const buildAbilityPoints = buildToAbilityPoints(build); const abilityPoints = applySpecialEffects({ - abilityPoints: buildsAbilityPoints, + abilityPoints: buildAbilityPoints, effects, ldeIntensity, }); - const analyzed = buildStats({ abilityPoints, weaponSplId: mainWeaponId, mainOnlyAbilities: build .map((row) => row[0]) - .filter((ability): ability is Ability => { - const abilityObj = abilities.find((a) => a.name === ability); - return Boolean(abilityObj && abilityObj.type !== "STACKABLE"); - }), + .filter(filterMainOnlyAbilities), + }); + + const buildAbilityPoints2 = buildToAbilityPoints(build2); + const abilityPoints2 = applySpecialEffects({ + abilityPoints: buildAbilityPoints2, + effects: effects2, + ldeIntensity, + }); + const analyzed2 = buildStats({ + abilityPoints: abilityPoints2, + weaponSplId: mainWeaponId, + mainOnlyAbilities: build2 + .map((row) => row[0]) + .filter(filterMainOnlyAbilities), }); return { build, + build2, + focused, mainWeaponId, handleChange, analyzed, + analyzed2, abilityPoints, effects, ldeIntensity, }; } +function filterMainOnlyAbilities( + ability: AbilityWithUnknown +): ability is Ability { + const abilityObj = abilities.find((a) => a.name === ability); + return Boolean(abilityObj && abilityObj.type !== "STACKABLE"); +} + function serializeBuild(build: BuildAbilitiesTupleWithUnknown) { return build .flat() @@ -88,10 +119,11 @@ function serializeBuild(build: BuildAbilitiesTupleWithUnknown) { } function validatedBuildFromSearchParams( - searchParams: URLSearchParams + searchParams: URLSearchParams, + key = "build" ): BuildAbilitiesTupleWithUnknown { - const abilitiesArr = searchParams.get("build") - ? searchParams.get("build")?.split(",") + const abilitiesArr = searchParams.get(key) + ? searchParams.get(key)?.split(",") : null; if (!abilitiesArr) return EMPTY_BUILD; @@ -188,3 +220,15 @@ function validatedEffectsFromSearchParams({ return result; } + +function validatedFocusedFromSearchParams({ + searchParams, +}: { + searchParams: URLSearchParams; +}) { + const focused = searchParams.get("focused"); + + if (focused === "2") return 2; + + return 1; +} diff --git a/app/features/build-analyzer/analyzer.css b/app/features/build-analyzer/analyzer.css index 1c8d88fce..938d4fb07 100644 --- a/app/features/build-analyzer/analyzer.css +++ b/app/features/build-analyzer/analyzer.css @@ -152,6 +152,7 @@ display: flex; flex-direction: column; align-items: center; + justify-content: center; } .analyzer__stat-card__value__title { @@ -194,6 +195,10 @@ margin-block-start: var(--s-3); } +.analyzer__sub-nav { + margin-block-end: var(--s-4); +} + .analyzer__patch { border-radius: var(--rounded); background-color: var(--theme-transparent); diff --git a/app/features/build-analyzer/routes/analyzer.tsx b/app/features/build-analyzer/routes/analyzer.tsx index da4e4cc12..c67fb06b8 100644 --- a/app/features/build-analyzer/routes/analyzer.tsx +++ b/app/features/build-analyzer/routes/analyzer.tsx @@ -58,6 +58,7 @@ import { MAX_LDE_INTENSITY, } from "../analyzer-constants"; import { useAnalyzeBuild } from "../analyzer-hooks"; +import { Tabs, Tab } from "~/components/Tabs"; export const CURRENT_PATCH = "2.0"; @@ -83,33 +84,47 @@ export const handle: SendouRouteHandle = { // Resolves this Github issue: https://github.com/Sendouc/sendou.ink/issues/1053 export const unstable_shouldReload: ShouldReloadFunction = () => false; +// xxx: for comparison AP bar charts show green if the better value (except for unknown), green can be on both sides too export default function BuildAnalyzerPage() { const { t } = useTranslation(["analyzer", "common", "weapons"]); useSetTitle(t("common:pages.analyzer")); const { build, + build2, mainWeaponId, handleChange, analyzed, + analyzed2, + focused, abilityPoints, ldeIntensity, effects, } = useAnalyzeBuild(); + const statKeyToTuple = (key: keyof AnalyzedBuild["stats"]) => { + return [analyzed.stats[key], analyzed2.stats[key]] as [Stat, Stat]; + }; + const objectShredderSelected = build[2][0] === "OS"; + const isComparing = + build.flat().some((ability) => ability !== "UNKNOWN") && + build2.flat().some((ability) => ability !== "UNKNOWN"); + const mainWeaponCategoryItems = [ analyzed.stats.shotSpreadAir && ( ), typeof analyzed.stats.shotSpreadGround === "number" && (
- handleChange({ newBuild })} - /> +
+ + handleChange({ newFocused: 1 })} + > + {t("analyzer:build1")} + + handleChange({ newFocused: 2 })} + > + {t("analyzer:build2")} + + + { + handleChange({ + [focused === 1 ? "newBuild" : "newBuild2"]: newBuild, + }); + }} + /> +
handleChange({ newLdeIntensity }) @@ -203,6 +243,7 @@ export default function BuildAnalyzerPage() { } effects={effects} /> + {/* xxx: rework these - maybe 3rd tab "Comparison" */} {abilityPoints.size > 0 && ( )} @@ -249,12 +290,14 @@ export default function BuildAnalyzerPage() { } > {analyzed.stats.subVelocity && ( )} {analyzed.stats.subFirstPhaseDuration && ( )} {analyzed.stats.subSecondPhaseDuration && ( )} {analyzed.stats.subMarkingTimeInSeconds && ( )} {analyzed.stats.subMarkingRadius && ( )} {analyzed.stats.subExplosionRadius && ( )} {analyzed.stats.subHp && ( )} {analyzed.stats.subQsjBoost && ( @@ -342,27 +393,31 @@ export default function BuildAnalyzerPage() { } > {analyzed.stats.specialDurationInSeconds && ( ) : null} + {/* xxx: handle comparison */} {analyzed.stats.shootingRunSpeed && ( )} {analyzed.stats.shootingRunSpeedCharging && ( )} {analyzed.stats.shootingRunSpeedFullCharge && ( )} @@ -693,37 +784,43 @@ export default function BuildAnalyzerPage() { @@ -750,6 +847,7 @@ export default function BuildAnalyzerPage() { function EffectsSelector({ build, + build2, effects, ldeIntensity, handleLdeIntensityChange, @@ -757,6 +855,7 @@ function EffectsSelector({ handleRemoveEffect, }: { build: BuildAbilitiesTupleWithUnknown; + build2: BuildAbilitiesTupleWithUnknown; effects: Array; ldeIntensity: number; handleLdeIntensityChange: (newLdeIntensity: number) => void; @@ -766,7 +865,10 @@ function EffectsSelector({ const { t } = useTranslation(["weapons", "analyzer"]); const effectsToShow = SPECIAL_EFFECTS.filter( - (effect) => !isAbility(effect.type) || build.flat().includes(effect.type) + (effect) => + !isAbility(effect.type) || + build.flat().includes(effect.type) || + build2.flat().includes(effect.type) ).reverse(); // reverse to show Tacticooler first as it always shows return ( @@ -925,36 +1027,51 @@ function StatCard({ suffix, popoverInfo, abilityPoints, + isComparing, }: { title: string; - stat: Stat | Stat | number | string; + stat: [Stat, Stat] | [Stat, Stat] | number | string; suffix?: string; popoverInfo?: string; abilityPoints: AbilityPoints; + isComparing: boolean; }) { const { t } = useTranslation("analyzer"); const isStaticValue = typeof stat === "number" || typeof stat === "string"; - const baseValue = isStaticValue ? stat : stat.baseValue; + const baseValue = isStaticValue ? stat : stat[0].baseValue; + const showBuildValue = () => { if (isStaticValue) return false; + if (isComparing) return true; // slightly hacky but handles the edge case // where baseValue === value which can happen when // you have Ninja Squid and stack swim speed // -> we still want to show the build value - return [stat.modifiedBy].flat().some((ability) => { + return [stat[0].modifiedBy].flat().some((ability) => { const hasStackable = (abilityPoints.get(ability)?.ap ?? 0) > 0; - const hasEffect = stat.baseValue !== stat.value; + const hasEffect = + baseValue !== stat[0].value && baseValue !== stat[1].value; return hasEffect || hasStackable; }); }; + const showComparison = isComparing && !isStaticValue; + + const isHighlighted = () => { + if (!showComparison) return showBuildValue(); + + return ( + stat[0].value !== stat[0].baseValue || stat[1].value !== stat[1].baseValue + ); + }; + return (
@@ -973,20 +1090,24 @@ function StatCard({

- {typeof stat === "number" ? t("value") : t("base")} + {typeof stat === "number" + ? t("value") + : showComparison + ? t("build1") + : t("base")}

{" "}
- {baseValue} + {showComparison ? (stat as [Stat, Stat])[0].value : baseValue} {suffix}
{showBuildValue() ? (

- {t("build")} + {showComparison ? t("build2") : t("build")}

{" "}
- {(stat as Stat).value} + {(stat as [Stat, Stat])[showComparison ? 1 : 0].value} {suffix}
@@ -994,7 +1115,9 @@ function StatCard({
- {!isStaticValue && } + {!isStaticValue && ( + + )}
); diff --git a/app/styles/common.css b/app/styles/common.css index 92a183478..e0d8f69fa 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -495,8 +495,8 @@ dialog::backdrop { padding: var(--s-1) var(--s-2); border-radius: var(--rounded); background-color: var(--bg-lightest); + cursor: pointer; font-size: var(--fonts-xs); - font-weight: var(--semi-bold); text-align: center; white-space: nowrap; } diff --git a/public/locales/en/analyzer.json b/public/locales/en/analyzer.json index a7627d4b5..824846182 100644 --- a/public/locales/en/analyzer.json +++ b/public/locales/en/analyzer.json @@ -103,6 +103,8 @@ "base": "Base", "value": "Value", "build": "Build", + "build1": "Build 1", + "build2": "Build 2", "patch": "Patch:", "abilityPoints": "Ability points", "abilityPoints.short": "AP",