feat(workbench): refine feature detail popover
This commit is contained in:
@@ -0,0 +1,80 @@
|
|||||||
|
import type { ReactNode, SVGProps } from "react";
|
||||||
|
|
||||||
|
export type DrainageFeatureIconProps = SVGProps<SVGSVGElement> & {
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DrainageIconFrameProps = DrainageFeatureIconProps & {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DrainageIconFrame({ children, size = 20, ...props }: DrainageIconFrameProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
focusable="false"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConduitFeatureIcon(props: DrainageFeatureIconProps) {
|
||||||
|
return (
|
||||||
|
<DrainageIconFrame {...props}>
|
||||||
|
<circle cx="3.5" cy="12" r="1.75" />
|
||||||
|
<circle cx="20.5" cy="12" r="1.75" />
|
||||||
|
<path d="M5.25 9.5h13.5M5.25 14.5h13.5" />
|
||||||
|
</DrainageIconFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JunctionFeatureIcon(props: DrainageFeatureIconProps) {
|
||||||
|
return (
|
||||||
|
<DrainageIconFrame {...props}>
|
||||||
|
<circle cx="12" cy="12" r="5" />
|
||||||
|
<circle cx="12" cy="12" r="1.75" />
|
||||||
|
<path d="M12 2.5V7M2.5 12H7M17 12h4.5M12 17v4.5" />
|
||||||
|
</DrainageIconFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrificeFeatureIcon(props: DrainageFeatureIconProps) {
|
||||||
|
return (
|
||||||
|
<DrainageIconFrame {...props}>
|
||||||
|
<path d="M2.5 12H9M15 12h6.5" />
|
||||||
|
<path d="M9.5 4v5.35M9.5 14.65V20M14.5 4v5.35M14.5 14.65V20" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</DrainageIconFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PumpFeatureIcon(props: DrainageFeatureIconProps) {
|
||||||
|
return (
|
||||||
|
<DrainageIconFrame {...props}>
|
||||||
|
<circle cx="9.5" cy="13.5" r="6" />
|
||||||
|
<circle cx="9.5" cy="13.5" r="1.25" />
|
||||||
|
<path d="M9.5 12.25V8.5M10.75 13.5h3.75M8.65 14.4 6.2 16.85" />
|
||||||
|
<path d="M9.5 7.5V3.5h10v5M3.5 13.5H1.75" />
|
||||||
|
<path d="m17 6 2.5 2.5L22 6" />
|
||||||
|
</DrainageIconFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OutfallFeatureIcon(props: DrainageFeatureIconProps) {
|
||||||
|
return (
|
||||||
|
<DrainageIconFrame {...props}>
|
||||||
|
<path d="M4 4.5h10v7h5" />
|
||||||
|
<path d="m16.5 8.75 2.75 2.75-2.75 2.75" />
|
||||||
|
<path d="M3 18c1.5-1.25 3-1.25 4.5 0s3 1.25 4.5 0 3-1.25 4.5 0 3 1.25 4.5 0M5 21c1.15-.75 2.3-.75 3.45 0s2.3.75 3.45 0 2.3-.75 3.45 0 2.3.75 3.45 0" />
|
||||||
|
</DrainageIconFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,55 +1,212 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
Check,
|
||||||
MAP_READABLE_RADIUS_CLASS_NAME
|
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";
|
} from "@/features/map/core/components/map-control-styles";
|
||||||
|
import { showMapNotice } from "@/features/map/core/components/notice";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { WaterNetworkSourceId } from "../map/sources";
|
||||||
import type { DetailFeature } from "../types";
|
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 = {
|
type FeaturePopoverProps = {
|
||||||
feature: DetailFeature | null;
|
feature: DetailFeature | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FEATURE_LAYER_META: Record<
|
||||||
|
WaterNetworkSourceId,
|
||||||
|
{ icon: ComponentType<DrainageFeatureIconProps>; 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<FeaturePanelBadgeTone, string> = {
|
||||||
|
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<FeaturePanelBadgeTone, string> = {
|
||||||
|
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) {
|
export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
|
||||||
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
const copyResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (copyResetTimeoutRef.current) {
|
||||||
|
clearTimeout(copyResetTimeoutRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!feature) {
|
if (!feature) {
|
||||||
return null;
|
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 (
|
return (
|
||||||
<aside className={cn("pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(360px,calc(100vw-32px))] -translate-x-1/2 border border-white/60 bg-white/70 p-4 shadow-2xl shadow-blue-950/20 backdrop-blur-2xl", MAP_MAJOR_PANEL_RADIUS_CLASS_NAME)}>
|
<aside
|
||||||
<div className="flex items-start justify-between gap-3">
|
className={cn(
|
||||||
<div className="min-w-0">
|
"pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(360px,calc(100vw-24px))] -translate-x-1/2 bg-white/90 p-3 shadow-2xl shadow-blue-950/20 ring-1 ring-white/80 backdrop-blur-xl",
|
||||||
<p className="text-xs font-semibold text-blue-600">{feature.layer === "pipes" ? "管线要素" : "节点要素"}</p>
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||||
<h3 className="mt-1 truncate text-base font-semibold text-slate-950">{feature.title}</h3>
|
)}
|
||||||
<p className="mt-1 truncate text-sm text-slate-500">{feature.subtitle}</p>
|
>
|
||||||
|
<header className="grid grid-cols-[40px_minmax(0,1fr)_auto] items-center gap-2.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid h-10 w-10 place-items-center bg-blue-50 text-blue-700 ring-1 ring-blue-600/10",
|
||||||
|
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<LayerIcon size={20} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-h-5 flex-wrap items-center gap-1.5">
|
||||||
|
<p className="text-xs font-semibold text-blue-700">{layerMeta.label}</p>
|
||||||
|
{panelModel.badge ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-semibold leading-4 ring-1 ring-inset",
|
||||||
|
BADGE_CLASS_NAMES[panelModel.badge.tone]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn("h-1.5 w-1.5 rounded-full", BADGE_DOT_CLASS_NAMES[panelModel.badge.tone])}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{panelModel.badge.label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
<h3 className="mt-1 min-w-0 select-text break-all text-sm font-semibold leading-5 text-slate-950 tabular-nums">
|
||||||
|
{panelModel.id}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{canCopy ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={copied ? "编号已复制" : "复制编号"}
|
||||||
|
title={copied ? "编号已复制" : "复制编号"}
|
||||||
|
onClick={() => void handleCopyId()}
|
||||||
|
className="relative grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-400 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-slate-100 [@media(hover:hover)]:hover:text-blue-700 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
||||||
|
>
|
||||||
|
<Copy
|
||||||
|
size={15}
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"absolute transition-[opacity,transform] duration-[120ms]",
|
||||||
|
copied ? "scale-90 opacity-0" : "scale-100 opacity-100"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Check
|
||||||
|
size={16}
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"absolute text-emerald-600 transition-[opacity,transform] duration-[120ms]",
|
||||||
|
copied ? "scale-100 opacity-100" : "scale-90 opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="关闭要素信息"
|
aria-label="关闭要素信息"
|
||||||
title="关闭要素信息"
|
title="关闭要素信息"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="grid h-8 w-8 shrink-0 place-items-center rounded-full text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
className="grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-400 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-slate-100 [@media(hover:hover)]:hover:text-slate-900 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
||||||
>
|
>
|
||||||
<X size={16} aria-hidden="true" />
|
<X size={16} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
{entries.length > 0 ? (
|
{panelModel.attributes.length > 0 ? (
|
||||||
<dl className="mt-3 grid grid-cols-2 gap-2">
|
<dl className="mt-2 border-y border-slate-200/80">
|
||||||
{entries.map((entry) => (
|
{panelModel.attributes.map((entry) => (
|
||||||
<div key={entry.key} className={cn("bg-white/95 px-3 py-2 shadow-lg shadow-blue-950/10", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
<div
|
||||||
<dt className="truncate text-xs font-medium text-slate-500">{entry.label}</dt>
|
key={entry.key}
|
||||||
<dd className="mt-1 truncate text-sm font-semibold text-slate-800">{entry.value}</dd>
|
className="grid min-h-10 grid-cols-[96px_minmax(0,1fr)] items-baseline gap-3 border-b border-slate-200/70 px-1 py-2.5 last:border-b-0"
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<span className="sr-only" aria-live="polite">
|
||||||
|
{copied ? "编号已复制" : ""}
|
||||||
|
</span>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { WaterNetworkSourceId } from "../map/sources";
|
||||||
import { formatValue } from "./format-value";
|
import { formatValue } from "./format-value";
|
||||||
|
|
||||||
export type LocalizedFeatureProperty = {
|
export type LocalizedFeatureProperty = {
|
||||||
@@ -6,6 +7,19 @@ export type LocalizedFeatureProperty = {
|
|||||||
value: string;
|
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<string, string> = {
|
const PROPERTY_LABELS: Record<string, string> = {
|
||||||
id: "编号",
|
id: "编号",
|
||||||
gid: "要素编号",
|
gid: "要素编号",
|
||||||
@@ -20,6 +34,20 @@ const PROPERTY_LABELS: Record<string, string> = {
|
|||||||
dn: "管径",
|
dn: "管径",
|
||||||
length: "长度",
|
length: "长度",
|
||||||
roughness: "粗糙系数",
|
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: "状态",
|
status: "状态",
|
||||||
material: "材质",
|
material: "材质",
|
||||||
elevation: "高程",
|
elevation: "高程",
|
||||||
@@ -42,6 +70,12 @@ const PROPERTY_ORDER: Record<string, number> = {
|
|||||||
dn: 20,
|
dn: 20,
|
||||||
length: 21,
|
length: 21,
|
||||||
roughness: 22,
|
roughness: 22,
|
||||||
|
max_depth: 30,
|
||||||
|
invert_elevation: 31,
|
||||||
|
orifice_type: 32,
|
||||||
|
outfall_type: 32,
|
||||||
|
shape: 33,
|
||||||
|
pump_curve: 34,
|
||||||
material: 23,
|
material: 23,
|
||||||
node1: 24,
|
node1: 24,
|
||||||
from_node: 24,
|
from_node: 24,
|
||||||
@@ -56,6 +90,8 @@ const PROPERTY_ORDER: Record<string, number> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
on: "运行",
|
||||||
|
off: "停止",
|
||||||
active: "运行中",
|
active: "运行中",
|
||||||
inactive: "停用",
|
inactive: "停用",
|
||||||
online: "在线",
|
online: "在线",
|
||||||
@@ -67,6 +103,32 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
critical: "严重"
|
critical: "严重"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OUTFALL_TYPE_LABELS: Record<string, string> = {
|
||||||
|
free: "自由出流"
|
||||||
|
};
|
||||||
|
|
||||||
|
const GATED_LABELS: Record<string, string> = {
|
||||||
|
no: "否",
|
||||||
|
false: "否",
|
||||||
|
"0": "否",
|
||||||
|
yes: "是",
|
||||||
|
true: "是",
|
||||||
|
"1": "是"
|
||||||
|
};
|
||||||
|
|
||||||
|
const FEATURE_PANEL_PROPERTY_KEYS: Record<WaterNetworkSourceId, readonly string[]> = {
|
||||||
|
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<Record<WaterNetworkSourceId, string>> = {
|
||||||
|
pumps: "status",
|
||||||
|
orifices: "gated"
|
||||||
|
};
|
||||||
|
|
||||||
export function getLocalizedFeatureProperties(properties: Record<string, unknown>): LocalizedFeatureProperty[] {
|
export function getLocalizedFeatureProperties(properties: Record<string, unknown>): LocalizedFeatureProperty[] {
|
||||||
return Object.entries(properties).map(([key, value]) => ({
|
return Object.entries(properties).map(([key, value]) => ({
|
||||||
key,
|
key,
|
||||||
@@ -75,6 +137,39 @@ export function getLocalizedFeatureProperties(properties: Record<string, unknown
|
|||||||
})).sort((first, second) => getPropertyOrder(first.key) - getPropertyOrder(second.key));
|
})).sort((first, second) => getPropertyOrder(first.key) - getPropertyOrder(second.key));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getFeaturePanelProperties(
|
||||||
|
layer: WaterNetworkSourceId,
|
||||||
|
properties: Record<string, unknown>,
|
||||||
|
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<string, unknown>,
|
||||||
|
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) {
|
export function getFeaturePropertyLabel(key: string) {
|
||||||
const normalizedKey = normalizePropertyKey(key);
|
const normalizedKey = normalizePropertyKey(key);
|
||||||
return PROPERTY_LABELS[normalizedKey] ?? key;
|
return PROPERTY_LABELS[normalizedKey] ?? key;
|
||||||
@@ -87,11 +182,23 @@ export function formatFeaturePropertyValue(key: string, value: unknown) {
|
|||||||
return STATUS_LABELS[value.toLowerCase()] ?? value;
|
return STATUS_LABELS[value.toLowerCase()] ?? value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((normalizedKey === "diameter" || normalizedKey === "dn") && value !== null && value !== undefined && value !== "") {
|
if (normalizedKey === "outfall_type" && typeof value === "string") {
|
||||||
return `DN${formatValue(value)}`;
|
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`;
|
return `${formatValue(value)} m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,3 +216,36 @@ function normalizePropertyKey(key: string) {
|
|||||||
function isStatusKey(key: string) {
|
function isStatusKey(key: string) {
|
||||||
return normalizePropertyKey(key) === "status";
|
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" };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user