diff --git a/src/app/tools/compare/components/Body.tsx b/src/app/tools/compare/components/Body.tsx
new file mode 100644
index 000000000..869e43813
--- /dev/null
+++ b/src/app/tools/compare/components/Body.tsx
@@ -0,0 +1,400 @@
+import { Flex, Heading, IconButton, Table } from '@radix-ui/themes';
+import { StickyColumnHeaderCell } from '../../../../components/StickyColumnHeaderCell';
+import { asset } from '../../../../core/blitzkit/asset';
+import { TankCharacteristics } from '../../../../core/blitzkit/tankCharacteristics';
+import * as CompareEphemeral from '../../../../stores/compareEphemeral';
+import { CompareRow } from './CompareRow';
+import { CompareSectionTitle } from './CompareSectionTitle';
+
+interface BodyProps {
+ stats: TankCharacteristics[];
+}
+
+export function Body({ stats }: BodyProps) {
+ const members = CompareEphemeral.use((state) => state.members);
+ const hasNonRegularGun = members.some(({ gun }) => gun.type !== 'regular');
+ const mutateCompareEphemeral = CompareEphemeral.useMutation();
+
+ return (
+ <>
+
+
+
+
+ Firepower
+
+
+
+ {members.map(({ gun, shell, key }, index) => (
+
+
+
+ {gun.shells.map((thisShell, shellIndex) => (
+ {
+ mutateCompareEphemeral((draft) => {
+ draft.members[index].shell = thisShell;
+ });
+ }}
+ >
+
+
+ ))}
+
+
+
+ ))}
+
+
+
+
+
+ stats.shellReload ??
+ stats.shellReloads!.reduce((a, b) => a + b, 0) /
+ stats.shellReloads!.length
+ }
+ display={(stats) =>
+ stats.shellReload?.toFixed(2) ??
+ stats.shellReloads!.map((reload) => reload.toFixed(2)).join(', ')
+ }
+ deltaNominalDisplay={(delta) => delta.toFixed(2)}
+ />
+ {hasNonRegularGun && (
+ <>
+
+
+ >
+ )}
+
+
+ {hasNonRegularGun && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Maneuverability
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Survivability
+
+
+ (stats.fireChance * 100).toFixed(0)}
+ deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
+ />
+
+ (stats.camouflageStill * 100).toFixed(0)}
+ deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
+ />
+ (stats.camouflageMoving * 100).toFixed(0)}
+ deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
+ />
+ (stats.camouflageShootingStill * 100).toFixed(0)}
+ deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
+ />
+ (stats.camouflageShootingMoving * 100).toFixed(0)}
+ deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
+ />
+ (stats.camouflageCaughtOnFire * 100).toFixed(0)}
+ deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
+ />
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/tools/compare/components/CompareRow.tsx b/src/app/tools/compare/components/CompareRow.tsx
new file mode 100644
index 000000000..77c73f01c
--- /dev/null
+++ b/src/app/tools/compare/components/CompareRow.tsx
@@ -0,0 +1,176 @@
+import {
+ CaretLeftIcon,
+ CaretRightIcon,
+ CaretSortIcon,
+} from '@radix-ui/react-icons';
+import { Flex, IconButton, Table, Text } from '@radix-ui/themes';
+import { StickyRowHeaderCell } from '../../../../components/StickyRowHeaderCell';
+import { TankCharacteristics } from '../../../../core/blitzkit/tankCharacteristics';
+import { theme } from '../../../../stitches.config';
+import * as CompareEphemeral from '../../../../stores/compareEphemeral';
+import * as ComparePersistent from '../../../../stores/comparePersistent';
+
+interface CompareRowProps {
+ name: string;
+ value:
+ | keyof Awaited
+ | ((member: Awaited) => number | undefined);
+ display?: (
+ member: Awaited,
+ ) => number | string | undefined;
+ deltaType?: 'higherIsBetter' | 'lowerIsBetter';
+ decimals?: number;
+ deltaNominalDisplay?: (delta: number) => string | number;
+ indent?: boolean;
+ stats: TankCharacteristics[];
+}
+
+export function CompareRow({
+ value,
+ name,
+ display,
+ deltaType = 'higherIsBetter',
+ decimals,
+ deltaNominalDisplay,
+ indent,
+ stats,
+}: CompareRowProps) {
+ const mutateCompareEphemeral = CompareEphemeral.useMutation();
+ const sorting = CompareEphemeral.use((state) => state.sorting);
+ const deltaMode = ComparePersistent.use((state) => state.deltaMode);
+
+ const values = stats.map((stat) =>
+ typeof value === 'function' ? value(stat)! : (stat[value] as number),
+ );
+
+ return (
+
+
+
+ {
+ mutateCompareEphemeral((draft) => {
+ draft.members.sort((memberA, memberB) => {
+ const indexA = draft.members.indexOf(memberA);
+ const indexB = draft.members.indexOf(memberB);
+ const valueA = values[indexA];
+ const valueB = values[indexB];
+
+ return draft.sorting?.direction === 'ascending' &&
+ draft.sorting.by === name
+ ? valueA - valueB
+ : valueB - valueA;
+ });
+
+ draft.sorting = {
+ by: name,
+ direction:
+ draft.sorting?.direction === 'ascending' &&
+ draft.sorting.by === name
+ ? 'descending'
+ : 'ascending',
+ };
+ });
+ }}
+ >
+ {(sorting === undefined || sorting.by !== name) && (
+
+ )}
+ {sorting?.by === name && (
+ <>
+ {sorting.direction === 'ascending' && }
+ {sorting.direction === 'descending' && }
+ >
+ )}
+
+
+ {name}
+
+
+
+ {values.map((value, index) => {
+ const delta = value - values[0];
+ const deltaPercentage = value / values[0] - 1;
+ const normalizedDeltaPercentage = Math.round(
+ Math.min(100, Math.abs(deltaPercentage) * 2 * 100 + 25),
+ );
+ const resolvedDisplayValue = display
+ ? display(stats[index])
+ : decimals === undefined
+ ? value
+ : value?.toFixed(decimals);
+
+ return (
+ values[0]
+ : value < values[0]
+ )
+ ? `color-mix(in srgb, ${theme.colors.componentCallToActionInteractive_green} ${normalizedDeltaPercentage}%, ${theme.colors.componentCallToActionInteractive_green}00)`
+ : `color-mix(in srgb, ${theme.colors.componentCallToActionInteractive_red} ${normalizedDeltaPercentage}%, ${theme.colors.componentCallToActionInteractive_red}00)`,
+ }}
+ >
+
+
+ {resolvedDisplayValue}
+
+
+ {delta !== 0 &&
+ resolvedDisplayValue !== undefined &&
+ values[0] !== undefined && (
+ <>
+ {deltaMode === 'absolute' && (
+
+ (
+ {`${delta > 0 ? '+' : ''}${
+ deltaNominalDisplay
+ ? deltaNominalDisplay(delta)
+ : decimals === undefined
+ ? delta
+ : delta.toFixed(decimals)
+ }`}
+ )
+
+ )}
+
+ {deltaMode === 'percentage' && (
+
+ (
+ {`${deltaPercentage > 0 ? '+' : ''}${Math.round(
+ deltaPercentage * 100,
+ )}%`}
+ )
+
+ )}
+ >
+ )}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/app/tools/compare/components/CompareSectionTitle.tsx b/src/app/tools/compare/components/CompareSectionTitle.tsx
new file mode 100644
index 000000000..83009f647
--- /dev/null
+++ b/src/app/tools/compare/components/CompareSectionTitle.tsx
@@ -0,0 +1,22 @@
+import { Heading, Table } from '@radix-ui/themes';
+import { times } from 'lodash';
+import { StickyColumnHeaderCell } from '../../../../components/StickyColumnHeaderCell';
+import * as CompareEphemeral from '../../../../stores/compareEphemeral';
+
+export function CompareSectionTitle({ children }: { children: string }) {
+ const memberCount = CompareEphemeral.use((state) => state.members.length);
+
+ return (
+
+
+
+ {children}
+
+
+ {times(memberCount, (index) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/tools/compare/components/CompareTable.tsx b/src/app/tools/compare/components/CompareTable.tsx
new file mode 100644
index 000000000..2f4bd3180
--- /dev/null
+++ b/src/app/tools/compare/components/CompareTable.tsx
@@ -0,0 +1,546 @@
+import { ComponentPlaceholderIcon } from '@radix-ui/react-icons';
+import { Button, Flex, Popover, Table } from '@radix-ui/themes';
+import { times } from 'lodash';
+import { use } from 'react';
+import { ConsumablesManager } from '../../../../components/ConsumablesManager';
+import { CrewSkillManager } from '../../../../components/CrewSkillManager';
+import { EquipmentManager } from '../../../../components/EquipmentManager';
+import { ModuleManager } from '../../../../components/ModuleManager';
+import { ProvisionsManager } from '../../../../components/ProvisionsManager';
+import { StickyColumnHeaderCell } from '../../../../components/StickyColumnHeaderCell';
+import { StickyTableRoot } from '../../../../components/StickyTableRoot';
+import { asset } from '../../../../core/blitzkit/asset';
+import { availableProvisions } from '../../../../core/blitzkit/availableProvisions';
+import { checkConsumableProvisionInclusivity } from '../../../../core/blitzkit/checkConsumableProvisionInclusivity';
+import { consumableDefinitions } from '../../../../core/blitzkit/consumableDefinitions';
+import { equipmentDefinitions } from '../../../../core/blitzkit/equipmentDefinitions';
+import { provisionDefinitions } from '../../../../core/blitzkit/provisionDefinitions';
+import { skillDefinitions } from '../../../../core/blitzkit/skillDefinitions';
+import { TankCharacteristics } from '../../../../core/blitzkit/tankCharacteristics';
+import { BlitzkitButtonWatermark } from '../../../../icons/BlitzkitButtonWatermark';
+import { theme } from '../../../../stitches.config';
+import * as CompareEphemeral from '../../../../stores/compareEphemeral';
+import { EquipmentMatrix } from '../../../../stores/duel';
+import { permanentSkills } from '../../tankopedia/[id]/components/Characteristics/components/Skills/constants';
+import { Body } from './Body';
+import { InsertionMarker } from './IntersectionMarker';
+import { TankCard } from './TankCard';
+
+interface CompareTableProps {
+ stats: TankCharacteristics[];
+}
+
+export function CompareTable({ stats }: CompareTableProps) {
+ const crewSkills = CompareEphemeral.use((state) => state.crewSkills);
+ const members = CompareEphemeral.use((state) => state.members);
+ const awaitedSkillDefinitions = use(skillDefinitions);
+ const mutateCompareEphemeral = CompareEphemeral.useMutation();
+ const awaitedEquipmentDefinitions = use(equipmentDefinitions);
+ const awaitedConsumableDefinitions = use(consumableDefinitions);
+ const awaitedProvisionDefinitions = use(provisionDefinitions);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {members.map(({ tank, key }, index) => {
+ return ;
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ mutateCompareEphemeral((draft) => {
+ draft.crewSkills = skills;
+ });
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+ {members.map(
+ (
+ {
+ equipmentMatrix,
+ tank,
+ key,
+ provisions,
+ gun,
+ shell,
+ turret,
+ engine,
+ track,
+ consumables,
+ },
+ index,
+ ) => {
+ const equipmentPreset =
+ awaitedEquipmentDefinitions.presets[tank.equipment];
+ const provisionsList = availableProvisions(
+ tank,
+ gun,
+ awaitedProvisionDefinitions,
+ );
+ const consumablesList = Object.values(
+ awaitedConsumableDefinitions,
+ ).filter((consumable) =>
+ checkConsumableProvisionInclusivity(consumable, tank, gun),
+ );
+
+ return (
+
+
+
+
+
+
+
+
+ {
+ mutateCompareEphemeral((draft) => {
+ draft.members[index] = {
+ ...draft.members[index],
+ ...modules,
+ };
+ });
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ provision.id,
+ )}
+ selected={provisions}
+ disabled={tank.provisions === provisions.length}
+ onChange={(provisions) => {
+ mutateCompareEphemeral((draft) => {
+ draft.members[index].provisions = provisions;
+ });
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ mutateCompareEphemeral((draft) => {
+ draft.members[index].equipmentMatrix = matrix;
+ draft.sorting = undefined;
+ });
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ mutateCompareEphemeral((draft) => {
+ draft.members[index].consumables = consumables;
+ });
+ }}
+ disabled={tank.consumables === consumables.length}
+ />
+
+
+
+
+
+
+
+
+
+ );
+ },
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/app/tools/compare/components/Controls.tsx b/src/app/tools/compare/components/Controls.tsx
new file mode 100644
index 000000000..22d6a5464
--- /dev/null
+++ b/src/app/tools/compare/components/Controls.tsx
@@ -0,0 +1,111 @@
+import { PlusIcon, TrashIcon } from '@radix-ui/react-icons';
+import { Button, Dialog, Flex, SegmentedControl } from '@radix-ui/themes';
+import { use } from 'react';
+import { provisionDefinitions } from '../../../../core/blitzkit/provisionDefinitions';
+import { tankToCompareMember } from '../../../../core/blitzkit/tankToCompareMember';
+import * as CompareEphemeral from '../../../../stores/compareEphemeral';
+import * as ComparePersistent from '../../../../stores/comparePersistent';
+import { TankSearch } from '../../tankopedia/components/TankSearch';
+
+interface ControlsProps {
+ addTankDialogOpen: boolean;
+ onAddTankDialogOpenChange: (open: boolean) => void;
+}
+
+export function Controls({
+ addTankDialogOpen,
+ onAddTankDialogOpenChange,
+}: ControlsProps) {
+ const deltaMode = ComparePersistent.use((state) => state.deltaMode);
+ const awaitedProvisionDefinitions = use(provisionDefinitions);
+ const mutateCompareEphemeral = CompareEphemeral.useMutation();
+ const mutateComparePersistent = ComparePersistent.useMutation();
+
+ return (
+
+
+
+
+
+
+
+
+
+ {
+ mutateCompareEphemeral((draft) => {
+ draft.members.push(
+ tankToCompareMember(tank, awaitedProvisionDefinitions),
+ );
+ draft.sorting = undefined;
+ });
+ onAddTankDialogOpenChange(false);
+ }}
+ onSelectAll={(tanks) => {
+ mutateCompareEphemeral((draft) => {
+ draft.members.push(
+ ...tanks.map((tank) =>
+ tankToCompareMember(tank, awaitedProvisionDefinitions),
+ ),
+ );
+ draft.sorting = undefined;
+ });
+ onAddTankDialogOpenChange(false);
+ }}
+ />
+
+
+
+
+
+
+
+ {
+ mutateComparePersistent((draft) => {
+ draft.deltaMode = value as ComparePersistent.DeltaMode;
+ });
+ }}
+ >
+
+ No deltas
+
+
+ Percentage
+
+
+ Absolute
+
+
+
+ );
+}
diff --git a/src/app/tools/compare/components/DamageWarning.tsx b/src/app/tools/compare/components/DamageWarning.tsx
new file mode 100644
index 000000000..4c29ad59a
--- /dev/null
+++ b/src/app/tools/compare/components/DamageWarning.tsx
@@ -0,0 +1,28 @@
+import { InfoCircledIcon } from '@radix-ui/react-icons';
+import { Callout } from '@radix-ui/themes';
+import * as CompareEphemeral from '../../../../stores/compareEphemeral';
+
+export function DamageWarning() {
+ const members = CompareEphemeral.use((state) => state.members);
+ const hasReactive = members.some((member) => member.consumables.includes(33));
+ const haDynamicArmor = members.some((member) =>
+ member.consumables.includes(73),
+ );
+ const hasSpallLiner = members.some((member) =>
+ member.provisions.includes(101),
+ );
+
+ if (!hasReactive && !haDynamicArmor && !hasSpallLiner) return null;
+
+ {
+
+
+
+
+
+ Consumables like Reactive Armor and Dynamic Armor System and provisions
+ like Spall Liner may reduce other tanks' damage.
+
+ ;
+ }
+}
diff --git a/src/app/tools/compare/components/IntersectionMarker/constants.ts b/src/app/tools/compare/components/IntersectionMarker/constants.ts
new file mode 100644
index 000000000..12b5899fe
--- /dev/null
+++ b/src/app/tools/compare/components/IntersectionMarker/constants.ts
@@ -0,0 +1,2 @@
+export const insertionMarkers: { element: HTMLDivElement; index: number }[] =
+ [];
diff --git a/src/app/tools/compare/components/IntersectionMarker/index.tsx b/src/app/tools/compare/components/IntersectionMarker/index.tsx
new file mode 100644
index 000000000..fd43a8170
--- /dev/null
+++ b/src/app/tools/compare/components/IntersectionMarker/index.tsx
@@ -0,0 +1,40 @@
+import { useEffect, useRef } from 'react';
+import { theme } from '../../../../../stitches.config';
+import { insertionMarkers } from './constants';
+
+export function InsertionMarker({ index }: { index: number }) {
+ const marker = useRef(null);
+
+ useEffect(() => {
+ insertionMarkers.push({
+ element: marker.current!,
+ index,
+ });
+
+ return () => {
+ insertionMarkers.splice(
+ insertionMarkers.findIndex(
+ (insertionMarker) => insertionMarker.element === marker.current!,
+ ),
+ 1,
+ );
+ };
+ });
+
+ return (
+
+ );
+}
diff --git a/src/app/tools/compare/components/TankCard.tsx b/src/app/tools/compare/components/TankCard.tsx
new file mode 100644
index 000000000..b027b399f
--- /dev/null
+++ b/src/app/tools/compare/components/TankCard.tsx
@@ -0,0 +1,148 @@
+import { Flex, Text } from '@radix-ui/themes';
+import { useEffect, useRef } from 'react';
+import { Vector2 } from 'three';
+import { StickyColumnHeaderCell } from '../../../../components/StickyColumnHeaderCell';
+import { TankDefinition } from '../../../../core/blitzkit/tankDefinitions';
+import { tankIcon } from '../../../../core/blitzkit/tankIcon';
+import * as CompareEphemeral from '../../../../stores/compareEphemeral';
+import { InsertionMarker } from './IntersectionMarker';
+import { insertionMarkers } from './IntersectionMarker/constants';
+import { TankControl } from './TankControl';
+
+interface TankCardProps {
+ index: number;
+ tank: TankDefinition;
+}
+
+export function TankCard({ index, tank }: TankCardProps) {
+ const draggable = useRef(null);
+ const mutateCompareEphemeral = CompareEphemeral.useMutation();
+
+ useEffect(() => {
+ const initial = new Vector2();
+ const current = new Vector2();
+ const delta = new Vector2();
+ const marker = new Vector2();
+ let initialRect = draggable.current!.getBoundingClientRect();
+ let dropIndex = -1;
+
+ function handlePointerDown(event: PointerEvent) {
+ event.preventDefault();
+
+ initialRect = draggable.current!.getBoundingClientRect();
+
+ delta.set(0, 0);
+ current.copy(initial.set(event.clientX, event.clientY));
+ window.addEventListener('pointermove', handlePointerMove);
+ window.addEventListener('pointerup', handlePointerUp);
+ }
+ function handlePointerMove(event: PointerEvent) {
+ event.preventDefault();
+
+ current.set(event.clientX, event.clientY);
+ delta.copy(current).sub(initial);
+ insertionMarkers.forEach(({ element }) => {
+ element.style.opacity = '0';
+ });
+
+ const distances = insertionMarkers.map((insertionMarker) => {
+ const markerBounds = insertionMarker.element.getBoundingClientRect();
+ marker.set(markerBounds.left, markerBounds.top);
+
+ return {
+ ...insertionMarker,
+ distance: marker.distanceTo(current),
+ };
+ });
+ const closest = distances.reduce((a, b) =>
+ a.distance < b.distance ? a : b,
+ );
+
+ dropIndex = closest.index;
+ closest.element.style.opacity = '1';
+ draggable.current!.style.position = 'fixed';
+ draggable.current!.style.cursor = 'grabbing';
+ draggable.current!.style.zIndex = '1';
+ draggable.current!.style.left = `${initialRect.left + delta.x}px`;
+ draggable.current!.style.top = `${initialRect.top + delta.y}px`;
+ }
+ function handlePointerUp(event: PointerEvent) {
+ event.preventDefault();
+
+ draggable.current!.style.position = 'static';
+ draggable.current!.style.zIndex = 'unset';
+ draggable.current!.style.cursor = 'grab';
+
+ insertionMarkers.forEach(({ element }) => {
+ element.style.opacity = '0';
+ });
+
+ window.removeEventListener('pointermove', handlePointerMove);
+ window.removeEventListener('pointerup', handlePointerUp);
+
+ if (dropIndex === -1) return;
+
+ mutateCompareEphemeral((draft) => {
+ const fromIndex = index;
+ const toIndex = dropIndex > index ? dropIndex - 1 : dropIndex;
+
+ const member = draft.members[fromIndex];
+ draft.members.splice(fromIndex, 1);
+ draft.members.splice(toIndex, 0, member);
+ });
+
+ dropIndex = -1;
+ }
+
+ draggable.current?.addEventListener('pointerdown', handlePointerDown);
+
+ return () => {
+ draggable.current?.removeEventListener('pointerdown', handlePointerDown);
+ };
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+ {tank.name}
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/tools/compare/page.tsx b/src/app/tools/compare/page.tsx
index 5a2e004b3..c4926a571 100644
--- a/src/app/tools/compare/page.tsx
+++ b/src/app/tools/compare/page.tsx
@@ -1,94 +1,37 @@
'use client';
-import {
- CaretLeftIcon,
- CaretRightIcon,
- CaretSortIcon,
- ComponentPlaceholderIcon,
- InfoCircledIcon,
- PlusIcon,
- TrashIcon,
-} from '@radix-ui/react-icons';
-import {
- Box,
- Button,
- Callout,
- Dialog,
- Flex,
- Heading,
- IconButton,
- Popover,
- SegmentedControl,
- Table,
- Text,
-} from '@radix-ui/themes';
-import { times } from 'lodash';
+import { PlusIcon } from '@radix-ui/react-icons';
+import { Box, Flex, Heading, IconButton, Text } from '@radix-ui/themes';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
-import { use, useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { Vector2 } from 'three';
-import { ConsumablesManager } from '../../../components/ConsumablesManager';
-import { CrewSkillManager } from '../../../components/CrewSkillManager';
-import { EquipmentManager } from '../../../components/EquipmentManager';
-import { ModuleManager } from '../../../components/ModuleManager';
+import { use, useCallback, useEffect, useMemo, useState } from 'react';
import PageWrapper from '../../../components/PageWrapper';
-import { ProvisionsManager } from '../../../components/ProvisionsManager';
-import { StickyColumnHeaderCell } from '../../../components/StickyColumnHeaderCell';
-import { StickyRowHeaderCell } from '../../../components/StickyRowHeaderCell';
-import { StickyTableRoot } from '../../../components/StickyTableRoot';
-import { asset } from '../../../core/blitzkit/asset';
-import { availableProvisions } from '../../../core/blitzkit/availableProvisions';
-import { checkConsumableProvisionInclusivity } from '../../../core/blitzkit/checkConsumableProvisionInclusivity';
-import { consumableDefinitions } from '../../../core/blitzkit/consumableDefinitions';
import { createDefaultSkills } from '../../../core/blitzkit/createDefaultSkills';
import { equipmentDefinitions } from '../../../core/blitzkit/equipmentDefinitions';
import { modelDefinitions } from '../../../core/blitzkit/modelDefinitions';
import { provisionDefinitions } from '../../../core/blitzkit/provisionDefinitions';
import { skillDefinitions } from '../../../core/blitzkit/skillDefinitions';
import { tankCharacteristics } from '../../../core/blitzkit/tankCharacteristics';
-import {
- TankDefinition,
- tankDefinitions,
-} from '../../../core/blitzkit/tankDefinitions';
-import { tankIcon } from '../../../core/blitzkit/tankIcon';
+import { tankDefinitions } from '../../../core/blitzkit/tankDefinitions';
import { tankToCompareMember } from '../../../core/blitzkit/tankToCompareMember';
-import { BlitzkitButtonWatermark } from '../../../icons/BlitzkitButtonWatermark';
-import { theme } from '../../../stitches.config';
import * as CompareEphemeral from '../../../stores/compareEphemeral';
-import * as ComparePersistent from '../../../stores/comparePersistent';
-import { EquipmentMatrix } from '../../../stores/duel';
-import { permanentSkills } from '../tankopedia/[id]/components/Characteristics/components/Skills/constants';
-import { TankSearch } from '../tankopedia/components/TankSearch';
-import { TankControl } from './components/TankControl';
-
-const insertionMarkers: { element: HTMLDivElement; index: number }[] = [];
+import { CompareTable } from './components/CompareTable';
+import { Controls } from './components/Controls';
+import { DamageWarning } from './components/DamageWarning';
export default function Page() {
const pathname = usePathname();
const router = useRouter();
const awaitedTankDefinitions = use(tankDefinitions);
const awaitedSkillDefinitions = use(skillDefinitions);
- const awaitedConsumableDefinitions = use(consumableDefinitions);
const searchParams = useSearchParams();
const members = CompareEphemeral.use((state) => state.members);
const [addTankDialogOpen, setAddTankDialogOpen] = useState(false);
- const sorting = CompareEphemeral.use((state) => state.sorting);
const awaitedModelDefinitions = use(modelDefinitions);
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
const awaitedProvisionDefinitions = use(provisionDefinitions);
- const deltaMode = ComparePersistent.use((state) => state.deltaMode);
const crewSkills = CompareEphemeral.use((state) => state.crewSkills);
const mutateCompareEphemeral = CompareEphemeral.useMutation();
- const mutateComparePersistent = ComparePersistent.useMutation();
- const haveReactive = members.some((member) =>
- member.consumables.includes(33),
- );
- const haveDynamicArmor = members.some((member) =>
- member.consumables.includes(73),
- );
- const haveSpallLiner = members.some((member) =>
- member.provisions.includes(101),
- );
- const stats = useMemo(
+ const stats = useMemo[]>(
() =>
members.map((thisMember) =>
tankCharacteristics(
@@ -134,7 +77,6 @@ export default function Page() {
[searchParams],
);
- const hasNonRegularGun = members.some(({ gun }) => gun.type !== 'regular');
useEffect(() => {
router.push(
@@ -171,1325 +113,20 @@ export default function Page() {
}
}, []);
- function Row({
- value,
- name,
- display,
- deltaType = 'higherIsBetter',
- decimals,
- deltaNominalDisplay,
- indent,
- }: {
- name: string;
- value:
- | keyof Awaited>
- | ((
- member: Awaited>,
- ) => number | undefined);
- display?: (
- member: Awaited>,
- ) => number | string | undefined;
- deltaType?: 'higherIsBetter' | 'lowerIsBetter';
- decimals?: number;
- deltaNominalDisplay?: (delta: number) => string | number;
- indent?: boolean;
- }) {
- const values = stats.map((stat) =>
- typeof value === 'function' ? value(stat)! : (stat[value] as number),
- );
-
- return (
-
-
-
- {
- mutateCompareEphemeral((draft) => {
- draft.members.sort((memberA, memberB) => {
- const indexA = draft.members.indexOf(memberA);
- const indexB = draft.members.indexOf(memberB);
- const valueA = values[indexA];
- const valueB = values[indexB];
-
- return draft.sorting?.direction === 'ascending' &&
- draft.sorting.by === name
- ? valueA - valueB
- : valueB - valueA;
- });
-
- draft.sorting = {
- by: name,
- direction:
- draft.sorting?.direction === 'ascending' &&
- draft.sorting.by === name
- ? 'descending'
- : 'ascending',
- };
- });
- }}
- >
- {(sorting === undefined || sorting.by !== name) && (
-
- )}
- {sorting?.by === name && (
- <>
- {sorting.direction === 'ascending' && }
- {sorting.direction === 'descending' && }
- >
- )}
-
-
- {name}
-
-
-
- {values.map((value, index) => {
- const delta = value - values[0];
- const deltaPercentage = value / values[0] - 1;
- const normalizedDeltaPercentage = Math.round(
- Math.min(100, Math.abs(deltaPercentage) * 2 * 100 + 25),
- );
- const resolvedDisplayValue = display
- ? display(stats[index])
- : decimals === undefined
- ? value
- : value?.toFixed(decimals);
-
- return (
- values[0]
- : value < values[0]
- )
- ? `color-mix(in srgb, ${theme.colors.componentCallToActionInteractive_green} ${normalizedDeltaPercentage}%, ${theme.colors.componentCallToActionInteractive_green}00)`
- : `color-mix(in srgb, ${theme.colors.componentCallToActionInteractive_red} ${normalizedDeltaPercentage}%, ${theme.colors.componentCallToActionInteractive_red}00)`,
- }}
- >
-
-
- {resolvedDisplayValue}
-
-
- {delta !== 0 &&
- resolvedDisplayValue !== undefined &&
- values[0] !== undefined && (
- <>
- {deltaMode === 'absolute' && (
-
- (
- {`${delta > 0 ? '+' : ''}${
- deltaNominalDisplay
- ? deltaNominalDisplay(delta)
- : decimals === undefined
- ? delta
- : delta.toFixed(decimals)
- }`}
- )
-
- )}
-
- {deltaMode === 'percentage' && (
-
- (
- {`${deltaPercentage > 0 ? '+' : ''}${Math.round(
- deltaPercentage * 100,
- )}%`}
- )
-
- )}
- >
- )}
-
-
- );
- })}
-
- );
- }
-
- function Title({ children }: { children: string }) {
- return (
-
-
-
- {children}
-
-
- {members.map(({ key }) => (
-
- ))}
-
-
- );
- }
-
- function TankCard({ index, tank }: { index: number; tank: TankDefinition }) {
- const draggable = useRef(null);
-
- useEffect(() => {
- const initial = new Vector2();
- const current = new Vector2();
- const delta = new Vector2();
- const marker = new Vector2();
- let initialRect = draggable.current!.getBoundingClientRect();
- let dropIndex = -1;
-
- function handlePointerDown(event: PointerEvent) {
- event.preventDefault();
-
- initialRect = draggable.current!.getBoundingClientRect();
-
- delta.set(0, 0);
- current.copy(initial.set(event.clientX, event.clientY));
- window.addEventListener('pointermove', handlePointerMove);
- window.addEventListener('pointerup', handlePointerUp);
- }
- function handlePointerMove(event: PointerEvent) {
- event.preventDefault();
-
- current.set(event.clientX, event.clientY);
- delta.copy(current).sub(initial);
- insertionMarkers.forEach(({ element }) => {
- element.style.opacity = '0';
- });
-
- const distances = insertionMarkers.map((insertionMarker) => {
- const markerBounds = insertionMarker.element.getBoundingClientRect();
- marker.set(markerBounds.left, markerBounds.top);
-
- return {
- ...insertionMarker,
- distance: marker.distanceTo(current),
- };
- });
- const closest = distances.reduce((a, b) =>
- a.distance < b.distance ? a : b,
- );
-
- dropIndex = closest.index;
- closest.element.style.opacity = '1';
- draggable.current!.style.position = 'fixed';
- draggable.current!.style.cursor = 'grabbing';
- draggable.current!.style.zIndex = '1';
- draggable.current!.style.left = `${initialRect.left + delta.x}px`;
- draggable.current!.style.top = `${initialRect.top + delta.y}px`;
- }
- function handlePointerUp(event: PointerEvent) {
- event.preventDefault();
-
- draggable.current!.style.position = 'static';
- draggable.current!.style.zIndex = 'unset';
- draggable.current!.style.cursor = 'grab';
-
- insertionMarkers.forEach(({ element }) => {
- element.style.opacity = '0';
- });
-
- window.removeEventListener('pointermove', handlePointerMove);
- window.removeEventListener('pointerup', handlePointerUp);
-
- if (dropIndex === -1) return;
-
- mutateCompareEphemeral((draft) => {
- const fromIndex = index;
- const toIndex = dropIndex > index ? dropIndex - 1 : dropIndex;
-
- const member = draft.members[fromIndex];
- draft.members.splice(fromIndex, 1);
- draft.members.splice(toIndex, 0, member);
- });
-
- dropIndex = -1;
- }
-
- draggable.current?.addEventListener('pointerdown', handlePointerDown);
-
- return () => {
- draggable.current?.removeEventListener(
- 'pointerdown',
- handlePointerDown,
- );
- };
- }, []);
-
- return (
-
-
-
-
-
-
-
-
- {tank.name}
-
-
-
-
-
-
- );
- }
-
- function InsertionMarker({ index }: { index: number }) {
- const marker = useRef(null);
-
- useEffect(() => {
- insertionMarkers.push({
- element: marker.current!,
- index,
- });
-
- return () => {
- insertionMarkers.splice(
- insertionMarkers.findIndex(
- (insertionMarker) => insertionMarker.element === marker.current!,
- ),
- 1,
- );
- };
- });
-
- return (
-
- );
- }
-
return (
- {(haveReactive || haveDynamicArmor || haveSpallLiner) && (
-
-
-
-
-
- Consumables like Reactive Armor and Dynamic Armor System and
- provisions like Spall Liner may reduce other tanks' damage.
-
-
- )}
-
-
-
-
-
-
-
-
-
-
- {
- mutateCompareEphemeral((draft) => {
- draft.members.push(
- tankToCompareMember(
- tank,
- awaitedProvisionDefinitions,
- ),
- );
- draft.sorting = undefined;
- });
- setAddTankDialogOpen(false);
- }}
- onSelectAll={(tanks) => {
- mutateCompareEphemeral((draft) => {
- draft.members.push(
- ...tanks.map((tank) =>
- tankToCompareMember(
- tank,
- awaitedProvisionDefinitions,
- ),
- ),
- );
- draft.sorting = undefined;
- });
- setAddTankDialogOpen(false);
- }}
- />
-
-
-
-
-
-
-
- {
- mutateComparePersistent((draft) => {
- draft.deltaMode = value as ComparePersistent.DeltaMode;
- });
- }}
- >
-
- No deltas
-
-
- Percentage
-
-
- Absolute
-
-
-
+
+
{members.length > 0 && (
-
-
-
-
-
-
-
-
-
-
- {members.map(({ tank, key }, index) => {
- return ;
- })}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- mutateCompareEphemeral((draft) => {
- draft.crewSkills = skills;
- });
- }}
- />
-
-
-
-
-
-
-
-
-
-
- {members.map(
- (
- {
- equipmentMatrix,
- tank,
- key,
- provisions,
- gun,
- shell,
- turret,
- engine,
- track,
- consumables,
- },
- index,
- ) => {
- const equipmentPreset =
- awaitedEquipmentDefinitions.presets[tank.equipment];
- const provisionsList = availableProvisions(
- tank,
- gun,
- awaitedProvisionDefinitions,
- );
- const consumablesList = Object.values(
- awaitedConsumableDefinitions,
- ).filter((consumable) =>
- checkConsumableProvisionInclusivity(
- consumable,
- tank,
- gun,
- ),
- );
-
- return (
-
-
-
-
-
-
-
-
- {
- mutateCompareEphemeral((draft) => {
- draft.members[index] = {
- ...draft.members[index],
- ...modules,
- };
- });
- }}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- provision.id,
- )}
- selected={provisions}
- disabled={
- tank.provisions === provisions.length
- }
- onChange={(provisions) => {
- mutateCompareEphemeral((draft) => {
- draft.members[index].provisions =
- provisions;
- });
- }}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- mutateCompareEphemeral((draft) => {
- draft.members[index].equipmentMatrix =
- matrix;
- draft.sorting = undefined;
- });
- }}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- mutateCompareEphemeral((draft) => {
- draft.members[index].consumables =
- consumables;
- });
- }}
- disabled={
- tank.consumables === consumables.length
- }
- />
-
-
-
-
-
-
-
-
-
- );
- },
- )}
-
-
-
-
-
-
-
- Firepower
-
-
-
- {members.map(({ gun, shell, key }, index) => (
-
-
-
- {gun.shells.map((thisShell, shellIndex) => (
- {
- mutateCompareEphemeral((draft) => {
- draft.members[index].shell = thisShell;
- });
- }}
- >
-
-
- ))}
-
-
-
- ))}
-
-
-
-
-
- stats.shellReload ??
- stats.shellReloads!.reduce((a, b) => a + b, 0) /
- stats.shellReloads!.length
- }
- display={(stats) =>
- stats.shellReload?.toFixed(2) ??
- stats
- .shellReloads!.map((reload) => reload.toFixed(2))
- .join(', ')
- }
- deltaNominalDisplay={(delta) => delta.toFixed(2)}
- />
- {hasNonRegularGun && (
- <>
-
-
- >
- )}
-
-
- {hasNonRegularGun && (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Maneuverability
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Survivability
-
-
- (stats.fireChance * 100).toFixed(0)}
- deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
- />
-
- (stats.camouflageStill * 100).toFixed(0)}
- deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
- />
- (stats.camouflageMoving * 100).toFixed(0)}
- deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
- />
-
- (stats.camouflageShootingStill * 100).toFixed(0)
- }
- deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
- />
-
- (stats.camouflageShootingMoving * 100).toFixed(0)
- }
- deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
- />
-
- (stats.camouflageCaughtOnFire * 100).toFixed(0)
- }
- deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
- />
-
-
-
-
-
-
+
;
+
export function tankCharacteristics(
{
tank,