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

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-SmjESM0y.js"></script>
<link rel="stylesheet" crossorigin href="/v2/assets/index-CB6KCKZz.css">
<script type="module" crossorigin src="/v2/assets/index-BkJV_2F3.js"></script>
<link rel="stylesheet" crossorigin href="/v2/assets/index-B55o-nLL.css">
</head>
<body>
<div id="root"></div>