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; + }); + }} + > + {thisShell.name} + + ))} + + + + ))} + + + + + + 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} + + + {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} - - - {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; - }); - }} - > - {thisShell.name} - - ))} - - - - ))} - - - - - - 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,