feat: refine workbench visuals and map controls
Unify the Agent history extension with the header acrylic surface, preserve the full conversation body, and consolidate shared control and status styling. Restore map flow and SCADA controller behavior, remove obsolete rendering paths, and extend regression coverage. Button press coverage now releases outside the target so state assertions cannot accidentally toggle the control.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown, Crosshair, LocateFixed, RotateCcw, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChevronDown, Crosshair, RotateCcw, X, ZoomIn } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
LAYER_GROUP_GEOMETRIES,
|
||||
@@ -13,7 +14,10 @@ import type {
|
||||
WorkbenchMapCommands,
|
||||
WorkbenchMapControllerState
|
||||
} from "../map/workbench-map-controller";
|
||||
import { AVAILABLE_WATER_NETWORK_SOURCE_IDS, type AvailableWaterNetworkSourceId } from "../map/sources";
|
||||
import {
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
||||
type AvailableWaterNetworkSourceId
|
||||
} from "../map/sources";
|
||||
import type { DetailFeature } from "../types";
|
||||
|
||||
type MapDevPanelProps = {
|
||||
@@ -33,21 +37,80 @@ const GROUP_LABELS: Record<AvailableWaterNetworkSourceId, string> = {
|
||||
|
||||
const STYLE_GROUP_IDS = Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[];
|
||||
|
||||
const LABEL_PRESETS: Record<
|
||||
LayerGroupId,
|
||||
{
|
||||
property: string;
|
||||
precision: number;
|
||||
unit: string;
|
||||
options: { value: string; label: string }[];
|
||||
}
|
||||
> = {
|
||||
pipes: {
|
||||
property: "diameter",
|
||||
precision: 0,
|
||||
unit: "mm",
|
||||
options: [
|
||||
{ value: "diameter", label: "管径 diameter" },
|
||||
{ value: "length", label: "长度 length" },
|
||||
{ value: "roughness", label: "粗糙系数 roughness" },
|
||||
{ value: "minor_loss", label: "局部损失 minor_loss" }
|
||||
]
|
||||
},
|
||||
junctions: {
|
||||
property: "elevation",
|
||||
precision: 1,
|
||||
unit: "m",
|
||||
options: [
|
||||
{ value: "elevation", label: "高程 elevation" },
|
||||
{ value: "demand", label: "需水量 demand" }
|
||||
]
|
||||
},
|
||||
valves: {
|
||||
property: "diameter",
|
||||
precision: 0,
|
||||
unit: "mm",
|
||||
options: [
|
||||
{ value: "diameter", label: "管径 diameter" },
|
||||
{ value: "setting", label: "设定值 setting" },
|
||||
{ value: "minor_loss", label: "局部损失 minor_loss" }
|
||||
]
|
||||
},
|
||||
reservoirs: {
|
||||
property: "head",
|
||||
precision: 1,
|
||||
unit: "m",
|
||||
options: [{ value: "head", label: "水头 head" }]
|
||||
}
|
||||
};
|
||||
|
||||
const ERROR_LABELS: Record<string, string> = {
|
||||
MAP_NOT_READY: "地图尚未就绪",
|
||||
FEATURE_NOT_FOUND: "目标要素不存在",
|
||||
WFS_UNAVAILABLE: "WFS 查询暂不可用",
|
||||
FLOW_UNAVAILABLE: "水流图层初始化失败",
|
||||
INVALID_FLOW_CALCULATION: "管道 ID 或有符号流速无效",
|
||||
INVALID_STYLE_PATCH: "参数不符合受控范围"
|
||||
};
|
||||
|
||||
export function MapDevPanel({ commands, controllerState, detailFeature, onClose }: MapDevPanelProps) {
|
||||
export function MapDevPanel({
|
||||
commands,
|
||||
controllerState,
|
||||
detailFeature,
|
||||
onClose
|
||||
}: MapDevPanelProps) {
|
||||
const [sourceId, setSourceId] = useState<AvailableWaterNetworkSourceId>("pipes");
|
||||
const [featureId, setFeatureId] = useState("DWS30265WS2010031117");
|
||||
const [featureId, setFeatureId] = useState("440681");
|
||||
const [property, setProperty] = useState("diameter");
|
||||
const [flowPipeId, setFlowPipeId] = useState("440681");
|
||||
const [flowVelocity, setFlowVelocity] = useState("-1");
|
||||
const [precision, setPrecision] = useState(0);
|
||||
const [unit, setUnit] = useState("mm");
|
||||
const [groupId, setGroupId] = useState<LayerGroupId>("pipes");
|
||||
const [showValueLabels, setShowValueLabels] = useState(true);
|
||||
const [showFeatureIds, setShowFeatureIds] = useState(false);
|
||||
const [labelSize, setLabelSize] = useState(12);
|
||||
const [labelColor, setLabelColor] = useState<string>(MAP_STYLE_TOKENS.label.primary);
|
||||
const [draft, setDraft] = useState<Record<string, string | number>>({
|
||||
color: MAP_STYLE_TOKENS.supply.pipeMedium,
|
||||
fillColor: MAP_STYLE_TOKENS.supply.junction,
|
||||
@@ -68,26 +131,49 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
|
||||
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];
|
||||
const activeLabel = controllerState.valueLabels[groupId];
|
||||
const hasActiveLabels = Object.keys(controllerState.valueLabels).length > 0;
|
||||
|
||||
function selectStyleGroup(nextGroupId: LayerGroupId) {
|
||||
const preset = LABEL_PRESETS[nextGroupId];
|
||||
const saved = controllerState.valueLabels[nextGroupId];
|
||||
setGroupId(nextGroupId);
|
||||
setProperty(saved?.property ?? preset.property);
|
||||
setPrecision(saved?.precision ?? preset.precision);
|
||||
setUnit(saved?.unit ?? preset.unit);
|
||||
setShowValueLabels(saved?.showValue ?? true);
|
||||
setShowFeatureIds(saved?.showId ?? false);
|
||||
setLabelSize(saved?.textSize ?? 12);
|
||||
setLabelColor(saved?.textColor ?? MAP_STYLE_TOKENS.label.primary);
|
||||
}
|
||||
|
||||
function useSelectedFeature() {
|
||||
if (!detailFeature) return;
|
||||
if (AVAILABLE_WATER_NETWORK_SOURCE_IDS.includes(detailFeature.layer as AvailableWaterNetworkSourceId)) {
|
||||
if (
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS.includes(
|
||||
detailFeature.layer as AvailableWaterNetworkSourceId
|
||||
)
|
||||
) {
|
||||
setSourceId(detailFeature.layer as AvailableWaterNetworkSourceId);
|
||||
}
|
||||
setFeatureId(detailFeature.id);
|
||||
const firstNumeric = getNumericFeatureProperties(detailFeature.properties)[0];
|
||||
if (firstNumeric) setProperty(firstNumeric[0]);
|
||||
if (detailFeature.layer === "pipes") setFlowPipeId(detailFeature.id);
|
||||
if (detailFeature.layer in LAYER_GROUP_GEOMETRIES) {
|
||||
const nextGroupId = detailFeature.layer as LayerGroupId;
|
||||
selectStyleGroup(nextGroupId);
|
||||
const allowedProperties = new Set(
|
||||
LABEL_PRESETS[nextGroupId].options.map((option) => option.value)
|
||||
);
|
||||
const firstNumeric = getNumericFeatureProperties(detailFeature.properties).find(
|
||||
([name]) => allowedProperties.has(name)
|
||||
);
|
||||
if (firstNumeric) setProperty(firstNumeric[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function applyDraft() {
|
||||
function applyLayerStyle() {
|
||||
let patch: LayerGroupStylePatch;
|
||||
if (geometry === "line") {
|
||||
patch = {
|
||||
@@ -103,15 +189,44 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
|
||||
opacity: Number(draft.opacity),
|
||||
radiusMultiplier: Number(draft.radiusMultiplier),
|
||||
strokeMultiplier: Number(draft.strokeMultiplier),
|
||||
iconMultiplier: Number(draft.iconMultiplier)
|
||||
};
|
||||
}
|
||||
commands.applyLayerGroupStyle(groupId, patch);
|
||||
}
|
||||
|
||||
function applyLabels() {
|
||||
if (showValueLabels || showFeatureIds) {
|
||||
void commands.showValueLabel({
|
||||
sourceId: groupId,
|
||||
property,
|
||||
precision,
|
||||
unit,
|
||||
showValue: showValueLabels,
|
||||
showId: showFeatureIds,
|
||||
textSize: labelSize,
|
||||
textColor: labelColor
|
||||
});
|
||||
} else {
|
||||
commands.clearValueLabel(groupId);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFlowCalculation() {
|
||||
commands.updatePipeFlowCalculation({
|
||||
pipeId: flowPipeId.trim(),
|
||||
velocity: Number(flowVelocity)
|
||||
});
|
||||
}
|
||||
|
||||
const flowSummary = controllerState.flowSummary;
|
||||
const validFlowCalculation =
|
||||
flowPipeId.trim().length > 0 && Number.isFinite(Number(flowVelocity));
|
||||
|
||||
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"
|
||||
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 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">
|
||||
@@ -119,14 +234,23 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
|
||||
<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}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
aria-label="关闭 Dev Panel"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</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">
|
||||
<div
|
||||
role="alert"
|
||||
className="status-tone-danger rounded-xl border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs font-medium text-[var(--status-foreground)]"
|
||||
>
|
||||
{ERROR_LABELS[controllerState.errorCode] ?? controllerState.errorCode}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -134,101 +258,522 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
|
||||
<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 AvailableWaterNetworkSourceId)} className={inputClassName}>
|
||||
{AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => <option key={id} value={id}>{GROUP_LABELS[id]}</option>)}
|
||||
<select
|
||||
value={sourceId}
|
||||
onChange={(event) => {
|
||||
setSourceId(event.target.value as AvailableWaterNetworkSourceId);
|
||||
setFeatureId("");
|
||||
}}
|
||||
className={inputClassName}
|
||||
>
|
||||
{AVAILABLE_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} />
|
||||
<input
|
||||
value={featureId}
|
||||
onChange={(event) => setFeatureId(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<button type="button" disabled={!detailFeature} onClick={useSelectedFeature} className={secondaryButtonClassName}>使用当前地图选中</button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!detailFeature}
|
||||
onClick={useSelectedFeature}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
使用当前地图选中
|
||||
</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>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!target.featureId || controllerState.pending}
|
||||
onClick={() => void commands.highlight(target)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
<Crosshair size={14} />
|
||||
仅高亮
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!target.featureId || controllerState.pending}
|
||||
onClick={() => void commands.zoomToFeature(target)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
<ZoomIn size={14} />
|
||||
缩放到 Feature
|
||||
</Button>
|
||||
</div>
|
||||
<button type="button" onClick={commands.clearHighlight} className={secondaryButtonClassName}>清除高亮</button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!target.featureId || controllerState.pending}
|
||||
onClick={() => void commands.locateAndHighlight(target)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
定位并高亮
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.clearHighlight()}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清除高亮
|
||||
</Button>
|
||||
{controllerState.target ? (
|
||||
<StatusText>
|
||||
已高亮 {controllerState.target.sourceId}:{controllerState.target.featureId}
|
||||
</StatusText>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="全网水流" description="按管线拓扑方向显示静态或动画流纹。">
|
||||
<PanelSection
|
||||
title="全网水流"
|
||||
description="开启时为全部管道生成正反向 mock;有符号流速可覆盖单管方向。"
|
||||
>
|
||||
<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>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={controllerState.pending || controllerState.flowVisible}
|
||||
onClick={() => void commands.setFlowVisible(true)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
开启水流
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!controllerState.flowVisible}
|
||||
onClick={() => void commands.setFlowVisible(false)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
关闭水流
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="管道 ID">
|
||||
<input
|
||||
value={flowPipeId}
|
||||
onChange={(event) => setFlowPipeId(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="有符号流速">
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={flowVelocity}
|
||||
onChange={(event) => setFlowVelocity(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!validFlowCalculation || controllerState.pending}
|
||||
onClick={applyFlowCalculation}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
更新该管道方向
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={sourceId !== "pipes" || !target.featureId}
|
||||
onClick={() => setFlowPipeId(target.featureId)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
使用 Feature ID
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void commands.clearPipeFlowCalculations()}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清空流速
|
||||
</Button>
|
||||
</div>
|
||||
{flowSummary.total > 0 ? (
|
||||
<StatusText>
|
||||
全网 mock · 已赋值 {flowSummary.total} · 正向 {flowSummary.forward} · 反向{" "}
|
||||
{flowSummary.reverse} · 停止 {flowSummary.stopped}
|
||||
</StatusText>
|
||||
) : controllerState.flowVisible ? (
|
||||
<StatusText>全网 mock 已启用 · 正反向交错显示</StatusText>
|
||||
) : (
|
||||
<StatusText>开启后自动显示全网 mock 流向</StatusText>
|
||||
)}
|
||||
</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="覆盖业务组样式,交互语义色保持不变。">
|
||||
<PanelSection
|
||||
title="样式编辑"
|
||||
description="统一配置业务几何与整层 MVT 标注,交互语义色保持不变。"
|
||||
>
|
||||
<Field label="业务图层组">
|
||||
<div className="relative">
|
||||
<select value={groupId} onChange={(event) => setGroupId(event.target.value as LayerGroupId)} className={cn(inputClassName, "appearance-none pr-8")}>
|
||||
{STYLE_GROUP_IDS.map((id) => <option key={id} value={id}>{GROUP_LABELS[id]}</option>)}
|
||||
<select
|
||||
value={groupId}
|
||||
onChange={(event) => selectStyleGroup(event.target.value as LayerGroupId)}
|
||||
className={cn(inputClassName, "appearance-none pr-8")}
|
||||
>
|
||||
{STYLE_GROUP_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" />
|
||||
<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>
|
||||
<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 }))} />
|
||||
<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 }))
|
||||
}
|
||||
/>
|
||||
{groupId !== "junctions" ? (
|
||||
<RangeField
|
||||
label="图标倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
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 className="space-y-2.5 rounded-xl bg-slate-100/75 p-3 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.18)]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-[11px] font-semibold text-slate-800">整层标注</h4>
|
||||
<p className="mt-0.5 text-[10px] leading-4 text-slate-500">
|
||||
作用于当前图层所有可见 Feature,自动避让重叠。
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-md bg-teal-50 px-1.5 py-0.5 text-[9px] font-semibold text-teal-700">
|
||||
MVT 原生
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} />
|
||||
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} />
|
||||
</div>
|
||||
{showValueLabels ? (
|
||||
<>
|
||||
<Field label="数值属性">
|
||||
<select
|
||||
value={property}
|
||||
onChange={(event) => setProperty(event.target.value)}
|
||||
className={inputClassName}
|
||||
>
|
||||
{LABEL_PRESETS[groupId].options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
</>
|
||||
) : null}
|
||||
<RangeField
|
||||
label="字号"
|
||||
min={10}
|
||||
max={18}
|
||||
step={1}
|
||||
value={labelSize}
|
||||
onChange={setLabelSize}
|
||||
/>
|
||||
<ColorField label="文字颜色" value={labelColor} onChange={setLabelColor} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!showValueLabels && !showFeatureIds}
|
||||
onClick={applyLabels}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
应用标注
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!hasActiveLabels}
|
||||
onClick={() => {
|
||||
setShowValueLabels(false);
|
||||
setShowFeatureIds(false);
|
||||
commands.clearValueLabel();
|
||||
}}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清除全部标注
|
||||
</Button>
|
||||
</div>
|
||||
{activeLabel ? (
|
||||
<StatusText>
|
||||
已标注全部 {GROUP_LABELS[groupId]} Feature · {activeLabel.showId ? "ID + " : ""}
|
||||
{activeLabel.showValue ? activeLabel.property : "仅 ID"}
|
||||
</StatusText>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button type="button" onClick={applyLayerStyle} className="w-full text-xs">
|
||||
应用图层样式
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.resetLayerGroupStyle(groupId)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
重置本组
|
||||
</Button>
|
||||
</div>
|
||||
{controllerState.styledGroups.includes(groupId) ? (
|
||||
<StatusText>已覆盖当前图层组样式</StatusText>
|
||||
) : null}
|
||||
</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>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.resetAll()}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
<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 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>;
|
||||
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 ToggleField({
|
||||
label,
|
||||
checked,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-white/80 px-2.5 text-[11px] font-medium text-slate-700 shadow-[inset_0_0_0_1px_rgba(203,213,225,0.9)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
className="h-4 w-4 accent-blue-600"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const inputClassName = "h-9 w-full rounded-lg border border-slate-200 bg-white/80 px-2.5 text-xs text-slate-800 outline-hidden 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";
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusText({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
className="status-tone-success rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-2.5 py-1.5 text-[11px] font-medium text-[var(--status-foreground)]"
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const inputClassName =
|
||||
"h-9 w-full rounded-lg border border-slate-200 bg-white/80 px-2.5 text-xs text-slate-800 outline-hidden transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/15";
|
||||
|
||||
Reference in New Issue
Block a user