Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for curved polylines/polygons using B茅zier curves #9286

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
114 changes: 114 additions & 0 deletions debug/vector/vector-canvas-curved.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Leaflet debug page - Vector Canvas</title>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=0" />
<link rel="stylesheet" href="../../dist/leaflet.css" />
<link rel="stylesheet" href="../css/screen.css" />
<script type="importmap">
{
"imports": {
"leaflet": "../../dist/leaflet-src.esm.js"
}
}
</script>
</head>
<body>
<div id="map"></div>
<button id="removePath" type="button">Remove path</button>
<button id="removeCircle" type="button">Remove circle</button>
<button id="removeAll" type="button">Remove all layers</button>
<script type="module">
import {TileLayer, LatLng, Canvas, Polyline, Map, LayerGroup, LatLngBounds, Circle, CircleMarker, Polygon} from 'leaflet';
import route from './route.js';

const osm = new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom: 18});
const latlngs = [];

for (let i = 0; i < route.length; i++) {
latlngs.push(new LatLng(route[i][0], route[i][1]));
}

const canvas = new Canvas();
const path = new Polyline(latlngs, {
renderer: canvas,
curved: true
});
const map = new Map('map', {layers: [osm], preferCanvas: true});
const group = new LayerGroup();

map.fitBounds(new LatLngBounds(latlngs));

const circleLocation = new LatLng(51.508, -0.11);
const circleOptions = {
color: 'red',
fillColor: 'yellow',
fillOpacity: 0.7,
renderer: canvas
};

const circle = new Circle(circleLocation, 500000, circleOptions);
const circleMarker = new CircleMarker(circleLocation, {fillColor: 'blue', fillOpacity: 1, stroke: false});

group.addLayer(circle).addLayer(circleMarker);

circle.bindPopup('I am a circle');
circleMarker.bindPopup('I am a circle marker');

group.addLayer(path);
path.bindPopup('I am a polyline');

const p1 = latlngs[0];
const p2 = latlngs[Math.round(route.length / 4)];
const p3 = latlngs[Math.round(route.length / 3)];
const p4 = latlngs[Math.round(route.length / 2)];
const p5 = latlngs[route.length - 1];
const polygonPoints = [p1, p2, p3, p4, p5];

const h1 = new LatLng(p1.lat, p1.lng);
const h2 = new LatLng(p2.lat, p2.lng);
const h3 = new LatLng(p3.lat, p3.lng);
const h4 = new LatLng(p4.lat, p4.lng);
const h5 = new LatLng(p5.lat, p5.lng);

h1.lng += 20;
h2.lat -= 5;
h3.lat -= 5;
h4.lng -= 10;
h5.lng -= 8;
h5.lat += 10;

const holePoints = [h5, h4, h3, h2, h1];
const polygon = new Polygon([polygonPoints, holePoints], {
fillColor: '#333',
color: 'green',
renderer: canvas,
curved: true
});
group.addLayer(polygon);

const line = new Polyline([h1, h4, h5], {
dashArray: '5, 5'
});
group.addLayer(line);

polygon.bindPopup('I am a polygon');

const circleMarker2 = new CircleMarker([25.5, 0], {
dashArray: '5, 5',
fillColor: 'red',
color: 'green',
renderer: canvas,
curved: true
});
group.addLayer(circleMarker2);

map.addLayer(group);

document.getElementById('removePath').addEventListener('click', () => group.removeLayer(path));
document.getElementById('removeCircle').addEventListener('click', () => group.removeLayer(circle));
document.getElementById('removeAll').addEventListener('click', () => group.clearLayers());
</script>
</body>
</html>
16 changes: 15 additions & 1 deletion debug/vector/vector-simple.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<body>
<div id="map"></div>
<script type="module">
import {Map, TileLayer, Marker, Circle, Polygon} from 'leaflet';
import {Map, TileLayer, Marker, Circle, Polygon, Polyline} from 'leaflet';

const map = new Map('map')
.setView([51.505, -0.09], 13);
Expand All @@ -35,6 +35,20 @@
new Polygon([[51.509, -0.08], [51.503, -0.06], [51.51, -0.047]])
.bindPopup('I am a polygon.')
.addTo(map);

new Polygon([[51.509, -0.08], [51.503, -0.06], [51.51, -0.047]], {
color: '#000',
curved: true
})
.bindPopup('I am a curved polygon.')
.addTo(map);

new Polyline([[51.505, -0.09], [51.501, -0.07], [51.495, -0.09], [51.490, -0.07], [51.485, -0.09], [51.480, -0.07], [51.475, -0.09]], {
color: '#f0f',
curved: true
})
.bindPopup('I am a curved polyline.')
.addTo(map);
</script>
</body>
</html>
34 changes: 28 additions & 6 deletions src/layer/vector/Canvas.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {Renderer} from './Renderer.js';
import * as DomEvent from '../../dom/DomEvent.js';
import * as Util from '../../core/Util.js';
import {curvedPathCommands} from './SVG.Util.js';
import {Bounds} from '../../geometry/Bounds.js';

/*
Expand Down Expand Up @@ -284,13 +285,34 @@ export const Canvas = Renderer.extend({

ctx.beginPath();

for (i = 0; i < len; i++) {
for (j = 0, len2 = parts[i].length; j < len2; j++) {
p = parts[i][j];
ctx[j ? 'lineTo' : 'moveTo'](p.x, p.y);
if (layer.options.curved) {
for (let i = 0; i < parts.length; i++) {
const points = parts[i];

const cmds = curvedPathCommands(points, closed);
for (j = 0; j < cmds.length; j++) {
const cmd = cmds[j];
switch (cmd.type) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't see the point of holding "command" objects. The non-bezier code can differentiate between lineTo and moveTo by j===0, so there is no need to do an initial array.push of a M "command" and then push C commands.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what you mean.
Do you want me to replace curvedPathCommands with in-loop calculation of Bezier curve parameters?
It would be more efficient but then the code in the loop would become more complicated.
Also, for polygons (or polylines with closed=true) curvedPathCommands modifies the first C command at the end of the loop to make the joint smooth. It would not be possible if curvedPathCommands was extracted here.

case 'C':
ctx.bezierCurveTo(...cmd.values);
break;
case 'M':
ctx.moveTo(...cmd.values);
break;
default:
console.error(`Unsupported SVG command: ${cmd}`);
}
}
}
if (closed) {
ctx.closePath();
} else {
for (i = 0; i < len; i++) {
for (j = 0, len2 = parts[i].length; j < len2; j++) {
p = parts[i][j];
ctx[j ? 'lineTo' : 'moveTo'](p.x, p.y);
}
if (closed) {
ctx.closePath();
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/layer/vector/Polygon.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export const Polygon = Polyline.extend({
return;
}

if (this.options.noClip) {
if (this.options.noClip || this.options.curved) {
this._parts = this._rings;
return;
}
Expand Down
8 changes: 6 additions & 2 deletions src/layer/vector/Polyline.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ export const Polyline = Path.extend({

// @option noClip: Boolean = false
// Disable polyline clipping.
noClip: false
noClip: false,

// @option curved: Boolean = false
// Enable polyline B茅zier curves. Curved polylines don't support clipping - each line is rendered fully.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to reflect that bezier curves are not supported when polylines are drawn on an L.Canvas.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But that's supported. See changes in src/layer/vector/Canvas.js. SVG drawing commands are transformed into Canvas drawing commands in Canvas.js. I have also added vector-canvas-curved.html to test that.

curved: false
},

initialize(latlngs, options) {
Expand Down Expand Up @@ -221,7 +225,7 @@ export const Polyline = Path.extend({
return;
}

if (this.options.noClip) {
if (this.options.noClip || this.options.curved) {
this._parts = this._rings;
return;
}
Expand Down
85 changes: 85 additions & 0 deletions src/layer/vector/SVG.Util.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,88 @@ export function pointsToPath(rings, closed) {
// SVG complains about empty path strings
return str || 'M0 0';
}

// @function pointsToCurvedPath(rings: Point[], closed: Boolean, smoothing: number): String
// Generates a curved SVG path string (using B茅zier curves) for multiple rings, with each ring turning
// into "M..C..C.." instructions
export function pointsToCurvedPath(rings, closed, smoothing = 0.1) {
let path = '';
for (let i = 0; i < rings.length; i++) {
const points = rings[i];

const cmds = curvedPathCommands(points, closed, smoothing);
path += commandsToPath(cmds);
}
return path;
}

// @function curvedPathCommands(rings: Point[], closed: Boolean, smoothing: number): Command
// Generates a curved SVG path commands (using B茅zier curves) for multiple rings, with each ring turning
// into `{type: 'C', values: [..]}` commands
export function curvedPathCommands(points, closed, smoothing = 0.1) {
// append first 2 points for closed paths
if (closed) {
points = points.concat(points.slice(0, 2));
}

// Properties of a line
const line = (a, b) => {
const lengthX = b.x - a.x;
const lengthY = b.y - a.y;
return {
length: Math.sqrt(Math.pow(lengthX, 2) + Math.pow(lengthY, 2)),
angle: Math.atan2(lengthY, lengthX)
};
};

// Position of a control point
const controlPoint = (current, previous, next, reverse) => {
const p = previous || current;
const n = next || current;
const o = line(p, n);

const angle = o.angle + (reverse ? Math.PI : 0);
const length = o.length * smoothing;

const x = current.x + Math.cos(angle) * length;
const y = current.y + Math.sin(angle) * length;
return {x, y};
};

let cmds = [];
cmds.push({type: 'M', values: [points[0].x, points[0].y]});

for (let i = 1; i < points.length; i++) {
const point = points[i];
const cp1 = controlPoint(points[i - 1], points[i - 2], point);
const cp2 = controlPoint(point, points[i - 1], points[i + 1], true);
const command = {
type: 'C',
values: [cp1.x, cp1.y, cp2.x, cp2.y, point.x, point.y]
};

cmds.push(command);
}

// copy last commands 1st control point to first curveTo
if (closed) {
const comLast = cmds[cmds.length - 1];
const valuesLastC = comLast.values;
const valuesFirstC = cmds[1].values;

cmds[1] = {
type: 'C',
values: [valuesLastC[0], valuesLastC[1], valuesFirstC.slice(2)].flat()
};
// delete last curveto
cmds = cmds.slice(0, cmds.length - 1);
}

return cmds;
}

function commandsToPath(commands, decimals = 3) {
return commands
.map(com => `${com.type}${com.values.map(value => +value.toFixed(decimals)).join(' ')}`)
.join(' ');
}
8 changes: 6 additions & 2 deletions src/layer/vector/SVG.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {Renderer} from './Renderer.js';
import * as DomUtil from '../../dom/DomUtil.js';
import {splitWords, stamp} from '../../core/Util.js';
import {svgCreate, pointsToPath} from './SVG.Util.js';
import {svgCreate, pointsToPath, pointsToCurvedPath} from './SVG.Util.js';

export {pointsToPath};
export {pointsToCurvedPath};

export const create = svgCreate;

Expand Down Expand Up @@ -151,7 +153,9 @@ export const SVG = Renderer.extend({
},

_updatePoly(layer, closed) {
this._setPath(layer, pointsToPath(layer._parts, closed));
const rings = layer._parts;
const path = layer.options.curved ? pointsToCurvedPath(rings, closed) : pointsToPath(rings, closed);
this._setPath(layer, path);
},

_updateCircle(layer) {
Expand Down