From 82f2dcf3df97d0b43cd95a316592225893a374bf Mon Sep 17 00:00:00 2001 From: itsamejms Date: Mon, 13 Jul 2026 10:50:55 +0100 Subject: [PATCH] adding initial world map frontend --- docs/ui-ux-improvements.md | 2 +- package-lock.json | 100 +++++++++++++++++++- package.json | 2 + scripts/check-worldmap.ts | 5 + src/components/WorldBuilder.tsx | 160 ++++++++++++++++++++------------ src/components/WorldMap.tsx | 158 +++++++++++++++++++++++++++++++ src/lib/worldMap.ts | 83 +++++++++++++++++ 7 files changed, 448 insertions(+), 62 deletions(-) create mode 100644 scripts/check-worldmap.ts create mode 100644 src/components/WorldMap.tsx create mode 100644 src/lib/worldMap.ts 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