feat: dragging

This commit is contained in:
tresabhi committed 2024-11-16 23:11:31 -06:00
1 parent aee1ddb948
commit 71c1f8dac9
7 files changed
+316 -206

No files matched your search

@@ -1,5 +1,5 @@
.tank-search-card {
cursor: pointer;
user-select: none;
display: flex;
flex-direction: column;
align-items: center;
@@ -10,6 +10,10 @@
overflow: hidden;
}
.tank-search-card[data-provide-link='true'] {
cursor: pointer;
}
.tank-search-card .link {
width: 100%;
display: flex;
@@ -1,215 +1,105 @@
import {
asset,
fetchModelDefinitions,
normalizeBoundingBox,
resolveDpm,
tankIcon,
TankType,
unionBoundingBox,
type TankDefinition,
} from '@blitzkit/core';
import { useStore } from '@nanostores/react';
import { Flex, Link, Text } from '@radix-ui/themes';
import { asset, tankIcon, TankType, type TankDefinition } from '@blitzkit/core';
import { Flex, Link, Text, type TextProps } from '@radix-ui/themes';
import { uniq } from 'lodash-es';
import { useMemo } from 'react';
import { resolveReload } from '../../core/blitzkit/resolveReload';
import { forwardRef } from 'react';
import { TankopediaPersistent } from '../../stores/tankopediaPersistent';
import { $tankopediaSort } from '../../stores/tankopediaSort';
import { classIcons } from '../ClassIcon';
import { MAX_RECENTLY_VIEWED } from '../TankSearch/constants';
import './index.css';
interface TankCardProps {
type TankCardProps = TextProps & {
tank: TankDefinition;
onSelect?: (tank: TankDefinition) => void;
}
onTankSelect?: (tank: TankDefinition) => void;
discriminator?: string;
noLink?: boolean;
};
const modelDefinitions = await fetchModelDefinitions();
export const TankCard = forwardRef<HTMLSpanElement, TankCardProps>(
(
{
tank,
discriminator,
onTankSelect: onSelect,
noLink,
style,
...props
}: TankCardProps,
ref,
) => {
const provideLink = !noLink && onSelect === undefined;
const mutateTankopediaPersistent = TankopediaPersistent.useMutation();
const Icon = classIcons[tank.class];
export function TankCard({ tank, onSelect }: TankCardProps) {
const mutateTankopediaPersistent = TankopediaPersistent.useMutation();
const Icon = classIcons[tank.class];
const tankopediaSort = useStore($tankopediaSort);
const discriminator = useMemo(() => {
if (tankopediaSort.by.startsWith('meta')) return undefined;
const turret = tank.turrets.at(-1)!;
const gun = turret.guns.at(-1)!;
const shell0 = gun.gun_type!.value.base.shells[0];
const shell1 = gun.gun_type!.value.base.shells[1];
const tracks = tank.tracks.at(-1)!;
const engine = tank.engines.at(-1)!;
const tankModelDefinition = modelDefinitions.models[tank.id];
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition =
turretModelDefinition.guns[gun.gun_type!.value.base.id];
switch (tankopediaSort.by) {
case 'fire.aimTime':
return gun.gun_type!.value.base.aim_time.toFixed(2);
case 'fire.caliber':
return shell0.caliber.toFixed(0);
case 'fire.damage':
return shell0.armor_damage.toFixed(0);
case 'fire.moduleDamage':
return shell0.armor_damage.toFixed(0);
case 'fire.dispersionMoving':
return tracks.dispersion_move.toFixed(3);
case 'fire.dispersionStill':
return gun.gun_type!.value.base.dispersion_base.toFixed(3);
case 'fire.dpm':
return resolveDpm(gun, shell0).toFixed(0);
case 'fire.dpmPremium':
return shell1 ? resolveDpm(gun, shell1).toFixed(0) : '--';
case 'fire.reload':
return resolveReload(gun).toFixed(2);
case 'fire.standardPenetration':
return shell0.penetration.near.toFixed(0);
case 'fire.premiumPenetration':
return shell1 ? shell1.penetration.near.toFixed(0) : '--';
case 'fire.shellVelocity':
return shell0.velocity.toFixed(0);
case 'fire.shellCapacity':
return gun.gun_type!.value.base.shell_capacity.toFixed(0);
case 'fire.shellRange':
return shell0.range.toFixed(0);
case 'fire.gunDepression':
return (
gunModelDefinition.pitch.max +
(tankModelDefinition.initial_turret_rotation?.pitch ?? 0)
).toFixed(1);
case 'fire.gunElevation':
return (
-gunModelDefinition.pitch.min -
(tankModelDefinition.initial_turret_rotation?.pitch ?? 0)
).toFixed(1);
case 'maneuverability.forwardsSpeed':
return tank.speed_forwards.toFixed(0);
case 'maneuverability.backwardsSpeed':
return tank.speed_backwards.toFixed(0);
case 'maneuverability.power':
return engine.power.toFixed(0);
case 'maneuverability.powerToWeight':
return (
(1000 * engine.power) /
(tank.weight +
engine.weight +
tracks.weight +
turret.weight +
gun.gun_type!.value.base.weight)
).toFixed(1);
case 'maneuverability.weight':
return (
(tank.weight +
engine.weight +
tracks.weight +
turret.weight +
gun.gun_type!.value.base.weight) /
1000
).toFixed(1);
case 'maneuverability.traverseSpeed':
return tracks.traverse_speed.toFixed(1);
case 'survivability.health':
return (tank.health + turret.health).toFixed(0);
case 'survivability.viewRange':
return turret.view_range.toFixed(0);
case 'survivability.camouflageStill':
return (tank.camouflage_still * 100).toFixed(0);
case 'survivability.camouflageMoving':
return (tank.camouflage_moving * 100).toFixed(0);
case 'survivability.camouflageShooting':
return (
tank.camouflage_still *
gun.gun_type!.value.base.camouflage_loss *
100
).toFixed(0);
case 'survivability.volume': {
const dimensions = normalizeBoundingBox(
unionBoundingBox(
tankModelDefinition.bounding_box,
turretModelDefinition.bounding_box,
),
);
return (dimensions.x * dimensions.y * dimensions.z).toFixed(0);
}
case 'survivability.length': {
const bounds = normalizeBoundingBox(
unionBoundingBox(
tankModelDefinition.bounding_box,
turretModelDefinition.bounding_box,
),
);
return Math.max(bounds.x, bounds.y, bounds.z).toFixed(0);
}
}
}, [tankopediaSort.by]);
return (
<Text
tabIndex={onSelect ? 0 : undefined}
size="1"
color={
tank.type === TankType.COLLECTOR
? 'blue'
: tank.type === TankType.PREMIUM
? 'amber'
: 'gray'
}
highContrast={tank.type === TankType.RESEARCHABLE}
onClick={() => {
onSelect?.(tank);
mutateTankopediaPersistent((draft) => {
draft.recentlyViewed = uniq([tank.id, ...draft.recentlyViewed]).slice(
0,
MAX_RECENTLY_VIEWED,
);
});
}}
className="tank-search-card"
style={{
backgroundImage: `url(${asset(`flags/scratched/${tank.nation}.webp`)})`,
}}
>
<Link
className="link"
underline="hover"
href={onSelect ? '#' : `/tools/tankopedia/${tank.id}`}
onClick={(event) => {
if (onSelect) event.preventDefault();
return (
<Text
ref={ref}
tabIndex={onSelect ? 0 : undefined}
size="1"
color={
tank.type === TankType.COLLECTOR
? 'blue'
: tank.type === TankType.PREMIUM
? 'amber'
: 'gray'
}
highContrast={tank.type === TankType.RESEARCHABLE}
onClick={() => {
onSelect?.(tank);
mutateTankopediaPersistent((draft) => {
draft.recentlyViewed = uniq([
tank.id,
...draft.recentlyViewed,
]).slice(0, MAX_RECENTLY_VIEWED);
});
}}
className="tank-search-card"
data-provide-link={provideLink}
style={{
backgroundImage: `url(${asset(`flags/scratched/${tank.nation}.webp`)})`,
...style,
}}
{...props}
>
<img alt={tank.name} src={tankIcon(tank.id)} className="image" />
<Flex
justify="center"
gap="1"
align="center"
overflow="hidden"
width="100%"
<Link
className="link"
underline="hover"
href={provideLink ? `/tools/tankopedia/${tank.id}` : undefined}
>
<Icon className="class-icon" />
<Text align="center" className="name">
{tank.name}
</Text>
</Flex>
<img
alt={tank.name}
src={tankIcon(tank.id)}
className="image"
draggable={false}
/>
{discriminator && (
<Text
color="gray"
<Flex
justify="center"
gap="1"
align="center"
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
overflow="hidden"
width="100%"
>
{discriminator}
</Text>
)}
</Link>
</Text>
);
}
<Icon className="class-icon" />
<Text align="center" className="name">
{tank.name}
</Text>
</Flex>
{discriminator && (
<Text
color="gray"
align="center"
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{discriminator}
</Text>
)}
</Link>
</Text>
);
},
);
@@ -0,0 +1,148 @@
import {
fetchModelDefinitions,
normalizeBoundingBox,
resolveDpm,
unionBoundingBox,
type TankDefinition,
} from '@blitzkit/core';
import { useStore } from '@nanostores/react';
import { useMemo } from 'react';
import { resolveReload } from '../../../core/blitzkit/resolveReload';
import { $tankopediaSort } from '../../../stores/tankopediaSort';
import { TankCard } from '../../TankCard';
interface TankSearchCardProps {
tank: TankDefinition;
onSelect?: (tank: TankDefinition) => void;
}
const modelDefinitions = await fetchModelDefinitions();
export function TankSearchCard({ tank, onSelect }: TankSearchCardProps) {
const tankopediaSort = useStore($tankopediaSort);
const discriminator = useMemo(() => {
if (tankopediaSort.by.startsWith('meta')) return undefined;
const turret = tank.turrets.at(-1)!;
const gun = turret.guns.at(-1)!;
const shell0 = gun.gun_type!.value.base.shells[0];
const shell1 = gun.gun_type!.value.base.shells[1];
const tracks = tank.tracks.at(-1)!;
const engine = tank.engines.at(-1)!;
const tankModelDefinition = modelDefinitions.models[tank.id];
const turretModelDefinition = tankModelDefinition.turrets[turret.id];
const gunModelDefinition =
turretModelDefinition.guns[gun.gun_type!.value.base.id];
switch (tankopediaSort.by) {
case 'fire.aimTime':
return gun.gun_type!.value.base.aim_time.toFixed(2);
case 'fire.caliber':
return shell0.caliber.toFixed(0);
case 'fire.damage':
return shell0.armor_damage.toFixed(0);
case 'fire.moduleDamage':
return shell0.armor_damage.toFixed(0);
case 'fire.dispersionMoving':
return tracks.dispersion_move.toFixed(3);
case 'fire.dispersionStill':
return gun.gun_type!.value.base.dispersion_base.toFixed(3);
case 'fire.dpm':
return resolveDpm(gun, shell0).toFixed(0);
case 'fire.dpmPremium':
return shell1 ? resolveDpm(gun, shell1).toFixed(0) : '--';
case 'fire.reload':
return resolveReload(gun).toFixed(2);
case 'fire.standardPenetration':
return shell0.penetration.near.toFixed(0);
case 'fire.premiumPenetration':
return shell1 ? shell1.penetration.near.toFixed(0) : '--';
case 'fire.shellVelocity':
return shell0.velocity.toFixed(0);
case 'fire.shellCapacity':
return gun.gun_type!.value.base.shell_capacity.toFixed(0);
case 'fire.shellRange':
return shell0.range.toFixed(0);
case 'fire.gunDepression':
return (
gunModelDefinition.pitch.max +
(tankModelDefinition.initial_turret_rotation?.pitch ?? 0)
).toFixed(1);
case 'fire.gunElevation':
return (
-gunModelDefinition.pitch.min -
(tankModelDefinition.initial_turret_rotation?.pitch ?? 0)
).toFixed(1);
case 'maneuverability.forwardsSpeed':
return tank.speed_forwards.toFixed(0);
case 'maneuverability.backwardsSpeed':
return tank.speed_backwards.toFixed(0);
case 'maneuverability.power':
return engine.power.toFixed(0);
case 'maneuverability.powerToWeight':
return (
(1000 * engine.power) /
(tank.weight +
engine.weight +
tracks.weight +
turret.weight +
gun.gun_type!.value.base.weight)
).toFixed(1);
case 'maneuverability.weight':
return (
(tank.weight +
engine.weight +
tracks.weight +
turret.weight +
gun.gun_type!.value.base.weight) /
1000
).toFixed(1);
case 'maneuverability.traverseSpeed':
return tracks.traverse_speed.toFixed(1);
case 'survivability.health':
return (tank.health + turret.health).toFixed(0);
case 'survivability.viewRange':
return turret.view_range.toFixed(0);
case 'survivability.camouflageStill':
return (tank.camouflage_still * 100).toFixed(0);
case 'survivability.camouflageMoving':
return (tank.camouflage_moving * 100).toFixed(0);
case 'survivability.camouflageShooting':
return (
tank.camouflage_still *
gun.gun_type!.value.base.camouflage_loss *
100
).toFixed(0);
case 'survivability.volume': {
const dimensions = normalizeBoundingBox(
unionBoundingBox(
tankModelDefinition.bounding_box,
turretModelDefinition.bounding_box,
),
);
return (dimensions.x * dimensions.y * dimensions.z).toFixed(0);
}
case 'survivability.length': {
const bounds = normalizeBoundingBox(
unionBoundingBox(
tankModelDefinition.bounding_box,
turretModelDefinition.bounding_box,
),
);
return Math.max(bounds.x, bounds.y, bounds.z).toFixed(0);
}
}
}, [tankopediaSort.by]);
return (
<TankCard
key={tank.id}
onTankSelect={onSelect}
tank={tank}
discriminator={discriminator}
/>
);
}
@@ -24,7 +24,7 @@ import {
} from '../../stores/tankopediaPersistent/constants';
import { $tankopediaSort } from '../../stores/tankopediaSort';
import { ExperimentIcon } from '../ExperimentIcon';
import { TankCard } from '../TankCard';
import { TankSearchCard } from './components/Card';
import { FilterControl } from './components/FilterControl';
import { NoResults } from './components/NoResults';
import { RecentlyViewed } from './components/RecentlyViewed';
@@ -504,7 +504,11 @@ export const TankSearch = memo<TankSearchProps>(
{tanks.length > 0 && (
<TankCardWrapper>
{tanks.map((tank) => (
<TankCard key={tank.id} onSelect={onSelect} tank={tank} />
<TankSearchCard
tank={tank}
key={tank.id}
onSelect={onSelect}
/>
))}
{times(
@@ -0,0 +1,64 @@
import type { TankDefinition } from '@blitzkit/core';
import {
useCallback,
useRef,
type PointerEvent as ReactPointerEvent,
} from 'react';
import { TankCard } from '../TankCard';
interface TierListTileProps {
tank: TankDefinition;
}
export function TierListTile({ tank }: TierListTileProps) {
const card = useRef<HTMLDivElement>(null);
const lastPosition = useRef({ x: 0, y: 0 });
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLSpanElement>) => {
if (!card.current) return;
const rect = card.current.getBoundingClientRect();
card.current.style.position = 'fixed';
card.current.style.cursor = 'grabbing';
card.current.style.zIndex = '1';
card.current.style.left = `${rect.left}px`;
card.current.style.top = `${rect.top}px`;
lastPosition.current = { x: event.clientX, y: event.clientY };
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
},
[],
);
const handlePointerMove = useCallback((event: PointerEvent) => {
if (!card.current) return;
const rect = card.current.getBoundingClientRect();
const dx = event.clientX - lastPosition.current.x;
const dy = event.clientY - lastPosition.current.y;
card.current.style.left = `${rect.left + dx}px`;
card.current.style.top = `${rect.top + dy}px`;
lastPosition.current = { x: event.clientX, y: event.clientY };
}, []);
const handlePointerUp = useCallback(() => {
if (!card.current) return;
card.current.style.position = 'static';
card.current.style.zIndex = 'unset';
card.current.style.cursor = 'grab';
}, []);
return (
<TankCard
ref={card}
noLink
tank={tank}
style={{ cursor: 'grab' }}
onPointerDown={handlePointerDown}
/>
);
}
@@ -1,6 +1,6 @@
import { fetchTankDefinitions } from '@blitzkit/core';
import { TankCard } from '../TankCard';
import { TankCardWrapper } from '../TankSearch/components/TankCardWrapper';
import { TierListTile } from './Tile';
const tankDefinitions = await fetchTankDefinitions();
const tanks = Object.values(tankDefinitions.tanks);
@@ -9,7 +9,7 @@ export function TierListTiles() {
return (
<TankCardWrapper>
{tanks.map((tank) => (
<TankCard tank={tank} />
<TierListTile key={tank.id} tank={tank} />
))}
</TankCardWrapper>
);
@@ -1,6 +1,6 @@
import { PageWrapper } from '../../../components/PageWrapper';
import { TierListTable } from '../../../components/TierList/Table';
import { TierListTiles } from '../../../components/TierList/Tiles';
import { TierListTable } from '../../../components/TierList/Table';
import { TankopediaPersistent } from '../../../stores/tankopediaPersistent';
export function Page() {