feat: initialize drainage network frontend
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Download, ListChecks, MapPinned, Plus, Share2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MapActionRow,
|
||||
MapPanelSection
|
||||
} from "./control-panel";
|
||||
import {
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
export type MapAnnotationItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon?: LucideIcon;
|
||||
status?: string;
|
||||
selected?: boolean;
|
||||
muted?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export type MapAnnotationShareAction = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon?: LucideIcon;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export type MapAnnotationPanelProps = {
|
||||
annotations: MapAnnotationItem[];
|
||||
shareActions?: MapAnnotationShareAction[];
|
||||
emptyText?: string;
|
||||
};
|
||||
|
||||
export function MapAnnotationPanel({
|
||||
annotations,
|
||||
shareActions = [],
|
||||
emptyText = "暂无标注"
|
||||
}: MapAnnotationPanelProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<MapPanelSection title="标注列表">
|
||||
{annotations.length > 0 ? (
|
||||
annotations.map((annotation) => (
|
||||
<MapActionRow
|
||||
key={annotation.id}
|
||||
icon={annotation.icon ?? getDefaultAnnotationIcon(annotation.status)}
|
||||
label={annotation.label}
|
||||
description={annotation.description}
|
||||
status={annotation.status}
|
||||
selected={annotation.selected}
|
||||
muted={annotation.muted}
|
||||
onClick={annotation.onClick}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className={cn("px-3 py-2 text-xs text-slate-500", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}>
|
||||
{emptyText}
|
||||
</div>
|
||||
)}
|
||||
</MapPanelSection>
|
||||
|
||||
{shareActions.length > 0 ? (
|
||||
<MapPanelSection title="共享">
|
||||
{shareActions.map((action) => (
|
||||
<MapActionRow
|
||||
key={action.id}
|
||||
icon={action.icon ?? getDefaultShareIcon(action.id)}
|
||||
label={action.label}
|
||||
description={action.description}
|
||||
onClick={action.onClick}
|
||||
/>
|
||||
))}
|
||||
</MapPanelSection>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultAnnotationIcon(status?: string) {
|
||||
if (status) {
|
||||
return ListChecks;
|
||||
}
|
||||
|
||||
return MapPinned;
|
||||
}
|
||||
|
||||
function getDefaultShareIcon(actionId: string) {
|
||||
if (actionId.includes("export") || actionId.includes("download")) {
|
||||
return Download;
|
||||
}
|
||||
|
||||
if (actionId.includes("share")) {
|
||||
return Share2;
|
||||
}
|
||||
|
||||
return Plus;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Map } from "lucide-react";
|
||||
import { type FocusEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
export type BaseLayerOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
swatchClassName?: string;
|
||||
};
|
||||
|
||||
type BaseLayersControlProps = {
|
||||
options: BaseLayerOption[];
|
||||
activeLayerId: string;
|
||||
onSelectLayer: (id: string) => void;
|
||||
title?: string;
|
||||
variant?: "floating" | "panel";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function BaseLayersControl({
|
||||
options,
|
||||
activeLayerId,
|
||||
onSelectLayer,
|
||||
title = "底图",
|
||||
variant = "floating",
|
||||
className = ""
|
||||
}: BaseLayersControlProps) {
|
||||
const activeLayer = options.find((option) => option.id === activeLayerId) ?? options[0];
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const closeTimerRef = useRef<number | null>(null);
|
||||
|
||||
const clearCloseTimer = useCallback(() => {
|
||||
if (closeTimerRef.current) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openControl = useCallback(() => {
|
||||
clearCloseTimer();
|
||||
setIsOpen(true);
|
||||
}, [clearCloseTimer]);
|
||||
|
||||
const scheduleClose = useCallback(() => {
|
||||
clearCloseTimer();
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
closeTimerRef.current = null;
|
||||
}, 260);
|
||||
}, [clearCloseTimer]);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(event: FocusEvent<HTMLElement>) => {
|
||||
if (event.currentTarget.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleClose();
|
||||
},
|
||||
[scheduleClose]
|
||||
);
|
||||
|
||||
useEffect(() => clearCloseTimer, [clearCloseTimer]);
|
||||
|
||||
if (variant === "panel") {
|
||||
return (
|
||||
<section
|
||||
aria-label={title}
|
||||
className={cn(
|
||||
"pointer-events-auto w-[276px] p-3",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||
<Map size={17} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
|
||||
<p className="mt-0.5 truncate text-xs text-slate-500">
|
||||
当前 {activeLayer?.label ?? "--"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{options.map((option) => {
|
||||
const active = option.id === activeLayerId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
aria-pressed={active}
|
||||
onClick={() => onSelectLayer(option.id)}
|
||||
className={cn(
|
||||
"flex min-h-[76px] flex-col p-1 text-left transition duration-150",
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
active
|
||||
? "border-blue-500 text-blue-700 shadow-sm ring-2 ring-blue-100"
|
||||
: "text-slate-700 hover:border-blue-300 hover:bg-blue-50/40",
|
||||
option.disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
>
|
||||
<BaseLayerSwatch option={option} active={active} />
|
||||
<span className="mt-1 block w-full truncate px-0.5 text-xs font-semibold leading-none">
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={title}
|
||||
onMouseEnter={openControl}
|
||||
onMouseLeave={scheduleClose}
|
||||
onFocus={openControl}
|
||||
onBlur={handleBlur}
|
||||
className={cn(
|
||||
"pointer-events-auto relative flex h-[96px] items-end justify-end",
|
||||
isOpen ? "w-[384px]" : "w-[96px]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-0 right-[104px] grid h-[96px] grid-cols-[repeat(3,84px)] gap-2 p-1.5 transition-opacity duration-150",
|
||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
isOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
|
||||
)}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const active = option.id === activeLayerId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
aria-pressed={active}
|
||||
onClick={() => onSelectLayer(option.id)}
|
||||
className={cn(
|
||||
"flex h-[84px] w-[84px] flex-col p-1 text-center transition duration-150",
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
active ? "border-blue-500 shadow-sm ring-2 ring-blue-100" : "border-slate-200 hover:border-blue-300 hover:bg-blue-50/40",
|
||||
option.disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
>
|
||||
<BaseLayerSwatch option={option} active={active} />
|
||||
<span
|
||||
data-base-layer-label="true"
|
||||
className="block h-4 shrink-0 truncate px-0.5 pt-1 text-xs font-semibold leading-none text-slate-800"
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={cn("absolute bottom-0 right-0 grid h-[96px] w-[96px] place-items-center p-1.5", MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME)}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`}
|
||||
title={`${title}:当前 ${activeLayer?.label ?? ""}`}
|
||||
className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-sm transition hover:border-blue-300 hover:text-blue-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
|
||||
>
|
||||
<span className={cn("relative h-full w-full overflow-hidden border border-white shadow-inner", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||
{activeLayer ? (
|
||||
<span className={cn("absolute inset-0", activeLayer.swatchClassName ?? "bg-slate-100")} />
|
||||
) : (
|
||||
<Map size={18} aria-hidden="true" className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
)}
|
||||
<span className="absolute bottom-1 left-1 right-1 rounded bg-white/95 px-1 py-0.5 text-xs font-semibold leading-none text-slate-800 shadow-sm">
|
||||
{activeLayer?.label ?? title}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type BaseLayerSwatchProps = {
|
||||
option: BaseLayerOption;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
function BaseLayerSwatch({ option, active }: BaseLayerSwatchProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"relative block min-h-0 flex-1 overflow-hidden border border-white shadow-inner",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
option.swatchClassName ?? "bg-slate-100"
|
||||
)}
|
||||
>
|
||||
{active ? (
|
||||
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white shadow-sm">
|
||||
<Check size={10} aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { ChevronRight, type LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_STRONG_CLASS_NAME,
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
export type MapControlPanelProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon: LucideIcon;
|
||||
widthClassName?: string;
|
||||
visible?: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function MapControlPanel({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
widthClassName = "w-[292px]",
|
||||
visible = true,
|
||||
children
|
||||
}: MapControlPanelProps) {
|
||||
return (
|
||||
<section
|
||||
aria-label={title}
|
||||
aria-hidden={!visible}
|
||||
className={cn(
|
||||
"relative origin-top-right p-3 transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none",
|
||||
visible
|
||||
? "pointer-events-auto opacity-100 translate-x-0 scale-100 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-right-2 motion-safe:zoom-in-95 motion-safe:duration-150 motion-safe:ease-out"
|
||||
: "pointer-events-none translate-x-2 scale-[0.98] opacity-0",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
widthClassName
|
||||
)}
|
||||
>
|
||||
<div className={cn("relative mb-3 flex items-center gap-2.5 px-2.5 py-2", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}>
|
||||
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-100 text-blue-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||
<Icon size={17} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
|
||||
{description ? <p className="mt-0.5 truncate text-xs text-slate-500">{description}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative space-y-3">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapPanelSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-1.5 px-1 text-xs font-semibold text-slate-500">{title}</div>
|
||||
<div className="space-y-1.5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export type MapModeButtonProps = {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export function MapModeButton({ icon: Icon, label, active = false, disabled = false, onClick }: MapModeButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex h-14 flex-col items-center justify-center gap-1 text-xs font-semibold transition duration-150",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
active ? "bg-blue-600 text-white shadow-sm" : "bg-white/95 text-slate-600 hover:text-blue-700",
|
||||
disabled && "cursor-not-allowed opacity-55 hover:text-slate-600"
|
||||
)}
|
||||
>
|
||||
<Icon size={17} aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapActionChip({
|
||||
icon: Icon,
|
||||
label,
|
||||
disabled = false,
|
||||
onClick
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex h-10 items-center justify-center gap-1.5 text-xs font-semibold text-slate-700 transition duration-150 hover:border-blue-200 hover:text-blue-700",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
disabled && "cursor-not-allowed opacity-55 hover:border-white/70 hover:text-slate-700"
|
||||
)}
|
||||
>
|
||||
<Icon size={14} aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export type MapActionRowProps = {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
description: string;
|
||||
status?: string;
|
||||
strong?: boolean;
|
||||
variant?: "default" | "primary";
|
||||
selected?: boolean;
|
||||
muted?: boolean;
|
||||
disabled?: boolean;
|
||||
tone?: "default" | "danger";
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export function MapActionRow({
|
||||
icon: Icon,
|
||||
label,
|
||||
description,
|
||||
status,
|
||||
strong = false,
|
||||
variant = "default",
|
||||
selected = false,
|
||||
muted = false,
|
||||
disabled = false,
|
||||
tone = "default",
|
||||
onClick
|
||||
}: MapActionRowProps) {
|
||||
const isDanger = tone === "danger";
|
||||
const isPrimary = variant === "primary";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex min-h-12 w-full items-center gap-2.5 border px-2.5 py-2 text-left transition duration-150 active:translate-y-px active:scale-[0.99]",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
isPrimary
|
||||
? "border-blue-600 bg-blue-600 text-white shadow-lg shadow-blue-600/25 hover:bg-blue-700"
|
||||
: strong
|
||||
? "border-blue-100 bg-blue-50/95 text-blue-800"
|
||||
: "border-white/70 bg-white/95 text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700",
|
||||
isDanger && "hover:border-red-100 hover:text-red-700",
|
||||
selected && !isPrimary && "border-blue-200 bg-blue-50 text-blue-800",
|
||||
muted && "opacity-70",
|
||||
disabled &&
|
||||
(isPrimary
|
||||
? "cursor-not-allowed opacity-55 hover:border-blue-600 hover:bg-blue-600 hover:text-white"
|
||||
: "cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-white/95 hover:text-slate-700"),
|
||||
disabled && "active:translate-y-0 active:scale-100"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
isPrimary
|
||||
? "bg-white/16 text-white"
|
||||
: selected || strong
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-slate-100 text-slate-500",
|
||||
isDanger && "bg-red-50 text-red-600"
|
||||
)}
|
||||
>
|
||||
<Icon size={15} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold">{label}</span>
|
||||
<span className={cn("block truncate text-xs", isPrimary ? "text-blue-100" : "text-slate-500")}>{description}</span>
|
||||
</span>
|
||||
{status ? (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold",
|
||||
isPrimary ? "bg-white/15 text-white" : "bg-slate-100 text-slate-500"
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
) : (
|
||||
<ChevronRight size={15} aria-hidden="true" className={cn("shrink-0", isPrimary ? "text-blue-100" : "text-slate-400")} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapMetricTile({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className={cn("px-2.5 py-2", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
|
||||
<div className="text-xs text-slate-500">{label}</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-slate-900">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@/shared/ui/tooltip";
|
||||
import {
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
export type MapDrawTool = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
tone?: "default" | "danger";
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export type MapDrawToolbarProps = {
|
||||
items: MapDrawTool[];
|
||||
label?: string;
|
||||
className?: string;
|
||||
variant?: "compact" | "panel";
|
||||
};
|
||||
|
||||
export function MapDrawToolbar({ items, label = "绘制工具", className = "", variant = "compact" }: MapDrawToolbarProps) {
|
||||
if (variant === "panel") {
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label={label}
|
||||
className={cn("pointer-events-auto grid grid-cols-2 gap-1.5", className)}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isDanger = item.tone === "danger";
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-pressed={item.active}
|
||||
disabled={item.disabled}
|
||||
onClick={item.onClick}
|
||||
className={cn(
|
||||
"flex min-h-10 items-center gap-2 border px-2.5 py-2 text-left text-xs font-semibold transition duration-150",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
item.active
|
||||
? "border-blue-600 bg-blue-600 text-white shadow-md shadow-blue-600/20"
|
||||
: "text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700",
|
||||
!item.active && MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
isDanger && "hover:border-red-100 hover:text-red-700",
|
||||
item.disabled &&
|
||||
"cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-white/95 hover:text-slate-700"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-7 w-7 shrink-0 place-items-center",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
item.active ? "bg-white/16 text-white" : "bg-slate-100 text-slate-500",
|
||||
isDanger && "bg-red-50 text-red-600"
|
||||
)}
|
||||
>
|
||||
<Icon size={15} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={180}>
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"pointer-events-auto inline-flex p-1",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isDanger = item.tone === "danger";
|
||||
|
||||
return (
|
||||
<Tooltip key={item.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={item.label}
|
||||
aria-pressed={item.active}
|
||||
disabled={item.disabled}
|
||||
onClick={item.onClick}
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center text-slate-600 transition duration-150",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
|
||||
item.active
|
||||
? "bg-blue-600 text-white shadow-sm"
|
||||
: "hover:bg-blue-50 hover:text-blue-700",
|
||||
isDanger && "hover:bg-red-50 hover:text-red-700",
|
||||
item.disabled && "cursor-not-allowed opacity-45 hover:bg-transparent hover:text-slate-600"
|
||||
)}
|
||||
>
|
||||
<Icon size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
sideOffset={7}
|
||||
className="border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
|
||||
>
|
||||
{item.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Eye, EyeOff, Layers3, Lock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
export type MapLayerControlItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
visible: boolean;
|
||||
disabled?: boolean;
|
||||
locked?: boolean;
|
||||
};
|
||||
|
||||
type MapLayerControlProps = {
|
||||
title?: string;
|
||||
items: MapLayerControlItem[];
|
||||
onToggleLayer: (id: string, visible: boolean) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function MapLayerControl({
|
||||
title = "图层",
|
||||
items,
|
||||
onToggleLayer,
|
||||
className = ""
|
||||
}: MapLayerControlProps) {
|
||||
return (
|
||||
<section
|
||||
aria-label={title}
|
||||
className={cn(
|
||||
"pointer-events-auto w-[276px] p-3",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 px-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
|
||||
<Layers3 size={17} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
{items.filter((item) => item.visible).length}/{items.length} 可见
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-1.5">
|
||||
{items.map((item) => {
|
||||
const Icon = item.visible ? Eye : EyeOff;
|
||||
const disabled = item.disabled || item.locked;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={item.visible}
|
||||
onClick={() => onToggleLayer(item.id, !item.visible)}
|
||||
className={cn(
|
||||
"flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm transition duration-150",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
item.visible
|
||||
? "border-blue-100 bg-white/95 text-slate-900 shadow-md shadow-blue-950/10"
|
||||
: "border-white/70 bg-white/95 text-slate-500 hover:border-slate-200 hover:bg-white",
|
||||
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
item.visible ? "bg-blue-600 text-white" : "bg-slate-100 text-slate-400"
|
||||
)}
|
||||
>
|
||||
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold">{item.label}</span>
|
||||
{item.description ? <span className="block truncate text-xs text-slate-500">{item.description}</span> : null}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 rounded-full",
|
||||
item.visible ? "bg-blue-600" : "bg-slate-300"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Check, Eye, EyeOff, Lock, Map } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BaseLayerOption } from "./base-layers-control";
|
||||
import type { MapLayerControlItem } from "./layer-control";
|
||||
import {
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
export type MapLayerPanelProps = {
|
||||
layerItems: MapLayerControlItem[];
|
||||
baseLayerOptions: BaseLayerOption[];
|
||||
activeBaseLayerId: string;
|
||||
onSelectBaseLayer: (id: string) => void;
|
||||
onToggleLayer: (id: string, visible: boolean) => void;
|
||||
};
|
||||
|
||||
export function MapLayerPanel({
|
||||
layerItems,
|
||||
baseLayerOptions,
|
||||
activeBaseLayerId,
|
||||
onSelectBaseLayer,
|
||||
onToggleLayer
|
||||
}: MapLayerPanelProps) {
|
||||
return (
|
||||
<>
|
||||
<LayerVisibilitySection items={layerItems} onToggleLayer={onToggleLayer} />
|
||||
<BaseLayerSection
|
||||
options={baseLayerOptions}
|
||||
activeLayerId={activeBaseLayerId}
|
||||
onSelectLayer={onSelectBaseLayer}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type LayerVisibilitySectionProps = {
|
||||
items: MapLayerControlItem[];
|
||||
onToggleLayer: (id: string, visible: boolean) => void;
|
||||
};
|
||||
|
||||
function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySectionProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1.5 px-1 text-xs font-semibold text-slate-500">业务图层</div>
|
||||
<div className="space-y-1.5">
|
||||
{items.map((item) => {
|
||||
const Icon = item.visible ? Eye : EyeOff;
|
||||
const disabled = item.disabled || item.locked;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={item.visible}
|
||||
onClick={() => onToggleLayer(item.id, !item.visible)}
|
||||
className={cn(
|
||||
"flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm transition duration-150",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
item.visible
|
||||
? "border-blue-100 bg-white/95 text-slate-900"
|
||||
: "border-white/70 bg-white/95 text-slate-500 hover:border-slate-200 hover:bg-white",
|
||||
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
item.visible ? "bg-blue-600 text-white" : "bg-slate-100 text-slate-400"
|
||||
)}
|
||||
>
|
||||
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold">{item.label}</span>
|
||||
{item.description ? <span className="block truncate text-xs text-slate-500">{item.description}</span> : null}
|
||||
</span>
|
||||
<span
|
||||
className={cn("h-2.5 w-2.5 rounded-full", item.visible ? "bg-blue-600" : "bg-slate-300")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type BaseLayerSectionProps = {
|
||||
options: BaseLayerOption[];
|
||||
activeLayerId: string;
|
||||
onSelectLayer: (id: string) => void;
|
||||
};
|
||||
|
||||
function BaseLayerSection({ options, activeLayerId, onSelectLayer }: BaseLayerSectionProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between px-1">
|
||||
<span className="text-xs font-semibold text-slate-500">底图</span>
|
||||
<Map size={14} aria-hidden="true" className="text-slate-400" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{options.map((option) => {
|
||||
const active = option.id === activeLayerId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
aria-pressed={active}
|
||||
onClick={() => onSelectLayer(option.id)}
|
||||
className={cn(
|
||||
"flex min-h-[76px] flex-col p-1 text-left transition duration-150",
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
active ? MAP_READABLE_SURFACE_STRONG_CLASS_NAME : MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
active
|
||||
? "border-blue-500 text-blue-700 ring-2 ring-blue-100"
|
||||
: "text-slate-700 hover:border-blue-300 hover:bg-blue-50/40",
|
||||
option.disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
>
|
||||
<BaseLayerSwatch option={option} active={active} />
|
||||
<span className="mt-1 block w-full truncate px-0.5 text-xs font-semibold leading-none">
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type BaseLayerSwatchProps = {
|
||||
option: BaseLayerOption;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
function BaseLayerSwatch({ option, active }: BaseLayerSwatchProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"relative block min-h-0 flex-1 overflow-hidden border border-white shadow-inner",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
option.swatchClassName ?? "bg-slate-100"
|
||||
)}
|
||||
>
|
||||
{active ? (
|
||||
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white">
|
||||
<Check size={10} aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
export type MapLegendItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
color: string;
|
||||
icon?: LucideIcon;
|
||||
shape?: "line" | "dot" | "square";
|
||||
};
|
||||
|
||||
type MapLegendProps = {
|
||||
title?: string;
|
||||
items: MapLegendItem[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function MapLegend({ title = "图例", items, className = "" }: MapLegendProps) {
|
||||
return (
|
||||
<section
|
||||
aria-label={title}
|
||||
className={cn(
|
||||
"pointer-events-auto w-[236px] p-3",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
|
||||
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<h2 className="px-1 text-sm font-semibold leading-tight text-slate-950">{title}</h2>
|
||||
<p className="mt-0.5 px-1 text-xs text-slate-500">业务图层语义</p>
|
||||
<MapLegendList items={items} className="mt-3" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type MapLegendListProps = {
|
||||
items: MapLegendItem[];
|
||||
className?: string;
|
||||
showStatusDot?: boolean;
|
||||
};
|
||||
|
||||
export function MapLegendList({ items, className = "", showStatusDot = false }: MapLegendListProps) {
|
||||
return (
|
||||
<ul className={cn("space-y-1.5", className)}>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
className={cn("flex min-h-10 items-center gap-2.5 px-2.5 py-2 text-sm text-slate-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
|
||||
>
|
||||
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
|
||||
{Icon ? <Icon size={15} style={{ color: item.color }} /> : <MapLegendSwatch color={item.color} shape={item.shape} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold">{item.label}</span>
|
||||
{item.description ? <span className="block truncate text-xs text-slate-500">{item.description}</span> : null}
|
||||
</span>
|
||||
{showStatusDot ? <span className="h-2 w-2 rounded-full bg-emerald-500" aria-hidden="true" /> : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapLegendSwatch({ color, shape = "dot" }: Pick<MapLegendItem, "color" | "shape">) {
|
||||
if (shape === "line") {
|
||||
return <span className="h-1 w-6 rounded-full" style={{ backgroundColor: color }} />;
|
||||
}
|
||||
|
||||
if (shape === "square") {
|
||||
return <span className="h-4 w-4 rounded-sm" style={{ backgroundColor: color }} />;
|
||||
}
|
||||
|
||||
return <span className="h-4 w-4 rounded-full ring-2 ring-white" style={{ backgroundColor: color }} />;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export const MAP_MAJOR_PANEL_RADIUS_CLASS_NAME = "rounded-2xl";
|
||||
|
||||
export const MAP_CONTROL_RADIUS_CLASS_NAME = "rounded-xl";
|
||||
|
||||
export const MAP_READABLE_RADIUS_CLASS_NAME = "rounded-xl";
|
||||
|
||||
export const MAP_COMPACT_RADIUS_CLASS_NAME = "rounded-xl";
|
||||
|
||||
export const MAP_ICON_CELL_RADIUS_CLASS_NAME = "rounded-lg";
|
||||
|
||||
export const MAP_CONTROL_SURFACE_CLASS_NAME =
|
||||
"border border-white/75 bg-white/95 shadow-lg shadow-slate-900/20 ring-1 ring-slate-900/5 backdrop-blur-xl";
|
||||
|
||||
export const MAP_TOOL_PANEL_SURFACE_CLASS_NAME =
|
||||
"border border-white/70 bg-white/80 shadow-2xl shadow-blue-950/20 backdrop-blur-xl";
|
||||
|
||||
export const MAP_READABLE_SURFACE_CLASS_NAME =
|
||||
"border border-white/70 bg-white/95";
|
||||
|
||||
export const MAP_READABLE_SURFACE_STRONG_CLASS_NAME =
|
||||
"border border-white/80 bg-white/95";
|
||||
|
||||
export const MAP_CONTROL_HOVER_CLASS_NAME =
|
||||
"hover:bg-blue-100/70 hover:text-blue-700 hover:ring-1 hover:ring-blue-200/80 active:bg-blue-100";
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Copy, Ruler, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MapActionRow,
|
||||
MapModeButton,
|
||||
MapPanelSection
|
||||
} from "./control-panel";
|
||||
import {
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_CLASS_NAME,
|
||||
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
export type MapMeasureMode = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type MapMeasureUnit = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type MapMeasureResultMetric = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type MapMeasureAction = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon?: LucideIcon;
|
||||
strong?: boolean;
|
||||
variant?: "default" | "primary";
|
||||
tone?: "default" | "danger";
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export type MapMeasurePanelProps = {
|
||||
modes: MapMeasureMode[];
|
||||
activeModeId: string;
|
||||
resultLabel: string;
|
||||
resultValue: string;
|
||||
resultUnit: string;
|
||||
metrics?: MapMeasureResultMetric[];
|
||||
units: MapMeasureUnit[];
|
||||
activeUnitId: string;
|
||||
actions: MapMeasureAction[];
|
||||
onSelectMode?: (id: string) => void;
|
||||
onSelectUnit?: (id: string) => void;
|
||||
};
|
||||
|
||||
export function MapMeasurePanel({
|
||||
modes,
|
||||
activeModeId,
|
||||
resultLabel,
|
||||
resultValue,
|
||||
resultUnit,
|
||||
metrics = [],
|
||||
units,
|
||||
activeUnitId,
|
||||
actions,
|
||||
onSelectMode,
|
||||
onSelectUnit
|
||||
}: MapMeasurePanelProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<MapPanelSection title="模式">
|
||||
<div className={cn("grid grid-cols-3 gap-1.5 border border-white/60 bg-white/80 p-1.5", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||
{modes.map((mode) => (
|
||||
<MapModeButton
|
||||
key={mode.id}
|
||||
icon={mode.icon}
|
||||
label={mode.label}
|
||||
active={mode.id === activeModeId}
|
||||
disabled={mode.disabled}
|
||||
onClick={() => onSelectMode?.(mode.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</MapPanelSection>
|
||||
|
||||
<MapPanelSection title="结果">
|
||||
<div className={cn("border border-blue-100 bg-blue-50/95 p-2", MAP_READABLE_RADIUS_CLASS_NAME)}>
|
||||
<div className={cn("p-3", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
|
||||
<div className="min-w-0 border-b border-slate-100 pb-3">
|
||||
<div className="text-xs font-semibold text-blue-700">{resultLabel}</div>
|
||||
<div className="mt-1 flex min-w-0 items-baseline gap-1.5 text-slate-950">
|
||||
<span className="truncate text-2xl font-semibold leading-none">{resultValue}</span>
|
||||
<span className="shrink-0 text-xs font-semibold text-slate-500">{resultUnit}</span>
|
||||
</div>
|
||||
</div>
|
||||
{metrics.length > 0 ? (
|
||||
<div className="mt-2 grid grid-cols-[repeat(auto-fit,minmax(92px,1fr))] gap-1.5">
|
||||
{metrics.map((metric) => (
|
||||
<div
|
||||
key={metric.label}
|
||||
className={cn("min-w-0 px-2.5 py-1.5 text-left", MAP_COMPACT_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
|
||||
>
|
||||
<div className="truncate text-xs text-slate-500">{metric.label}</div>
|
||||
<div className="mt-0.5 truncate text-sm font-semibold text-slate-900">{metric.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-[repeat(auto-fit,minmax(0,1fr))] gap-1.5">
|
||||
{units.map((unit) => (
|
||||
<button
|
||||
key={unit.id}
|
||||
type="button"
|
||||
onClick={() => onSelectUnit?.(unit.id)}
|
||||
className={cn(
|
||||
"h-7 text-xs font-semibold transition",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
unit.id === activeUnitId
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-white/95 text-slate-600 hover:text-blue-700"
|
||||
)}
|
||||
>
|
||||
{unit.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</MapPanelSection>
|
||||
|
||||
<MapPanelSection title="操作">
|
||||
{actions.map((action) => (
|
||||
<MapActionRow
|
||||
key={action.id}
|
||||
icon={action.icon ?? getDefaultActionIcon(action.id)}
|
||||
label={action.label}
|
||||
description={action.description}
|
||||
strong={action.strong}
|
||||
variant={action.variant}
|
||||
tone={action.tone}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
/>
|
||||
))}
|
||||
</MapPanelSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultActionIcon(actionId: string) {
|
||||
if (actionId.includes("copy")) {
|
||||
return Copy;
|
||||
}
|
||||
|
||||
if (actionId.includes("clear") || actionId.includes("delete")) {
|
||||
return Trash2;
|
||||
}
|
||||
|
||||
return Ruler;
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { toast, Toaster } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_READABLE_RADIUS_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
export type MapNoticeTone = "info" | "success" | "warning" | "error" | "loading";
|
||||
export type MapNoticePosition = "top-center" | "top-right" | "bottom-left" | "bottom-right";
|
||||
|
||||
export type MapNoticeOptions = {
|
||||
id?: string | number;
|
||||
title?: string;
|
||||
message: string;
|
||||
tone?: MapNoticeTone;
|
||||
duration?: number;
|
||||
position?: MapNoticePosition;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
dismissible?: boolean;
|
||||
};
|
||||
|
||||
type SonnerPosition = NonNullable<ComponentProps<typeof Toaster>["position"]>;
|
||||
|
||||
const DEFAULT_POSITION: MapNoticePosition = "top-center";
|
||||
|
||||
const positionClassNames: Record<MapNoticePosition, string> = {
|
||||
"top-center": "top-[84px]",
|
||||
"top-right": "top-[84px] right-[72px]",
|
||||
"bottom-left": "bottom-12 left-4",
|
||||
"bottom-right": "bottom-12 right-4"
|
||||
};
|
||||
|
||||
const sonnerPositions: Record<MapNoticePosition, SonnerPosition> = {
|
||||
"top-center": "top-center",
|
||||
"top-right": "top-right",
|
||||
"bottom-left": "bottom-left",
|
||||
"bottom-right": "bottom-right"
|
||||
};
|
||||
|
||||
const toneClassNames: Record<MapNoticeTone, string> = {
|
||||
info: "border-white/80 bg-white/95 text-slate-900",
|
||||
success: "border-white/80 bg-white/95 text-slate-900",
|
||||
warning: "border-white/80 bg-white/95 text-slate-900",
|
||||
error: "border-white/80 bg-white/95 text-slate-900",
|
||||
loading: "border-white/80 bg-white/95 text-slate-900"
|
||||
};
|
||||
|
||||
const iconClassNames: Record<MapNoticeTone, string> = {
|
||||
info: "text-blue-600",
|
||||
success: "text-green-600",
|
||||
warning: "text-orange-500",
|
||||
error: "text-red-500",
|
||||
loading: "text-blue-600"
|
||||
};
|
||||
|
||||
const noticeAccentClassNames: Record<MapNoticeTone, string> = {
|
||||
info: "bg-blue-600",
|
||||
success: "bg-green-500",
|
||||
warning: "bg-orange-500",
|
||||
error: "bg-red-500",
|
||||
loading: "bg-blue-600"
|
||||
};
|
||||
|
||||
const noticeIconSurfaceClassNames: Record<MapNoticeTone, string> = {
|
||||
info: "border-blue-100 bg-blue-50",
|
||||
success: "border-green-100 bg-green-50",
|
||||
warning: "border-orange-100 bg-orange-50",
|
||||
error: "border-red-100 bg-red-50",
|
||||
loading: "border-blue-100 bg-blue-50"
|
||||
};
|
||||
|
||||
export function MapToaster() {
|
||||
return (
|
||||
<Toaster
|
||||
closeButton={false}
|
||||
expand
|
||||
visibleToasts={4}
|
||||
position={sonnerPositions[DEFAULT_POSITION]}
|
||||
toastOptions={{
|
||||
unstyled: true,
|
||||
classNames: {
|
||||
toast: "group pointer-events-auto",
|
||||
closeButton:
|
||||
"absolute right-2 top-2 grid h-6 w-6 place-items-center rounded-lg border border-transparent text-slate-400 transition hover:bg-white/70 hover:text-slate-700"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function showMapNotice(options: MapNoticeOptions) {
|
||||
const tone = options.tone ?? "info";
|
||||
const position = options.position ?? DEFAULT_POSITION;
|
||||
const duration = options.duration ?? (tone === "info" || tone === "success" ? 3200 : Infinity);
|
||||
const dismissible = options.dismissible ?? tone !== "loading";
|
||||
|
||||
return toast.custom(
|
||||
() => <MapNoticeContent {...options} tone={tone} />,
|
||||
{
|
||||
id: options.id,
|
||||
duration,
|
||||
position: sonnerPositions[position],
|
||||
dismissible,
|
||||
cancel: dismissible
|
||||
? {
|
||||
label: <MapNoticeCloseIcon />,
|
||||
onClick: (event) => event.stopPropagation()
|
||||
}
|
||||
: undefined,
|
||||
classNames: {
|
||||
cancelButton: cn(
|
||||
"absolute right-2 top-2 z-10 grid h-7 w-7 place-items-center border border-transparent bg-transparent p-0 text-slate-400 transition hover:border-slate-200 hover:bg-slate-50 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||
)
|
||||
},
|
||||
className: cn("!fixed !z-[60]", positionClassNames[position])
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function dismissMapNotice(id?: string | number) {
|
||||
toast.dismiss(id);
|
||||
}
|
||||
|
||||
export function MapErrorNotice({ message, id = "map-error" }: { message: string; id?: string | number }) {
|
||||
useEffect(() => {
|
||||
showMapNotice({
|
||||
id,
|
||||
tone: "error",
|
||||
title: "地图异常",
|
||||
message,
|
||||
duration: Infinity
|
||||
});
|
||||
|
||||
return () => dismissMapNotice(id);
|
||||
}, [id, message]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function MapLoadingNotice({
|
||||
message = "正在初始化地图...",
|
||||
title = "地图加载中",
|
||||
id = "map-loading"
|
||||
}: {
|
||||
message?: string;
|
||||
title?: string;
|
||||
id?: string | number;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
showMapNotice({
|
||||
id,
|
||||
tone: "loading",
|
||||
title,
|
||||
message,
|
||||
duration: Infinity,
|
||||
dismissible: false
|
||||
});
|
||||
|
||||
return () => dismissMapNotice(id);
|
||||
}, [id, message, title]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export type MapSourceStatus = "online" | "degraded" | "offline";
|
||||
|
||||
export type MapSourceStatusNoticeProps = {
|
||||
sourceName: string;
|
||||
status: MapSourceStatus;
|
||||
message?: string;
|
||||
id?: string | number;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
};
|
||||
|
||||
export function MapSourceStatusNotice({
|
||||
sourceName,
|
||||
status,
|
||||
message,
|
||||
id = `map-source-${sourceName}`,
|
||||
actionLabel,
|
||||
onAction
|
||||
}: MapSourceStatusNoticeProps) {
|
||||
useEffect(() => {
|
||||
showMapNotice({
|
||||
id,
|
||||
tone: getSourceStatusTone(status),
|
||||
title: getSourceStatusTitle(sourceName, status),
|
||||
message: message ?? getSourceStatusMessage(status),
|
||||
duration: status === "online" ? 2600 : Infinity,
|
||||
actionLabel,
|
||||
onAction
|
||||
});
|
||||
|
||||
return () => dismissMapNotice(id);
|
||||
}, [actionLabel, id, message, onAction, sourceName, status]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function MapNoticeContent({
|
||||
title,
|
||||
message,
|
||||
tone = "info",
|
||||
actionLabel,
|
||||
onAction
|
||||
}: MapNoticeOptions) {
|
||||
const Icon = getNoticeIcon(tone);
|
||||
|
||||
return (
|
||||
<MapNoticeFrame tone={tone}>
|
||||
<MapNoticeIcon tone={tone} icon={Icon} />
|
||||
<MapNoticeBody
|
||||
title={title}
|
||||
message={message}
|
||||
action={actionLabel && onAction ? <MapNoticeAction label={actionLabel} onClick={onAction} /> : null}
|
||||
/>
|
||||
</MapNoticeFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
role={tone === "error" || tone === "warning" ? "alert" : "status"}
|
||||
className={cn(
|
||||
"relative flex w-[min(420px,calc(100vw-24px))] items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm shadow-lg shadow-slate-900/20 backdrop-blur-xl",
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
toneClassNames[tone]
|
||||
)}
|
||||
>
|
||||
<span className={cn("absolute bottom-0 left-0 top-0 w-1", noticeAccentClassNames[tone])} aria-hidden="true" />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MapNoticeIcon({ tone, icon: Icon }: { tone: MapNoticeTone; icon: ReturnType<typeof getNoticeIcon> }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 grid h-7 w-7 shrink-0 place-items-center border",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
iconClassNames[tone],
|
||||
noticeIconSurfaceClassNames[tone]
|
||||
)}
|
||||
>
|
||||
<Icon size={17} aria-hidden="true" className={tone === "loading" ? "animate-spin" : undefined} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MapNoticeBody({
|
||||
title,
|
||||
message,
|
||||
action
|
||||
}: {
|
||||
title?: string;
|
||||
message: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span className="min-w-0 flex-1">
|
||||
{title ? <span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span> : null}
|
||||
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>{message}</span>
|
||||
{action}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MapNoticeAction({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn("mt-2 inline-flex h-7 items-center justify-center border border-blue-100 bg-blue-50 px-2.5 text-xs font-semibold text-blue-700 transition hover:bg-blue-100", MAP_COMPACT_RADIUS_CLASS_NAME)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MapNoticeCloseIcon() {
|
||||
return (
|
||||
<>
|
||||
<span className="sr-only">关闭通知</span>
|
||||
<X size={15} aria-hidden="true" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getNoticeIcon(tone: MapNoticeTone) {
|
||||
if (tone === "success") {
|
||||
return CheckCircle2;
|
||||
}
|
||||
|
||||
if (tone === "warning") {
|
||||
return AlertTriangle;
|
||||
}
|
||||
|
||||
if (tone === "error") {
|
||||
return XCircle;
|
||||
}
|
||||
|
||||
if (tone === "loading") {
|
||||
return Loader2;
|
||||
}
|
||||
|
||||
return Info;
|
||||
}
|
||||
|
||||
function getSourceStatusTone(status: MapSourceStatus): MapNoticeTone {
|
||||
if (status === "online") {
|
||||
return "success";
|
||||
}
|
||||
|
||||
if (status === "degraded") {
|
||||
return "warning";
|
||||
}
|
||||
|
||||
return "error";
|
||||
}
|
||||
|
||||
function getSourceStatusTitle(sourceName: string, status: MapSourceStatus) {
|
||||
if (status === "online") {
|
||||
return `${sourceName} 已连接`;
|
||||
}
|
||||
|
||||
if (status === "degraded") {
|
||||
return `${sourceName} 降级运行`;
|
||||
}
|
||||
|
||||
return `${sourceName} 不可用`;
|
||||
}
|
||||
|
||||
function getSourceStatusMessage(status: MapSourceStatus) {
|
||||
if (status === "online") {
|
||||
return "地图数据源已恢复正常。";
|
||||
}
|
||||
|
||||
if (status === "degraded") {
|
||||
return "部分地图能力不可用,系统已使用可用替代方案。";
|
||||
}
|
||||
|
||||
return "地图数据源请求失败,请检查网络或稍后重试。";
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { type RefObject, useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MAP_CONTROL_SURFACE_CLASS_NAME } from "./map-control-styles";
|
||||
|
||||
type ScaleLineState = {
|
||||
label: string;
|
||||
width: number;
|
||||
zoom: string;
|
||||
};
|
||||
|
||||
type MapScaleLineProps = {
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
maxWidth?: number;
|
||||
coordinatePrecision?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type CoordinateState = {
|
||||
lng: number;
|
||||
lat: number;
|
||||
};
|
||||
|
||||
const DEFAULT_SCALE: ScaleLineState = {
|
||||
label: "500 m",
|
||||
width: 80,
|
||||
zoom: "Z --"
|
||||
};
|
||||
|
||||
const MIN_SCALE_WIDTH = 34;
|
||||
const COORDINATE_READOUT_WIDTH_CLASS_NAME = "w-[160px]";
|
||||
|
||||
export function MapScaleLine({
|
||||
mapRef,
|
||||
mapReady,
|
||||
maxWidth = 96,
|
||||
coordinatePrecision = 5,
|
||||
className = ""
|
||||
}: MapScaleLineProps) {
|
||||
const [scale, setScale] = useState<ScaleLineState>(DEFAULT_SCALE);
|
||||
const [coordinate, setCoordinate] = useState<CoordinateState | null>(null);
|
||||
const coordinateReadout = coordinate
|
||||
? createCoordinateReadout(coordinate, coordinatePrecision)
|
||||
: null;
|
||||
const coordinateLabel = coordinateReadout
|
||||
? `${coordinateReadout.mercatorText} · ${coordinateReadout.lngLatText}`
|
||||
: "移动鼠标读取坐标";
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateScale = () => {
|
||||
setScale(createScaleLineState(map, maxWidth));
|
||||
};
|
||||
|
||||
updateScale();
|
||||
map.on("move", updateScale);
|
||||
map.on("zoom", updateScale);
|
||||
map.on("resize", updateScale);
|
||||
|
||||
return () => {
|
||||
map.off("move", updateScale);
|
||||
map.off("zoom", updateScale);
|
||||
map.off("resize", updateScale);
|
||||
};
|
||||
}, [mapReady, mapRef, maxWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMove = (event: { lngLat: { lng: number; lat: number } }) => {
|
||||
setCoordinate({
|
||||
lng: event.lngLat.lng,
|
||||
lat: event.lngLat.lat
|
||||
});
|
||||
};
|
||||
|
||||
const handleLeave = () => {
|
||||
setCoordinate(null);
|
||||
};
|
||||
|
||||
map.on("mousemove", handleMove);
|
||||
map.on("mouseout", handleLeave);
|
||||
|
||||
return () => {
|
||||
map.off("mousemove", handleMove);
|
||||
map.off("mouseout", handleLeave);
|
||||
};
|
||||
}, [mapReady, mapRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={`比例尺 ${scale.label},${coordinateLabel}`}
|
||||
className={cn(
|
||||
"pointer-events-none inline-flex w-auto max-w-full select-none items-center gap-2.5 overflow-hidden rounded-none rounded-tl-xl border-b-0 border-r-0 px-2.5 py-1.5 text-xs font-semibold leading-none text-slate-800",
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="min-w-11 whitespace-nowrap text-right tabular-nums">{scale.label}</span>
|
||||
<div
|
||||
className="relative h-2 shrink-0 border-x-2 border-b-2 border-slate-800 transition-[width,border-color] duration-200 ease-out motion-reduce:transition-none"
|
||||
style={{ width: `${scale.width}px` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScaleLineDivider />
|
||||
|
||||
<div className="flex min-w-0 items-center justify-end gap-2.5 overflow-hidden whitespace-nowrap text-slate-700">
|
||||
<span className="tabular-nums text-slate-800">{scale.zoom}</span>
|
||||
<span className="text-slate-600">EPSG:3857</span>
|
||||
{coordinateReadout ? (
|
||||
<span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-right tabular-nums text-slate-800")} title={coordinateReadout.lngLatText}>
|
||||
{coordinateReadout.mercatorText}
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-right tabular-nums")}>{coordinateLabel}</span>
|
||||
)}
|
||||
<span className="text-slate-500">Mapbox/OSM</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScaleLineDivider() {
|
||||
return (
|
||||
<div
|
||||
className="h-3.5 w-px shrink-0 bg-slate-300/70"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function createScaleLineState(map: MapLibreMap, maxWidth: number): ScaleLineState {
|
||||
const canvas = map.getCanvas();
|
||||
const canvasWidth = canvas.clientWidth || canvas.width;
|
||||
const center = map.project(map.getCenter());
|
||||
const halfWidth = Math.min(maxWidth / 2, Math.max(1, canvasWidth / 2));
|
||||
const y = Math.round(center.y);
|
||||
const start = map.unproject([Math.max(0, center.x - halfWidth), y]);
|
||||
const end = map.unproject([Math.min(canvasWidth, center.x + halfWidth), y]);
|
||||
const measuredWidth = Math.max(1, Math.min(canvasWidth, center.x + halfWidth) - Math.max(0, center.x - halfWidth));
|
||||
const metersPerMaxWidth = distanceInMeters(start.lng, start.lat, end.lng, end.lat);
|
||||
if (!Number.isFinite(metersPerMaxWidth) || metersPerMaxWidth <= 0) {
|
||||
return {
|
||||
...DEFAULT_SCALE,
|
||||
zoom: `Z ${map.getZoom().toFixed(1)}`
|
||||
};
|
||||
}
|
||||
|
||||
const niceMeters = getNiceDistance(metersPerMaxWidth);
|
||||
const widthPx = Math.min(
|
||||
measuredWidth,
|
||||
Math.max(Math.min(MIN_SCALE_WIDTH, measuredWidth), Math.round((niceMeters / metersPerMaxWidth) * measuredWidth))
|
||||
);
|
||||
|
||||
return {
|
||||
label: formatDistance(niceMeters),
|
||||
width: widthPx,
|
||||
zoom: `Z ${map.getZoom().toFixed(1)}`
|
||||
};
|
||||
}
|
||||
|
||||
function getNiceDistance(maxMeters: number) {
|
||||
const target = Math.max(1, maxMeters);
|
||||
const magnitude = 10 ** Math.floor(Math.log10(target));
|
||||
const normalized = target / magnitude;
|
||||
const multiplier = normalized >= 5 ? 5 : normalized >= 2 ? 2 : 1;
|
||||
|
||||
return multiplier * magnitude;
|
||||
}
|
||||
|
||||
function formatDistance(meters: number) {
|
||||
if (meters >= 1000) {
|
||||
return `${Number((meters / 1000).toFixed(meters >= 10000 ? 0 : 1))} km`;
|
||||
}
|
||||
|
||||
return `${Math.round(meters)} m`;
|
||||
}
|
||||
|
||||
function createCoordinateReadout(coordinate: CoordinateState, precision: number) {
|
||||
return {
|
||||
lngLatText: `Lng ${coordinate.lng.toFixed(precision)} Lat ${coordinate.lat.toFixed(precision)}`,
|
||||
mercatorText: formatWebMercator(coordinate.lng, coordinate.lat)
|
||||
};
|
||||
}
|
||||
|
||||
function formatWebMercator(lng: number, lat: number) {
|
||||
const earthRadius = 6378137;
|
||||
const clampedLat = Math.max(-85.05112878, Math.min(85.05112878, lat));
|
||||
const x = earthRadius * toRadians(lng);
|
||||
const y = earthRadius * Math.log(Math.tan(Math.PI / 4 + toRadians(clampedLat) / 2));
|
||||
|
||||
return `X ${Math.round(x).toLocaleString("en-US")} Y ${Math.round(y).toLocaleString("en-US")}`;
|
||||
}
|
||||
|
||||
function distanceInMeters(startLng: number, startLat: number, endLng: number, endLat: number) {
|
||||
const earthRadius = 6371008.8;
|
||||
const startPhi = toRadians(startLat);
|
||||
const endPhi = toRadians(endLat);
|
||||
const deltaPhi = toRadians(endLat - startLat);
|
||||
const deltaLambda = toRadians(endLng - startLng);
|
||||
const a =
|
||||
Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
|
||||
Math.cos(startPhi) * Math.cos(endPhi) * Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2);
|
||||
|
||||
return 2 * earthRadius * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
function toRadians(degrees: number) {
|
||||
return (degrees * Math.PI) / 180;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@/shared/ui/tooltip";
|
||||
import {
|
||||
MAP_CONTROL_HOVER_CLASS_NAME,
|
||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
export type MapToolbarItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon: LucideIcon;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
badge?: string | number;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type MapToolbarProps = {
|
||||
items: MapToolbarItem[];
|
||||
label?: string;
|
||||
orientation?: "vertical" | "horizontal";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function MapToolbar({
|
||||
items,
|
||||
label = "地图工具",
|
||||
orientation = "vertical",
|
||||
className = ""
|
||||
}: MapToolbarProps) {
|
||||
const isVertical = orientation === "vertical";
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={180}>
|
||||
<nav
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"pointer-events-auto p-[3px]",
|
||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
isVertical ? "flex w-[46px] flex-col" : "inline-flex",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{items.map((tool, index) => {
|
||||
const Icon = tool.icon;
|
||||
const title = tool.description ? `${tool.label}:${tool.description}` : tool.label;
|
||||
|
||||
return (
|
||||
<div key={tool.id} className={cn(isVertical ? "contents" : "flex items-center")}>
|
||||
{index > 0 ? <ToolbarDivider vertical={isVertical} /> : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={title}
|
||||
aria-pressed={tool.active}
|
||||
disabled={tool.disabled}
|
||||
onClick={tool.onClick}
|
||||
className={cn(
|
||||
"relative grid h-[38px] w-[38px] shrink-0 place-items-center text-slate-700 transition duration-150",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
"focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
|
||||
tool.active
|
||||
? "bg-blue-50 text-blue-800 ring-1 ring-blue-300/70 hover:bg-blue-100 hover:text-blue-900"
|
||||
: MAP_CONTROL_HOVER_CLASS_NAME,
|
||||
tool.disabled && "cursor-not-allowed text-slate-400 opacity-60 hover:bg-transparent hover:text-slate-400 hover:ring-0 active:bg-transparent"
|
||||
)}
|
||||
>
|
||||
<Icon size={20} strokeWidth={2.15} aria-hidden="true" />
|
||||
<span className="sr-only">{tool.label}</span>
|
||||
{tool.badge ? (
|
||||
<span className="absolute -right-1 -top-1 grid min-w-4 place-items-center rounded-full bg-red-500 px-1 text-xs font-semibold leading-4 text-white ring-2 ring-white">
|
||||
{tool.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side={isVertical ? "left" : "top"}
|
||||
sideOffset={8}
|
||||
className="border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
|
||||
>
|
||||
{title}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarDivider({ vertical }: { vertical: boolean }) {
|
||||
if (vertical) {
|
||||
return (
|
||||
<div
|
||||
className="my-0.5 flex h-px w-full items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="block h-px w-3.5 bg-slate-300/70" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mx-0.5 flex h-full w-px items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="block h-3.5 w-px bg-slate-300/70" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { Home, Minus, Plus, type LucideIcon } from "lucide-react";
|
||||
import { type RefObject, useCallback } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@/shared/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
MAP_CONTROL_HOVER_CLASS_NAME,
|
||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME
|
||||
} from "./map-control-styles";
|
||||
|
||||
type MapZoomProps = {
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
onHome: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomProps) {
|
||||
const handleZoomIn = useCallback(() => {
|
||||
mapRef.current?.zoomIn({ duration: 260 });
|
||||
}, [mapRef]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
mapRef.current?.zoomOut({ duration: 260 });
|
||||
}, [mapRef]);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={180}>
|
||||
<div
|
||||
aria-label="地图缩放"
|
||||
className={cn(
|
||||
"pointer-events-auto flex w-[46px] flex-col p-[3px]",
|
||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ControlButton
|
||||
icon={Plus}
|
||||
label="放大"
|
||||
disabled={!mapReady}
|
||||
onClick={handleZoomIn}
|
||||
/>
|
||||
<Divider />
|
||||
<ControlButton
|
||||
icon={Minus}
|
||||
label="缩小"
|
||||
disabled={!mapReady}
|
||||
onClick={handleZoomOut}
|
||||
/>
|
||||
<Divider />
|
||||
<ControlButton
|
||||
icon={Home}
|
||||
label="缩放到全局"
|
||||
disabled={!mapReady}
|
||||
onClick={onHome}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type ControlButtonProps = {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
function ControlButton({ icon: Icon, label, disabled, onClick }: ControlButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"grid h-[38px] w-[38px] place-items-center transition duration-150",
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
|
||||
"bg-transparent text-slate-700",
|
||||
MAP_CONTROL_HOVER_CLASS_NAME,
|
||||
disabled &&
|
||||
"cursor-not-allowed text-slate-400 opacity-55 hover:bg-transparent hover:text-slate-400 active:bg-transparent"
|
||||
)}
|
||||
>
|
||||
<Icon size={18} strokeWidth={2.2} aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
sideOffset={8}
|
||||
className="border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
|
||||
>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return (
|
||||
<div
|
||||
className="my-0.5 h-px w-3.5 self-center bg-slate-300/70"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user