158 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
} |