Rebuilds the v1 map-centric experience in React: Layout: - 400px sidebar on left, interactive map on right (flex, 100vh) - Exact same proportions and dark theme as v1 Sidebar (top→bottom): - Header with active player count + Dashboard toggle button - Server status dot (Coldeve online/offline with pulse) - Aggregate counters: Rares (gold), Server KPH (blue glow), Kills (red) - 6 sort buttons (Name, KPH, S.Kills, S.Rares, T.Kills, KPR) - Player name filter - Scrollable player list with per-row: - Name + coordinates - HP/Stamina/Mana vital bars (red/orange/blue gradients) - Session kills, total kills, KPH - Session rares, total rares, VTank meta state pill - Online time, deaths, prismatic tapers - Color-coded left border per player Map: - dereth.png with CSS transform pan (drag) + zoom (wheel, 1.1x factor, max 20x) - Player dots (6px circles, color-matched to sidebar) - Hover tooltip (name, coords, kph, kills) - World coordinate display at cursor position - Fit-to-window on first load View toggle: Map View ↔ Dashboard with localStorage persistence. All v1 CSS ported under ml-* prefix, scoped via map-layout.css. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
111 lines
4.6 KiB
TypeScript
111 lines
4.6 KiB
TypeScript
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
|
import { useMapTransform } from '../../contexts/MapTransformContext';
|
|
import { pxToWorld, formatCoord } from '../../utils/coordinates';
|
|
import { PlayerDots } from './PlayerDots';
|
|
import type { TelemetrySnapshot } from '../../types';
|
|
|
|
interface Props {
|
|
players: TelemetrySnapshot[];
|
|
getColor: (name: string) => string;
|
|
onSelectPlayer: (name: string) => void;
|
|
}
|
|
|
|
export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer }) => {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const { transform, dispatch } = useMapTransform();
|
|
const [imgSize, setImgSize] = useState({ w: 0, h: 0 });
|
|
const [tooltip, setTooltip] = useState<{ x: number; y: number; player: TelemetrySnapshot } | null>(null);
|
|
const [worldCoord, setWorldCoord] = useState<{ ns: number; ew: number } | null>(null);
|
|
const dragRef = useRef<{ dragging: boolean; sx: number; sy: number; startOffX: number; startOffY: number }>({
|
|
dragging: false, sx: 0, sy: 0, startOffX: 0, startOffY: 0,
|
|
});
|
|
|
|
const onImgLoad = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
|
|
const img = e.currentTarget;
|
|
setImgSize({ w: img.naturalWidth, h: img.naturalHeight });
|
|
// Fit map to container on first load
|
|
if (containerRef.current) {
|
|
const cw = containerRef.current.clientWidth;
|
|
const ch = containerRef.current.clientHeight;
|
|
const scale = Math.min(cw / img.naturalWidth, ch / img.naturalHeight);
|
|
dispatch({ type: 'SET', scale, offX: (cw - img.naturalWidth * scale) / 2, offY: (ch - img.naturalHeight * scale) / 2 });
|
|
}
|
|
}, [dispatch]);
|
|
|
|
// Wheel zoom
|
|
const onWheel = useCallback((e: React.WheelEvent) => {
|
|
e.preventDefault();
|
|
const rect = containerRef.current?.getBoundingClientRect();
|
|
if (!rect) return;
|
|
const factor = e.deltaY < 0 ? 1.1 : 0.9;
|
|
dispatch({ type: 'ZOOM', factor, cx: e.clientX - rect.left, cy: e.clientY - rect.top });
|
|
}, [dispatch]);
|
|
|
|
// Pan drag
|
|
const onMouseDown = useCallback((e: React.MouseEvent) => {
|
|
if (e.button !== 0) return;
|
|
dragRef.current = { dragging: true, sx: e.clientX, sy: e.clientY, startOffX: transform.offX, startOffY: transform.offY };
|
|
}, [transform.offX, transform.offY]);
|
|
|
|
useEffect(() => {
|
|
const onMouseMove = (e: MouseEvent) => {
|
|
const d = dragRef.current;
|
|
if (d.dragging) {
|
|
dispatch({ type: 'SET', scale: transform.scale, offX: d.startOffX + (e.clientX - d.sx), offY: d.startOffY + (e.clientY - d.sy) });
|
|
}
|
|
// World coordinate display
|
|
if (containerRef.current && imgSize.w > 0) {
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
const coord = pxToWorld(e.clientX - rect.left, e.clientY - rect.top, transform.scale, transform.offX, transform.offY, imgSize.w, imgSize.h);
|
|
setWorldCoord(coord);
|
|
}
|
|
};
|
|
const onMouseUp = () => { dragRef.current.dragging = false; };
|
|
window.addEventListener('mousemove', onMouseMove);
|
|
window.addEventListener('mouseup', onMouseUp);
|
|
return () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); };
|
|
}, [dispatch, transform.scale, transform.offX, transform.offY, imgSize.w, imgSize.h]);
|
|
|
|
const handleDotHover = useCallback((player: TelemetrySnapshot | null, x: number, y: number) => {
|
|
setTooltip(player ? { x, y, player } : null);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="ml-map-container" ref={containerRef} onWheel={onWheel} onMouseDown={onMouseDown}>
|
|
<div
|
|
className="ml-map-group"
|
|
style={{ transform: `translate(${transform.offX}px, ${transform.offY}px) scale(${transform.scale})` }}
|
|
>
|
|
<img src="/dereth.png" alt="Dereth" className="ml-map-img" onLoad={onImgLoad} draggable={false} />
|
|
{imgSize.w > 0 && (
|
|
<PlayerDots
|
|
players={players}
|
|
imgW={imgSize.w}
|
|
imgH={imgSize.h}
|
|
getColor={getColor}
|
|
onHover={handleDotHover}
|
|
onSelect={onSelectPlayer}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tooltip */}
|
|
{tooltip && (
|
|
<div className="ml-tooltip" style={{ left: tooltip.x + 12, top: tooltip.y - 10 }}>
|
|
<strong>{tooltip.player.character_name}</strong>
|
|
<br />
|
|
{formatCoord(tooltip.player.ns, tooltip.player.ew)}
|
|
<br />
|
|
{tooltip.player.kills_per_hour} kph · {tooltip.player.kills?.toLocaleString()} kills
|
|
</div>
|
|
)}
|
|
|
|
{/* Coordinate display */}
|
|
{worldCoord && (
|
|
<div className="ml-coords">
|
|
{formatCoord(worldCoord.ns, worldCoord.ew)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|