feat(v2): Phase 1 — map-first layout matching v1

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>
This commit is contained in:
Erik 2026-04-12 15:38:14 +02:00
parent 3791c01bf3
commit 2c4b8d3afb
16 changed files with 995 additions and 151 deletions

View file

@ -0,0 +1,54 @@
import React, { useCallback } from 'react';
import { MapTransformProvider } from '../../contexts/MapTransformContext';
import { MapView } from './MapView';
import { Sidebar } from './Sidebar';
import { usePlayerColors } from '../../hooks/usePlayerColors';
import type { DashboardState } from '../../hooks/useLiveData';
interface Props {
data: DashboardState;
onViewToggle: () => void;
}
export const MapLayout: React.FC<Props> = ({ data, onViewToggle }) => {
const getColor = usePlayerColors();
// Build flat players array from characters map
const players = Array.from(data.characters.values())
.filter(c => c.telemetry)
.map(c => c.telemetry!);
// Build vitals map
const vitalsMap = new Map(
Array.from(data.characters.values())
.filter(c => c.vitals)
.map(c => [c.name, c.vitals!])
);
const handleSelectPlayer = useCallback((name: string) => {
// TODO: zoom map to player position
console.log('Select player:', name);
}, []);
return (
<MapTransformProvider>
<div className="ml-layout">
<Sidebar
players={players}
vitals={vitalsMap}
serverHealth={data.serverHealth}
totalRares={data.totalRares}
totalKills={data.totalKills}
getColor={getColor}
onSelectPlayer={handleSelectPlayer}
onViewToggle={onViewToggle}
/>
<MapView
players={players}
getColor={getColor}
onSelectPlayer={handleSelectPlayer}
/>
</div>
</MapTransformProvider>
);
};

View file

@ -0,0 +1,111 @@
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 &middot; {tooltip.player.kills?.toLocaleString()} kills
</div>
)}
{/* Coordinate display */}
{worldCoord && (
<div className="ml-coords">
{formatCoord(worldCoord.ns, worldCoord.ew)}
</div>
)}
</div>
);
};

View file

@ -0,0 +1,46 @@
import React, { useMemo } from 'react';
import { worldToPx } from '../../utils/coordinates';
import type { TelemetrySnapshot } from '../../types';
interface Props {
players: TelemetrySnapshot[];
imgW: number;
imgH: number;
getColor: (name: string) => string;
onHover: (player: TelemetrySnapshot | null, x: number, y: number) => void;
onSelect: (name: string) => void;
}
export const PlayerDots: React.FC<Props> = React.memo(({ players, imgW, imgH, getColor, onHover, onSelect }) => {
const dots = useMemo(() =>
players.filter(p => p.ew !== undefined && p.ns !== undefined).map(p => ({
...p,
pos: worldToPx(p.ew, p.ns, imgW, imgH),
color: getColor(p.character_name),
})),
[players, imgW, imgH, getColor]);
return (
<div className="ml-dots-layer">
{dots.map(d => (
<div
key={d.character_name}
className="ml-dot"
style={{
left: d.pos.x,
top: d.pos.y,
backgroundColor: d.color,
}}
onMouseEnter={(e) => {
const rect = e.currentTarget.closest('.ml-map-container')?.getBoundingClientRect();
if (rect) onHover(d, e.clientX - rect.left, e.clientY - rect.top);
}}
onMouseLeave={() => onHover(null, 0, 0)}
onClick={() => onSelect(d.character_name)}
/>
))}
</div>
);
});
PlayerDots.displayName = 'PlayerDots';

View file

@ -0,0 +1,88 @@
import React, { useState, useMemo, useCallback } from 'react';
import { PlayerList } from '../sidebar/PlayerList';
import { SortButtons, type SortKey } from '../sidebar/SortButtons';
import type { TelemetrySnapshot, VitalsMessage, ServerHealth } from '../../types';
interface Props {
players: TelemetrySnapshot[];
vitals: Map<string, VitalsMessage>;
serverHealth: ServerHealth | null;
totalRares: number;
totalKills: number;
getColor: (name: string) => string;
onSelectPlayer: (name: string) => void;
onViewToggle: () => void;
}
export const Sidebar: React.FC<Props> = ({
players, vitals, serverHealth, totalRares, totalKills, getColor, onSelectPlayer, onViewToggle,
}) => {
const [sortKey, setSortKey] = useState<SortKey>('name');
const [filter, setFilter] = useState('');
const serverKph = useMemo(() =>
players.reduce((sum, p) => sum + (parseInt(p.kills_per_hour) || 0), 0),
[players]);
const isOnline = serverHealth?.status?.toLowerCase() === 'online' || serverHealth?.status?.toLowerCase() === 'up';
const sorted = useMemo(() => {
let list = [...players];
if (filter) list = list.filter(p => p.character_name.toLowerCase().startsWith(filter.toLowerCase()));
switch (sortKey) {
case 'kph': list.sort((a, b) => (parseInt(b.kills_per_hour) || 0) - (parseInt(a.kills_per_hour) || 0)); break;
case 'skills': list.sort((a, b) => (b.kills || 0) - (a.kills || 0)); break;
case 'srares': list.sort((a, b) => (b.session_rares ?? 0) - (a.session_rares ?? 0)); break;
case 'tkills': list.sort((a, b) => (b.total_kills ?? 0) - (a.total_kills ?? 0)); break;
case 'kpr': list.sort((a, b) => {
const ar = (a.total_kills ?? 0) / Math.max(1, a.total_rares ?? 1);
const br = (b.total_kills ?? 0) / Math.max(1, b.total_rares ?? 1);
return ar - br;
}); break;
default: list.sort((a, b) => a.character_name.localeCompare(b.character_name));
}
return list;
}, [players, sortKey, filter]);
return (
<div className="ml-sidebar">
{/* Header */}
<div className="ml-sidebar-header">
<span className="ml-sidebar-title">Active Mosswart Enjoyers ({players.length})</span>
<button className="ml-view-toggle" onClick={onViewToggle}>Dashboard</button>
</div>
{/* Server status */}
<div className="ml-server-status">
<span className={`ml-status-dot ${isOnline ? 'online' : 'offline'}`} />
<span className="ml-status-text">Coldeve</span>
{serverHealth?.latency_ms != null && <span className="ml-status-latency">{serverHealth.latency_ms}ms</span>}
</div>
{/* Aggregate counters */}
<div className="ml-counters">
<div className="ml-counter rares"><span className="ml-counter-val">{totalRares}</span><span className="ml-counter-lbl">Rares</span></div>
<div className={`ml-counter kph ${serverKph > 5000 ? 'ultra' : ''}`}><span className="ml-counter-val">{serverKph.toLocaleString()}</span><span className="ml-counter-lbl">Server KPH</span></div>
<div className="ml-counter kills"><span className="ml-counter-val">{totalKills.toLocaleString()}</span><span className="ml-counter-lbl">Kills</span></div>
</div>
{/* Sort + filter */}
<SortButtons value={sortKey} onChange={setSortKey} />
<input
className="ml-filter"
type="text"
placeholder="Filter players..."
value={filter}
onChange={e => setFilter(e.target.value)}
/>
{/* Player list */}
<PlayerList
players={sorted}
vitals={vitals}
getColor={getColor}
onSelect={onSelectPlayer}
/>
</div>
);
};

View file

@ -0,0 +1,24 @@
import React from 'react';
import { PlayerRow } from './PlayerRow';
import type { TelemetrySnapshot, VitalsMessage } from '../../types';
interface Props {
players: TelemetrySnapshot[];
vitals: Map<string, VitalsMessage>;
getColor: (name: string) => string;
onSelect: (name: string) => void;
}
export const PlayerList: React.FC<Props> = ({ players, vitals, getColor, onSelect }) => (
<ul className="ml-player-list">
{players.map(p => (
<PlayerRow
key={p.character_name}
player={p}
vitals={vitals.get(p.character_name) ?? null}
color={getColor(p.character_name)}
onSelect={() => onSelect(p.character_name)}
/>
))}
</ul>
);

View file

@ -0,0 +1,59 @@
import React from 'react';
import { formatCoord } from '../../utils/coordinates';
import type { TelemetrySnapshot, VitalsMessage } from '../../types';
interface Props {
player: TelemetrySnapshot;
vitals: VitalsMessage | null;
color: string;
onSelect: () => void;
}
export const PlayerRow: React.FC<Props> = React.memo(({ player: p, vitals: v, color, onSelect }) => {
const vtState = (p.vt_state || 'idle').toLowerCase();
const isActive = vtState === 'combat' || vtState === 'hunt';
return (
<li className="ml-player-row" style={{ borderLeftColor: color }} onClick={onSelect}>
{/* Row 1: Name + coords */}
<div className="ml-pr-name">{p.character_name}</div>
<div className="ml-pr-coords">{formatCoord(p.ns, p.ew)}</div>
{/* Row 2: Vital bars */}
<div className="ml-pr-vitals">
<div className="ml-vital-bar hp">
<div className="ml-vital-fill" style={{ width: `${v?.health_percentage ?? 0}%` }} />
</div>
<div className="ml-vital-bar sta">
<div className="ml-vital-fill" style={{ width: `${v?.stamina_percentage ?? 0}%` }} />
</div>
<div className="ml-vital-bar mana">
<div className="ml-vital-fill" style={{ width: `${v?.mana_percentage ?? 0}%` }} />
</div>
</div>
{/* Row 3: Stats */}
<div className="ml-pr-stats">
<span className="ml-stat" title="Session kills">{p.kills?.toLocaleString() ?? 0}</span>
<span className="ml-stat" title="Total kills">{(p.total_kills ?? 0).toLocaleString()}</span>
<span className="ml-stat" title="Kills per hour">{p.kills_per_hour ?? '0'} kph</span>
</div>
{/* Row 4: Rares + meta */}
<div className="ml-pr-stats">
<span className="ml-stat" title="Session rares">{p.session_rares ?? 0}r</span>
<span className="ml-stat" title="Total rares">{p.total_rares ?? 0}r</span>
<span className={`ml-meta-pill ${isActive ? 'active' : ''}`}>{p.vt_state || 'idle'}</span>
</div>
{/* Row 5: Time + deaths + tapers */}
<div className="ml-pr-stats">
<span className="ml-stat" title="Online time">{p.onlinetime?.replace(/^00\./, '') ?? '--'}</span>
<span className="ml-stat" title="Deaths">{p.deaths ?? '0'}d</span>
<span className="ml-stat" title="Prismatic tapers">{p.prismatic_taper_count ?? '0'}t</span>
</div>
</li>
);
});
PlayerRow.displayName = 'PlayerRow';

View file

@ -0,0 +1,31 @@
import React from 'react';
export type SortKey = 'name' | 'kph' | 'skills' | 'srares' | 'tkills' | 'kpr';
const SORTS: { key: SortKey; label: string }[] = [
{ key: 'name', label: 'Name' },
{ key: 'kph', label: 'KPH' },
{ key: 'skills', label: 'S.Kills' },
{ key: 'srares', label: 'S.Rares' },
{ key: 'tkills', label: 'T.Kills' },
{ key: 'kpr', label: 'KPR' },
];
interface Props {
value: SortKey;
onChange: (key: SortKey) => void;
}
export const SortButtons: React.FC<Props> = ({ value, onChange }) => (
<div className="ml-sort-buttons">
{SORTS.map(s => (
<button
key={s.key}
className={`ml-sort-btn ${value === s.key ? 'active' : ''}`}
onClick={() => onChange(s.key)}
>
{s.label}
</button>
))}
</div>
);