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,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>
);