Mapbox-gl-js: Feature Request: SVG symbols (via addImage)

Created on 26 Oct 2017  路  12Comments  路  Source: mapbox/mapbox-gl-js

Motivation

Use with Webpack (for modern workflows)

import mySVG from './foo.svg'
map.addImage(mySvg) // or similar

This isn't a great solution:
_Can we use svg symbols? #1577_

And this isn't either (yuck, callbacks).

mapbox-gl-js version: v0.41.0

Most helpful comment

Thanks @jfirebaugh! There was something srewy with my version (i was using a fork).
Now it's 4 lines of code and voila:

import mySVG from 'foo.svg'
let img = new Image(20,20)
img.onload = ()=>map.addImage('bus', img)
img.src = mySVG

All 12 comments

Also I'd like to note that the docs say addImage accepts an HTMLImageElement, but I haven't been able to get this to work.

I get this error: Cannot read property 'sdfIcons' of undefined

@krabbypattified did you try mapboxgl.Marker() ?

https://www.mapbox.com/mapbox-gl-js/api/#marker

The following tutorial works also with SVG:
https://www.mapbox.com/help/building-a-store-locator/#add-custom-markers

@pathmapper yes I have tried Markers. I need to animate it so I even jerry-rigged an animation mechanism for it. The problem is that on mobile it gets slow and the markers lag behind the rest of the map. Basically all I鈥檓 trying to accomplish is this demo:
https://www.mapbox.com/mapbox-gl-js/example/animate-point-along-route/
Except with my own SVGs, not an airplane.

You need to rasterize your SVG so it can be used as an icon-image. You can do this by rendering the SVG into a canvas:
https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Drawing_DOM_objects_into_a_canvas

Then you should be able to get the image data from the canvas 2d context:
https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getImageData

map.addImage allows you to pass this ImageData instead of an HTMLImageElement.

Passing an HTMLImageElement (or ImageData) is the API we're likely to support here. In fact, it should already work. Are you sure you're not using a build from source more recent than the official v0.41.0 release? The error you're getting looks like #5516, which was introduced after 0.41.0.

Thanks @jfirebaugh! There was something srewy with my version (i was using a fork).
Now it's 4 lines of code and voila:

import mySVG from 'foo.svg'
let img = new Image(20,20)
img.onload = ()=>map.addImage('bus', img)
img.src = mySVG

@krabbypattified how do you add the image to the layer?
I tried your solution different ways but I get the same error message:

index.js:2178 Error: An image with this name already exists.
    at i.addImage (mapbox-gl.js:32)
    at n.addImage (mapbox-gl.js:32)
    at Image.img.onload (Component.jsx:11)

And the layers don't show.

Here is my code:

let img = new Image(15, 15);
img.onload = () => map.addImage("girls", img);
img.src = girlsIcon;

map.addLayer({
     id: id,
     type: "symbol",
     source: {
        type: type,
        url: url
     },
     "source-layer": sourceLayer,
     layout: {
        visibility: visibility,
        "icon-image": "girls", <I also tried with "girlsIcon" and "img.src">
        "icon-allow-overlap": true,
        "icon-size": 0.95
     }
})

Ive come across the behavior as described by @gabrielmoncea (especially in chrome) and think ive figured out whats going on.

The issue is that image.src is fundamentally async. Thus by the time you try to run map.addImage the svg may (or may not be) rastered. The solution is to wait on the addImage until the image is actually there. My workaround looks something like

import { BehaviorSubject } from 'rxjs'
import { first } from 'rxjs/operators'

const setupMarkers = async () => {
    const image = new Image(50, 55)
    const imageLoaded = new BehaviorSubject(false);
    image.addEventListener('load', () => { imageLoaded.next(true) }, { once: true })
    image.src = svgData
    await imageLoaded.pipe(first()).toPromise()

    map.addImage('exampleSvg', image)
}

Had a similar issue on Firefox v65.

Turns out Firefox currently requires both a width and height to be explicitly defined on the SVG in order for it to render correctly with ctx.drawImage (used internally by map.addImage) ... updating any SVGs with the dimensions of the image (on the fly or otherwise) resolves the issue.

Perhaps a little bit overkill, but Mapbox-gl could attempt to resolve this internally by type-checking the src of a passed HTMLImageElement and updating with width & height attributes where appropriate. Or warning when the resultant image data is fully transparent, e.g.

const isTransparent = (data /* canvasImageData */) => {
  const len = data.length / 4; // insert fancy accuracy factor here
  for(let i = 0; i < len; i += 1) {
    // insert fancy inside-out iterator here
    if (data[(i * 4) + 3]) { // check opacity is larger than zero
      return false;
      break;
    }
  }
  return true;
};
if (isTransparent(imgData)) console.warn('Your symbol is invisible!');

I modified @bdirito's excellent solution to remove the dependency on rxjs, and it's working well for me. The first method generates the ImageData from an external path, and the second generates the string for an inline image src.

export const svgPathToImage = ({ path, width, height }: { path: string; width: number; height: number }) =>
  new Promise(resolve => {
    const image = new Image(width, height);
    image.addEventListener('load', () => resolve(image));
    image.src = path;
  });

// This does a lookup of the symbol's name to find its string value
export const symbolAsInlineImage = ({ name, width, height }: { name: string; width: number; height: number }) =>
  svgPathToImage({ path: 'data:image/svg+xml;charset=utf-8;base64,' + btoa(symbols[name]), width, height });

I'm using react-mapbox-gl, and having this promise resolve once the image is loaded lets me delay rendering of the layer until that's complete.

Thanks @jfirebaugh! There was something srewy with my version (i was using a fork).
Now it's 4 lines of code and voila:

import mySVG from 'foo.svg'
let img = new Image(20,20)
img.onload = ()=>map.addImage('bus', img)
img.src = mySVG

SOLVED:- This is the best solution I have found - thanks so much for sharing the outcome of your journey!

In my instance, I needed to load "many" svgs, so simply created a map of them with and ran this snippet in a loop:-

// CustomIcons.js
import CustSVG from './CustSVG';
import CustBlueSVG from './CustBlueSVG';
const CustomIcons = [
    {
        src: CustSVG,
        name: 'custom-svg'
    },
    {
        src: CustBlueSVG,
        name: 'custom-blue-svg'
    }
];

export default CustomIcons;

Then I import these, and use them within the useEffect hook of my Component:

// MapComponent.js
import CustomIcons from './CustomIcons';

function MapComponent() {

    // ...

    useEffect( () => {
        map.on('load', function () {

            CustomIcons.forEach(icon => {
                let customIcon = new Image(24, 24);
                customIcon.onload = () => map.addImage(icon.name, customIcon)
                customIcon.src = icon.src;
            });

            // ... the rest of your map code that uses the SVGs
        });
    }, []);

}

Thanks again, super helpful!

Was this page helpful?
0 / 5 - 0 ratings

Related issues

rasagy picture rasagy  路  3Comments

stevage picture stevage  路  3Comments

aderaaij picture aderaaij  路  3Comments

aendrew picture aendrew  路  3Comments

aaronlidman picture aaronlidman  路  3Comments