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 (
+
+ );
+}
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",