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: 1 addition & 0 deletions components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"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
2 changes: 2 additions & 0 deletions components/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export { default as ScaleControl } from './maplibre/ScaleControl/ScaleControl.sv
export { default as VectorLayer } from './maplibre/VectorLayer/VectorLayer.svelte';
export { default as VectorTileSource } from './maplibre/VectorTileSource/VectorTileSource.svelte';
export { default as GeoJSONSource } from './maplibre/GeoJSONSource/GeoJSONSource.svelte';
export { default as ArrowSource } from './maplibre/ArrowSource/ArrowSource.svelte';
export { default as MapSource } from './maplibre/Source/MapSource.svelte';
export { default as Tooltip } from './maplibre/Tooltip/Tooltip.svelte';
export { default as WithLinkLocation } from './maplibre/WithLinkLocation/WithLinkLocation.svelte';

Expand Down
66 changes: 66 additions & 0 deletions components/src/maplibre/ArrowSource/ArrowSource.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Story, Meta, Primary, Controls, Stories } from '@storybook/addon-docs/blocks';

import * as ArrowSourceStories from './ArrowSource.stories.svelte';

<Meta of={ArrowSourceStories} />

# 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).

<Controls />

## Usage

ArrowSource takes an `arrow` prop of shape `ArrowSpec[]`:

```js
interface ArrowSpec {
a: [number, number]; // Start point
b: [number, number]; // End point
c: [number, number]; // Control point
width?: number;
}

```

Use regular VectorLayers to render the arrows:

```jsx
<Map>
<ArrowSource
id="demo"
attribution="Demo attribution"
arrows={[
{width: 10, a: [-80.1, 11.3], b: [-84.783, 15.6], c: [-81, 14.6]},
{width: 15, a: [-71.1, 11.3], b: [-75.783, 15.6], c: [-75, 12.6]}
]}/>
<VectorLayer
id="arrow-tails"
type="line"
sourceId="demo"
filter={['==', 'kind', 'arrow-tail']}
paint={{
'line-gradient': ['interpolate',['linear'],['line-progress'],0,'transparent',0.4"red"],
'line-width': ['get', 'width']
}}/>
<VectorLayer
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"}}/>
</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)
97 changes: 97 additions & 0 deletions components/src/maplibre/ArrowSource/ArrowSource.stories.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<script context="module" lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
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';

const { Story } = defineMeta({
title: 'Maplibre/Source/ArrowSource',
component: ArrowSource
});
</script>

<Story asChild name="Default">
<DesignTokens theme="light">
<div class="container">
<Map
showDebug={true}
style={SWRDataLabLight()}
initialLocation={{
lng: -84.783,
lat: 15.623,
zoom: 3.39,
pitch: 0
}}
>
<ArrowSource
id="demo"
attribution="Demo attribution"
arrows={[
{
width: 10,
a: [-80.1, 11.3],
b: [-84.783, 15.6],
c: [-81, 14.6]
},
{
width: 15,
a: [-71.1, 11.3],
b: [-75.783, 15.6],
c: [-75, 12.6]
}
]}
/>
<InspectControl />
<AttributionControl />
<VectorLayer
sourceId="demo"
id="arrow-tails"
filter={['==', 'kind', 'arrow-tail']}
type="line"
paint={{
'line-gradient': [
'interpolate',
['linear'],
['line-progress'],
0,
'transparent',
0.4,
tokens.shades.red.base
],
'line-width': ['get', 'width']
}}
/>
<VectorLayer
sourceId="demo"
id="arrow-heads"
filter={['==', 'kind', 'arrow-head']}
type="symbol"
paint={{
'icon-color': tokens.shades.red.base
}}
layout={{
'icon-image': 'arrow-head',
'icon-anchor': 'top',
'icon-offset': [0, -2],
'icon-rotate': ['get', 'angle'],
'icon-overlap': 'always',
'icon-size': ['get', 'size']
}}
/>
</Map>
</div>
</DesignTokens>
</Story>

<style>
.container {
width: 100%;
height: 600px;
}
</style>
142 changes: 142 additions & 0 deletions components/src/maplibre/ArrowSource/ArrowSource.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<script lang="ts">
import { type GeoJSONSourceSpecification } from 'maplibre-gl';
import { default as calcSdf } from 'bitmap-sdf';

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

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

interface ArrowSpec {
a: [number, number];
b: [number, number];
c: [number, number];
width?: 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 JsonArrow {
width: number;
points: [number, number][];
}

const arrowsToJson = (arrows: JsonArrow[] = []) => {
const tails = arrows.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;
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
}
};
});

return {
type: 'FeatureCollection',
features: [...tails, ...heads]
} 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][];
};
$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 });
}
});
onDestroy(() => {
if (map) {
map.removeImage('arrow-head');
}
});
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} />
2 changes: 2 additions & 0 deletions components/src/maplibre/ArrowSource/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import ArrowSource from './ArrowSource.svelte';
export default ArrowSource;
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.