mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 03:52:48 +05:30
feat(builder): Added worldmap visualization
This commit is contained in:
@@ -604,6 +604,7 @@ export default function BuilderPage() {
|
||||
worldAttributes={worldAttributes}
|
||||
setWorldAttributes={setWorldAttributes}
|
||||
entityIds={entityIds}
|
||||
entities={entities}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -612,6 +613,7 @@ export default function BuilderPage() {
|
||||
<LocationsTab
|
||||
locations={locations}
|
||||
setLocations={setLocations}
|
||||
entities={entities}
|
||||
locationIds={locationIds}
|
||||
entityIds={entityIds}
|
||||
selectedLocIndex={selectedLocIndex}
|
||||
@@ -625,6 +627,7 @@ export default function BuilderPage() {
|
||||
<EntitiesTab
|
||||
entities={entities}
|
||||
setEntities={setEntities}
|
||||
locations={locations}
|
||||
locationIds={locationIds}
|
||||
entityIds={entityIds}
|
||||
selectedEntIndex={selectedEntIndex}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import type { AttributeData } from "./types";
|
||||
import type { AttributeData, EntityData } from "./types";
|
||||
import { getEntityDisplayNameById } from "./utils";
|
||||
|
||||
interface AttributeEditorProps {
|
||||
title?: string;
|
||||
@@ -13,6 +14,7 @@ interface AttributeEditorProps {
|
||||
onChange: (attrs: AttributeData[]) => void;
|
||||
onAdd: () => void;
|
||||
entityIds: string[];
|
||||
entities?: EntityData[];
|
||||
}
|
||||
|
||||
export function AttributeEditor({
|
||||
@@ -21,6 +23,7 @@ export function AttributeEditor({
|
||||
onChange,
|
||||
onAdd,
|
||||
entityIds,
|
||||
entities,
|
||||
}: AttributeEditorProps) {
|
||||
const handleAttrChange = <K extends keyof AttributeData>(
|
||||
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}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 items-start min-h-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 items-start min-h-0 pb-12">
|
||||
{/* Left sidebar: Entities list */}
|
||||
<div className="md:col-span-1 border border-border/20 bg-card shadow-[2px_2px_0_0_var(--border)] flex flex-col max-h-[500px]">
|
||||
<div className="p-3 border-b border-border/25 flex justify-between items-center bg-secondary/15">
|
||||
@@ -72,7 +79,9 @@ export function EntitiesTab({
|
||||
: "hover:bg-secondary/40 text-foreground"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{ent.id || `(Empty ID)`}</span>
|
||||
<span className="truncate">
|
||||
{getEntityDisplayName(ent) || `(Empty ID)`}
|
||||
</span>
|
||||
{entities.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -104,16 +113,9 @@ export function EntitiesTab({
|
||||
<div className="space-y-1.5">
|
||||
<Label>Entity ID</Label>
|
||||
<Input
|
||||
placeholder="e.g. alice"
|
||||
value={selectedEnt.id}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].id = e.target.value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-_]/g, "");
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="font-mono text-xs"
|
||||
readOnly
|
||||
className="font-mono text-xs bg-muted cursor-not-allowed text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -132,7 +134,7 @@ export function EntitiesTab({
|
||||
<option value="">-- No Location (floating) --</option>
|
||||
{locationIds.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{id}
|
||||
{getLocationDisplayNameById(id, locations)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -162,6 +164,7 @@ export function EntitiesTab({
|
||||
setEntities(copy);
|
||||
}}
|
||||
entityIds={entityIds}
|
||||
entities={entities}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,8 +214,11 @@ export function EntitiesTab({
|
||||
key={targetId}
|
||||
className="flex gap-2 items-center bg-secondary/15 p-2 rounded"
|
||||
>
|
||||
<span className="text-[11px] font-mono text-muted-foreground w-1/3 truncate">
|
||||
{targetId}
|
||||
<span
|
||||
className="text-[11px] font-mono text-muted-foreground w-1/3 truncate"
|
||||
title={targetId}
|
||||
>
|
||||
{getEntityDisplayNameById(targetId, entities)}
|
||||
</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
@@ -352,7 +358,7 @@ export function EntitiesTab({
|
||||
<option value="">-- Nowhere --</option>
|
||||
{locationIds.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{id}
|
||||
{getLocationDisplayNameById(id, locations)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -435,7 +441,7 @@ export function EntitiesTab({
|
||||
: "bg-background border-border/30 text-muted-foreground hover:bg-secondary"
|
||||
}`}
|
||||
>
|
||||
{entId}
|
||||
{getEntityDisplayNameById(entId, entities)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 items-start min-h-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 items-start min-h-0 pb-12">
|
||||
{/* Map Visualizer */}
|
||||
{locations.length > 0 && (
|
||||
<div className="md:col-span-4 w-full">
|
||||
<WorldMap
|
||||
locations={locations}
|
||||
selectedLocId={selectedLoc?.id}
|
||||
onSelectLocId={(id) => {
|
||||
const idx = locations.findIndex((l) => l.id === id);
|
||||
if (idx !== -1) setSelectedLocIndex(idx);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Left sidebar: Locations list */}
|
||||
<div className="md:col-span-1 border border-border/20 bg-card shadow-[2px_2px_0_0_var(--border)] flex flex-col max-h-[500px]">
|
||||
<div className="p-3 border-b border-border/25 flex justify-between items-center bg-secondary/15">
|
||||
@@ -101,7 +119,9 @@ export function LocationsTab({
|
||||
: "hover:bg-secondary/40 text-foreground"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{loc.id || `(Empty ID)`}</span>
|
||||
<span className="truncate">
|
||||
{getLocationDisplayName(loc) || `(Empty ID)`}
|
||||
</span>
|
||||
{locations.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -121,260 +141,258 @@ export function LocationsTab({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel: Edit selected location details */}
|
||||
{selectedLoc ? (
|
||||
<div className="md:col-span-3 border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic location fields */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-body-lg text-primary font-bold border-b border-border/20 pb-2">
|
||||
Location Configuration
|
||||
</h2>
|
||||
{/* Right panel wrapper */}
|
||||
<div className="md:col-span-3 space-y-6">
|
||||
{selectedLoc ? (
|
||||
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic location fields */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-body-lg text-primary font-bold border-b border-border/20 pb-2">
|
||||
Location Configuration
|
||||
</h2>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Location ID</Label>
|
||||
<Input
|
||||
placeholder="e.g. tavern-cellar"
|
||||
value={selectedLoc.id}
|
||||
onChange={(e) => {
|
||||
const copy = [...locations];
|
||||
copy[selectedLocIndex].id = e.target.value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-_]/g, "");
|
||||
setLocations(copy);
|
||||
}}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Location ID</Label>
|
||||
<Input
|
||||
value={selectedLoc.id}
|
||||
readOnly
|
||||
className="font-mono text-xs bg-muted cursor-not-allowed text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Parent Location (Optional)</Label>
|
||||
<select
|
||||
className="bg-card border border-border/30 px-3 py-1.5 text-xs outline-none w-full rounded"
|
||||
value={selectedLoc.parentId || ""}
|
||||
onChange={(e) => {
|
||||
const copy = [...locations];
|
||||
copy[selectedLocIndex].parentId =
|
||||
e.target.value || undefined;
|
||||
setLocations(copy);
|
||||
}}
|
||||
>
|
||||
<option value="">-- No parent --</option>
|
||||
{locationIds
|
||||
.filter((id) => id !== selectedLoc.id)
|
||||
.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{getLocationDisplayNameById(id, locations)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Attributes for Location */}
|
||||
<div className="pt-2">
|
||||
<AttributeEditor
|
||||
title="Location Attributes"
|
||||
attributes={selectedLoc.attributes || []}
|
||||
onChange={(newAttrs) => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Parent Location (Optional)</Label>
|
||||
<select
|
||||
className="bg-card border border-border/30 px-3 py-1.5 text-xs outline-none w-full rounded"
|
||||
value={selectedLoc.parentId || ""}
|
||||
onChange={(e) => {
|
||||
const copy = [...locations];
|
||||
copy[selectedLocIndex].parentId = e.target.value || undefined;
|
||||
setLocations(copy);
|
||||
}}
|
||||
>
|
||||
<option value="">-- No parent --</option>
|
||||
{locationIds
|
||||
.filter((id) => id !== selectedLoc.id)
|
||||
.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* Connections (spatial paths) */}
|
||||
<div className="space-y-4 border-t lg:border-t-0 lg:border-l border-border/20 lg:pl-6 pt-4 lg:pt-0">
|
||||
<div className="flex justify-between items-center border-b border-border/20 pb-2">
|
||||
<h3 className="text-body-md text-foreground font-bold">
|
||||
Connections / Portals
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => addLocationConnection(selectedLocIndex)}
|
||||
className="h-7 text-xs flex gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="size-3" /> Add Connection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Attributes for Location */}
|
||||
<div className="pt-2">
|
||||
<AttributeEditor
|
||||
title="Location Attributes"
|
||||
attributes={selectedLoc.attributes || []}
|
||||
onChange={(newAttrs) => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connections (spatial paths) */}
|
||||
<div className="space-y-4 border-t lg:border-t-0 lg:border-l border-border/20 lg:pl-6 pt-4 lg:pt-0">
|
||||
<div className="flex justify-between items-center border-b border-border/20 pb-2">
|
||||
<h3 className="text-body-md text-foreground font-bold">
|
||||
Connections / Portals
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => addLocationConnection(selectedLocIndex)}
|
||||
className="h-7 text-xs flex gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="size-3" /> Add Connection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!selectedLoc.connections ||
|
||||
selectedLoc.connections.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No connections leading from this location.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4 overflow-y-auto max-h-[400px] pr-1">
|
||||
{selectedLoc.connections.map((conn, connIdx) => (
|
||||
<div
|
||||
key={connIdx}
|
||||
className="border border-border/20 bg-secondary/10 p-3 rounded space-y-3 relative group"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
removeLocationConnection(selectedLocIndex, connIdx)
|
||||
}
|
||||
className="absolute top-2 right-2 text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
title="Delete connection"
|
||||
{!selectedLoc.connections ||
|
||||
selectedLoc.connections.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No connections leading from this location.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4 overflow-y-auto max-h-[400px] pr-1">
|
||||
{selectedLoc.connections.map((conn, connIdx) => (
|
||||
<div
|
||||
key={connIdx}
|
||||
className="border border-border/20 bg-secondary/10 p-3 rounded space-y-3 relative group"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
removeLocationConnection(selectedLocIndex, connIdx)
|
||||
}
|
||||
className="absolute top-2 right-2 text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
title="Delete connection"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 pr-6">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Target Location
|
||||
</span>
|
||||
<select
|
||||
className="bg-card border border-border/30 px-2 py-1 text-xs outline-none w-full rounded"
|
||||
value={conn.targetId}
|
||||
onChange={(e) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"targetId",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">-- Choose target --</option>
|
||||
{locationIds
|
||||
.filter((id) => id !== selectedLoc.id)
|
||||
.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 pr-6">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Target Location
|
||||
</span>
|
||||
<select
|
||||
className="bg-card border border-border/30 px-2 py-1 text-xs outline-none w-full rounded"
|
||||
value={conn.targetId}
|
||||
onChange={(e) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"targetId",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">-- Choose target --</option>
|
||||
{locationIds
|
||||
.filter((id) => id !== selectedLoc.id)
|
||||
.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{getLocationDisplayNameById(id, locations)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Portal Name
|
||||
</span>
|
||||
<Input
|
||||
placeholder="e.g. wooden door"
|
||||
value={conn.portalName || ""}
|
||||
onChange={(e) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"portalName",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Portal Name
|
||||
Portal State
|
||||
</span>
|
||||
<Input
|
||||
placeholder="e.g. wooden door"
|
||||
value={conn.portalName || ""}
|
||||
placeholder="e.g. locked, closed, heavy iron gate"
|
||||
value={conn.portalStateDescriptor || ""}
|
||||
onChange={(e) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"portalName",
|
||||
"portalStateDescriptor",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Portal State
|
||||
</span>
|
||||
<Input
|
||||
placeholder="e.g. locked, closed, heavy iron gate"
|
||||
value={conn.portalStateDescriptor || ""}
|
||||
onChange={(e) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"portalStateDescriptor",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground block">
|
||||
Vision Propagation ({conn.visionProp})
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
value={conn.visionProp}
|
||||
onChange={(e) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"visionProp",
|
||||
Number(e.target.value),
|
||||
)
|
||||
}
|
||||
className="w-full h-1 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground block">
|
||||
Vision Propagation ({conn.visionProp})
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
value={conn.visionProp}
|
||||
onChange={(e) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"visionProp",
|
||||
Number(e.target.value),
|
||||
)
|
||||
}
|
||||
className="w-full h-1 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground block">
|
||||
Sound Propagation ({conn.soundProp})
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
value={conn.soundProp}
|
||||
onChange={(e) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"soundProp",
|
||||
Number(e.target.value),
|
||||
)
|
||||
}
|
||||
className="w-full h-1 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground block">
|
||||
Sound Propagation ({conn.soundProp})
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
value={conn.soundProp}
|
||||
onChange={(e) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"soundProp",
|
||||
Number(e.target.value),
|
||||
)
|
||||
}
|
||||
className="w-full h-1 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-[11px] text-muted-foreground flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={conn.bidirectional}
|
||||
onCheckedChange={(checked) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"bidirectional",
|
||||
!!checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
Bidirectional Connection (creates reverse path
|
||||
automatically)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-[11px] text-muted-foreground flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={conn.bidirectional}
|
||||
onCheckedChange={(checked) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"bidirectional",
|
||||
!!checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
Bidirectional Connection (creates reverse path
|
||||
automatically)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="md:col-span-3 border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] text-center text-xs text-muted-foreground">
|
||||
No locations defined.
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] text-center py-12 text-xs text-muted-foreground">
|
||||
No location selected. Choose a location from the sidebar to edit, or
|
||||
view the world layout below.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 pb-12">
|
||||
{/* Basic Fields */}
|
||||
<div className="lg:col-span-2 space-y-5 border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)]">
|
||||
<h2 className="text-body-lg text-primary font-bold border-b border-border/20 pb-2">
|
||||
@@ -113,6 +115,7 @@ export function MetadataTab({
|
||||
onChange={setWorldAttributes}
|
||||
onAdd={addWorldAttribute}
|
||||
entityIds={entityIds}
|
||||
entities={entities}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
515
apps/gui/src/components/builder/WorldMap.tsx
Normal file
515
apps/gui/src/components/builder/WorldMap.tsx
Normal file
@@ -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<SVGSVGElement>) => {
|
||||
if (e.button === 0) {
|
||||
setIsPanning(true);
|
||||
panStartRef.current = { x: e.clientX - pan.x, y: e.clientY - pan.y };
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||
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<string, BoxNode>();
|
||||
|
||||
// 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<string>();
|
||||
|
||||
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 (
|
||||
<g key={node.id}>
|
||||
{/* Box */}
|
||||
<rect
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
width={node.width}
|
||||
height={node.height}
|
||||
rx={8}
|
||||
onClick={(e) => {
|
||||
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 */}
|
||||
<text
|
||||
x={node.x + 12}
|
||||
y={node.y + 24}
|
||||
className={`text-xs font-mono select-none font-semibold ${
|
||||
isSelected ? "fill-primary font-bold" : "fill-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{node.name}
|
||||
</text>
|
||||
|
||||
{/* Portal Name on Boundary of Child */}
|
||||
{hasParentConn && portalName && (
|
||||
<g transform={`translate(${node.x + node.width / 2}, ${node.y})`}>
|
||||
<rect
|
||||
x={-((portalName.length * 5) / 2) - 4}
|
||||
y={-6}
|
||||
width={portalName.length * 5 + 8}
|
||||
height={12}
|
||||
rx={3}
|
||||
className="fill-zinc-900 stroke stroke-border/40"
|
||||
style={{ strokeWidth: 0.5 }}
|
||||
/>
|
||||
<text
|
||||
textAnchor="middle"
|
||||
y={3}
|
||||
className="text-[8px] font-mono fill-primary font-semibold select-none"
|
||||
>
|
||||
{portalName}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Render children inside */}
|
||||
{node.children.map((child) => renderNode(child))}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center pb-2">
|
||||
{/* Controls */}
|
||||
<div className="flex gap-1 bg-secondary/15 p-1 rounded border border-border/10">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setZoom((z) => Math.min(4, z + 0.1))}
|
||||
className="size-7 cursor-pointer hover:bg-secondary/40 text-muted-foreground hover:text-foreground"
|
||||
title="Zoom In"
|
||||
>
|
||||
<ZoomIn className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setZoom((z) => Math.max(0.3, z - 0.1))}
|
||||
className="size-7 cursor-pointer hover:bg-secondary/40 text-muted-foreground hover:text-foreground"
|
||||
title="Zoom Out"
|
||||
>
|
||||
<ZoomOut className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}}
|
||||
className="size-7 cursor-pointer hover:bg-secondary/40 text-muted-foreground hover:text-foreground"
|
||||
title="Reset view"
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas container */}
|
||||
<div className="relative border border-border/35 bg-zinc-950/95 rounded-lg overflow-hidden h-[450px]">
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
className={`w-full h-full select-none ${
|
||||
isPanning ? "cursor-grabbing" : "cursor-grab"
|
||||
}`}
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="grid-pattern"
|
||||
width="40"
|
||||
height="40"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path
|
||||
d="M 40 0 L 0 0 0 40"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="text-muted-foreground/10"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
{/* Grid Background */}
|
||||
<rect width="100%" height="100%" fill="url(#grid-pattern)" />
|
||||
|
||||
{/* Draggable/scalable group */}
|
||||
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
|
||||
{/* 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 (
|
||||
<g key={line.id} className="transition-all">
|
||||
{/* Glow path for highlighted connections */}
|
||||
{isHighlighted && (
|
||||
<line
|
||||
x1={line.x1}
|
||||
y1={line.y1}
|
||||
x2={line.x2}
|
||||
y2={line.y2}
|
||||
className="stroke-primary/30 stroke-[4px] blur-sm animate-pulse"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Connection Line */}
|
||||
<line
|
||||
x1={line.x1}
|
||||
y1={line.y1}
|
||||
x2={line.x2}
|
||||
y2={line.y2}
|
||||
className={`transition-all ${
|
||||
isHighlighted
|
||||
? "stroke-primary stroke-2"
|
||||
: "stroke-border/40 stroke-1"
|
||||
}`}
|
||||
style={{
|
||||
strokeDasharray: line.bidirectional ? undefined : "4, 4",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Midpoint Direction Arrow for Uni-directional connections */}
|
||||
{!line.bidirectional && (
|
||||
<g transform={`translate(${mx}, ${my}) rotate(${angle})`}>
|
||||
<path
|
||||
d="M -4 -3.5 L 4 0 L -4 3.5 Z"
|
||||
className={
|
||||
isHighlighted
|
||||
? "fill-primary"
|
||||
: "fill-muted-foreground/60"
|
||||
}
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Portal Name Label bubble */}
|
||||
{line.portalName && (
|
||||
<g
|
||||
transform={`translate(${mx}, ${my - 12})`}
|
||||
className="cursor-default"
|
||||
>
|
||||
<rect
|
||||
x={-((line.portalName.length * 6) / 2) - 4}
|
||||
y={-8}
|
||||
width={line.portalName.length * 6 + 8}
|
||||
height={16}
|
||||
rx={4}
|
||||
className="fill-background/90 stroke stroke-border/30"
|
||||
style={{ strokeWidth: 0.5 }}
|
||||
/>
|
||||
<text
|
||||
textAnchor="middle"
|
||||
y={3}
|
||||
className="text-[9px] font-mono fill-muted-foreground select-none"
|
||||
>
|
||||
{line.portalName}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Draw Box Nodes recursively */}
|
||||
{roots.map((root) => renderNode(root))}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Map Hint Overlay */}
|
||||
<div className="absolute bottom-2 right-2 px-2 py-1 rounded bg-black/60 border border-border/20 text-[9.5px] text-muted-foreground font-mono pointer-events-none">
|
||||
Drag to pan • Scroll or buttons to zoom • Click boxes to select
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
45
apps/gui/src/components/builder/utils.ts
Normal file
45
apps/gui/src/components/builder/utils.ts
Normal file
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user