feat: extend agent-driven map workbench
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown, Crosshair, LocateFixed, RotateCcw, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
LAYER_GROUP_GEOMETRIES,
|
||||
type LayerGroupId,
|
||||
type LayerGroupStylePatch
|
||||
} from "../map/layer-group-style";
|
||||
import { getNumericFeatureProperties } from "../map/value-label";
|
||||
import type {
|
||||
FeatureTarget,
|
||||
WorkbenchMapCommands,
|
||||
WorkbenchMapControllerState
|
||||
} from "../map/workbench-map-controller";
|
||||
import { WATER_NETWORK_SOURCE_IDS, type WaterNetworkSourceId } from "../map/sources";
|
||||
import type { DetailFeature } from "../types";
|
||||
|
||||
type MapDevPanelProps = {
|
||||
commands: WorkbenchMapCommands;
|
||||
controllerState: WorkbenchMapControllerState;
|
||||
detailFeature: DetailFeature | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const GROUP_LABELS: Record<WaterNetworkSourceId, string> = {
|
||||
conduits: "管渠",
|
||||
junctions: "检查井",
|
||||
orifices: "孔口",
|
||||
outfalls: "排放口",
|
||||
pumps: "泵",
|
||||
scada: "SCADA"
|
||||
};
|
||||
|
||||
const ERROR_LABELS: Record<string, string> = {
|
||||
MAP_NOT_READY: "地图尚未就绪",
|
||||
FEATURE_NOT_FOUND: "目标要素不存在",
|
||||
WFS_UNAVAILABLE: "WFS 查询暂不可用",
|
||||
FLOW_UNAVAILABLE: "水流图层初始化失败",
|
||||
INVALID_STYLE_PATCH: "参数不符合受控范围"
|
||||
};
|
||||
|
||||
export function MapDevPanel({ commands, controllerState, detailFeature, onClose }: MapDevPanelProps) {
|
||||
const [sourceId, setSourceId] = useState<WaterNetworkSourceId>("conduits");
|
||||
const [featureId, setFeatureId] = useState("DWS30265WS2010031117");
|
||||
const [property, setProperty] = useState("diameter");
|
||||
const [precision, setPrecision] = useState(0);
|
||||
const [unit, setUnit] = useState("mm");
|
||||
const [groupId, setGroupId] = useState<LayerGroupId>("conduits");
|
||||
const [draft, setDraft] = useState<Record<string, string | number>>({
|
||||
color: "#0F766E",
|
||||
fillColor: "#0369A1",
|
||||
strokeColor: "#FFFFFF",
|
||||
opacity: 0.9,
|
||||
widthMultiplier: 1,
|
||||
radiusMultiplier: 1,
|
||||
strokeMultiplier: 1,
|
||||
iconMultiplier: 1,
|
||||
dash: "original"
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const numericProperties = useMemo(
|
||||
() => detailFeature && detailFeature.layer === sourceId && detailFeature.id === featureId
|
||||
? getNumericFeatureProperties(detailFeature.properties)
|
||||
: [],
|
||||
[detailFeature, featureId, sourceId]
|
||||
);
|
||||
const target: FeatureTarget = { sourceId, featureId: featureId.trim() };
|
||||
const geometry = LAYER_GROUP_GEOMETRIES[groupId];
|
||||
|
||||
function useSelectedFeature() {
|
||||
if (!detailFeature) return;
|
||||
setSourceId(detailFeature.layer);
|
||||
setFeatureId(detailFeature.id);
|
||||
const firstNumeric = getNumericFeatureProperties(detailFeature.properties)[0];
|
||||
if (firstNumeric) setProperty(firstNumeric[0]);
|
||||
}
|
||||
|
||||
function applyDraft() {
|
||||
let patch: LayerGroupStylePatch;
|
||||
if (geometry === "line") {
|
||||
patch = {
|
||||
color: String(draft.color),
|
||||
opacity: Number(draft.opacity),
|
||||
widthMultiplier: Number(draft.widthMultiplier),
|
||||
dash: draft.dash as "original" | "solid" | "dashed"
|
||||
};
|
||||
} else {
|
||||
patch = {
|
||||
fillColor: String(draft.fillColor),
|
||||
strokeColor: String(draft.strokeColor),
|
||||
opacity: Number(draft.opacity),
|
||||
radiusMultiplier: Number(draft.radiusMultiplier),
|
||||
strokeMultiplier: Number(draft.strokeMultiplier),
|
||||
...(geometry === "scada" ? { iconMultiplier: Number(draft.iconMultiplier) } : {})
|
||||
};
|
||||
}
|
||||
commands.applyLayerGroupStyle(groupId, patch);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="地图开发面板"
|
||||
className="acrylic-panel pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] flex-col overflow-hidden rounded-2xl border border-white/70 shadow-2xl lg:flex"
|
||||
data-testid="map-dev-panel"
|
||||
>
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200/70 px-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-slate-950">地图能力 Dev Panel</h2>
|
||||
<p className="text-[11px] text-slate-500">仅本次页面会话生效</p>
|
||||
</div>
|
||||
<button className="grid h-8 w-8 place-items-center rounded-lg text-slate-500 transition hover:bg-white/70 hover:text-slate-900 active:scale-95" type="button" aria-label="关闭 Dev Panel" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
|
||||
{controllerState.errorCode ? (
|
||||
<div role="alert" className="rounded-xl border border-red-200 bg-red-50/90 px-3 py-2 text-xs font-medium text-red-700">
|
||||
{ERROR_LABELS[controllerState.errorCode] ?? controllerState.errorCode}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<PanelSection title="Feature" description="WFS 验证后执行独立高亮或相机定位。">
|
||||
<div className="grid grid-cols-[120px_1fr] gap-2">
|
||||
<Field label="业务源">
|
||||
<select value={sourceId} onChange={(event) => setSourceId(event.target.value as WaterNetworkSourceId)} className={inputClassName}>
|
||||
{WATER_NETWORK_SOURCE_IDS.map((id) => <option key={id} value={id}>{GROUP_LABELS[id]}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Feature ID">
|
||||
<input value={featureId} onChange={(event) => setFeatureId(event.target.value)} className={inputClassName} />
|
||||
</Field>
|
||||
</div>
|
||||
<button type="button" disabled={!detailFeature} onClick={useSelectedFeature} className={secondaryButtonClassName}>使用当前地图选中</button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button" disabled={!target.featureId || controllerState.pending} onClick={() => void commands.highlight(target)} className={primaryButtonClassName}><Crosshair size={14} />仅高亮</button>
|
||||
<button type="button" disabled={!target.featureId || controllerState.pending} onClick={() => void commands.locateAndHighlight(target)} className={primaryButtonClassName}><LocateFixed size={14} />定位并高亮</button>
|
||||
</div>
|
||||
<button type="button" onClick={commands.clearHighlight} className={secondaryButtonClassName}>清除高亮</button>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="全网水流" description="按管渠拓扑方向显示静态或动画流纹。">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button" disabled={controllerState.pending || controllerState.flowVisible} onClick={() => void commands.setFlowVisible(true)} className={primaryButtonClassName}>开启水流</button>
|
||||
<button type="button" disabled={!controllerState.flowVisible} onClick={() => void commands.setFlowVisible(false)} className={secondaryButtonClassName}>关闭水流</button>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="数值标注" description="仅为当前测试目标显示一个数值属性。">
|
||||
<Field label="数值属性">
|
||||
{numericProperties.length ? (
|
||||
<select value={property} onChange={(event) => setProperty(event.target.value)} className={inputClassName}>
|
||||
{numericProperties.map(([key]) => <option key={key} value={key}>{key}</option>)}
|
||||
</select>
|
||||
) : <input value={property} onChange={(event) => setProperty(event.target.value)} className={inputClassName} />}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="精度 0-3"><input type="number" min={0} max={3} value={precision} onChange={(event) => setPrecision(Number(event.target.value))} className={inputClassName} /></Field>
|
||||
<Field label="单位"><input maxLength={16} value={unit} onChange={(event) => setUnit(event.target.value)} className={inputClassName} /></Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button" disabled={!target.featureId || !property || controllerState.pending} onClick={() => void commands.showValueLabel({ target, property, precision, unit })} className={primaryButtonClassName}>显示标注</button>
|
||||
<button type="button" onClick={commands.clearValueLabel} className={secondaryButtonClassName}>清除标注</button>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="样式编辑" description="覆盖业务组样式,交互语义色保持不变。">
|
||||
<Field label="业务图层组">
|
||||
<div className="relative">
|
||||
<select value={groupId} onChange={(event) => setGroupId(event.target.value as LayerGroupId)} className={cn(inputClassName, "appearance-none pr-8")}>
|
||||
{WATER_NETWORK_SOURCE_IDS.map((id) => <option key={id} value={id}>{GROUP_LABELS[id]}</option>)}
|
||||
</select>
|
||||
<ChevronDown size={14} className="pointer-events-none absolute right-2.5 top-2.5 text-slate-400" />
|
||||
</div>
|
||||
</Field>
|
||||
{geometry === "line" ? (
|
||||
<>
|
||||
<ColorField label="颜色" value={String(draft.color)} onChange={(value) => setDraft((current) => ({ ...current, color: value }))} />
|
||||
<RangeField label="透明度" min={0} max={1} step={0.05} value={Number(draft.opacity)} onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))} />
|
||||
<RangeField label="宽度倍率" min={0.5} max={3} step={0.1} value={Number(draft.widthMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, widthMultiplier: value }))} />
|
||||
<Field label="线型"><select value={draft.dash} onChange={(event) => setDraft((current) => ({ ...current, dash: event.target.value }))} className={inputClassName}><option value="original">原始</option><option value="solid">实线</option><option value="dashed">虚线</option></select></Field>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2"><ColorField label="填充" value={String(draft.fillColor)} onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))} /><ColorField label="描边" value={String(draft.strokeColor)} onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))} /></div>
|
||||
<RangeField label="透明度" min={0} max={1} step={0.05} value={Number(draft.opacity)} onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))} />
|
||||
<RangeField label="半径倍率" min={0.5} max={3} step={0.1} value={Number(draft.radiusMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, radiusMultiplier: value }))} />
|
||||
<RangeField label="描边倍率" min={0.5} max={3} step={0.1} value={Number(draft.strokeMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, strokeMultiplier: value }))} />
|
||||
{geometry === "scada" ? <RangeField label="图标倍率" min={0.5} max={2} step={0.1} value={Number(draft.iconMultiplier)} onChange={(value) => setDraft((current) => ({ ...current, iconMultiplier: value }))} /> : null}
|
||||
</>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button" onClick={applyDraft} className={primaryButtonClassName}>应用</button>
|
||||
<button type="button" onClick={() => commands.resetLayerGroupStyle(groupId)} className={secondaryButtonClassName}>重置本组</button>
|
||||
</div>
|
||||
</PanelSection>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-slate-200/70 p-3">
|
||||
<button type="button" onClick={commands.resetAll} className={cn(secondaryButtonClassName, "w-full")}><RotateCcw size={14} />重置全部</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelSection({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
|
||||
return <section className="space-y-2.5 border-b border-slate-200/70 px-1 pb-4 last:border-b-0 last:pb-1"><div><h3 className="text-xs font-semibold text-slate-900">{title}</h3><p className="mt-0.5 text-[11px] leading-4 text-slate-500">{description}</p></div>{children}</section>;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return <label className="block min-w-0"><span className="mb-1 block text-[11px] font-medium text-slate-600">{label}</span>{children}</label>;
|
||||
}
|
||||
|
||||
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
||||
return <Field label={label}><div className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-white/80 px-2"><input type="color" value={value} onChange={(event) => onChange(event.target.value.toUpperCase())} className="h-5 w-7 cursor-pointer border-0 bg-transparent p-0" /><span className="text-xs font-medium text-slate-600">{value}</span></div></Field>;
|
||||
}
|
||||
|
||||
function RangeField({ label, min, max, step, value, onChange }: { label: string; min: number; max: number; step: number; value: number; onChange: (value: number) => void }) {
|
||||
return <Field label={`${label} · ${value}`}><input aria-label={label} type="range" min={min} max={max} step={step} value={value} onChange={(event) => onChange(Number(event.target.value))} className="h-6 w-full accent-blue-600" /></Field>;
|
||||
}
|
||||
|
||||
const inputClassName = "h-9 w-full rounded-lg border border-slate-200 bg-white/80 px-2.5 text-xs text-slate-800 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/15";
|
||||
const primaryButtonClassName = "inline-flex h-9 items-center justify-center gap-1.5 rounded-lg bg-blue-600 px-3 text-xs font-semibold text-white transition hover:bg-blue-700 active:scale-95 disabled:cursor-not-allowed disabled:scale-100 disabled:opacity-45";
|
||||
const secondaryButtonClassName = "inline-flex h-9 items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white/70 px-3 text-xs font-semibold text-slate-700 transition hover:bg-white active:scale-95 disabled:cursor-not-allowed disabled:scale-100 disabled:opacity-45";
|
||||
Reference in New Issue
Block a user