Files
next-tjwater-frontend/src/features/workbench/components/toolbar-panel.tsx
T

551 lines
17 KiB
TypeScript

import {
Camera,
Circle,
Copy,
Database,
Dot,
Eraser,
Keyboard,
Layers3,
MapPinned,
Pentagon,
Redo2,
RefreshCw,
Route,
Ruler,
Share2,
Square,
Trash2,
Undo2,
Waypoints,
type LucideIcon
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
MapAnnotationPanel,
MapActionRow,
MapControlPanel,
MapDrawToolbar,
MapLayerPanel,
MapLegendList,
MapMeasurePanel,
MapPanelSection,
type BaseLayerOption,
type MapAnnotationItem,
type MapAnnotationShareAction,
type MapLayerControlItem,
type MapLegendItem
} from "@/features/map/core";
import {
type WorkbenchDrawingAnnotation,
type WorkbenchDrawMode
} from "../hooks/use-workbench-drawing";
import {
type WorkbenchMeasurementResult,
type WorkbenchMeasureMode,
type WorkbenchMeasureUnit
} from "../hooks/use-workbench-measurement";
import { waterNetworkToolbarItems } from "../map/toolbar-config";
import type { WorkbenchGeocoding } from "../hooks/use-workbench-geocoding";
import { GeocodingPanel } from "./geocoding-panel";
import { FeatureLocationPanel, type FeatureLocationPanelProps } from "./feature-location-panel";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
export type ToolbarToolId = "geocoding" | "layers" | "measure" | "analysis" | "annotation" | "more";
export type ExportViewPreset = "current" | "4k";
type MeasureModeOption = {
id: WorkbenchMeasureMode;
label: string;
icon: LucideIcon;
};
const MEASURE_MODES: MeasureModeOption[] = [
{ id: "distance", label: "距离", icon: Route },
{ id: "area", label: "面积", icon: Pentagon },
{ id: "segment", label: "管段", icon: Waypoints }
];
const MEASURE_UNITS: Array<{ id: WorkbenchMeasureUnit; label: string }> = [
{ id: "m", label: "m" },
{ id: "km", label: "km" }
];
const ANNOTATION_SHARE_ACTIONS: MapAnnotationShareAction[] = [
{
id: "export",
icon: Share2,
label: "导出标注",
description: "GeoJSON / 截图报告"
}
];
const TOOL_PANEL_EXIT_MS = 160;
const DRAW_TOOL_OPTIONS: Array<{
id: WorkbenchDrawMode | "undo" | "redo" | "delete" | "clear";
label: string;
icon: LucideIcon;
tone?: "danger";
}> = [
{ id: "point", label: "点标注", icon: Dot },
{ id: "line", label: "线标注", icon: Route },
{ id: "polygon", label: "范围标注", icon: Pentagon },
{ id: "circle", label: "圆形标注", icon: Circle },
{ id: "undo", label: "撤销", icon: Undo2 },
{ id: "redo", label: "重做", icon: Redo2 },
{ id: "delete", label: "删除选中", icon: Trash2, tone: "danger" },
{ id: "clear", label: "清空全部", icon: Trash2, tone: "danger" }
];
export type ToolbarPanelProps = {
geocoding: WorkbenchGeocoding;
featureLocation: FeatureLocationPanelProps;
activeToolId: ToolbarToolId | null;
activeMeasureModeId: WorkbenchMeasureMode;
activeMeasureUnitId: WorkbenchMeasureUnit;
measuring: boolean;
measureResult: WorkbenchMeasurementResult;
measureEnabled: boolean;
hasMeasurePoints: boolean;
activeDrawToolId: WorkbenchDrawMode | null;
drawingEnabled: boolean;
drawingAnnotations: WorkbenchDrawingAnnotation[];
canUndoDrawing: boolean;
canRedoDrawing: boolean;
canDeleteSelectedDrawing: boolean;
hasDrawingFeatures: boolean;
layerItems: MapLayerControlItem[];
legendItems: MapLegendItem[];
baseLayerOptions: BaseLayerOption[];
activeBaseLayerId: string;
onSelectMeasureMode: (id: WorkbenchMeasureMode) => void;
onSelectMeasureUnit: (id: WorkbenchMeasureUnit) => void;
onStartMeasure: () => void;
onStopMeasure: () => void;
onClearMeasure: () => void;
onCopyMeasure: () => void;
onSelectDrawTool: (id: WorkbenchDrawMode | null) => void;
onUndoDrawing: () => void;
onRedoDrawing: () => void;
onDeleteSelectedDrawing: () => void;
onClearDrawing: () => void;
onExportAnnotations: () => void;
onSelectBaseLayer: (id: string) => void;
onToggleLayer: (id: string, visible: boolean) => void;
onExportView: (preset: ExportViewPreset) => void;
onRefreshTiles: () => void;
onShowDataStatus: () => void;
onShowShortcuts: () => void;
onClearMapOverlays: () => void;
panelWidthClassName?: string;
};
export function ToolbarPanel({
geocoding,
featureLocation,
activeToolId,
activeMeasureModeId,
activeMeasureUnitId,
measuring,
measureResult,
measureEnabled,
hasMeasurePoints,
activeDrawToolId,
drawingEnabled,
drawingAnnotations,
canUndoDrawing,
canRedoDrawing,
canDeleteSelectedDrawing,
hasDrawingFeatures,
layerItems,
legendItems,
baseLayerOptions,
activeBaseLayerId,
onSelectMeasureMode,
onSelectMeasureUnit,
onStartMeasure,
onStopMeasure,
onClearMeasure,
onCopyMeasure,
onSelectDrawTool,
onUndoDrawing,
onRedoDrawing,
onDeleteSelectedDrawing,
onClearDrawing,
onExportAnnotations,
onSelectBaseLayer,
onToggleLayer,
onExportView,
onRefreshTiles,
onShowDataStatus,
onShowShortcuts,
onClearMapOverlays,
panelWidthClassName
}: ToolbarPanelProps) {
const annotationItems = useMemo<MapAnnotationItem[]>(
() =>
drawingAnnotations.map((annotation) => ({
id: annotation.id,
icon: getDrawingAnnotationIcon(annotation.kind),
label: annotation.label,
description: annotation.description,
status: annotation.hidden ? "隐藏" : "显示",
selected: annotation.selected,
muted: annotation.hidden,
onClick: annotation.onToggleVisibility
})),
[drawingAnnotations]
);
const annotationShareActions = useMemo(
() =>
ANNOTATION_SHARE_ACTIONS.map((action) => ({
...action,
onClick: action.id === "export" ? onExportAnnotations : action.onClick
})),
[onExportAnnotations]
);
const [renderedToolId, setRenderedToolId] = useState<ToolbarToolId | null>(activeToolId);
const [locationMode, setLocationMode] = useState("address");
const panelVisible = Boolean(activeToolId);
useEffect(() => {
if (activeToolId) {
setRenderedToolId(activeToolId);
return;
}
if (!renderedToolId) {
return;
}
const exitTimer = window.setTimeout(() => {
setRenderedToolId(null);
}, TOOL_PANEL_EXIT_MS);
return () => window.clearTimeout(exitTimer);
}, [activeToolId, renderedToolId]);
if (!renderedToolId) {
return null;
}
const activeTool = waterNetworkToolbarItems.find((item) => item.id === renderedToolId);
const Icon = activeTool?.icon ?? Layers3;
if (renderedToolId === "geocoding") {
return (
<MapControlPanel
key="geocoding" title="定位" description={locationMode === "features" ? "按图层和要素 ID 定位" : geocoding.configured ? `${geocoding.region} · 天地图地名搜索` : "当前项目暂未配置地名搜索"} icon={Icon}
widthClassName={`${panelWidthClassName ?? "w-[324px] max-h-[calc(100dvh-13.25rem)]"} flex flex-col overflow-hidden`}
contentClassName="flex min-h-0 flex-1 flex-col"
visible={panelVisible}
>
<Tabs value={locationMode} onValueChange={(value) => {
if (value === "features" && geocoding.picking) geocoding.togglePicking();
setLocationMode(value);
}} className="flex min-h-0 flex-1 flex-col gap-3">
<TabsList aria-label="定位方式" className="surface-control grid shrink-0 grid-cols-2">
<TabsTrigger value="address">地址地名</TabsTrigger>
<TabsTrigger value="features">管网要素</TabsTrigger>
</TabsList>
<TabsContent value="address" forceMount hidden={locationMode !== "address"}
className="flex min-h-0 flex-1 flex-col overflow-hidden">
<GeocodingPanel geocoding={geocoding} />
</TabsContent>
<TabsContent value="features" forceMount hidden={locationMode !== "features"}
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-0.5">
<FeatureLocationPanel {...featureLocation} />
</TabsContent>
</Tabs>
</MapControlPanel>
);
}
if (renderedToolId === "layers") {
return (
<MapControlPanel
key="layers"
title="图层"
description={`${layerItems.filter((item) => item.visible).length}/${layerItems.length} 个业务图层可见`}
icon={Icon}
widthClassName={panelWidthClassName ?? "flex w-[324px] max-h-[calc(100dvh-13.25rem)] flex-col"}
contentClassName={panelWidthClassName ? undefined : "min-h-0 overflow-y-auto overscroll-contain pr-0.5"}
visible={panelVisible}
>
<MapLayerPanel
layerItems={layerItems}
baseLayerOptions={baseLayerOptions}
activeBaseLayerId={activeBaseLayerId}
onSelectBaseLayer={onSelectBaseLayer}
onToggleLayer={onToggleLayer}
/>
</MapControlPanel>
);
}
if (renderedToolId === "measure") {
const measureStatus = getMeasureStatus({
enabled: measureEnabled,
measuring,
hasPoints: hasMeasurePoints,
mode: activeMeasureModeId
});
return (
<MapControlPanel
key="measure"
title="测量"
description="距离、面积与管段测量"
icon={Icon}
widthClassName={panelWidthClassName ?? "flex w-[324px] max-h-[calc(100dvh-13.25rem)] flex-col"}
contentClassName={panelWidthClassName ? undefined : "min-h-0 overflow-y-auto overscroll-contain pr-0.5"}
visible={panelVisible}
>
<MapMeasurePanel
modes={MEASURE_MODES}
activeModeId={activeMeasureModeId}
resultLabel={measureResult.label}
resultValue={measureResult.value}
resultUnit={measureResult.unit}
metrics={measureResult.metrics}
units={MEASURE_UNITS}
activeUnitId={activeMeasureUnitId}
status={measureStatus}
actions={[
{
id: measuring ? "stop" : "start",
icon: measuring ? Square : Ruler,
label: measuring ? "停止测量" : "开始测量",
description:
measuring
? "暂停并保留当前读数"
: activeMeasureModeId === "segment"
? "在地图中选择管线"
: activeMeasureModeId === "area"
? "在地图中圈定范围"
: "在地图中连续取点",
variant: measuring ? "active" : "primary",
disabled: !measureEnabled,
onClick: measuring ? onStopMeasure : onStartMeasure
},
{
id: "copy",
icon: Copy,
label: "复制结果",
description: "复制当前读数",
disabled: !hasMeasurePoints,
onClick: onCopyMeasure
},
{
id: "clear",
icon: Trash2,
label: "清除测量",
description: activeMeasureModeId === "segment" ? "清空已选管段" : "移除临时测量线",
tone: "danger",
disabled: !hasMeasurePoints,
onClick: onClearMeasure
}
]}
onSelectMode={(id) => onSelectMeasureMode(id as WorkbenchMeasureMode)}
onSelectUnit={(id) => onSelectMeasureUnit(id as WorkbenchMeasureUnit)}
/>
</MapControlPanel>
);
}
if (renderedToolId === "analysis") {
return (
<MapControlPanel
key="analysis"
title="图例"
description={`${legendItems.length} 项当前地图符号`}
icon={Icon}
widthClassName={panelWidthClassName ?? "flex w-[324px] max-h-[calc(100dvh-13.25rem)] flex-col"}
contentClassName={panelWidthClassName ? undefined : "min-h-0 overflow-y-auto overscroll-contain pr-0.5"}
visible={panelVisible}
>
<LegendPanel legendItems={legendItems} />
</MapControlPanel>
);
}
if (renderedToolId === "annotation") {
return (
<MapControlPanel key="annotation" title="标注" description="创建与管理地图标注" icon={Icon} widthClassName={panelWidthClassName ?? "w-[312px]"} visible={panelVisible}>
<MapPanelSection title="绘制">
<MapDrawToolbar
variant="panel"
items={DRAW_TOOL_OPTIONS.map((item) => ({
...item,
active: activeDrawToolId === item.id,
disabled:
!drawingEnabled ||
(item.id === "undo" && !canUndoDrawing) ||
(item.id === "redo" && !canRedoDrawing) ||
(item.id === "delete" && !canDeleteSelectedDrawing) ||
(item.id === "clear" && !canUndoDrawing && !hasDrawingFeatures),
onClick: () => {
if (item.id === "undo") {
onUndoDrawing();
return;
}
if (item.id === "redo") {
onRedoDrawing();
return;
}
if (item.id === "delete") {
onDeleteSelectedDrawing();
return;
}
if (item.id === "clear") {
onClearDrawing();
return;
}
onSelectDrawTool(activeDrawToolId === item.id ? null : item.id);
}
}))}
/>
</MapPanelSection>
<MapAnnotationPanel annotations={annotationItems} shareActions={annotationShareActions} />
</MapControlPanel>
);
}
return (
<MapControlPanel key="more" title="更多" description={activeTool?.description} icon={Icon} widthClassName={panelWidthClassName ?? "w-[292px]"} visible={panelVisible}>
<MorePanel
onExportView={onExportView}
onRefreshTiles={onRefreshTiles}
onShowDataStatus={onShowDataStatus}
onShowShortcuts={onShowShortcuts}
onClearMapOverlays={onClearMapOverlays}
/>
</MapControlPanel>
);
}
function getMeasureStatus({
enabled,
measuring,
hasPoints,
mode
}: {
enabled: boolean;
measuring: boolean;
hasPoints: boolean;
mode: WorkbenchMeasureMode;
}) {
if (!enabled) {
return {
label: "地图未就绪",
description: "等待地图加载后再开始测量",
tone: "disabled" as const
};
}
if (measuring) {
return {
label: "测量中",
description:
mode === "segment"
? "点击地图中的管线追加资产"
: mode === "area"
? "连续点击范围顶点,停止后保留读数"
: "连续点击地图取点,停止后保留读数",
tone: "active" as const
};
}
if (hasPoints) {
return {
label: "读数已保留",
description: "可继续测量、复制结果或清除",
tone: "ready" as const
};
}
return {
label: "等待开始",
description:
mode === "segment"
? "开始后点击管线选择资产"
: mode === "area"
? "开始后连续点击范围顶点"
: "开始后连续点击地图取点",
tone: "idle" as const
};
}
function LegendPanel({ legendItems }: { legendItems: MapLegendItem[] }) {
return (
<div className="space-y-2">
<MapLegendList items={legendItems} />
<div role="note" className="surface-control rounded-xl border px-3 py-2.5">
<p className="text-[11px] font-semibold text-slate-700">图例随图层显隐同步</p>
<p className="mt-1 text-[10px] leading-relaxed text-[var(--ink-muted)]">
管线颜色和线宽表示口径,设施颜色表示类型,风险色仅用于分析覆盖。
</p>
</div>
</div>
);
}
type MorePanelProps = {
onClearMapOverlays: () => void;
onExportView: (preset: ExportViewPreset) => void;
onRefreshTiles: () => void;
onShowDataStatus: () => void;
onShowShortcuts: () => void;
};
function MorePanel({
onClearMapOverlays,
onExportView,
onRefreshTiles,
onShowDataStatus,
onShowShortcuts
}: MorePanelProps) {
return (
<div className="space-y-3">
<MapPanelSection title="清理地图">
<MapActionRow icon={Eraser} label="清空所有高亮和覆盖层" description="分析、测量、标注与定位标记" onClick={onClearMapOverlays} />
</MapPanelSection>
<MapPanelSection title="导出视图">
<MapActionRow icon={Camera} label="当前分辨率" description="按当前地图画布尺寸导出" onClick={() => onExportView("current")} />
<MapActionRow icon={Camera} label="4K 分辨率" description="长边约 3840px 的 PNG" onClick={() => onExportView("4k")} />
</MapPanelSection>
<MapPanelSection title="数据">
<MapActionRow icon={RefreshCw} label="刷新瓦片" description="请求地图重新渲染" onClick={onRefreshTiles} />
<MapActionRow icon={Database} label="数据状态" description="查看当前数据源状态" status="查看" onClick={onShowDataStatus} />
</MapPanelSection>
<MapPanelSection title="系统">
<MapActionRow icon={Keyboard} label="快捷键" description="查看地图操作键位" onClick={onShowShortcuts} />
</MapPanelSection>
</div>
);
}
function getDrawingAnnotationIcon(kind: WorkbenchDrawMode) {
if (kind === "line") {
return Route;
}
if (kind === "polygon") {
return Pentagon;
}
if (kind === "circle") {
return Circle;
}
return MapPinned;
}