Files
next-tjwater-drainage-frontend/features/workbench/components/feature-popover.tsx
T

213 lines
7.5 KiB
TypeScript

"use client";
import {
Check,
Copy,
X
} from "lucide-react";
import type { ComponentType } from "react";
import { useEffect, useRef, useState } from "react";
import {
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import { showMapNotice } from "@/features/map/core/components/notice";
import { cn } from "@/lib/utils";
import type { WaterNetworkSourceId } from "../map/sources";
import type { DetailFeature } from "../types";
import {
getFeaturePanelModel,
type FeaturePanelBadgeTone
} from "../utils/feature-properties";
import {
ConduitFeatureIcon,
JunctionFeatureIcon,
OrificeFeatureIcon,
OutfallFeatureIcon,
PumpFeatureIcon,
type DrainageFeatureIconProps
} from "./drainage-feature-icons";
type FeaturePopoverProps = {
feature: DetailFeature | null;
onClose: () => void;
};
const FEATURE_LAYER_META: Record<
WaterNetworkSourceId,
{ icon: ComponentType<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) {
const [copiedId, setCopiedId] = useState<string | null>(null);
const copyResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (copyResetTimeoutRef.current) {
clearTimeout(copyResetTimeoutRef.current);
}
}, []);
if (!feature) {
return null;
}
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 (
<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",
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>
<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>
<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
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>
</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>
))}
</dl>
) : null}
<span className="sr-only" aria-live="polite">
{copied ? "编号已复制" : ""}
</span>
</aside>
);
}