adding initial world map frontend
This commit is contained in:
@@ -1122,7 +1122,7 @@ These are blockers or bugs that make the app feel broken.
|
|||||||
- [ ] Weighted tables
|
- [ ] Weighted tables
|
||||||
- [x] Export table
|
- [x] Export table
|
||||||
- [ ] **World Builder**:
|
- [ ] **World Builder**:
|
||||||
- [ ] Map canvas (react-konva)
|
- [x] Map canvas (react-konva)
|
||||||
- [x] Theme presets
|
- [x] Theme presets
|
||||||
- [ ] Hierarchy tree
|
- [ ] Hierarchy tree
|
||||||
- [x] Persist world
|
- [x] Persist world
|
||||||
|
|||||||
Generated
+98
-2
@@ -12,9 +12,11 @@
|
|||||||
"@tauri-apps/plugin-log": "^2",
|
"@tauri-apps/plugin-log": "^2",
|
||||||
"@tauri-apps/plugin-store": "^2.4.3",
|
"@tauri-apps/plugin-store": "^2.4.3",
|
||||||
"framer-motion": "^12",
|
"framer-motion": "^12",
|
||||||
|
"konva": "^10.3.0",
|
||||||
"lucide-react": "^0.525",
|
"lucide-react": "^0.525",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
"react-konva": "^19.2.5",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1341,7 +1343,6 @@
|
|||||||
"version": "19.2.17",
|
"version": "19.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -1357,6 +1358,15 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/react-reconciler": {
|
||||||
|
"version": "0.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.33.0.tgz",
|
||||||
|
"integrity": "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
|
||||||
@@ -1387,7 +1397,6 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
@@ -1481,6 +1490,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/its-fine": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/react-reconciler": "^0.28.9"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/its-fine/node_modules/@types/react-reconciler": {
|
||||||
|
"version": "0.28.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
|
||||||
|
"integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
@@ -1491,6 +1521,26 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/konva": {
|
||||||
|
"version": "10.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/konva/-/konva-10.3.0.tgz",
|
||||||
|
"integrity": "sha512-gt19K2gzY4lHbnkvsku7eSmB+A9PTS2jG4F9coBMsdjM1UKfJNxJbDbXVpeCW1wjEGRwBD3nBamcHnqJhAeKlg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/lavrton"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/konva"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/lavrton"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -1936,6 +1986,52 @@
|
|||||||
"react": "^19.2.7"
|
"react": "^19.2.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-konva": {
|
||||||
|
"version": "19.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-konva/-/react-konva-19.2.5.tgz",
|
||||||
|
"integrity": "sha512-AdsuDB59GdB86QdenTAqRzr6Tfw3z9x9Opxo4BoqDjdNLsjekF9a1VhN4FlMPvg0qtrMl3d4dNmagaUTGH7fSA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/lavrton"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/konva"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/lavrton"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/react-reconciler": "^0.33.0",
|
||||||
|
"its-fine": "^2.0.0",
|
||||||
|
"react-reconciler": "0.33.0",
|
||||||
|
"scheduler": "0.27.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"konva": "^8.0.1 || ^7.2.5 || ^9.0.0 || ^10.0.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-reconciler": {
|
||||||
|
"version": "0.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz",
|
||||||
|
"integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"scheduler": "^0.27.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
|
||||||
|
|||||||
@@ -15,9 +15,11 @@
|
|||||||
"@tauri-apps/plugin-log": "^2",
|
"@tauri-apps/plugin-log": "^2",
|
||||||
"@tauri-apps/plugin-store": "^2.4.3",
|
"@tauri-apps/plugin-store": "^2.4.3",
|
||||||
"framer-motion": "^12",
|
"framer-motion": "^12",
|
||||||
|
"konva": "^10.3.0",
|
||||||
"lucide-react": "^0.525",
|
"lucide-react": "^0.525",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
"react-konva": "^19.2.5",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// ponytail: runnable self-check for the world-map layout math. Not part of
|
||||||
|
// the app build (scripts/ is outside tsconfig.app's include). Run with:
|
||||||
|
// node scripts/check-worldmap.ts
|
||||||
|
import { demo } from "../src/lib/worldMap.ts";
|
||||||
|
demo();
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
|
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { addToLore } from "../lib/lore";
|
import { addToLore } from "../lib/lore";
|
||||||
import { useToast } from "./Toast";
|
import { useToast } from "./Toast";
|
||||||
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
|
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
|
||||||
import { usePrefillEffect } from "../lib/usePrefill";
|
import { usePrefillEffect } from "../lib/usePrefill";
|
||||||
import { usePersistentState } from "../lib/usePersistentState";
|
import { usePersistentState } from "../lib/usePersistentState";
|
||||||
|
import { WorldMap } from "./WorldMap";
|
||||||
|
|
||||||
interface WorldResponse {
|
interface WorldResponse {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -14,6 +15,9 @@ interface WorldResponse {
|
|||||||
landmarks: string[];
|
landmarks: string[];
|
||||||
conflicts: string[];
|
conflicts: string[];
|
||||||
cultures: string[];
|
cultures: string[];
|
||||||
|
// ponytail: DM-placed pin positions (normalized 0..1). UI-managed, not
|
||||||
|
// returned by the LLM. Persisted with the world.
|
||||||
|
pins?: Record<string, { x: number; y: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -26,8 +30,22 @@ export function WorldBuilder({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
const [theme, setTheme] = usePersistentState<string>("world.theme", "high fantasy");
|
const [theme, setTheme] = usePersistentState<string>("world.theme", "high fantasy");
|
||||||
const [world, setWorld] = usePersistentState<WorldResponse | null>("world.last", null);
|
const [world, setWorld] = usePersistentState<WorldResponse | null>("world.last", null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
// ponytail: record a dragged pin's position into the world so it persists.
|
||||||
|
function onPinMove(name: string, x: number, y: number) {
|
||||||
|
setWorld((w) => w ? { ...w, pins: { ...(w.pins ?? {}), [name]: { x, y } } } : w);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: drop a selection that no longer exists on the map (e.g.
|
||||||
|
// after a regenerate or loading a different world from history).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!world || !selected) return;
|
||||||
|
const names = [...ensureArray(world.regions), ...ensureArray(world.landmarks)].map(flattenValue);
|
||||||
|
if (!names.includes(selected)) setSelected(null);
|
||||||
|
}, [world, selected]);
|
||||||
|
|
||||||
// ponytail: genre presets as a quick-pick row.
|
// ponytail: genre presets as a quick-pick row.
|
||||||
const THEME_PRESETS = [
|
const THEME_PRESETS = [
|
||||||
"high fantasy", "dark fantasy", "sword & sorcery",
|
"high fantasy", "dark fantasy", "sword & sorcery",
|
||||||
@@ -140,34 +158,60 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Regions */}
|
{/* Map + side details. Two-pane on wide screens; stacked on narrow. */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<WorldMap
|
||||||
|
regions={ensureArray(world.regions).map(flattenValue)}
|
||||||
|
landmarks={ensureArray(world.landmarks).map(flattenValue)}
|
||||||
|
pins={world.pins ?? {}}
|
||||||
|
selected={selected}
|
||||||
|
onSelect={setSelected}
|
||||||
|
onPinMove={onPinMove}
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-[var(--color-text-dim)] mt-1">
|
||||||
|
Drag pins to place regions (●) and landmarks (✦). Click a pin to select it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{/* Selected pin detail, or a hint to pick one. */}
|
||||||
|
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3">
|
||||||
|
{selected ? (
|
||||||
|
<>
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-[var(--color-gold-muted)]">
|
||||||
|
{ensureArray(world.regions).map(flattenValue).includes(selected) ? "Region" : "Landmark"}
|
||||||
|
</span>
|
||||||
|
<p className="text-[var(--color-text-primary)] text-sm mt-1">{selected}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-[var(--color-text-dim)] text-xs italic">
|
||||||
|
Click a pin on the map to inspect it here.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Regions (clickable → select on map) */}
|
||||||
{world.regions?.length > 0 && (
|
{world.regions?.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-[var(--color-text-secondary)] text-xs font-medium mb-1.5">
|
<h4 className="text-[var(--color-text-secondary)] text-xs font-medium mb-1.5">🏔 Regions</h4>
|
||||||
🏔 Regions
|
<div className="flex flex-col gap-1">
|
||||||
</h4>
|
{ensureArray(world.regions).map((r, i) => {
|
||||||
<div className="flex flex-col gap-1.5">
|
const name = flattenValue(r);
|
||||||
{ensureArray(world.regions).map((r, i) => (
|
return (
|
||||||
<div key={i} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] px-3 py-2 text-sm text-[var(--color-text-primary)]">
|
<button
|
||||||
{flattenValue(r)}
|
key={i}
|
||||||
</div>
|
onClick={() => setSelected(name)}
|
||||||
))}
|
className={`text-left rounded-lg px-3 py-1.5 text-xs cursor-pointer transition-colors ${
|
||||||
</div>
|
selected === name
|
||||||
</div>
|
? "bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] border border-[var(--color-gold-bright)]"
|
||||||
)}
|
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] text-[var(--color-text-primary)] hover:border-[var(--color-gold-muted)]"
|
||||||
|
}`}
|
||||||
{/* Landmarks */}
|
>
|
||||||
{world.landmarks?.length > 0 && (
|
{name}
|
||||||
<div>
|
</button>
|
||||||
<h4 className="text-[var(--color-text-secondary)] text-xs font-medium mb-1.5">
|
);
|
||||||
🏛 Landmarks
|
})}
|
||||||
</h4>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{ensureArray(world.landmarks).map((l, i) => (
|
|
||||||
<span key={i} className="rounded-full bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] px-3 py-1 text-xs">
|
|
||||||
{flattenValue(l)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -175,12 +219,10 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
{/* Conflicts */}
|
{/* Conflicts */}
|
||||||
{world.conflicts?.length > 0 && (
|
{world.conflicts?.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-[var(--color-text-secondary)] text-xs font-medium mb-1.5">
|
<h4 className="text-[var(--color-text-secondary)] text-xs font-medium mb-1.5">⚔ Conflicts</h4>
|
||||||
⚔ Conflicts
|
<div className="flex flex-col gap-1">
|
||||||
</h4>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
{ensureArray(world.conflicts).map((c, i) => (
|
{ensureArray(world.conflicts).map((c, i) => (
|
||||||
<div key={i} className="rounded-lg bg-[var(--color-bg-surface)] border-l-2 border-[var(--color-danger)] px-3 py-2 text-sm text-[var(--color-text-primary)]">
|
<div key={i} className="rounded-lg bg-[var(--color-bg-surface)] border-l-2 border-[var(--color-danger)] px-3 py-1.5 text-xs text-[var(--color-text-primary)]">
|
||||||
{flattenValue(c)}
|
{flattenValue(c)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -191,12 +233,10 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
{/* Cultures */}
|
{/* Cultures */}
|
||||||
{world.cultures?.length > 0 && (
|
{world.cultures?.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-[var(--color-text-secondary)] text-xs font-medium mb-1.5">
|
<h4 className="text-[var(--color-text-secondary)] text-xs font-medium mb-1.5">👥 Cultures</h4>
|
||||||
👥 Cultures
|
<div className="flex flex-wrap gap-1">
|
||||||
</h4>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{ensureArray(world.cultures).map((c, i) => (
|
{ensureArray(world.cultures).map((c, i) => (
|
||||||
<span key={i} className="rounded-full bg-[var(--color-bg-surface)] border border-[var(--color-info)]/30 text-[var(--color-info)] px-3 py-1 text-xs">
|
<span key={i} className="rounded-full bg-[var(--color-bg-surface)] border border-[var(--color-info)]/30 text-[var(--color-info)] px-2.5 py-1 text-[11px]">
|
||||||
{flattenValue(c)}
|
{flattenValue(c)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -204,6 +244,8 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// ponytail: pure layout math for the WorldMap, extracted so it's testable
|
||||||
|
// without konva. Positions are normalized 0..1 so the map survives a resize.
|
||||||
|
|
||||||
|
export type PinKind = "region" | "landmark";
|
||||||
|
|
||||||
|
export interface PinPos {
|
||||||
|
name: string;
|
||||||
|
x: number; // 0..1
|
||||||
|
y: number; // 0..1
|
||||||
|
kind: PinKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp a normalized coordinate into the canvas with a small margin so a pin
|
||||||
|
// can't be dragged half-off the edge.
|
||||||
|
export function clamp(v: number): number {
|
||||||
|
return Math.max(0.04, Math.min(0.96, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distribute N items on a centered ellipse so an unedited world reads as a
|
||||||
|
// map rather than a stacked list. Deterministic by index.
|
||||||
|
export function autoLayout(index: number, total: number, kind: PinKind): { x: number; y: number } {
|
||||||
|
if (total <= 0) return { x: 0.5, y: 0.5 };
|
||||||
|
if (total === 1) return { x: 0.5, y: 0.5 };
|
||||||
|
if (kind === "region") {
|
||||||
|
const angle = (index / total) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
return { x: 0.5 + Math.cos(angle) * 0.32, y: 0.5 + Math.sin(angle) * 0.34 };
|
||||||
|
}
|
||||||
|
// landmarks: inner ring, offset by half a step so they don't overlap regions
|
||||||
|
const angle = (index / total) * Math.PI * 2;
|
||||||
|
return { x: 0.5 + Math.cos(angle) * 0.18, y: 0.5 + Math.sin(angle) * 0.2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the pin list: saved positions win, else auto-layout. Returns one pin
|
||||||
|
// per name; duplicates keep distinct pins by kind+index but share a position
|
||||||
|
// key, so the last duplicate's saved position wins for that name.
|
||||||
|
export function buildPins(
|
||||||
|
regions: string[],
|
||||||
|
landmarks: string[],
|
||||||
|
pins: Record<string, { x: number; y: number }>,
|
||||||
|
): PinPos[] {
|
||||||
|
const out: PinPos[] = [];
|
||||||
|
regions.forEach((name, i) => {
|
||||||
|
const pos = pins[name] ?? autoLayout(i, regions.length, "region");
|
||||||
|
out.push({ name, x: clamp(pos.x), y: clamp(pos.y), kind: "region" });
|
||||||
|
});
|
||||||
|
landmarks.forEach((name, i) => {
|
||||||
|
const pos = pins[name] ?? autoLayout(i, landmarks.length, "landmark");
|
||||||
|
out.push({ name, x: clamp(pos.x), y: clamp(pos.y), kind: "landmark" });
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Self-check (run: node src/lib/worldMap.ts) ──────────────
|
||||||
|
// ponytail: the smallest thing that fails if the layout math breaks. No
|
||||||
|
// framework — plain asserts. Verifies clamp bounds, auto-layout spread, and
|
||||||
|
// that saved positions survive a buildPins round-trip.
|
||||||
|
function assert(cond: boolean, msg: string): void {
|
||||||
|
if (!cond) throw new Error(`worldMap self-check failed: ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function demo(): void {
|
||||||
|
assert(clamp(-1) === 0.04, "clamp floors at margin");
|
||||||
|
assert(clamp(2) === 0.96, "clamp ceilings at margin");
|
||||||
|
assert(clamp(0.5) === 0.5, "clamp passes through mid");
|
||||||
|
|
||||||
|
// Single item centers; many items spread (no two identical on a ring).
|
||||||
|
const single = autoLayout(0, 1, "region");
|
||||||
|
assert(single.x === 0.5 && single.y === 0.5, "single item centers");
|
||||||
|
|
||||||
|
const many = Array.from({ length: 6 }, (_, i) => autoLayout(i, 6, "region"));
|
||||||
|
const uniq = new Set(many.map((p) => `${p.x.toFixed(3)},${p.y.toFixed(3)}`));
|
||||||
|
assert(uniq.size === 6, "6 regions spread to 6 distinct spots");
|
||||||
|
|
||||||
|
// Saved positions survive buildPins; unsaved ones get auto-laid-out + clamped.
|
||||||
|
const built = buildPins(["A", "B"], ["X"], { A: { x: 0.2, y: 0.3 } });
|
||||||
|
assert(built.length === 3, "buildPins returns regions + landmarks");
|
||||||
|
const a = built.find((p) => p.name === "A")!;
|
||||||
|
assert(a.x === 0.2 && a.y === 0.3, "saved position preserved");
|
||||||
|
const b = built.find((p) => p.name === "B")!;
|
||||||
|
assert(b.x >= 0.04 && b.x <= 0.96, "auto-laid-out pin is clamped");
|
||||||
|
|
||||||
|
console.log("worldMap self-check passed ✓");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user