nakarte

Source code of https://map.sikmir.ru (fork)
git clone git://git.sikmir.ru/nakarte
Log | Files | Refs | LICENSE

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 );