779 lines
29 KiB
TypeScript
779 lines
29 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { RotateCcw, X } from "lucide-react";
|
||
import { Button } from "@/shared/ui/button";
|
||
import { Checkbox } from "@/shared/ui/checkbox";
|
||
import { Disclosure } from "@/shared/ui/disclosure";
|
||
import { FieldSelect } from "@/shared/ui/field-select";
|
||
import { Input } from "@/shared/ui/input";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||
import {
|
||
LAYER_GROUP_GEOMETRIES,
|
||
type LayerGroupId,
|
||
type LayerGroupStylePatch
|
||
} from "../map/layer-group-style";
|
||
import { MAP_STYLE_TOKENS } from "../map/map-colors";
|
||
import { getNumericFeatureProperties } from "../map/value-label";
|
||
import type {
|
||
WorkbenchMapCommands,
|
||
WorkbenchMapControllerState
|
||
} from "../map/workbench-map-controller";
|
||
import {
|
||
SUPPLY_LAYER_CATALOG,
|
||
type AvailableWaterNetworkSourceId
|
||
} from "../map/sources";
|
||
import type { DetailFeature } from "../types";
|
||
import { INVESTIGATION_DEV_NETWORK } from "../utils/investigation-dev-network";
|
||
import { InvestigationDevTools, type InvestigationDevToolsProps } from "./investigation-dev-tools";
|
||
import { FeatureLocationPanel } from "./feature-location-panel";
|
||
import { FEATURE_LOCATION_ERROR_LABELS } from "../map/map-feature-feedback";
|
||
import type { ScadaAlertIntensity } from "./scada-alert-atmosphere";
|
||
import { NoticeDevTools } from "./notice-dev-tools";
|
||
|
||
type MapDevPanelProps = {
|
||
commands: WorkbenchMapCommands;
|
||
controllerState: WorkbenchMapControllerState;
|
||
detailFeature: DetailFeature | null;
|
||
flowDataSource?: "realtime" | "mock";
|
||
onClose: () => void;
|
||
investigationFollow?: boolean;
|
||
onInvestigationFollowChange?: (enabled: boolean) => void;
|
||
investigationTools?: InvestigationDevToolsProps;
|
||
onPreviewScadaAlert?: () => void;
|
||
onEndScadaAlertPreview?: () => void;
|
||
scadaAlertPreviewActive?: boolean;
|
||
scadaAlertIntensity?: ScadaAlertIntensity;
|
||
onScadaAlertIntensityChange?: (intensity: ScadaAlertIntensity) => void;
|
||
};
|
||
|
||
const GROUP_LABELS = Object.fromEntries(
|
||
SUPPLY_LAYER_CATALOG.map((layer) => [layer.id, layer.label])
|
||
) as 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: "base_demand", label: "基础需水量 base_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> = {
|
||
...FEATURE_LOCATION_ERROR_LABELS,
|
||
FLOW_UNAVAILABLE: "水流图层初始化失败",
|
||
INVALID_FLOW_CALCULATION: "管道 ID 或有符号流速无效",
|
||
INVALID_STYLE_PATCH: "参数不符合受控范围"
|
||
};
|
||
|
||
export function MapDevPanel({
|
||
commands,
|
||
controllerState,
|
||
detailFeature,
|
||
flowDataSource = "mock",
|
||
onClose,
|
||
investigationFollow = true,
|
||
onInvestigationFollowChange,
|
||
investigationTools,
|
||
onPreviewScadaAlert,
|
||
onEndScadaAlertPreview,
|
||
scadaAlertPreviewActive = false,
|
||
scadaAlertIntensity = "standard",
|
||
onScadaAlertIntensityChange
|
||
}: MapDevPanelProps) {
|
||
const [activeTab, setActiveTab] = useState(onInvestigationFollowChange ? "investigation" : "map");
|
||
const [locationDraft, setLocationDraft] = useState<{ sourceId: AvailableWaterNetworkSourceId; featureId: string }>({
|
||
sourceId: "pipes", featureId: INVESTIGATION_DEV_NETWORK.pipeIds[0]
|
||
});
|
||
const { sourceId, featureId } = locationDraft;
|
||
const featureIds = [...new Set(featureId.split(/[\s,,;;、]+/u).filter(Boolean))];
|
||
const [property, setProperty] = useState("diameter");
|
||
const [flowPipeId, setFlowPipeId] = useState<string>(INVESTIGATION_DEV_NETWORK.pipeIds[0]);
|
||
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,
|
||
strokeColor: "#FFFFFF",
|
||
opacity: 0.9,
|
||
widthMultiplier: 1,
|
||
radiusMultiplier: 1,
|
||
strokeMultiplier: 1,
|
||
iconMultiplier: 1,
|
||
dash: "original"
|
||
});
|
||
|
||
useEffect(() => {
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === "Escape" && !event.defaultPrevented) onClose();
|
||
};
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||
}, [onClose]);
|
||
|
||
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 (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 applyLayerStyle() {
|
||
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),
|
||
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="workbench-shadcn-surface bg-background text-foreground shadow-lg pointer-events-auto absolute right-3 top-[4.5rem] z-[80] hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex"
|
||
data-testid="map-dev-panel"
|
||
>
|
||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
|
||
<div>
|
||
<h2 className="text-sm font-semibold text-foreground">
|
||
开发测试{" "}
|
||
<span className="ml-1 text-[11px] font-medium text-muted-foreground">Dev Panel</span>
|
||
</h2>
|
||
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
|
||
调查、通知与地图工具 · 仅当前页面生效
|
||
</p>
|
||
</div>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
type="button"
|
||
aria-label="关闭 Dev Panel"
|
||
onClick={onClose}
|
||
>
|
||
<X size={16} />
|
||
</Button>
|
||
</div>
|
||
|
||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex min-h-0 flex-1 flex-col">
|
||
<div className="shrink-0 border-b border-border px-4 py-2">
|
||
<TabsList aria-label="开发测试类型" className="grid w-full grid-cols-3">
|
||
<TabsTrigger value="investigation" disabled={!onInvestigationFollowChange}>调查测试</TabsTrigger>
|
||
<TabsTrigger value="notices">通知测试</TabsTrigger>
|
||
<TabsTrigger value="map">地图调试</TabsTrigger>
|
||
</TabsList>
|
||
</div>
|
||
<TabsContent value="investigation" hidden={activeTab !== "investigation"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
|
||
{onPreviewScadaAlert ? <section aria-label="爆管视觉警告测试" className="mb-4 space-y-2 border-b border-border pb-4">
|
||
<h3 className="text-xs font-semibold">全屏爆管视觉警告</h3>
|
||
<p className="text-[11px] leading-5 text-muted-foreground">本地持续预览,可模拟事件处理结束。</p>
|
||
{onScadaAlertIntensityChange ? <div role="group" aria-label="告警灯效强度" className="flex gap-2">
|
||
{([ ["soft", "柔和"], ["standard", "标准"], ["strong", "醒目"] ] as const).map(([value, label]) =>
|
||
<Button key={value} type="button" size="sm" variant="outline" aria-pressed={scadaAlertIntensity === value}
|
||
onClick={() => onScadaAlertIntensityChange(value)}>{label}</Button>)}
|
||
</div> : null}
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button type="button" size="sm" variant="outline" onClick={onPreviewScadaAlert}>测试爆管告警效果</Button>
|
||
<Button type="button" size="sm" variant="ghost" disabled={!scadaAlertPreviewActive}
|
||
onClick={onEndScadaAlertPreview}>模拟事件处理结束</Button>
|
||
</div>
|
||
</section> : null}
|
||
{onInvestigationFollowChange ? (
|
||
<section aria-label="调查联动" className="space-y-4">
|
||
<div className="space-y-2">
|
||
<ToggleField label="跟随所选调查" checked={investigationFollow} onChange={onInvestigationFollowChange} />
|
||
<p className="text-xs leading-5 text-muted-foreground">选中调查后,跟随新的地图高亮、图表和影响范围指令。</p>
|
||
</div>
|
||
{investigationTools ? <InvestigationDevTools key={`${investigationTools.task?.task_id}:${investigationTools.task?.execution_generation}`} {...investigationTools} /> : null}
|
||
</section>
|
||
) : null}
|
||
</TabsContent>
|
||
<TabsContent value="notices" hidden={activeTab !== "notices"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
|
||
<NoticeDevTools />
|
||
</TabsContent>
|
||
<TabsContent value="map" hidden={activeTab !== "map"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
|
||
{controllerState.errorCode ? (
|
||
<div role="alert" className="status-tone-danger my-3 break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs text-[var(--status-foreground)]">
|
||
{ERROR_LABELS[controllerState.errorCode] ?? controllerState.errorCode}
|
||
</div>
|
||
) : null}
|
||
|
||
<PanelSection
|
||
title="要素定位"
|
||
description="按业务源与要素 ID 定位,也可从地图工具的定位面板使用。"
|
||
initiallyOpen
|
||
status={controllerState.pending ? "执行中" : undefined}
|
||
>
|
||
<FeatureLocationPanel commands={commands} controllerState={controllerState}
|
||
detailFeature={detailFeature} value={locationDraft} onValueChange={setLocationDraft}
|
||
onUseSelectedFeature={useSelectedFeature} showError={false}
|
||
contentClassName="workbench-shadcn-surface" />
|
||
</PanelSection>
|
||
|
||
<PanelSection
|
||
title="水流调试"
|
||
status={controllerState.flowVisible ? "已开启" : "未开启"}
|
||
description={
|
||
flowDataSource === "realtime"
|
||
? "已读取后端最近 15 分钟的管道流速;可用有符号流速覆盖单管方向。"
|
||
: "后端流速不可用,开启时使用正反向演示流向;有符号流速可覆盖单管方向。"
|
||
}
|
||
>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<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" || featureIds.length !== 1}
|
||
onClick={() => setFlowPipeId(featureIds[0])}
|
||
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>
|
||
{flowDataSource === "realtime" ? "后端实时流速" : "演示流向"} · 已赋值{" "}
|
||
{flowSummary.total} · 正向 {flowSummary.forward} · 反向 {flowSummary.reverse} · 停止{" "}
|
||
{flowSummary.stopped}
|
||
</StatusText>
|
||
) : controllerState.flowVisible ? (
|
||
<StatusText>演示流向已启用 · 正反向交错显示</StatusText>
|
||
) : (
|
||
<StatusText>开启后显示演示流向</StatusText>
|
||
)}
|
||
</PanelSection>
|
||
|
||
<PanelSection
|
||
title="图层样式"
|
||
status={
|
||
controllerState.styledGroups.length > 0
|
||
? `${controllerState.styledGroups.length} 组已覆盖`
|
||
: undefined
|
||
}
|
||
description="调整业务图层的颜色、透明度和尺寸,交互语义色保持不变。"
|
||
>
|
||
<FieldSelect
|
||
contentClassName="workbench-shadcn-surface"
|
||
label="业务图层组"
|
||
value={groupId}
|
||
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
|
||
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
|
||
/>
|
||
{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 }))
|
||
}
|
||
/>
|
||
<FieldSelect
|
||
contentClassName="workbench-shadcn-surface"
|
||
label="线型"
|
||
value={String(draft.dash)}
|
||
onValueChange={(value) => setDraft((current) => ({ ...current, dash: value }))}
|
||
options={[
|
||
{ value: "original", label: "原始" },
|
||
{ value: "solid", label: "实线" },
|
||
{ value: "dashed", label: "虚线" }
|
||
]}
|
||
/>
|
||
</>
|
||
) : (
|
||
<>
|
||
<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={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>
|
||
|
||
<PanelSection
|
||
title="数值标注"
|
||
description="作用于所选图层的全部可见要素,使用原生 MVT 标注并自动避让。"
|
||
status={
|
||
hasActiveLabels
|
||
? `${Object.keys(controllerState.valueLabels).length} 组已标注`
|
||
: undefined
|
||
}
|
||
>
|
||
<FieldSelect
|
||
contentClassName="workbench-shadcn-surface"
|
||
label="标注图层组"
|
||
value={groupId}
|
||
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
|
||
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
|
||
/>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} />
|
||
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} />
|
||
</div>
|
||
{showValueLabels ? (
|
||
<>
|
||
<FieldSelect
|
||
contentClassName="workbench-shadcn-surface"
|
||
label="数值属性"
|
||
value={property}
|
||
onValueChange={setProperty}
|
||
options={LABEL_PRESETS[groupId].options}
|
||
/>
|
||
<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}
|
||
</PanelSection>
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
<div className="shrink-0 border-t border-border p-3" hidden={activeTab !== "map"}>
|
||
<Button
|
||
type="button"
|
||
onClick={() => commands.resetAll()}
|
||
className="w-full text-xs"
|
||
variant="outline"
|
||
>
|
||
<RotateCcw size={14} />
|
||
重置地图调试效果
|
||
</Button>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function PanelSection({
|
||
title,
|
||
description,
|
||
initiallyOpen = false,
|
||
status,
|
||
children
|
||
}: {
|
||
title: string;
|
||
description: string;
|
||
initiallyOpen?: boolean;
|
||
status?: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<Disclosure
|
||
initiallyOpen={initiallyOpen}
|
||
className="border-b border-border py-2 last:border-b-0 [&>summary]:px-2 [&>summary]:text-foreground [&>summary]:text-sm"
|
||
label={
|
||
<span className="flex min-w-0 items-center justify-between gap-2">
|
||
<span className="font-semibold">{title}</span>
|
||
{status ? (
|
||
<span className="shrink-0 text-[11px] font-normal text-muted-foreground">
|
||
{status}
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
}
|
||
>
|
||
<div className="space-y-3 px-2 pb-3 pt-1">
|
||
<p className="text-[11px] leading-5 text-muted-foreground">{description}</p>
|
||
{children}
|
||
</div>
|
||
</Disclosure>
|
||
);
|
||
}
|
||
|
||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<label className="block min-w-0">
|
||
<span className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</span>
|
||
{children}
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function ToggleField({
|
||
label,
|
||
checked,
|
||
onChange
|
||
}: {
|
||
label: string;
|
||
checked: boolean;
|
||
onChange: (checked: boolean) => void;
|
||
}) {
|
||
return (
|
||
<label className="flex min-h-10 cursor-pointer items-center gap-2 rounded-lg border border-input bg-background px-2.5 py-2 text-xs font-medium text-foreground">
|
||
<Checkbox
|
||
checked={checked}
|
||
onChange={(event) => onChange(event.target.checked)}
|
||
/>
|
||
{label}
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function ColorField({
|
||
label,
|
||
value,
|
||
onChange
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
}) {
|
||
return (
|
||
<Field label={label}>
|
||
<div className="flex min-h-10 items-center gap-2 rounded-lg border border-input bg-background px-2">
|
||
<input
|
||
type="color"
|
||
value={value}
|
||
onChange={(event) => onChange(event.target.value.toUpperCase())}
|
||
aria-label={label}
|
||
className="h-10 w-10 shrink-0 cursor-pointer rounded border-0 bg-transparent p-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--action-blue)]"
|
||
/>
|
||
<span className="text-xs font-medium tabular-nums text-muted-foreground">{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-10 w-full rounded accent-[var(--action-blue)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--action-blue)]"
|
||
/>
|
||
</Field>
|
||
);
|
||
}
|
||
|
||
function StatusText({ children, tone = "success" }: { children: React.ReactNode; tone?: "success" | "warning" }) {
|
||
return (
|
||
<p
|
||
role="status"
|
||
className={`status-tone-${tone} break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-2.5 py-1.5 text-[11px] font-medium leading-5 text-[var(--status-foreground)] [overflow-wrap:anywhere]`}
|
||
>
|
||
{children}
|
||
</p>
|
||
);
|
||
}
|
||
|
||
const inputClassName =
|
||
"h-10 w-full rounded-lg border border-input bg-background px-2.5 text-xs text-foreground transition-colors";
|