Files
dm-pal/src/components/WorldMap.tsx
T
2026-07-13 10:50:55 +01:00

158 lines
5.6 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { Stage, Layer, Rect, Circle, Text, Group, Line, Star } from "react-konva";
import type { KonvaEventObject } from "konva/lib/Node";
import { buildPins, clamp, type PinPos } from "../lib/worldMap";
interface Props {
regions: string[];
landmarks: string[];
pins: Record<string, { x: number; y: number }>;
selected: string | null;
onSelect: (name: string | null) => void;
onPinMove: (name: string, x: number, y: number) => void;
}
export function WorldMap({ regions, landmarks, pins, selected, onSelect, onPinMove }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ w: 600, h: 420 });
// Measure the container so the canvas is responsive. 5:7-ish aspect.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const update = () => {
const w = el.clientWidth;
setSize({ w, h: Math.round(w * 0.62) });
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
// Build the pin list: saved positions win, else auto-layout. Pure helper so
// the layout math has a runnable self-check (see src/lib/worldMap.ts).
const pinList: PinPos[] = buildPins(regions, landmarks, pins);
const { w, h } = size;
return (
<div ref={containerRef} className="w-full">
<Stage
width={w}
height={h}
onClick={(e: KonvaEventObject<MouseEvent>) => {
// Click on empty canvas deselects.
if (e.target === e.target.getStage()) onSelect(null);
}}
className="rounded-xl border border-[var(--color-border-glass)]"
>
<Layer>
{/* Parchment-style chart background: deep navy with a faint gold grid. */}
<Rect x={0} y={0} width={w} height={h} fill="#0d1424" />
{/* Faint grid lines */}
{Array.from({ length: 9 }, (_, i) => {
const gx = (i + 1) * (w / 10);
const gy = (i + 1) * (h / 10);
return (
<Group key={`grid-${i}`}>
<Line points={[gx, 0, gx, h]} stroke="#d4af37" opacity={0.05} />
<Line points={[0, gy, w, gy]} stroke="#d4af37" opacity={0.05} />
</Group>
);
})}
{/* Vignette frame */}
<Rect
x={0}
y={0}
width={w}
height={h}
stroke="#d4af37"
strokeWidth={2}
opacity={0.3}
listening={false}
cornerRadius={6}
/>
{/* Compass rose (top-right) */}
<Group x={w - 36} y={32} listening={false}>
<Circle radius={14} stroke="#d4af37" opacity={0.4} />
<Line points={[0, -14, 0, 14]} stroke="#d4af37" opacity={0.5} />
<Line points={[-14, 0, 14, 0]} stroke="#d4af37" opacity={0.5} />
<Text text="N" x={-4} y={-26} fontSize={9} fill="#d4af37" />
</Group>
</Layer>
<Layer>
{pinList.map((pin) => {
const px = pin.x * w;
const py = pin.y * h;
const isRegion = pin.kind === "region";
const isSelected = pin.name === selected;
return (
<Group
key={`${pin.kind}-${pin.name}`}
x={px}
y={py}
draggable
onClick={(e) => {
e.cancelBubble = true;
onSelect(pin.name);
}}
onTap={(e) => {
e.cancelBubble = true;
onSelect(pin.name);
}}
onDragEnd={(e) => {
// ponytail: record the clamped normalized position; the React
// re-render repositions the node, so no manual snap-back here
// (the closure `pin` would be stale and snap it to the old spot).
onPinMove(pin.name, clamp(e.target.x() / w), clamp(e.target.y() / h));
}}
>
{isRegion ? (
<>
<Circle
radius={isSelected ? 11 : 8}
fill="#d4af37"
stroke={isSelected ? "#fff" : "#0a0e1a"}
strokeWidth={isSelected ? 2 : 1}
shadowColor="#d4af37"
shadowBlur={isSelected ? 16 : 6}
shadowOpacity={0.6}
/>
<Circle radius={3} fill="#0a0e1a" />
</>
) : (
<Star
numPoints={5}
innerRadius={4}
outerRadius={isSelected ? 9 : 7}
fill="#8b9bb4"
stroke={isSelected ? "#d4af37" : "#0a0e1a"}
strokeWidth={isSelected ? 2 : 1}
/>
)}
<Text
text={pin.name}
x={-60}
y={isRegion ? 12 : 10}
width={120}
align="center"
fontSize={isRegion ? 11 : 10}
fontStyle={isRegion ? "bold" : "normal"}
fill={isSelected ? "#d4af37" : "#e8e0d0"}
stroke="#0a0e1a"
strokeWidth={3}
// ponytail: paint fill over stroke so the label gets a dark
// outline and reads over grid lines + other pins.
fillAfterStrokeEnabled
listening={false}
/>
</Group>
);
})}
</Layer>
</Stage>
</div>
);
}