From 4618be015149a6e0935e14df46d4a7e03fd0f916 Mon Sep 17 00:00:00 2001 From: sethg Date: Wed, 26 Aug 2026 14:06:38 +0200 Subject: [PATCH] Use geometric lengths for line highlighting --- app/plugin/LineSliceHighlight.js | 62 +++++++-- test/spec/plugin/LineSliceHighlight.spec.js | 140 ++++++++++++++++++++ 2 files changed, 190 insertions(+), 12 deletions(-) create mode 100644 test/spec/plugin/LineSliceHighlight.spec.js diff --git a/app/plugin/LineSliceHighlight.js b/app/plugin/LineSliceHighlight.js index e7ee264ed..6a9727422 100644 --- a/app/plugin/LineSliceHighlight.js +++ b/app/plugin/LineSliceHighlight.js @@ -11,13 +11,43 @@ Ext.define('CpsiMapview.plugin.LineSliceHighlight', { */ style: null, + constructor: function (config) { + this.callParent([config]); + if (config && config.style) { + this.style = config.style; + } + }, + init: function () { - this.style = new ol.style.Style({ - stroke: new ol.style.Stroke({ - color: 'red', - width: 2 - }) - }); + if (!this.style) { + this.style = new ol.style.Style({ + stroke: new ol.style.Stroke({ + color: 'red', + width: 2 + }) + }); + } + }, + + /** + * Measure a segment in the units the chainages are expressed in. + * + * @param {ol.geom.LineString} segment + * @param {ol.proj.Projection} projection + * @returns {number} length in metres + */ + getSegmentLength: function (segment, projection) { + if (projection && projection.getUnits() === 'degrees') { + return ol.sphere.getLength(segment, { + projection: projection.getCode() + }); + } + if (projection && projection.getCode() === 'EPSG:3857') { + // Web Mercator grid distances are inflated by 1/cos(latitude), + // so planar length is not usable here + return ol.sphere.getLength(segment, { projection: 'EPSG:3857' }); + } + return segment.getLength(); }, /** @@ -25,14 +55,16 @@ Ext.define('CpsiMapview.plugin.LineSliceHighlight', { * @param {ol.geom.LineString} geometry * @param {number} start in m * @param {number} end in m + * @param {ol.proj.Projection} projection projection of the geometry * @returns {ol.geom.LineString} */ - calculateSlice: function (geometry, start, end) { + calculateSlice: function (geometry, start, end, projection) { + const me = this; let length = 0; const coordinates = []; geometry.forEachSegment(function (a, b) { const segment = new ol.geom.LineString([a, b]); - const segmentLength = ol.sphere.getLength(segment); + const segmentLength = me.getSegmentLength(segment, projection); if (length <= start && start < length + segmentLength) { // start is in this segment coordinates.push( @@ -44,7 +76,7 @@ Ext.define('CpsiMapview.plugin.LineSliceHighlight', { length + segmentLength <= end ) { // the endpoint of the segment is between start and end - // openlayers forEachSegment reuses the arrays for the coordinates so it needs to be cloned + // OpenLayers forEachSegment reuses the arrays for the coordinates so it needs to be cloned coordinates.push(b.slice()); } if (length <= end && end < length + segmentLength) { @@ -64,9 +96,15 @@ Ext.define('CpsiMapview.plugin.LineSliceHighlight', { * @param {ol.geom.LineString} geometry * @param {number} start * @param {number} end + * @param {ol.Map} [map] defaults to the application's main map */ - highlightSlice: function (geometry, start, end) { - const map = BasiGX.util.Map.getMapComponent().map; + highlightSlice: function (geometry, start, end, map) { + if (!map) { + map = BasiGX.util.Map.getMapComponent().map; + } + + const projection = map.getView().getProjection(); + if (!this.layer) { this.layer = new ol.layer.Vector({ style: this.style, @@ -78,7 +116,7 @@ Ext.define('CpsiMapview.plugin.LineSliceHighlight', { } const feature = new ol.Feature( - this.calculateSlice(geometry, start, end) + this.calculateSlice(geometry, start, end, projection) ); this.layer.getSource().addFeature(feature); diff --git a/test/spec/plugin/LineSliceHighlight.spec.js b/test/spec/plugin/LineSliceHighlight.spec.js new file mode 100644 index 000000000..9ef59db3e --- /dev/null +++ b/test/spec/plugin/LineSliceHighlight.spec.js @@ -0,0 +1,140 @@ +describe('CpsiMapview.plugin.LineSliceHighlight', function () { + Ext.Loader.syncRequire(['CpsiMapview.plugin.LineSliceHighlight']); + + // a straight 1000 m East-West line in Irish Transverse Mercator + const makeLine = function () { + return new ol.geom.LineString([ + [714000, 729000], + [714500, 729000], + [715000, 729000] + ]); + }; + + const itm = new ol.proj.Projection({ + code: 'EPSG:2157', + units: 'm' + }); + + const makePlugin = function () { + const plugin = new CpsiMapview.plugin.LineSliceHighlight(); + plugin.init(); + return plugin; + }; + + describe('Basics', function () { + it('is defined', function () { + expect(CpsiMapview.plugin.LineSliceHighlight).not.to.be(undefined); + }); + + it('can be created', function () { + const plugin = new CpsiMapview.plugin.LineSliceHighlight(); + expect(plugin).to.not.be(undefined); + }); + + it('sets a default style on init', function () { + const plugin = makePlugin(); + expect(plugin.style).to.not.be(null); + }); + + it('does not overwrite a configured style', function () { + const style = new ol.style.Style({ + stroke: new ol.style.Stroke({ color: 'blue', width: 9 }) + }); + const plugin = new CpsiMapview.plugin.LineSliceHighlight({ + style: style + }); + plugin.init(); + expect(plugin.style).to.be(style); + }); + + it('can set a style', function () { + const plugin = makePlugin(); + const style = new ol.style.Style({ + stroke: new ol.style.Stroke({ color: 'green', width: 1 }) + }); + plugin.setStyle(style); + expect(plugin.style).to.be(style); + }); + }); + + describe('calculateSlice', function () { + it('measures projected geometry as planar length', function () { + const plugin = makePlugin(); + const slice = plugin.calculateSlice(makeLine(), 0, 1000, itm); + expect(Math.round(slice.getLength())).to.be(1000); + }); + + it('slices from the start of the line', function () { + const plugin = makePlugin(); + const slice = plugin.calculateSlice(makeLine(), 0, 250, itm); + expect(Math.round(slice.getLength())).to.be(250); + }); + + it('slices across a vertex', function () { + const plugin = makePlugin(); + const slice = plugin.calculateSlice(makeLine(), 400, 600, itm); + const coords = slice.getCoordinates(); + expect(Math.round(slice.getLength())).to.be(200); + expect(Math.round(coords[0][0])).to.be(714400); + expect(Math.round(coords[coords.length - 1][0])).to.be(714600); + }); + + it('slices to the end of the line', function () { + const plugin = makePlugin(); + const slice = plugin.calculateSlice(makeLine(), 750, 1000, itm); + expect(Math.round(slice.getLength())).to.be(250); + }); + + it('keeps the intermediate vertex in a full slice', function () { + const plugin = makePlugin(); + const slice = plugin.calculateSlice(makeLine(), 0, 1000, itm); + expect(slice.getCoordinates().length).to.be(3); + }); + + it('does not mutate the source geometry', function () { + const plugin = makePlugin(); + const line = makeLine(); + const before = JSON.stringify(line.getCoordinates()); + plugin.calculateSlice(line, 100, 900, itm); + expect(JSON.stringify(line.getCoordinates())).to.be(before); + }); + + it('handles a north-south line', function () { + const plugin = makePlugin(); + const line = new ol.geom.LineString([ + [714000, 729000], + [714000, 730000] + ]); + const slice = plugin.calculateSlice(line, 0, 1000, itm); + expect(Math.round(slice.getLength())).to.be(1000); + }); + + it('handles a diagonal line', function () { + const plugin = makePlugin(); + const line = new ol.geom.LineString([ + [714000, 729000], + [714300, 729400] + ]); + // hypotenuse is exactly 500 m + const slice = plugin.calculateSlice(line, 0, 500, itm); + expect(Math.round(slice.getLength())).to.be(500); + }); + }); + + describe('removeHighlight', function () { + it('does nothing when there is no layer', function () { + const plugin = makePlugin(); + plugin.removeHighlight(); + expect(plugin.layer).to.be(undefined); + }); + + it('clears the layer reference', function () { + const plugin = makePlugin(); + plugin.layer = new ol.layer.Vector({ + source: new ol.source.Vector() + }); + plugin.removeHighlight(); + expect(plugin.layer).to.be(null); + }); + }); +});