feat: add MapArc primitive for curved connections

This commit is contained in:
Anmoldeep Singh
2026-04-21 23:39:53 +05:30
parent be3717e845
commit 2c22d20dc1
15 changed files with 852 additions and 150 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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",

View File

@@ -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",

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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>

View 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 origindestination 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>
);
}

View File

@@ -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>

View File

@@ -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" },

View File

@@ -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 },
],

View File

@@ -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;
}

View File

@@ -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}>

View File

@@ -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 };