Skip to content

Commit

Permalink
Add utility for loading images from URLs
Browse files Browse the repository at this point in the history
  • Loading branch information
ciscorn committed Nov 27, 2024
1 parent ae66e7d commit 1ea9c69
Show file tree
Hide file tree
Showing 10 changed files with 256 additions and 8 deletions.
3 changes: 1 addition & 2 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
cache: 'pnpm'

- name: Install dependencies
run: pnpm install
Expand All @@ -27,4 +27,3 @@ jobs:

- name: Lint
run: pnpm run check

2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: "pnpm"
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'
- run: pnpm install
- run: pnpm publish --provenance --access public --no-git-checks
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@sveltejs/vite-plugin-svelte": "^4.0.1",
"@tailwindcss/typography": "^0.5.15",
"@types/eslint": "^9.6.1",
"@types/geojson": "^7946.0.14",
"autoprefixer": "^10.4.20",
"bits-ui": "1.0.0-next.64",
"clsx": "^2.1.1",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

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

5 changes: 3 additions & 2 deletions src/content/examples/Index.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@
'/examples/clusters': 'Clusters',
'/examples/limit-interaction': 'Limit Map Interactions',
'/examples/dynamic-image': 'Dynamic Image',
'/examples/animate-images': 'Animate a series of images',
'/examples/video-on-a-map': 'Video on a map',
'/examples/animate-images': 'Animate a Series of Images',
'/examples/video-on-a-map': 'Video on a Map',
'/examples/canvas-source': 'Canvas Source',
'/examples/fullscreen': 'Fullscreen',
'/examples/geolocate': 'Locate the User',
'/examples/image-loader': 'Loading Images',
'/examples/custom-control': 'Custom Control',
'/examples/custom-protocol': 'Custom Protocols',
'/examples/contour': 'Contour Lines'
Expand Down
71 changes: 71 additions & 0 deletions src/content/examples/image-loader/Images.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<script lang="ts">
import { MapLibre, ImageLoader, GeoJSONSource, SymbolLayer } from 'svelte-maplibre-gl';
import type { FeatureCollection } from 'geojson';
let data: FeatureCollection = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: { type: 'Point', coordinates: [-48.47279, -1.44585] },
properties: { imageName: 'osgeo', year: 2024 }
},
{
type: 'Feature',
geometry: { type: 'Point', coordinates: [0, 0] },
properties: { imageName: 'cat', scale: 0.2 }
},
{
type: 'Feature',
geometry: { type: 'Point', coordinates: [40, -30] },
properties: { imageName: 'popup-debug', name: 'Line 1\nLine 2\nLine 3' }
},
{
type: 'Feature',
geometry: { type: 'Point', coordinates: [-40, -30] },
properties: { imageName: 'popup-debug', name: 'One longer line' }
}
]
};
</script>

<MapLibre
class="h-[60vh] min-h-[300px]"
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
zoom={1.5}
center={{ lng: -10.0, lat: -20 }}
>
<GeoJSONSource {data}>
<ImageLoader
images={{
osgeo: 'https://maplibre.org/maplibre-gl-js/docs/assets/osgeo-logo.png',
cat: 'https://upload.wikimedia.org/wikipedia/commons/7/7c/201408_cat.png',
'popup-debug': [
'https://maplibre.org/maplibre-gl-js/docs/assets/popup_debug.png',
{
// stretchable image
stretchX: [
[25, 55],
[85, 115]
],
stretchY: [[25, 100]],
content: [25, 25, 115, 100],
pixelRatio: 2
}
]
}}
>
<!-- Children components will be added after all images have been loaded -->
<SymbolLayer
layout={{
'text-field': ['get', 'name'],
'icon-image': ['get', 'imageName'],
'icon-size': ['number', ['get', 'scale'], 1],
'icon-text-fit': 'both',
'icon-overlap': 'always',
'text-overlap': 'always'
}}
/>
</ImageLoader>
</GeoJSONSource>
</MapLibre>
14 changes: 14 additions & 0 deletions src/content/examples/image-loader/content.svelte.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
title: Loading Images
description: Utility for loading images from URLs
---

<script lang="ts">
import Demo from "./Images.svelte";
import demoRaw from "./Images.svelte?raw";
import CodeBlock from "../../CodeBlock.svelte";
</script>

<Demo />

<CodeBlock content={demoRaw} />
3 changes: 3 additions & 0 deletions src/lib/maplibre/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export { default as LogoControl } from './controls/LogoControl.svelte';
export { default as CustomControl } from './controls/CustomControl.svelte';
export { default as Hash } from './controls/Hash.svelte';

// utilities
export { default as ImageLoader } from './utilities/ImageLoader.svelte';

// extensions
export { default as PMTilesProtocol } from './extensions/PMTilesProtocol.svelte';
export { default as MapLibreContourSource } from './extensions/MapLibreContourSource.svelte';
Expand Down
35 changes: 32 additions & 3 deletions src/lib/maplibre/map/Image.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
// https://maplibre.org/maplibre-gl-js/docs/API/classes/Map/#addimage
import { onDestroy } from 'svelte';
import { onDestroy, untrack } from 'svelte';
import maplibregl from 'maplibre-gl';
import { getMapContext } from '../contexts.svelte.js';
Expand All @@ -13,12 +13,14 @@
| ImageData
| { width: number; height: number; data: Uint8Array | Uint8ClampedArray }
| maplibregl.StyleImageInterface;
options?: Partial<maplibregl.StyleImageMetadata>;
}
let { id, image: srcImage }: Props = $props();
let { id, image: srcImage, options }: Props = $props();
const mapCtx = getMapContext();
let prevId = id;
let firstRun = true;
$effect(() => {
if (!mapCtx.map) {
Expand All @@ -36,12 +38,39 @@
}
if (!prevImage) {
mapCtx.map.addImage(id, srcImage);
mapCtx.map.addImage(
id,
srcImage,
untrack(() => options)
);
firstRun = true;
} else {
mapCtx.map.updateImage(id, srcImage);
}
});
$effect(() => {
options;
if (!firstRun) {
const image = mapCtx.map?.getImage(id);
if (!image) {
return;
}
image.pixelRatio = options?.pixelRatio ?? 1;
image.sdf = options?.sdf ?? false;
image.stretchX = options?.stretchX;
image.stretchY = options?.stretchY;
image.content = options?.content;
image.textFitWidth = options?.textFitWidth;
image.textFitHeight = options?.textFitHeight;
mapCtx.map?.style.updateImage(id, image);
}
});
$effect(() => {
firstRun = false;
});
onDestroy(() => {
mapCtx.map?.removeImage(id);
});
Expand Down
127 changes: 127 additions & 0 deletions src/lib/maplibre/utilities/ImageLoader.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<script lang="ts">
import { onDestroy, type Snippet } from 'svelte';
import { getMapContext } from '../contexts.svelte.js';
import maplibregl from 'maplibre-gl';
const mapCtx = getMapContext();
if (!mapCtx.map) throw new Error('Map instance is not initialized.');
let {
images,
loading = $bindable(),
children
}: {
images: Record<string, string | [string, Partial<maplibregl.StyleImageMetadata>]>;
loading?: boolean;
children?: Snippet;
} = $props();
let initialLoaded = $state(false);
// map from loaded image id to url
const loadedImages: Map<string, string> = new Map();
$effect(() => {
// Remove images that are not in the new list or have a different url
for (const [id, url] of loadedImages) {
const src = images[id];
if (src) {
const newUrl = Array.isArray(src) ? src[0] : src;
if (url === newUrl) {
continue;
} else {
loadedImages.delete(id);
}
} else {
loadedImages.delete(id);
mapCtx.map?.removeImage(id);
}
}
// Load and add images that are not already loaded
const tasks = [];
for (const [id, src] of Object.entries(images)) {
// if already loaded
if (loadedImages.has(id)) {
// Update image options if necessary
const image = mapCtx.map?.getImage(id);
if (image) {
const options = Array.isArray(src) ? src[1] : {};
let changed = false;
if (image.pixelRatio !== (options.pixelRatio ?? 1)) {
image.pixelRatio = options.pixelRatio ?? 1;
changed = true;
}
if (image.sdf !== (options.sdf ?? false)) {
image.sdf = options.sdf ?? false;
changed = true;
}
if (image.stretchX !== options.stretchX) {
image.stretchX = options.stretchX;
changed = true;
}
if (image.stretchY !== options.stretchY) {
image.stretchY = options.stretchY;
changed = true;
}
if (image.content !== options.content) {
image.content = options.content;
changed = true;
}
if (image.textFitHeight !== options.textFitHeight) {
image.textFitHeight = options.textFitHeight;
changed = true;
}
if (image.textFitWidth !== options.textFitWidth) {
image.textFitWidth = options.textFitWidth;
changed = true;
}
if (changed) {
mapCtx.map?.style.updateImage(id, image);
}
}
continue;
}
const [url, options] = Array.isArray(src) ? src : [src, undefined];
loadedImages.set(id, url);
tasks.push(
(async () => {
const image = await mapCtx.map?.loadImage(url);
if (mapCtx.map?.getImage(id)) {
mapCtx.map?.removeImage(id);
}
if (image && loadedImages.has(id)) {
mapCtx.map?.addImage(id, image?.data, options);
}
})()
);
}
if (tasks.length > 0) {
loading = true;
Promise.allSettled([tasks]).then((results) => {
const failures = results.filter((r) => r.status === 'rejected');
if (failures.length > 0) {
console.error(`${failures.length} images failed to load:`, failures);
}
initialLoaded = true;
loading = false;
});
} else {
initialLoaded = true;
}
});
onDestroy(() => {
for (const id of loadedImages.keys()) {
mapCtx.map?.removeImage(id);
}
});
</script>

{#if initialLoaded}
{@render children?.()}
{/if}

0 comments on commit 1ea9c69

Please sign in to comment.