mapbox-gl-js icon indicating copy to clipboard operation
mapbox-gl-js copied to clipboard

Data driven properties on line-gradient colors

Open matthieugouel opened this issue 4 years ago • 26 comments

Motivation

As far as I tested for know, it seems not possible to use data driven property to set the color associated with the percentages in the line-gradient / heatmap paint property. For instance see this API example below :

map.addLayer({
    type: 'line',
    source: 'line',
    id: 'line',
    paint: {
         'line-width': 14,
         'line-gradient': [
             'interpolate',
             ['linear'],
             ['line-progress'],
             0, ["get", "src-color"],
             1, "["get", "dst-color"]
        ]
    },
});

Cheers, Matthieu.

matthieugouel avatar Nov 14 '19 10:11 matthieugouel

For the heatmap, this is technically impossible since the heatmap layer gets colorized after the accumulation stage (when all features are rendered into one grayscale texture), using a single gradient as lookup.

For the line gradient though, this might be possible — needs investigation.

mourner avatar Nov 14 '19 11:11 mourner

Ok. I thought I was the same "engine" underneath since they share common API but, yeah, I'm mostly interested in line-gradient anyway.

matthieugouel avatar Nov 14 '19 11:11 matthieugouel

Is there a some kind of "standard procedure" in order to implement a data driven property ? I'm trying to investigate on my own but I'm not familiar with the code so it's not very efficient 😄

matthieugouel avatar Nov 18 '19 10:11 matthieugouel

I've been using something like this:

const stops = [
  0, 'green',
  0.2, 'cyan',
  0.6, 'orange',
  0.9, 'green',
  1, 'cyan',
];

map.addLayer({
    type: 'line',
    source: 'line',
    id: 'line',
    paint: {
         'line-width': 14,
         'line-gradient': [
             'interpolate',
             ['linear'],
             ['line-progress'],
             ...stops
        ]
    },
});

image

Seems to work well, but ideally I want there to be a hard line between the colour transitions. But I can't seem to figure out how to do that.

brendan33 avatar Nov 27 '19 16:11 brendan33

Hello, I have a need to put several animated lines with different gradients on the map. Trying to read gradient values from a line's properties (in the same manner as matthieugouel did) ended up with this error:

Error: layers.line-animation.paint.line-gradient: data expressions not supported

Will it be fixed sometime? It looks like the only way to do my task is to put lines in different layers which will hit performance, and I would rather not to do that.

Buzhanin avatar Dec 05 '19 00:12 Buzhanin

any update?

cola119 avatar Mar 24 '20 06:03 cola119

I'm also very much interested in having such functionality. Is there a way to give a hand to help implementing it?

dalbani avatar Jun 13 '20 07:06 dalbani

Adding another request for this feature!

ohmegasquared avatar Jul 07 '20 15:07 ohmegasquared

This feature would be great, thanks :)

ChristopherLR avatar Sep 01 '20 05:09 ChristopherLR

Also it would be great if the gradient values (not colors) were to be taken from a property or an array that matches the line points array. There's a nice feature in the Oruxmaps app that shows the slope using gradient colors - i.e. when the slope is "hard" the color is red and when the slope is "easy" the color is green. This would be a very nice addition to this framework to be able to do it. I have looked at the code to understand where the data from line progress is coming from but couldn't fully understand, mainly I guess because it's tiled base...? If someone can help me better understand the code I might be able to help here... IF you want me to open a new issue since it's not exactly the same let me know...

HarelM avatar Nov 01 '20 06:11 HarelM

Hi. I found a solution for this, maybe. Example. We need set gradient to route by speed data. 0 - green, 50 - yellow, 100 -red

  1. Create source with all points
map.addSource('route', {
          'type': 'geojson',
          'lineMetrics': true,
          'data': {
            'type': 'FeatureCollection',
            'features': [{
              "type": "Feature",
              "geometry": {
                "type": "LineString",
                "coordinates": values.map(item => item.location)
              }
            }]
          }
        });
  1. Create layer
map.addLayer({
          id: "route",
          type: "line",
          source: "route",
          paint: {
            "line-width": 5,
            'line-gradient': [
              'interpolate',
              ['linear'],
              ['line-progress'],
              ...getLineBackground()
            ]
          }
        })
  1. Create getLineBackground function.
const getLineBackground = () => {
   
    const range = chroma.scale('green', 'yellow', 'red').domain(0, 50, 100); // use chroma.js

    const colorsData = [];
    const totalDistance = values.reduce((total, currentPoint, index) => {
      if (index !== this.props.values?.length - 1) {
        return total + getDistanceFromLatLng(currentPoint.location[0], currentPoint.location[1], this.props.values[index + 1].location[0], this.props.values[index + 1].location[1]);
      }
      return total;
    }, 0); // distance between first and past point

    // next calculate percentage one line to all distance 
    const lengthBetweenPoints = values?.map((item, index) => {
      if (index === 0) {
        return {
          ...item,
          weight: 0
        };
      }
      if (index === values?.length - 1) {
        return {
          ...item,
          weight: 1
        };
      }

      return {
        ...item,
        weight: getDistanceFromLatLng(item.location[0], item.location[1], values[index + 1].location[0], values[index + 1].location[1]) / totalDistance
      };

    }); 


    let weight = 0;
    // next fill colorsData for **line-progress** property 
    lengthBetweenPoints.forEach((item, index) => {
      if (item.weight || index === 0) {
        if (index !== lengthBetweenPoints.length - 1) {
          weight += item.weight;
          colorsData.push(weight);
        } else {
          colorsData.push(item.weight);
        }
        colorsData.push(range(item.speed).hex());
      }
    });

    return colorsData;
  };

In final, we have image

PS: Code for getDistanceFromLatLng

const getDistanceFromLatLng = (lon1,lat1,lon2,lat2) => {
  var R = 6371; // Radius of the earth in km
  var dLat = deg2rad(lat2-lat1);  // deg2rad below
  var dLon = deg2rad(lon2-lon1);
  var a =
    Math.sin(dLat/2) * Math.sin(dLat/2) +
    Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) *
    Math.sin(dLon/2) * Math.sin(dLon/2)
  ;
  var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
  var d = R * c; // Distance in km
  return d;
};

function deg2rad(deg) {
  return deg * (Math.PI/180);
}

EarlOld avatar Dec 16 '20 16:12 EarlOld

@EarlOld this only works for a single feature. Ideally, we would be able to set data-driven line-gradients for tons of lines in the same layer (such as a road network visualizing traffic). One way we could do that is by keeping line-gradient static (so there's one texture for color lookup), but introducing a new line-gradient-progress property that would map line-progress to the final gradient key value, e.g.:

'line-gradient-progress': [
  'interpolate', 'linear', ['line-progress'], 
  0, ['get', 'start_speed'], 
  1, ['get', 'end_speed']
]

mourner avatar Dec 16 '20 17:12 mourner

Any updates? Would love to have this feature to track altitude above the ground for a flight tracking app.

cgibsonmm avatar Mar 30 '21 03:03 cgibsonmm

Also very interested in this feature. We have a lot of line segments that we want to dynamically aplly a gradient to, and currently the only way to achieve this is to split them into individual layers, which comes with a huge performance hit (we have hundreds of them). Having an ability to use data driven properties to control gradient would be extremely useful!

eslavnov avatar Apr 15 '21 08:04 eslavnov

Adding my support here - this would be perfect for visualizing a vehicle's speed along a route (collection of LineStrings, each of which has an initialVelocity and finalVelocity). In fact, the use case for line-gradient as it currently exists is extremely narrow, as you'll rarely know your stop outputs ahead of time unless you're visualizing something super simple where the outputs are completely unrelated to each feature itself. As mentioned, the only workaround is a complete anti-pattern and abuse of the library - introducing N layers for N styled features.

nickfaughey avatar Apr 27 '21 19:04 nickfaughey

Ultimately we've decided to implement a custom mapbox layer with three.js (via threebox) and some custom gradient logic. Now we have one layer with hundreds of lines and we can change the gradient dynamicly on a per-line basis. The performance with this approach is really nice, we notice virtually no difference compared to the vanilla implementation. Since it's a custom layer for mapbox, this means all the higher-level logic related to mapbox required very little changes - most of the effort was spent building this custom layer itself.

This is not the ideal solution since it introduces some extra dependencies and extra complexity, but if the dynamic gradients are a key feature for your particular use-case (as it is for us), I would recommend looking into supplementing mapbox with three.js.

eslavnov avatar May 06 '21 09:05 eslavnov

@eslavnov Would you mind sharing a small working example of your solution? I don't have much experience with three.js so that would be helpful. Thanks!

dalbani avatar May 06 '21 10:05 dalbani

As mentioned, the only workaround is a complete anti-pattern and abuse of the library - introducing N layers for N styled features.

@nickfaughey @eslavnov @dalbani There's actually another way to achieve this that scales with only the numbers of colors in your ramp. Instead of adding your line geometry and varying the gradient stops, we can tweak the source geometry such that it's representable by a static gradient scheme.

Here's how it would work, with a color ramp that goes Red - Orange - Yellow - Green:

In the style, we add a line layer for every pairwise colors in the ramp sequence: RedToOrange, OrangeToYellow, YellowToGreen. This strategy requires N-1 layers, where N is the number of colors in the ramp.

In the geometry, we figure out where each stop would be, and chop up the line at those points (this is easy with a utility like turf.lineSliceAlong), so that the original line is now up to N-1 shorter segments. This can be done at runtime if you wrap it in a function.

We use layer RedToOrange to render the first segment of each original line, OrangeToYellow to render the second segments, and YellowToGreen to render the thirds (we can make it easy to filter by tagging each segment with a segmentIndex property). This works because even with variable gradient stops, the gradient between stops is always linear.

  R-O     O-Y     Y-G
|-----||-------||-------|

    R-O      O-Y    Y-G
|---------||----||---------|

The ideal solution is probably data-driven gradient stops, but this should be viable until then.

peterqliu avatar Jun 28 '21 23:06 peterqliu

Here is an implementation with the Mapbox custom layer and Three.js (via ThreeBox-plugin). It only uses one layer, but you can define different lines with different start and end colors. You can also extend it easily in order to support more-points gradient. Here is a JSFiddle where you can check the implementation: https://jsfiddle.net/rqdz0ufL/4/ Before run you should put your Mapbox token into the code!

Hope this will help!

mway77 avatar Jun 29 '21 17:06 mway77

Any update on this? It would be great to have data-driven gradient stops. Thanks

ondrejrohon avatar Sep 10 '21 14:09 ondrejrohon

Adding my interest in this feature, especially considering threebox is now archived. Would be great to paint different gradients in the same layer as opposed to sorting features by color and then painting them to different layers.

ArohanD avatar Feb 11 '22 18:02 ArohanD

Has anyone tried splitting the line into features and adding a layer for each feature? Hard, medium, easy Red, yellow, green

Should work, no idea at which amount of layers it might break though 🙈

LunicLynx avatar May 07 '22 10:05 LunicLynx

@LunicLynx Can you please share your solution code as soon as possible ?

Has anyone tried splitting the line into features and adding a layer for each feature? Hard, medium, easy Red, yellow, green

GadhiyaRiddhi avatar Nov 18 '22 11:11 GadhiyaRiddhi

@GadhiyaRiddhi there is no code. I just think it should be possible.

LunicLynx avatar Nov 18 '22 13:11 LunicLynx

We would also be very interested in this.

mholt avatar Nov 22 '22 23:11 mholt

Also expressing interest in this!

yerffejytnac avatar Dec 18 '22 21:12 yerffejytnac

Hi. I found a solution for this, maybe. Example. We need set gradient to route by speed data. 0 - green, 50 - yellow, 100 -red

  1. Create source with all points
map.addSource('route', {
          'type': 'geojson',
          'lineMetrics': true,
          'data': {
            'type': 'FeatureCollection',
            'features': [{
              "type": "Feature",
              "geometry": {
                "type": "LineString",
                "coordinates": values.map(item => item.location)
              }
            }]
          }
        });
  1. Create layer
map.addLayer({
          id: "route",
          type: "line",
          source: "route",
          paint: {
            "line-width": 5,
            'line-gradient': [
              'interpolate',
              ['linear'],
              ['line-progress'],
              ...getLineBackground()
            ]
          }
        })
  1. Create getLineBackground function.
const getLineBackground = () => {
   
    const range = chroma.scale('green', 'yellow', 'red').domain(0, 50, 100); // use chroma.js

    const colorsData = [];
    const totalDistance = values.reduce((total, currentPoint, index) => {
      if (index !== this.props.values?.length - 1) {
        return total + getDistanceFromLatLng(currentPoint.location[0], currentPoint.location[1], this.props.values[index + 1].location[0], this.props.values[index + 1].location[1]);
      }
      return total;
    }, 0); // distance between first and past point

    // next calculate percentage one line to all distance 
    const lengthBetweenPoints = values?.map((item, index) => {
      if (index === 0) {
        return {
          ...item,
          weight: 0
        };
      }
      if (index === values?.length - 1) {
        return {
          ...item,
          weight: 1
        };
      }

      return {
        ...item,
        weight: getDistanceFromLatLng(item.location[0], item.location[1], values[index + 1].location[0], values[index + 1].location[1]) / totalDistance
      };

    }); 


    let weight = 0;
    // next fill colorsData for **line-progress** property 
    lengthBetweenPoints.forEach((item, index) => {
      if (item.weight || index === 0) {
        if (index !== lengthBetweenPoints.length - 1) {
          weight += item.weight;
          colorsData.push(weight);
        } else {
          colorsData.push(item.weight);
        }
        colorsData.push(range(item.speed).hex());
      }
    });

    return colorsData;
  };

In final, we have image

PS: Code for getDistanceFromLatLng

const getDistanceFromLatLng = (lon1,lat1,lon2,lat2) => {
  var R = 6371; // Radius of the earth in km
  var dLat = deg2rad(lat2-lat1);  // deg2rad below
  var dLon = deg2rad(lon2-lon1);
  var a =
    Math.sin(dLat/2) * Math.sin(dLat/2) +
    Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) *
    Math.sin(dLon/2) * Math.sin(dLon/2)
  ;
  var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
  var d = R * c; // Distance in km
  return d;
};

function deg2rad(deg) {
  return deg * (Math.PI/180);
}

Thanks. That approach works great

JuanIrache avatar Feb 05 '23 21:02 JuanIrache

I implemented it this way now:

map.on('load', function() {
    // Get the upper and lower bounds for the speed variable
    const lowerBound = lineFeature.properties.myProperty[0];
    const upperBound = lineFeature.properties.myProperty[1];
    const midpoint = (lowerBound + upperBound) / 2;

    map.addLayer({
        'id': 'line',
        'type': 'line',
        'source': {
            'type': 'geojson',
            'data': lineFeature,
            'lineMetrics': true
        },
        'layout': {
            'line-join': 'round',
            'line-cap': 'round'
        },
        'paint': {
            'line-width': 8,
            'line-gradient': [
              'interpolate',
              ['linear'],
              ['line-progress'],
              0, ['rgba', ...getRGBGradientGreenYellowRed(lowerBound, 0, 10), 1],
              0.5, ['rgba', ...getRGBGradientGreenYellowRed(midpoint, 0, 10), 1],
              1, ['rgba', ...getRGBGradientGreenYellowRed(upperBound, 0, 10), 1]
            ]
          }
    })
    });

This colors the line in a gradient green to yellow to red depending on the value of myProperty. myProperty has two values, one for the starting point and one for the end point. Everything <= 0 is green and >=10 is red. So if I have myProperty=[2.5, 7.5] the start is yellow-green, the end is orange and the colors in between a smooth gradient.

Here is the function to get the RGB tuple based on an input number (myProperty) and the upper and lower bounds (here 0 and 10):

function getRGBGradientGreenYellowRed(input: number, rangeStart: number = 0.0, rangeEnd: number = 1.0, convert255 = true): [number, number, number] {
    const distance: number = rangeEnd - rangeStart;
    const r: number = 2.0 * ((input - rangeStart) / distance);
    const g: number = 2.0 * (1.0 - ((input - rangeStart) / distance));
    const rgb: [number, number, number] = [r > 1.0 ? 1.0 : r / 1.0, g > 1.0 ? 1.0 : g / 1.0, 0.0];
    if (convert255) {
        return [rgb[0] * 255.0, rgb[1] * 255.0, rgb[2] * 255.0];
    } else {
        return rgb;
    }
}

Wagnerd6 avatar May 11 '23 09:05 Wagnerd6