diff --git a/docs/ui-ux-improvements.md b/docs/ui-ux-improvements.md
index bf56395..7e9c880 100644
--- a/docs/ui-ux-improvements.md
+++ b/docs/ui-ux-improvements.md
@@ -1122,7 +1122,7 @@ These are blockers or bugs that make the app feel broken.
- [ ] Weighted tables
- [x] Export table
- [ ] **World Builder**:
- - [ ] Map canvas (react-konva)
+ - [x] Map canvas (react-konva)
- [x] Theme presets
- [ ] Hierarchy tree
- [x] Persist world
diff --git a/package-lock.json b/package-lock.json
index 9e26d13..f271f93 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,9 +12,11 @@
"@tauri-apps/plugin-log": "^2",
"@tauri-apps/plugin-store": "^2.4.3",
"framer-motion": "^12",
+ "konva": "^10.3.0",
"lucide-react": "^0.525",
"react": "^19.2.7",
"react-dom": "^19.2.7",
+ "react-konva": "^19.2.5",
"zustand": "^5.0.14"
},
"devDependencies": {
@@ -1341,7 +1343,6 @@
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
- "devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1357,6 +1358,15 @@
"@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": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
@@ -1387,7 +1397,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "devOptional": true,
"license": "MIT"
},
"node_modules/detect-libc": {
@@ -1481,6 +1490,27 @@
"dev": true,
"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": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -1491,6 +1521,26 @@
"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": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1936,6 +1986,52 @@
"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": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
diff --git a/package.json b/package.json
index 9cf32a2..9c44253 100644
--- a/package.json
+++ b/package.json
@@ -15,9 +15,11 @@
"@tauri-apps/plugin-log": "^2",
"@tauri-apps/plugin-store": "^2.4.3",
"framer-motion": "^12",
+ "konva": "^10.3.0",
"lucide-react": "^0.525",
"react": "^19.2.7",
"react-dom": "^19.2.7",
+ "react-konva": "^19.2.5",
"zustand": "^5.0.14"
},
"devDependencies": {
diff --git a/scripts/check-worldmap.ts b/scripts/check-worldmap.ts
new file mode 100644
index 0000000..4f1f7e8
--- /dev/null
+++ b/scripts/check-worldmap.ts
@@ -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();
diff --git a/src/components/WorldBuilder.tsx b/src/components/WorldBuilder.tsx
index f71512f..347e1df 100644
--- a/src/components/WorldBuilder.tsx
+++ b/src/components/WorldBuilder.tsx
@@ -1,11 +1,12 @@
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 { addToLore } from "../lib/lore";
import { useToast } from "./Toast";
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
import { usePrefillEffect } from "../lib/usePrefill";
import { usePersistentState } from "../lib/usePersistentState";
+import { WorldMap } from "./WorldMap";
interface WorldResponse {
name: string;
@@ -14,6 +15,9 @@ interface WorldResponse {
landmarks: string[];
conflicts: string[];
cultures: string[];
+ // ponytail: DM-placed pin positions (normalized 0..1). UI-managed, not
+ // returned by the LLM. Persisted with the world.
+ pins?: Record;
}
interface Props {
@@ -26,8 +30,22 @@ export function WorldBuilder({ prefill, onPrefillConsumed }: Props = {}) {
const [theme, setTheme] = usePersistentState("world.theme", "high fantasy");
const [world, setWorld] = usePersistentState("world.last", null);
const [loading, setLoading] = useState(false);
+ const [selected, setSelected] = useState(null);
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.
const THEME_PRESETS = [
"high fantasy", "dark fantasy", "sword & sorcery",
@@ -140,69 +158,93 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
- {/* Regions */}
- {world.regions?.length > 0 && (
-
-
- 🏔 Regions
-
-
- {ensureArray(world.regions).map((r, i) => (
-
- {flattenValue(r)}
+ {/* Map + side details. Two-pane on wide screens; stacked on narrow. */}
+
+
+
+
+ Drag pins to place regions (●) and landmarks (✦). Click a pin to select it.
+
+
+
+
+ {/* Selected pin detail, or a hint to pick one. */}
+
+ {selected ? (
+ <>
+
+ {ensureArray(world.regions).map(flattenValue).includes(selected) ? "Region" : "Landmark"}
+
+
{selected}
+ >
+ ) : (
+
+ Click a pin on the map to inspect it here.
+
+ )}
+
+
+ {/* Regions (clickable → select on map) */}
+ {world.regions?.length > 0 && (
+
+
🏔 Regions
+
+ {ensureArray(world.regions).map((r, i) => {
+ const name = flattenValue(r);
+ return (
+
+ );
+ })}
- ))}
-
-
- )}
+
+ )}
- {/* Landmarks */}
- {world.landmarks?.length > 0 && (
-
-
- 🏛 Landmarks
-
-
- {ensureArray(world.landmarks).map((l, i) => (
-
- {flattenValue(l)}
-
- ))}
-
-
- )}
-
- {/* Conflicts */}
- {world.conflicts?.length > 0 && (
-
-
- ⚔ Conflicts
-
-
- {ensureArray(world.conflicts).map((c, i) => (
-
- {flattenValue(c)}
+ {/* Conflicts */}
+ {world.conflicts?.length > 0 && (
+
+
⚔ Conflicts
+
+ {ensureArray(world.conflicts).map((c, i) => (
+
+ {flattenValue(c)}
+
+ ))}
- ))}
-
-
- )}
+
+ )}
- {/* Cultures */}
- {world.cultures?.length > 0 && (
-
-
- 👥 Cultures
-
-
- {ensureArray(world.cultures).map((c, i) => (
-
- {flattenValue(c)}
-
- ))}
-
+ {/* Cultures */}
+ {world.cultures?.length > 0 && (
+
+
👥 Cultures
+
+ {ensureArray(world.cultures).map((c, i) => (
+
+ {flattenValue(c)}
+
+ ))}
+
+
+ )}
- )}
+
)}
diff --git a/src/components/WorldMap.tsx b/src/components/WorldMap.tsx
new file mode 100644
index 0000000..5cedcc2
--- /dev/null
+++ b/src/components/WorldMap.tsx
@@ -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
;
+ 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(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 (
+
+ ) => {
+ // Click on empty canvas deselects.
+ if (e.target === e.target.getStage()) onSelect(null);
+ }}
+ className="rounded-xl border border-[var(--color-border-glass)]"
+ >
+
+ {/* Parchment-style chart background: deep navy with a faint gold grid. */}
+
+ {/* Faint grid lines */}
+ {Array.from({ length: 9 }, (_, i) => {
+ const gx = (i + 1) * (w / 10);
+ const gy = (i + 1) * (h / 10);
+ return (
+
+
+
+
+ );
+ })}
+ {/* Vignette frame */}
+
+ {/* Compass rose (top-right) */}
+
+
+
+
+
+
+
+
+
+ {pinList.map((pin) => {
+ const px = pin.x * w;
+ const py = pin.y * h;
+ const isRegion = pin.kind === "region";
+ const isSelected = pin.name === selected;
+ return (
+ {
+ 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 ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+ );
+ })}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/lib/worldMap.ts b/src/lib/worldMap.ts
new file mode 100644
index 0000000..603e4d7
--- /dev/null
+++ b/src/lib/worldMap.ts
@@ -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,
+): 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 ✓");
+}
\ No newline at end of file