diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d2b47d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +node_modules \ No newline at end of file diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..9eabbfb --- /dev/null +++ b/README.MD @@ -0,0 +1,261 @@ +📦🤗 npm-friendly fork of +[jawj/OverlappingMarkerSpiderfier](https://github.com/jawj/OverlappingMarkerSpiderfier). + +Overlapping Marker Spiderfier for Leaflet +========================================= + +**Ever noticed how, in [Google Earth](http://earth.google.com), marker +pins that overlap each other spring apart gracefully when you click +them, so you can pick the one you meant?** + +**And ever noticed how, when using the [Leaflet +API](http://leaflet.cloudmade.com), the same thing doesn't happen?** + +This code makes Leaflet map markers behave in that Google Earth way +(minus the animation). Small numbers of markers (yes, up to 8) spiderfy +into a circle. Larger numbers fan out into a more space-efficient +spiral. + +The compiled code has no dependencies beyond Leaflet. And it's under 3K +when compiled out of +[CoffeeScript](http://jashkenas.github.com/coffee-script/), minified +with Google's [Closure +Compiler](http://code.google.com/closure/compiler/) and gzipped. + +It's a port of my [original library for the Google Maps +API](https://github.com/jawj/OverlappingMarkerSpiderfier). (Since the +Leaflet API doesn't let us observe all the event types that the Google +one does, the main difference between the original and the port is this: +you must first call `unspiderfy` if and when you want to move a marker +in the Leaflet version). + +### Doesn't clustering solve this problem? + +You may have seen the marker clustering libraries, which also help deal +with markers that are close together. + +That might be what you want. However, it probably **isn't** what you +want (or isn't the only thing you want) if you have markers that could +be in the exact same location, or close enough to overlap even at the +maximum zoom level. In that case, clustering won't help your users see +and/or click on the marker they're looking for. + +Demo +---- + +See the [demo +map](http://jawj.github.com/OverlappingMarkerSpiderfier-Leaflet/demo.html) +(the data is random: reload the map to reposition the markers). + +Download +-------- + +Download [the compiled, minified JS +source](https://unpkg.com/overlapping-marker-spiderfier-leaflet@latest). + +Or download it from npm: +`npm isntall -S @krozamdev/overlapping-marker-spiderfier` or +`yarn add @krozamdev/overlapping-marker-spiderfier` + +How to use +---------- + +See the [demo map +source](http://github.com/jawj/OverlappingMarkerSpiderfier-Leaflet/blob/gh-pages/demo.html), +or follow along here for a slightly simpler usage with commentary. + +Create your map like normal (using the beautiful [Stamen watercolour OSM +map](http://maps.stamen.com/#watercolor)): + +```javascript +var map = new L.Map('map_canvas', {center: new L.LatLng(51.505, -0.09), zoom: 13}); +var layer = new L.StamenTileLayer('watercolor'); +map.addLayer(layer); +``` + +Create an `OverlappingMarkerSpiderfier` instance: + +```javascript +var oms = new OverlappingMarkerSpiderfier(map); +``` + +Instead of adding click listeners to your markers directly via +`marker.addEventListener` or `marker.on`, add a global listener on the +`OverlappingMarkerSpiderfier` instance instead. The listener will be +passed the clicked marker as its first argument. + +```javascript +var popup = new L.Popup(); +oms.addListener('click', function(marker) { + popup.setContent(marker.desc); + popup.setLatLng(marker.getLatLng()); + map.openPopup(popup); +}); +``` + +You can also add listeners on the `spiderfy` and `unspiderfy` events, +which will be passed an array of the markers affected. In this example, +we observe only the `spiderfy` event, using it to close any open +`InfoWindow`: + +```javascript +oms.addListener('spiderfy', function(markers) { + map.closePopup(); +}); +``` + +Finally, tell the `OverlappingMarkerSpiderfier` instance about each +marker as you add it, using the `addMarker` method: + +```javascript +for (var i = 0; i < window.mapData.length; i ++) { + var datum = window.mapData[i]; + var loc = new L.LatLng(datum.lat, datum.lon); + var marker = new L.Marker(loc); + marker.desc = datum.d; + map.addLayer(marker); + oms.addMarker(marker); // <-- here +} +``` + +Docs +---- + +### Loading + +The Leaflet `L` object must be available when this code runs --- i.e. +put the Leaflet API \ void; + unhighlight: (event: L.LeafletEvent) => void; + }; +} +interface OMSOptions { + keepSpiderfied?: boolean; + nearbyDistance?: number; + circleSpiralSwitchover?: number; + circleFootSeparation?: number; + circleStartAngle?: number; + spiralFootSeparation?: number; + spiralLengthStart?: number; + spiralLengthFactor?: number; + legWeight?: number; + legColors?: { + usual: string; + highlighted: string; + }; +} +declare module 'leaflet' { + interface Marker { + _omsData?: OmsData; + } +} +export default class OverlappingMarkerSpiderfier { + static VERSION: string; + private map; + private keepSpiderfied; + private nearbyDistance; + private circleSpiralSwitchover; + private circleFootSeparation; + private circleStartAngle; + private spiralFootSeparation; + private spiralLengthStart; + private spiralLengthFactor; + private legWeight; + private legColors; + private markers; + private markerListeners; + private listeners; + private spiderfying?; + private spiderfied?; + private unspiderfying?; + constructor(map: L.Map, opts?: OMSOptions); + private initMarkerArrays; + addMarker(marker: L.Marker): this; + getMarkers(): L.Marker[]; + removeMarker(marker: L.Marker): this; + clearMarkers(): this; + addListener(event: string, func: Function): this; + removeListener(event: string, func: Function): this; + clearListeners(event: string): this; + trigger(event: string, ...args: any[]): void; + private generatePtsCircle; + private generatePtsSpiral; + private spiderListener; + private makeHighlightListeners; + private spiderfy; + unspiderfy(markerNotToMove?: L.Marker): void; + private ptDistanceSq; + private ptAverage; + private minExtract; + private arrIndexOf; +} +export {}; diff --git a/dist/OverlappingMarkerSpiderfier.js b/dist/OverlappingMarkerSpiderfier.js new file mode 100644 index 0000000..70595ef --- /dev/null +++ b/dist/OverlappingMarkerSpiderfier.js @@ -0,0 +1,301 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { + if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { + if (ar || !(i in from)) { + if (!ar) ar = Array.prototype.slice.call(from, 0, i); + ar[i] = from[i]; + } + } + return to.concat(ar || Array.prototype.slice.call(from)); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +var L = __importStar(require("leaflet")); +var OverlappingMarkerSpiderfier = /** @class */ (function () { + function OverlappingMarkerSpiderfier(map, opts) { + if (opts === void 0) { opts = {}; } + var _this = this; + this.markers = []; + this.markerListeners = []; + this.listeners = {}; + this.spiderfying = false; + this.spiderfied = false; + this.unspiderfying = false; + this.map = map; + this.keepSpiderfied = opts.keepSpiderfied || false; + this.nearbyDistance = opts.nearbyDistance || 20; + this.circleSpiralSwitchover = opts.circleSpiralSwitchover || 9; + this.circleFootSeparation = opts.circleFootSeparation || 25; + this.circleStartAngle = opts.circleStartAngle || (Math.PI * 2) / 12; + this.spiralFootSeparation = opts.spiralFootSeparation || 28; + this.spiralLengthStart = opts.spiralLengthStart || 11; + this.spiralLengthFactor = opts.spiralLengthFactor || 5; + this.legWeight = opts.legWeight || 1.5; + this.legColors = opts.legColors || { + usual: "#222", + highlighted: "#f00" + }; + this.initMarkerArrays(); + ['click', 'zoomend'].forEach(function (e) { + return _this.map.addEventListener(e, function () { return _this.unspiderfy(); }); + }); + } + OverlappingMarkerSpiderfier.prototype.initMarkerArrays = function () { + this.markers = []; + this.markerListeners = []; + }; + OverlappingMarkerSpiderfier.prototype.addMarker = function (marker) { + var _this = this; + if (marker._oms) + return this; + marker._oms = true; + var markerListener = function () { return _this.spiderListener(marker); }; + marker.addEventListener('click', markerListener); + this.markerListeners.push({ marker: marker, listener: markerListener }); + this.markers.push(marker); + return this; + }; + OverlappingMarkerSpiderfier.prototype.getMarkers = function () { + return __spreadArray([], this.markers, true); + }; + OverlappingMarkerSpiderfier.prototype.removeMarker = function (marker) { + if (marker._omsData) + this.unspiderfy(); + var index = this.arrIndexOf(this.markers, marker); + if (index < 0) + return this; + var listenerData = this.markerListeners.find(function (ml) { return ml.marker === marker; }); + if (listenerData) { + marker.removeEventListener('click', listenerData.listener); + this.markerListeners = this.markerListeners.filter(function (ml) { return ml !== listenerData; }); + } + delete marker._oms; + this.markers.splice(index, 1); + return this; + }; + OverlappingMarkerSpiderfier.prototype.clearMarkers = function () { + var _this = this; + this.unspiderfy(); + this.markers.forEach(function (marker, i) { + var listenerData = _this.markerListeners.find(function (ml) { return ml.marker === marker; }); + if (listenerData) { + marker.removeEventListener('click', listenerData.listener); + } + delete marker._oms; + }); + this.initMarkerArrays(); + return this; + }; + OverlappingMarkerSpiderfier.prototype.addListener = function (event, func) { + if (!this.listeners[event]) + this.listeners[event] = []; + this.listeners[event].push(func); + return this; + }; + OverlappingMarkerSpiderfier.prototype.removeListener = function (event, func) { + var index = this.arrIndexOf(this.listeners[event], func); + if (index >= 0) + this.listeners[event].splice(index, 1); + return this; + }; + OverlappingMarkerSpiderfier.prototype.clearListeners = function (event) { + this.listeners[event] = []; + return this; + }; + OverlappingMarkerSpiderfier.prototype.trigger = function (event) { + var args = []; + for (var _i = 1; _i < arguments.length; _i++) { + args[_i - 1] = arguments[_i]; + } + (this.listeners[event] || []).forEach(function (func) { return func.apply(void 0, args); }); + }; + OverlappingMarkerSpiderfier.prototype.generatePtsCircle = function (count, centerPt) { + var _this = this; + var circumference = this.circleFootSeparation * (2 + count); + var legLength = circumference / (Math.PI * 2); + var angleStep = (Math.PI * 2) / count; + return Array.from({ length: count }, function (_, i) { + var angle = _this.circleStartAngle + i * angleStep; + return new L.Point(centerPt.x + legLength * Math.cos(angle), centerPt.y + legLength * Math.sin(angle)); + }); + }; + OverlappingMarkerSpiderfier.prototype.generatePtsSpiral = function (count, centerPt) { + var _this = this; + var angle = 0; + var legLength = this.spiralLengthStart; + return Array.from({ length: count }, function (_, i) { + angle += _this.spiralFootSeparation / legLength + i * 0.0005; + var pt = new L.Point(centerPt.x + legLength * Math.cos(angle), centerPt.y + legLength * Math.sin(angle)); + legLength += (Math.PI * 2 * _this.spiralLengthFactor) / angle; + return pt; + }); + }; + OverlappingMarkerSpiderfier.prototype.spiderListener = function (marker) { + var _this = this; + var markerSpiderfied = marker._omsData != null; + if (!(markerSpiderfied && this.keepSpiderfied)) { + this.unspiderfy(); + } + if (markerSpiderfied) { + this.trigger('click', marker); + } + else { + var nearbyMarkerData_1 = []; + var nonNearbyMarkers_1 = []; + var pxSq_1 = this.nearbyDistance * this.nearbyDistance; + var markerPt_1 = this.map.latLngToLayerPoint(marker.getLatLng()); + this.markers.forEach(function (m) { + if (!_this.map.hasLayer(m)) + return; + var mPt = _this.map.latLngToLayerPoint(m.getLatLng()); + if (_this.ptDistanceSq(mPt, markerPt_1) < pxSq_1) { + nearbyMarkerData_1.push({ marker: m, markerPt: mPt }); + } + else { + nonNearbyMarkers_1.push(m); + } + }); + if (nearbyMarkerData_1.length === 1) { + this.trigger('click', marker); + } + else { + this.spiderfy(nearbyMarkerData_1, nonNearbyMarkers_1); + } + } + }; + OverlappingMarkerSpiderfier.prototype.makeHighlightListeners = function (marker) { + var _this = this; + return { + highlight: function (event) { + var data = marker._omsData; + if (data && data.leg) { + data.leg.setStyle({ + color: _this.legColors.highlighted, + }); + } + }, + unhighlight: function (event) { + var data = marker._omsData; + if (data && data.leg) { + data.leg.setStyle({ + color: _this.legColors.usual, + }); + } + } + }; + }; + OverlappingMarkerSpiderfier.prototype.spiderfy = function (markerData, nonNearbyMarkers) { + var _this = this; + this.spiderfying = true; + var numFeet = markerData.length; + var bodyPt = this.ptAverage(markerData.map(function (md) { return md.markerPt; })); + var footPts = numFeet >= this.circleSpiralSwitchover + ? this.generatePtsSpiral(numFeet, bodyPt).reverse() + : this.generatePtsCircle(numFeet, bodyPt); + var spiderfiedMarkers = footPts.map(function (footPt) { + var footLl = _this.map.layerPointToLatLng(footPt); + var nearestMarkerDatum = _this.minExtract(markerData, function (md) { + return _this.ptDistanceSq(md.markerPt, footPt); + }); + var marker = nearestMarkerDatum.marker; + var leg = new L.Polyline([marker.getLatLng(), footLl], { + color: _this.legColors.usual, + weight: _this.legWeight, + interactive: false + }); + _this.map.addLayer(leg); + marker._omsData = { + usualPosition: marker.getLatLng(), + leg: leg + }; + if (_this.legColors.highlighted !== _this.legColors.usual) { + var mhl = _this.makeHighlightListeners(marker); + marker._omsData.highlightListeners = mhl; + marker.addEventListener('mouseover', mhl.highlight); + marker.addEventListener('mouseout', mhl.unhighlight); + } + marker.setLatLng(footLl); + marker.setZIndexOffset(1000000); + return marker; + }); + delete this.spiderfying; + this.spiderfied = true; + this.trigger('spiderfy', spiderfiedMarkers, nonNearbyMarkers); + }; + OverlappingMarkerSpiderfier.prototype.unspiderfy = function (markerNotToMove) { + var _this = this; + if (this.unspiderfying || !this.spiderfied) + return; + this.unspiderfying = true; + var unspiderfiedMarkers = []; + this.markers.forEach(function (marker) { + var data = marker._omsData; + if (data) { + _this.map.removeLayer(data.leg); + if (marker !== markerNotToMove) { + marker.setLatLng(data.usualPosition); + } + marker.setZIndexOffset(0); + var hl = data.highlightListeners; + if (hl) { + marker.removeEventListener('mouseover', hl.highlight); + marker.removeEventListener('mouseout', hl.unhighlight); + } + delete marker._omsData; + unspiderfiedMarkers.push(marker); + } + }); + delete this.unspiderfying; + delete this.spiderfied; + this.trigger('unspiderfy', unspiderfiedMarkers); + }; + OverlappingMarkerSpiderfier.prototype.ptDistanceSq = function (pt1, pt2) { + var dx = pt1.x - pt2.x; + var dy = pt1.y - pt2.y; + return dx * dx + dy * dy; + }; + OverlappingMarkerSpiderfier.prototype.ptAverage = function (pts) { + var sumPt = pts.reduce(function (acc, pt) { return new L.Point(acc.x + pt.x, acc.y + pt.y); }, new L.Point(0, 0)); + return new L.Point(sumPt.x / pts.length, sumPt.y / pts.length); + }; + OverlappingMarkerSpiderfier.prototype.minExtract = function (set, func) { + var bestIndex = 0; + var bestVal = func(set[0]); + set.forEach(function (item, i) { + var val = func(item); + if (val < bestVal) { + bestVal = val; + bestIndex = i; + } + }); + return set.splice(bestIndex, 1)[0]; + }; + OverlappingMarkerSpiderfier.prototype.arrIndexOf = function (arr, obj) { + return arr.indexOf(obj); + }; + OverlappingMarkerSpiderfier.VERSION = "1.0.0"; + return OverlappingMarkerSpiderfier; +}()); +exports.default = OverlappingMarkerSpiderfier; diff --git a/package.json b/package.json new file mode 100644 index 0000000..f5c7b8f --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "@krozamdev/overlapping-marker-spiderfier", + "version": "0.2.7", + "description": "Deals with overlapping markers in the Leaflet maps API, Google Earth-style", + "keywords": [ + "overlapping marker", + "spiderfier", + "leaflet", + "leaflet plugin" + ], + "homepage": "https://github.com/jawj/OverlappingMarkerSpiderfier-Leaflet", + "repository": { + "type": "git", + "url": "https://github.com/krozamdev/OverlappingMarkerSpiderfier-Leaflet.git" + }, + "bugs": "https://github.com/jawj/OverlappingMarkerSpiderfier-Leaflet/issues", + "main": "dist/OverlappingMarkerSpiderfier.js", + "types": "dist/OverlappingMarkerSpiderfier.d.ts", + "peerDependencies": { + "leaflet": "^1.7.1" + }, + "devDependencies": { + "typescript": "^4.5.4", + "@types/leaflet": "^1.7.6" + }, + "scripts": { + "build": "tsc" + }, + "author": "George MacKerron (http://mackerron.com)", + "license": "MIT" +} diff --git a/src/OverlappingMarkerSpiderfier.ts b/src/OverlappingMarkerSpiderfier.ts new file mode 100644 index 0000000..49fd0a0 --- /dev/null +++ b/src/OverlappingMarkerSpiderfier.ts @@ -0,0 +1,329 @@ +import * as L from 'leaflet'; + +interface MarkerData { + marker: L.Marker; + markerPt: L.Point; +} + +interface OmsData { + usualPosition: L.LatLng; + leg: L.Polyline; + highlightListeners?: { + highlight: (event: L.LeafletEvent) => void; + unhighlight: (event: L.LeafletEvent) => void; + }; +} + +interface OMSOptions { + keepSpiderfied?: boolean; + nearbyDistance?: number; + circleSpiralSwitchover?: number; + circleFootSeparation?: number; + circleStartAngle?: number; + spiralFootSeparation?: number; + spiralLengthStart?: number; + spiralLengthFactor?: number; + legWeight?: number; + legColors?: { + usual: string; + highlighted: string; + }; +} + +declare module 'leaflet' { + interface Marker { + _omsData?: OmsData; + } +} + +export default class OverlappingMarkerSpiderfier { + static VERSION = "1.0.0"; + private map: L.Map; + private keepSpiderfied: boolean; + private nearbyDistance: number; + private circleSpiralSwitchover: number; + private circleFootSeparation: number; + private circleStartAngle: number; + private spiralFootSeparation: number; + private spiralLengthStart: number; + private spiralLengthFactor: number; + private legWeight: number; + private legColors: { usual: string; highlighted: string }; + private markers: L.Marker[] = []; + private markerListeners: Array<{ marker: L.Marker; listener: L.LeafletEventHandlerFn }> = []; + private listeners: { [key: string]: Function[] } = {}; + private spiderfying?: boolean = false; + private spiderfied?: boolean = false; + private unspiderfying?: boolean = false; + + constructor(map: L.Map, opts: OMSOptions = {}) { + this.map = map; + this.keepSpiderfied = opts.keepSpiderfied || false; + this.nearbyDistance = opts.nearbyDistance || 20; + this.circleSpiralSwitchover = opts.circleSpiralSwitchover || 9; + this.circleFootSeparation = opts.circleFootSeparation || 25; + this.circleStartAngle = opts.circleStartAngle || (Math.PI * 2) / 12; + this.spiralFootSeparation = opts.spiralFootSeparation || 28; + this.spiralLengthStart = opts.spiralLengthStart || 11; + this.spiralLengthFactor = opts.spiralLengthFactor || 5; + this.legWeight = opts.legWeight || 1.5; + this.legColors = opts.legColors || { + usual: "#222", + highlighted: "#f00" + }; + + this.initMarkerArrays(); + ['click', 'zoomend'].forEach((e) => + this.map.addEventListener(e, () => this.unspiderfy()) + ); + } + + private initMarkerArrays(): void { + this.markers = []; + this.markerListeners = []; + } + + addMarker(marker: L.Marker): this { + if ((marker as any)._oms) return this; + (marker as any)._oms = true; + + const markerListener: L.LeafletEventHandlerFn = () => this.spiderListener(marker); + marker.addEventListener('click', markerListener); + this.markerListeners.push({ marker, listener: markerListener }); + this.markers.push(marker); + return this; + } + + getMarkers(): L.Marker[] { + return [...this.markers]; + } + + removeMarker(marker: L.Marker): this { + if ((marker as any)._omsData) this.unspiderfy(); + + const index = this.arrIndexOf(this.markers, marker); + if (index < 0) return this; + + const listenerData = this.markerListeners.find((ml) => ml.marker === marker); + if (listenerData) { + marker.removeEventListener('click', listenerData.listener); + this.markerListeners = this.markerListeners.filter((ml) => ml !== listenerData); + } + delete (marker as any)._oms; + this.markers.splice(index, 1); + return this; + } + + clearMarkers(): this { + this.unspiderfy(); + this.markers.forEach((marker, i) => { + const listenerData = this.markerListeners.find((ml) => ml.marker === marker); + if (listenerData) { + marker.removeEventListener('click', listenerData.listener); + } + delete (marker as any)._oms; + }); + this.initMarkerArrays(); + return this; + } + + addListener(event: string, func: Function): this { + if (!this.listeners[event]) this.listeners[event] = []; + this.listeners[event].push(func); + return this; + } + + removeListener(event: string, func: Function): this { + const index = this.arrIndexOf(this.listeners[event], func); + if (index >= 0) this.listeners[event].splice(index, 1); + return this; + } + + clearListeners(event: string): this { + this.listeners[event] = []; + return this; + } + + trigger(event: string, ...args: any[]): void { + (this.listeners[event] || []).forEach((func) => func(...args)); + } + + private generatePtsCircle(count: number, centerPt: L.Point): L.Point[] { + const circumference = this.circleFootSeparation * (2 + count); + const legLength = circumference / (Math.PI * 2); + const angleStep = (Math.PI * 2) / count; + return Array.from({ length: count }, (_, i) => { + const angle = this.circleStartAngle + i * angleStep; + return new L.Point( + centerPt.x + legLength * Math.cos(angle), + centerPt.y + legLength * Math.sin(angle) + ); + }); + } + + private generatePtsSpiral(count: number, centerPt: L.Point): L.Point[] { + let angle = 0; + let legLength = this.spiralLengthStart; + return Array.from({ length: count }, (_, i) => { + angle += this.spiralFootSeparation / legLength + i * 0.0005; + const pt = new L.Point( + centerPt.x + legLength * Math.cos(angle), + centerPt.y + legLength * Math.sin(angle) + ); + legLength += (Math.PI * 2 * this.spiralLengthFactor) / angle; + return pt; + }); + } + + private spiderListener(marker: L.Marker): void { + const markerSpiderfied = (marker as any)._omsData != null; + if (!(markerSpiderfied && this.keepSpiderfied)) { + this.unspiderfy(); + } + if (markerSpiderfied) { + this.trigger('click', marker); + } else { + const nearbyMarkerData: MarkerData[] = []; + const nonNearbyMarkers: L.Marker[] = []; + const pxSq = this.nearbyDistance * this.nearbyDistance; + const markerPt = this.map.latLngToLayerPoint(marker.getLatLng()); + this.markers.forEach((m) => { + if (!this.map.hasLayer(m)) return; + const mPt = this.map.latLngToLayerPoint(m.getLatLng()); + if (this.ptDistanceSq(mPt, markerPt) < pxSq) { + nearbyMarkerData.push({ marker: m, markerPt: mPt }); + } else { + nonNearbyMarkers.push(m); + } + }); + if (nearbyMarkerData.length === 1) { + this.trigger('click', marker); + } else { + this.spiderfy(nearbyMarkerData, nonNearbyMarkers); + } + } + } + + private makeHighlightListeners(marker: L.Marker) { + return { + highlight: (event: L.LeafletEvent) => { + const data = marker._omsData; + if (data && data.leg) { + data.leg.setStyle({ + color: this.legColors.highlighted, + }); + } + }, + unhighlight: (event: L.LeafletEvent) => { + const data = marker._omsData; + if (data && data.leg) { + data.leg.setStyle({ + color: this.legColors.usual, + }); + } + } + }; + } + + + private spiderfy(markerData: MarkerData[], nonNearbyMarkers: L.Marker[]): void { + this.spiderfying = true; + const numFeet = markerData.length; + const bodyPt = this.ptAverage(markerData.map((md) => md.markerPt)); + const footPts = + numFeet >= this.circleSpiralSwitchover + ? this.generatePtsSpiral(numFeet, bodyPt).reverse() + : this.generatePtsCircle(numFeet, bodyPt); + + const spiderfiedMarkers = footPts.map((footPt) => { + const footLl = this.map.layerPointToLatLng(footPt); + const nearestMarkerDatum = this.minExtract(markerData, (md) => + this.ptDistanceSq(md.markerPt, footPt) + ); + const marker = nearestMarkerDatum.marker; + const leg = new L.Polyline([marker.getLatLng(), footLl], { + color: this.legColors.usual, + weight: this.legWeight, + interactive: false + }); + this.map.addLayer(leg); + (marker as any)._omsData = { + usualPosition: marker.getLatLng(), + leg: leg + }; + if (this.legColors.highlighted !== this.legColors.usual) { + const mhl = this.makeHighlightListeners(marker); + (marker as any)._omsData.highlightListeners = mhl; + marker.addEventListener('mouseover', mhl.highlight); + marker.addEventListener('mouseout', mhl.unhighlight); + } + marker.setLatLng(footLl); + marker.setZIndexOffset(1000000); + return marker; + }); + + delete this.spiderfying; + this.spiderfied = true; + this.trigger('spiderfy', spiderfiedMarkers, nonNearbyMarkers); + } + + unspiderfy(markerNotToMove?: L.Marker): void { + if (this.unspiderfying || !this.spiderfied) return; + this.unspiderfying = true; + const unspiderfiedMarkers: L.Marker[] = []; + + this.markers.forEach((marker) => { + const data = (marker as any)._omsData; + if (data) { + this.map.removeLayer(data.leg); + if (marker !== markerNotToMove) { + marker.setLatLng(data.usualPosition); + } + marker.setZIndexOffset(0); + const hl = data.highlightListeners; + if (hl) { + marker.removeEventListener('mouseover', hl.highlight); + marker.removeEventListener('mouseout', hl.unhighlight); + } + delete (marker as any)._omsData; + unspiderfiedMarkers.push(marker); + } + }); + + delete this.unspiderfying; + delete this.spiderfied; + this.trigger('unspiderfy', unspiderfiedMarkers); + } + + private ptDistanceSq(pt1: L.Point, pt2: L.Point): number { + const dx = pt1.x - pt2.x; + const dy = pt1.y - pt2.y; + return dx * dx + dy * dy; + } + + private ptAverage(pts: L.Point[]): L.Point { + const sumPt = pts.reduce( + (acc, pt) => new L.Point(acc.x + pt.x, acc.y + pt.y), + new L.Point(0, 0) + ); + return new L.Point(sumPt.x / pts.length, sumPt.y / pts.length); + } + + private minExtract(set: T[], func: (item: T) => number): T { + let bestIndex = 0; + let bestVal = func(set[0]); + set.forEach((item, i) => { + const val = func(item); + if (val < bestVal) { + bestVal = val; + bestIndex = i; + } + }); + return set.splice(bestIndex, 1)[0]; + } + + private arrIndexOf(arr: T[], obj: T): number { + return arr.indexOf(obj); + } +} + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..88c7939 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "outDir": "./dist" + }, + "include": ["src/**/*"] + } + \ No newline at end of file diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..323da18 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,20 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@types/geojson@*": + version "7946.0.14" + resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.14.tgz#319b63ad6df705ee2a65a73ef042c8271e696613" + integrity sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg== + +"@types/leaflet@^1.9.12": + version "1.9.12" + resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.9.12.tgz#a6626a0b3fba36fd34723d6e95b22e8024781ad6" + integrity sha512-BK7XS+NyRI291HIo0HCfE18Lp8oA30H1gpi1tf0mF3TgiCEzanQjOqNZ4x126SXzzi2oNSZhZ5axJp1k0iM6jg== + dependencies: + "@types/geojson" "*" + +typescript@^5.6.2: + version "5.6.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.2.tgz#d1de67b6bef77c41823f822df8f0b3bcff60a5a0" + integrity sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==