feat(map): refine SCADA feature experience
This commit is contained in:
@@ -3,19 +3,22 @@
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Database,
|
||||
MapPin,
|
||||
Radio,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
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 { 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 { SCADA_ICON_PATHS } from "../map/scada";
|
||||
import type { WaterNetworkSourceId } from "../map/sources";
|
||||
import type { DetailFeature } from "../types";
|
||||
import {
|
||||
formatFeaturePropertyValue,
|
||||
getFeaturePanelModel,
|
||||
type FeaturePanelBadgeTone
|
||||
} from "../utils/feature-properties";
|
||||
@@ -35,13 +38,20 @@ type FeaturePopoverProps = {
|
||||
|
||||
const FEATURE_LAYER_META: Record<
|
||||
WaterNetworkSourceId,
|
||||
{ icon: ComponentType<DrainageFeatureIconProps>; label: string }
|
||||
{
|
||||
icon: ComponentType<DrainageFeatureIconProps>;
|
||||
label: string;
|
||||
headerClassName: string;
|
||||
labelClassName: string;
|
||||
iconClassName: string;
|
||||
}
|
||||
> = {
|
||||
conduits: { icon: ConduitFeatureIcon, label: "管渠" },
|
||||
junctions: { icon: JunctionFeatureIcon, label: "检查井" },
|
||||
orifices: { icon: OrificeFeatureIcon, label: "孔口" },
|
||||
outfalls: { icon: OutfallFeatureIcon, label: "排放口" },
|
||||
pumps: { icon: PumpFeatureIcon, label: "泵" }
|
||||
conduits: { icon: ConduitFeatureIcon, label: "管渠", headerClassName: "bg-teal-50", labelClassName: "text-teal-700", iconClassName: "bg-teal-700 text-white ring-teal-900/10" },
|
||||
junctions: { icon: JunctionFeatureIcon, label: "检查井", headerClassName: "bg-sky-50", labelClassName: "text-sky-700", iconClassName: "bg-sky-700 text-white ring-sky-900/10" },
|
||||
orifices: { icon: OrificeFeatureIcon, label: "孔口", headerClassName: "bg-slate-100", labelClassName: "text-slate-700", iconClassName: "bg-slate-600 text-white ring-slate-900/10" },
|
||||
outfalls: { icon: OutfallFeatureIcon, label: "排放口", headerClassName: "bg-blue-50", labelClassName: "text-blue-800", iconClassName: "bg-slate-700 text-white ring-slate-900/10" },
|
||||
pumps: { icon: PumpFeatureIcon, label: "泵", headerClassName: "bg-cyan-50", labelClassName: "text-cyan-800", iconClassName: "bg-cyan-800 text-white ring-cyan-950/10" },
|
||||
scada: { icon: JunctionFeatureIcon, label: "SCADA 测点", headerClassName: "bg-blue-50", labelClassName: "text-blue-700", iconClassName: "bg-blue-700 text-white ring-blue-900/10" }
|
||||
};
|
||||
|
||||
const BADGE_CLASS_NAMES: Record<FeaturePanelBadgeTone, string> = {
|
||||
@@ -103,106 +113,81 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
|
||||
}
|
||||
};
|
||||
|
||||
if (feature.layer === "scada") {
|
||||
return (
|
||||
<ScadaFeaturePopover
|
||||
feature={feature}
|
||||
copied={copied}
|
||||
canCopy={canCopy}
|
||||
onCopy={() => void handleCopyId()}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"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",
|
||||
"pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))] -translate-x-1/2 overflow-hidden bg-white/94 shadow-2xl shadow-blue-950/20 ring-1 ring-white/80 backdrop-blur-xl",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
<header className={cn("px-4 pb-3 pt-4", layerMeta.headerClassName)}>
|
||||
<div className="grid grid-cols-[48px_minmax(0,1fr)_auto] items-start gap-3">
|
||||
<span className={cn("grid h-12 w-12 place-items-center rounded-full ring-1", layerMeta.iconClassName)} aria-hidden="true">
|
||||
<LayerIcon size={24} />
|
||||
</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 className="min-w-0">
|
||||
<div className="flex min-w-0 flex-nowrap items-center gap-1.5">
|
||||
<span className={cn("min-w-0 truncate text-xs font-semibold", layerMeta.labelClassName)}>{layerMeta.label}</span>
|
||||
{panelModel.badge ? (
|
||||
<span className={cn("inline-flex shrink-0 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>
|
||||
<h3 className="mt-1 truncate text-base font-semibold text-slate-950">{feature.title}</h3>
|
||||
<p className="mt-0.5 truncate text-xs text-slate-600 tabular-nums">编号 {panelModel.id}</p>
|
||||
</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"
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{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-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 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 type="button" aria-label="关闭要素信息" title="关闭要素信息" onClick={onClose} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25">
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭要素信息"
|
||||
title="关闭要素信息"
|
||||
onClick={onClose}
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{panelModel.attributes.length > 0 ? (
|
||||
<dl className="mt-2 border-y border-slate-200/80">
|
||||
{panelModel.attributes.map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
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 className="px-4 py-3">
|
||||
{panelModel.attributes.length > 0 ? (
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-500">
|
||||
<Database size={14} aria-hidden="true" />
|
||||
设施属性
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
<dl className="divide-y divide-slate-100 border-y border-slate-200/80">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="sr-only" aria-live="polite">
|
||||
{copied ? "编号已复制" : ""}
|
||||
@@ -210,3 +195,101 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
type ScadaFeaturePopoverProps = {
|
||||
feature: DetailFeature;
|
||||
copied: boolean;
|
||||
canCopy: boolean;
|
||||
onCopy: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: ScadaFeaturePopoverProps) {
|
||||
const properties = feature.properties;
|
||||
const presentation = getScadaPresentation(properties.device_type_id);
|
||||
const active = properties.active === true || String(properties.active).toLowerCase() === "true";
|
||||
const pointId = formatFeaturePropertyValue("point_external_id", properties.point_external_id ?? feature.id);
|
||||
const attributes = [
|
||||
["设备编号", formatFeaturePropertyValue("device_external_id", properties.device_external_id)],
|
||||
["关联检查井", formatFeaturePropertyValue("junction_id", properties.junction_id)],
|
||||
["同步时间", formatFeaturePropertyValue("synced_at", properties.synced_at)]
|
||||
];
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))] bg-white/94 shadow-2xl shadow-blue-950/20 ring-1 ring-white/80 backdrop-blur-xl",
|
||||
"-translate-x-1/2 overflow-hidden",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
<header className={cn("px-4 pb-3 pt-4", presentation.headerClassName)}>
|
||||
<div className="grid grid-cols-[48px_minmax(0,1fr)_auto] items-start gap-3">
|
||||
<Image src={presentation.iconPath} alt="" width={48} height={48} className="drop-shadow-sm" />
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-nowrap items-center gap-1.5">
|
||||
<span className={cn("min-w-0 truncate text-xs font-semibold", presentation.labelClassName)} title={formatFeaturePropertyValue("device_type_name", properties.device_type_name)}>
|
||||
{formatFeaturePropertyValue("device_type_name", properties.device_type_name)}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1.5 rounded-full bg-white/80 px-2 py-0.5 text-xs font-semibold ring-1 ring-inset",
|
||||
active ? "text-emerald-700 ring-emerald-600/20" : "text-slate-600 ring-slate-500/20"
|
||||
)}>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", active ? "bg-emerald-500" : "bg-slate-400")} />
|
||||
{active ? "在线" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-1 truncate text-base font-semibold text-slate-950">
|
||||
{formatFeaturePropertyValue("point_name", properties.point_name)}
|
||||
</h3>
|
||||
<p className="mt-0.5 truncate text-xs text-slate-600 tabular-nums">测点 {pointId}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{canCopy ? (
|
||||
<button type="button" aria-label={copied ? "测点编号已复制" : "复制测点编号"} title={copied ? "测点编号已复制" : "复制测点编号"} onClick={onCopy} className="relative grid h-10 w-10 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 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 type="button" aria-label="关闭 SCADA 测点信息" title="关闭 SCADA 测点信息" onClick={onClose} className="grid h-10 w-10 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25">
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="px-4 py-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-500">
|
||||
<Radio size={14} aria-hidden="true" />
|
||||
设备与关联信息
|
||||
</div>
|
||||
<dl className="divide-y divide-slate-100 border-y border-slate-200/80">
|
||||
{attributes.map(([label, value]) => (
|
||||
<div key={label} className="grid min-h-10 grid-cols-[92px_minmax(0,1fr)] items-baseline gap-3 py-2.5">
|
||||
<dt className="text-xs font-medium text-slate-500">{label}</dt>
|
||||
<dd className={cn("break-words text-right text-sm font-semibold text-slate-800 tabular-nums", value === "暂无" && "font-medium text-slate-400")}>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="mt-3 grid grid-cols-[18px_1fr] gap-2 rounded-xl bg-slate-50 px-3 py-2.5 text-xs leading-5 text-slate-600 ring-1 ring-inset ring-slate-200/70">
|
||||
<MapPin size={15} className="mt-0.5 text-slate-500" aria-hidden="true" />
|
||||
<p>当前点位由管网密度聚类生成,用于空间展示,不代表设备真实安装坐标。</p>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 px-1 text-xs text-slate-400">
|
||||
<Database size={13} aria-hidden="true" />
|
||||
当前图层仅提供设备元数据,暂未接入实时测值与历史曲线
|
||||
</div>
|
||||
</div>
|
||||
<span className="sr-only" aria-live="polite">{copied ? "测点编号已复制" : ""}</span>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function getScadaPresentation(rawTypeId: unknown) {
|
||||
const typeId = Number(rawTypeId);
|
||||
if (typeId === 72) return { iconPath: SCADA_ICON_PATHS.waterQuality, headerClassName: "bg-teal-50", labelClassName: "text-teal-700" };
|
||||
if (typeId === 58) return { iconPath: SCADA_ICON_PATHS.radarLevel, headerClassName: "bg-blue-50", labelClassName: "text-blue-700" };
|
||||
if (typeId === 45) return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, headerClassName: "bg-orange-50", labelClassName: "text-orange-700" };
|
||||
return { iconPath: SCADA_ICON_PATHS.unknown, headerClassName: "bg-slate-100", labelClassName: "text-slate-700" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user