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

@ -1,3 +1,4 @@
import { useState } from 'react';
import { Layout } from './components/Layout';
import { GlobalStats } from './components/GlobalStats';
import { CharacterGrid } from './components/CharacterGrid';
@ -6,44 +7,43 @@ import { CombatTab } from './components/tabs/CombatTab';
import { RaresTab } from './components/tabs/RaresTab';
import { MapTab } from './components/tabs/MapTab';
import { InventoryTab } from './components/tabs/InventoryTab';
import { MapLayout } from './components/map/MapLayout';
import { useLiveData } from './hooks/useLiveData';
import './styles/global.css';
import './styles/map-layout.css';
type ViewMode = 'map' | 'dashboard';
export default function App() {
const { characters, serverHealth, totalRares, totalKills, recentRares } = useLiveData();
const [viewMode, setViewMode] = useState<ViewMode>(
() => (localStorage.getItem('v2-view') as ViewMode) || 'map'
);
const data = useLiveData();
const toggleView = () => {
const next = viewMode === 'map' ? 'dashboard' : 'map';
setViewMode(next);
localStorage.setItem('v2-view', next);
};
if (viewMode === 'map') {
return <MapLayout data={data} onViewToggle={toggleView} />;
}
const tabs = [
{
id: 'combat',
label: 'Combat',
content: <CombatTab characters={characters} />,
},
{
id: 'rares',
label: 'Rares',
content: <RaresTab characters={characters} totalRares={totalRares} totalKills={totalKills} recentRares={recentRares} />,
},
{
id: 'map',
label: 'Map',
content: <MapTab characters={characters} />,
},
{
id: 'inventory',
label: 'Inventory',
content: <InventoryTab />,
},
{ id: 'combat', label: 'Combat', content: <CombatTab characters={data.characters} /> },
{ id: 'rares', label: 'Rares', content: <RaresTab characters={data.characters} totalRares={data.totalRares} totalKills={data.totalKills} recentRares={data.recentRares} /> },
{ id: 'map', label: 'Map', content: <MapTab characters={data.characters} /> },
{ id: 'inventory', label: 'Inventory', content: <InventoryTab /> },
];
return (
<Layout>
<GlobalStats
activeChars={characters.size}
totalKills={totalKills}
totalRares={totalRares}
serverHealth={serverHealth}
/>
<CharacterGrid characters={characters} />
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
<button onClick={toggleView} className="tab-btn">Map View</button>
</div>
<GlobalStats activeChars={data.characters.size} totalKills={data.totalKills} totalRares={data.totalRares} serverHealth={data.serverHealth} />
<CharacterGrid characters={data.characters} />
<TabContainer tabs={tabs} />
</Layout>
);

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

View file

@ -0,0 +1,47 @@
import React, { createContext, useContext, useReducer, type Dispatch } from 'react';
interface MapTransform {
scale: number;
offX: number;
offY: number;
}
type Action =
| { type: 'SET'; scale: number; offX: number; offY: number }
| { type: 'ZOOM'; factor: number; cx: number; cy: number }
| { type: 'PAN'; dx: number; dy: number };
const MAX_ZOOM = 20;
const MIN_ZOOM = 0.3;
function reducer(state: MapTransform, action: Action): MapTransform {
switch (action.type) {
case 'SET':
return { scale: action.scale, offX: action.offX, offY: action.offY };
case 'ZOOM': {
const newScale = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, state.scale * action.factor));
const ratio = newScale / state.scale;
return {
scale: newScale,
offX: action.cx - (action.cx - state.offX) * ratio,
offY: action.cy - (action.cy - state.offY) * ratio,
};
}
case 'PAN':
return { ...state, offX: state.offX + action.dx, offY: state.offY + action.dy };
default:
return state;
}
}
const Ctx = createContext<{ transform: MapTransform; dispatch: Dispatch<Action> }>({
transform: { scale: 1, offX: 0, offY: 0 },
dispatch: () => {},
});
export const MapTransformProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [transform, dispatch] = useReducer(reducer, { scale: 1, offX: 0, offY: 0 });
return <Ctx.Provider value={{ transform, dispatch }}>{children}</Ctx.Provider>;
};
export const useMapTransform = () => useContext(Ctx);

View file

@ -0,0 +1,33 @@
import { useRef, useCallback } from 'react';
// Matches v1 script.js PALETTE — 30 accessible colors
const PALETTE = [
'#1f77b4','#ff7f0e','#2ca02c','#d62728','#9467bd','#8c564b','#e377c2','#7f7f7f',
'#bcbd22','#17becf','#aec7e8','#ffbb78','#98df8a','#ff9896','#c5b0d5','#c49c94',
'#f7b6d2','#c7c7c7','#dbdb8d','#9edae5','#393b79','#637939','#8c6d31','#843c39',
'#7b4173','#5254a3','#6b6ecf','#9c9ede','#d6616b','#ce6dbd',
];
function hashColor(name: string): string {
let h = 0;
for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0;
return `hsl(${Math.abs(h) % 360}, 72%, 50%)`;
}
export function usePlayerColors() {
const mapRef = useRef(new Map<string, string>());
const idxRef = useRef(0);
const getColor = useCallback((name: string): string => {
let c = mapRef.current.get(name);
if (!c) {
c = idxRef.current < PALETTE.length
? PALETTE[idxRef.current++]
: hashColor(name);
mapRef.current.set(name, c);
}
return c;
}, []);
return getColor;
}

View file

@ -0,0 +1,320 @@
/*
Map Layout faithful reproduction of v1 style.css
Scoped under .ml-* prefix to avoid conflicts with dashboard
*/
/* ── Layout ───────────────────────────────────────────── */
.ml-layout {
display: flex;
height: 100vh;
overflow: hidden;
background: #111;
color: #eee;
font-family: "Segoe UI", sans-serif;
}
/* ── Sidebar ──────────────────────────────────────────── */
.ml-sidebar {
width: 400px;
min-width: 400px;
background: #1a1a1a;
border-right: 2px solid #333;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 12px 14px;
}
.ml-sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.ml-sidebar-title {
font-size: 0.85rem;
font-weight: 600;
color: #88f;
}
.ml-view-toggle {
font-size: 0.7rem;
padding: 3px 10px;
background: #333;
color: #aaa;
border: 1px solid #555;
border-radius: 3px;
cursor: pointer;
}
.ml-view-toggle:hover { background: #444; color: #fff; }
/* ── Server status ────────────────────────────────────── */
.ml-server-status {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0 8px;
font-size: 0.75rem;
color: #aaa;
}
.ml-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.ml-status-dot.online { background: #4c4; animation: ml-pulse 2s ease-in-out infinite; }
.ml-status-dot.offline { background: #c44; }
.ml-status-latency { margin-left: auto; color: #888; }
@keyframes ml-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
/* ── Aggregate counters ───────────────────────────────── */
.ml-counters {
display: flex;
gap: 6px;
margin-bottom: 10px;
}
.ml-counter {
flex: 1;
text-align: center;
padding: 6px 4px;
border-radius: 4px;
background: #222;
border: 1px solid #333;
}
.ml-counter-val {
display: block;
font-size: 1rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.ml-counter-lbl {
display: block;
font-size: 0.6rem;
color: #888;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.ml-counter.rares .ml-counter-val { color: #ffcc00; }
.ml-counter.kph .ml-counter-val { color: #4af; }
.ml-counter.kph { border-color: #234; animation: ml-kph-glow 3s ease-in-out infinite; }
.ml-counter.kph.ultra { background: linear-gradient(135deg, #112, #221); animation: ml-kph-glow 1.5s ease-in-out infinite; }
.ml-counter.kills .ml-counter-val { color: #f66; }
@keyframes ml-kph-glow {
0%, 100% { box-shadow: 0 0 4px rgba(68, 170, 255, 0.2); }
50% { box-shadow: 0 0 12px rgba(68, 170, 255, 0.5); }
}
/* ── Sort buttons ─────────────────────────────────────── */
.ml-sort-buttons {
display: flex;
gap: 2px;
margin: 8px 0;
}
.ml-sort-btn {
flex: 1;
padding: 4px 0;
font-size: 0.65rem;
font-weight: 600;
background: #2a2a2a;
color: #888;
border: 1px solid #444;
border-radius: 3px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.ml-sort-btn:hover { background: #333; color: #ccc; }
.ml-sort-btn.active { background: #334; color: #88f; border-color: #88f; }
/* ── Filter input ─────────────────────────────────────── */
.ml-filter {
width: 100%;
padding: 5px 8px;
font-size: 0.78rem;
background: #222;
color: #eee;
border: 1px solid #444;
border-radius: 3px;
outline: none;
margin-bottom: 8px;
box-sizing: border-box;
}
.ml-filter:focus { border-color: #88f; }
.ml-filter::placeholder { color: #666; }
/* ── Player list ──────────────────────────────────────── */
.ml-player-list {
list-style: none;
margin: 0;
padding: 0;
flex: 1;
overflow-y: auto;
}
.ml-player-row {
padding: 6px 8px;
border-bottom: 1px solid #2a2a2a;
border-left: 3px solid transparent;
cursor: pointer;
transition: background 0.1s;
}
.ml-player-row:hover { background: #252525; }
.ml-pr-name {
font-size: 0.82rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ml-pr-coords {
font-size: 0.65rem;
color: #888;
margin-bottom: 3px;
}
/* ── Vital bars ───────────────────────────────────────── */
.ml-pr-vitals {
display: flex;
gap: 3px;
margin-bottom: 4px;
}
.ml-vital-bar {
flex: 1;
height: 4px;
border-radius: 2px;
overflow: hidden;
}
.ml-vital-bar.hp { background: #330000; }
.ml-vital-bar.sta { background: #331a00; }
.ml-vital-bar.mana { background: #001433; }
.ml-vital-bar.hp .ml-vital-fill { background: linear-gradient(90deg, #ff4444, #ff6666); }
.ml-vital-bar.sta .ml-vital-fill { background: linear-gradient(90deg, #ffaa00, #ffcc44); }
.ml-vital-bar.mana .ml-vital-fill { background: linear-gradient(90deg, #4488ff, #66aaff); }
.ml-vital-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease-out;
}
/* ── Stats row ────────────────────────────────────────── */
.ml-pr-stats {
display: flex;
gap: 8px;
font-size: 0.68rem;
color: #aaa;
margin-bottom: 2px;
}
.ml-stat {
font-variant-numeric: tabular-nums;
}
.ml-meta-pill {
font-size: 0.6rem;
padding: 0 6px;
border-radius: 3px;
background: #333;
color: #888;
margin-left: auto;
}
.ml-meta-pill.active { background: rgba(68, 204, 68, 0.15); color: #4c4; }
/* ── Map container ────────────────────────────────────── */
.ml-map-container {
flex: 1;
position: relative;
overflow: hidden;
background: #000;
cursor: grab;
}
.ml-map-container:active { cursor: grabbing; }
.ml-map-group {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
}
.ml-map-img {
display: block;
user-select: none;
-webkit-user-drag: none;
}
/* ── Player dots ──────────────────────────────────────── */
.ml-dots-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.ml-dot {
position: absolute;
width: 6px;
height: 6px;
border-radius: 50%;
transform: translate(-50%, -50%);
border: 1px solid rgba(0, 0, 0, 0.5);
pointer-events: all;
cursor: pointer;
z-index: 5;
}
.ml-dot:hover {
width: 10px;
height: 10px;
z-index: 10;
}
/* ── Tooltip ──────────────────────────────────────────── */
.ml-tooltip {
position: absolute;
background: rgba(0, 30, 60, 0.92);
color: #eee;
padding: 6px 10px;
border-radius: 4px;
font-size: 0.75rem;
pointer-events: none;
z-index: 1000;
white-space: nowrap;
border: 1px solid #335;
}
/* ── Coordinate display ───────────────────────────────── */
.ml-coords {
position: absolute;
bottom: 8px;
left: 8px;
background: rgba(0, 50, 100, 0.85);
color: #eee;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.75rem;
pointer-events: none;
z-index: 100;
font-variant-numeric: tabular-nums;
}
/* ── Mobile ───────────────────────────────────────────── */
@media (max-width: 768px) {
.ml-layout { flex-direction: column; }
.ml-sidebar { width: 100%; min-width: 100%; max-height: 40vh; border-right: none; border-bottom: 2px solid #333; }
.ml-map-container { min-height: 60vh; }
}

View file

@ -0,0 +1,31 @@
// Matches v1 script.js MAP_BOUNDS (UtilityBelt's coordinate system)
export const MAP_BOUNDS = {
west: -102.1,
east: 102.1,
north: 102.1,
south: -102.1,
};
export function worldToPx(ew: number, ns: number, imgW: number, imgH: number) {
const x = ((ew - MAP_BOUNDS.west) / (MAP_BOUNDS.east - MAP_BOUNDS.west)) * imgW;
const y = ((MAP_BOUNDS.north - ns) / (MAP_BOUNDS.north - MAP_BOUNDS.south)) * imgH;
return { x, y };
}
export function pxToWorld(
screenX: number, screenY: number,
scale: number, offX: number, offY: number,
imgW: number, imgH: number,
) {
const mapX = (screenX - offX) / scale;
const mapY = (screenY - offY) / scale;
const ew = MAP_BOUNDS.west + (mapX / imgW) * (MAP_BOUNDS.east - MAP_BOUNDS.west);
const ns = MAP_BOUNDS.north - (mapY / imgH) * (MAP_BOUNDS.north - MAP_BOUNDS.south);
return { ew, ns };
}
export function formatCoord(ns: number, ew: number): string {
const nsDir = ns >= 0 ? 'N' : 'S';
const ewDir = ew >= 0 ? 'E' : 'W';
return `${Math.abs(ns).toFixed(1)}${nsDir}, ${Math.abs(ew).toFixed(1)}${ewDir}`;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mosswart Overlord v2</title>
<link rel="icon" type="image/png" href="/icons/7735.png" />
<script type="module" crossorigin src="/v2/assets/index-DytF6DLt.js"></script>
<link rel="stylesheet" crossorigin href="/v2/assets/index-pBHPuybU.css">
<script type="module" crossorigin src="/v2/assets/index-D9dGqbQ9.js"></script>
<link rel="stylesheet" crossorigin href="/v2/assets/index-Dfeaqu0o.css">
</head>
<body>
<div id="root"></div>