diff --git a/features/workbench/components/feature-insight-panel.tsx b/features/workbench/components/feature-insight-panel.tsx index 2a1b4f8..4a132be 100644 --- a/features/workbench/components/feature-insight-panel.tsx +++ b/features/workbench/components/feature-insight-panel.tsx @@ -9,8 +9,10 @@ import { SCADA_ICON_PATHS } from "../map/scada"; import type { DetailFeature } from "../types"; import { formatFeaturePropertyValue, + getFeatureInsightMetrics, getLocalizedFeatureProperties } from "../utils/feature-properties"; +import { FeaturePropertyList } from "./feature-property-list"; type FeatureInsightPanelProps = { feature: DetailFeature | null; @@ -32,50 +34,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro return []; } - switch (feature.layer) { - case "conduits": - return [ - ["管径", formatFeaturePropertyValue("diameter", feature.properties.diameter)], - ["长度", formatFeaturePropertyValue("length", feature.properties.length)], - ["粗糙系数", formatFeaturePropertyValue("roughness", feature.properties.roughness)], - ["状态", formatFeaturePropertyValue("status", feature.properties.status)] - ]; - case "junctions": - return [ - ["最大深度", formatFeaturePropertyValue("max_depth", feature.properties.max_depth)], - ["井底高程", formatFeaturePropertyValue("invert_elevation", feature.properties.invert_elevation)], - ["对象类型", "检查井"], - ["状态", "在线"] - ]; - case "orifices": - return [ - ["孔口类型", formatFeaturePropertyValue("orifice_type", feature.properties.orifice_type)], - ["断面形状", formatFeaturePropertyValue("shape", feature.properties.shape)], - ["流量系数", formatFeaturePropertyValue("discharge_coeff", feature.properties.discharge_coeff)], - ["闸门", formatFeaturePropertyValue("gated", feature.properties.gated)] - ]; - case "pumps": - return [ - ["状态", formatFeaturePropertyValue("status", feature.properties.status)], - ["泵曲线", formatFeaturePropertyValue("pump_curve", feature.properties.pump_curve)], - ["启动水深", formatFeaturePropertyValue("startup_depth", feature.properties.startup_depth)], - ["停泵水深", formatFeaturePropertyValue("shutoff_depth", feature.properties.shutoff_depth)] - ]; - case "outfalls": - return [ - ["排放类型", formatFeaturePropertyValue("outfall_type", feature.properties.outfall_type)], - ["井底高程", formatFeaturePropertyValue("invert_elevation", feature.properties.invert_elevation)], - ["阶段数据", formatFeaturePropertyValue("stage_data", feature.properties.stage_data)], - ["状态", "在线"] - ]; - case "scada": - return [ - ["设备类型", formatFeaturePropertyValue("kind", feature.properties.kind)], - ["运行状态", formatFeaturePropertyValue("active", feature.properties.active)], - ["关联检查井", formatFeaturePropertyValue("junction_id", feature.properties.junction_id)], - ["同步时间", formatFeaturePropertyValue("synced_at", feature.properties.synced_at)] - ]; - } + return getFeatureInsightMetrics(feature.layer, feature.properties); }, [feature]); const propertyEntries = useMemo(() => { @@ -83,14 +42,13 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro return []; } - const properties = feature.layer === "scada" && feature.properties.scada_id === undefined - ? { scada_id: feature.id, ...feature.properties } - : feature.properties; - return getLocalizedFeatureProperties(properties); + return feature.layer === "scada" + ? [] + : getLocalizedFeatureProperties(feature.properties); }, [feature]); const scadaQueryUuid = feature?.layer === "scada" - ? getCopyablePropertyValue(feature.properties.scada_id ?? feature.id) + ? getCopyablePropertyValue(feature.properties.sensor_id ?? feature.id) : null; const handleCopyScadaUuid = async () => { @@ -148,11 +106,23 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
{feature ? ( <> + {feature.layer === "scada" ? ( +

监测数据

+ ) : null}
- {metrics.map(([label, value]) => ( -
+ {metrics.map(({ label, value }, index) => ( +

{label}

-

{value}

+

{value}

))}
@@ -162,18 +132,18 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
-

{formatFeaturePropertyValue("name", feature.properties.name)}

-

{formatFeaturePropertyValue("external_id", feature.properties.external_id)}

+

{formatFeaturePropertyValue("sensor_name", feature.properties.sensor_name)}

+

{formatFeaturePropertyValue("sensor_id", feature.properties.sensor_id ?? feature.id)}

{scadaQueryUuid ? (
-

编号 UUID

+

传感器 ID

{scadaQueryUuid}

-

SCADA 时序查询 asset_ids

+

SCADA 时序查询 sensor_id

) : null} -

当前点位由管网节点空间均匀采样生成,仅用于空间展示,不代表设备真实安装坐标。图层暂未提供实时测值或历史曲线。

+

设备类型为综合监测点,数据源为 geo_scadas_mat。

) : null} @@ -199,20 +169,14 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro

-
-

属性

-
- {propertyEntries.map((entry) => ( -
-
{feature.layer === "scada" && entry.key === "scada_id" ? "编号 UUID" : entry.label}
-
{entry.value}
-
- ))} -
-
+ {feature.layer !== "scada" ? ( +
+

属性

+
+ +
+
+ ) : null} ) : (
@@ -248,5 +212,6 @@ function getScadaPresentation(rawKind: unknown) { if (kind === "water_quality") return { iconPath: SCADA_ICON_PATHS.waterQuality, sectionClassName: "material-tone-normal border-teal-200", labelClassName: "text-teal-800" }; if (kind === "radar_level") return { iconPath: SCADA_ICON_PATHS.radarLevel, sectionClassName: "material-tone-info border-blue-200", labelClassName: "text-blue-800" }; if (kind === "ultrasonic_flow") return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, sectionClassName: "material-tone-warning border-orange-200", labelClassName: "text-orange-800" }; - return { iconPath: SCADA_ICON_PATHS.unknown, sectionClassName: "surface-control border-transparent", labelClassName: "text-slate-800" }; + if (kind === "integrated_monitoring" || kind === "综合监测点") return { iconPath: SCADA_ICON_PATHS.integratedMonitoring, sectionClassName: "material-tone-normal border-cyan-200", labelClassName: "text-cyan-800" }; + return { iconPath: SCADA_ICON_PATHS.integratedMonitoring, sectionClassName: "material-tone-normal border-cyan-200", labelClassName: "text-cyan-800" }; } diff --git a/features/workbench/components/feature-popover.tsx b/features/workbench/components/feature-popover.tsx index d3b0514..2d41e4f 100644 --- a/features/workbench/components/feature-popover.tsx +++ b/features/workbench/components/feature-popover.tsx @@ -24,6 +24,7 @@ import { ScadaFeatureIcon, type DrainageFeatureIconProps } from "./drainage-feature-icons"; +import { FeaturePropertyList } from "./feature-property-list"; type FeaturePopoverProps = { feature: DetailFeature | null; @@ -156,16 +157,7 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
-
- {panelModel.attributes.map((entry) => ( -
-
{entry.label}
-
- {entry.value} -
-
- ))} -
+ ) : (

暂无可展示属性

diff --git a/features/workbench/components/feature-property-list.tsx b/features/workbench/components/feature-property-list.tsx new file mode 100644 index 0000000..fcc2843 --- /dev/null +++ b/features/workbench/components/feature-property-list.tsx @@ -0,0 +1,51 @@ +import { cn } from "@/lib/utils"; +import type { LocalizedFeatureProperty } from "../utils/feature-properties"; + +type FeaturePropertyListProps = { + entries: LocalizedFeatureProperty[]; + variant?: "insight" | "popover"; +}; + +export function FeaturePropertyList({ + entries, + variant = "insight" +}: FeaturePropertyListProps) { + if (entries.length === 0) { + return

暂无可展示属性

; + } + + return ( +
+ {entries.map((entry) => ( +
+
+ {entry.label} +
+
+ {entry.value} +
+
+ ))} +
+ ); +} diff --git a/features/workbench/utils/feature-properties.test.ts b/features/workbench/utils/feature-properties.test.ts index e24fb53..c8e0b0f 100644 --- a/features/workbench/utils/feature-properties.test.ts +++ b/features/workbench/utils/feature-properties.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { formatFeaturePropertyValue, getFeaturePanelModel, getFeaturePanelProperties, getLocalizedFeatureProperties } from "./feature-properties"; +import { + formatFeaturePropertyValue, + getFeatureInsightMetrics, + getFeaturePanelModel, + getFeaturePanelProperties, + getLocalizedFeatureProperties, + getScadaMetricProperties +} from "./feature-properties"; describe("feature panel properties", () => { it.each([ @@ -29,6 +36,22 @@ describe("feature panel properties", () => { ]); }); + it("builds insight metrics from the layer configuration", () => { + const metrics = getFeatureInsightMetrics("conduits", { + diameter: 0.6, + length: 42.5, + roughness: 0.013, + status: "OPEN" + }); + + expect(metrics).toEqual([ + { label: "管径", value: "600 mm" }, + { label: "长度", value: "42.50 m" }, + { label: "粗糙系数", value: "0.01" }, + { label: "状态", value: "开启" } + ]); + }); + it("localizes free outfall type", () => { const entries = getFeaturePanelProperties("outfalls", { id: "OF-1", @@ -69,27 +92,55 @@ describe("feature panel properties", () => { expect(model.attributes.map((entry) => entry.key)).toEqual(["invert_elevation", "max_depth"]); }); - it("shows SCADA identity, device, state, and sync fields", () => { + it("shows SCADA facility properties without monitoring readings", () => { const model = getFeaturePanelModel("scada", { - scada_id: "4bfa834b-cbbb-5f35-9523-7487ac021ed2", - site_external_id: "36455", - name: "DY22东市南街环城南路西段交叉口", - external_id: "26825171", - kind: "water_quality", - active: true, - junction_id: "31011502010001809", - synced_at: "Jul 15, 2026, 7:38:45 AM" + sensor_id: "SCADA-001", + sensor_name: "DY22东市南街环城南路西段交叉口", + kind: "integrated_monitoring", + swmm_node: "J-1024", + topology_order: 17, + upstream_node_count: 8, + distance_to_wwtp_m: 1260, + COD_mg_L: 23.45, + 氨氮_mg_L: 1.26, + 电导率_uS_cm: 746, + 液位_m: 2.18, + 流量_m3_s: 0.37 }); - expect(model.id).toBe("4bfa834b-cbbb-5f35-9523-7487ac021ed2"); - expect(model.badge).toEqual({ label: "在线", tone: "active" }); + expect(model.id).toBe("SCADA-001"); + expect(model.badge).toBeUndefined(); expect(model.attributes.map((entry) => entry.key)).toEqual([ - "site_external_id", - "name", - "external_id", "kind", - "junction_id", - "synced_at" + "swmm_node", + "topology_order", + "upstream_node_count", + "distance_to_wwtp_m" + ]); + expect(model.attributes.map((entry) => entry.value)).toEqual([ + "综合监测点", + "J-1024", + "17", + "8", + "1260 m" + ]); + }); + + it("formats the five integrated SCADA metrics with localized labels and units", () => { + const metrics = getScadaMetricProperties({ + cod_mg_l: 23.45, + 氨氮_mg_L: 1.26, + 电导率_uS_cm: 746, + 液位_m: 2.18, + 流量_m3_s: 0.37 + }); + + expect(metrics.map(({ label, value }) => [label, value])).toEqual([ + ["COD", "23.45 mg/L"], + ["氨氮", "1.26 mg/L"], + ["电导率", "746 μS/cm"], + ["液位", "2.18 m"], + ["流量", "0.37 m³/s"] ]); }); diff --git a/features/workbench/utils/feature-properties.ts b/features/workbench/utils/feature-properties.ts index aa5421d..46da02a 100644 --- a/features/workbench/utils/feature-properties.ts +++ b/features/workbench/utils/feature-properties.ts @@ -20,10 +20,30 @@ export type FeaturePanelModel = { attributes: LocalizedFeatureProperty[]; }; +export type FeatureInsightMetric = { + label: string; + value: string; +}; + const PROPERTY_LABELS: Record = { fid: "GeoServer 要素 ID", id: "编号", scada_id: "编号 UUID", + sensor_id: "传感器 ID", + sensor_name: "传感器名称", + swmm_node: "SWMM 节点", + topology_order: "拓扑序", + distance_to_wwtp_m: "距污水厂距离", + upstream_node_count: "上游节点数", + upstream_sensors: "上游传感器", + downstream_path: "下游路径", + x: "X 坐标", + y: "Y 坐标", + cod_mg_l: "COD", + 氨氮_mg_l: "氨氮", + 电导率_us_cm: "电导率", + 液位_m: "液位", + 流量_m3_s: "流量", gid: "要素编号", name: "名称", code: "编码", @@ -82,6 +102,19 @@ const PROPERTY_ORDER: Record = { fid: 9, id: 10, scada_id: 10, + sensor_id: 10, + sensor_name: 12, + swmm_node: 13, + topology_order: 14, + distance_to_wwtp_m: 15, + upstream_node_count: 16, + upstream_sensors: 17, + downstream_path: 18, + cod_mg_l: 20, + 氨氮_mg_l: 21, + 电导率_us_cm: 22, + 液位_m: 23, + 流量_m3_s: 24, gid: 11, name: 12, code: 13, @@ -135,19 +168,91 @@ const GATED_LABELS: Record = { "1": "是" }; +const SCADA_KIND_LABELS: Record = { + water_quality: "水质监测", + radar_level: "雷达液位", + ultrasonic_flow: "超声流量", + integrated_monitoring: "综合监测点" +}; + +export const SCADA_METRIC_PROPERTY_KEYS = [ + "COD_mg_L", + "氨氮_mg_L", + "电导率_uS_cm", + "液位_m", + "流量_m3_s" +] as const; + +const PROPERTY_UNITS: Record = { + cod_mg_l: "mg/L", + 氨氮_mg_l: "mg/L", + 电导率_us_cm: "μS/cm", + 液位_m: "m", + 流量_m3_s: "m³/s" +}; + 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"], - scada: ["scada_id", "site_external_id", "name", "external_id", "kind", "active", "junction_id", "synced_at"] + scada: [ + "sensor_id", + "kind", + "swmm_node", + "topology_order", + "upstream_node_count", + "distance_to_wwtp_m" + ] }; const FEATURE_PANEL_BADGE_KEYS: Partial> = { pumps: "status", orifices: "gated", - scada: "active" + scada: undefined +}; + +type FeatureInsightMetricDefinition = { + label: string; + key?: string; + value?: string; +}; + +const FEATURE_INSIGHT_METRICS: Record< + Exclude, + readonly FeatureInsightMetricDefinition[] +> = { + conduits: [ + { label: "管径", key: "diameter" }, + { label: "长度", key: "length" }, + { label: "粗糙系数", key: "roughness" }, + { label: "状态", key: "status" } + ], + junctions: [ + { label: "最大深度", key: "max_depth" }, + { label: "井底高程", key: "invert_elevation" }, + { label: "对象类型", value: "检查井" }, + { label: "状态", value: "在线" } + ], + orifices: [ + { label: "孔口类型", key: "orifice_type" }, + { label: "断面形状", key: "shape" }, + { label: "流量系数", key: "discharge_coeff" }, + { label: "闸门", key: "gated" } + ], + pumps: [ + { label: "状态", key: "status" }, + { label: "泵曲线", key: "pump_curve" }, + { label: "启动水深", key: "startup_depth" }, + { label: "停泵水深", key: "shutoff_depth" } + ], + outfalls: [ + { label: "排放类型", key: "outfall_type" }, + { label: "井底高程", key: "invert_elevation" }, + { label: "阶段数据", key: "stage_data" }, + { label: "状态", value: "在线" } + ] }; export function getLocalizedFeatureProperties(properties: Record): LocalizedFeatureProperty[] { @@ -164,7 +269,8 @@ export function getFeaturePanelProperties( featureId?: string ): LocalizedFeatureProperty[] { return FEATURE_PANEL_PROPERTY_KEYS[layer].map((key) => { - const value = key === "id" || key === "scada_id" ? properties[key] ?? featureId : properties[key]; + const propertyValue = getPropertyValue(properties, key); + const value = key === "id" || key === "scada_id" || key === "sensor_id" ? propertyValue ?? featureId : propertyValue; return { key, @@ -174,13 +280,39 @@ export function getFeaturePanelProperties( }); } +export function getScadaMetricProperties( + properties: Record +): LocalizedFeatureProperty[] { + return SCADA_METRIC_PROPERTY_KEYS.map((key) => ({ + key, + label: getFeaturePropertyLabel(key), + value: formatFeaturePropertyValue(key, getPropertyValue(properties, key)) + })); +} + +export function getFeatureInsightMetrics( + layer: WaterNetworkSourceId, + properties: Record +): FeatureInsightMetric[] { + if (layer === "scada") { + return getScadaMetricProperties(properties).map(({ label, value }) => ({ label, value })); + } + + return FEATURE_INSIGHT_METRICS[layer].map((metric) => ({ + label: metric.label, + value: metric.key + ? formatFeaturePropertyValue(metric.key, getPropertyValue(properties, metric.key)) + : metric.value ?? "暂无" + })); +} + export function getFeaturePanelModel( layer: WaterNetworkSourceId, properties: Record, featureId?: string ): FeaturePanelModel { const entries = getFeaturePanelProperties(layer, properties, featureId); - const idKey = layer === "scada" ? "scada_id" : "id"; + const idKey = layer === "scada" ? "sensor_id" : "id"; const id = entries.find((entry) => entry.key === idKey)?.value ?? "暂无"; const badgeKey = FEATURE_PANEL_BADGE_KEYS[layer]; const badgeEntry = badgeKey ? entries.find((entry) => entry.key === badgeKey) : undefined; @@ -224,6 +356,15 @@ export function formatFeaturePropertyValue(key: string, value: unknown) { return "空间均匀采样模拟位置"; } + if (normalizedKey === "kind" && typeof value === "string") { + return SCADA_KIND_LABELS[value] ?? value; + } + + const unit = PROPERTY_UNITS[normalizedKey]; + if (unit && value !== null && value !== undefined && value !== "") { + return `${formatValue(value)} ${unit}`; + } + if ((normalizedKey === "diameter" || normalizedKey === "dn") && value !== null && value !== undefined && value !== "") { const diameterInMeters = typeof value === "number" ? value : Number(value); return Number.isFinite(diameterInMeters) @@ -231,7 +372,7 @@ export function formatFeaturePropertyValue(key: string, value: unknown) { : formatValue(value); } - if (["length", "elevation", "invert_elevation", "max_depth", "startup_depth", "shutoff_depth"].includes(normalizedKey) + if (["length", "elevation", "invert_elevation", "max_depth", "startup_depth", "shutoff_depth", "distance_to_wwtp_m"].includes(normalizedKey) && value !== null && value !== undefined && value !== "") { return `${formatValue(value)} m`; } @@ -247,6 +388,16 @@ function normalizePropertyKey(key: string) { return key.trim().replaceAll("-", "_").toLowerCase(); } +function getPropertyValue(properties: Record, key: string) { + if (Object.prototype.hasOwnProperty.call(properties, key)) { + return properties[key]; + } + + const normalizedKey = normalizePropertyKey(key); + const matchingKey = Object.keys(properties).find((propertyKey) => normalizePropertyKey(propertyKey) === normalizedKey); + return matchingKey ? properties[matchingKey] : undefined; +} + function isStatusKey(key: string) { return normalizePropertyKey(key) === "status"; }