From eb28accd9781040f7c67be1afbee34fd9315444a Mon Sep 17 00:00:00 2001
From: Kalle <38327916+Sendouc@users.noreply.github.com>
Date: Sun, 1 Oct 2023 12:20:48 +0300
Subject: [PATCH] Build Analyzer visualizations (#1517)
* Initial
* Pass mainWeaponId
* Repeating args to "context"
* Fix todos
* Date header
* Fix comparison showing for sub weapon def dmg even if no abilities selected
* Sub damage table charts
* Chart dot focus style
* Color grid initial
* Can change shots in the grid
* Optimize perf
* Style - cells
* Grid hover effect
* Mobile friendly
---
app/components/Ability.tsx | 1 +
app/components/Chart.tsx | 97 +++-
app/components/Table.tsx | 3 +
app/components/icons/Beaker.tsx | 18 +
app/features/build-analyzer/analyzer.css | 75 +++
.../components/PerInkTankGrid.tsx | 328 ++++++++++++
app/features/build-analyzer/core/stats.ts | 2 +-
app/features/build-analyzer/core/utils.ts | 34 +-
.../build-analyzer/routes/analyzer.tsx | 504 ++++++++++++------
app/modules/auth/DiscordStrategy.server.ts | 2 -
app/routes/calendar/$id/index.tsx | 5 +-
.../results/components/UserResultsTable.tsx | 5 +-
app/routes/u.$identifier/seasons.tsx | 2 +-
app/styles/common.css | 43 +-
app/styles/utils.css | 4 +
app/styles/vars.css | 2 +
public/locales/en/analyzer.json | 4 +-
17 files changed, 944 insertions(+), 185 deletions(-)
create mode 100644 app/components/Table.tsx
create mode 100644 app/components/icons/Beaker.tsx
create mode 100644 app/features/build-analyzer/components/PerInkTankGrid.tsx
diff --git a/app/components/Ability.tsx b/app/components/Ability.tsx
index 4a40215ac..c63fc59d4 100644
--- a/app/components/Ability.tsx
+++ b/app/components/Ability.tsx
@@ -8,6 +8,7 @@ import { useTranslation } from "~/hooks/useTranslation";
const sizeMap = {
MAIN: 42,
SUB: 32,
+ SUBTINY: 26,
TINY: 22,
} as const;
diff --git a/app/components/Chart.tsx b/app/components/Chart.tsx
index cf5f7db50..167be83f4 100644
--- a/app/components/Chart.tsx
+++ b/app/components/Chart.tsx
@@ -1,14 +1,23 @@
-import React from "react";
+import clsx from "clsx";
+import * as React from "react";
import { type AxisOptions, Chart as ReactChart } from "react-charts";
+import type { TooltipRendererProps } from "react-charts/types/components/TooltipRenderer";
+import { useTranslation } from "~/hooks/useTranslation";
import { useIsMounted } from "~/hooks/useIsMounted";
import { Theme, useTheme } from "~/modules/theme";
export default function Chart({
options,
+ containerClassName,
+ headerSuffix,
+ valueSuffix,
}: {
options: [
{ label: string; data: Array<{ primary: Date; secondary: number }> },
];
+ containerClassName?: string;
+ headerSuffix?: string;
+ valueSuffix?: string;
}) {
const theme = useTheme();
const isMounted = useIsMounted();
@@ -33,19 +42,99 @@ export default function Chart({
[],
);
- if (!isMounted) return
;
+ if (!isMounted) {
+ return ;
+ }
return (
-
+
(
+
+ ),
+ },
primaryAxis,
secondaryAxes,
dark: theme.htmlThemeClass === Theme.DARK,
- defaultColors: ["var(--theme)"],
+ defaultColors: [
+ "var(--theme)",
+ "var(--theme-secondary)",
+ "var(--theme-info)",
+ ],
}}
/>
);
}
+
+interface ChartTooltipProps extends TooltipRendererProps
{
+ headerSuffix?: string;
+ valueSuffix?: string;
+}
+
+function ChartTooltip({
+ focusedDatum,
+ headerSuffix = "",
+ valueSuffix = "",
+}: ChartTooltipProps) {
+ const { i18n } = useTranslation();
+ const dataPoints = focusedDatum?.interactiveGroup ?? [];
+
+ const header = () => {
+ const primaryValue = dataPoints[0]?.primaryValue;
+ if (!primaryValue) return null;
+
+ if (primaryValue instanceof Date) {
+ return primaryValue.toLocaleDateString(i18n.language, {
+ weekday: "short",
+ day: "numeric",
+ month: "long",
+ });
+ }
+
+ return primaryValue;
+ };
+
+ return (
+
+
+ {header()}
+ {headerSuffix}
+
+ {dataPoints.map((dataPoint, index) => {
+ const color = dataPoint.style?.fill ?? "var(--theme)";
+
+ return (
+
+
+
+ {dataPoint.originalSeries.label}
+
+
+ {dataPoint.secondaryValue}
+ {valueSuffix}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/app/components/Table.tsx b/app/components/Table.tsx
new file mode 100644
index 000000000..756b392bd
--- /dev/null
+++ b/app/components/Table.tsx
@@ -0,0 +1,3 @@
+export function Table({ children }: { children: React.ReactNode }) {
+ return ;
+}
diff --git a/app/components/icons/Beaker.tsx b/app/components/icons/Beaker.tsx
new file mode 100644
index 000000000..5e311bca0
--- /dev/null
+++ b/app/components/icons/Beaker.tsx
@@ -0,0 +1,18 @@
+export function BeakerIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/app/features/build-analyzer/analyzer.css b/app/features/build-analyzer/analyzer.css
index 277257cd2..f3386eaeb 100644
--- a/app/features/build-analyzer/analyzer.css
+++ b/app/features/build-analyzer/analyzer.css
@@ -73,6 +73,16 @@
padding-inline: var(--s-2);
}
+.analyzer__stat-popover-trigger {
+ border-radius: 100%;
+ padding: 2px;
+}
+
+.analyzer__stat-popover-trigger__icon {
+ width: 16px;
+ stroke-width: 2;
+}
+
.analyzer__ap-text {
color: var(--text-lighter);
font-size: var(--fonts-xxs);
@@ -161,6 +171,9 @@
.analyzer__stat-card__ability-container {
min-height: 22px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
}
.analyzer__stat-card__popover {
@@ -256,3 +269,65 @@
position: initial;
}
}
+
+.analyzer__stat-popover {
+ min-width: 360px;
+
+ --chart-bg: transparent;
+ --chart-height: 250px;
+ --chart-width: 340px;
+}
+
+@media screen and (min-width: 700px) {
+ .analyzer__stat-popover {
+ min-width: calc(360px * 1.75);
+ --chart-height: calc(250px * 1.75);
+ --chart-width: calc(340px * 1.75);
+ }
+}
+
+.analyzer__ink-grid__container {
+ overflow: auto;
+ min-width: min(100vw, 1100px);
+}
+
+.analyzer__ink-grid {
+ display: grid;
+ grid-template-columns: repeat(41, 1fr);
+}
+
+.analyzer__ink-grid__vertical-ability {
+ grid-row: span 41;
+ display: grid;
+ place-items: center;
+ padding-inline-end: var(--s-2);
+}
+
+.analyzer__ink-grid__horizontal-ability {
+ grid-column: span 41;
+ display: grid;
+ place-items: center;
+ padding-block-end: var(--s-2);
+}
+
+.analyzer__ink-grid__ap {
+ display: grid;
+ place-items: center;
+ font-weight: var(--semi-bold);
+ font-size: var(--fonts-xs);
+}
+
+.analyzer__ink-grid__ap__focused {
+ color: var(--theme-secondary);
+ font-weight: var(--bold);
+ text-decoration: underline;
+}
+
+.analyzer__ink-grid__cell {
+ width: 25px;
+ font-size: var(--fonts-xs);
+ display: grid;
+ place-items: center;
+ background-color: var(--cell-color);
+ cursor: help;
+}
diff --git a/app/features/build-analyzer/components/PerInkTankGrid.tsx b/app/features/build-analyzer/components/PerInkTankGrid.tsx
new file mode 100644
index 000000000..d2e180dbb
--- /dev/null
+++ b/app/features/build-analyzer/components/PerInkTankGrid.tsx
@@ -0,0 +1,328 @@
+import { Ability } from "~/components/Ability";
+import { Popover } from "~/components/Popover";
+import { MAX_AP } from "~/constants";
+import type { MainWeaponId } from "~/modules/in-game-lists";
+import { fullInkTankOptions } from "../core/stats";
+import * as React from "react";
+import { useTranslation } from "~/hooks/useTranslation";
+import { weaponParams } from "../core/utils";
+import clsx from "clsx";
+
+interface PerInkTankGridProps {
+ weaponSplId: MainWeaponId;
+}
+
+export function PerInkTankGrid(props: PerInkTankGridProps) {
+ return (
+ Show consumption grid>}
+ contentClassName="analyzer__ink-grid__container"
+ >
+
+
+ );
+}
+
+// if adding to this, also update analyzer.css
+const AP_VALUES_TO_SHOW = [
+ 0, 3, 6, 9, 10, 12, 13, 15, 16, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28,
+ 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 41, 42, 44, 45, 47, 48, 51, 54,
+ 57,
+];
+
+function Grid({ weaponSplId }: PerInkTankGridProps) {
+ const { t } = useTranslation(["weapons", "analyzer"]);
+ const [subsUsed, setSubsUsed] = React.useState(1);
+ const [ismHovered, setIsmHovered] = React.useState();
+ const [issHovered, setIssHovered] = React.useState();
+
+ const values = React.useMemo(
+ () => calculateGrid({ weaponSplId, subsUsed }),
+ [weaponSplId, subsUsed],
+ );
+
+ const handleHover = ({ ismAP, issAP }: { ismAP: number; issAP: number }) => {
+ setIsmHovered(ismAP);
+ setIssHovered(issAP);
+ };
+
+ const handleMouseLeaveGrid = () => {
+ setIsmHovered(undefined);
+ setIssHovered(undefined);
+ };
+
+ return (
+
+
+ {t("analyzer:perInkTankGrid.header", {
+ weapon: t(`weapons:MAIN_${weaponSplId}`),
+ count: subsUsed,
+ })}
+
+
+ {[0, 1, 2].map((subsUsedOption) => {
+ const id = String(subsUsedOption);
+ return (
+
+ setSubsUsed(Number(e.target.value))}
+ />
+
+
+ );
+ })}
+
+
+
+
+
+
+ {AP_VALUES_TO_SHOW.map((ap) => (
+
+ {ap}
+
+ ))}
+ {values.map((row, i) =>
+ [
+
+ {AP_VALUES_TO_SHOW[i]}
+
,
+ ].concat(
+ row.map((cell, j) => {
+ const key = `${i}-${j}`;
+
+ if (cell === "N/A") {
+ return
;
+ }
+
+ const title = `${cell.shots ?? "-"} (ISM: ${cell.ismAP}, ISS: ${
+ cell.issAP
+ })`;
+
+ if (typeof cell.shots !== "number") {
+ return (
+
+ handleHover({ ismAP: cell.ismAP, issAP: cell.issAP })
+ }
+ >
+ -
+
+ );
+ }
+
+ return (
+
+ handleHover({ ismAP: cell.ismAP, issAP: cell.issAP })
+ }
+ >
+ {cell.shots}
+
+ );
+ }),
+ ),
+ )}
+
+
+
+ );
+}
+
+type ShotCellData = { shots: number | null; ismAP: number; issAP: number };
+
+// LDE boosts both ISM and ISS by max 18 AP each, but you need 10 AP to wear it.
+const MAX_LDE_AP = 18 * 2;
+const AP_NEEDED_TO_WEAR_LDE = 10;
+const apsArePossible = (issAP: number, ismAP: number) =>
+ issAP + ismAP - MAX_LDE_AP + AP_NEEDED_TO_WEAR_LDE <= MAX_AP;
+function calculateGrid({
+ weaponSplId,
+ subsUsed,
+}: {
+ weaponSplId: MainWeaponId;
+ subsUsed: number;
+}) {
+ const result: ("N/A" | ShotCellData)[][] = [];
+ for (
+ let issAPIndex = 0;
+ issAPIndex < AP_VALUES_TO_SHOW.length;
+ issAPIndex++
+ ) {
+ const issAP = AP_VALUES_TO_SHOW[issAPIndex];
+
+ const row: ("N/A" | ShotCellData)[] = [];
+ for (
+ let ismAPIndex = 0;
+ ismAPIndex < AP_VALUES_TO_SHOW.length;
+ ismAPIndex++
+ ) {
+ const ismAP = AP_VALUES_TO_SHOW[ismAPIndex];
+
+ if (!apsArePossible(issAP, ismAP)) {
+ row.push("N/A" as const);
+ continue;
+ }
+
+ const option = inkTankOptionsWhenNSubsUsed({
+ ismAP,
+ issAP,
+ subsUsed,
+ weaponSplId,
+ });
+
+ row.push({
+ shots: option?.value ? Math.floor(option.value) : null,
+ issAP,
+ ismAP,
+ });
+ }
+
+ result.push(row);
+ }
+
+ const withColors = addGridColors(result);
+
+ return withColors;
+}
+
+// this is a performance optimization over simply calling "buildStats"
+// as it would be doing a lot of unnecessary work
+function inkTankOptionsWhenNSubsUsed({
+ issAP,
+ ismAP,
+ subsUsed,
+ weaponSplId,
+}: {
+ issAP: number;
+ ismAP: number;
+ subsUsed: number;
+ weaponSplId: MainWeaponId;
+}) {
+ const mainWeaponParams = weaponParams().mainWeapons[weaponSplId];
+
+ const subWeaponParams =
+ weaponParams().subWeapons[mainWeaponParams.subWeaponId];
+
+ const specialWeaponParams =
+ weaponParams().specialWeapons[mainWeaponParams.specialWeaponId];
+
+ const options = fullInkTankOptions({
+ abilityPoints: new Map([
+ ["ISS", issAP],
+ ["ISM", ismAP],
+ ]),
+ weaponSplId,
+ hasTacticooler: false,
+ mainOnlyAbilities: [],
+ mainWeaponParams,
+ specialWeaponParams,
+ subWeaponParams,
+ });
+
+ return options.find((o) => o.subsUsed === subsUsed);
+}
+
+function addGridColors(grid: ("N/A" | ShotCellData)[][]) {
+ const maxValue = grid
+ .flat()
+ .filter(
+ (v): v is { shots: number; ismAP: number; issAP: number } =>
+ v !== "N/A" && v.shots !== null,
+ )
+ .reduce((max, v) => Math.max(max, v.shots), 0);
+
+ const minValue = grid
+ .flat()
+ .filter(
+ (v): v is { shots: number; ismAP: number; issAP: number } =>
+ v !== "N/A" && v.shots !== null,
+ )
+ .reduce((min, v) => Math.min(min, v.shots), Infinity);
+
+ const result = grid.map((row) =>
+ row.map((cell) => {
+ if (cell === "N/A") return cell;
+ if (cell.shots === null) return { ...cell, hex: undefined };
+
+ const { shots } = cell;
+ const hex = generateHexCode({
+ minValue,
+ maxValue,
+ value: shots,
+ });
+
+ return {
+ ...cell,
+ hex,
+ };
+ }),
+ );
+
+ return result;
+}
+
+function generateHexCode({
+ minValue,
+ maxValue,
+ value,
+}: {
+ minValue: number;
+ maxValue: number;
+ value: number;
+}) {
+ const clampedValue = Math.max(minValue, Math.min(value, maxValue));
+ const normalizedPosition = (clampedValue - minValue) / (maxValue - minValue);
+
+ // Adjust saturation and brightness to lighten the colors
+ const saturationFactor = 0.5; // Adjust this to control the saturation
+ const brightnessFactor = 0.8; // Adjust this to control the brightness
+
+ const red =
+ Math.floor(255 * (1 - normalizedPosition) * saturationFactor) +
+ Math.floor(255 * (1 - brightnessFactor));
+ const green =
+ Math.floor(255 * normalizedPosition * saturationFactor) +
+ Math.floor(255 * (1 - brightnessFactor));
+ const blue = Math.floor(255 * (1 - brightnessFactor));
+
+ const hex = ((1 << 24) | (red << 16) | (green << 8) | blue)
+ .toString(16)
+ .slice(1)
+ .toUpperCase();
+
+ return `#${hex}`;
+}
diff --git a/app/features/build-analyzer/core/stats.ts b/app/features/build-analyzer/core/stats.ts
index a609e4835..c1e0a6543 100644
--- a/app/features/build-analyzer/core/stats.ts
+++ b/app/features/build-analyzer/core/stats.ts
@@ -265,7 +265,7 @@ function subWeaponInkConsumptionPercentage(args: StatFunctionInput) {
};
}
-function fullInkTankOptions(
+export function fullInkTankOptions(
args: StatFunctionInput,
): AnalyzedBuild["stats"]["fullInkTankOptions"] {
const result: AnalyzedBuild["stats"]["fullInkTankOptions"] = [];
diff --git a/app/features/build-analyzer/core/utils.ts b/app/features/build-analyzer/core/utils.ts
index 8bd9935b7..efd5813e2 100644
--- a/app/features/build-analyzer/core/utils.ts
+++ b/app/features/build-analyzer/core/utils.ts
@@ -1,23 +1,25 @@
+import invariant from "tiny-invariant";
+import { EMPTY_BUILD } from "~/constants";
import type {
+ Ability,
AbilityType,
+ AbilityWithUnknown,
+ BuildAbilitiesTupleWithUnknown,
+ MainWeaponId,
SpecialWeaponId,
SubWeaponId,
- Ability,
- BuildAbilitiesTupleWithUnknown,
- AbilityWithUnknown,
- MainWeaponId,
} from "~/modules/in-game-lists";
import {
- subWeaponIds,
+ abilities,
+ mainWeaponIds,
nonBombSubWeaponIds,
nonDamagingSpecialWeaponIds,
specialWeaponIds,
- abilities,
- mainWeaponIds,
+ subWeaponIds,
weaponCategories,
} from "~/modules/in-game-lists";
-import weaponParamsJson from "./weapon-params.json";
-import abilityValuesJson from "./ability-values.json";
+import type { Unpacked } from "~/utils/types";
+import { UNKNOWN_SHORT } from "../analyzer-constants";
import type {
AbilityPoints,
AnalyzedBuild,
@@ -28,10 +30,8 @@ import type {
SubWeaponDamage,
SubWeaponParams,
} from "../analyzer-types";
-import invariant from "tiny-invariant";
-import { EMPTY_BUILD } from "~/constants";
-import { UNKNOWN_SHORT } from "../analyzer-constants";
-import type { Unpacked } from "~/utils/types";
+import abilityValuesJson from "./ability-values.json";
+import weaponParamsJson from "./weapon-params.json";
export function weaponParams(): ParamsJson {
return weaponParamsJson as ParamsJson;
@@ -71,6 +71,14 @@ export function isStackableAbility(
return abilityObj.type === "STACKABLE";
}
+export function isMainOnlyAbility(
+ ability: AbilityWithUnknown,
+): ability is Ability {
+ if (ability === "UNKNOWN") return false;
+
+ return !isStackableAbility(ability);
+}
+
export function apFromMap({
abilityPoints,
ability,
diff --git a/app/features/build-analyzer/routes/analyzer.tsx b/app/features/build-analyzer/routes/analyzer.tsx
index e0589ad0c..9e28c7bb1 100644
--- a/app/features/build-analyzer/routes/analyzer.tsx
+++ b/app/features/build-analyzer/routes/analyzer.tsx
@@ -1,30 +1,38 @@
import { type LinksFunction, type V2_MetaFunction } from "@remix-run/node";
import type { ShouldRevalidateFunction } from "@remix-run/react";
import { Link } from "@remix-run/react";
+import clsx from "clsx";
import * as React from "react";
-import { useTranslation } from "~/hooks/useTranslation";
import { AbilitiesSelector } from "~/components/AbilitiesSelector";
import { Ability } from "~/components/Ability";
+import Chart from "~/components/Chart";
import { WeaponCombobox } from "~/components/Combobox";
import { Image } from "~/components/Image";
import { Main } from "~/components/Main";
import { Popover } from "~/components/Popover";
+import { Table } from "~/components/Table";
+import { Tab, Tabs } from "~/components/Tabs";
import { Toggle } from "~/components/Toggle";
+import { BeakerIcon } from "~/components/icons/Beaker";
+import { MAX_AP } from "~/constants";
import { useSetTitle } from "~/hooks/useSetTitle";
+import { useTranslation } from "~/hooks/useTranslation";
+import { useUser } from "~/modules/auth";
+import type { Ability as AbilityType } from "~/modules/in-game-lists";
import {
ANGLE_SHOOTER_ID,
INK_MINE_ID,
INK_STORM_ID,
- isAbility,
POINT_SENSOR_ID,
- TOXIC_MIST_ID,
TORPEDO_ID,
+ TOXIC_MIST_ID,
+ abilitiesShort,
+ isAbility,
type BuildAbilitiesTupleWithUnknown,
type MainWeaponId,
type SubWeaponId,
- abilitiesShort,
} from "~/modules/in-game-lists";
-import styles from "../analyzer.css";
+import { atOrError, nullFilledArray, removeDuplicates } from "~/utils/arrays";
import { damageTypeTranslationString } from "~/utils/i18next";
import { type SendouRouteHandle } from "~/utils/remix";
import { makeTitle } from "~/utils/strings";
@@ -37,34 +45,36 @@ import {
subWeaponImageUrl,
userNewBuildPage,
} from "~/utils/urls";
-import clsx from "clsx";
+import {
+ MAX_LDE_INTENSITY,
+ damageTypeToWeaponType,
+} from "../analyzer-constants";
+import { useAnalyzeBuild } from "../analyzer-hooks";
+import type {
+ AbilityPoints,
+ AnalyzedBuild,
+ Damage,
+ SpecialEffectType,
+ Stat,
+ SubWeaponDamage,
+} from "../analyzer-types";
+import styles from "../analyzer.css";
import {
ABILITIES_WITHOUT_CHUNKS,
getAbilityChunksMapAsArray,
} from "../core/abilityChunksCalc";
-import type {
- AbilityPoints,
- AnalyzedBuild,
- SpecialEffectType,
- Stat,
-} from "../analyzer-types";
import {
- lastDitchEffortIntensityToAp,
SPECIAL_EFFECTS,
+ lastDitchEffortIntensityToAp,
} from "../core/specialEffects";
-import {
- damageTypeToWeaponType,
- MAX_LDE_INTENSITY,
-} from "../analyzer-constants";
-import { useAnalyzeBuild } from "../analyzer-hooks";
-import { Tabs, Tab } from "~/components/Tabs";
+import { buildStats } from "../core/stats";
import {
buildIsEmpty,
damageIsSubWeaponDamage,
+ isMainOnlyAbility,
isStackableAbility,
} from "../core/utils";
-import { useUser } from "~/modules/auth";
-import { atOrError } from "~/utils/arrays";
+import { PerInkTankGrid } from "../components/PerInkTankGrid";
export const CURRENT_PATCH = "5.0";
@@ -114,18 +124,25 @@ export default function BuildAnalyzerPage() {
} = useAnalyzeBuild();
const statKeyToTuple = (key: keyof AnalyzedBuild["stats"]) => {
- return [analyzed.stats[key], analyzed2.stats[key]] as [Stat, Stat];
+ return [analyzed.stats[key], analyzed2.stats[key], key] as [
+ Stat,
+ Stat,
+ keyof AnalyzedBuild["stats"],
+ ];
};
const objectShredderSelected = build[2][0] === "OS" || build2[2][0] === "OS";
- const isComparing = !buildIsEmpty(build) && !buildIsEmpty(build2);
+ const context = {
+ isComparing: !buildIsEmpty(build) && !buildIsEmpty(build2),
+ mainWeaponId,
+ abilityPoints,
+ };
const mainWeaponCategoryItems = [
analyzed.stats.shotSpreadAir && (
{analyzed.stats.subVelocity && (
)}
{analyzed.stats.subFirstPhaseDuration && (
)}
{analyzed.stats.subExplosionRadius && (
)}
{analyzed.stats.subHp && (
{analyzed.stats.specialDurationInSeconds && (
) : null}
dmg.value !==
- analyzed.stats.subWeaponDefenseDamages[i].value,
+ analyzed.stats.subWeaponDefenseDamages[i].value &&
+ dmg.baseValue !== dmg.value,
)
? analyzed2.stats.subWeaponDefenseDamages
: undefined
@@ -760,13 +742,16 @@ export default function BuildAnalyzerPage() {
) : null}
+
)}
@@ -775,90 +760,77 @@ export default function BuildAnalyzerPage() {
testId="movement-category"
>
{analyzed.stats.shootingRunSpeed && (
)}
{analyzed.stats.shootingRunSpeedCharging && (
)}
{analyzed.stats.shootingRunSpeedFullCharge && (
)}
{analyzed.stats.shootingRunSpeedSecondaryMode && (
)}
+ }
+ contentClassName="analyzer__stat-popover"
+ triggerClassName={
+ props.simple ? undefined : "analyzer__stat-popover-trigger"
+ }
+ >
+ {props.title}
+
+
+ );
+}
+
+function StatChart({
+ statKey,
+ modifiedBy,
+ valueSuffix,
+ mainWeaponId,
+ subWeaponId,
+}: StatChartProps) {
+ const { t } = useTranslation(["analyzer"]);
+
+ const distanceLabel = t("analyzer:damage.header.distance");
+ const chartOptions = React.useMemo(() => {
+ const stackableAbility = modifiedBy.find(isStackableAbility)!;
+ const mainOnlyAbility = modifiedBy.find(isMainOnlyAbility);
+
+ return statKey
+ ? statKeyGraphOptions({
+ stackableAbility,
+ mainOnlyAbility,
+ statKey,
+ mainWeaponId,
+ })
+ : typeof subWeaponId === "number"
+ ? subDefenseGraphOptions({
+ subWeaponId,
+ distanceLabel,
+ })
+ : [];
+ }, [statKey, modifiedBy, mainWeaponId, subWeaponId, distanceLabel]);
+
+ // prevent crash but this should not happen
+ if (chartOptions.length === 0) {
+ console.error("no chart options");
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+function statKeyGraphOptions({
+ stackableAbility,
+ mainOnlyAbility,
+ statKey,
+ mainWeaponId,
+}: {
+ stackableAbility: AbilityType;
+ mainOnlyAbility: AbilityType | undefined;
+ statKey: keyof AnalyzedBuild["stats"];
+ mainWeaponId: MainWeaponId;
+}) {
+ const analyzedBuilds = nullFilledArray(MAX_AP + 1).map((_, i) =>
+ buildStats({
+ abilityPoints: new Map([[stackableAbility, i]]),
+ weaponSplId: mainWeaponId,
+ mainOnlyAbilities: [],
+ hasTacticooler: false,
+ }),
+ );
+
+ const result = [
+ {
+ label: ,
+ data: analyzedBuilds.map((a, i) => ({
+ primary: i,
+ secondary: (a.stats[statKey] as Stat).value,
+ })),
+ },
+ ];
+
+ if (mainOnlyAbility) {
+ const mainOnlyAbilityAnalyzedBuilds = nullFilledArray(MAX_AP + 1).map(
+ (_, i) =>
+ buildStats({
+ abilityPoints: new Map([[stackableAbility, i]]),
+ weaponSplId: 0,
+ mainOnlyAbilities: [mainOnlyAbility],
+ hasTacticooler: false,
+ }),
+ );
+
+ result.push({
+ label: (
+
+ ),
+ data: mainOnlyAbilityAnalyzedBuilds.map((a, i) => ({
+ primary: i,
+ secondary: (a.stats[statKey] as Stat).value,
+ })),
+ });
+ }
+
+ return result;
+}
+
+const damageToKey = (damage: SubWeaponDamage) => {
+ if (typeof damage.distance === "number") {
+ return `${damage.distance},${damage.baseValue}`;
+ }
+
+ return `${damage.distance!.join(",")},${damage.baseValue}`;
+};
+function subDefenseGraphOptions({
+ subWeaponId,
+ distanceLabel,
+}: {
+ subWeaponId: SubWeaponId;
+ distanceLabel: string;
+}) {
+ const analyzedBuilds = nullFilledArray(MAX_AP + 1).map((_, i) =>
+ buildStats({
+ abilityPoints: new Map([["SRU", i]]),
+ weaponSplId: 0,
+ mainOnlyAbilities: [],
+ hasTacticooler: false,
+ }),
+ );
+
+ const distanceKeys = removeDuplicates(
+ analyzedBuilds[0].stats.subWeaponDefenseDamages
+ .filter((d) => (d as SubWeaponDamage).subWeaponId === subWeaponId)
+ .filter((d) => d.value < 100)
+ .map((d) => damageToKey(d)),
+ );
+
+ const result = [];
+
+ for (const key of distanceKeys) {
+ const distance = key.split(",")[0];
+
+ result.push({
+ label: `${distanceLabel}: ${distance}`,
+ data: analyzedBuilds.map((a, i) => ({
+ primary: i,
+ secondary:
+ a.stats.subWeaponDefenseDamages.find(
+ (d) =>
+ (d as SubWeaponDamage).subWeaponId === subWeaponId &&
+ damageToKey(d) === key,
+ )?.value ?? 0,
+ })),
+ });
+ }
+
+ return result;
+}
+
function APCompare({
abilityPoints,
abilityPoints2,
@@ -1174,22 +1321,25 @@ function StatCategory({
);
}
+type StatTuple = [Stat, Stat, keyof AnalyzedBuild["stats"]];
function StatCard({
title,
stat,
suffix,
popoverInfo,
- abilityPoints,
- isComparing,
testId,
+ context: { mainWeaponId, abilityPoints, isComparing },
}: {
title: string;
- stat: [Stat, Stat] | [Stat, Stat] | number | string;
+ stat: StatTuple | StatTuple | number | string;
suffix?: string;
popoverInfo?: string;
- abilityPoints: AbilityPoints;
- isComparing: boolean;
testId?: string;
+ context: {
+ mainWeaponId: MainWeaponId;
+ abilityPoints: AbilityPoints;
+ isComparing: boolean;
+ };
}) {
const { t } = useTranslation("analyzer");
@@ -1222,6 +1372,12 @@ function StatCard({
);
};
+ const memoKey = isStaticValue ? stat : stat[2];
+ const modifiedBy = React.useMemo(() => {
+ return isStaticValue ? [] : [stat[0].modifiedBy].flat();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [memoKey]);
+
return (
- {showComparison ? (stat as [Stat, Stat])[0].value : baseValue}
+ {showComparison ? (stat as StatTuple)[0].value : baseValue}
{suffix}
@@ -1268,16 +1424,26 @@ function StatCard({
{showComparison ? t("build2") : t("build")}
{" "}
- {(stat as [Stat, Stat])[showComparison ? 1 : 0].value}
+ {(stat as StatTuple)[showComparison ? 1 : 0].value}
{suffix}
) : null}
+ {/* always render this so it reserves space */}
{!isStaticValue && (
-
+ <>
+
+
+ >
)}
@@ -1290,7 +1456,7 @@ function ModifiedByAbilities({ abilities }: { abilities: Stat["modifiedBy"] }) {
return (
{abilitiesArray.map((ability) => (
-
+
))}
);
@@ -1300,6 +1466,7 @@ function DamageTable({
values,
comparisonValues,
multiShots,
+ showPopovers = false,
}: {
values:
| AnalyzedBuild["stats"]["damages"]
@@ -1308,6 +1475,7 @@ function DamageTable({
| AnalyzedBuild["stats"]["damages"]
| AnalyzedBuild["stats"]["subWeaponDefenseDamages"];
multiShots?: AnalyzedBuild["weapon"]["multiShots"];
+ showPopovers?: boolean;
}) {
const { t } = useTranslation(["weapons", "analyzer"]);
@@ -1319,9 +1487,19 @@ function DamageTable({
// essentially checking that we are using some sub resistance up
values.some((val) => val.value !== (val as any).baseValue);
+ const renderedDamagesTypes = new Set();
+ const renderPopover = (damage: Damage, subWeaponId: SubWeaponId) => {
+ if (damage.value >= 100) return false;
+ if (renderedDamagesTypes.has(subWeaponId)) return false;
+
+ renderedDamagesTypes.add(subWeaponId);
+
+ return true;
+ };
+
return (
<>
-
+
| {t("analyzer:damage.header.type")} |
@@ -1336,6 +1514,7 @@ function DamageTable({
) : null}
{showDamageColumn && {t("analyzer:damage.header.damage")} | }
+ {showPopovers ? | : null}
@@ -1390,11 +1569,32 @@ function DamageTable({
)}
)}
+ {showPopovers ? (
+
+ {renderPopover(
+ val,
+ (val as SubWeaponDamage).subWeaponId,
+ ) ? (
+
+ ) : null}
+ |
+ ) : null}
);
})}
-
+
>
);
}
@@ -1422,7 +1622,7 @@ function ConsumptionTable({
return (
<>
-
+
| {t(`weapons:SUB_${subWeaponId}`)} |
@@ -1472,7 +1672,7 @@ function ConsumptionTable({
);
})}
-
+
{subWeaponId === TORPEDO_ID && (
<> {t("analyzer:torpedoExplanation")}>
diff --git a/app/modules/auth/DiscordStrategy.server.ts b/app/modules/auth/DiscordStrategy.server.ts
index a5d7cb29d..16c54cecd 100644
--- a/app/modules/auth/DiscordStrategy.server.ts
+++ b/app/modules/auth/DiscordStrategy.server.ts
@@ -88,7 +88,6 @@ export class DiscordStrategy extends OAuth2Strategy<
}
private async fetchProfileViaDiscordApi(token: string) {
- console.log("authenticating via discord...");
const authHeader: [string, string] = ["Authorization", `Bearer ${token}`];
return Promise.all([
@@ -102,7 +101,6 @@ export class DiscordStrategy extends OAuth2Strategy<
}
private async fetchProfileViaGateway(token: string) {
- console.log("authenticating via gateway...");
const url = `${process.env["AUTH_GATEWAY_PROFILE_URL"]}?token=${token}`;
return fetch(url).then(this.jsonIfOk);
diff --git a/app/routes/calendar/$id/index.tsx b/app/routes/calendar/$id/index.tsx
index 06d08cb06..db170f5e2 100644
--- a/app/routes/calendar/$id/index.tsx
+++ b/app/routes/calendar/$id/index.tsx
@@ -53,6 +53,7 @@ import {
} from "~/utils/urls";
import { actualNumber, id } from "~/utils/zod";
import { Tags } from "../components/Tags";
+import { Table } from "~/components/Table";
export const action: ActionFunction = async ({ params, request }) => {
const user = await requireUserId(request);
@@ -270,7 +271,7 @@ function Results() {
})}
)}
-
+
| {t("calendar:forms.team.placing")} |
@@ -310,7 +311,7 @@ function Results() {
))}
-
+
);
}
diff --git a/app/routes/u.$identifier/results/components/UserResultsTable.tsx b/app/routes/u.$identifier/results/components/UserResultsTable.tsx
index c6fb6ce1c..21401c1a6 100644
--- a/app/routes/u.$identifier/results/components/UserResultsTable.tsx
+++ b/app/routes/u.$identifier/results/components/UserResultsTable.tsx
@@ -11,6 +11,7 @@ import {
tournamentTeamPage,
userPage,
} from "~/utils/urls";
+import { Table } from "~/components/Table";
export type UserResultsTableProps = {
results: UserPageLoaderData["results"];
@@ -31,7 +32,7 @@ export function UserResultsTable({
const placementHeaderId = `${id}-th-placement`;
return (
-
+
{hasHighlightCheckboxes && | }
@@ -136,6 +137,6 @@ export function UserResultsTable({
);
})}
-
+
);
}
diff --git a/app/routes/u.$identifier/seasons.tsx b/app/routes/u.$identifier/seasons.tsx
index 6ed476b21..9ad375932 100644
--- a/app/routes/u.$identifier/seasons.tsx
+++ b/app/routes/u.$identifier/seasons.tsx
@@ -320,7 +320,7 @@ function PowerChart() {
const chartOptions = React.useMemo(() => {
return [
{
- label: "Power",
+ label: "SP",
data: data.skills.map((s) => {
// hack to force shorter bottom axis text
const date = new Date(s.date);
diff --git a/app/styles/common.css b/app/styles/common.css
index 7e9c92f70..f9417523f 100644
--- a/app/styles/common.css
+++ b/app/styles/common.css
@@ -293,7 +293,7 @@ select:focus {
outline: 2px solid var(--theme);
}
-table {
+.my-table {
width: 100%;
border-collapse: collapse;
border-spacing: 0 var(--s-1-5);
@@ -301,19 +301,19 @@ table {
text-align: left;
}
-table > thead {
+.my-table > thead {
font-size: var(--fonts-xxs);
}
-table > tbody > tr:nth-child(2n) {
+.my-table > tbody > tr:nth-child(2n) {
background-color: var(--bg);
}
-table > thead > tr > th {
+.my-table > thead > tr > th {
padding-inline: var(--s-1);
}
-table > tbody > tr > td {
+.my-table > tbody > tr > td {
padding-inline: var(--s-1);
}
@@ -1310,11 +1310,40 @@ dialog::backdrop {
}
.chart__container {
- height: 175px;
- background-color: var(--bg-lighter);
+ height: var(--chart-height, 175px);
+ width: var(--chart-width);
+ background-color: var(--chart-bg, var(--bg-lighter));
border-radius: var(--rounded);
}
+.chart__tooltip {
+ border: 1.75px solid var(--border);
+ border-radius: var(--rounded);
+ background-color: var(--bg-darker);
+ padding: var(--s-1) var(--s-2);
+ font-weight: var(--semi-bold);
+ font-size: var(--fonts-sm);
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-1);
+}
+
+.chart__tooltip__value {
+ margin-inline-start: auto;
+ min-width: 40px;
+}
+
+.chart__dot {
+ background-color: var(--dot-color);
+ border-radius: 100%;
+ width: 12px;
+ height: 12px;
+}
+
+.chart__dot__focused {
+ outline: 3px solid var(--dot-color-outline);
+}
+
.chat__container {
display: flex;
flex-direction: column;
diff --git a/app/styles/utils.css b/app/styles/utils.css
index f6ba89a7d..02feb6832 100644
--- a/app/styles/utils.css
+++ b/app/styles/utils.css
@@ -10,6 +10,10 @@
font-size: var(--fonts-sm);
}
+.text-md {
+ font-size: var(--fonts-md);
+}
+
.text-xs {
font-size: var(--fonts-xs);
}
diff --git a/app/styles/vars.css b/app/styles/vars.css
index a99b51d82..960838c1d 100644
--- a/app/styles/vars.css
+++ b/app/styles/vars.css
@@ -39,6 +39,7 @@ html {
--theme-vibrant: #f73e8b;
--theme-semi-transparent: #ff99c477;
--theme-secondary: rgb(63 58 255);
+ --theme-secondary-transparent: rgb(63 58 255 / 55%);
--backdrop-filter: blur(10px) brightness(95%);
--rounded: 16px;
--rounded-full: 200px;
@@ -130,6 +131,7 @@ html.dark {
--theme-semi-transparent: #ff99c477;
--theme-transparent: #ffc6de52;
--theme-secondary: rgb(239 229 83);
+ --theme-secondary-transparent: rgb(239 229 83 / 55%);
--backdrop-filter: blur(10px) brightness(75%);
--inactive-image-filter: grayscale(100%) brightness(130%);
}
diff --git a/public/locales/en/analyzer.json b/public/locales/en/analyzer.json
index 18404e354..fcb1856aa 100644
--- a/public/locales/en/analyzer.json
+++ b/public/locales/en/analyzer.json
@@ -153,5 +153,7 @@
"labels.damageType": "Damage type",
"labels.weapon": "Weapon",
"dmgHtdExplanation": "DMG = Damage | HTD = Hits to destroy",
- "noDmgData": "No damage data yet for this weapon. Check back later!"
+ "noDmgData": "No damage data yet for this weapon. Check back later!",
+ "perInkTankGrid.header_one": "{{weapon}} shots after ×{{count}} sub weapon used",
+ "perInkTankGrid.header_other": "{{weapon}} shots after ×{{count}} sub weapons used"
}