index.js (18451B)
1 import L from 'leaflet'; 2 import './canvasMarkers.css'; 3 import RBush from 'rbush'; 4 import loadImage from 'image-promise'; 5 import {wrapLatLngToTarget} from '~/lib/leaflet.fixes/fixWorldCopyJump'; 6 7 /* 8 Marker definition: 9 { 10 latlng: L.Latlng, 11 icon: {url: string, center: [x, y]} or function(marker) returning icon, 12 label: string or function, 13 tooltip: string or function, 14 any other fields 15 } 16 */ 17 18 function calcIntersectionSum(rect, rects) { 19 let sum = 0, 20 left, right, top, bottom, rect2; 21 22 for (rect2 of rects) { 23 left = Math.max(rect.minX, rect2.minX); 24 right = Math.min(rect.maxX, rect2.maxX); 25 top = Math.max(rect.minY, rect2.minY); 26 bottom = Math.min(rect.maxY, rect2.maxY); 27 if (top < bottom && left < right) { 28 sum += ((right - left) * (bottom - top)); 29 } 30 } 31 return sum; 32 } 33 34 class MarkerRBush extends RBush { 35 toBBox(marker) { 36 const x = marker.latlng.lng; 37 const y = marker.latlng.lat; 38 return { 39 minX: x, 40 minY: y, 41 maxX: x, 42 maxY: y 43 }; 44 } 45 46 compareMinX(a, b) { 47 return a.latlng.lng - b.latlng.lng; 48 } 49 50 compareMinY(a, b) { 51 return a.latlng.lat - b.latlng.lat; 52 } 53 } 54 55 L.Layer.CanvasMarkers = L.GridLayer.extend({ 56 options: { 57 async: true, 58 labelFontName: 'Verdana, Arial, sans-serif', 59 labelFontSize: 10, 60 iconScale: 1, 61 pane: 'rasterMarker', 62 updateWhenZooming: !L.Browser.mobile, 63 iconsOpacity: 1, 64 labelShadowWidth: 1, 65 }, 66 67 initialize: function(markers, options) { 68 L.GridLayer.prototype.initialize.call(this, options); 69 this.rtree = new MarkerRBush(9); 70 this._regions = new RBush(); 71 this._iconPositions = {}; 72 this._labelPositions = {}; 73 this._labelPositionsZoom = null; 74 this.addMarkers(markers); 75 this._images = {}; 76 this._tileQueue = []; 77 this._hoverMarker = null; 78 this.on('markerenter', this.onMarkerEnter, this); 79 this.on('markerleave', this.onMarkerLeave, this); 80 }, 81 82 addMarkers: function(markers) { 83 if (markers) { 84 this.rtree.load(markers); 85 this.resetLabels(); 86 setTimeout(() => this.redraw(), 0); 87 } 88 }, 89 90 addMarker: function(marker) { 91 // FIXME: adding existing marker must be be noop 92 this.rtree.insert(marker); 93 this.resetLabels(); 94 setTimeout(() => this.redraw(), 0); 95 }, 96 97 removeMarker: function(marker) { 98 this.removeMarkers([marker]); 99 }, 100 101 removeMarkers: function(markers) { 102 markers.forEach((marker) => this.rtree.remove(marker)); 103 this.resetLabels(); 104 setTimeout(() => this.redraw(), 0); 105 }, 106 107 updateMarkers: function(markers) { 108 this.removeMarkers(markers); 109 this.addMarkers(markers); 110 }, 111 112 updateMarker: function(marker) { 113 this.updateMarkers([marker]); 114 }, 115 116 setMarkerPosition: function(marker, latlng) { 117 this.removeMarker(marker); 118 marker.latlng = latlng; 119 this.addMarker(marker); 120 }, 121 122 getMarkers: function() { 123 return this.rtree.all(); 124 }, 125 126 findLabelPosition: function(iconCenter, iconSize, textWidth, textHeight, pixelExtents) { 127 const 128 verticalPadding = 0, 129 xPositions = [iconCenter[0] + iconSize[0] / 2 + 2, iconCenter[0] - iconSize[0] / 2 - textWidth - 2], 130 yPositions = [ 131 iconCenter[1] - textHeight / 2 + verticalPadding, 132 iconCenter[1] - textHeight * 0.75 - iconSize[1] / 4 + verticalPadding, 133 iconCenter[1] - textHeight / 4 + iconSize[1] / 4 + verticalPadding, 134 iconCenter[1] - textHeight - iconSize[1] / 2 + verticalPadding, 135 iconCenter[1] + iconSize[1] / 2 + verticalPadding 136 ]; 137 138 let minIntersectionSum = Infinity; 139 let bestX = xPositions[0], 140 bestY = yPositions[0]; 141 for (let x of xPositions) { 142 for (let y of yPositions) { 143 const rect = {minX: x, minY: y, maxX: x + textWidth, maxY: y + textHeight}; 144 if (pixelExtents && (rect.minX < pixelExtents.tileW || rect.maxX > pixelExtents.tileE || 145 rect.minY < pixelExtents.tileN || rect.maxY > pixelExtents.tileS)) { 146 continue; 147 } 148 let intersectionSum = calcIntersectionSum(rect, this._regions.search(rect)); 149 if (intersectionSum < minIntersectionSum) { 150 minIntersectionSum = intersectionSum; 151 bestX = x; 152 bestY = y; 153 if (minIntersectionSum === 0) { 154 break; 155 } 156 } 157 } 158 if (minIntersectionSum === 0) { 159 break; 160 } 161 } 162 return [bestX, bestY]; 163 }, 164 165 preloadIcons: function(urls) { 166 const newUrls = urls.filter((url) => !(url in this._images)); 167 if (newUrls.length) { 168 return loadImage(newUrls).then((images) => { 169 for (let image of images) { 170 this._images[image.src] = image; 171 } 172 } 173 ); 174 } 175 return Promise.resolve(); 176 }, 177 178 createTile: function(coords, done) { 179 const canvas = L.DomUtil.create('canvas', 'leaflet-tile'); 180 canvas.width = this.options.tileSize; 181 canvas.height = this.options.tileSize; 182 setTimeout(() => { 183 this.drawTile(canvas, coords).then(() => done(null, canvas)); 184 }, 0 185 ); 186 return canvas; 187 }, 188 189 selectMarkersForDraw: function({tileN, tileS, tileE, tileW}, zoom, withoutPadding) { 190 // FIXME: padding should depend on options.iconScale and fontSize 191 if (!this._map) { 192 return {}; 193 } 194 const 195 iconsHorPad = withoutPadding ? 0 : 520, 196 iconsVertPad = withoutPadding ? 0 : 50, 197 labelsHorPad = withoutPadding ? 0 : 256, 198 labelsVertPad = withoutPadding ? 0 : 20; 199 const 200 iconsBounds = L.latLngBounds( 201 this._map.unproject(L.point(tileW - iconsHorPad, tileS + iconsHorPad), zoom), 202 this._map.unproject(L.point(tileE + iconsHorPad, tileN - iconsVertPad), zoom) 203 ), 204 labelsBounds = L.latLngBounds( 205 this._map.unproject(L.point(tileW - labelsHorPad, tileS + labelsHorPad), zoom), 206 this._map.unproject(L.point(tileE + labelsHorPad, tileN - labelsVertPad), zoom) 207 ); 208 const 209 iconUrls = [], 210 markerJobs = {}; 211 212 // used only to preload icons 213 const pointsForMarkers = []; 214 for (let shift of [-360, 0, 360]) { 215 pointsForMarkers.push(...this.rtree.search({ 216 minX: iconsBounds.getWest() + shift, 217 minY: iconsBounds.getSouth(), 218 maxX: iconsBounds.getEast() + shift, 219 maxY: iconsBounds.getNorth() 220 })); 221 } 222 223 // used to place labels 224 const pointsForLabels = []; 225 for (let shift of [-360, 0, 360]) { 226 pointsForLabels.push(...this.rtree.search({ 227 minX: labelsBounds.getWest() + shift, 228 minY: labelsBounds.getSouth(), 229 maxX: labelsBounds.getEast() + shift, 230 maxY: labelsBounds.getNorth() 231 })); 232 } 233 const tileMidLongitude = this._map.unproject([(tileE + tileW) / 2, tileN], zoom); 234 235 for (let marker of pointsForMarkers) { 236 let latlng = marker.latlng; 237 if (this.options.noWrap) { 238 latlng = wrapLatLngToTarget(latlng, tileMidLongitude); 239 } 240 const p = this._map.project(latlng, zoom); 241 let icon = marker.icon; 242 if (typeof icon === 'function') { 243 icon = icon(marker); 244 } 245 iconUrls.push(icon.url); 246 let markerId = L.stamp(marker); 247 markerJobs[markerId] = {marker: marker, icon: icon, projectedXY: p}; 248 } 249 return {iconUrls, markerJobs, pointsForLabels}; 250 }, 251 252 drawSelectedMarkers: function(canvas, pixelExtents, markerJobs, pointsForLabels, zoom, limitLabelsToImage) { 253 const {tileN, tileW, tileS, tileE} = pixelExtents; 254 const textHeight = this.options.labelFontSize * this.options.iconScale; 255 if (this._labelPositionsZoom !== zoom) { 256 this._labelPositionsZoom = zoom; 257 this.resetLabels(); 258 } 259 const ctx = canvas.getContext('2d'); 260 ctx.font = L.Util.template('bold {size}px {name}', 261 {name: this.options.labelFontName, size: this.options.labelFontSize * this.options.iconScale} 262 ); 263 for (let [markerId, job] of Object.entries(markerJobs)) { 264 let img = this._images[job.icon.url]; 265 job.img = img; 266 const imgW = Math.round(img.width * this.options.iconScale); 267 const imgH = Math.round(img.height * this.options.iconScale); 268 if (!(markerId in this._iconPositions)) { 269 let x = job.projectedXY.x - job.icon.center[0] * this.options.iconScale; 270 let y = job.projectedXY.y - job.icon.center[1] * this.options.iconScale; 271 x = Math.round(x); 272 y = Math.round(y); 273 this._iconPositions[markerId] = [x, y]; 274 this._regions.insert({ 275 minX: x, 276 minY: y, 277 maxX: x + imgW, 278 maxY: y + imgH, 279 marker: job.marker, 280 isLabel: false 281 }); 282 } 283 let [x, y] = this._iconPositions[markerId]; 284 job.iconCenter = [x + imgW / 2, y + imgH / 2]; 285 job.iconSize = [imgW, imgH]; 286 } 287 for (let marker of pointsForLabels) { 288 const markerId = L.stamp(marker); 289 const job = markerJobs[markerId]; 290 let label = job.marker.label; 291 if (typeof label === 'function') { 292 label = label(job.marker, zoom); 293 } 294 if (label) { 295 job.label = label; 296 if (!(markerId in this._labelPositions)) { 297 const textWidth = ctx.measureText(label).width; 298 const p = this.findLabelPosition(job.iconCenter, job.iconSize, textWidth, textHeight, 299 limitLabelsToImage ? pixelExtents : null); 300 this._labelPositions[markerId] = p; 301 let [x, y] = p; 302 this._regions.insert({ 303 minX: x, 304 minY: y, 305 maxX: x + textWidth, 306 maxY: y + textHeight, 307 marker: job.marker, 308 isLabel: true 309 }); 310 } 311 } else { 312 this._labelPositions[markerId] = null; 313 } 314 } 315 316 const regionsInTile = this._regions.search({minX: tileW, minY: tileN, maxX: tileE, maxY: tileS}); 317 // draw labels 318 for (let region of regionsInTile) { 319 if (region.isLabel) { 320 // TODO: set font name ant size in options 321 const markerId = L.stamp(region.marker); 322 const job = markerJobs[markerId]; 323 const p = this._labelPositions[markerId]; 324 const x = p[0] - tileW; 325 const y = p[1] - tileN; 326 ctx.textBaseline = 'bottom'; 327 ctx.shadowColor = '#fff'; 328 ctx.strokeStyle = '#fff'; 329 ctx.fillStyle = '#000'; 330 ctx.lineWidth = 1.2 * this.options.iconScale; 331 ctx.shadowBlur = this.options.labelShadowWidth * this.options.iconScale; 332 ctx.strokeText(job.label, x, y + textHeight); 333 ctx.shadowBlur = 0; 334 ctx.fillText(job.label, x, y + textHeight); 335 } 336 } 337 ctx.globalAlpha = this.options.iconsOpacity; 338 // draw icons 339 for (let region of regionsInTile) { 340 if (!region.isLabel) { 341 const markerId = L.stamp(region.marker); 342 const job = markerJobs[markerId]; 343 const p = this._iconPositions[markerId]; 344 const x = p[0] - tileW; 345 const y = p[1] - tileN; 346 ctx.drawImage(job.img, x, y, job.iconSize[0], job.iconSize[1]); 347 } 348 } 349 ctx.globalAlpha = 1; 350 }, 351 352 drawTile: async function(canvas, coords) { 353 const 354 zoom = coords.z, 355 tileSize = this.options.tileSize, 356 tileN = coords.y * tileSize, 357 tileW = coords.x * tileSize, 358 tileS = tileN + tileSize, 359 tileE = tileW + tileSize; 360 const pixelExtents = {tileN, tileS, tileE, tileW}; 361 const {iconUrls, markerJobs, pointsForLabels} = this.selectMarkersForDraw(pixelExtents, zoom); 362 if (!markerJobs) { 363 return; 364 } 365 await this.preloadIcons(iconUrls); 366 this.drawSelectedMarkers(canvas, pixelExtents, markerJobs, pointsForLabels, zoom); 367 }, 368 369 resetLabels: function() { 370 this._iconPositions = {}; 371 this._labelPositions = {}; 372 this._regions.clear(); 373 }, 374 375 findMarkerFromMouseEvent: function(e) { 376 const 377 p = this._map.project(e.latlng.wrap()), 378 region = this._regions.search({minX: p.x, minY: p.y, maxX: p.x, maxY: p.y})[0]; 379 let marker; 380 if (region) { 381 marker = region.marker; 382 } else { 383 marker = null; 384 } 385 return marker; 386 }, 387 388 onMouseMove: function(e) { 389 const marker = this.findMarkerFromMouseEvent(e); 390 if (this._hoverMarker !== marker) { 391 if (this._hoverMarker) { 392 this.fire('markerleave', {marker: this._hoverMarker}); 393 } 394 if (marker) { 395 this.fire('markerenter', {marker: marker}); 396 } 397 this._hoverMarker = marker; 398 } 399 }, 400 401 showTooltip: function(e) { 402 if (!e.marker.tooltip) { 403 return; 404 } 405 let text = e.marker.tooltip; 406 if (typeof text === 'function') { 407 text = text(e.marker); 408 if (!e.marker.tooltip) { 409 return; 410 } 411 } 412 this.toolTip.innerHTML = text; 413 const p = this._map.latLngToLayerPoint(e.marker.latlng); 414 L.DomUtil.setPosition(this.toolTip, p); 415 L.DomUtil.addClass(this.toolTip, 'canvas-marker-tooltip-on'); 416 }, 417 418 onMarkerEnter: function(e) { 419 this._map._container.style.cursor = 'pointer'; 420 this.showTooltip(e); 421 }, 422 423 onMarkerLeave: function() { 424 this._map._container.style.cursor = ''; 425 L.DomUtil.removeClass(this.toolTip, 'canvas-marker-tooltip-on'); 426 }, 427 428 onMouseOut: function() { 429 if (this._hoverMarker) { 430 const marker = this._hoverMarker; 431 this._hoverMarker = null; 432 this.fire('markerleave', {marker}); 433 } 434 }, 435 436 onClick: function(e) { 437 if (this._map.clickLocked) { 438 return; 439 } 440 const marker = this.findMarkerFromMouseEvent(e); 441 if (marker) { 442 L.extend(e, {marker: marker}); 443 this.fire('markerclick', e); 444 } 445 }, 446 447 onRightClick: function(e) { 448 const marker = this.findMarkerFromMouseEvent(e); 449 if (marker) { 450 L.extend(e, {marker: marker}); 451 this.fire('markercontextmenu', e); 452 } 453 }, 454 455 onAdd: function(map) { 456 if (this.options.pane === 'rasterMarker' && !map.getPane('rasterMarker')) { 457 map.createPane('rasterMarker').style.zIndex = 550; 458 } 459 L.GridLayer.prototype.onAdd.call(this, map); 460 map.on('mousemove', this.onMouseMove, this); 461 map.on('mouseout', this.onMouseOut, this); 462 map.on('click', this.onClick, this); 463 map.on('contextmenu', this.onRightClick, this); 464 this.toolTip = L.DomUtil.create('div', 'canvas-marker-tooltip', this._map.getPanes().markerPane); 465 }, 466 467 onRemove: function(map) { 468 this._map.off('mousemove', this.onMouseMove, this); 469 this._map.off('mouseout', this.onMouseOut, this); 470 this._map.off('click', this.onClick, this); 471 this._map.off('contextmenu', this.onRightClick, this); 472 if (this._hoverMarker) { 473 this._hoverMarker = null; 474 this.fire('markerleave', {marker: this._hoverMarker}); 475 } 476 this._map.getPanes().markerPane.removeChild(this.toolTip); 477 L.GridLayer.prototype.onRemove.call(this, map); 478 } 479 } 480 );