nakarte

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

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