feat(v2): Phases 2-6 — trails, heatmap, portals, windows, effects

Phase 2 — Map overlays:
- TrailsSVG: SVG polylines per character from /trails, polled 2s
- HeatmapCanvas: canvas radial gradients from /spawns/heatmap
- PortalMarkers: emoji markers from /portals
- Sidebar toggles for heatmap and portals

Phase 3 — Draggable windows:
- WindowManagerContext: z-index stack for open windows
- DraggableWindow: generic shell with drag-header, close btn, z-stack
- ChatWindow: color-coded messages + input form (1000 msg buffer)
- CharacterWindow: combat stats with monster damage table
- InventoryWindow: item table with material/set/AL/dmg/workmanship
- WindowRenderer: reads context, renders all open windows
- Action buttons (Chat/Stats/Inv/Char/Radar) now open windows

Phase 4 — Window types share same DraggableWindow shell with
character-specific content. Combat stats and inventory via API.

Phase 5 — Effects:
- RareNotification: slide-in/slide-out banner with gold border
- Fireworks: 30-particle explosion with CSS custom property animation
- Notification queue with 6s display + 0.5s exit animation

Phase 6 — Polish:
- Window header uses modern blue gradient (not solid purple)
- Chat uses monospace font
- All overlay layers properly stacked (heatmap → trails → dots → portals)
- Mobile: sidebar stacks above map at 768px breakpoint
- Chat messages tracked per-character in useLiveData

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 15:58:58 +02:00
parent 183d662bb9
commit de7b547349
20 changed files with 1040 additions and 193 deletions

View file

@ -0,0 +1,95 @@
import React, { useEffect, useState, useCallback } from 'react';
import type { RareMessage } from '../../types';
interface Props {
recentRares: RareMessage[];
}
interface ActiveNotification {
key: number;
charName: string;
rareName: string;
exiting: boolean;
}
let notifKey = 0;
export const RareNotification: React.FC<Props> = ({ recentRares }) => {
const [active, setActive] = useState<ActiveNotification[]>([]);
const [lastCount, setLastCount] = useState(0);
const [fireworks, setFireworks] = useState<Array<{ id: number; particles: Array<{ dx: number; dy: number; color: string }> }>>([]);
// Detect new rares
useEffect(() => {
if (recentRares.length > lastCount && lastCount > 0) {
const newRares = recentRares.slice(0, recentRares.length - lastCount);
for (const r of newRares) {
const key = ++notifKey;
setActive(prev => [...prev, { key, charName: r.character_name, rareName: r.name, exiting: false }]);
// Trigger fireworks
triggerFireworks();
// Auto-remove after 6s
setTimeout(() => {
setActive(prev => prev.map(n => n.key === key ? { ...n, exiting: true } : n));
setTimeout(() => {
setActive(prev => prev.filter(n => n.key !== key));
}, 500);
}, 6000);
}
}
setLastCount(recentRares.length);
}, [recentRares.length]); // eslint-disable-line react-hooks/exhaustive-deps
const triggerFireworks = useCallback(() => {
const id = Date.now();
const colors = ['#FFD700', '#FF4444', '#FF8800', '#AA44FF', '#4488FF'];
const particles = Array.from({ length: 30 }, (_, i) => {
const angle = (Math.PI * 2 * i) / 30 + (Math.random() - 0.5) * 0.5;
const velocity = 100 + Math.random() * 200;
return {
dx: Math.cos(angle) * velocity,
dy: Math.sin(angle) * velocity - 50,
color: colors[Math.floor(Math.random() * colors.length)],
};
});
setFireworks(prev => [...prev, { id, particles }]);
setTimeout(() => setFireworks(prev => prev.filter(f => f.id !== id)), 2200);
}, []);
return (
<>
{/* Notification banners */}
<div className="ml-rare-notifications">
{active.map(n => (
<div key={n.key} className={`ml-rare-notif ${n.exiting ? 'exiting' : ''}`}>
<div className="ml-rare-notif-title">LEGENDARY RARE!</div>
<div className="ml-rare-notif-name">{n.rareName}</div>
<div className="ml-rare-notif-by">found by</div>
<div className="ml-rare-notif-char">{n.charName}</div>
</div>
))}
</div>
{/* Fireworks particles */}
<div className="ml-fireworks">
{fireworks.map(fw => (
<React.Fragment key={fw.id}>
{fw.particles.map((p, i) => (
<div
key={i}
className="ml-firework-particle"
style={{
left: '50%',
top: '30%',
backgroundColor: p.color,
'--dx': `${p.dx}px`,
'--dy': `${p.dy + 200}px`,
} as React.CSSProperties}
/>
))}
</React.Fragment>
))}
</div>
</>
);
};

View file

@ -0,0 +1,58 @@
import React, { useRef, useEffect, useState } from 'react';
import { worldToPx } from '../../utils/coordinates';
import { apiFetch } from '../../api/client';
interface HeatmapPoint {
ew: number;
ns: number;
intensity: number;
}
interface Props {
imgW: number;
imgH: number;
enabled: boolean;
}
export const HeatmapCanvas: React.FC<Props> = ({ imgW, imgH, enabled }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [data, setData] = useState<HeatmapPoint[]>([]);
useEffect(() => {
if (!enabled) return;
const fetch = async () => {
try {
const resp = await apiFetch<{ spawn_points: HeatmapPoint[] }>('/spawns/heatmap?hours=24&limit=50000');
setData(resp.spawn_points ?? []);
} catch { /* ignore */ }
};
fetch();
}, [enabled]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !enabled || data.length === 0 || imgW === 0) return;
canvas.width = imgW;
canvas.height = imgH;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, imgW, imgH);
for (const point of data) {
const { x, y } = worldToPx(point.ew, point.ns, imgW, imgH);
const radius = Math.max(5, Math.min(12, 5 + Math.sqrt(point.intensity * 0.5)));
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
gradient.addColorStop(0, `rgba(255, 0, 0, ${Math.min(0.9, point.intensity / 40)})`);
gradient.addColorStop(0.6, `rgba(255, 100, 0, ${Math.min(0.4, point.intensity / 120)})`);
gradient.addColorStop(1, 'rgba(255, 150, 0, 0)');
ctx.fillStyle = gradient;
ctx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
}
}, [data, imgW, imgH, enabled]);
if (!enabled) return null;
return <canvas ref={canvasRef} className="ml-heatmap-canvas" />;
};

View file

@ -1,7 +1,10 @@
import React, { useCallback } from 'react';
import React, { useCallback, useState } from 'react';
import { MapTransformProvider } from '../../contexts/MapTransformContext';
import { WindowManagerProvider } from '../../contexts/WindowManagerContext';
import { MapView } from './MapView';
import { Sidebar } from './Sidebar';
import { WindowRenderer } from '../windows/WindowRenderer';
import { RareNotification } from '../effects/RareNotification';
import { usePlayerColors } from '../../hooks/usePlayerColors';
import type { DashboardState } from '../../hooks/useLiveData';
@ -12,43 +15,52 @@ interface Props {
export const MapLayout: React.FC<Props> = ({ data, onViewToggle }) => {
const getColor = usePlayerColors();
const [showHeatmap, setShowHeatmap] = useState(false);
const [showPortals, setShowPortals] = useState(false);
// 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) => {
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>
<WindowManagerProvider>
<div className="ml-layout">
<Sidebar
players={players}
vitals={vitalsMap}
serverHealth={data.serverHealth}
totalRares={data.totalRares}
totalKills={data.totalKills}
getColor={getColor}
onSelectPlayer={handleSelectPlayer}
onViewToggle={onViewToggle}
showHeatmap={showHeatmap}
showPortals={showPortals}
onToggleHeatmap={setShowHeatmap}
onTogglePortals={setShowPortals}
/>
<MapView
players={players}
getColor={getColor}
onSelectPlayer={handleSelectPlayer}
showHeatmap={showHeatmap}
showPortals={showPortals}
/>
<WindowRenderer characters={data.characters} chatMessages={data.chatMessages} />
<RareNotification recentRares={data.recentRares} />
</div>
</WindowManagerProvider>
</MapTransformProvider>
);
};

View file

@ -2,15 +2,20 @@ import React, { useRef, useState, useCallback, useEffect } from 'react';
import { useMapTransform } from '../../contexts/MapTransformContext';
import { pxToWorld, formatCoord } from '../../utils/coordinates';
import { PlayerDots } from './PlayerDots';
import { TrailsSVG } from './TrailsSVG';
import { HeatmapCanvas } from './HeatmapCanvas';
import { PortalMarkers } from './PortalMarkers';
import type { TelemetrySnapshot } from '../../types';
interface Props {
players: TelemetrySnapshot[];
getColor: (name: string) => string;
onSelectPlayer: (name: string) => void;
showHeatmap: boolean;
showPortals: boolean;
}
export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer }) => {
export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer, showHeatmap, showPortals }) => {
const containerRef = useRef<HTMLDivElement>(null);
const { transform, dispatch } = useMapTransform();
const [imgSize, setImgSize] = useState({ w: 0, h: 0 });
@ -23,7 +28,6 @@ export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer })
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;
@ -32,7 +36,6 @@ export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer })
}
}, [dispatch]);
// Wheel zoom
const onWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const rect = containerRef.current?.getBoundingClientRect();
@ -41,7 +44,6 @@ export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer })
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 };
@ -53,7 +55,6 @@ export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer })
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);
@ -78,33 +79,32 @@ export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer })
>
<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}
/>
<>
<HeatmapCanvas imgW={imgSize.w} imgH={imgSize.h} enabled={showHeatmap} />
<TrailsSVG imgW={imgSize.w} imgH={imgSize.h} getColor={getColor} />
<PlayerDots
players={players}
imgW={imgSize.w}
imgH={imgSize.h}
getColor={getColor}
onHover={handleDotHover}
onSelect={onSelectPlayer}
/>
<PortalMarkers imgW={imgSize.w} imgH={imgSize.h} enabled={showPortals} />
</>
)}
</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 />
<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 className="ml-coords">{formatCoord(worldCoord.ns, worldCoord.ew)}</div>
)}
</div>
);

View file

@ -0,0 +1,54 @@
import React, { useEffect, useState, useMemo } from 'react';
import { worldToPx } from '../../utils/coordinates';
import { apiFetch } from '../../api/client';
interface Portal {
portal_name: string;
coordinates: { ns: number; ew: number; z: number };
discovered_by: string;
}
interface Props {
imgW: number;
imgH: number;
enabled: boolean;
}
export const PortalMarkers: React.FC<Props> = ({ imgW, imgH, enabled }) => {
const [portals, setPortals] = useState<Portal[]>([]);
useEffect(() => {
if (!enabled) return;
const fetch = async () => {
try {
const data = await apiFetch<{ portals: Portal[] }>('/portals');
setPortals(data.portals ?? []);
} catch { /* ignore */ }
};
fetch();
const id = setInterval(fetch, 60000);
return () => clearInterval(id);
}, [enabled]);
const markers = useMemo(() =>
portals.map(p => ({
...p,
pos: worldToPx(p.coordinates.ew, p.coordinates.ns, imgW, imgH),
})),
[portals, imgW, imgH]);
if (!enabled || markers.length === 0) return null;
return (
<div className="ml-portals-layer">
{markers.map((p, i) => (
<div
key={i}
className="ml-portal-icon"
style={{ left: p.pos.x, top: p.pos.y }}
title={`${p.portal_name} (by ${p.discovered_by})`}
/>
))}
</div>
);
};

View file

@ -1,4 +1,4 @@
import React, { useState, useMemo, useCallback } from 'react';
import React, { useState, useMemo } from 'react';
import { PlayerList } from '../sidebar/PlayerList';
import { SortButtons, type SortKey } from '../sidebar/SortButtons';
import type { TelemetrySnapshot, VitalsMessage, ServerHealth } from '../../types';
@ -12,10 +12,15 @@ interface Props {
getColor: (name: string) => string;
onSelectPlayer: (name: string) => void;
onViewToggle: () => void;
showHeatmap: boolean;
showPortals: boolean;
onToggleHeatmap: (v: boolean) => void;
onTogglePortals: (v: boolean) => void;
}
export const Sidebar: React.FC<Props> = ({
players, vitals, serverHealth, totalRares, totalKills, getColor, onSelectPlayer, onViewToggle,
showHeatmap, showPortals, onToggleHeatmap, onTogglePortals,
}) => {
const [sortKey, setSortKey] = useState<SortKey>('name');
const [filter, setFilter] = useState('');
@ -46,27 +51,35 @@ export const Sidebar: React.FC<Props> = ({
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 */}
{/* Map toggles */}
<div className="ml-toggles">
<label className="ml-toggle-label">
<input type="checkbox" checked={showHeatmap} onChange={e => onToggleHeatmap(e.target.checked)} />
<span>Spawn Heatmap</span>
</label>
<label className="ml-toggle-label">
<input type="checkbox" checked={showPortals} onChange={e => onTogglePortals(e.target.checked)} />
<span>Portals</span>
</label>
</div>
<SortButtons value={sortKey} onChange={setSortKey} />
<input
className="ml-filter"
@ -76,7 +89,6 @@ export const Sidebar: React.FC<Props> = ({
onChange={e => setFilter(e.target.value)}
/>
{/* Player list */}
<PlayerList
players={sorted}
vitals={vitals}

View file

@ -0,0 +1,62 @@
import React, { useMemo, useEffect, useState } from 'react';
import { worldToPx } from '../../utils/coordinates';
import { apiFetch } from '../../api/client';
interface TrailPoint {
character_name: string;
ew: number;
ns: number;
}
interface Props {
imgW: number;
imgH: number;
getColor: (name: string) => string;
}
export const TrailsSVG: React.FC<Props> = React.memo(({ imgW, imgH, getColor }) => {
const [trails, setTrails] = useState<TrailPoint[]>([]);
useEffect(() => {
const fetchTrails = async () => {
try {
const data = await apiFetch<{ trails: TrailPoint[] }>('/trails/?seconds=600');
setTrails(data.trails ?? []);
} catch { /* ignore */ }
};
fetchTrails();
const id = setInterval(fetchTrails, 2000);
return () => clearInterval(id);
}, []);
const polylines = useMemo(() => {
const byChar: Record<string, string[]> = {};
for (const pt of trails) {
const { x, y } = worldToPx(pt.ew, pt.ns, imgW, imgH);
if (!byChar[pt.character_name]) byChar[pt.character_name] = [];
byChar[pt.character_name].push(`${x},${y}`);
}
return Object.entries(byChar)
.filter(([, pts]) => pts.length >= 2)
.map(([name, pts]) => ({ name, points: pts.join(' ') }));
}, [trails, imgW, imgH]);
return (
<svg className="ml-trails-svg" viewBox={`0 0 ${imgW} ${imgH}`} preserveAspectRatio="none">
{polylines.map(p => (
<polyline
key={p.name}
points={p.points}
stroke={getColor(p.name)}
fill="none"
strokeWidth={2}
strokeOpacity={0.7}
strokeLinecap="round"
strokeLinejoin="round"
/>
))}
</svg>
);
});
TrailsSVG.displayName = 'TrailsSVG';

View file

@ -1,5 +1,6 @@
import React from 'react';
import { formatCoord } from '../../utils/coordinates';
import { useWindowManager } from '../../contexts/WindowManagerContext';
import type { TelemetrySnapshot, VitalsMessage } from '../../types';
interface Props {
@ -10,34 +11,28 @@ interface Props {
}
export const PlayerRow: React.FC<Props> = React.memo(({ player: p, vitals: v, color, onSelect }) => {
const { openWindow } = useWindowManager();
const vtState = (p.vt_state || 'idle').toLowerCase();
const isActive = vtState === 'combat' || vtState === 'hunt';
const kpr = (p.total_rares ?? 0) > 0
? Math.round((p.total_kills ?? 0) / (p.total_rares ?? 1)).toLocaleString()
: null;
const name = p.character_name;
return (
<li className="ml-player-row" style={{ borderLeftColor: color }}>
{/* Row 1: Name + coords */}
<div className="ml-pr-header" onClick={onSelect}>
<span className="ml-pr-name">{p.character_name}</span>
<span className="ml-pr-name">{name}</span>
<span className="ml-pr-coords">{formatCoord(p.ns, p.ew)}</span>
</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 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-5: Stats grid — 3 columns × 3 rows for alignment */}
<div className="ml-pr-grid">
<span className="ml-gs" title="Session kills"> {p.kills?.toLocaleString() ?? 0}</span>
<span className="ml-gs" title="Total kills">🏆 {(p.total_kills ?? 0).toLocaleString()}</span>
@ -52,13 +47,12 @@ export const PlayerRow: React.FC<Props> = React.memo(({ player: p, vitals: v, co
<span className="ml-gs" title="Prismatic tapers"><img src="/prismatic-taper-icon.png" className="ml-taper-icon" alt="" />{p.prismatic_taper_count ?? '0'}</span>
</div>
{/* Row 6: Action buttons */}
<div className="ml-pr-buttons">
<button className="ml-btn accent" title="Chat">Chat</button>
<button className="ml-btn accent" title="Stats">Stats</button>
<button className="ml-btn accent" title="Inventory">Inv</button>
<button className="ml-btn" title="Character">Char</button>
<button className="ml-btn" title="Radar">Radar</button>
<button className="ml-btn accent" onClick={() => openWindow(`chat-${name}`, `Chat: ${name}`, name)}>Chat</button>
<button className="ml-btn accent" onClick={() => openWindow(`combat-${name}`, `Combat: ${name}`, name)}>Stats</button>
<button className="ml-btn accent" onClick={() => openWindow(`inv-${name}`, `Inventory: ${name}`, name)}>Inv</button>
<button className="ml-btn" onClick={() => openWindow(`char-${name}`, `Character: ${name}`, name)}>Char</button>
<button className="ml-btn" onClick={() => openWindow(`radar-${name}`, `Radar: ${name}`, name)}>Radar</button>
</div>
</li>
);

View file

@ -0,0 +1,61 @@
import React, { useEffect, useState } from 'react';
import { DraggableWindow } from './DraggableWindow';
import { apiFetch } from '../../api/client';
interface Props {
id: string;
charName: string;
zIndex: number;
}
export const CharacterWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
const [stats, setStats] = useState<Record<string, unknown> | null>(null);
useEffect(() => {
apiFetch<Record<string, unknown>>(`/combat-stats/${encodeURIComponent(charName)}`)
.then(setStats).catch(() => {});
}, [charName]);
const session = (stats as any)?.session;
return (
<DraggableWindow id={id} title={`Character: ${charName}`} zIndex={zIndex} width={500} height={400}>
<div style={{ padding: 8, fontSize: '0.8rem', color: '#ccc', overflowY: 'auto', flex: 1 }}>
{session ? (
<>
<div style={{ marginBottom: 8 }}>
<strong>Session</strong>: {session.total_kills ?? 0} kills, {(session.total_damage_given ?? 0).toLocaleString()} dmg given
</div>
<div style={{ marginBottom: 8 }}>
<strong>Monsters fought</strong>: {Object.keys(session.monsters ?? {}).filter((k: string) => k !== '__cloak_surges__').length}
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem' }}>
<thead>
<tr style={{ borderBottom: '1px solid #444', color: '#888' }}>
<th style={{ textAlign: 'left', padding: '2px 4px' }}>Monster</th>
<th style={{ textAlign: 'right', padding: '2px 4px' }}>Kills</th>
<th style={{ textAlign: 'right', padding: '2px 4px' }}>Dmg Given</th>
</tr>
</thead>
<tbody>
{Object.values(session.monsters ?? {})
.filter((m: any) => m.name !== '__cloak_surges__')
.sort((a: any, b: any) => (b.damage_given ?? 0) - (a.damage_given ?? 0))
.slice(0, 30)
.map((m: any) => (
<tr key={m.name} style={{ borderBottom: '1px solid #222' }}>
<td style={{ padding: '2px 4px' }}>{m.name}</td>
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{m.kill_count}</td>
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{(m.damage_given ?? 0).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</>
) : (
<div style={{ color: '#666' }}>Loading combat data...</div>
)}
</div>
</DraggableWindow>
);
};

View file

@ -0,0 +1,45 @@
import React, { useEffect, useRef, useState } from 'react';
import { DraggableWindow } from './DraggableWindow';
interface ChatMsg {
text: string;
color?: number;
timestamp: string;
}
const CHAT_COLORS: Record<number, string> = {
0:'#00FF00', 2:'#FFFFFF', 3:'#FF0000', 4:'#FFFFFF', 5:'#33CCFF', 6:'#CCFF99',
7:'#00FFFF', 14:'#FFD700', 15:'#FF69B4', 17:'#AAAAFF', 18:'#88FF88',
21:'#FF8888', 22:'#FFAA66',
};
interface Props {
id: string;
charName: string;
zIndex: number;
messages: ChatMsg[];
}
export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages }) => {
const msgsRef = useRef<HTMLDivElement>(null);
const [input, setInput] = useState('');
useEffect(() => {
if (msgsRef.current) msgsRef.current.scrollTop = msgsRef.current.scrollHeight;
}, [messages.length]);
return (
<DraggableWindow id={id} title={`Chat: ${charName}`} zIndex={zIndex} width={600} height={300}>
<div className="ml-chat-messages" ref={msgsRef}>
{messages.map((m, i) => (
<div key={i} className="ml-chat-line" style={{ color: CHAT_COLORS[m.color ?? 2] ?? '#ddd' }}>
{m.text}
</div>
))}
</div>
<form className="ml-chat-form" onSubmit={e => { e.preventDefault(); setInput(''); }}>
<input className="ml-chat-input" value={input} onChange={e => setInput(e.target.value)} placeholder="Enter chat..." />
</form>
</DraggableWindow>
);
};

View file

@ -0,0 +1,58 @@
import React, { useRef, useCallback, useEffect } from 'react';
import { useWindowManager } from '../../contexts/WindowManagerContext';
interface Props {
id: string;
title: string;
zIndex: number;
width?: number;
height?: number;
children: React.ReactNode;
}
export const DraggableWindow: React.FC<Props> = ({ id, title, zIndex, width = 700, height = 340, children }) => {
const { closeWindow, bringToFront } = useWindowManager();
const winRef = useRef<HTMLDivElement>(null);
const dragRef = useRef({ dragging: false, sx: 0, sy: 0, ox: 0, oy: 0 });
const posRef = useRef({ x: 420, y: 10 + Math.random() * 40 });
const onHeaderDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
bringToFront(id);
const rect = winRef.current?.getBoundingClientRect();
if (!rect) return;
dragRef.current = { dragging: true, sx: e.clientX, sy: e.clientY, ox: rect.left, oy: rect.top };
}, [id, bringToFront]);
useEffect(() => {
const onMove = (e: MouseEvent) => {
const d = dragRef.current;
if (!d.dragging || !winRef.current) return;
posRef.current.x = d.ox + (e.clientX - d.sx);
posRef.current.y = d.oy + (e.clientY - d.sy);
winRef.current.style.left = `${posRef.current.x}px`;
winRef.current.style.top = `${posRef.current.y}px`;
};
const onUp = () => { dragRef.current.dragging = false; };
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
}, []);
return (
<div
ref={winRef}
className="ml-window"
style={{ zIndex, width, height, left: posRef.current.x, top: posRef.current.y }}
onMouseDown={() => bringToFront(id)}
>
<div className="ml-window-header" onMouseDown={onHeaderDown}>
<span className="ml-window-title">{title}</span>
<button className="ml-window-close" onClick={() => closeWindow(id)}>&times;</button>
</div>
<div className="ml-window-content">
{children}
</div>
</div>
);
};

View file

@ -0,0 +1,70 @@
import React, { useEffect, useState } from 'react';
import { DraggableWindow } from './DraggableWindow';
import { apiFetch } from '../../api/client';
interface Props { id: string; charName: string; zIndex: number; }
interface Item {
Name: string;
ObjectClass?: number;
Icon?: number;
Value?: number;
Burden?: number;
ArmorLevel?: number;
MaxDamage?: number;
Workmanship?: number;
Material?: string;
ItemSet?: string;
Imbue?: string;
EquipSkill?: string;
}
export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
apiFetch<{ items: Item[] }>(`/inventory/${encodeURIComponent(charName)}?limit=500`)
.then(d => setItems(d.items ?? []))
.catch(() => {})
.finally(() => setLoading(false));
}, [charName]);
return (
<DraggableWindow id={id} title={`Inventory: ${charName}`} zIndex={zIndex} width={650} height={450}>
<div style={{ overflowY: 'auto', flex: 1, fontSize: '0.75rem' }}>
{loading ? (
<div style={{ padding: 16, color: '#666' }}>Loading inventory...</div>
) : items.length === 0 ? (
<div style={{ padding: 16, color: '#666' }}>No inventory data</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid #444', color: '#888', fontSize: '0.7rem' }}>
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Item</th>
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Material</th>
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Set</th>
<th style={{ textAlign: 'right', padding: '3px 4px' }}>AL</th>
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Dmg</th>
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Work</th>
</tr>
</thead>
<tbody>
{items.map((item, i) => (
<tr key={i} style={{ borderBottom: '1px solid #1a1a1a', color: '#ccc' }}>
<td style={{ padding: '2px 4px', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.Name}</td>
<td style={{ padding: '2px 4px', color: '#888' }}>{item.Material || ''}</td>
<td style={{ padding: '2px 4px', color: '#888' }}>{item.ItemSet || ''}</td>
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.ArmorLevel && item.ArmorLevel > 0 ? item.ArmorLevel : ''}</td>
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.MaxDamage && item.MaxDamage > 0 ? item.MaxDamage : ''}</td>
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.Workmanship && item.Workmanship > 0 ? item.Workmanship : ''}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</DraggableWindow>
);
};

View file

@ -0,0 +1,37 @@
import React from 'react';
import { useWindowManager } from '../../contexts/WindowManagerContext';
import { ChatWindow } from './ChatWindow';
import { CharacterWindow } from './CharacterWindow';
import { InventoryWindow } from './InventoryWindow';
import type { CharacterState } from '../../types';
interface Props {
characters: Map<string, CharacterState>;
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
}
export const WindowRenderer: React.FC<Props> = ({ characters, chatMessages }) => {
const { windows } = useWindowManager();
return (
<>
{windows.map(w => {
const charName = w.charName ?? '';
if (w.id.startsWith('chat-')) {
return <ChatWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} messages={chatMessages.get(charName) ?? []} />;
}
if (w.id.startsWith('char-') || w.id.startsWith('combat-')) {
return <CharacterWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
}
if (w.id.startsWith('inv-')) {
return <InventoryWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
}
// Fallback: generic window with placeholder
return (
<ChatWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} messages={[]} />
);
})}
</>
);
};

View file

@ -0,0 +1,49 @@
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
interface WindowState {
id: string;
title: string;
charName?: string;
zIndex: number;
}
interface WindowManagerValue {
windows: WindowState[];
openWindow: (id: string, title: string, charName?: string) => void;
closeWindow: (id: string) => void;
bringToFront: (id: string) => void;
}
const Ctx = createContext<WindowManagerValue>({
windows: [],
openWindow: () => {},
closeWindow: () => {},
bringToFront: () => {},
});
export const WindowManagerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [windows, setWindows] = useState<WindowState[]>([]);
const zRef = useRef(10000);
const openWindow = useCallback((id: string, title: string, charName?: string) => {
setWindows(prev => {
const existing = prev.find(w => w.id === id);
if (existing) {
return prev.map(w => w.id === id ? { ...w, zIndex: ++zRef.current } : w);
}
return [...prev, { id, title, charName, zIndex: ++zRef.current }];
});
}, []);
const closeWindow = useCallback((id: string) => {
setWindows(prev => prev.filter(w => w.id !== id));
}, []);
const bringToFront = useCallback((id: string) => {
setWindows(prev => prev.map(w => w.id === id ? { ...w, zIndex: ++zRef.current } : w));
}, []);
return <Ctx.Provider value={{ windows, openWindow, closeWindow, bringToFront }}>{children}</Ctx.Provider>;
};
export const useWindowManager = () => useContext(Ctx);

View file

@ -12,6 +12,7 @@ export interface DashboardState {
totalRares: number;
totalKills: number;
recentRares: RareMessage[];
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
}
export function useLiveData(): DashboardState {
@ -20,6 +21,7 @@ export function useLiveData(): DashboardState {
const [totalRares, setTotalRares] = useState(0);
const [totalKills, setTotalKills] = useState(0);
const [recentRares, setRecentRares] = useState<RareMessage[]>([]);
const [chatMessages, setChatMessages] = useState<Map<string, Array<{ text: string; color?: number; timestamp: string }>>>(new Map());
const charsRef = useRef(characters);
charsRef.current = characters;
@ -49,6 +51,15 @@ export function useLiveData(): DashboardState {
} else if (msg.type === 'rare') {
const r = msg as RareMessage;
setRecentRares(prev => [r, ...prev].slice(0, 50));
} else if (msg.type === 'chat') {
const m = msg as unknown as { character_name: string; text: string; color?: number; timestamp: string };
setChatMessages(prev => {
const next = new Map(prev);
const arr = [...(next.get(m.character_name) ?? []), { text: m.text, color: m.color, timestamp: m.timestamp }];
if (arr.length > 1000) arr.splice(0, arr.length - 1000);
next.set(m.character_name, arr);
return next;
});
}
}, [updateChar]);
@ -129,5 +140,5 @@ export function useLiveData(): DashboardState {
return () => clearInterval(id);
}, []);
return { characters, serverHealth, totalRares, totalKills, recentRares };
return { characters, serverHealth, totalRares, totalKills, recentRares, chatMessages };
}

View file

@ -373,6 +373,235 @@
font-variant-numeric: tabular-nums;
}
/* ── Map toggles ──────────────────────────────────────── */
.ml-toggles {
display: flex;
gap: 12px;
margin-bottom: 8px;
font-size: 0.72rem;
}
.ml-toggle-label {
display: flex;
align-items: center;
gap: 4px;
color: #aaa;
cursor: pointer;
}
.ml-toggle-label input { accent-color: #4488ff; }
/* ── Trail SVG overlay ────────────────────────────────── */
.ml-trails-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
/* ── Heatmap canvas overlay ───────────────────────────── */
.ml-heatmap-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
opacity: 0.8;
}
/* ── Portal markers ───────────────────────────────────── */
.ml-portals-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.ml-portal-icon {
position: absolute;
width: 6px;
height: 6px;
transform: translate(-50%, -50%);
pointer-events: all;
cursor: help;
}
.ml-portal-icon::before {
content: '🌀';
font-size: 10px;
position: absolute;
transform: translate(-50%, -50%);
}
/* ── Draggable windows ────────────────────────────────── */
.ml-window {
position: fixed;
background: #1a1a1a;
border: 1px solid #444;
border-radius: 6px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.ml-window-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 12px;
background: linear-gradient(135deg, #2a3a5a, #1a2a40);
cursor: move;
user-select: none;
border-bottom: 1px solid #334;
}
.ml-window-title {
font-size: 0.8rem;
font-weight: 600;
color: #aaccff;
}
.ml-window-close {
background: none;
border: none;
color: #888;
font-size: 1.1rem;
cursor: pointer;
line-height: 1;
padding: 0 4px;
}
.ml-window-close:hover { color: #f66; }
.ml-window-content {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
}
/* ── Chat window ──────────────────────────────────────── */
.ml-chat-messages {
flex: 1;
overflow-y: auto;
padding: 6px 10px;
font-size: 0.75rem;
font-family: 'Consolas', 'Courier New', monospace;
line-height: 1.4;
}
.ml-chat-line {
word-break: break-word;
}
.ml-chat-form {
display: flex;
border-top: 1px solid #333;
padding: 4px;
}
.ml-chat-input {
flex: 1;
background: #222;
color: #eee;
border: 1px solid #444;
border-radius: 3px;
padding: 4px 8px;
font-size: 0.78rem;
outline: none;
}
.ml-chat-input:focus { border-color: #4488ff; }
.ml-chat-input::placeholder { color: #666; }
/* ── Rare notifications ───────────────────────────────── */
.ml-rare-notifications {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 99999;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.ml-rare-notif {
background: linear-gradient(135deg, #1a0a2e, #2a1040);
border: 2px solid #ffcc00;
border-radius: 8px;
padding: 16px 32px;
text-align: center;
animation: ml-notif-in 0.5s ease-out;
box-shadow: 0 0 40px rgba(255, 204, 0, 0.3);
}
.ml-rare-notif.exiting {
animation: ml-notif-out 0.5s ease-in forwards;
}
.ml-rare-notif-title {
font-size: 1.4rem;
font-weight: 800;
color: #ffcc00;
text-shadow: 0 0 20px rgba(255, 204, 0, 0.5);
margin-bottom: 4px;
}
.ml-rare-notif-name {
font-size: 1.1rem;
font-weight: 600;
color: #fff;
margin-bottom: 4px;
}
.ml-rare-notif-by {
font-size: 0.75rem;
color: #888;
}
.ml-rare-notif-char {
font-size: 1rem;
font-weight: 700;
color: #ffcc00;
}
@keyframes ml-notif-in {
from { transform: translateY(-40px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes ml-notif-out {
to { transform: translateY(-60px); opacity: 0; }
}
/* ── Fireworks ────────────────────────────────────────── */
.ml-fireworks {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 99998;
}
.ml-firework-particle {
position: absolute;
width: 6px;
height: 6px;
border-radius: 50%;
animation: ml-particle 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
@keyframes ml-particle {
0% { transform: translate(0, 0) scale(1); opacity: 1; }
100% { transform: translate(var(--dx), var(--dy)) scale(0); opacity: 0; }
}
/* ── Mobile ───────────────────────────────────────────── */
@media (max-width: 768px) {
.ml-layout { flex-direction: column; }