Add the Leaflet map page and client (markers, labels, layer toggles, shipment interpolation), the placement and authoring server actions, the map-features and map-import API routes, and the map styles including leaflet pane isolation so portals stack correctly. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
778 lines
28 KiB
TypeScript
778 lines
28 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuCheckboxItem,
|
|
DropdownMenuContent,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
deleteMapFeature,
|
|
saveMapNode,
|
|
saveMapRoad,
|
|
saveMapZone,
|
|
} from "@/app/(frontend)/logistics/map/actions";
|
|
import type { MapPoint } from "@/lib/map/points";
|
|
import type { NodeFeature, RoadFeature, ZoneFeature } from "@/lib/map/types";
|
|
|
|
export type AuthoringKind = "road" | "zone" | "node" | null;
|
|
|
|
export interface EditingFeature {
|
|
kind: Exclude<AuthoringKind, null>;
|
|
id: number;
|
|
label: string;
|
|
}
|
|
|
|
export interface MapAuthoring {
|
|
kind: AuthoringKind;
|
|
draftPoints: MapPoint[];
|
|
canSave: boolean;
|
|
saving: boolean;
|
|
editing: EditingFeature | null;
|
|
fields: {
|
|
roadName: string;
|
|
setRoadName: (value: string) => void;
|
|
roadDescription: string;
|
|
setRoadDescription: (value: string) => void;
|
|
surface: "paved" | "dirt" | "trail";
|
|
setSurface: (value: "paved" | "dirt" | "trail") => void;
|
|
speedMultiplier: string;
|
|
setSpeedMultiplier: (value: string) => void;
|
|
strokeColor: string;
|
|
setStrokeColor: (value: string) => void;
|
|
strokeWeight: string;
|
|
setStrokeWeight: (value: string) => void;
|
|
strokeStyle: "solid" | "dashed" | "dotted";
|
|
setStrokeStyle: (value: "solid" | "dashed" | "dotted") => void;
|
|
labelVisible: boolean;
|
|
setLabelVisible: (value: boolean) => void;
|
|
zoneName: string;
|
|
setZoneName: (value: string) => void;
|
|
zoneDescription: string;
|
|
setZoneDescription: (value: string) => void;
|
|
zoneType: "water" | "land";
|
|
setZoneType: (value: "water" | "land") => void;
|
|
fillColor: string;
|
|
setFillColor: (value: string) => void;
|
|
zoneStrokeColor: string;
|
|
setZoneStrokeColor: (value: string) => void;
|
|
fillOpacity: string;
|
|
setFillOpacity: (value: string) => void;
|
|
zoneStrokeWeight: string;
|
|
setZoneStrokeWeight: (value: string) => void;
|
|
nodeName: string;
|
|
setNodeName: (value: string) => void;
|
|
nodeDescription: string;
|
|
setNodeDescription: (value: string) => void;
|
|
resourceIds: number[];
|
|
toggleResourceId: (id: number) => void;
|
|
zoneResourceIds: number[];
|
|
toggleZoneResourceId: (id: number) => void;
|
|
richness: string;
|
|
setRichness: (value: string) => void;
|
|
};
|
|
setKind: (kind: AuthoringKind) => void;
|
|
pushPoint: (point: MapPoint) => void;
|
|
clearDraft: () => void;
|
|
save: () => Promise<void>;
|
|
removeFeature: (target: "road" | "zone" | "node", id: number) => Promise<void>;
|
|
startEditingRoad: (road: RoadFeature) => void;
|
|
startEditingZone: (zone: ZoneFeature) => void;
|
|
startEditingNode: (node: NodeFeature) => void;
|
|
}
|
|
|
|
export function useMapAuthoring(options: { mapId: number; onSaved: () => void }): MapAuthoring {
|
|
const { mapId, onSaved } = options;
|
|
const [kind, setKindState] = useState<AuthoringKind>(null);
|
|
const [draftPoints, setDraftPoints] = useState<MapPoint[]>([]);
|
|
const [roadName, setRoadName] = useState("");
|
|
const [roadDescription, setRoadDescription] = useState("");
|
|
const [surface, setSurface] = useState<"paved" | "dirt" | "trail">("paved");
|
|
const [speedMultiplier, setSpeedMultiplier] = useState("1");
|
|
const [strokeColor, setStrokeColor] = useState("");
|
|
const [strokeWeight, setStrokeWeight] = useState("");
|
|
const [strokeStyle, setStrokeStyle] = useState<"solid" | "dashed" | "dotted">("solid");
|
|
const [labelVisible, setLabelVisible] = useState(true);
|
|
const [zoneName, setZoneName] = useState("");
|
|
const [zoneDescription, setZoneDescription] = useState("");
|
|
const [zoneType, setZoneType] = useState<"water" | "land">("water");
|
|
const [fillColor, setFillColor] = useState("");
|
|
const [zoneStrokeColor, setZoneStrokeColor] = useState("");
|
|
const [fillOpacity, setFillOpacity] = useState("");
|
|
const [zoneStrokeWeight, setZoneStrokeWeight] = useState("");
|
|
const [nodeName, setNodeName] = useState("");
|
|
const [nodeDescription, setNodeDescription] = useState("");
|
|
const [resourceIds, setResourceIds] = useState<number[]>([]);
|
|
const [zoneResourceIds, setZoneResourceIds] = useState<number[]>([]);
|
|
const [richness, setRichness] = useState("100");
|
|
|
|
const toggleResourceId = useCallback((id: number) => {
|
|
setResourceIds((current) =>
|
|
current.includes(id) ? current.filter((entry) => entry !== id) : [...current, id],
|
|
);
|
|
}, []);
|
|
|
|
const toggleZoneResourceId = useCallback((id: number) => {
|
|
setZoneResourceIds((current) =>
|
|
current.includes(id) ? current.filter((entry) => entry !== id) : [...current, id],
|
|
);
|
|
}, []);
|
|
const [saving, setSaving] = useState(false);
|
|
const [editing, setEditing] = useState<EditingFeature | null>(null);
|
|
|
|
const pushPoint = useCallback((point: MapPoint) => {
|
|
setDraftPoints((current) => [...current, point]);
|
|
}, []);
|
|
|
|
const clearDraft = useCallback(() => setDraftPoints([]), []);
|
|
|
|
const setKind = useCallback((next: AuthoringKind) => {
|
|
setKindState(next);
|
|
setDraftPoints([]);
|
|
setEditing(null);
|
|
}, []);
|
|
|
|
const startEditingRoad = useCallback((road: RoadFeature) => {
|
|
setKindState("road");
|
|
setDraftPoints(road.points.map((point) => [point[0], point[1]]));
|
|
setRoadName(road.name ?? "");
|
|
setRoadDescription(road.description ?? "");
|
|
setSurface(road.surface);
|
|
setSpeedMultiplier(String(road.speedMultiplier));
|
|
setStrokeColor(road.strokeColor ?? "");
|
|
setStrokeWeight(road.strokeWeight != null ? String(road.strokeWeight) : "");
|
|
setStrokeStyle(road.strokeStyle ?? "solid");
|
|
setLabelVisible(road.labelVisible !== false);
|
|
setEditing({ kind: "road", id: road.id, label: road.name ?? `Road #${road.id}` });
|
|
}, []);
|
|
|
|
const startEditingZone = useCallback((zone: ZoneFeature) => {
|
|
setKindState("zone");
|
|
setDraftPoints(zone.points.map((point) => [point[0], point[1]]));
|
|
setZoneName(zone.name ?? "");
|
|
setZoneDescription(zone.description ?? "");
|
|
setZoneType(zone.type);
|
|
setZoneResourceIds(zone.resources.map((entry) => entry.id));
|
|
setFillColor(zone.fillColor ?? "");
|
|
setZoneStrokeColor(zone.strokeColor ?? "");
|
|
setFillOpacity(zone.fillOpacity != null ? String(zone.fillOpacity) : "");
|
|
setZoneStrokeWeight(zone.strokeWeight != null ? String(zone.strokeWeight) : "");
|
|
setLabelVisible(zone.labelVisible !== false);
|
|
setEditing({ kind: "zone", id: zone.id, label: zone.name ?? `Zone #${zone.id}` });
|
|
}, []);
|
|
|
|
const startEditingNode = useCallback((node: NodeFeature) => {
|
|
setKindState("node");
|
|
setDraftPoints([[node.point[0], node.point[1]]]);
|
|
setNodeName(node.name);
|
|
setNodeDescription(node.description ?? "");
|
|
setResourceIds(node.resources.map((entry) => entry.id));
|
|
setRichness(String(node.richness ?? 100));
|
|
setEditing({ kind: "node", id: node.id, label: node.name });
|
|
}, []);
|
|
|
|
const removeFeature = useCallback(
|
|
async (target: "road" | "zone" | "node", id: number) => {
|
|
const result = await deleteMapFeature(target, id);
|
|
if (result.success) {
|
|
if (editing && editing.id === id && editing.kind === target) setEditing(null);
|
|
toast.success("Feature deleted.");
|
|
onSaved();
|
|
} else {
|
|
toast.error(result.error ?? "Delete failed.");
|
|
}
|
|
},
|
|
[editing, onSaved],
|
|
);
|
|
|
|
const save = useCallback(async () => {
|
|
if (kind == null) return;
|
|
setSaving(true);
|
|
try {
|
|
if (kind === "road") {
|
|
const result = await saveMapRoad({
|
|
id: editing?.kind === "road" ? editing.id : undefined,
|
|
mapId,
|
|
name: roadName.trim() || null,
|
|
description: roadDescription.trim() || null,
|
|
surface,
|
|
speedMultiplier: Number(speedMultiplier) || 1,
|
|
strokeColor: strokeColor.trim() || null,
|
|
strokeWeight: strokeWeight.trim() ? Number(strokeWeight) : null,
|
|
strokeStyle,
|
|
labelVisible,
|
|
points: draftPoints,
|
|
});
|
|
if (!result.success) throw new Error(result.error ?? "Save failed.");
|
|
toast.success(editing?.kind === "road" ? "Road updated." : "Road saved.");
|
|
} else if (kind === "zone") {
|
|
const result = await saveMapZone({
|
|
id: editing?.kind === "zone" ? editing.id : undefined,
|
|
mapId,
|
|
name: zoneName.trim() || null,
|
|
description: zoneDescription.trim() || null,
|
|
type: zoneType,
|
|
resourceIds: zoneResourceIds,
|
|
fillColor: fillColor.trim() || null,
|
|
strokeColor: zoneStrokeColor.trim() || null,
|
|
fillOpacity: fillOpacity.trim() ? Number(fillOpacity) : null,
|
|
strokeWeight: zoneStrokeWeight.trim() ? Number(zoneStrokeWeight) : null,
|
|
labelVisible,
|
|
points: draftPoints,
|
|
});
|
|
if (!result.success) throw new Error(result.error ?? "Save failed.");
|
|
toast.success(editing?.kind === "zone" ? "Zone updated." : "Zone saved.");
|
|
} else {
|
|
const result = await saveMapNode({
|
|
id: editing?.kind === "node" ? editing.id : undefined,
|
|
mapId,
|
|
name: nodeName.trim(),
|
|
description: nodeDescription.trim() || null,
|
|
resourceIds,
|
|
position: draftPoints[0],
|
|
richness: Number(richness) || 100,
|
|
});
|
|
if (!result.success) throw new Error(result.error ?? "Save failed.");
|
|
toast.success(editing?.kind === "node" ? "Resource node updated." : "Resource node saved.");
|
|
}
|
|
setKindState(null);
|
|
setDraftPoints([]);
|
|
setEditing(null);
|
|
setRoadName("");
|
|
setRoadDescription("");
|
|
setZoneName("");
|
|
setZoneDescription("");
|
|
setNodeName("");
|
|
setNodeDescription("");
|
|
setResourceIds([]);
|
|
setZoneResourceIds([]);
|
|
setStrokeColor("");
|
|
setStrokeWeight("");
|
|
setStrokeStyle("solid");
|
|
setFillColor("");
|
|
setZoneStrokeColor("");
|
|
setFillOpacity("");
|
|
setZoneStrokeWeight("");
|
|
setLabelVisible(true);
|
|
onSaved();
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "Save failed.");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [
|
|
kind,
|
|
editing,
|
|
mapId,
|
|
roadName,
|
|
roadDescription,
|
|
surface,
|
|
speedMultiplier,
|
|
strokeColor,
|
|
strokeWeight,
|
|
strokeStyle,
|
|
labelVisible,
|
|
draftPoints,
|
|
zoneName,
|
|
zoneDescription,
|
|
zoneType,
|
|
fillColor,
|
|
zoneStrokeColor,
|
|
fillOpacity,
|
|
zoneStrokeWeight,
|
|
nodeName,
|
|
nodeDescription,
|
|
resourceIds,
|
|
zoneResourceIds,
|
|
richness,
|
|
onSaved,
|
|
]);
|
|
|
|
const roadReady = kind !== "road" || draftPoints.length >= 2;
|
|
const zoneReady = kind !== "zone" || draftPoints.length >= 3;
|
|
const nodeReady =
|
|
kind !== "node" ||
|
|
(draftPoints.length === 1 && resourceIds.length > 0 && nodeName.trim().length > 0);
|
|
const canSave = kind != null && roadReady && zoneReady && nodeReady && !saving;
|
|
|
|
return {
|
|
kind,
|
|
draftPoints,
|
|
canSave,
|
|
saving,
|
|
editing,
|
|
fields: {
|
|
roadName,
|
|
setRoadName,
|
|
roadDescription,
|
|
setRoadDescription,
|
|
surface,
|
|
setSurface,
|
|
speedMultiplier,
|
|
setSpeedMultiplier,
|
|
strokeColor,
|
|
setStrokeColor,
|
|
strokeWeight,
|
|
setStrokeWeight,
|
|
strokeStyle,
|
|
setStrokeStyle,
|
|
labelVisible,
|
|
setLabelVisible,
|
|
zoneName,
|
|
setZoneName,
|
|
zoneDescription,
|
|
setZoneDescription,
|
|
zoneType,
|
|
setZoneType,
|
|
fillColor,
|
|
setFillColor,
|
|
zoneStrokeColor,
|
|
setZoneStrokeColor,
|
|
fillOpacity,
|
|
setFillOpacity,
|
|
zoneStrokeWeight,
|
|
setZoneStrokeWeight,
|
|
nodeName,
|
|
setNodeName,
|
|
nodeDescription,
|
|
setNodeDescription,
|
|
resourceIds,
|
|
toggleResourceId,
|
|
zoneResourceIds,
|
|
toggleZoneResourceId,
|
|
richness,
|
|
setRichness,
|
|
},
|
|
setKind,
|
|
pushPoint,
|
|
clearDraft,
|
|
save,
|
|
removeFeature,
|
|
startEditingRoad,
|
|
startEditingZone,
|
|
startEditingNode,
|
|
};
|
|
}
|
|
|
|
function useResourceOptions(): { id: number; name: string }[] {
|
|
const [options, setOptions] = useState<{ id: number; name: string }[]>([]);
|
|
useEffect(() => {
|
|
void (async () => {
|
|
const response = await fetch("/api/resources?limit=500&depth=0&sort=name", {
|
|
cache: "no-store",
|
|
});
|
|
if (response.ok) {
|
|
const body = (await response.json()) as { docs: { id: number; name: string }[] };
|
|
setOptions(body.docs);
|
|
}
|
|
})();
|
|
}, []);
|
|
return options;
|
|
}
|
|
|
|
function ResourcePicker({
|
|
label,
|
|
selected,
|
|
onToggle,
|
|
resources,
|
|
}: {
|
|
label: string;
|
|
selected: number[];
|
|
onToggle: (id: number) => void;
|
|
resources: { id: number; name: string }[];
|
|
}) {
|
|
const [query, setQuery] = useState("");
|
|
const [open, setOpen] = useState(false);
|
|
const needle = query.trim().toLowerCase();
|
|
const filtered = needle
|
|
? resources.filter((resource) => resource.name.toLowerCase().includes(needle))
|
|
: resources;
|
|
const summary =
|
|
selected.length === 0
|
|
? label
|
|
: selected.length <= 2
|
|
? resources
|
|
.filter((resource) => selected.includes(resource.id))
|
|
.map((resource) => resource.name)
|
|
.join(", ") || label
|
|
: `${selected.length} selected`;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-[10px] uppercase text-white/60">{label}</Label>
|
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
className="h-8 w-52 justify-between rounded-none px-2 text-xs font-normal normal-case"
|
|
>
|
|
<span className="truncate">{summary}</span>
|
|
<span className="text-white/40">▾</span>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
className="z-[1100] w-60 rounded-none border-white/20 bg-black/95"
|
|
>
|
|
<div className="p-1.5">
|
|
<Input
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="Search resources..."
|
|
className="h-7 rounded-none text-xs"
|
|
/>
|
|
</div>
|
|
<div className="max-h-64 overflow-y-auto">
|
|
{filtered.map((resource) => (
|
|
<DropdownMenuCheckboxItem
|
|
key={resource.id}
|
|
checked={selected.includes(resource.id)}
|
|
onCheckedChange={() => onToggle(resource.id)}
|
|
onSelect={(event) => event.preventDefault()}
|
|
className="text-xs"
|
|
>
|
|
{resource.name}
|
|
</DropdownMenuCheckboxItem>
|
|
))}
|
|
{filtered.length === 0 && (
|
|
<div className="px-2 py-3 text-center text-xs text-white/40">No matches</div>
|
|
)}
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring; mapId: number }) {
|
|
const resources = useResourceOptions();
|
|
const { kind, fields } = authoring;
|
|
|
|
return (
|
|
<div className="pointer-events-auto w-[640px] max-w-[calc(100vw-10rem)] overflow-hidden rounded-none border border-white/15 bg-black/80 backdrop-blur-sm">
|
|
<div className="flex items-center justify-between gap-6 border-b border-white/15 bg-black/50 px-3 py-1">
|
|
<span className="font-mono text-[9px] uppercase tracking-[0.2em] text-white/60">
|
|
GM Authoring // Feature Editor
|
|
</span>
|
|
<span className="font-mono text-[9px] uppercase tracking-[0.2em] text-white/60">
|
|
REF {String(mapId).padStart(3, "0")}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col gap-2 p-2">
|
|
<div className="flex items-center gap-1.5">
|
|
{(["road", "zone", "node"] as const).map((target) => (
|
|
<Button
|
|
key={target}
|
|
size="sm"
|
|
variant={kind === target && !authoring.editing ? "secondary" : "outline"}
|
|
className="h-8 px-2.5 text-xs capitalize"
|
|
onClick={() =>
|
|
authoring.setKind(kind === target && !authoring.editing ? null : target)
|
|
}
|
|
>
|
|
{target === "node" ? "New node" : `New ${target}`}
|
|
</Button>
|
|
))}
|
|
<span className="ml-auto text-xs text-white/60">
|
|
{authoring.draftPoints.length} point{authoring.draftPoints.length === 1 ? "" : "s"}
|
|
{kind === "road" && authoring.draftPoints.length < 2 && " (need 2+)"}
|
|
{kind === "zone" && authoring.draftPoints.length < 3 && " (need 3+)"}
|
|
{kind === "node" && authoring.draftPoints.length === 0 && " (click to set)"}
|
|
</span>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-7 px-2 text-xs"
|
|
onClick={authoring.clearDraft}
|
|
disabled={authoring.draftPoints.length === 0}
|
|
>
|
|
Clear
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
className="h-7 px-2 text-xs"
|
|
onClick={() => void authoring.save()}
|
|
disabled={!authoring.canSave}
|
|
>
|
|
{authoring.saving ? "Saving..." : authoring.editing ? "Save changes" : "Save"}
|
|
</Button>
|
|
</div>
|
|
|
|
{authoring.editing && (
|
|
<div className="flex items-center gap-2 rounded-md border border-sky-500/40 bg-sky-950/50 px-2 py-1.5 text-xs text-sky-200">
|
|
<span>
|
|
Editing {authoring.editing.kind}:{" "}
|
|
<span className="font-semibold">{authoring.editing.label}</span>
|
|
{kind === "road" && " (click the map to add points to the route)"}
|
|
{kind === "zone" && " (click the map to add vertices to the polygon)"}
|
|
{kind === "node" && " (click the map to move the node)"}
|
|
</span>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="ml-auto h-6 px-2 text-xs"
|
|
onClick={() => authoring.setKind(null)}
|
|
>
|
|
Cancel edit
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{kind === "road" && (
|
|
<>
|
|
<div className="flex flex-wrap items-end gap-2">
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-[10px] uppercase text-white/60">Name</Label>
|
|
<Input
|
|
value={fields.roadName}
|
|
onChange={(event) => fields.setRoadName(event.target.value)}
|
|
placeholder="Road name"
|
|
className="h-8 w-40 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-[10px] uppercase text-white/60">Surface</Label>
|
|
<Select
|
|
value={fields.surface}
|
|
onValueChange={(value) => fields.setSurface(value as "paved" | "dirt" | "trail")}
|
|
>
|
|
<SelectTrigger className="h-8 w-24 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="paved">Paved</SelectItem>
|
|
<SelectItem value="dirt">Dirt</SelectItem>
|
|
<SelectItem value="trail">Trail</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-[10px] uppercase text-white/60">Speed x</Label>
|
|
<Input
|
|
type="number"
|
|
step="0.1"
|
|
min="0.1"
|
|
value={fields.speedMultiplier}
|
|
onChange={(event) => fields.setSpeedMultiplier(event.target.value)}
|
|
className="h-8 w-16 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-[10px] uppercase text-white/60">Color</Label>
|
|
<Input
|
|
value={fields.strokeColor}
|
|
onChange={(event) => fields.setStrokeColor(event.target.value)}
|
|
placeholder="#cbd5e1"
|
|
className="h-8 w-24 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-[10px] uppercase text-white/60">Width</Label>
|
|
<Input
|
|
type="number"
|
|
min="1"
|
|
max="10"
|
|
value={fields.strokeWeight}
|
|
onChange={(event) => fields.setStrokeWeight(event.target.value)}
|
|
placeholder="2"
|
|
className="h-8 w-14 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-[10px] uppercase text-white/60">Style</Label>
|
|
<Select
|
|
value={fields.strokeStyle}
|
|
onValueChange={(value) =>
|
|
fields.setStrokeStyle(value as "solid" | "dashed" | "dotted")
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8 w-24 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="solid">Solid</SelectItem>
|
|
<SelectItem value="dashed">Dashed</SelectItem>
|
|
<SelectItem value="dotted">Dotted</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-end gap-3">
|
|
<div className="flex flex-1 flex-col gap-1">
|
|
<Label className="text-[10px] uppercase text-white/60">Description</Label>
|
|
<textarea
|
|
value={fields.roadDescription}
|
|
onChange={(event) => fields.setRoadDescription(event.target.value)}
|
|
placeholder="Shown in the tooltip + popup"
|
|
rows={2}
|
|
className="w-full rounded-none border border-white/20 bg-black/40 px-2 py-1 text-xs text-white placeholder:text-white/30"
|
|
/>
|
|
</div>
|
|
<label className="flex items-center gap-1.5 pb-1 text-[10px] uppercase tracking-wide text-white/60">
|
|
<input
|
|
type="checkbox"
|
|
checked={fields.labelVisible}
|
|
onChange={(event) => fields.setLabelVisible(event.target.checked)}
|
|
/>
|
|
Label
|
|
</label>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{kind === "zone" && (
|
|
<>
|
|
<div className="flex flex-wrap items-end gap-2">
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-[10px] uppercase text-white/60">Name</Label>
|
|
<Input
|
|
value={fields.zoneName}
|
|
onChange={(event) => fields.setZoneName(event.target.value)}
|
|
placeholder="Zone name"
|
|
className="h-8 w-40 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-[10px] uppercase text-white/60">Type</Label>
|
|
<Select
|
|
value={fields.zoneType}
|
|
onValueChange={(value) => fields.setZoneType(value as "water" | "land")}
|
|
>
|
|
<SelectTrigger className="h-8 w-24 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="water">Water</SelectItem>
|
|
<SelectItem value="land">Land</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-[10px] uppercase text-white/60">Fill</Label>
|
|
<Input
|
|
value={fields.fillColor}
|
|
onChange={(event) => fields.setFillColor(event.target.value)}
|
|
placeholder="#38bdf8"
|
|
className="h-8 w-24 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-[10px] uppercase text-white/60">Edge</Label>
|
|
<Input
|
|
value={fields.zoneStrokeColor}
|
|
onChange={(event) => fields.setZoneStrokeColor(event.target.value)}
|
|
placeholder="#38bdf8"
|
|
className="h-8 w-24 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-[10px] uppercase text-white/60">Opacity</Label>
|
|
<Input
|
|
type="number"
|
|
step="0.05"
|
|
min="0"
|
|
max="1"
|
|
value={fields.fillOpacity}
|
|
onChange={(event) => fields.setFillOpacity(event.target.value)}
|
|
placeholder="0.25"
|
|
className="h-8 w-16 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-[10px] uppercase text-white/60">Width</Label>
|
|
<Input
|
|
type="number"
|
|
min="0"
|
|
max="10"
|
|
value={fields.zoneStrokeWeight}
|
|
onChange={(event) => fields.setZoneStrokeWeight(event.target.value)}
|
|
placeholder="1"
|
|
className="h-8 w-14 text-xs"
|
|
/>
|
|
</div>
|
|
<label className="flex items-center gap-1.5 pb-1 text-[10px] uppercase tracking-wide text-white/60">
|
|
<input
|
|
type="checkbox"
|
|
checked={fields.labelVisible}
|
|
onChange={(event) => fields.setLabelVisible(event.target.checked)}
|
|
/>
|
|
Label
|
|
</label>
|
|
</div>
|
|
|
|
<ResourcePicker
|
|
label="Resources in zone"
|
|
selected={fields.zoneResourceIds}
|
|
onToggle={fields.toggleZoneResourceId}
|
|
resources={resources}
|
|
/>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-[10px] uppercase text-white/60">Description</Label>
|
|
<textarea
|
|
value={fields.zoneDescription}
|
|
onChange={(event) => fields.setZoneDescription(event.target.value)}
|
|
placeholder="Shown in the tooltip + popup"
|
|
rows={2}
|
|
className="w-full rounded-none border border-white/20 bg-black/40 px-2 py-1 text-xs text-white placeholder:text-white/30"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{kind === "node" && (
|
|
<div className="flex flex-wrap items-end gap-2">
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-[10px] uppercase text-white/60">Name</Label>
|
|
<Input
|
|
value={fields.nodeName}
|
|
onChange={(event) => fields.setNodeName(event.target.value)}
|
|
placeholder="Node name"
|
|
className="h-8 w-40 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-[10px] uppercase text-white/60">Description</Label>
|
|
<textarea
|
|
value={fields.nodeDescription}
|
|
onChange={(event) => fields.setNodeDescription(event.target.value)}
|
|
placeholder="Shown in the tooltip + popup"
|
|
rows={2}
|
|
className="w-full rounded-none border border-white/20 bg-black/40 px-2 py-1 text-xs text-white placeholder:text-white/30"
|
|
/>
|
|
</div>
|
|
<ResourcePicker
|
|
label="Resources"
|
|
selected={fields.resourceIds}
|
|
onToggle={fields.toggleResourceId}
|
|
resources={resources}
|
|
/>
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-[10px] uppercase text-white/60">Richness</Label>
|
|
<Input
|
|
type="number"
|
|
min="0"
|
|
value={fields.richness}
|
|
onChange={(event) => fields.setRichness(event.target.value)}
|
|
className="h-8 w-16 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|