mirror of
https://github.com/AnmolSaini16/mapcn
synced 2026-04-25 16:14:54 +02:00
feat: add MapArc primitive for curved connections
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -80,11 +80,6 @@
|
||||
"type": "registry:component",
|
||||
"target": "app/logistics/data.ts"
|
||||
},
|
||||
{
|
||||
"path": "src/registry/blocks/logistics-network/components/map-arcs.tsx",
|
||||
"type": "registry:component",
|
||||
"target": "app/logistics/components/map-arcs.tsx"
|
||||
},
|
||||
{
|
||||
"path": "src/registry/blocks/logistics-network/components/filter-sidebar.tsx",
|
||||
"type": "registry:component",
|
||||
|
||||
@@ -80,11 +80,6 @@
|
||||
"type": "registry:component",
|
||||
"target": "app/logistics/data.ts"
|
||||
},
|
||||
{
|
||||
"path": "src/registry/blocks/logistics-network/components/map-arcs.tsx",
|
||||
"type": "registry:component",
|
||||
"target": "app/logistics/components/map-arcs.tsx"
|
||||
},
|
||||
{
|
||||
"path": "src/registry/blocks/logistics-network/components/filter-sidebar.tsx",
|
||||
"type": "registry:component",
|
||||
|
||||
70
src/app/(main)/docs/_components/examples/arc-example.tsx
Normal file
70
src/app/(main)/docs/_components/examples/arc-example.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Map,
|
||||
MapArc,
|
||||
MapMarker,
|
||||
MarkerContent,
|
||||
MarkerLabel,
|
||||
} from "@/registry/map";
|
||||
|
||||
const hub = { name: "London", lng: -0.1276, lat: 51.5074 };
|
||||
|
||||
const destinations = [
|
||||
{ name: "New York", lng: -74.006, lat: 40.7128 },
|
||||
{ name: "São Paulo", lng: -46.6333, lat: -23.5505 },
|
||||
{ name: "Cape Town", lng: 18.4241, lat: -33.9249 },
|
||||
{ name: "Dubai", lng: 55.2708, lat: 25.2048 },
|
||||
{ name: "Mumbai", lng: 72.8777, lat: 19.076 },
|
||||
{ name: "Singapore", lng: 103.8198, lat: 1.3521 },
|
||||
{ name: "Tokyo", lng: 139.6917, lat: 35.6895 },
|
||||
{ name: "Sydney", lng: 151.2093, lat: -33.8688 },
|
||||
];
|
||||
|
||||
const arcs = destinations.map((dest) => ({
|
||||
id: dest.name,
|
||||
from: [hub.lng, hub.lat] as [number, number],
|
||||
to: [dest.lng, dest.lat] as [number, number],
|
||||
}));
|
||||
|
||||
export function ArcExample() {
|
||||
return (
|
||||
<div className="h-[420px] w-full">
|
||||
<Map center={[hub.lng, hub.lat]} zoom={1} projection={{ type: "globe" }}>
|
||||
<MapArc
|
||||
data={arcs}
|
||||
paint={{
|
||||
"line-color": "#3b82f6",
|
||||
"line-dasharray": [2, 2],
|
||||
}}
|
||||
interactive={false}
|
||||
/>
|
||||
|
||||
<MapMarker longitude={hub.lng} latitude={hub.lat}>
|
||||
<MarkerContent>
|
||||
<div className="size-3 rounded-full border-2 border-white bg-blue-500 shadow-md" />
|
||||
<MarkerLabel
|
||||
position="top"
|
||||
className="bg-background/80 rounded-sm px-1.5 py-0.5 text-[11px] font-semibold backdrop-blur"
|
||||
>
|
||||
{hub.name}
|
||||
</MarkerLabel>
|
||||
</MarkerContent>
|
||||
</MapMarker>
|
||||
|
||||
{destinations.map((dest) => (
|
||||
<MapMarker key={dest.name} longitude={dest.lng} latitude={dest.lat}>
|
||||
<MarkerContent>
|
||||
<div
|
||||
className={cn(
|
||||
"size-2 rounded-full border-2 border-white",
|
||||
"bg-emerald-500 shadow",
|
||||
)}
|
||||
/>
|
||||
<MarkerLabel position="top">{dest.name}</MarkerLabel>
|
||||
</MarkerContent>
|
||||
</MapMarker>
|
||||
))}
|
||||
</Map>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ExpressionSpecification } from "maplibre-gl";
|
||||
import {
|
||||
Map,
|
||||
MapArc,
|
||||
MapMarker,
|
||||
MapPopup,
|
||||
MarkerContent,
|
||||
MarkerLabel,
|
||||
type MapArcDatum,
|
||||
} from "@/registry/map";
|
||||
|
||||
interface Lane extends MapArcDatum {
|
||||
origin: string;
|
||||
destination: string;
|
||||
volume: string;
|
||||
mode: "air" | "sea";
|
||||
}
|
||||
|
||||
const lanes: Lane[] = [
|
||||
{
|
||||
id: "shg-lax",
|
||||
origin: "Shanghai",
|
||||
destination: "Los Angeles",
|
||||
from: [121.4737, 31.2304],
|
||||
to: [-118.2437, 34.0522],
|
||||
volume: "24.8k TEU",
|
||||
mode: "sea",
|
||||
},
|
||||
{
|
||||
id: "sin-rtm",
|
||||
origin: "Singapore",
|
||||
destination: "Rotterdam",
|
||||
from: [103.8198, 1.3521],
|
||||
to: [4.4777, 51.9244],
|
||||
volume: "9.4k TEU",
|
||||
mode: "sea",
|
||||
},
|
||||
{
|
||||
id: "san-cpt",
|
||||
origin: "Santos",
|
||||
destination: "Cape Town",
|
||||
from: [-46.3322, -23.9608],
|
||||
to: [18.4241, -33.9249],
|
||||
volume: "3.2k TEU",
|
||||
mode: "sea",
|
||||
},
|
||||
{
|
||||
id: "syd-nrt",
|
||||
origin: "Sydney",
|
||||
destination: "Tokyo",
|
||||
from: [151.2093, -33.8688],
|
||||
to: [139.6917, 35.6895],
|
||||
volume: "640 tons",
|
||||
mode: "air",
|
||||
},
|
||||
{
|
||||
id: "dxb-jfk",
|
||||
origin: "Dubai",
|
||||
destination: "New York",
|
||||
from: [55.2708, 25.2048],
|
||||
to: [-74.006, 40.7128],
|
||||
volume: "980 tons",
|
||||
mode: "air",
|
||||
},
|
||||
{
|
||||
id: "dxb-bom",
|
||||
origin: "Dubai",
|
||||
destination: "Mumbai",
|
||||
from: [55.2708, 25.2048],
|
||||
to: [72.8777, 19.076],
|
||||
volume: "1.2k tons",
|
||||
mode: "sea",
|
||||
},
|
||||
];
|
||||
|
||||
const modeColors = {
|
||||
air: "#a78bfa",
|
||||
sea: "#34d399",
|
||||
};
|
||||
|
||||
interface SelectedLane {
|
||||
lane: Lane;
|
||||
popupLngLat: { longitude: number; latitude: number };
|
||||
}
|
||||
|
||||
const modeColorExpression: ExpressionSpecification = [
|
||||
"match",
|
||||
["get", "mode"],
|
||||
"air",
|
||||
modeColors.air,
|
||||
"sea",
|
||||
modeColors.sea,
|
||||
"#888",
|
||||
];
|
||||
|
||||
export function InteractiveArcExample() {
|
||||
const [selected, setSelected] = useState<SelectedLane | null>(null);
|
||||
|
||||
const endpoints = useMemo(() => {
|
||||
const points: { name: string; coords: [number, number] }[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const lane of lanes) {
|
||||
if (!seen.has(lane.origin)) {
|
||||
seen.add(lane.origin);
|
||||
points.push({ name: lane.origin, coords: lane.from });
|
||||
}
|
||||
if (!seen.has(lane.destination)) {
|
||||
seen.add(lane.destination);
|
||||
points.push({ name: lane.destination, coords: lane.to });
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative h-[420px] w-full">
|
||||
<Map center={[20, 20]} zoom={0.8}>
|
||||
<MapArc<Lane>
|
||||
data={lanes}
|
||||
paint={{
|
||||
"line-color": modeColorExpression,
|
||||
"line-width": 1.5,
|
||||
}}
|
||||
hoverPaint={{
|
||||
"line-width": 3,
|
||||
"line-opacity": 1,
|
||||
}}
|
||||
onHover={(event) =>
|
||||
setSelected(
|
||||
event
|
||||
? {
|
||||
lane: event.arc,
|
||||
popupLngLat: {
|
||||
longitude: event.longitude,
|
||||
latitude: event.latitude,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{endpoints.map((point) => (
|
||||
<MapMarker
|
||||
key={point.name}
|
||||
longitude={point.coords[0]}
|
||||
latitude={point.coords[1]}
|
||||
>
|
||||
<MarkerContent>
|
||||
<div className="bg-foreground/80 size-2 rounded-full shadow-sm" />
|
||||
<MarkerLabel
|
||||
position="top"
|
||||
className="text-foreground/80 tracking-tight"
|
||||
>
|
||||
{point.name}
|
||||
</MarkerLabel>
|
||||
</MarkerContent>
|
||||
</MapMarker>
|
||||
))}
|
||||
|
||||
{selected && (
|
||||
<MapPopup
|
||||
longitude={selected.popupLngLat.longitude}
|
||||
latitude={selected.popupLngLat.latitude}
|
||||
offset={12}
|
||||
closeOnClick={false}
|
||||
className="p-0"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-2.5 py-1.5 text-xs">
|
||||
<span
|
||||
className="size-1.5 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
selected.lane.mode === "air"
|
||||
? modeColors.air
|
||||
: modeColors.sea,
|
||||
}}
|
||||
/>
|
||||
<span className="font-medium">
|
||||
{selected.lane.origin} → {selected.lane.destination}
|
||||
</span>
|
||||
<span className="text-muted-foreground border-l pl-2">
|
||||
{selected.lane.volume}
|
||||
</span>
|
||||
</div>
|
||||
</MapPopup>
|
||||
)}
|
||||
</Map>
|
||||
|
||||
<div className="bg-background/80 absolute bottom-3 left-3 flex items-center gap-3 rounded-full border px-3 py-0.5 text-[11px] shadow-sm backdrop-blur">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-1.5 rounded-full"
|
||||
style={{ background: modeColors.air }}
|
||||
/>
|
||||
Air
|
||||
</div>
|
||||
<span className="bg-border h-3 w-px" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="size-1.5 rounded-full"
|
||||
style={{ background: modeColors.sea }}
|
||||
/>
|
||||
Sea
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Map, MapControls } from "@/registry/map";
|
||||
export function MapControlsExample() {
|
||||
return (
|
||||
<div className="h-[420px] w-full">
|
||||
<Map center={[2.3522, 48.8566]} zoom={8.5}>
|
||||
<Map center={[2.3522, 48.8566]} zoom={10}>
|
||||
<MapControls
|
||||
position="top-right"
|
||||
showZoom
|
||||
|
||||
@@ -25,6 +25,7 @@ const anatomyCode = `<Map>
|
||||
<MapPopup longitude={...} latitude={...} />
|
||||
<MapControls />
|
||||
<MapRoute coordinates={...} />
|
||||
<MapArc data={...} />
|
||||
<MapClusterLayer data={...} />
|
||||
</Map>`;
|
||||
|
||||
@@ -49,6 +50,7 @@ export default function ApiReferencePage() {
|
||||
{ title: "MarkerLabel", slug: "markerlabel" },
|
||||
{ title: "MapPopup", slug: "mappopup" },
|
||||
{ title: "MapRoute", slug: "maproute" },
|
||||
{ title: "MapArc", slug: "maparc" },
|
||||
{ title: "MapClusterLayer", slug: "mapclusterlayer" },
|
||||
]}
|
||||
>
|
||||
@@ -559,6 +561,114 @@ export default function ApiReferencePage() {
|
||||
/>
|
||||
</DocsSection>
|
||||
|
||||
{/* MapArc */}
|
||||
<DocsSection title="MapArc">
|
||||
<p>
|
||||
Renders curved lines between coordinate pairs using a quadratic Bézier
|
||||
in longitude/latitude space. Must be used inside{" "}
|
||||
<DocsCode>Map</DocsCode>. Supports click and hover interactions for
|
||||
building arc selection UIs.
|
||||
</p>
|
||||
<p>
|
||||
Built on a MapLibre{" "}
|
||||
<DocsLink
|
||||
href="https://maplibre.org/maplibre-style-spec/layers/#line"
|
||||
external
|
||||
>
|
||||
line layer
|
||||
</DocsLink>{" "}
|
||||
— the <DocsCode>paint</DocsCode> and <DocsCode>layout</DocsCode> props
|
||||
accept any field from <DocsCode>LineLayerSpecification</DocsCode>{" "}
|
||||
(e.g. <DocsCode>line-color</DocsCode>, <DocsCode>line-width</DocsCode>
|
||||
, <DocsCode>line-opacity</DocsCode>,{" "}
|
||||
<DocsCode>line-dasharray</DocsCode>, <DocsCode>line-blur</DocsCode>).
|
||||
</p>
|
||||
<p>
|
||||
Style per arc by passing a{" "}
|
||||
<DocsLink
|
||||
href="https://maplibre.org/maplibre-style-spec/expressions/"
|
||||
external
|
||||
>
|
||||
MapLibre expression
|
||||
</DocsLink>{" "}
|
||||
as any paint value. Reference fields on each datum with{" "}
|
||||
<DocsCode>{`["get", "fieldName"]`}</DocsCode>.
|
||||
</p>
|
||||
|
||||
<DocsPropTable
|
||||
props={[
|
||||
{
|
||||
name: "data",
|
||||
type: "MapArcDatum[]",
|
||||
description:
|
||||
"Arcs to render. Each needs a unique id and from / to as [lng, lat]. Extra fields are forwarded to feature properties.",
|
||||
},
|
||||
{
|
||||
name: "id",
|
||||
type: "string",
|
||||
default: "auto",
|
||||
description: "Id prefix for the underlying source/layers.",
|
||||
},
|
||||
{
|
||||
name: "curvature",
|
||||
type: "number",
|
||||
default: "0.2",
|
||||
description:
|
||||
"How far the arc bows away from a straight line. 0 renders a straight line, higher values bend more, negative values bend to the opposite side.",
|
||||
},
|
||||
{
|
||||
name: "samples",
|
||||
type: "number",
|
||||
default: "64",
|
||||
description: "Points per arc. Higher = smoother.",
|
||||
},
|
||||
{
|
||||
name: "paint",
|
||||
type: "LineLayerSpecification['paint']",
|
||||
default:
|
||||
'{ "line-color": "#4285F4", "line-width": 2, "line-opacity": 0.85 }',
|
||||
description:
|
||||
"Paint props merged over defaults. Values may be MapLibre expressions for per-feature styling.",
|
||||
},
|
||||
{
|
||||
name: "layout",
|
||||
type: "LineLayerSpecification['layout']",
|
||||
default: '{ "line-join": "round", "line-cap": "round" }',
|
||||
description: "Layout props merged over defaults.",
|
||||
},
|
||||
{
|
||||
name: "hoverPaint",
|
||||
type: "LineLayerSpecification['paint']",
|
||||
description:
|
||||
"Paint overrides applied to the hovered arc via feature-state.",
|
||||
},
|
||||
{
|
||||
name: "onClick",
|
||||
type: "(e: MapArcEvent) => void",
|
||||
description: "Fired when an arc is clicked.",
|
||||
},
|
||||
{
|
||||
name: "onHover",
|
||||
type: "(e: MapArcEvent | null) => void",
|
||||
description:
|
||||
"Fired when the hovered arc changes, with the cursor's lng/lat at entry. Receives null when the cursor leaves all arcs.",
|
||||
},
|
||||
{
|
||||
name: "interactive",
|
||||
type: "boolean",
|
||||
default: "true",
|
||||
description:
|
||||
"Respond to mouse events (hover, cursor, callbacks).",
|
||||
},
|
||||
{
|
||||
name: "beforeId",
|
||||
type: "string",
|
||||
description: "Insert the arc layers before this layer id.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DocsSection>
|
||||
|
||||
{/* MapClusterLayer */}
|
||||
<DocsSection title="MapClusterLayer">
|
||||
<p>
|
||||
|
||||
62
src/app/(main)/docs/arcs/page.tsx
Normal file
62
src/app/(main)/docs/arcs/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { DocsLayout, DocsSection, DocsCode } from "../_components/docs";
|
||||
import { ComponentPreview } from "../_components/component-preview";
|
||||
import { ArcExample } from "../_components/examples/arc-example";
|
||||
import { InteractiveArcExample } from "../_components/examples/interactive-arc-example";
|
||||
import { getExampleSource } from "../_components/get-example-source";
|
||||
import { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Arcs",
|
||||
};
|
||||
|
||||
export default function ArcsPage() {
|
||||
const arcSource = getExampleSource("arc-example.tsx");
|
||||
const interactiveArcSource = getExampleSource("interactive-arc-example.tsx");
|
||||
|
||||
return (
|
||||
<DocsLayout
|
||||
title="Arcs"
|
||||
description="Draw curved connections between two coordinates with hover and click support."
|
||||
prev={{ title: "Routes", href: "/docs/routes" }}
|
||||
next={{ title: "Clusters", href: "/docs/clusters" }}
|
||||
toc={[
|
||||
{ title: "Basic Arc", slug: "basic-arc" },
|
||||
{ title: "Interactive Arcs", slug: "interactive-arcs" },
|
||||
]}
|
||||
>
|
||||
<DocsSection>
|
||||
<p>
|
||||
Use <DocsCode>MapArc</DocsCode> to draw curved lines between
|
||||
coordinate pairs. Arcs are great for showing flight paths, shipping
|
||||
lanes, or any origin–destination connection where a straight line
|
||||
would feel flat.
|
||||
</p>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection title="Basic Arc">
|
||||
<p>
|
||||
Pass an array of arcs to the <DocsCode>data</DocsCode> prop. Each arc
|
||||
needs a unique <DocsCode>id</DocsCode> and <DocsCode>from</DocsCode> /{" "}
|
||||
<DocsCode>to</DocsCode> coordinates as{" "}
|
||||
<DocsCode>[longitude, latitude]</DocsCode> tuples.
|
||||
</p>
|
||||
<ComponentPreview code={arcSource}>
|
||||
<ArcExample />
|
||||
</ComponentPreview>
|
||||
</DocsSection>
|
||||
|
||||
<DocsSection title="Interactive Arcs">
|
||||
<p>
|
||||
Combine <DocsCode>hoverPaint</DocsCode> with{" "}
|
||||
<DocsCode>onHover</DocsCode> to highlight an arc and surface details
|
||||
in a <DocsCode>MapPopup</DocsCode>. Use a <DocsCode>match</DocsCode>{" "}
|
||||
expression on <DocsCode>line-color</DocsCode> to style arcs by
|
||||
category. Here, air and sea lanes are styled differently.
|
||||
</p>
|
||||
<ComponentPreview code={interactiveArcSource}>
|
||||
<InteractiveArcExample />
|
||||
</ComponentPreview>
|
||||
</DocsSection>
|
||||
</DocsLayout>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export default function ClustersPage() {
|
||||
<DocsLayout
|
||||
title="Clusters"
|
||||
description="Visualize large datasets with automatic point clustering."
|
||||
prev={{ title: "Routes", href: "/docs/routes" }}
|
||||
prev={{ title: "Arcs", href: "/docs/arcs" }}
|
||||
next={{ title: "Advanced", href: "/docs/advanced-usage" }}
|
||||
>
|
||||
<DocsSection>
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function RoutesPage() {
|
||||
title="Routes"
|
||||
description="Draw lines and paths connecting coordinates on the map."
|
||||
prev={{ title: "Popups", href: "/docs/popups" }}
|
||||
next={{ title: "Clusters", href: "/docs/clusters" }}
|
||||
next={{ title: "Arcs", href: "/docs/arcs" }}
|
||||
toc={[
|
||||
{ title: "Basic Route", slug: "basic-route" },
|
||||
{ title: "Route Planning", slug: "route-planning" },
|
||||
|
||||
@@ -34,6 +34,7 @@ export const docsNavigation: SiteNavigationGroup[] = [
|
||||
{ title: "Markers", href: "/docs/markers", icon: Layers2 },
|
||||
{ title: "Popups", href: "/docs/popups", icon: Layers2 },
|
||||
{ title: "Routes", href: "/docs/routes", icon: Layers2 },
|
||||
{ title: "Arcs", href: "/docs/arcs", icon: Layers2 },
|
||||
{ title: "Clusters", href: "/docs/clusters", icon: Layers2 },
|
||||
{ title: "Advanced", href: "/docs/advanced-usage", icon: Layers2 },
|
||||
],
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useMap } from "@/registry/map";
|
||||
import type MapLibreGL from "maplibre-gl";
|
||||
import { hubs, modeConfig, statusConfig, type Route } from "../data";
|
||||
|
||||
const SOURCE_ID = "logistics-arcs-source";
|
||||
const LAYER_ID = "logistics-arcs-layer";
|
||||
|
||||
function generateArc(
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
segments = 50,
|
||||
): number[][] {
|
||||
const [x1, y1] = start;
|
||||
const [x2, y2] = end;
|
||||
|
||||
const mx = (x1 + x2) / 2;
|
||||
const my = (y1 + y2) / 2;
|
||||
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
const nx = -dy / dist;
|
||||
const ny = dx / dist;
|
||||
const height = dist * 0.3;
|
||||
|
||||
const cx = mx + nx * height;
|
||||
const cy = my + ny * height;
|
||||
|
||||
const coords: number[][] = [];
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const t = i / segments;
|
||||
const px = (1 - t) * (1 - t) * x1 + 2 * (1 - t) * t * cx + t * t * x2;
|
||||
const py = (1 - t) * (1 - t) * y1 + 2 * (1 - t) * t * cy + t * t * y2;
|
||||
coords.push([px, py]);
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
|
||||
function getHubById(id: string) {
|
||||
return hubs.find((h) => h.id === id)!;
|
||||
}
|
||||
|
||||
interface MapArcsProps {
|
||||
routes: Route[];
|
||||
}
|
||||
|
||||
export function MapArcs({ routes: arcRoutes }: MapArcsProps) {
|
||||
const { map, isLoaded } = useMap();
|
||||
|
||||
const geoJSON = useMemo<GeoJSON.FeatureCollection>(() => {
|
||||
const features: GeoJSON.Feature[] = arcRoutes.map((route) => {
|
||||
const fromHub = getHubById(route.from);
|
||||
const toHub = getHubById(route.to);
|
||||
const coordinates = generateArc(
|
||||
[fromHub.lng, fromHub.lat],
|
||||
[toHub.lng, toHub.lat],
|
||||
);
|
||||
return {
|
||||
type: "Feature" as const,
|
||||
properties: {
|
||||
id: `${route.from}-${route.to}`,
|
||||
mode: route.mode,
|
||||
status: route.status,
|
||||
color:
|
||||
route.status === "delayed"
|
||||
? statusConfig.delayed.color
|
||||
: modeConfig[route.mode].color,
|
||||
},
|
||||
geometry: {
|
||||
type: "LineString" as const,
|
||||
coordinates,
|
||||
},
|
||||
};
|
||||
});
|
||||
return { type: "FeatureCollection", features };
|
||||
}, [arcRoutes]);
|
||||
|
||||
const addLayer = useCallback(() => {
|
||||
if (!map) return;
|
||||
|
||||
if (!map.getSource(SOURCE_ID)) {
|
||||
map.addSource(SOURCE_ID, { type: "geojson", data: geoJSON });
|
||||
map.addLayer({
|
||||
id: LAYER_ID,
|
||||
type: "line",
|
||||
source: SOURCE_ID,
|
||||
layout: { "line-join": "round", "line-cap": "round" },
|
||||
paint: {
|
||||
"line-color": ["get", "color"],
|
||||
"line-width": 2,
|
||||
"line-opacity": 0.65,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
(map.getSource(SOURCE_ID) as MapLibreGL.GeoJSONSource).setData(geoJSON);
|
||||
}
|
||||
}, [map, geoJSON]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || !isLoaded) return;
|
||||
|
||||
addLayer();
|
||||
|
||||
return () => {
|
||||
try {
|
||||
if (map.getLayer(LAYER_ID)) map.removeLayer(LAYER_ID);
|
||||
if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [map, isLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || !isLoaded) return;
|
||||
const source = map.getSource(SOURCE_ID) as MapLibreGL.GeoJSONSource;
|
||||
if (source) source.setData(geoJSON);
|
||||
}, [map, isLoaded, geoJSON]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Map,
|
||||
MapArc,
|
||||
MapControls,
|
||||
MapMarker,
|
||||
MarkerContent,
|
||||
@@ -15,7 +17,6 @@ import {
|
||||
type Hub,
|
||||
type Route,
|
||||
} from "../data";
|
||||
import { MapArcs } from "./map-arcs";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
interface NetworkMapProps {
|
||||
@@ -61,13 +62,44 @@ function MapControlsCard() {
|
||||
}
|
||||
|
||||
export function NetworkMap({ hubs, routes }: NetworkMapProps) {
|
||||
const arcs = useMemo(() => {
|
||||
const hubById: Record<string, Hub> = Object.fromEntries(
|
||||
hubs.map((hub) => [hub.id, hub]),
|
||||
);
|
||||
return routes.flatMap((route) => {
|
||||
const fromHub = hubById[route.from];
|
||||
const toHub = hubById[route.to];
|
||||
if (!fromHub || !toHub) return [];
|
||||
return [
|
||||
{
|
||||
id: `${route.from}-${route.to}`,
|
||||
from: [fromHub.lng, fromHub.lat] as [number, number],
|
||||
to: [toHub.lng, toHub.lat] as [number, number],
|
||||
color:
|
||||
route.status === "delayed"
|
||||
? statusConfig.delayed.color
|
||||
: modeConfig[route.mode].color,
|
||||
},
|
||||
];
|
||||
});
|
||||
}, [hubs, routes]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<MapControlsCard />
|
||||
|
||||
<Map center={[-98, 39]} zoom={4} projection={{ type: "globe" }}>
|
||||
<MapControls />
|
||||
<MapArcs routes={routes} />
|
||||
<MapArc
|
||||
data={arcs}
|
||||
curvature={0.3}
|
||||
paint={{
|
||||
"line-color": ["get", "color"],
|
||||
"line-width": 2,
|
||||
"line-opacity": 0.65,
|
||||
}}
|
||||
interactive={false}
|
||||
/>
|
||||
|
||||
{hubs.map((hub) => (
|
||||
<MapMarker key={hub.id} longitude={hub.lng} latitude={hub.lat}>
|
||||
|
||||
@@ -1167,6 +1167,362 @@ function MapRoute({
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A single arc to render inside <MapArc data={...}>. */
|
||||
type MapArcDatum = {
|
||||
/** Unique identifier for this arc. Required for hover state tracking and event payloads. */
|
||||
id: string | number;
|
||||
/** Start coordinate as [longitude, latitude]. */
|
||||
from: [number, number];
|
||||
/** End coordinate as [longitude, latitude]. */
|
||||
to: [number, number];
|
||||
};
|
||||
|
||||
/** Event payload passed to MapArc interaction callbacks. */
|
||||
type MapArcEvent<T extends MapArcDatum = MapArcDatum> = {
|
||||
/** The arc datum that was hovered or clicked. */
|
||||
arc: T;
|
||||
/** Longitude of the cursor at the time of the event. */
|
||||
longitude: number;
|
||||
/** Latitude of the cursor at the time of the event. */
|
||||
latitude: number;
|
||||
/** The underlying MapLibre mouse event for advanced use cases. */
|
||||
originalEvent: MapLibreGL.MapMouseEvent;
|
||||
};
|
||||
|
||||
type MapArcLinePaint = NonNullable<MapLibreGL.LineLayerSpecification["paint"]>;
|
||||
type MapArcLineLayout = NonNullable<
|
||||
MapLibreGL.LineLayerSpecification["layout"]
|
||||
>;
|
||||
|
||||
type MapArcProps<T extends MapArcDatum = MapArcDatum> = {
|
||||
/** Array of arcs to render. Each arc must have a unique `id`. */
|
||||
data: T[];
|
||||
/** Optional unique identifier prefix for the arc source/layers. Auto-generated if not provided. */
|
||||
id?: string;
|
||||
/**
|
||||
* How far each arc bows away from a straight line. `0` renders straight
|
||||
* lines; higher values bend further. Negative values bend to the opposite
|
||||
* side. Arcs are computed as a quadratic Bézier in lng/lat space and do not
|
||||
* account for the antimeridian. (default: 0.2)
|
||||
*/
|
||||
curvature?: number;
|
||||
/** Number of samples used to render each curve. Higher = smoother. (default: 64) */
|
||||
samples?: number;
|
||||
/**
|
||||
* MapLibre paint properties for the arc layer. Merged on top of sensible
|
||||
* defaults (`line-color: #4285F4`, `line-width: 2`, `line-opacity: 0.85`).
|
||||
* Any value can be a MapLibre expression for per-feature styling, every
|
||||
* field on each arc datum (besides `from`/`to`) is exposed via `["get", ...]`.
|
||||
*/
|
||||
paint?: MapArcLinePaint;
|
||||
/** MapLibre layout properties for the arc layer. Defaults to rounded joins/caps. */
|
||||
layout?: MapArcLineLayout;
|
||||
/**
|
||||
* Paint properties applied to the arc currently under the cursor. Each key
|
||||
* is merged into `paint` as a `case` expression keyed on per-feature hover
|
||||
* state, so only the hovered arc changes appearance.
|
||||
*/
|
||||
hoverPaint?: MapArcLinePaint;
|
||||
/** Callback when an arc is clicked. */
|
||||
onClick?: (e: MapArcEvent<T>) => void;
|
||||
/**
|
||||
* Callback fired when the hovered arc changes. Receives the cursor's
|
||||
* lng/lat at the moment of entry, and `null` when the cursor leaves the
|
||||
* last hovered arc.
|
||||
*/
|
||||
onHover?: (e: MapArcEvent<T> | null) => void;
|
||||
/** Whether arcs respond to mouse events (default: true). */
|
||||
interactive?: boolean;
|
||||
/** Optional MapLibre layer id to insert the arc layers before (z-order control). */
|
||||
beforeId?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_ARC_CURVATURE = 0.2;
|
||||
const DEFAULT_ARC_SAMPLES = 64;
|
||||
const ARC_HIT_MIN_WIDTH = 12;
|
||||
const ARC_HIT_PADDING = 6;
|
||||
|
||||
const DEFAULT_ARC_PAINT: MapArcLinePaint = {
|
||||
"line-color": "#4285F4",
|
||||
"line-width": 2,
|
||||
"line-opacity": 0.85,
|
||||
};
|
||||
|
||||
const DEFAULT_ARC_LAYOUT: MapArcLineLayout = {
|
||||
"line-join": "round",
|
||||
"line-cap": "round",
|
||||
};
|
||||
|
||||
function mergeArcPaint(
|
||||
paint: MapArcLinePaint,
|
||||
hoverPaint: MapArcLinePaint | undefined,
|
||||
): MapArcLinePaint {
|
||||
if (!hoverPaint) return paint;
|
||||
const merged: Record<string, unknown> = { ...paint };
|
||||
for (const [key, hoverValue] of Object.entries(hoverPaint)) {
|
||||
if (hoverValue === undefined) continue;
|
||||
const baseValue = merged[key];
|
||||
merged[key] =
|
||||
baseValue === undefined
|
||||
? hoverValue
|
||||
: [
|
||||
"case",
|
||||
["boolean", ["feature-state", "hover"], false],
|
||||
hoverValue,
|
||||
baseValue,
|
||||
];
|
||||
}
|
||||
return merged as MapArcLinePaint;
|
||||
}
|
||||
|
||||
function buildArcCoordinates(
|
||||
from: [number, number],
|
||||
to: [number, number],
|
||||
curvature: number,
|
||||
samples: number,
|
||||
): [number, number][] {
|
||||
const [x0, y0] = from;
|
||||
const [x2, y2] = to;
|
||||
const dx = x2 - x0;
|
||||
const dy = y2 - y0;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
|
||||
if (distance === 0 || curvature === 0) return [from, to];
|
||||
|
||||
const mx = (x0 + x2) / 2;
|
||||
const my = (y0 + y2) / 2;
|
||||
const nx = -dy / distance;
|
||||
const ny = dx / distance;
|
||||
const offset = distance * curvature;
|
||||
const cx = mx + nx * offset;
|
||||
const cy = my + ny * offset;
|
||||
|
||||
const points: [number, number][] = [];
|
||||
const segments = Math.max(2, Math.floor(samples));
|
||||
for (let i = 0; i <= segments; i += 1) {
|
||||
const t = i / segments;
|
||||
const inv = 1 - t;
|
||||
const x = inv * inv * x0 + 2 * inv * t * cx + t * t * x2;
|
||||
const y = inv * inv * y0 + 2 * inv * t * cy + t * t * y2;
|
||||
points.push([x, y]);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function MapArc<T extends MapArcDatum = MapArcDatum>({
|
||||
data,
|
||||
id: propId,
|
||||
curvature = DEFAULT_ARC_CURVATURE,
|
||||
samples = DEFAULT_ARC_SAMPLES,
|
||||
paint,
|
||||
layout,
|
||||
hoverPaint,
|
||||
onClick,
|
||||
onHover,
|
||||
interactive = true,
|
||||
beforeId,
|
||||
}: MapArcProps<T>) {
|
||||
const { map, isLoaded } = useMap();
|
||||
const autoId = useId();
|
||||
const id = propId ?? autoId;
|
||||
const sourceId = `arc-source-${id}`;
|
||||
const layerId = `arc-layer-${id}`;
|
||||
const hitLayerId = `arc-hit-layer-${id}`;
|
||||
|
||||
const mergedPaint = useMemo(
|
||||
() => mergeArcPaint({ ...DEFAULT_ARC_PAINT, ...paint }, hoverPaint),
|
||||
[paint, hoverPaint],
|
||||
);
|
||||
const mergedLayout = useMemo(
|
||||
() => ({ ...DEFAULT_ARC_LAYOUT, ...layout }),
|
||||
[layout],
|
||||
);
|
||||
|
||||
const hitWidth = useMemo(() => {
|
||||
const w = paint?.["line-width"] ?? DEFAULT_ARC_PAINT["line-width"];
|
||||
const base = typeof w === "number" ? w : ARC_HIT_MIN_WIDTH;
|
||||
return Math.max(base + ARC_HIT_PADDING, ARC_HIT_MIN_WIDTH);
|
||||
}, [paint]);
|
||||
|
||||
const geoJSON = useMemo<GeoJSON.FeatureCollection<GeoJSON.LineString>>(
|
||||
() => ({
|
||||
type: "FeatureCollection",
|
||||
features: data.map((arc) => {
|
||||
const { from, to, ...properties } = arc;
|
||||
return {
|
||||
type: "Feature",
|
||||
properties,
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: buildArcCoordinates(from, to, curvature, samples),
|
||||
},
|
||||
};
|
||||
}),
|
||||
}),
|
||||
[data, curvature, samples],
|
||||
);
|
||||
|
||||
const latestRef = useRef({ data, onClick, onHover });
|
||||
latestRef.current = { data, onClick, onHover };
|
||||
|
||||
// Add source and layers on mount.
|
||||
useEffect(() => {
|
||||
if (!isLoaded || !map) return;
|
||||
|
||||
map.addSource(sourceId, {
|
||||
type: "geojson",
|
||||
data: geoJSON,
|
||||
promoteId: "id",
|
||||
});
|
||||
|
||||
map.addLayer(
|
||||
{
|
||||
id: hitLayerId,
|
||||
type: "line",
|
||||
source: sourceId,
|
||||
layout: DEFAULT_ARC_LAYOUT,
|
||||
paint: {
|
||||
"line-color": "rgba(0, 0, 0, 0)",
|
||||
"line-width": hitWidth,
|
||||
"line-opacity": 1,
|
||||
},
|
||||
},
|
||||
beforeId,
|
||||
);
|
||||
|
||||
map.addLayer(
|
||||
{
|
||||
id: layerId,
|
||||
type: "line",
|
||||
source: sourceId,
|
||||
layout: mergedLayout,
|
||||
paint: mergedPaint,
|
||||
},
|
||||
beforeId,
|
||||
);
|
||||
|
||||
return () => {
|
||||
try {
|
||||
if (map.getLayer(layerId)) map.removeLayer(layerId);
|
||||
if (map.getLayer(hitLayerId)) map.removeLayer(hitLayerId);
|
||||
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoaded, map]);
|
||||
|
||||
// Sync features when data / curvature / samples change.
|
||||
useEffect(() => {
|
||||
if (!isLoaded || !map) return;
|
||||
const source = map.getSource(sourceId) as
|
||||
| MapLibreGL.GeoJSONSource
|
||||
| undefined;
|
||||
source?.setData(geoJSON);
|
||||
}, [isLoaded, map, geoJSON, sourceId]);
|
||||
|
||||
// Sync paint/layout when they change.
|
||||
useEffect(() => {
|
||||
if (!isLoaded || !map || !map.getLayer(layerId)) return;
|
||||
for (const [key, value] of Object.entries(mergedPaint)) {
|
||||
map.setPaintProperty(
|
||||
layerId,
|
||||
key as keyof MapArcLinePaint,
|
||||
value as never,
|
||||
);
|
||||
}
|
||||
for (const [key, value] of Object.entries(mergedLayout)) {
|
||||
map.setLayoutProperty(
|
||||
layerId,
|
||||
key as keyof MapArcLineLayout,
|
||||
value as never,
|
||||
);
|
||||
}
|
||||
if (map.getLayer(hitLayerId)) {
|
||||
map.setPaintProperty(hitLayerId, "line-width", hitWidth);
|
||||
}
|
||||
}, [isLoaded, map, layerId, hitLayerId, mergedPaint, mergedLayout, hitWidth]);
|
||||
|
||||
// Interaction handlers
|
||||
useEffect(() => {
|
||||
if (!isLoaded || !map || !interactive) return;
|
||||
|
||||
let hoveredId: string | number | null = null;
|
||||
|
||||
const setHover = (next: string | number | null) => {
|
||||
if (next === hoveredId) return;
|
||||
const sourceExists = !!map.getSource(sourceId);
|
||||
if (hoveredId != null && sourceExists) {
|
||||
map.setFeatureState(
|
||||
{ source: sourceId, id: hoveredId },
|
||||
{ hover: false },
|
||||
);
|
||||
}
|
||||
hoveredId = next;
|
||||
if (next != null && sourceExists) {
|
||||
map.setFeatureState({ source: sourceId, id: next }, { hover: true });
|
||||
}
|
||||
};
|
||||
|
||||
const findArc = (featureId: string | number | undefined) =>
|
||||
featureId == null
|
||||
? undefined
|
||||
: latestRef.current.data.find(
|
||||
(arc) => String(arc.id) === String(featureId),
|
||||
);
|
||||
|
||||
const handleMouseMove = (e: MapLibreGL.MapLayerMouseEvent) => {
|
||||
const featureId = e.features?.[0]?.id as string | number | undefined;
|
||||
if (featureId == null || featureId === hoveredId) return;
|
||||
|
||||
setHover(featureId);
|
||||
map.getCanvas().style.cursor = "pointer";
|
||||
|
||||
const arc = findArc(featureId);
|
||||
if (arc) {
|
||||
latestRef.current.onHover?.({
|
||||
arc: arc as T,
|
||||
longitude: e.lngLat.lng,
|
||||
latitude: e.lngLat.lat,
|
||||
originalEvent: e,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHover(null);
|
||||
map.getCanvas().style.cursor = "";
|
||||
latestRef.current.onHover?.(null);
|
||||
};
|
||||
|
||||
const handleClick = (e: MapLibreGL.MapLayerMouseEvent) => {
|
||||
const arc = findArc(e.features?.[0]?.id as string | number | undefined);
|
||||
if (!arc) return;
|
||||
latestRef.current.onClick?.({
|
||||
arc: arc as T,
|
||||
longitude: e.lngLat.lng,
|
||||
latitude: e.lngLat.lat,
|
||||
originalEvent: e,
|
||||
});
|
||||
};
|
||||
|
||||
map.on("mousemove", hitLayerId, handleMouseMove);
|
||||
map.on("mouseleave", hitLayerId, handleMouseLeave);
|
||||
map.on("click", hitLayerId, handleClick);
|
||||
|
||||
return () => {
|
||||
map.off("mousemove", hitLayerId, handleMouseMove);
|
||||
map.off("mouseleave", hitLayerId, handleMouseLeave);
|
||||
map.off("click", hitLayerId, handleClick);
|
||||
setHover(null);
|
||||
map.getCanvas().style.cursor = "";
|
||||
};
|
||||
}, [isLoaded, map, hitLayerId, sourceId, interactive]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type MapClusterLayerProps<
|
||||
P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties,
|
||||
> = {
|
||||
@@ -1481,7 +1837,8 @@ export {
|
||||
MapPopup,
|
||||
MapControls,
|
||||
MapRoute,
|
||||
MapArc,
|
||||
MapClusterLayer,
|
||||
};
|
||||
|
||||
export type { MapRef, MapViewport };
|
||||
export type { MapRef, MapViewport, MapArcDatum, MapArcEvent };
|
||||
|
||||
Reference in New Issue
Block a user