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 {children}
; +} 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 ( <> - +
@@ -1336,6 +1514,7 @@ function DamageTable({ ) : null} {showDamageColumn && } + {showPopovers ? @@ -1390,11 +1569,32 @@ function DamageTable({ )} )} + {showPopovers ? ( + + ) : null} ); })} -
{t("analyzer:damage.header.type")}{t("analyzer:damage.header.damage")} : null}
+ {renderPopover( + val, + (val as SubWeaponDamage).subWeaponId, + ) ? ( + + ) : null} +
+ ); } @@ -1422,7 +1622,7 @@ function ConsumptionTable({ return ( <> - +
@@ -1472,7 +1672,7 @@ function ConsumptionTable({ ); })} -
{t(`weapons:SUB_${subWeaponId}`)}
+ {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() { })}
)} - +
@@ -310,7 +311,7 @@ function Results() { ))} -
{t("calendar:forms.team.placing")}
+ ); } 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" }