3

I'm working on a map application, where I should display multiple routes, each with its own name and coloring. In some locations, there are multiple routes following the same path.

Is there a way to multiply a single line with an offset in a such way, that if there are more routes using the same line, parallel lines would be displayed next to each other?

Here's the object I receive from the server:

{
    "type": "Feature",
    "properties": {
        "routes": [
            131, 141, 151
        ]
    },
    "geometry": {
        "type": "LineString",
        "coordinates": [
            [
                17.146185,
                47.913635
            ],
            [
                17.152311,
                47.910369
            ],
            [
                17.152475,
                47.910285
            ]
        ]
    }
}

Take the line from the above as the example. One line should be multiplied 2 times, original and two parallel lines adjacent to it.

I was hoping to use a function like this, in a loop manner, which would add the stroke, but I can only achieve it with stacking thinner lines on top of thicker. Which is not ideal.

export const multiplyLine = (color: Color | ColorLike, order: number) => {
  const styles: Array<Style> = []
  for (let i = 1; i <= order; i++) {
    styles.push(new Style({
      stroke: new Stroke({
        color,
        width: i * 4 // 4, 8, etc.
      }),
      zIndex: i === 1 ? order : order - i // thicker on bottom
    }),)
  }
  return styles;
};

Is there a simple way to multiply lines in a parallel way? And not just straight lines, but curves also. Below is the picture of what I'm hoping for:

enter image description here

4
  • 1
    Some pictures of desired result for different zoom levels would help.
    – TomazicM
    Commented Sep 13, 2023 at 15:55
  • Added picture of expected result
    – mat.hudak
    Commented Sep 13, 2023 at 16:50
  • what if you use an offset like feature.getGeometry().translate(10, 0); ?
    – Pepe N O
    Commented Sep 13, 2023 at 17:30
  • @PepeNO I tried this already, works great for straight lines, less so with polylines
    – mat.hudak
    Commented Sep 13, 2023 at 18:34

2 Answers 2

2

One possible solution is to use turf.js library turf.lineOffset method to create parallel lines in style function.

Relevant part of the code to show two parallel lines for the same linestring could then look something like this (code edited to include suggestions from @Mike):

var geoJSONFormat = new ol.format.GeoJSON();
const scale = 180 / ol.extent.getHeight(ol.proj.get('EPSG:3857').getExtent());

function featureToGeoJSON(feature) {
  const clone = feature.getGeometry().clone();
  clone.scale(scale, scale, [0, 0]);
  var geoJSON = geoJSONFormat.writeGeometryObject(clone);
  return(geoJSON);
}

function geoJSONToFeature(geoJSON) {
  var feature = geoJSONFormat.readFeature(geoJSON);
  feature.getGeometry().scale(1 / scale, 1 / scale, [0, 0]);
  return(feature);
}

var raster = new ol.layer.Tile({
  source: new ol.source.OSM()
});

var drawSource = new ol.source.Vector({wrapX: false});

function lineStyle(feature, resolution) {
  var styles = [];
  var style = new ol.style.Style({
    stroke: new ol.style.Stroke({
      color: '#ff0000',
      width: 3
    })
  });
  styles.push(style);
  
  var geoJSON = turf.cleanCoords(featureToGeoJSON(feature));
  var offset = resolution * 3;
  var offsetGeoJSON = turf.lineOffset(geoJSON, offset * scale, {
    units: 'degrees',
  });
  var offsetTrack = geoJSONToFeature(offsetGeoJSON);
  var offsetGeometry = offsetTrack.getGeometry();
  style = new ol.style.Style({
    stroke: new ol.style.Stroke({
      color: '#0000ff',
      width: 3
    }),
    geometry: offsetGeometry
  });
  styles.push(style);

  return(styles);
}

Here is an example of a single line styled this way:

enter image description here

There is one problem with this method that it does not like sharp corners:

enter image description here

Edit: Solution for sharp corners is to set style stroke lineJoin property to miter and miterLimit to some high value:

style = new ol.style.Style({
  stroke: new ol.style.Stroke({
    color: '#0000ff',
    width: 3,
    lineJoin: 'miter',
    miterLimit: 50
  }),
  geometry: offsetGeometry
});

As a consequence all outer line joins are sharp, but there are no more empty spaces between lines:

enter image description here

6
  • I'll give it a try, looks promising
    – mat.hudak
    Commented Sep 13, 2023 at 18:35
  • turf calculates offsets in meters on the ground while Openlayers resolution is projection meters so the styled offset will vary with latitude. If you want the style to be independent of latitude instead of transforming to and from EPSG:4326 scale the geometry so the coordinates are in the -90 to +90 range and use degrees as the offset. Also the lineOffset method does not like linestrings with repeated coordinates (which routing APIs often return if waypoints are specified) codesandbox.io/s/feature-move-animation-forked-nvmghh?file=/…
    – Mike
    Commented Sep 14, 2023 at 22:41
  • @Mike Thanks for insights. I'm actually using this method on my maps. Since they are in latitude range +-2 degrees, I didn't bother with latitude. As for repeated coordinates, I'm actually using turf.cleanCoords to get rid of them. I'll update my answer to include your suggestions.
    – TomazicM
    Commented Sep 15, 2023 at 8:25
  • This one works, but with the issues as shown. Will try another one
    – mat.hudak
    Commented Sep 19, 2023 at 7:19
  • 1
    This can happen if coordinates of adjacent points on line are too close together. In such a case line string .simplify method can help.
    – TomazicM
    Commented Sep 19, 2023 at 8:26
1

This solution is based upon Leaflet plugin Leaflet Polyline Offset. Coordinates are first converted to map pixels and the offset is then done on pixel coordinates. In Leaflet pixel coordinates are the directly used, in OpenLayers they are converted back to map coordinates.

Advantage of this solution is that outer corners of sharp line angles are drawn rounded as arcs, so there are no protruding outer spikes.

Here is part of the code that does pixel offset. It's directly taken from Leaflet plugin and just slightly modified to remove dependence on Leaflet methods/function:

var polylineOffset = (function () {

  function bind(fn, obj) {
    var slice = Array.prototype.slice;
    if (fn.bind) {
      return fn.bind.apply(fn, slice.call(arguments, 1));
    }     
    var args = slice.call(arguments, 2);   
    return function () {
      return fn.apply(obj, args.length ? args.concat(slice.call(arguments)) : arguments);
    };
  }

  function forEachPair(list, callback) {
    if (!list || list.length < 1) { return; }
    for (var i = 1, l = list.length; i < l; i++) {
      callback(list[i-1], list[i]);
    }
  }
  
  function lineEquation(pt1, pt2) {
    if (pt1.x === pt2.x) {
      return pt1.y === pt2.y ? null : { x: pt1.x };
    }        
    var a = (pt2.y - pt1.y) / (pt2.x - pt1.x);
    return {
      a: a,
      b: pt1.y - a * pt1.x,
    };
  }
  
  function intersection(l1a, l1b, l2a, l2b) {
    var line1 = lineEquation(l1a, l1b);
    var line2 = lineEquation(l2a, l2b);     
    if (line1 === null || line2 === null) {
      return null;
    }
    if (line1.hasOwnProperty('x')) {
      return line2.hasOwnProperty('x')
        ? null
        : {
            x: line1.x,
            y: line2.a * line1.x + line2.b,
        };
    }
    if (line2.hasOwnProperty('x')) {
      return {
        x: line2.x,
        y: line1.a * line2.x + line1.b,
      };
    }    
    if (line1.a === line2.a) {
      return null;
    }  
    var x = (line2.b - line1.b) / (line1.a - line2.a);
    return {
      x: x,
      y: line1.a * x + line1.b,
    };
  }
  
  function translatePoint(pt, dist, heading) {
    return {
      x: pt.x + dist * Math.cos(heading),
      y: pt.y + dist * Math.sin(heading),
    };
  }
  
  var PolylineOffset = {
    offsetPointLine: function(points, distance) {
      var offsetSegments = [];      
      forEachPair(points, bind(function(a, b) {
        if (a.x === b.x && a.y === b.y) { return; }
        var segmentAngle = Math.atan2(a.y - b.y, a.x - b.x);
        var offsetAngle = segmentAngle - Math.PI/2;
        offsetSegments.push({
          offsetAngle: offsetAngle,
          original: [a, b],
          offset: [
            translatePoint(a, distance, offsetAngle),
            translatePoint(b, distance, offsetAngle)
          ]
        });
      }, this)); 
      return offsetSegments;
    },       
    offsetPoints: function(pts, offset) {
      var simplifiedPts = pts;
      var offsetSegments = this.offsetPointLine(simplifiedPts, offset);
      return this.joinLineSegments(offsetSegments, offset);
    },       
    joinSegments: function(s1, s2, offset) {
      return this.circularArc(s1, s2, offset)
        .filter(function(x) { return x; })
    },      
    joinLineSegments: function(segments, offset) {
      var joinedPoints = [];
      var first = segments[0];
      var last = segments[segments.length - 1];  
      if (first && last) {
        joinedPoints.push(first.offset[0]);
        forEachPair(segments, bind(function(s1, s2) {
          joinedPoints = joinedPoints.concat(this.joinSegments(s1, s2, offset));
        }, this));
        joinedPoints.push(last.offset[1]);
      }   
      return joinedPoints;
    },     
    segmentAsVector: function(s) {
      return {
        x: s[1].x - s[0].x,
        y: s[1].y - s[0].y,
      };
    },    
    getSignedAngle: function(s1, s2) {
      const a = this.segmentAsVector(s1);
      const b = this.segmentAsVector(s2);
      return Math.atan2(a.x * b.y - a.y * b.x, a.x * b.x + a.y * b.y);
    },    
    circularArc: function(s1, s2, distance) {
      if (s1.offsetAngle === s2.offsetAngle) {
        return [s1.offset[1]];
      }    
      const signedAngle = this.getSignedAngle(s1.offset, s2.offset);
      if ((signedAngle * distance > 0) &&
        (signedAngle * this.getSignedAngle(s1.offset, [s1.offset[0], s2.offset[1]]) > 0)) {
        return [intersection(s1.offset[0], s1.offset[1], s2.offset[0], s2.offset[1])];
      }       
      var points = [];
      var center = s1.original[1];
      var rightOffset = distance > 0;
      var startAngle = rightOffset ? s2.offsetAngle : s1.offsetAngle;
      var endAngle = rightOffset ? s1.offsetAngle : s2.offsetAngle;
      if (endAngle < startAngle) {
        endAngle += Math.PI * 2;
      }
      var step = Math.PI / 8;
      for (var alpha = startAngle; alpha < endAngle; alpha += step) {
        points.push(translatePoint(center, distance, alpha));
      }
      points.push(translatePoint(center, distance, endAngle));       
      return rightOffset ? points.reverse() : points;
    }
  }
  
  return PolylineOffset;      
})();

Here is then an example of style that draws double red/blue line, each part 3 pixels wide:

function getOffsetGeometry(geom, offsetPixels) {
  var coords = geom.getCoordinates();
  var pixels = [];
  coords.forEach(function(coord) {
    var pixel = map.getPixelFromCoordinate(coord);
    pixels.push({x: pixel[0], y: pixel[1]});
  });
  var offsetPixels = polylineOffset.offsetPoints(pixels, offsetPixels);
  var offsetCoords = [];
  offsetPixels.forEach(function(pixel) {
    var coord = map.getCoordinateFromPixel([pixel.x, pixel.y]);
    offsetCoords.push(coord);
  });
  var offsetGeometry = new ol.geom.LineString(offsetCoords);
  
  return offsetGeometry;
}

function lineStyle(feature, resolution) {
  var styles = [];
  var geom = feature.getGeometry();
  
  var offsetGeometry = getOffsetGeometry(geom, 1.5)
  style = new ol.style.Style({
    stroke: new ol.style.Stroke({
      color: '#0000ff',
      width: 3,
      lineJoin: 'miter',
      miterLimit: 50
    }),
    geometry: offsetGeometry
  });
  styles.push(style);

  offsetGeometry = getOffsetGeometry(geom, -1.5)
  style = new ol.style.Style({
    stroke: new ol.style.Stroke({
      color: '#ff0000',
      width: 3,
      lineJoin: 'miter',
      miterLimit: 50
    }),
    geometry: offsetGeometry
  });
  styles.push(style);

  return styles;
}

Here's how it then looks like:

enter image description here

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.