refactor: abstract feature property panels
This commit is contained in:
@@ -9,8 +9,10 @@ import { SCADA_ICON_PATHS } from "../map/scada";
|
|||||||
import type { DetailFeature } from "../types";
|
import type { DetailFeature } from "../types";
|
||||||
import {
|
import {
|
||||||
formatFeaturePropertyValue,
|
formatFeaturePropertyValue,
|
||||||
|
getFeatureInsightMetrics,
|
||||||
getLocalizedFeatureProperties
|
getLocalizedFeatureProperties
|
||||||
} from "../utils/feature-properties";
|
} from "../utils/feature-properties";
|
||||||
|
import { FeaturePropertyList } from "./feature-property-list";
|
||||||
|
|
||||||
type FeatureInsightPanelProps = {
|
type FeatureInsightPanelProps = {
|
||||||
feature: DetailFeature | null;
|
feature: DetailFeature | null;
|
||||||
@@ -32,50 +34,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (feature.layer) {
|
return getFeatureInsightMetrics(feature.layer, feature.properties);
|
||||||
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)]
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}, [feature]);
|
}, [feature]);
|
||||||
|
|
||||||
const propertyEntries = useMemo(() => {
|
const propertyEntries = useMemo(() => {
|
||||||
@@ -83,14 +42,13 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const properties = feature.layer === "scada" && feature.properties.scada_id === undefined
|
return feature.layer === "scada"
|
||||||
? { scada_id: feature.id, ...feature.properties }
|
? []
|
||||||
: feature.properties;
|
: getLocalizedFeatureProperties(feature.properties);
|
||||||
return getLocalizedFeatureProperties(properties);
|
|
||||||
}, [feature]);
|
}, [feature]);
|
||||||
|
|
||||||
const scadaQueryUuid = feature?.layer === "scada"
|
const scadaQueryUuid = feature?.layer === "scada"
|
||||||
? getCopyablePropertyValue(feature.properties.scada_id ?? feature.id)
|
? getCopyablePropertyValue(feature.properties.sensor_id ?? feature.id)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const handleCopyScadaUuid = async () => {
|
const handleCopyScadaUuid = async () => {
|
||||||
@@ -148,11 +106,23 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
|||||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||||
{feature ? (
|
{feature ? (
|
||||||
<>
|
<>
|
||||||
|
{feature.layer === "scada" ? (
|
||||||
|
<h3 className="mb-2 text-sm font-semibold text-slate-900">监测数据</h3>
|
||||||
|
) : null}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{metrics.map(([label, value]) => (
|
{metrics.map(({ label, value }, index) => (
|
||||||
<div key={label} className="surface-reading rounded border p-3">
|
<div
|
||||||
|
key={label}
|
||||||
|
className={cn(
|
||||||
|
"surface-reading rounded border p-3",
|
||||||
|
feature.layer === "scada" && index === metrics.length - 1 && "col-span-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<p className="text-xs text-slate-500">{label}</p>
|
<p className="text-xs text-slate-500">{label}</p>
|
||||||
<p className="mt-1 truncate text-sm font-semibold text-slate-900">{value}</p>
|
<p className={cn(
|
||||||
|
"mt-1 truncate text-sm font-semibold text-slate-900 tabular-nums",
|
||||||
|
value === "暂无" && "font-medium text-slate-400"
|
||||||
|
)}>{value}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -162,18 +132,18 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Image src={getScadaPresentation(feature.properties.kind).iconPath} alt="" width={42} height={42} />
|
<Image src={getScadaPresentation(feature.properties.kind).iconPath} alt="" width={42} height={42} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className={cn("truncate text-sm font-semibold", getScadaPresentation(feature.properties.kind).labelClassName)}>{formatFeaturePropertyValue("name", feature.properties.name)}</p>
|
<p className={cn("truncate text-sm font-semibold", getScadaPresentation(feature.properties.kind).labelClassName)}>{formatFeaturePropertyValue("sensor_name", feature.properties.sensor_name)}</p>
|
||||||
<p className="mt-0.5 truncate text-xs text-slate-500 tabular-nums">{formatFeaturePropertyValue("external_id", feature.properties.external_id)}</p>
|
<p className="mt-0.5 truncate text-xs text-slate-500 tabular-nums">{formatFeaturePropertyValue("sensor_id", feature.properties.sensor_id ?? feature.id)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{scadaQueryUuid ? (
|
{scadaQueryUuid ? (
|
||||||
<div className="mt-3 rounded border border-white/70 bg-white/65 px-3 py-2 shadow-sm">
|
<div className="mt-3 rounded border border-white/70 bg-white/65 px-3 py-2 shadow-sm">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="text-xs font-semibold text-slate-500">编号 UUID</p>
|
<p className="text-xs font-semibold text-slate-500">传感器 ID</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={copiedScadaUuid ? "编号 UUID 已复制" : "复制编号 UUID"}
|
aria-label={copiedScadaUuid ? "传感器 ID 已复制" : "复制传感器 ID"}
|
||||||
title={copiedScadaUuid ? "编号 UUID 已复制" : "复制编号 UUID"}
|
title={copiedScadaUuid ? "传感器 ID 已复制" : "复制传感器 ID"}
|
||||||
onClick={() => void handleCopyScadaUuid()}
|
onClick={() => void handleCopyScadaUuid()}
|
||||||
className="relative grid h-8 w-8 shrink-0 place-items-center rounded border border-slate-200/80 bg-white/80 text-slate-500 transition-[background-color,color,transform] duration-150 hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
className="relative grid h-8 w-8 shrink-0 place-items-center rounded border border-slate-200/80 bg-white/80 text-slate-500 transition-[background-color,color,transform] duration-150 hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
||||||
>
|
>
|
||||||
@@ -182,10 +152,10 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 break-all font-mono text-xs font-semibold leading-5 text-slate-900">{scadaQueryUuid}</p>
|
<p className="mt-1 break-all font-mono text-xs font-semibold leading-5 text-slate-900">{scadaQueryUuid}</p>
|
||||||
<p className="mt-1 text-xs leading-5 text-slate-500">SCADA 时序查询 asset_ids</p>
|
<p className="mt-1 text-xs leading-5 text-slate-500">SCADA 时序查询 sensor_id</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="mt-2 text-sm leading-6 text-slate-700">当前点位由管网节点空间均匀采样生成,仅用于空间展示,不代表设备真实安装坐标。图层暂未提供实时测值或历史曲线。</p>
|
<p className="mt-2 text-sm leading-6 text-slate-700">设备类型为综合监测点,数据源为 geo_scadas_mat。</p>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -199,20 +169,14 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-4">
|
{feature.layer !== "scada" ? (
|
||||||
<h3 className="text-sm font-semibold text-slate-900">属性</h3>
|
<section className="mt-4">
|
||||||
<dl className="surface-reading mt-2 divide-y divide-slate-100 rounded border">
|
<h3 className="text-sm font-semibold text-slate-900">属性</h3>
|
||||||
{propertyEntries.map((entry) => (
|
<div className="mt-2">
|
||||||
<div key={entry.key} className="grid grid-cols-[110px_1fr] gap-2 px-3 py-2 text-sm">
|
<FeaturePropertyList entries={propertyEntries} />
|
||||||
<dt className="truncate text-slate-500">{feature.layer === "scada" && entry.key === "scada_id" ? "编号 UUID" : entry.label}</dt>
|
</div>
|
||||||
<dd className={cn(
|
</section>
|
||||||
"font-medium text-slate-800",
|
) : null}
|
||||||
feature.layer === "scada" && entry.key === "scada_id" ? "break-all font-mono text-xs leading-5" : "truncate"
|
|
||||||
)}>{entry.value}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||||
@@ -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 === "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 === "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" };
|
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" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
ScadaFeatureIcon,
|
ScadaFeatureIcon,
|
||||||
type DrainageFeatureIconProps
|
type DrainageFeatureIconProps
|
||||||
} from "./drainage-feature-icons";
|
} from "./drainage-feature-icons";
|
||||||
|
import { FeaturePropertyList } from "./feature-property-list";
|
||||||
|
|
||||||
type FeaturePopoverProps = {
|
type FeaturePopoverProps = {
|
||||||
feature: DetailFeature | null;
|
feature: DetailFeature | null;
|
||||||
@@ -156,16 +157,7 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
|
|||||||
<Database size={14} aria-hidden="true" />
|
<Database size={14} aria-hidden="true" />
|
||||||
设施属性
|
设施属性
|
||||||
</div>
|
</div>
|
||||||
<dl className="surface-reading divide-y divide-slate-100 rounded-xl px-3">
|
<FeaturePropertyList entries={panelModel.attributes} variant="popover" />
|
||||||
{panelModel.attributes.map((entry) => (
|
|
||||||
<div key={entry.key} className="grid min-h-10 grid-cols-[96px_minmax(0,1fr)] items-baseline gap-3 py-2.5">
|
|
||||||
<dt className="text-xs font-medium leading-5 text-slate-500">{entry.label}</dt>
|
|
||||||
<dd className={cn("break-words text-right text-sm font-semibold leading-5 text-slate-800 tabular-nums", entry.value === "暂无" && "font-medium text-slate-400")}>
|
|
||||||
{entry.value}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="py-3 text-center text-sm text-slate-500">暂无可展示属性</p>
|
<p className="py-3 text-center text-sm text-slate-500">暂无可展示属性</p>
|
||||||
|
|||||||
@@ -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 <p className="py-3 text-center text-sm text-slate-500">暂无可展示属性</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dl className={cn(
|
||||||
|
"surface-reading divide-y divide-slate-100 border",
|
||||||
|
variant === "popover" ? "rounded-xl px-3" : "rounded"
|
||||||
|
)}>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.key}
|
||||||
|
className={cn(
|
||||||
|
"grid",
|
||||||
|
variant === "popover"
|
||||||
|
? "min-h-10 grid-cols-[96px_minmax(0,1fr)] items-baseline gap-3 py-2.5"
|
||||||
|
: "grid-cols-[110px_minmax(0,1fr)] gap-2 px-3 py-2 text-sm"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<dt className={cn(
|
||||||
|
"text-slate-500",
|
||||||
|
variant === "popover" ? "text-xs font-medium leading-5" : "truncate"
|
||||||
|
)}>
|
||||||
|
{entry.label}
|
||||||
|
</dt>
|
||||||
|
<dd className={cn(
|
||||||
|
"font-medium text-slate-800",
|
||||||
|
variant === "popover"
|
||||||
|
? "break-words text-right text-sm font-semibold leading-5 tabular-nums"
|
||||||
|
: "truncate",
|
||||||
|
entry.value === "暂无" && "font-medium text-slate-400"
|
||||||
|
)}>
|
||||||
|
{entry.value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("feature panel properties", () => {
|
||||||
it.each([
|
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", () => {
|
it("localizes free outfall type", () => {
|
||||||
const entries = getFeaturePanelProperties("outfalls", {
|
const entries = getFeaturePanelProperties("outfalls", {
|
||||||
id: "OF-1",
|
id: "OF-1",
|
||||||
@@ -69,27 +92,55 @@ describe("feature panel properties", () => {
|
|||||||
expect(model.attributes.map((entry) => entry.key)).toEqual(["invert_elevation", "max_depth"]);
|
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", {
|
const model = getFeaturePanelModel("scada", {
|
||||||
scada_id: "4bfa834b-cbbb-5f35-9523-7487ac021ed2",
|
sensor_id: "SCADA-001",
|
||||||
site_external_id: "36455",
|
sensor_name: "DY22东市南街环城南路西段交叉口",
|
||||||
name: "DY22东市南街环城南路西段交叉口",
|
kind: "integrated_monitoring",
|
||||||
external_id: "26825171",
|
swmm_node: "J-1024",
|
||||||
kind: "water_quality",
|
topology_order: 17,
|
||||||
active: true,
|
upstream_node_count: 8,
|
||||||
junction_id: "31011502010001809",
|
distance_to_wwtp_m: 1260,
|
||||||
synced_at: "Jul 15, 2026, 7:38:45 AM"
|
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.id).toBe("SCADA-001");
|
||||||
expect(model.badge).toEqual({ label: "在线", tone: "active" });
|
expect(model.badge).toBeUndefined();
|
||||||
expect(model.attributes.map((entry) => entry.key)).toEqual([
|
expect(model.attributes.map((entry) => entry.key)).toEqual([
|
||||||
"site_external_id",
|
|
||||||
"name",
|
|
||||||
"external_id",
|
|
||||||
"kind",
|
"kind",
|
||||||
"junction_id",
|
"swmm_node",
|
||||||
"synced_at"
|
"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"]
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,30 @@ export type FeaturePanelModel = {
|
|||||||
attributes: LocalizedFeatureProperty[];
|
attributes: LocalizedFeatureProperty[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FeatureInsightMetric = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
const PROPERTY_LABELS: Record<string, string> = {
|
const PROPERTY_LABELS: Record<string, string> = {
|
||||||
fid: "GeoServer 要素 ID",
|
fid: "GeoServer 要素 ID",
|
||||||
id: "编号",
|
id: "编号",
|
||||||
scada_id: "编号 UUID",
|
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: "要素编号",
|
gid: "要素编号",
|
||||||
name: "名称",
|
name: "名称",
|
||||||
code: "编码",
|
code: "编码",
|
||||||
@@ -82,6 +102,19 @@ const PROPERTY_ORDER: Record<string, number> = {
|
|||||||
fid: 9,
|
fid: 9,
|
||||||
id: 10,
|
id: 10,
|
||||||
scada_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,
|
gid: 11,
|
||||||
name: 12,
|
name: 12,
|
||||||
code: 13,
|
code: 13,
|
||||||
@@ -135,19 +168,91 @@ const GATED_LABELS: Record<string, string> = {
|
|||||||
"1": "是"
|
"1": "是"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SCADA_KIND_LABELS: Record<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<WaterNetworkSourceId, readonly string[]> = {
|
const FEATURE_PANEL_PROPERTY_KEYS: Record<WaterNetworkSourceId, readonly string[]> = {
|
||||||
pumps: ["id", "status", "startup_depth", "shutoff_depth"],
|
pumps: ["id", "status", "startup_depth", "shutoff_depth"],
|
||||||
conduits: ["id", "length", "diameter"],
|
conduits: ["id", "length", "diameter"],
|
||||||
junctions: ["id", "invert_elevation", "max_depth"],
|
junctions: ["id", "invert_elevation", "max_depth"],
|
||||||
orifices: ["id", "discharge_coeff", "gated", "shape", "geom1", "geom2"],
|
orifices: ["id", "discharge_coeff", "gated", "shape", "geom1", "geom2"],
|
||||||
outfalls: ["id", "invert_elevation", "outfall_type"],
|
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<Record<WaterNetworkSourceId, string>> = {
|
const FEATURE_PANEL_BADGE_KEYS: Partial<Record<WaterNetworkSourceId, string>> = {
|
||||||
pumps: "status",
|
pumps: "status",
|
||||||
orifices: "gated",
|
orifices: "gated",
|
||||||
scada: "active"
|
scada: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
type FeatureInsightMetricDefinition = {
|
||||||
|
label: string;
|
||||||
|
key?: string;
|
||||||
|
value?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FEATURE_INSIGHT_METRICS: Record<
|
||||||
|
Exclude<WaterNetworkSourceId, "scada">,
|
||||||
|
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<string, unknown>): LocalizedFeatureProperty[] {
|
export function getLocalizedFeatureProperties(properties: Record<string, unknown>): LocalizedFeatureProperty[] {
|
||||||
@@ -164,7 +269,8 @@ export function getFeaturePanelProperties(
|
|||||||
featureId?: string
|
featureId?: string
|
||||||
): LocalizedFeatureProperty[] {
|
): LocalizedFeatureProperty[] {
|
||||||
return FEATURE_PANEL_PROPERTY_KEYS[layer].map((key) => {
|
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 {
|
return {
|
||||||
key,
|
key,
|
||||||
@@ -174,13 +280,39 @@ export function getFeaturePanelProperties(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getScadaMetricProperties(
|
||||||
|
properties: Record<string, unknown>
|
||||||
|
): 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<string, unknown>
|
||||||
|
): 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(
|
export function getFeaturePanelModel(
|
||||||
layer: WaterNetworkSourceId,
|
layer: WaterNetworkSourceId,
|
||||||
properties: Record<string, unknown>,
|
properties: Record<string, unknown>,
|
||||||
featureId?: string
|
featureId?: string
|
||||||
): FeaturePanelModel {
|
): FeaturePanelModel {
|
||||||
const entries = getFeaturePanelProperties(layer, properties, featureId);
|
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 id = entries.find((entry) => entry.key === idKey)?.value ?? "暂无";
|
||||||
const badgeKey = FEATURE_PANEL_BADGE_KEYS[layer];
|
const badgeKey = FEATURE_PANEL_BADGE_KEYS[layer];
|
||||||
const badgeEntry = badgeKey ? entries.find((entry) => entry.key === badgeKey) : undefined;
|
const badgeEntry = badgeKey ? entries.find((entry) => entry.key === badgeKey) : undefined;
|
||||||
@@ -224,6 +356,15 @@ export function formatFeaturePropertyValue(key: string, value: unknown) {
|
|||||||
return "空间均匀采样模拟位置";
|
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 !== "") {
|
if ((normalizedKey === "diameter" || normalizedKey === "dn") && value !== null && value !== undefined && value !== "") {
|
||||||
const diameterInMeters = typeof value === "number" ? value : Number(value);
|
const diameterInMeters = typeof value === "number" ? value : Number(value);
|
||||||
return Number.isFinite(diameterInMeters)
|
return Number.isFinite(diameterInMeters)
|
||||||
@@ -231,7 +372,7 @@ export function formatFeaturePropertyValue(key: string, value: unknown) {
|
|||||||
: formatValue(value);
|
: 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 !== "") {
|
&& value !== null && value !== undefined && value !== "") {
|
||||||
return `${formatValue(value)} m`;
|
return `${formatValue(value)} m`;
|
||||||
}
|
}
|
||||||
@@ -247,6 +388,16 @@ function normalizePropertyKey(key: string) {
|
|||||||
return key.trim().replaceAll("-", "_").toLowerCase();
|
return key.trim().replaceAll("-", "_").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPropertyValue(properties: Record<string, unknown>, 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) {
|
function isStatusKey(key: string) {
|
||||||
return normalizePropertyKey(key) === "status";
|
return normalizePropertyKey(key) === "status";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user