adding initial world map frontend
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user