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:

feature.getGeometry().translate(10, 0);
?