maplibre / maplibre-native

MapLibre Native - Interactive vector tile maps for iOS, Android and other platforms.
https://maplibre.org
BSD 2-Clause "Simplified" License
1.08k stars 319 forks source link

Issue with `MGLLineStyleLayer.lineGradient` interpolation precision leading to missing colours #1284

Open JuLink opened 1 year ago

JuLink commented 1 year ago

Describe the bug Using NSExpression(forMGLInterpolating:curveType:parameters:stops:) for the MGLLineStyleLayer.lineGradient on a long Polyine with small increment in between stops leads to incorrect rendering of the gradient and missing colours.

In my project, I'm trying to create a polyline for an itinerary that shows the traffic. To do that I create a Polyline and compute stops for a lineGradient so that the transition between two segments of the polyline with different colours is as small as possible. Here is a schematic representation: [green]--------[green]--[red]--------[red]--[blue]--------[blue]--[orange]--------[orange] where each [colour] is a stop (associated with its colour) and each gradient between different colours is as small as possible.

This seems to work properly only if the segments are large enough relatively to the length of the whole polyline.

To Reproduce You can use the following snippet that reproduces the issue. In the snippet you can comment/uncomment the lines in the viewDidAppear method to see the issue and the expected behaviour. To show the expected behaviour the last point of the polyline is simply moved to a closer distance from the rest, decreasing the length of the whole polyline (which increases the relative size of each segment).

import UIKit
import Mapbox

class ViewController: UIViewController {

    var mapView: MGLMapView!
    let trafficSourceIdentifier = "traffic-shape"
    let trafficLayerIdentifier = "traffic-layer"

    override func viewDidLoad() {
        super.viewDidLoad()
        let mapView = MGLMapView(frame: view.bounds)
        self.mapView = mapView
        mapView.autoresizingMask = [.flexibleHeight, .flexibleWidth]
        mapView.delegate = self
        self.view.addSubview(mapView)
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        // Uncomment or comment to see the difference
        let (polyline, gradient) = computePolylineAndGradient(forSegments: fakeSegmentsForGradientNotWorking)
//        let (polyline, gradient) = computePolylineAndGradient(forSegments: fakeSegmentsForGradientWorking)

        let style = mapView.style!
        let options: [MGLShapeSourceOption : Any] = [MGLShapeSourceOption.lineDistanceMetrics : true]
        let source = MGLShapeSource(identifier: trafficSourceIdentifier, shape: polyline, options: options)

        style.addSource(source)

        let layer = MGLLineStyleLayer(identifier: trafficLayerIdentifier, source: source)
        layer.lineJoin = NSExpression(forConstantValue: MGLLineJoin.round.rawValue)
        layer.lineWidth = lineWidthExpression
        layer.lineCap = NSExpression(forConstantValue: MGLLineCap.round.rawValue)
        layer.lineGradient = gradient

        mapView.style!.addLayer(layer)

        mapView.setCenter(point3.coordinate, zoomLevel: 15, animated: animated)
    }

    /**
     The idea behind this method is to create a traffic polyline where each segment of the polyline is of some colour and a gradient is computed between each segment to properly make the transition.

     To do that we iterate over each segment and create a gradient stop at a small distance before and after the coordinate where the colour of the segment changes.
     This way we should have solid colour for all the segment and just a gradient in between every transition.
     */
    func computePolylineAndGradient(forSegments segments: [GroupedRouteSegment]) -> (polyline: MGLPolylineFeature, gradient: NSExpression) {
        let totalDistance: CLLocationDistance = segments.map(\.distance).reduce(0, +)
        var distance: CLLocationDistance = 0
        var coordinates = [CLLocationCoordinate2D]()
        var gradientStops = [Float: UIColor]()

        gradientStops[0.0] = segments.first?.color ?? .black
        gradientStops[1.0] = segments.last?.color ?? .black

        for segment in segments {

            coordinates.append(contentsOf: segment.coordinates)

            let epsilon: CLLocationDistance = 1 // 1 meter

            let stopBegin = Float((distance+epsilon) / totalDistance).nextDown
            gradientStops[stopBegin] = segment.color

            distance += segment.distance

            let stopEnd = Float((distance-epsilon) / totalDistance).nextUp
            gradientStops[stopEnd] = segment.color
        }

        let polyline = MGLPolylineFeature(coordinates: coordinates, count: UInt(coordinates.count))

        let lineGradient = NSExpression(forMGLInterpolating: .lineProgressVariable,
                                        curveType: .exponential,
                                        parameters: NSExpression(forConstantValue: 1.5),
                                        stops: NSExpression(forConstantValue: gradientStops))

        return (polyline: polyline, gradient: lineGradient)
    }

    lazy var lineWidthExpression: NSExpression = {
        let lineWidthByZoomLevel = NSExpression(forConstantValue: [
            10: 10,
            13: 11,
            16: 13,
            19: 24,
            22: 30
        ])

        let lineWidth = NSExpression(forMGLInterpolating: .zoomLevelVariable,
                                     curveType: .linear,
                                     parameters: nil,
                                     stops: lineWidthByZoomLevel)
        return lineWidth
    }()

    struct GroupedRouteSegment {
        let color: UIColor
        let distance: CLLocationDistance
        let coordinates: [CLLocationCoordinate2D]
    }

    let point1 = CLLocation(latitude: 48.875022, longitude: 2.309344)
    let point2 = CLLocation(latitude: 48.874307, longitude: 2.310036)
    let point3 = CLLocation(latitude: 48.873135, longitude: 2.309970)
    let point4 = CLLocation(latitude: 48.872832, longitude: 2.310762)
    let point5 = CLLocation(latitude: 48.874654, longitude: 2.320029)

    let point6 = CLLocation(latitude: 52.360245, longitude: 4.886787) // Point 6 is very far and will make the distance of the complete polyline pretty long leading to the issue

    lazy var fakeSegmentsForGradientWorking: [GroupedRouteSegment] = [
        GroupedRouteSegment(color: .green, distance: point1.distance(from: point2), coordinates: [point1.coordinate, point2.coordinate]),
        GroupedRouteSegment(color: .red, distance: point2.distance(from: point3), coordinates: [point2.coordinate, point3.coordinate]),
        GroupedRouteSegment(color: .blue, distance: point3.distance(from: point4), coordinates: [point3.coordinate, point4.coordinate]),
        GroupedRouteSegment(color: .orange, distance: point4.distance(from: point5), coordinates: [point4.coordinate, point5.coordinate])
    ]

    lazy var fakeSegmentsForGradientNotWorking: [GroupedRouteSegment] = [
        GroupedRouteSegment(color: .green, distance: point1.distance(from: point2), coordinates: [point1.coordinate, point2.coordinate]),
        GroupedRouteSegment(color: .red, distance: point2.distance(from: point3), coordinates: [point2.coordinate, point3.coordinate]),
        GroupedRouteSegment(color: .blue, distance: point3.distance(from: point4), coordinates: [point3.coordinate, point4.coordinate]),
        GroupedRouteSegment(color: .orange, distance: point4.distance(from: point6), coordinates: [point4.coordinate, point6.coordinate])
    ]

}

extension ViewController: MGLMapViewDelegate {

}

Expected behavior The final rendering when using a long polyline (in the exemple where the last point is really far) should display the proper colour on each segment and all the stops should be interpreted. The length of the polyline should not influence which stops are taken into account during interpolation.

Screenshots Issue (using fakeSegmentsForGradientNotWorking) Expected (using fakeSegmentsForGradientWorking)
Simulator Screen Shot - iPhone 14 Pro - 2023-06-29 at 14 01 38 Simulator Screen Shot - iPhone 14 Pro - 2023-06-29 at 14 02 29

Platform information (please complete the following information):

louwers commented 1 year ago

Thanks for this detailed bug report @JuLink !

Maybe @stefankarschti has an idea what could be going on since he worked with the line layer recently.

stefankarschti commented 1 year ago

The colorRamp has 256 steps here and the filter here may produce this effect for long lines. I suggest either increase the gradient texture size / change filter to nearest / introduce intermediate points for long lines.

(later edit) On a second thought, for this particular case there's a gradient from red to green, blue to red and so on, so the Nearest texture filter may do show a crisper transition.

stefankarschti commented 1 year ago

You can also try breaking the line into segments for each traffic value / color.

JuLink commented 1 year ago

Thanks for the quick response @stefankarschti and @louwers. I'm not sure to understand if it's something I can do as a user of the SDK or if it's something that needs to be change on the SDK itself.

Can you help me understand how I can change these filters and texture sizes with the current iOS API ?

I tried what you suggest in your last message (splitting the line by segments of the same colour), but I can't find a way to properly join the different line segments, especialy on tight curved roads for instance. The line cap of each segment doesn't connect properly to the next, and I found that the round line cap do not give the expected result.

stefankarschti commented 1 year ago

hello @JuLink !

my suggestion is to fix it as a user of the SDK by breaking into more lines. My expectation is that the rounded line cap should work (opaque, no alpha). If there is an issue with the line cap, please submit it.

JuLink commented 11 months ago

Hello,

Getting back to this issue, sorry for the delay 😥

We implemented a variant of your suggestion @stefankarschti. We don't have any gradient, but the result is pretty acceptable.

What we ended up doing is creating multiple polyline, one for each traffic intensity. Each polyline has a butt cap and to avoid the gap between two polylines (when they are angled for instance), each polyline takes a few coordinates of the following one. This make the end of one polyline disapear bellow the next one and there is no gap in between.

Thanks for the suggestion.