Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@
"dependencies": {
"@maplibre/maplibre-gl-geocoder": "^1.9.1",
"@maplibre/maplibre-gl-inspect": "^1.7.1",
"bitmap-sdf": "^1.0.4",
"maplibre-gl": "^5.7.0",
"svelte-select": "^5.8.3"
},
Expand Down
28 changes: 11 additions & 17 deletions components/src/maplibre/ArrowSource/ArrowSource.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as ArrowSourceStories from './ArrowSource.stories.svelte';

# ArrowSource

Source component for creating smooth ([quadratic bezier](https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Quadratic_B%C3%A9zier_curves)) vector arrows [like these](https://www.nytimes.com/interactive/2025/10/09/world/americas/drug-trafficking-venezuela.html?searchResultPosition=4).
Source component for creating [quadratic bezier](https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Quadratic_B%C3%A9zier_curves) vector arrows [like these](https://www.nytimes.com/interactive/2025/10/09/world/americas/drug-trafficking-venezuela.html).

<Controls />

Expand All @@ -19,17 +19,18 @@ interface ArrowSpec {
a: [number, number]; // Start point
b: [number, number]; // End point
c: [number, number]; // Control point
headScale?: [number, number];
width?: number;
}

```

Use regular VectorLayers to render the arrows:
Use regular [VectorLayers](?path=/docs/maplibre-layer-vectorlayer--docs) to render the arrows:

```jsx
<Map>
<ArrowSource
id="demo"
id="arrows"
attribution="Demo attribution"
arrows={[
{width: 10, a: [-80.1, 11.3], b: [-84.783, 15.6], c: [-81, 14.6]},
Expand All @@ -38,29 +39,22 @@ Use regular VectorLayers to render the arrows:
<VectorLayer
id="arrow-tails"
type="line"
sourceId="demo"
sourceId="arrows"
filter={['==', 'kind', 'arrow-tail']}
paint={{
'line-gradient': ['interpolate',['linear'],['line-progress'],0,'transparent',0.4"red"],
'line-gradient': ['interpolate', ['linear'], ['line-progress'], 0, 'transparent', 0.4, "red"],
'line-width': ['get', 'width']
}}/>
<VectorLayer
sourceId="arrows"
id="arrow-heads"
type="symbol"
sourceId="demo"
filter={['==', 'kind', 'arrow-head']}
layout={{
'icon-image': 'arrow-head',
'icon-anchor': 'top',
'icon-offset': [0, -2],
'icon-rotate': ['get', 'angle'],
'icon-overlap': 'always',
'icon-size': ['get', 'size']
}}
paint={{'icon-color': "red"}}/>
type="fill"
paint={{
'fill-color': tokens.shades.red.base
}}/>
</Map>
```

## Notes
- [https://github.com/dy/bitmap-sdf](https://github.com/dy/bitmap-sdf)
- [https://github.com/maplibre/maplibre-gl-js/issues/5037](https://github.com/maplibre/maplibre-gl-js/issues/5037)
74 changes: 60 additions & 14 deletions components/src/maplibre/ArrowSource/ArrowSource.stories.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@
import Map from '../Map/Map.svelte';
import ArrowSource from './ArrowSource.svelte';
import VectorLayer from '../VectorLayer/VectorLayer.svelte';
import DesignTokens from '../../DesignTokens/DesignTokens.svelte';
import InspectControl from '../InspectControl/InspectControl.svelte';
import AttributionControl from '../AttributionControl/AttributionControl.svelte';

import { SWRDataLabLight } from '../MapStyle';
import { tokens } from '../../DesignTokens';
import DesignTokens from '../../DesignTokens/DesignTokens.svelte';

const { Story } = defineMeta({
title: 'Maplibre/Source/ArrowSource',
Expand All @@ -30,7 +29,7 @@
}}
>
<ArrowSource
id="demo"
id="arrows"
attribution="Demo attribution"
arrows={[
{
Expand All @@ -47,10 +46,9 @@
}
]}
/>
<InspectControl />
<AttributionControl />
<VectorLayer
sourceId="demo"
sourceId="arrows"
id="arrow-tails"
filter={['==', 'kind', 'arrow-tail']}
type="line"
Expand All @@ -68,20 +66,68 @@
}}
/>
<VectorLayer
sourceId="demo"
sourceId="arrows"
id="arrow-heads"
filter={['==', 'kind', 'arrow-head']}
type="symbol"
type="fill"
paint={{
'icon-color': tokens.shades.red.base
'fill-color': tokens.shades.red.base
}}
/>
</Map>
</div>
</DesignTokens>
</Story>

<Story asChild name="fix/206">
<DesignTokens theme="light">
<div class="container">
<Map
showDebug={true}
style={SWRDataLabLight()}
initialLocation={{
lng: 7.72,
lat: 47.59,
zoom: 10
}}
>
<ArrowSource
id="arrows"
attribution=""
arrows={[{ a: [7.79, 47.55917], b: [7.69, 47.595], c: [7.69, 47.55], width: 40 }]}
/>
<AttributionControl />
<VectorLayer
sourceId="arrows"
id="arrow-tails"
filter={['==', 'kind', 'arrow-tail']}
type="line"
layout={{
'icon-image': 'arrow-head',
'icon-anchor': 'top',
'icon-offset': [0, -2],
'icon-rotate': ['get', 'angle'],
'icon-overlap': 'always',
'icon-size': ['get', 'size']
'line-join': 'round',
'line-cap': 'square'
}}
paint={{
'line-gradient': [
'interpolate',
['linear'],
['line-progress'],
0,
'transparent',
0.4,
tokens.shades.red.base
],
'line-width': ['get', 'width']
}}
/>

<VectorLayer
sourceId="arrows"
id="arrow-heads"
filter={['==', 'kind', 'arrow-head']}
type="fill"
paint={{
'fill-color': tokens.shades.blue.base,
'fill-opacity': 0.7
}}
/>
</Map>
Expand Down
154 changes: 58 additions & 96 deletions components/src/maplibre/ArrowSource/ArrowSource.svelte
Original file line number Diff line number Diff line change
@@ -1,88 +1,78 @@
<script lang="ts">
import { type GeoJSONSourceSpecification } from 'maplibre-gl';
import { default as calcSdf } from 'bitmap-sdf';

import MapSource from '../Source/MapSource.svelte';
import MapSource from '../Source';
import { getMapContext } from '../context.svelte.js';
import quadraticToPoints from './quadraticToPoints';
import { onDestroy } from 'svelte';

const { map, styleLoaded } = $derived(getMapContext());
const { map } = $derived(getMapContext());

interface ArrowSpec {
a: [number, number];
b: [number, number];
c: [number, number];
width?: number;
}
type V2 = [number, number];

interface ArrowSourceProps {
id: string;
attribution: string;
arrows: ArrowSpec[];
}

const { id, arrows = [], attribution = '' }: ArrowSourceProps = $props();

const makeArrowHead = (width: number, height: number) => {
const canvas = document.createElement('canvas');
const c = canvas.getContext('2d');
const w = Math.round(width);
const h = Math.round(height);
canvas.width = w;
canvas.height = h;
if (c) {
c.fillStyle = 'white';
c.beginPath();
c.moveTo(0, 0);
c.lineTo(w / 2, h);
c.lineTo(w, 0);
c.fill();

const sdf = calcSdf(canvas);
const data = new Uint8ClampedArray(w * h * 4);
for (let i = 0; i < w; i++) {
for (let j = 0; j < h; j++) {
data[j * w * 4 + i * 4 + 0] = sdf[j * w + i] * 255;
data[j * w * 4 + i * 4 + 1] = sdf[j * w + i] * 255;
data[j * w * 4 + i * 4 + 2] = sdf[j * w + i] * 255;
data[j * w * 4 + i * 4 + 3] = sdf[j * w + i] * 255;
}
}
return new ImageData(data, w, h);
}
};
interface ArrowSpec {
a: V2;
b: V2;
c: V2;
width?: number;
headScale?: V2;
}

interface JsonArrow {
width: number;
points: [number, number][];
points: V2[];
headScale?: V2;
}

const { id, arrows = [], attribution = '' }: ArrowSourceProps = $props();

const ars: JsonArrow[] = arrows.map((a) => {
return {
width: a.width || 10,
points: quadraticToPoints(a.a, a.b, a.c, 10),
headScale: a.headScale
};
});

const arrowsToJson = (arrows: JsonArrow[] = []) => {
const tails = arrows.map((a, i) => {
if (!map) return { type: 'FeatureCollection', features: [] } as GeoJSON.GeoJSON;

const tails = ars.map((a, i) => {
return {
type: 'Feature',
geometry: { type: 'LineString', coordinates: a.points },
properties: { width: a.width, kind: 'arrow-tail', id: i }
};
});

const heads = arrows.map((a, i) => {
const bc = a.points[a.points.length - 1][0] - a.points[a.points.length - 2][0];
const ac = a.points[a.points.length - 1][1] - a.points[a.points.length - 2][1];
const ba = Math.sqrt(bc * bc + ac * ac);
const angle = Math.asin(bc / ba) * (180 / Math.PI) - 180;
const heads = ars.map((arrow, i) => {
const a = Object.values(map.project(arrow.points[arrow.points.length - 1])) as V2;
const b = Object.values(map.project(arrow.points[arrow.points.length - 2])) as V2;

const ab = [b[0] - a[0], b[1] - a[1]];
const d = Math.sqrt(ab[0] * ab[0] + ab[1] * ab[1]);
const t = [-ab[1] / d, ab[0] / d];
const s = arrow.headScale
? [arrow.width * arrow.headScale[0], arrow.width * arrow.headScale[1]]
: [arrow.width * 1.33, arrow.width * 1.5];

const coordinates = [
map?.unproject(a).toArray(),
map?.unproject([a[0] + t[0] * s[0], a[1] + t[1] * s[0]]).toArray(),
map?.unproject([a[0] - (ab[0] / d) * s[0], a[1] - (ab[1] / d) * s[1]]).toArray(),
map?.unproject([a[0] - t[0] * s[0], a[1] - t[1] * s[0]]).toArray()
];

return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: a.points[a.points.length - 1]
},
properties: {
kind: 'arrow-head',
angle,
size: (a.width / 20) * 1.35,
id: arrows.length + i
}
geometry: { type: 'Polygon', coordinates: [coordinates] },
properties: { kind: 'arrow-head', id: arrows.length + i }
};
});

Expand All @@ -92,51 +82,23 @@
} as GeoJSON.GeoJSON;
};

const quadraticToPoints = (
a: [number, number],
b: [number, number],
c: [number, number],
pointCount = 10
) => {
// B(t) = (1-t)[(1-t)P0 + tP1] + t[(1-t)P1+tP2]
// This is a naive implementation but good enough for now
// See: https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Quadratic_B%C3%A9zier_curves
// https://bit-101.com/blog/posts/2024-09-29/evenly-placed-points-on-bezier-curves

let points = [];
for (let n = 0; n < pointCount; n++) {
const t = n / pointCount;
const x = (1 - t) * ((1 - t) * a[0] + t * c[0]) + t * ((1 - t) * c[0] + t * b[0]);
const y = (1 - t) * ((1 - t) * a[1] + t * c[1]) + t * ((1 - t) * c[1] + t * b[1]);
points.push([x.toFixed(4), y.toFixed(4)]);
}

return [...points, b] as [number, number][];
let sourceSpec: GeoJSONSourceSpecification = $state({
attribution,
type: 'geojson',
promoteId: 'id',
lineMetrics: true,
data: arrowsToJson(ars)
});

const onZoom = () => {
sourceSpec = { ...sourceSpec, data: arrowsToJson(ars) };
};
$effect(() => {
const s = 6.5;
const ah = makeArrowHead(10 * s, 10 * s * 0.75);
if (map && styleLoaded && ah) {
map.addImage('arrow-head', ah, { sdf: true, pixelRatio: 2 });
}
map?.on('zoom', onZoom);
});
onDestroy(() => {
if (map) {
map.removeImage('arrow-head');
}
map?.off('zoom', onZoom);
});
const ar = arrows.map((a) => {
return { width: a.width || 10, points: quadraticToPoints(a.a, a.b, a.c, 20) };
});

const sourceSpec: GeoJSONSourceSpecification = {
type: 'geojson',
maxzoom: 24,
attribution,
promoteId: 'id',
lineMetrics: true,
data: arrowsToJson(ar)
};
</script>

<MapSource {id} {sourceSpec} />
Loading