From dd1afed50737486a44962752031627b296381a95 Mon Sep 17 00:00:00 2001 From: Aleksey Kuznietsov Date: Mon, 2 Jun 2025 19:10:58 -0400 Subject: [PATCH 1/4] Don't check hover if hovering features are empty Otherwise, an error may occur in the inBounds() function when it checks if some point is within the bounds but the bounds are empty. --- src/lines.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/lines.ts b/src/lines.ts index 51d872c..2cf8681 100644 --- a/src/lines.ts +++ b/src/lines.ts @@ -514,6 +514,7 @@ export class Lines extends BaseGlLayer { weight, scale, } = instance; + function checkHover( coordinate: Position, prevCoordinate: Position, @@ -539,8 +540,13 @@ export class Lines extends BaseGlLayer { } return false; } - if (!instance.active) return; - if (map !== instance.map) return; + + if (!instance.active + || (map !== instance.map) + || !data.features.length) { + return; + } + const oldHoveredFeatures = hoveringFeatures; const newHoveredFeatures: Array> = []; From 4edb4a2bc58f8729863d5a12cd5709771b1851b2 Mon Sep 17 00:00:00 2001 From: Aleksey Kuznietsov Date: Tue, 22 Jul 2025 01:56:34 -0400 Subject: [PATCH 2/4] Fix for issue #182 + getBounds() & incBounds() methods + performance optimization for tryHover method --- src/lines.ts | 189 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 153 insertions(+), 36 deletions(-) diff --git a/src/lines.ts b/src/lines.ts index 2cf8681..4313f84 100644 --- a/src/lines.ts +++ b/src/lines.ts @@ -1,4 +1,14 @@ -import { Map, LeafletMouseEvent, geoJSON } from "leaflet"; +import type { + Map, + LeafletMouseEvent, + LatLngBounds, + LatLng, +} from "leaflet"; +import { + latLng, + latLngBounds, +} from "leaflet"; // L.latLngBounds() + import { Feature, FeatureCollection, @@ -74,7 +84,7 @@ export class Lines extends BaseGlLayer { constructor(settings: Partial) { super(settings); - this.settings = { ...Lines.defaults, ...settings }; + this.settings = { ...Lines.defaults, ...settings }; // as ILinesSettings; if (!settings.data) { throw new Error('"data" is missing'); @@ -330,6 +340,74 @@ export class Lines extends BaseGlLayer { return this; } + // cache, to not recalculate bounds on each tryHover + private _lastFeatureCount: number = 0; + private _cachedBounds?: LatLngBounds; + + /** + * Calculates and returns the bounding box (`LatLngBounds`) for all features in the current dataset (`settings.data.features`). + * + * The bounds are computed by iterating through each feature's geometry and determining the minimum and maximum + * latitude and longitude values. The result is cached (for performance optimization) and recalculated only if the number of features changes. + * + * @returns {LatLngBounds} The calculated bounding box containing all feature coordinates. + */ + getBounds(): LatLngBounds { + const me = this; + const features = me.settings.data?.features!; // settings.data is always declared in constructor. Avoiding warnings. + const featuresCount = features.length; + + if (me._lastFeatureCount !== featuresCount) { + me._lastFeatureCount = featuresCount; + + let minLat = Infinity, minLng = Infinity; + let maxLat = -Infinity, maxLng = -Infinity; + + for (const feature of features) { + for (const line of feature.geometry.type === 'LineString' + ? [feature.geometry.coordinates] + : feature.geometry.coordinates as Position[][]) { + for (const coord of line) { + const lat = coord[me.latitudeKey]; + const lng = coord[me.longitudeKey]; + if (lat < minLat) minLat = lat; + if (lat > maxLat) maxLat = lat; + if (lng < minLng) minLng = lng; + if (lng > maxLng) maxLng = lng; + } + } + } + + // set cached bounds + me._cachedBounds = latLngBounds( // L.latLngBounds() + { lat: minLat, lng: minLng }, // sw + { lat: maxLat, lng: maxLng } // ne + ); + } + + return me._cachedBounds!; // guarantee that bounds are calculated + } + + /** + * Expands the given bounding box by a specified sensitivity and the line weight. + * It's not really accurate if weight is a function, but it works for most cases. + * + * @param sensitivity - The amount to expand the bounds by, in map units. + * @param bounds - Optional. The bounding box to expand. If not provided, uses the object's bounds. + * @returns A new `LatLngBounds` object representing the expanded bounding box. + */ + incBounds(sensitivity: number, bounds?: LatLngBounds): LatLngBounds { + const bBox = bounds ?? this.getBounds(); + + // increase the bounding box of the features by sensitivity + weight + const pad = sensitivity + + (typeof this.weight === "function" ? 0 : this.weight) / this.scale; + const sw = latLng(bBox.getSouth() - pad, bBox.getWest() - pad); + const ne = latLng(bBox.getNorth() + pad, bBox.getEast() + pad); + + return latLngBounds(sw, ne); + } + // attempts to click the top-most Lines instance static tryClick( e: LeafletMouseEvent, @@ -340,10 +418,25 @@ export class Lines extends BaseGlLayer { let foundLines: Lines | null = null; instances.forEach((instance: Lines): void => { - const { latitudeKey, longitudeKey, sensitivity, weight, scale, active } = - instance; - if (!active) return; - if (instance.map !== map) return; + const { + latitudeKey, + longitudeKey, + sensitivity, + data, + weight, + scale, + active + } = instance; + + const features: Feature[] = data.features; + const featuresLen: number = features.length; + + if (!active + || map !== instance.map + || !featuresLen) { + return; + } + function checkClick( coordinate: Position, prevCoordinate: Position, @@ -358,15 +451,16 @@ export class Lines extends BaseGlLayer { coordinate[longitudeKey], coordinate[latitudeKey] ); + if (distance <= sensitivity + chosenWeight / scale) { foundFeature = feature; foundLines = instance; } } - instance.data.features.forEach( + + features.forEach( (feature: Feature, i: number): void => { - const chosenWeight = - typeof weight === "function" ? weight(i, feature) : weight; + const chosenWeight = typeof weight === "function" ? weight(i, feature) : weight; const { coordinates, type } = feature.geometry; if (type === "LineString") { for (let i = 1; i < coordinates.length; i++) { @@ -409,7 +503,7 @@ export class Lines extends BaseGlLayer { if (foundLines && foundFeature) { const result = (foundLines as Lines).click(e, foundFeature); - return result !== undefined ? result : undefined; + return result; // same as: return result !== undefined ? result : undefined; } } @@ -423,10 +517,25 @@ export class Lines extends BaseGlLayer { let foundLines: Lines | null = null; instances.forEach((instance: Lines): void => { - const { latitudeKey, longitudeKey, sensitivity, weight, scale, active } = - instance; - if (!active) return; - if (instance.map !== map) return; + const { + latitudeKey, + longitudeKey, + sensitivity, + data, + weight, + scale, + active + } = instance; + + const features: Feature[] = data.features; + const featuresLen: number = features.length; + + if (!active + || map !== instance.map + || !featuresLen) { + return; + } + function checkContextMenu( coordinate: Position, prevCoordinate: Position, @@ -446,10 +555,10 @@ export class Lines extends BaseGlLayer { foundLines = instance; } } - instance.data.features.forEach( + + features.forEach( (feature: Feature, i: number): void => { - const chosenWeight = - typeof weight === "function" ? weight(i, feature) : weight; + const chosenWeight = typeof weight === "function" ? weight(i, feature) : weight; const { coordinates, type } = feature.geometry; if (type === "LineString") { for (let i = 1; i < coordinates.length; i++) { @@ -492,11 +601,12 @@ export class Lines extends BaseGlLayer { if (foundLines && foundFeature) { const result = (foundLines as Lines).contextMenu(e, foundFeature); - return result !== undefined ? result : undefined; + return result; // same as: return result !== undefined ? result : undefined; } } hoveringFeatures: Array> = []; + // hovers all touching Lines instances static tryHover( e: LeafletMouseEvent, @@ -504,6 +614,7 @@ export class Lines extends BaseGlLayer { instances: Lines[] ): Array { const results: Array = []; + instances.forEach((instance: Lines): void => { const { sensitivityHover, @@ -513,8 +624,19 @@ export class Lines extends BaseGlLayer { hoveringFeatures, weight, scale, + active } = instance; + const features: Feature[] = data.features; + const featuresLen: number = features.length; + + if (!active + || map !== instance.map + || !featuresLen + || (!instance.hover && !instance.hoverOff)) { + return; + } + function checkHover( coordinate: Position, prevCoordinate: Position, @@ -541,24 +663,16 @@ export class Lines extends BaseGlLayer { return false; } - if (!instance.active - || (map !== instance.map) - || !data.features.length) { - return; - } - const oldHoveredFeatures = hoveringFeatures; - const newHoveredFeatures: Array> = - []; + const newHoveredFeatures: Array> = []; instance.hoveringFeatures = newHoveredFeatures; - // Check if e.latlng is inside the bbox of the features - const bounds = geoJSON(data.features).getBounds(); - if (inBounds(e.latlng, bounds)) { - data.features.forEach( + // AK 2025-07-21: this is check for performance optimization, if point is outside of boundaries of all features but feel free to comment out the inBounds check. + // Use incBounds to increase the bounding box of the features by sensitivityHover + weight + if (inBounds(e.latlng, instance.incBounds(sensitivityHover))) { // Check if e.latlng is inside the boundaries of all the features (BTW, don't use Leaflet's geoJSON, it has swapped lat/lng) + features.forEach( (feature: Feature, i: number): void => { - const chosenWeight = - typeof weight === "function" ? weight(i, feature) : weight; + const chosenWeight = typeof weight === "function" ? weight(i, feature) : weight; const { coordinates, type } = feature.geometry; let isHovering = false; if (type === "LineString") { @@ -587,6 +701,7 @@ export class Lines extends BaseGlLayer { chosenWeight ); if (isHovering) break; + } else if (j > 0) { isHovering = checkHover( coordinates[i][j] as Position, @@ -608,10 +723,12 @@ export class Lines extends BaseGlLayer { } ); } - for (let i = 0; i < oldHoveredFeatures.length; i++) { - const feature = oldHoveredFeatures[i]; - if (!newHoveredFeatures.includes(feature)) { - instance.hoverOff(e, feature); + + if (instance.hoverOff) { + for (const oldHoveredFeature of oldHoveredFeatures) { + if (!newHoveredFeatures.includes(oldHoveredFeature)) { + instance.hoverOff(e, oldHoveredFeature); + } } } }); From 6003cdb80fff52fe6a16e345e29f9386492701ab Mon Sep 17 00:00:00 2001 From: Aleksey Kuznietsov Date: Tue, 22 Jul 2025 01:58:18 -0400 Subject: [PATCH 3/4] Update lines.ts --- src/lines.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lines.ts b/src/lines.ts index 4313f84..44e1c5e 100644 --- a/src/lines.ts +++ b/src/lines.ts @@ -5,9 +5,9 @@ import type { LatLng, } from "leaflet"; import { - latLng, - latLngBounds, -} from "leaflet"; // L.latLngBounds() + latLng, // L.latLng() + latLngBounds, // L.latLngBounds() +} from "leaflet"; import { Feature, From 3d2881aacc5bc828f98da4f462f8fb4bb40f78b2 Mon Sep 17 00:00:00 2001 From: Aleksey Kuznietsov Date: Tue, 22 Jul 2025 02:01:09 -0400 Subject: [PATCH 4/4] Update lines.ts --- src/lines.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lines.ts b/src/lines.ts index 44e1c5e..1745da8 100644 --- a/src/lines.ts +++ b/src/lines.ts @@ -633,7 +633,7 @@ export class Lines extends BaseGlLayer { if (!active || map !== instance.map || !featuresLen - || (!instance.hover && !instance.hoverOff)) { + || (!instance.hover && !instance.hoverOff)) { // if none of these method are used, no need to do all the calculations below to check whether mouse hovers some feature. return; } @@ -724,6 +724,7 @@ export class Lines extends BaseGlLayer { ); } + // call `hoverOff()` only if it's really used in current instance. if (instance.hoverOff) { for (const oldHoveredFeature of oldHoveredFeatures) { if (!newHoveredFeatures.includes(oldHoveredFeature)) {