From 13483d54ddb00ad5aab35aa5dcff446bb9de5728 Mon Sep 17 00:00:00 2001 From: Aditya Gupta Date: Wed, 15 Jul 2026 21:10:56 +0530 Subject: [PATCH] feat(builder): Added worldmap visualization --- apps/gui/src/app/builder/page.tsx | 3 + .../components/builder/AttributeEditor.tsx | 9 +- .../src/components/builder/EntitiesTab.tsx | 40 +- .../src/components/builder/LocationsTab.tsx | 474 ++++++++-------- .../src/components/builder/MetadataTab.tsx | 7 +- apps/gui/src/components/builder/WorldMap.tsx | 515 ++++++++++++++++++ apps/gui/src/components/builder/utils.ts | 45 ++ 7 files changed, 844 insertions(+), 249 deletions(-) create mode 100644 apps/gui/src/components/builder/WorldMap.tsx create mode 100644 apps/gui/src/components/builder/utils.ts diff --git a/apps/gui/src/app/builder/page.tsx b/apps/gui/src/app/builder/page.tsx index 2f403aa..de80736 100644 --- a/apps/gui/src/app/builder/page.tsx +++ b/apps/gui/src/app/builder/page.tsx @@ -604,6 +604,7 @@ export default function BuilderPage() { worldAttributes={worldAttributes} setWorldAttributes={setWorldAttributes} entityIds={entityIds} + entities={entities} /> )} @@ -612,6 +613,7 @@ export default function BuilderPage() { void; onAdd: () => void; entityIds: string[]; + entities?: EntityData[]; } export function AttributeEditor({ @@ -21,6 +23,7 @@ export function AttributeEditor({ onChange, onAdd, entityIds, + entities, }: AttributeEditorProps) { const handleAttrChange = ( index: number, @@ -144,7 +147,9 @@ export function AttributeEditor({ : "bg-background border-border/30 text-muted-foreground hover:bg-secondary" }`} > - {entId} + {entities + ? getEntityDisplayNameById(entId, entities) + : entId} ); })} diff --git a/apps/gui/src/components/builder/EntitiesTab.tsx b/apps/gui/src/components/builder/EntitiesTab.tsx index 2d90e61..8002c83 100644 --- a/apps/gui/src/components/builder/EntitiesTab.tsx +++ b/apps/gui/src/components/builder/EntitiesTab.tsx @@ -6,11 +6,17 @@ import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { AttributeEditor } from "./AttributeEditor"; import { Plus, Trash2, ChevronRight } from "lucide-react"; -import type { EntityData, MemoryData } from "./types"; +import type { EntityData, MemoryData, LocationData } from "./types"; +import { + getEntityDisplayName, + getEntityDisplayNameById, + getLocationDisplayNameById, +} from "./utils"; interface EntitiesTabProps { entities: EntityData[]; setEntities: (ents: EntityData[]) => void; + locations: LocationData[]; locationIds: string[]; entityIds: string[]; selectedEntIndex: number; @@ -22,6 +28,7 @@ interface EntitiesTabProps { export function EntitiesTab({ entities, setEntities, + locations, locationIds, entityIds, selectedEntIndex, @@ -32,7 +39,7 @@ export function EntitiesTab({ const selectedEnt = entities[selectedEntIndex]; return ( -
+
{/* Left sidebar: Entities list */}
@@ -72,7 +79,9 @@ export function EntitiesTab({ : "hover:bg-secondary/40 text-foreground" }`} > - {ent.id || `(Empty ID)`} + + {getEntityDisplayName(ent) || `(Empty ID)`} + {entities.length > 1 && (
@@ -132,7 +134,7 @@ export function EntitiesTab({ {locationIds.map((id) => ( ))} @@ -162,6 +164,7 @@ export function EntitiesTab({ setEntities(copy); }} entityIds={entityIds} + entities={entities} />
@@ -211,8 +214,11 @@ export function EntitiesTab({ key={targetId} className="flex gap-2 items-center bg-secondary/15 p-2 rounded" > - - {targetId} + + {getEntityDisplayNameById(targetId, entities)} -- Nowhere -- {locationIds.map((id) => ( ))} @@ -435,7 +441,7 @@ export function EntitiesTab({ : "bg-background border-border/30 text-muted-foreground hover:bg-secondary" }`} > - {entId} + {getEntityDisplayNameById(entId, entities)} ); })} diff --git a/apps/gui/src/components/builder/LocationsTab.tsx b/apps/gui/src/components/builder/LocationsTab.tsx index 011879a..f81ef5d 100644 --- a/apps/gui/src/components/builder/LocationsTab.tsx +++ b/apps/gui/src/components/builder/LocationsTab.tsx @@ -6,11 +6,14 @@ import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { AttributeEditor } from "./AttributeEditor"; import { Plus, Trash2 } from "lucide-react"; -import type { LocationData, ConnectionData } from "./types"; +import type { LocationData, ConnectionData, EntityData } from "./types"; +import { getLocationDisplayName, getLocationDisplayNameById } from "./utils"; +import { WorldMap } from "./WorldMap"; interface LocationsTabProps { locations: LocationData[]; setLocations: (locs: LocationData[]) => void; + entities: EntityData[]; locationIds: string[]; entityIds: string[]; selectedLocIndex: number; @@ -21,6 +24,7 @@ interface LocationsTabProps { export function LocationsTab({ locations, setLocations, + entities, locationIds, entityIds, selectedLocIndex, @@ -67,7 +71,21 @@ export function LocationsTab({ const selectedLoc = locations[selectedLocIndex]; return ( -
+
+ {/* Map Visualizer */} + {locations.length > 0 && ( +
+ { + const idx = locations.findIndex((l) => l.id === id); + if (idx !== -1) setSelectedLocIndex(idx); + }} + /> +
+ )} + {/* Left sidebar: Locations list */}
@@ -101,7 +119,9 @@ export function LocationsTab({ : "hover:bg-secondary/40 text-foreground" }`} > - {loc.id || `(Empty ID)`} + + {getLocationDisplayName(loc) || `(Empty ID)`} + {locations.length > 1 && (
- {/* Right panel: Edit selected location details */} - {selectedLoc ? ( -
- {/* Basic location fields */} -
-

- Location Configuration -

+ {/* Right panel wrapper */} +
+ {selectedLoc ? ( +
+ {/* Basic location fields */} +
+

+ Location Configuration +

-
- - { - const copy = [...locations]; - copy[selectedLocIndex].id = e.target.value - .toLowerCase() - .replace(/[^a-z0-9-_]/g, ""); - setLocations(copy); - }} - className="font-mono text-xs" - /> +
+ + +
+ +
+ + +
+ + {/* Attributes for Location */} +
+ { + const copy = [...locations]; + copy[selectedLocIndex].attributes = newAttrs; + setLocations(copy); + }} + onAdd={() => { + const copy = [...locations]; + copy[selectedLocIndex].attributes = [ + ...copy[selectedLocIndex].attributes, + { + name: "", + value: "", + visibility: "PUBLIC", + allowedEntities: [], + }, + ]; + setLocations(copy); + }} + entityIds={entityIds} + entities={entities} + /> +
-
- - -
+ {/* Connections (spatial paths) */} +
+
+

+ Connections / Portals +

+ +
- {/* Attributes for Location */} -
- { - const copy = [...locations]; - copy[selectedLocIndex].attributes = newAttrs; - setLocations(copy); - }} - onAdd={() => { - const copy = [...locations]; - copy[selectedLocIndex].attributes = [ - ...copy[selectedLocIndex].attributes, - { - name: "", - value: "", - visibility: "PUBLIC", - allowedEntities: [], - }, - ]; - setLocations(copy); - }} - entityIds={entityIds} - /> -
-
- - {/* Connections (spatial paths) */} -
-
-

- Connections / Portals -

- -
- - {!selectedLoc.connections || - selectedLoc.connections.length === 0 ? ( -

- No connections leading from this location. -

- ) : ( -
- {selectedLoc.connections.map((conn, connIdx) => ( -
- + -
-
- - Target Location - - +
+
+ + Target Location + + +
+ +
+ + Portal Name + + + updateLocationConnection( + selectedLocIndex, + connIdx, + "portalName", + e.target.value, + ) + } + className="h-7 text-xs" + /> +
- Portal Name + Portal State updateLocationConnection( selectedLocIndex, connIdx, - "portalName", + "portalStateDescriptor", e.target.value, ) } className="h-7 text-xs" />
-
-
- - Portal State - - - updateLocationConnection( - selectedLocIndex, - connIdx, - "portalStateDescriptor", - e.target.value, - ) - } - className="h-7 text-xs" - /> -
+
+
+ + Vision Propagation ({conn.visionProp}) + + + updateLocationConnection( + selectedLocIndex, + connIdx, + "visionProp", + Number(e.target.value), + ) + } + className="w-full h-1 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" + /> +
-
-
- - Vision Propagation ({conn.visionProp}) - - - updateLocationConnection( - selectedLocIndex, - connIdx, - "visionProp", - Number(e.target.value), - ) - } - className="w-full h-1 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" - /> +
+ + Sound Propagation ({conn.soundProp}) + + + updateLocationConnection( + selectedLocIndex, + connIdx, + "soundProp", + Number(e.target.value), + ) + } + className="w-full h-1 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" + /> +
-
- - Sound Propagation ({conn.soundProp}) - - - updateLocationConnection( - selectedLocIndex, - connIdx, - "soundProp", - Number(e.target.value), - ) - } - className="w-full h-1 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" - /> +
+
- -
- -
-
- ))} -
- )} + ))} +
+ )} +
-
- ) : ( -
- No locations defined. -
- )} + ) : ( +
+ No location selected. Choose a location from the sidebar to edit, or + view the world layout below. +
+ )} +
); } diff --git a/apps/gui/src/components/builder/MetadataTab.tsx b/apps/gui/src/components/builder/MetadataTab.tsx index f0c37c2..87f39cd 100644 --- a/apps/gui/src/components/builder/MetadataTab.tsx +++ b/apps/gui/src/components/builder/MetadataTab.tsx @@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { AttributeEditor } from "./AttributeEditor"; -import type { AttributeData } from "./types"; +import type { AttributeData, EntityData } from "./types"; interface MetadataTabProps { scenarioId: string; @@ -18,6 +18,7 @@ interface MetadataTabProps { worldAttributes: AttributeData[]; setWorldAttributes: (attrs: AttributeData[]) => void; entityIds: string[]; + entities: EntityData[]; } export function MetadataTab({ @@ -32,6 +33,7 @@ export function MetadataTab({ worldAttributes, setWorldAttributes, entityIds, + entities, }: MetadataTabProps) { const addWorldAttribute = () => { setWorldAttributes([ @@ -41,7 +43,7 @@ export function MetadataTab({ }; return ( -
+
{/* Basic Fields */}

@@ -113,6 +115,7 @@ export function MetadataTab({ onChange={setWorldAttributes} onAdd={addWorldAttribute} entityIds={entityIds} + entities={entities} />

diff --git a/apps/gui/src/components/builder/WorldMap.tsx b/apps/gui/src/components/builder/WorldMap.tsx new file mode 100644 index 0000000..e31c6c9 --- /dev/null +++ b/apps/gui/src/components/builder/WorldMap.tsx @@ -0,0 +1,515 @@ +"use client"; + +import { useState, useMemo, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { ZoomIn, ZoomOut, RotateCcw, Map as MapIcon } from "lucide-react"; +import type { LocationData } from "./types"; + +interface BoxNode { + id: string; + name: string; + width: number; + height: number; + x: number; + y: number; + children: BoxNode[]; + location: LocationData; +} + +interface WorldMapProps { + locations: LocationData[]; + selectedLocId?: string; + onSelectLocId: (id: string) => void; +} + +export function WorldMap({ + locations, + selectedLocId, + onSelectLocId, +}: WorldMapProps) { + // Zoom and Pan States for Visualizer + const [zoom, setZoom] = useState(1); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const [isPanning, setIsPanning] = useState(false); + const panStartRef = useRef({ x: 0, y: 0 }); + + const handleMouseDown = (e: React.MouseEvent) => { + if (e.button === 0) { + setIsPanning(true); + panStartRef.current = { x: e.clientX - pan.x, y: e.clientY - pan.y }; + } + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (isPanning) { + setPan({ + x: e.clientX - panStartRef.current.x, + y: e.clientY - panStartRef.current.y, + }); + } + }; + + const handleMouseUp = () => { + setIsPanning(false); + }; + + // Build tree and layout computation + const { roots, nodeMap } = useMemo(() => { + const nodeMap = new Map(); + + // 1. Initialize all nodes + locations.forEach((loc) => { + const nameAttr = loc.attributes?.find( + (a) => a.name.toLowerCase() === "name", + )?.value; + const name = nameAttr ? nameAttr : loc.id; + nodeMap.set(loc.id, { + id: loc.id, + name, + width: 0, + height: 0, + x: 0, + y: 0, + children: [], + location: loc, + }); + }); + + const roots: BoxNode[] = []; + + // 2. Link parents and children + locations.forEach((loc) => { + const node = nodeMap.get(loc.id)!; + if (loc.parentId && nodeMap.has(loc.parentId)) { + nodeMap.get(loc.parentId)!.children.push(node); + } else { + roots.push(node); + } + }); + + // 3. Measure recursively + const measureNode = (node: BoxNode) => { + const textWidth = node.name.length * 7.2 + 24; + + if (node.children.length === 0) { + node.width = Math.max(140, textWidth); + node.height = 70; + return; + } + + node.children.forEach((child) => measureNode(child)); + + const cols = Math.ceil(Math.sqrt(node.children.length)); + const rows = Math.ceil(node.children.length / cols); + + const gap = 20; + const padLeft = 20; + const padRight = 20; + const padTop = 45; + const padBottom = 20; + + const colWidths = new Array(cols).fill(0); + const rowHeights = new Array(rows).fill(0); + + node.children.forEach((child, idx) => { + const col = idx % cols; + const row = Math.floor(idx / cols); + colWidths[col] = Math.max(colWidths[col], child.width); + rowHeights[row] = Math.max(rowHeights[row], child.height); + }); + + const totalGridWidth = + colWidths.reduce((sum, w) => sum + w, 0) + (cols - 1) * gap; + const totalGridHeight = + rowHeights.reduce((sum, h) => sum + h, 0) + (rows - 1) * gap; + + node.width = Math.max( + 180, + totalGridWidth + padLeft + padRight, + textWidth, + ); + node.height = Math.max(100, totalGridHeight + padTop + padBottom); + + let yOffset = padTop; + for (let r = 0; r < rows; r++) { + let xOffset = padLeft; + for (let c = 0; c < cols; c++) { + const idx = r * cols + c; + if (idx >= node.children.length) break; + const child = node.children[idx]; + + const cellWidth = colWidths[c]; + const cellHeight = rowHeights[r]; + child.x = xOffset + (cellWidth - child.width) / 2; + child.y = yOffset + (cellHeight - child.height) / 2; + + xOffset += cellWidth + gap; + } + yOffset += rowHeights[r] + gap; + } + }; + + roots.forEach((root) => measureNode(root)); + + // 4. Position roots and assign global coordinates + const assignGlobalCoordinates = ( + node: BoxNode, + parentX: number, + parentY: number, + ) => { + node.x += parentX; + node.y += parentY; + node.children.forEach((child) => { + assignGlobalCoordinates(child, node.x, node.y); + }); + }; + + const cols = Math.ceil(Math.sqrt(roots.length)); + const gap = 40; + + const colWidths = new Array(cols).fill(0); + const rowHeights: number[] = []; + + roots.forEach((root, idx) => { + const col = idx % cols; + const row = Math.floor(idx / cols); + colWidths[col] = Math.max(colWidths[col], root.width); + if (rowHeights[row] === undefined) rowHeights[row] = 0; + rowHeights[row] = Math.max(rowHeights[row], root.height); + }); + + let yOffset = 40; + let maxWidth = 0; + + for (let r = 0; r < rowHeights.length; r++) { + let xOffset = 40; + for (let c = 0; c < cols; c++) { + const idx = r * cols + c; + if (idx >= roots.length) break; + const root = roots[idx]; + + root.x = xOffset; + root.y = yOffset; + + assignGlobalCoordinates(root, 0, 0); + xOffset += colWidths[c] + gap; + } + yOffset += rowHeights[r] + gap; + maxWidth = Math.max(maxWidth, xOffset); + } + + return { roots, nodeMap }; + }, [locations]); + + // Connection lines computation + const connectionLines = useMemo(() => { + const lines: any[] = []; + const seen = new Set(); + + locations.forEach((loc) => { + const nodeA = nodeMap.get(loc.id); + if (!nodeA) return; + + loc.connections?.forEach((conn, cIdx) => { + const nodeB = nodeMap.get(conn.targetId); + if (!nodeB) return; + + const isParentChild = + loc.parentId === conn.targetId || nodeB.location.parentId === loc.id; + if (isParentChild) return; + + const sortedIds = [loc.id, conn.targetId].sort(); + const key = conn.bidirectional + ? `bidi-${sortedIds[0]}-${sortedIds[1]}` + : `uni-${loc.id}-${conn.targetId}`; + + if (seen.has(key)) return; + seen.add(key); + + const x1 = nodeA.x + nodeA.width / 2; + const y1 = nodeA.y + nodeA.height / 2; + const x2 = nodeB.x + nodeB.width / 2; + const y2 = nodeB.y + nodeB.height / 2; + + lines.push({ + id: `${loc.id}-${conn.targetId}-${cIdx}`, + x1, + y1, + x2, + y2, + portalName: conn.portalName, + portalState: conn.portalStateDescriptor, + vision: conn.visionProp, + sound: conn.soundProp, + bidirectional: conn.bidirectional, + sourceId: loc.id, + targetId: conn.targetId, + }); + }); + }); + + return lines; + }, [locations, nodeMap]); + + // Recursive render helper for boxes + const renderNode = (node: BoxNode) => { + const isSelected = selectedLocId === node.id; + + // Find parent connection details + const parentLoc = locations.find((l) => l.id === node.location.parentId); + const childToParentConn = node.location.parentId + ? node.location.connections?.find( + (c) => c.targetId === node.location.parentId, + ) + : undefined; + const parentToChildConn = + node.location.parentId && parentLoc + ? parentLoc.connections?.find((c) => c.targetId === node.id) + : undefined; + + const hasParentConn = !!(childToParentConn || parentToChildConn); + const portalName = + childToParentConn?.portalName || parentToChildConn?.portalName; + + return ( + + {/* Box */} + { + e.stopPropagation(); + onSelectLocId(node.id); + }} + className={`transition-all cursor-pointer ${ + isSelected + ? "fill-primary/5 stroke-primary stroke-2" + : node.children.length > 0 + ? "fill-secondary/5 stroke-border/40 hover:stroke-border/80" + : "fill-secondary/20 stroke-border/40 hover:stroke-foreground/40 hover:fill-secondary/30" + }`} + style={{ strokeWidth: isSelected ? 2 : 1 }} + strokeDasharray={ + hasParentConn + ? "4, 4" + : node.children.length > 0 + ? "3, 3" + : undefined + } + /> + + {/* Label */} + + {node.name} + + + {/* Portal Name on Boundary of Child */} + {hasParentConn && portalName && ( + + + + {portalName} + + + )} + + {/* Render children inside */} + {node.children.map((child) => renderNode(child))} + + ); + }; + + return ( + <> + {/* Header */} +
+ {/* Controls */} +
+ + + +
+
+ + {/* Canvas container */} +
+ + + + + + + + {/* Grid Background */} + + + {/* Draggable/scalable group */} + + {/* Draw Connection Lines first so they are behind boxes */} + {connectionLines.map((line) => { + const isSourceSelected = selectedLocId === line.sourceId; + const isTargetSelected = selectedLocId === line.targetId; + const isHighlighted = isSourceSelected || isTargetSelected; + + const mx = (line.x1 + line.x2) / 2; + const my = (line.y1 + line.y2) / 2; + const angle = + Math.atan2(line.y2 - line.y1, line.x2 - line.x1) * + (180 / Math.PI); + + return ( + + {/* Glow path for highlighted connections */} + {isHighlighted && ( + + )} + + {/* Main Connection Line */} + + + {/* Midpoint Direction Arrow for Uni-directional connections */} + {!line.bidirectional && ( + + + + )} + + {/* Portal Name Label bubble */} + {line.portalName && ( + + + + {line.portalName} + + + )} + + ); + })} + + {/* Draw Box Nodes recursively */} + {roots.map((root) => renderNode(root))} + + + + {/* Map Hint Overlay */} +
+ Drag to pan • Scroll or buttons to zoom • Click boxes to select +
+
+ + ); +} diff --git a/apps/gui/src/components/builder/utils.ts b/apps/gui/src/components/builder/utils.ts new file mode 100644 index 0000000..b9b6f60 --- /dev/null +++ b/apps/gui/src/components/builder/utils.ts @@ -0,0 +1,45 @@ +import type { EntityData, LocationData } from "./types"; + +export function getEntityDisplayName( + entity: EntityData | undefined, + fallbackId: string = "", +): string { + if (!entity) return fallbackId; + const nameAttr = entity.attributes?.find( + (a) => a.name.toLowerCase() === "name", + ); + if (nameAttr?.value) { + return `${nameAttr.value} (${entity.id})`; + } + return entity.id; +} + +export function getEntityDisplayNameById( + id: string, + entities: EntityData[], +): string { + const entity = entities.find((e) => e.id === id); + return getEntityDisplayName(entity, id); +} + +export function getLocationDisplayName( + location: LocationData | undefined, + fallbackId: string = "", +): string { + if (!location) return fallbackId; + const nameAttr = location.attributes?.find( + (a) => a.name.toLowerCase() === "name", + ); + if (nameAttr?.value) { + return `${nameAttr.value} (${location.id})`; + } + return location.id; +} + +export function getLocationDisplayNameById( + id: string, + locations: LocationData[], +): string { + const location = locations.find((l) => l.id === id); + return getLocationDisplayName(location, id); +}