From 225817772608805a7e3ad343aea1c4618f6c11ca Mon Sep 17 00:00:00 2001 From: Huarch Date: Fri, 10 Jul 2026 18:44:07 +0800 Subject: [PATCH] feat(workbench): refine feature detail popover --- .../components/drainage-feature-icons.tsx | 80 +++++++ .../workbench/components/feature-popover.tsx | 211 +++++++++++++++--- .../utils/feature-properties.test.ts | 71 ++++++ .../workbench/utils/feature-properties.ts | 146 +++++++++++- 4 files changed, 478 insertions(+), 30 deletions(-) create mode 100644 features/workbench/components/drainage-feature-icons.tsx create mode 100644 features/workbench/utils/feature-properties.test.ts diff --git a/features/workbench/components/drainage-feature-icons.tsx b/features/workbench/components/drainage-feature-icons.tsx new file mode 100644 index 0000000..eb5a2bf --- /dev/null +++ b/features/workbench/components/drainage-feature-icons.tsx @@ -0,0 +1,80 @@ +import type { ReactNode, SVGProps } from "react"; + +export type DrainageFeatureIconProps = SVGProps & { + size?: number; +}; + +type DrainageIconFrameProps = DrainageFeatureIconProps & { + children: ReactNode; +}; + +function DrainageIconFrame({ children, size = 20, ...props }: DrainageIconFrameProps) { + return ( + + {children} + + ); +} + +export function ConduitFeatureIcon(props: DrainageFeatureIconProps) { + return ( + + + + + + ); +} + +export function JunctionFeatureIcon(props: DrainageFeatureIconProps) { + return ( + + + + + + ); +} + +export function OrificeFeatureIcon(props: DrainageFeatureIconProps) { + return ( + + + + + + ); +} + +export function PumpFeatureIcon(props: DrainageFeatureIconProps) { + return ( + + + + + + + + ); +} + +export function OutfallFeatureIcon(props: DrainageFeatureIconProps) { + return ( + + + + + + ); +} diff --git a/features/workbench/components/feature-popover.tsx b/features/workbench/components/feature-popover.tsx index b0f6784..567930b 100644 --- a/features/workbench/components/feature-popover.tsx +++ b/features/workbench/components/feature-popover.tsx @@ -1,55 +1,212 @@ "use client"; -import { X } from "lucide-react"; import { - MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, - MAP_READABLE_RADIUS_CLASS_NAME + Check, + Copy, + X +} from "lucide-react"; +import type { ComponentType } from "react"; +import { useEffect, useRef, useState } from "react"; +import { + MAP_ICON_CELL_RADIUS_CLASS_NAME, + MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core/components/map-control-styles"; +import { showMapNotice } from "@/features/map/core/components/notice"; import { cn } from "@/lib/utils"; +import type { WaterNetworkSourceId } from "../map/sources"; import type { DetailFeature } from "../types"; -import { getLocalizedFeatureProperties } from "../utils/feature-properties"; +import { + getFeaturePanelModel, + type FeaturePanelBadgeTone +} from "../utils/feature-properties"; +import { + ConduitFeatureIcon, + JunctionFeatureIcon, + OrificeFeatureIcon, + OutfallFeatureIcon, + PumpFeatureIcon, + type DrainageFeatureIconProps +} from "./drainage-feature-icons"; type FeaturePopoverProps = { feature: DetailFeature | null; onClose: () => void; }; +const FEATURE_LAYER_META: Record< + WaterNetworkSourceId, + { icon: ComponentType; label: string } +> = { + conduits: { icon: ConduitFeatureIcon, label: "管渠" }, + junctions: { icon: JunctionFeatureIcon, label: "检查井" }, + orifices: { icon: OrificeFeatureIcon, label: "孔口" }, + outfalls: { icon: OutfallFeatureIcon, label: "排放口" }, + pumps: { icon: PumpFeatureIcon, label: "泵" } +}; + +const BADGE_CLASS_NAMES: Record = { + active: "bg-emerald-50 text-emerald-700 ring-emerald-600/15", + inactive: "bg-slate-100 text-slate-600 ring-slate-500/15", + warning: "bg-orange-50 text-orange-700 ring-orange-600/15", + critical: "bg-red-50 text-red-700 ring-red-600/15", + info: "bg-blue-50 text-blue-700 ring-blue-600/15" +}; + +const BADGE_DOT_CLASS_NAMES: Record = { + active: "bg-emerald-500", + inactive: "bg-slate-400", + warning: "bg-orange-500", + critical: "bg-red-500", + info: "bg-blue-500" +}; + export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) { + const [copiedId, setCopiedId] = useState(null); + const copyResetTimeoutRef = useRef | null>(null); + + useEffect(() => () => { + if (copyResetTimeoutRef.current) { + clearTimeout(copyResetTimeoutRef.current); + } + }, []); + if (!feature) { return null; } - const entries = getLocalizedFeatureProperties(feature.properties).slice(0, 4); + const panelModel = getFeaturePanelModel(feature.layer, feature.properties, feature.id); + const layerMeta = FEATURE_LAYER_META[feature.layer]; + const LayerIcon = layerMeta.icon; + const copied = copiedId === panelModel.id; + const canCopy = panelModel.id !== "暂无"; + + const handleCopyId = async () => { + try { + if (!navigator.clipboard) { + throw new Error("Clipboard API unavailable"); + } + + await navigator.clipboard.writeText(panelModel.id); + setCopiedId(panelModel.id); + + if (copyResetTimeoutRef.current) { + clearTimeout(copyResetTimeoutRef.current); + } + + copyResetTimeoutRef.current = setTimeout(() => setCopiedId(null), 1500); + } catch { + showMapNotice({ + tone: "error", + title: "无法复制编号", + message: "请手动选择编号后复制。" + }); + } + }; return ( - ); } diff --git a/features/workbench/utils/feature-properties.test.ts b/features/workbench/utils/feature-properties.test.ts new file mode 100644 index 0000000..3113d00 --- /dev/null +++ b/features/workbench/utils/feature-properties.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { getFeaturePanelModel, getFeaturePanelProperties } from "./feature-properties"; + +describe("feature panel properties", () => { + it.each([ + ["pumps", ["id", "status", "startup_depth", "shutoff_depth"]], + ["conduits", ["id", "length", "diameter"]], + ["junctions", ["id", "invert_elevation", "max_depth"]], + ["orifices", ["id", "discharge_coeff", "gated", "shape", "geom1", "geom2"]], + ["outfalls", ["id", "invert_elevation", "outfall_type"]] + ] as const)("shows the configured %s fields", (layer, expectedKeys) => { + const entries = getFeaturePanelProperties(layer, {}, "feature-id"); + + expect(entries.map((entry) => entry.key)).toEqual(expectedKeys); + expect(entries[0].value).toBe("feature-id"); + }); + + it("formats conduit diameter from meters to millimeters", () => { + const entries = getFeaturePanelProperties("conduits", { + id: "C-1", + length: 42.5, + diameter: 0.6 + }); + + expect(entries.map(({ label, value }) => [label, value])).toEqual([ + ["编号", "C-1"], + ["长度", "42.50 m"], + ["管径", "600 mm"] + ]); + }); + + it("localizes free outfall type", () => { + const entries = getFeaturePanelProperties("outfalls", { + id: "OF-1", + invert_elevation: -5.67, + outfall_type: "FREE" + }); + + expect(entries.map((entry) => entry.value)).toEqual(["OF-1", "-5.67 m", "自由出流"]); + }); + + it.each([ + { + layer: "pumps", + properties: { id: "P-1", status: "ON", startup_depth: 1.2, shutoff_depth: 0.4 }, + badge: { label: "运行", tone: "active" }, + attributeKeys: ["startup_depth", "shutoff_depth"] + }, + { + layer: "orifices", + properties: { id: "O-1", gated: "YES", discharge_coeff: 0.65, shape: "RECT_CLOSED" }, + badge: { label: "有闸门", tone: "info" }, + attributeKeys: ["discharge_coeff", "shape", "geom1", "geom2"] + } + ] as const)("promotes the $layer badge without repeating it", ({ layer, properties, badge, attributeKeys }) => { + const model = getFeaturePanelModel(layer, properties); + + expect(model.id).toBe(properties.id); + expect(model.badge).toEqual(badge); + expect(model.attributes.map((entry) => entry.key)).toEqual(attributeKeys); + expect(model.attributes.every((entry) => entry.value !== properties.id)).toBe(true); + }); + + it("uses the map feature id when the properties omit it", () => { + const model = getFeaturePanelModel("junctions", { max_depth: 3.4 }, "map-feature-id"); + + expect(model.id).toBe("map-feature-id"); + expect(model.badge).toBeUndefined(); + expect(model.attributes.map((entry) => entry.key)).toEqual(["invert_elevation", "max_depth"]); + }); +}); diff --git a/features/workbench/utils/feature-properties.ts b/features/workbench/utils/feature-properties.ts index f5bb9d9..1a2335f 100644 --- a/features/workbench/utils/feature-properties.ts +++ b/features/workbench/utils/feature-properties.ts @@ -1,3 +1,4 @@ +import type { WaterNetworkSourceId } from "../map/sources"; import { formatValue } from "./format-value"; export type LocalizedFeatureProperty = { @@ -6,6 +7,19 @@ export type LocalizedFeatureProperty = { value: string; }; +export type FeaturePanelBadgeTone = "active" | "inactive" | "warning" | "critical" | "info"; + +export type FeaturePanelBadge = { + label: string; + tone: FeaturePanelBadgeTone; +}; + +export type FeaturePanelModel = { + id: string; + badge?: FeaturePanelBadge; + attributes: LocalizedFeatureProperty[]; +}; + const PROPERTY_LABELS: Record = { id: "编号", gid: "要素编号", @@ -20,6 +34,20 @@ const PROPERTY_LABELS: Record = { dn: "管径", length: "长度", roughness: "粗糙系数", + shape: "断面形状", + max_depth: "最大深度", + invert_elevation: "井底高程", + orifice_type: "孔口类型", + discharge_coeff: "流量系数", + gated: "闸门", + offset_height: "偏移高度", + geom1: "几何参数 1", + geom2: "几何参数 2", + pump_curve: "泵曲线", + startup_depth: "启动水深", + shutoff_depth: "停泵水深", + outfall_type: "排放类型", + stage_data: "阶段数据", status: "状态", material: "材质", elevation: "高程", @@ -42,6 +70,12 @@ const PROPERTY_ORDER: Record = { dn: 20, length: 21, roughness: 22, + max_depth: 30, + invert_elevation: 31, + orifice_type: 32, + outfall_type: 32, + shape: 33, + pump_curve: 34, material: 23, node1: 24, from_node: 24, @@ -56,6 +90,8 @@ const PROPERTY_ORDER: Record = { }; const STATUS_LABELS: Record = { + on: "运行", + off: "停止", active: "运行中", inactive: "停用", online: "在线", @@ -67,6 +103,32 @@ const STATUS_LABELS: Record = { critical: "严重" }; +const OUTFALL_TYPE_LABELS: Record = { + free: "自由出流" +}; + +const GATED_LABELS: Record = { + no: "否", + false: "否", + "0": "否", + yes: "是", + true: "是", + "1": "是" +}; + +const FEATURE_PANEL_PROPERTY_KEYS: Record = { + pumps: ["id", "status", "startup_depth", "shutoff_depth"], + conduits: ["id", "length", "diameter"], + junctions: ["id", "invert_elevation", "max_depth"], + orifices: ["id", "discharge_coeff", "gated", "shape", "geom1", "geom2"], + outfalls: ["id", "invert_elevation", "outfall_type"] +}; + +const FEATURE_PANEL_BADGE_KEYS: Partial> = { + pumps: "status", + orifices: "gated" +}; + export function getLocalizedFeatureProperties(properties: Record): LocalizedFeatureProperty[] { return Object.entries(properties).map(([key, value]) => ({ key, @@ -75,6 +137,39 @@ export function getLocalizedFeatureProperties(properties: Record getPropertyOrder(first.key) - getPropertyOrder(second.key)); } +export function getFeaturePanelProperties( + layer: WaterNetworkSourceId, + properties: Record, + featureId?: string +): LocalizedFeatureProperty[] { + return FEATURE_PANEL_PROPERTY_KEYS[layer].map((key) => { + const value = key === "id" ? properties[key] ?? featureId : properties[key]; + + return { + key, + label: getFeaturePropertyLabel(key), + value: formatFeaturePropertyValue(key, value) + }; + }); +} + +export function getFeaturePanelModel( + layer: WaterNetworkSourceId, + properties: Record, + featureId?: string +): FeaturePanelModel { + const entries = getFeaturePanelProperties(layer, properties, featureId); + const id = entries.find((entry) => entry.key === "id")?.value ?? "暂无"; + const badgeKey = FEATURE_PANEL_BADGE_KEYS[layer]; + const badgeEntry = badgeKey ? entries.find((entry) => entry.key === badgeKey) : undefined; + + return { + id, + badge: badgeEntry ? getFeaturePanelBadge(layer, badgeEntry.value) : undefined, + attributes: entries.filter((entry) => entry.key !== "id" && entry.key !== badgeKey) + }; +} + export function getFeaturePropertyLabel(key: string) { const normalizedKey = normalizePropertyKey(key); return PROPERTY_LABELS[normalizedKey] ?? key; @@ -87,11 +182,23 @@ export function formatFeaturePropertyValue(key: string, value: unknown) { return STATUS_LABELS[value.toLowerCase()] ?? value; } - if ((normalizedKey === "diameter" || normalizedKey === "dn") && value !== null && value !== undefined && value !== "") { - return `DN${formatValue(value)}`; + if (normalizedKey === "outfall_type" && typeof value === "string") { + return OUTFALL_TYPE_LABELS[value.toLowerCase()] ?? value; } - if ((normalizedKey === "length" || normalizedKey === "elevation") && value !== null && value !== undefined && value !== "") { + if (normalizedKey === "gated" && value !== null && value !== undefined && value !== "") { + return GATED_LABELS[String(value).toLowerCase()] ?? formatValue(value); + } + + if ((normalizedKey === "diameter" || normalizedKey === "dn") && value !== null && value !== undefined && value !== "") { + const diameterInMeters = typeof value === "number" ? value : Number(value); + return Number.isFinite(diameterInMeters) + ? `${formatValue(diameterInMeters * 1000)} mm` + : formatValue(value); + } + + if (["length", "elevation", "invert_elevation", "max_depth", "startup_depth", "shutoff_depth"].includes(normalizedKey) + && value !== null && value !== undefined && value !== "") { return `${formatValue(value)} m`; } @@ -109,3 +216,36 @@ function normalizePropertyKey(key: string) { function isStatusKey(key: string) { return normalizePropertyKey(key) === "status"; } + +function getFeaturePanelBadge( + layer: WaterNetworkSourceId, + value: string +): FeaturePanelBadge { + if (layer === "orifices") { + return value === "是" + ? { label: "有闸门", tone: "info" } + : value === "否" + ? { label: "无闸门", tone: "inactive" } + : { label: `闸门 ${value}`, tone: "inactive" }; + } + + const normalizedValue = value.toLowerCase(); + + if (["运行", "运行中", "在线", "开启", "正常"].includes(value)) { + return { label: value, tone: "active" }; + } + + if (["停止", "停用", "离线", "关闭"].includes(value)) { + return { label: value, tone: "inactive" }; + } + + if (normalizedValue === "warning" || value === "告警") { + return { label: value, tone: "warning" }; + } + + if (normalizedValue === "critical" || value === "严重") { + return { label: value, tone: "critical" }; + } + + return { label: value, tone: "inactive" }; +}