mapbox / mapbox-gl-js

Interactive, thoroughly customizable maps in the browser, powered by vector tiles and WebGL
https://docs.mapbox.com/mapbox-gl-js/
Other
10.91k stars 2.2k forks source link

Support icon-color for non-SDF icons #3605

Open ivelander opened 7 years ago

ivelander commented 7 years ago

We have a use case that requires the ability to use icons for data-driven shapes as well as data-driven coloring of those icons. Currently we can get half of this functionality from the circle layer type and half from the symbol layer type. We have a hacked solution that merges the basic features of both on a fork of mapbox-gl-js (along with a style-spec and shader fork)...

https://github.com/ivelander/mapbox-gl-js

It would be great to be able to get off this fork, something we could do if support was added for icon-color for non-SDF icons.

ivelander commented 7 years ago

Here is an example of the result I am after, this uses the fork linked in the first comment to use a sprite sheet texture and a property based color.

screen shot 2016-11-13 at 9 51 00 am
lucaswoj commented 7 years ago

This would be great to support on fill-pattern and line-pattern too 😄

rhagigi commented 7 years ago

Just in case this helps anyone -- I solved this issue on my map by using a custom icon font (I used http://fontastic.me but FontAwesome or anything else will work just the same). Just upload the font to mapbox studio and use the text-font, text-color, text-size, etc styling options for the geoJson layer and just use no icon. You can do some pretty awesome stuff with that and it has the added benefit of making it much easier to set up an icon baseline size and scale it up or down (vs my previously uploaded SVGs to mapbox studio which all ended up slightly different sizes).

aidanlister commented 7 years ago

@rhagigi any chance you can post a full example / screenshot?

rhagigi commented 7 years ago

Sure thing, I'll put something together when I get home.

rhagigi commented 7 years ago

image

The way I'm achieving it is fairly simple. As I mentioned before, simply create a font using fontastic or another font generation service/build-tool, or use an existing icon font like fontawesome or material-icons. Then:

1) Upload that font on the Mapbox Studio Font Page. a) I think I had to also use the font on some layer in the map to get it to come down with the map and be usable. Could be a fake layer or just a layer you're not even really using in your style, like maybe "airport labels" or something obscure. Make a note of what the font is called there, you'll need it later see example screenshot

2) Add a GeoJSON source to the map as usual

 map.addSource('MySourceId', {
        type: 'geojson',
        data: geoJson
      });

3) Add a symbol layer to the map to show that source, using our font and a bunch of layout/styling tricks to get it working to your tastes:

map.addLayer({
        id: 'MyLayerId',
        type: 'symbol',
        source: 'MySourceId',
        layout: {
          'text-line-height': 1, // this is to avoid any padding around the "icon"
          'text-padding': 0,
          'text-anchor': 'bottom', // change if needed, "bottom" is good for marker style icons like in my screenshot,
          'text-allow-overlap': true, // assuming you want this, you probably do
          'text-field': iconString, // IMPORTANT SEE BELOW: -- this should be the unicode character you're trying to render as a string -- NOT the character code but the actual character,
          'icon-optional': true, // since we're not using an icon, only text.
          'text-font': ['FontAwesome Regular'], // see step 1 -- whatever the icon font name,
          'text-size': 18 // or whatever you want -- dont know if this can be data driven...
        },
        paint: {
          'text-translate-anchor': 'viewport', // up to you to change this -- see the docs
          'text-color': '#00FF00' // whatever you want -- can even be data driven using a `{featureProperty}`,
        }
      });

For more layout/paint properties you can use (like pitch/rotation styling, etc) and more info, visit the Mapbox Style spec page for symbol layers

4) In order to set the text field, you need to actually have a string value of the icon you want. If you already know the character code for the one you need, you can just hardcode it and use String.fromCharCode to get a string value from that character code from the font css. For example, fa-calendar is "\f073".

But, that can be hard when you don't know what the character will be in the font and you want to use the "classname" version for this, like fa-calendar or fa-plane or whatever. This makes you more resilient in case you upgrade font versions, as the underlying character-code mapping may change. Make sure you load the font in your CSS as well for this technique -- I use fontawesome on my site anyway so it wasn't extraneous.

This is kinda-font-awesome specific, as they load the character using CSS into the :before content based on the classname (fa-calendar example), but you can use a similar technique for most icon fonts. I wrapped my function in lodash memoize as once you call this once for a classname, you should never need to call it again.

const getFontAwesomeStringFromClassname = _memoize((className) => {
  const element = document.createElement('i');
  element.className = 'fa ' + className; // font-awesome specific where `className`==="fa-calendar"
  element.style.display = 'none'; //  or not
  document.body.appendChild(element);
  const contentValue = window.getComputedStyle(
    element, ':before'
  ).getPropertyValue('content');
  document.body.removeChild(element);
  return contentValue;
});

You can then pass that returned contentValue into text-field or -- if you want, use it in your GeoJSON source to use different icons for different features, and use the {someFeatureProperty} syntax to grab the textField from each feature property. I suggest a similar approach for the text-color property if you want to drive it based on data.

Unfortunately, I'm not sure if mapbox-gl supports data-driven styling yet for font-size (they might now), so I just used multiple layers with filters on them to do dynamic sizing (e.g.: one layer size 40 and one layer size 20, with filters for the feature type).

Hopefully that wasn't too much craziness and I didn't skip anything important. Let me know if you have any questions about all that.

aidanlister commented 7 years ago

That's great Royi, thanks!

ivelander commented 7 years ago

We are using a similar strategy as well. I'd be curious if anyone (perhaps the mapbox folks) have any input on potential performance or stability concerns with this strategy. I know there is a lot of logic in the text placement logic to manage collisions, positioning in relation to the icon, etc. Our apps can result in a lot of data on the map, so performance quickly becomes a concern with everything we are doing (part of the appeal of mapbox-gl in the first place).

aidanlister commented 7 years ago

We've solved this a different way!

We've used circles and icons as separate layers ... see this:

    icon_layer = {
        "id": "images",
        "type": "symbol",
        "source": "markers",
        "layout": {
            "icon-image": "{icon}-15",
            "icon-allow-overlap": True,
            "icon-ignore-placement": True,
            "icon-size": {
                "base": 1,
                "type": "exponential",
                "stops": [
                    [10, 0],
                    [12, 0.8],
                    [15, 0.8],
                    [18, 1]
                ]
            },

        }
    }
    circle_layer = {
        "id": "markers",
        "type": "circle",
        "source": "markers",
        "paint": {
            'circle-radius': {
                'property': 'task-priority',
                'type': 'categorical',
                'stops': [[1, 5], [5, 5], [10, 10]],
            },
            'circle-color': {
                'property': 'task-status',
                'type': 'categorical',
                'stops': list(qs.model.STATUS_CHOICES_COLOURS.items()),
            },
            'circle-stroke-width': 1,
            'circle-stroke-color': '#fff',
            'circle-stroke-opacity': 1,
        }
    }
    layers = [circle_layer, icon_layer]

This is what it looks like (if you want the icons inside the circles, change base size to 0.7):

bc24bf1a-1565-11e7-8daf-db1854c7a42b

davidascher commented 7 years ago

@rhagigi Thanks for the detailed description. I'm almost there.

I'm finding that even though I have added FontAwesome (or Material Icons) as a font to my Mapbox style, and even though some version of that font gets downloaded by the mapbox component, no markers show up.

If i use a font that I know exists like Arial Unicode MS Bold then I see their (ugly ;) letters.

I believe the font isn't being properly downloaded, based on the network traffic:

screenshot 2017-04-23 12 12 11

56 bytes seems a bit short. The file is a PBF file that contains:


"
FontAwesome Regular65280-65535

Any suggestion how to debug this?

One hint might be that the font name isn't actually being displayed:

screenshot 2017-04-23 12 15 04

(it'd be lovely if mapbox just published these two very common and openly licensed fonts)

davidascher commented 7 years ago

Hmm, reading on the API (https://www.mapbox.com/api-documentation/#fonts) it seems that API call is just getting a single glyph, which should be fine. Maybe it's just my character lookup isn't correct.

davidascher commented 7 years ago

Figured it out. The mapping from icon to a string was where I was failing.

For the next people who run into this issue, here are the incantations to map from a font-awesome or Material Design Icons to a string that will fit in with @rhagigi's method above.

For Font-Awesome, find the icon like, e.g. http://fontawesome.io/icon/camera/, you'll notice it says it is at Unicode location f030.

screenshot 2017-04-23 13 23 44

The string you want is therefore: String.fromCharCode("0xf030").

For Material Icons, you'll notice in the instructions for Web Font under "IE usage" that the entity is .

screenshot 2017-04-23 13 22 08

That corresponds to using the string String.fromCharCode("0xe8fc").

It's definitely a manual mapping, but it helped me out.

rohanrichards commented 6 years ago

I was running into an issue trying to use Glyphicons pro icon packs inside of an Ionic project using Mapbox GL. In Ionic, typescript forces String.fromCharCode() to be accept only a number, so you run into issues when you need to map to the higher end Unicode characters. For the character I wanted (shown as UTF+E592 on the Glyphicons website) I was trying `String.FromCharCode(592)' and just getting blank characters. And I was unable to use e592 or other variations due to the above typing limitations.

e.g 0xe592 is not a number so you get compilation errors. See: MDN FromCharCode - Getting it to work with higher values

The solution was to actually use String.fromCodePoint() which accepts an octet: String.fromCodePoint(0xe592)

Kilatsat commented 6 years ago

The biggest issue I have encountered using non-SDF icons is that icon-halo-XXX options do not work. non-SDF with valid a-channel seem to work as expected with icon-color

stdmn commented 6 years ago

@davidascher @rhagigi Trying your methods to use Google's Material Icons [I've also tried with Font Awesome] and I've not been able to get the icons to show up. I've tested the Material Icons on other parts of the page (ie: screen shot 2018-06-04 at 6 27 09 pm) without problems and used your exact coding. The layer also works with the Maki icons.

The icon is also showing up in console after using String.formCharCode().

screen shot 2018-06-04 at 6 23 35 pm

Here's the code: [

screen shot 2018-06-04 at 6 25 09 pm

](url)

@rhagigi Does this method only work using Mapbox Studio-hosted fonts? Or can I use the Google Material Icons CDN?

stdmn commented 6 years ago

For reference, here's the manual way to convert font for use as symbol text layer:

  1. Install genfontgl (https://github.com/sabas/genfontgl) via npm

  2. Convert font to pbf directory by following genfont instructions. Directory will be a folder of .pbf files (see below):

    screen shot 2018-06-11 at 12 59 37 pm
  3. Host all fonts you want to show up on your style (including the icon fonts [Material Icons or FontAwesome]) in a folder. Folder should look like this:

    screen shot 2018-06-11 at 12 59 22 pm
  4. Upload to a server (Github, S3, etc)

  5. On style.json file, change glyphs address to ("glyphs": "your_url_here/{fontstack}/{range}.pbf")

  6. Now you can follow @rhagigi 's instructions above.

Hope this helps!

everhardt commented 5 years ago

@Kilatsat I can't reproduce your comment that icon-color does work with images with an alpha channel. I tried with png files that are partially transparent. Am I doing something wrong?

Kilatsat commented 5 years ago

@everhardt I should have been more clear in my previous response: it appears to respect a=0. I did not test semitransparent images.

everhardt commented 5 years ago

@Kilatsat I'm sorry, I'm still not clear on what you are saying. Do you say that it's possible to change the color of certain non-SDF images or are you saying that it is possible to make an image invisible (completely transparent, alpha = 0) by changing the icon-color paint property to some value.

I fail to achieve either, but I'm interested in the former..

Kilatsat commented 5 years ago

@everhardt I can help you modify an existing example to illustrate the point I was originally making:

  1. Start from the code contained in https://www.mapbox.com/mapbox-gl-js/example/add-image/
  2. Modify line 29 to read map.addImage('cat', image, { sdf: true });
  3. In the map.addLayer add a new section that reads "paint": {"icon-color": "orange"}

This modification causes the cat to appear orange instead of the default black even though the cat image is non-SDF.

everhardt commented 5 years ago

@Kilatsat Thanks a lot, that is exactly what I was looking for!

ahmedkhan1039 commented 5 years ago

I have thoroughly investigated the issue. The problem is in the use of map.loadImage() function. If we reference a file by its path i-e map.loadImage('../../cat.png') then even if you make { sdf: true }, you will not able to change the color and other related features. If you want to change color you have to convert the image into its base64 equivalent. const catImg = require('../../cat.png'); and then use the map.loadImage() function to load it. Now if you do { sdf: true } you can successfully change the color or related features.

melroy89 commented 5 years ago

@rhagigi I opened a pull request, so hopefully this will be easier in the future: https://github.com/openmaptiles/fonts/pull/9 @stdmn You can download all pdb files from here (including my addition of Font Awesome 5 Free solid icons): https://github.com/danger89/fonts/tree/gh-pages

To shown the icon you need to use the hex value as a string. Eg for a nice marker icon of Font Awesome 5 solid, try:

    "layout": {
       'text-line-height': 1, // this is to avoid any padding around the "icon"
       'text-padding': 0,
       'text-anchor': 'center', // center, so when rotating the map, the "icon" stay on the same location
       'text-offset': [0, -0.3], // give it a little offset on y, so when zooming it stay on the right place
       'text-allow-overlap': true,
       'text-ignore-placement': true,
       'text-field': String.fromCharCode("0xF3C5"),
       'icon-optional': true, // since we're not using an icon, only text.
       'text-font': ['Font Awesome 5 Free Solid'],
       'text-size': 35
    },
    "paint": {
      'text-translate-anchor': 'viewport', 
       'text-color': ['get', 'color'] // get color from the properties geojson file !
    }

geojson data file should have atleast "color" property, eg:

{ ..., "properties":{"color": "#86EA66"}, "geometry": .....

North up 2D: afbeelding

Rotated & tilted: afbeelding

Rotated & tilted 180-degrees: afbeelding

Zoomed-out: afbeelding

sansumbrella commented 5 years ago

Piggy-backing on this issue since I recently ran into a need for coloring icons based on data. Currently working around it by using multiple SVGs, though I like the font-awesome workaround suggested above.

Coloring icons would be an excellent feature to add for the cost of a single multiply in our shader and 24 bits of color data.

strech345 commented 4 years ago

@everhardt I can help you modify an existing example to illustrate the point I was originally making:

  1. Start from the code contained in https://www.mapbox.com/mapbox-gl-js/example/add-image/
  2. Modify line 29 to read map.addImage('cat', image, { sdf: true });
  3. In the map.addLayer add a new section that reads "paint": {"icon-color": "orange"}

This modification causes the cat to appear orange instead of the default black even though the cat image is non-SDF.

Why this possiblity is not documented? Me help this information a lot!

HarelM commented 4 years ago

For anyone reading this I have came across the following solution (haven't tried it yet), which is different than using font awsome characters (which I think is problematic if you read the style from a json file without replacing anything like I need to): https://www.npmjs.com/package/@elastic/spritezero https://www.npmjs.com/package/@elastic/spritezero-cli This fork can create an sdf image from SVG files. These generated files can be used as sprites for mapbox-gl to receive the required behavior. Here's an example (I didn't write it just came across it): http://www.npeihl.com/maki-sdf-sprites/ I have looked at the code and it basically loads the image to Image DOM element and adds a single sdf image using addImage(... { sdf: true}). I hope it can be used as a sprite link instead but I haven't tried it yet. @nickpeihl can you confirm?

nickpeihl commented 4 years ago

I hope it can be used as a sprite link instead but I haven't tried it yet. @nickpeihl can you confirm?

@HarelM Are you asking if it's possible to set the URL to the spritesheet in a style definition? I think that should work, because the spritesheet.json file (example) includes { sdf: true } for each sprite.

HarelM commented 4 years ago

@nickpeihl thanks for the info! Here's an example of a colored icon using your example sdf sprite. https://stackblitz.com/edit/mapbox-simple-map-sdf-sprite image The only caveat I found is that spritezero-cli can't be installed on windows. It can be installed using ubuntu linux container with docker, but it's a hassle...

fxi commented 4 years ago

Here is a simple docker image to convert a folder of ./svg/*.svg files into a folder of ./dist/sprite*, with {sdf: true} set for each sprite :

docker run -v $(pwd)/dist:/dist -v $(pwd)/svg:/svg fredmoser/svg_to_sprite:latest

Repo : svgToSprite

cablehead commented 1 year ago

Hopefully that wasn't too much craziness and I didn't skip anything important. Let me know if you have any questions about all that.

This is a very helpful work around, thanks @rhagigi!

  1. Upload that font on the Mapbox Studio Font Page. a) I think I had to also use the font on some layer in the map to get it to come down with the map and be usable. Could be a fake layer or just a layer you're not even really using in your style, like maybe "airport labels" or something obscure

For shame mapbox :/

trytuna commented 7 months ago
const getFontAwesomeStringFromClassname = _memoize((className) => {
  const element = document.createElement('i');
  element.className = 'fa ' + className; // font-awesome specific where `className`==="fa-calendar"
  element.style.display = 'none'; //  or not
  document.body.appendChild(element);
  const contentValue = window.getComputedStyle(
    element, ':before'
  ).getPropertyValue('content');
  document.body.removeChild(element);
  return contentValue;
});

I did an even simpler and muuuch more performant version of that:

  1. Install dependencies
npm install @fortawesome/fontawesome-svg-core @fortawesome/free-solid-svg-icons

Here is an Example that integrates nicely with Angular

import { Injectable } from '@angular/core';
import { findIconDefinition, library, parse } from '@fortawesome/fontawesome-svg-core';
import { fas } from '@fortawesome/free-solid-svg-icons';

@Injectable({
  providedIn: 'root'
})
export class MapService {
  constructor() {
    library.add(fas);
  }

  public fontAwesomeCharacterCode(iconName: string): string {
    const parseIcon = parse.icon(iconName);
    const icon = findIconDefinition(parseIcon)
    return String.fromCharCode(parseInt(icon.icon[3], 16));
  }
}

Use it in a Layer

this.mapComponent.mapInstance.addLayer({
  id: 'font-awesome-icon',
  type: 'symbol',
  source: 'pois',
  filter: [],
  layout: {
    'text-font': ['Font Awesome 6 Free Solid'],
    'text-field': this.mapService.fontAwesomeCharacterCode('calendar') // or 'fa-calendar'
  },
  paint: {
    'text-color': '#FFFFFF'
  }
});

As for the performance. I measured 80_000 iterations with this code and it took only ~50ms. To be fair I measured your code without the usage of lodash#memoize - that took ~1050ms. I hope anyone finds this useful.

public measure() {
  const startTimeInMs = new Date().getTime();

  const icons = ['fa-heartbeat', 'fa-child', 'fa-medkit', 'fa-h-square', 'fa-crosshairs', 'fa-desktop', 'fa-cutlery', 'fa-adjust'];

  for(let i = 0; i < 10_000; i++) {
    for(const icon of icons) {
      this.fontAwesomeCharacterCode(icon);
    }
  }

  const endTimeInMs = new Date().getTime();
  const durationInMs = endTimeInMs - startTimeInMs;

  console.log(`myMethod took ${durationInMs} ms`);
}