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
11.13k stars 2.21k forks source link

Data driven properties on line-gradient colors #8977

Open matthieugouel opened 4 years ago

matthieugouel commented 4 years ago

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.

mourner commented 4 years ago

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.

matthieugouel commented 4 years ago

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 commented 4 years ago

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 😄

brendan33 commented 4 years ago

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.

Buzhanin commented 4 years ago

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.

cola119 commented 4 years ago

any update?

dalbani commented 4 years ago

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

ohmegasquared commented 4 years ago

Adding another request for this feature!

ChristopherLR commented 4 years ago

This feature would be great, thanks :)

HarelM commented 3 years ago

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...

EarlOld commented 3 years ago

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)
              }
            }]
          }
        });
  2. Create layer
    map.addLayer({
          id: "route",
          type: "line",
          source: "route",
          paint: {
            "line-width": 5,
            'line-gradient': [
              'interpolate',
              ['linear'],
              ['line-progress'],
              ...getLineBackground()
            ]
          }
        })
  3. 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);
}
mourner commented 3 years ago

@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']
]
cgibsonmm commented 3 years ago

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

eslavnov commented 3 years ago

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!

nickfaughey commented 3 years ago

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.

eslavnov commented 3 years ago

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.

dalbani commented 3 years ago

@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!

peterqliu commented 3 years ago

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.

mway77 commented 3 years ago

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!

ondrejrohon commented 3 years ago

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

ArohanD commented 2 years ago

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.

LunicLynx commented 2 years ago

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 🙈

GadhiyaRiddhi commented 1 year ago

@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

LunicLynx commented 1 year ago

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

mholt commented 1 year ago

We would also be very interested in this.

yerffejytnac commented 1 year ago

Also expressing interest in this!

JuanIrache commented 1 year ago

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

Wagnerd6 commented 1 year ago

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;
    }
}