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";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import {
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_RADIUS_CLASS_NAME
|
||||
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 { 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 = {
|
||||
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 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 (
|
||||
<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)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-blue-600">{feature.layer === "pipes" ? "管线要素" : "节点要素"}</p>
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭要素信息"
|
||||
title="关闭要素信息"
|
||||
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"
|
||||
<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"
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<LayerIcon size={20} />
|
||||
</span>
|
||||
|
||||
{entries.length > 0 ? (
|
||||
<dl className="mt-3 grid grid-cols-2 gap-2">
|
||||
{entries.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)}>
|
||||
<dt className="truncate text-xs font-medium text-slate-500">{entry.label}</dt>
|
||||
<dd className="mt-1 truncate text-sm font-semibold text-slate-800">{entry.value}</dd>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user