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