Skip to content

Commit dbb2615

Browse files
committed
Add utility for loading images from URLs
1 parent ae66e7d commit dbb2615

File tree

6 files changed

+244
-5
lines changed

6 files changed

+244
-5
lines changed

src/content/examples/Index.svelte

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@
1111
'/examples/clusters': 'Clusters',
1212
'/examples/limit-interaction': 'Limit Map Interactions',
1313
'/examples/dynamic-image': 'Dynamic Image',
14-
'/examples/animate-images': 'Animate a series of images',
15-
'/examples/video-on-a-map': 'Video on a map',
14+
'/examples/animate-images': 'Animate a Series of Images',
15+
'/examples/video-on-a-map': 'Video on a Map',
1616
'/examples/canvas-source': 'Canvas Source',
1717
'/examples/fullscreen': 'Fullscreen',
1818
'/examples/geolocate': 'Locate the User',
19+
'/examples/image-loader': 'Loading Images',
1920
'/examples/custom-control': 'Custom Control',
2021
'/examples/custom-protocol': 'Custom Protocols',
2122
'/examples/contour': 'Contour Lines'
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<script lang="ts">
2+
import { MapLibre, ImageLoader, GeoJSONSource, SymbolLayer } from 'svelte-maplibre-gl';
3+
4+
let data: GeoJSON.FeatureCollection = {
5+
type: 'FeatureCollection',
6+
features: [
7+
{
8+
type: 'Feature',
9+
geometry: { type: 'Point', coordinates: [-48.47279, -1.44585] },
10+
properties: { imageName: 'osgeo', year: 2024 }
11+
},
12+
{
13+
type: 'Feature',
14+
geometry: { type: 'Point', coordinates: [0, 0] },
15+
properties: { imageName: 'cat', scale: 0.2 }
16+
},
17+
{
18+
type: 'Feature',
19+
geometry: { type: 'Point', coordinates: [40, -30] },
20+
properties: { imageName: 'popup-debug', name: 'Line 1\nLine 2\nLine 3' }
21+
},
22+
{
23+
type: 'Feature',
24+
geometry: { type: 'Point', coordinates: [-40, -30] },
25+
properties: { imageName: 'popup-debug', name: 'One longer line' }
26+
}
27+
]
28+
};
29+
</script>
30+
31+
<MapLibre
32+
class="h-[60vh] min-h-[300px]"
33+
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
34+
zoom={1.5}
35+
center={{ lng: -10.0, lat: -20 }}
36+
>
37+
<GeoJSONSource {data}>
38+
<ImageLoader
39+
images={{
40+
osgeo: 'https://maplibre.org/maplibre-gl-js/docs/assets/osgeo-logo.png',
41+
cat: 'https://upload.wikimedia.org/wikipedia/commons/7/7c/201408_cat.png',
42+
'popup-debug': [
43+
'https://maplibre.org/maplibre-gl-js/docs/assets/popup_debug.png',
44+
{
45+
// stretchable image
46+
stretchX: [
47+
[25, 55],
48+
[85, 115]
49+
],
50+
stretchY: [[25, 100]],
51+
content: [25, 25, 115, 100],
52+
pixelRatio: 2
53+
}
54+
]
55+
}}
56+
>
57+
<!-- Children components will be added after all images have been loaded -->
58+
<SymbolLayer
59+
layout={{
60+
'text-field': ['get', 'name'],
61+
'icon-image': ['get', 'imageName'],
62+
'icon-size': ['number', ['get', 'scale'], 1],
63+
'icon-text-fit': 'both',
64+
'icon-overlap': 'always',
65+
'text-overlap': 'always'
66+
}}
67+
/>
68+
</ImageLoader>
69+
</GeoJSONSource>
70+
</MapLibre>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
title: Loading Images
3+
description: Utility for loading images from URLs
4+
---
5+
6+
<script lang="ts">
7+
import Demo from "./Images.svelte";
8+
import demoRaw from "./Images.svelte?raw";
9+
import CodeBlock from "../../CodeBlock.svelte";
10+
</script>
11+
12+
<Demo />
13+
14+
<CodeBlock content={demoRaw} />

src/lib/maplibre/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ export { default as LogoControl } from './controls/LogoControl.svelte';
4949
export { default as CustomControl } from './controls/CustomControl.svelte';
5050
export { default as Hash } from './controls/Hash.svelte';
5151

52+
// utilities
53+
export { default as ImageLoader } from './utilities/ImageLoader.svelte';
54+
5255
// extensions
5356
export { default as PMTilesProtocol } from './extensions/PMTilesProtocol.svelte';
5457
export { default as MapLibreContourSource } from './extensions/MapLibreContourSource.svelte';

src/lib/maplibre/map/Image.svelte

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script lang="ts">
22
// https://maplibre.org/maplibre-gl-js/docs/API/classes/Map/#addimage
33
4-
import { onDestroy } from 'svelte';
4+
import { onDestroy, untrack } from 'svelte';
55
import maplibregl from 'maplibre-gl';
66
import { getMapContext } from '../contexts.svelte.js';
77
@@ -13,12 +13,14 @@
1313
| ImageData
1414
| { width: number; height: number; data: Uint8Array | Uint8ClampedArray }
1515
| maplibregl.StyleImageInterface;
16+
options?: Partial<maplibregl.StyleImageMetadata>;
1617
}
1718
18-
let { id, image: srcImage }: Props = $props();
19+
let { id, image: srcImage, options }: Props = $props();
1920
2021
const mapCtx = getMapContext();
2122
let prevId = id;
23+
let firstRun = true;
2224
2325
$effect(() => {
2426
if (!mapCtx.map) {
@@ -36,12 +38,39 @@
3638
}
3739
3840
if (!prevImage) {
39-
mapCtx.map.addImage(id, srcImage);
41+
mapCtx.map.addImage(
42+
id,
43+
srcImage,
44+
untrack(() => options)
45+
);
46+
firstRun = true;
4047
} else {
4148
mapCtx.map.updateImage(id, srcImage);
4249
}
4350
});
4451
52+
$effect(() => {
53+
options;
54+
if (!firstRun) {
55+
const image = mapCtx.map?.getImage(id);
56+
if (!image) {
57+
return;
58+
}
59+
image.pixelRatio = options?.pixelRatio ?? 1;
60+
image.sdf = options?.sdf ?? false;
61+
image.stretchX = options?.stretchX;
62+
image.stretchY = options?.stretchY;
63+
image.content = options?.content;
64+
image.textFitWidth = options?.textFitWidth;
65+
image.textFitHeight = options?.textFitHeight;
66+
mapCtx.map?.style.updateImage(id, image);
67+
}
68+
});
69+
70+
$effect(() => {
71+
firstRun = false;
72+
});
73+
4574
onDestroy(() => {
4675
mapCtx.map?.removeImage(id);
4776
});
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<script lang="ts">
2+
import { onDestroy, type Snippet } from 'svelte';
3+
import { getMapContext } from '../contexts.svelte.js';
4+
5+
const mapCtx = getMapContext();
6+
if (!mapCtx.map) throw new Error('Map instance is not initialized.');
7+
8+
let {
9+
images,
10+
loading = $bindable(),
11+
children
12+
}: {
13+
images: Record<string, string | [string, Partial<maplibregl.StyleImageMetadata>]>;
14+
loading?: boolean;
15+
children?: Snippet;
16+
} = $props();
17+
18+
let initialLoaded = $state(false);
19+
20+
// map from loaded image id to url
21+
const loadedImages: Map<string, string> = new Map();
22+
23+
$effect(() => {
24+
// Remove images that are not in the new list or have a different url
25+
for (const [id, url] of loadedImages) {
26+
const src = images[id];
27+
if (src) {
28+
const newUrl = Array.isArray(src) ? src[0] : src;
29+
if (url === newUrl) {
30+
continue;
31+
} else {
32+
loadedImages.delete(id);
33+
}
34+
} else {
35+
loadedImages.delete(id);
36+
mapCtx.map?.removeImage(id);
37+
}
38+
}
39+
40+
// Load and add images that are not already loaded
41+
const tasks = [];
42+
for (const [id, src] of Object.entries(images)) {
43+
// if already loaded
44+
if (loadedImages.has(id)) {
45+
// Update image options if necessary
46+
const image = mapCtx.map?.getImage(id);
47+
if (image) {
48+
const options = Array.isArray(src) ? src[1] : {};
49+
let changed = false;
50+
if (image.pixelRatio !== (options.pixelRatio ?? 1)) {
51+
image.pixelRatio = options.pixelRatio ?? 1;
52+
changed = true;
53+
}
54+
if (image.sdf !== (options.sdf ?? false)) {
55+
image.sdf = options.sdf ?? false;
56+
changed = true;
57+
}
58+
if (image.stretchX !== options.stretchX) {
59+
image.stretchX = options.stretchX;
60+
changed = true;
61+
}
62+
if (image.stretchY !== options.stretchY) {
63+
image.stretchY = options.stretchY;
64+
changed = true;
65+
}
66+
if (image.content !== options.content) {
67+
image.content = options.content;
68+
changed = true;
69+
}
70+
if (image.textFitHeight !== options.textFitHeight) {
71+
image.textFitHeight = options.textFitHeight;
72+
changed = true;
73+
}
74+
if (image.textFitWidth !== options.textFitWidth) {
75+
image.textFitWidth = options.textFitWidth;
76+
changed = true;
77+
}
78+
79+
if (changed) {
80+
mapCtx.map?.style.updateImage(id, image);
81+
}
82+
}
83+
84+
continue;
85+
}
86+
87+
const [url, options] = Array.isArray(src) ? src : [src, undefined];
88+
loadedImages.set(id, url);
89+
tasks.push(
90+
(async () => {
91+
const image = await mapCtx.map?.loadImage(url);
92+
if (mapCtx.map?.getImage(id)) {
93+
mapCtx.map?.removeImage(id);
94+
}
95+
if (image && loadedImages.has(id)) {
96+
mapCtx.map?.addImage(id, image?.data, options);
97+
}
98+
})()
99+
);
100+
}
101+
102+
if (tasks) {
103+
loading = true;
104+
Promise.allSettled([tasks]).then(() => {
105+
initialLoaded = true;
106+
loading = false;
107+
});
108+
} else {
109+
initialLoaded = true;
110+
}
111+
});
112+
113+
onDestroy(() => {
114+
for (const [id, _] of loadedImages) {
115+
mapCtx.map?.removeImage(id);
116+
}
117+
});
118+
</script>
119+
120+
{#if initialLoaded}
121+
{@render children?.()}
122+
{/if}

0 commit comments

Comments
 (0)