├── .github ├── FUNDING.yml ├── check_examples.py └── workflows │ └── node.js.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── SPRITES.md ├── benchmark ├── example_1.png ├── example_2.png ├── index.html └── pixelmatch.js ├── biome.json ├── examples ├── comparison.html ├── fonts.html ├── inset.html ├── labels.html ├── leaflet.html ├── multi_source.html ├── pmtiles.html ├── pmtiles_headers.html ├── sandwich.html ├── sprites.html └── static.html ├── package-lock.json ├── package.json ├── src ├── attribute.ts ├── default_style │ └── style.ts ├── frontends │ ├── leaflet.ts │ └── static.ts ├── index.ts ├── labeler.ts ├── line.ts ├── painter.ts ├── symbolizer.ts ├── task.ts ├── text.ts ├── tilecache.ts ├── types │ └── unitbezier.d.ts └── view.ts ├── test ├── attribute.test.ts ├── labeler.test.ts ├── line.test.ts ├── symbolizer.test.ts ├── test_helpers.ts ├── text.test.ts ├── tilecache.test.ts └── view.test.ts ├── tsconfig.json └── tsup.config.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: protomaps 2 | -------------------------------------------------------------------------------- /.github/check_examples.py: -------------------------------------------------------------------------------- 1 | import json 2 | import glob 3 | import re 4 | 5 | fail = 0 6 | 7 | for package in glob.glob("**/package.json",recursive=True): 8 | if "node_modules" in package: 9 | continue 10 | 11 | with open(package,"r") as f: 12 | j = json.loads(f.read()) 13 | name = j["name"] 14 | version = j["version"] 15 | 16 | for html in glob.glob("**/*.html",recursive=True): 17 | if "node_modules" in html: 18 | continue 19 | 20 | with open(html,"r") as f: 21 | matches = re.findall("https://unpkg.com/" + name + r"@(\d+\.\d+\.\d+|latest)/",f.read()) 22 | for match in matches: 23 | if matches[0] == "latest" or matches[0] != version: 24 | print(html,"should be",version,"was",matches) 25 | fail = 1 26 | 27 | exit(fail) 28 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: Test suite 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v2 16 | with: 17 | node-version: 18.x 18 | - run: python .github/check_examples.py 19 | - run: npm ci 20 | - run: npm run build 21 | - run: npm test 22 | - run: npm run check -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | src/**/*.js 4 | test/*.js 5 | .tool-versions 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 5.0.1 2 | * Update pmtiles dependency by @ycouble [#194] 3 | 4 | # 5.0.0 5 | This is a breaking major version change. 6 | 7 | * Now only works with the protomaps basemap tileset v4.0 and above. [#177] 8 | * visualize the `landcover` layer in the light and dark flavors. 9 | * replace `theme` option with `flavor` in frontends. 10 | * Add a `lang` option to frontends to change the label language. [#157] 11 | * depend on the `@protomaps/basemaps` package for flavor definitions. 12 | 13 | # 4.1.0 14 | * clarify types of `LeafletLayerOptions` and `leafletLayer` via @jwasilgeo [#179] 15 | 16 | # 4.0.1 17 | * Fix left-shift numerical problem in static frontend projection via @saranrapjs [#171] 18 | 19 | # 4.0.0 20 | * switch to tsup for generating ESM, CJS and IIFE modules [#158, #161, #162]. 21 | * should fix import issues related to typescript. 22 | * IIFE script name changed from `protomaps-leaflet.min.js` to `protomaps-leaflet.js` 23 | * generate ESM and CJS builds unbundled. iife is still unbundled. 24 | * remove use of default exports: theme.ts exports `theme` 25 | * bump internal dependencies. 26 | 27 | # 3.1.2 28 | * Fix pmtiles URL detection on relative paths [#152] 29 | 30 | # 3.1.1 31 | * Detect pmtiles URLs by using URL parsing, which handles query parameters [#147] 32 | 33 | # 3.1.0 34 | * add `queryTileFeaturesDebug` back in for basic interactions (You should use MapLibre if you want interactivity) 35 | 36 | # 3.0.0 37 | * Unexport `PMTiles` because you shouldn't be depending on the one bundled in this library. 38 | * Bump `pmtiles` dependency to v3.x 39 | * package.json defaults to ES6. 40 | * remove CubicBezier function as no longer used. 41 | 42 | # 2.0.0 43 | 44 | Major version 2.0 aggressively reduces the scope of this rendering library. 45 | 46 | This library re-focuses on being a Leaflet plugin for vector tile basemaps, i.e. "Mapnik in the browser" 47 | 48 | * **All user interaction features are removed.** Every mapping application with clickable features should use MapLibre GL JS. 49 | 50 | * **MapLibre JSON style compatibility is removed.** The surface area of the JSON style is too large to maintain for real-world use cases, and styles written for MapLibre perform poorly with this library. 51 | 52 | * **Programmatic shading and extra basemap styles are removed.** This library's default style aligns with the 5 MapLibre styles developed in protomaps/basemaps. 53 | 54 | * `levelDiff` is no longer configurable and defaults to 1 to match MapLibre GL. 55 | 56 | * consistent camelCase naming e.g. `paint_rules` -> `paintRules` 57 | 58 | * You must pass one of the default basemap themes `light, dark, white, black, grayscale`. remove `dark` and `shade` options. 59 | 60 | * Remove WebKit vertex count workaround. 61 | 62 | * remove `PolygonLabelSymbolizer` as it is not accurate for tiled rendering. 63 | 64 | * `maxDataZoom` defaults to 15. 65 | 66 | * Use Protomaps basemap tileset v3 instead of v2. 67 | 68 | * remove `setDefaultStyle` method. 69 | 70 | * remove multi-language `language1`, `language2`: frontends take a single `language` parameter. 71 | 72 | 73 | # 1.24.2 74 | * Continue internal refactors in preparation of 2.0.0 major revision. 75 | 76 | # 1.24.1 77 | * Apply linting rules; fix scoping and equality problems caught by linter. 78 | 79 | # 1.24.0 80 | * library renamed from `protomaps` to `protomaps-leaflet`, including npm package. 81 | 82 | # 1.23 83 | * bump `pmtiles` to 2.6.1. 84 | 85 | # 1.22 86 | * fix type of multiple `Source`s passed into leaflet layer or static map. 87 | 88 | # 1.21 89 | * support pmtiles v3. 90 | 91 | # 1.20.2 92 | * missing tiles in PMTiles archive do not show a browser error 93 | 94 | # 1.20.1 95 | * Fix stroking of circle symbolizer 96 | * Fix static map `drawCanvasBounds` and `drawContextBounds` coordinate order 97 | 98 | # 1.20.0 99 | * Fix labeling-across-tiles bug introduced in 1.19.0 100 | * Support for vector PMTiles with compression 101 | 102 | # 1.19.0 103 | * Multiple vector tile sources in a single layer 104 | 105 | # 1.18.2 106 | * Fix missing last stroke of `PolygonSymbolizer` 107 | * make css-font-loading-module a dependency for types to be findable 108 | 109 | # 1.18.0 110 | * Sprites module formerly under `protosprites` package merged into this project 111 | 112 | # 1.16.0 113 | * Set canvas size to 0,0 before garbage collection, workaround for Safari 114 | 115 | # 1.15.0 116 | * Type improvements for Symbolizers, thanks nf-s 117 | * add additional TextTransforms 118 | * LineLabelSymbolizer features, step interpolation features, label deduplication 119 | 120 | # 1.14.0 121 | * Label repetition for lines 122 | * Limit label repetition when overzooming 123 | 124 | # 1.13.0 125 | * `PolygonSymbolizer` has `stroke` and `width` for efficient outlines. 126 | * `maxLineChars` line-breaking can be a function. 127 | 128 | # 1.12.0 129 | * `Static` takes same basic options as leaflet frontend. 130 | 131 | # 1.10.0 132 | * `backgroundColor` option for leaflet or static map. 133 | 134 | # 1.9.0 135 | * Center text justification only in the case of `CenterdSymbolizer` 136 | * `TextSymbolizer` `label_props` can be a function 137 | * `LineSymbolizer` `lineJoin` and `lineCap` attributes 138 | 139 | # 1.8.0 140 | * add `Padding` generic label symbolizer 141 | 142 | # 1.7.0 143 | * `TextSymbolizer` attributes: `lineHeight` in `em`, `letterSpacing` in `px` 144 | * add `linear` and `step` shortcut functions for zoom-based styling 145 | 146 | # 1.6.0 147 | * add `removeInspector` 148 | 149 | # 1.5.0 150 | * `levelDiff` option to set ratio of display tiles to data tiles. 151 | 152 | # 1.4.0 153 | * Feature picking more accurate; uses distance-to-line and point-in-polygon. 154 | * `xray` option to show all layers. 155 | 156 | # 1.3.0 157 | * `addInspector` to click on features and show an information popup. 158 | 159 | # 1.2.0 160 | * Label symbolizers for point and polygon features take the same set of attributes for text display. 161 | * Add maxLineChars to define line breaking by maximum [code units](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length) 162 | 163 | # 1.1.0 164 | PickedFeature in queryFeatures returns object with LayerName and feature. 165 | 166 | # 1.0.0 167 | 168 | * Most color and numerical Symbolizer attributes can now be treated as evaluated properties, with parameters (zoom:number,feature:Feature) 169 | * `Rule` filters parameters changed from (properties:any) to (zoom:number,feature:Feature) to enable filtering on zoom level and geom_type. 170 | * `Feature.properties` renamed to `Feature.props` for brevity 171 | * Internal `PaintSymbolizer.draw` signature now takes zoom as third parameter. 172 | * `properties` for defining fallbacks for text in label Symbolizers renamed to `label_props` e.g. ["name:en","name"] 173 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021-2024 Protomaps LLC 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # protomaps-leaflet 2 | 3 | Vector tile rendering and labeling for [Leaflet](https://github.com/Leaflet/Leaflet). 4 | 5 | [![npm](https://img.shields.io/npm/v/protomaps-leaflet)](https://www.npmjs.com/package/protomaps-leaflet) 6 | [![Test suite](https://github.com/protomaps/protomaps-leaflet/actions/workflows/node.js.yml/badge.svg)](https://github.com/protomaps/protomaps-leaflet/actions/workflows/node.js.yml) 7 | 8 |

9 | 10 | 11 |

12 | 13 | This project is a complete vector tile renderer - including quality label layout - in as simple as possible of an implementation. It's an alternative to renderers like [MapLibre GL JS](https://maplibre.org) in a fraction of the size. 14 | 15 | New projects starting from scratch should probably use MapLibre GL, but this library is useful as a drop-in replacement for raster basemaps in Leaflet, either using the [Protomaps API](https://protomaps.com/dashboard) or PMTiles on your own storage. 16 | 17 | ### Features 18 | 19 | * Render interactive slippy maps with [Leaflet](https://leafletjs.com) integration 20 | * Supports variable web fonts with multiple weights and italics in a single font file 21 | * Can read normal Z/X/Y tile URLs or offline, static-hosted tile archives in [PMTiles format](https://github.com/protomaps/PMTiles) 22 | * Full out-of-the-box support for right-to-left and Indic/Brahmic writing systems 23 | * Configurable via plain JavaScript 24 | * (Advanced) Extensible API for defining your own symbolizers 25 | 26 | See the docs on [what protomaps-leaflet is, what protomaps-leaflet is not](https://protomaps.com/docs/protomaps-js#protomapsjs-is-not) 27 | 28 | ## Demos 29 | 30 | * [Simple Leaflet demo](https://protomaps.github.io/protomaps-leaflet/examples/leaflet.html) 31 | * [Satellite + labels demo](https://protomaps.github.io/protomaps-leaflet/examples/labels.html) 32 | * [GeoJSON between basemap and labels demo](https://protomaps.github.io/protomaps-leaflet/examples/sandwich.html) 33 | * [Map inset](https://protomaps.github.io/protomaps-leaflet/examples/inset.html) 34 | * [Custom fonts](https://protomaps.github.io/protomaps-leaflet/examples/fonts.html) 35 | 36 | ## How to use 37 | 38 | ```html 39 | 40 | 45 | ``` 46 | 47 | ## See Also 48 | * [Tangram](https://github.com/tangrams/tangram) 49 | * [KothicJS](https://github.com/kothic/kothic-js) 50 | * [Leaflet.VectorGrid](https://github.com/Leaflet/Leaflet.VectorGrid) 51 | -------------------------------------------------------------------------------- /SPRITES.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | protomaps-leaflet includes a sprite-like system for sheets of icons. A sheet is just a single-file HTML document containing SVGs; sheets are loaded and baked into bitmaps on a canvas, where they can be sampled efficiently via Canvas 2D `drawImage`. This is useful for resolution-independent map symbology inside web browsers. 4 | 5 | ## Format 6 | 7 | A sheet is a collection of SVGs organized in a specific way: 8 | 9 | - It must be an HTML document with all SVGs as children of the `body` element 10 | - Each SVG element must have a width and a height in px, interpreted as CSS (not device) pixels 11 | - Each SVG must have a unique ID attribute 12 | 13 | Example of a valid sheet: 14 | 15 | ```html 16 | 17 | 18 | 25 | 26 | 27 | 28 | 29 | ``` 30 | 31 | SVGs can also be defined via inline strings, avoiding a fetch request. 32 | 33 | ```js 34 | const ICONS = ` 35 | 36 | 37 | 38 | 39 | 40 | `; 41 | let sheet = new Sheet(ICONS); 42 | ``` 43 | 44 | ## Library usage 45 | 46 | a Sheet instance asynchronously loads the sheet and renders it to an off-screen Canvas context at device-native resolution. The canvas can then be sampled by icon name via the `get` method: 47 | 48 | ```js 49 | let sheet = new Sheet("sheet.html"); 50 | let canvas = document.getElementById("canvas"); 51 | let ctx = canvas.getContext("2d"); 52 | sheet.load().then(() => { 53 | let s = sheet.get("foobar"); 54 | ctx.drawImage(s.canvas, s.x, s.y, s.w, s.h, 0, 0, s.w, s.h); 55 | }); 56 | ``` 57 | 58 | ## Limitations 59 | 60 | SVGs in a spritesheet should avoid advanced SVG features like drop shadows, because these are unlikely to be rendered correctly and consistently across web browsers. 61 | -------------------------------------------------------------------------------- /benchmark/example_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/protomaps/protomaps-leaflet/8202f02b5d2f10aa089807334fe60761cd0b7bd4/benchmark/example_1.png -------------------------------------------------------------------------------- /benchmark/example_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/protomaps/protomaps-leaflet/8202f02b5d2f10aa089807334fe60761cd0b7bd4/benchmark/example_2.png -------------------------------------------------------------------------------- /benchmark/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 30 | 31 | 32 |
33 |
34 | 35 | 36 | 37 |
38 | 39 |
40 |
41 |
42 | 43 | 44 | 45 |
46 | 47 |
48 | 130 | 131 | -------------------------------------------------------------------------------- /benchmark/pixelmatch.js: -------------------------------------------------------------------------------- 1 | // ISC License 2 | 3 | // Copyright (c) 2019, Mapbox 4 | 5 | // Permission to use, copy, modify, and/or distribute this software for any purpose 6 | // with or without fee is hereby granted, provided that the above copyright notice 7 | // and this permission notice appear in all copies. 8 | 9 | // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | // REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 11 | // FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | // INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 13 | // OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 14 | // TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 15 | // THIS SOFTWARE. 16 | 'use strict'; 17 | 18 | const defaultOptions = { 19 | threshold: 0.1, // matching threshold (0 to 1); smaller is more sensitive 20 | includeAA: false, // whether to skip anti-aliasing detection 21 | alpha: 0.1, // opacity of original image in diff output 22 | aaColor: [255, 255, 0], // color of anti-aliased pixels in diff output 23 | diffColor: [255, 0, 0], // color of different pixels in diff output 24 | diffColorAlt: null, // whether to detect dark on light differences between img1 and img2 and set an alternative color to differentiate between the two 25 | diffMask: false // draw the diff over a transparent background (a mask) 26 | }; 27 | 28 | function pixelmatch(img1, img2, output, width, height, options) { 29 | 30 | if (!isPixelData(img1) || !isPixelData(img2) || (output && !isPixelData(output))) 31 | throw new Error('Image data: Uint8Array, Uint8ClampedArray or Buffer expected.'); 32 | 33 | if (img1.length !== img2.length || (output && output.length !== img1.length)) 34 | throw new Error('Image sizes do not match.'); 35 | 36 | if (img1.length !== width * height * 4) throw new Error('Image data size does not match width/height.'); 37 | 38 | options = Object.assign({}, defaultOptions, options); 39 | 40 | // check if images are identical 41 | const len = width * height; 42 | const a32 = new Uint32Array(img1.buffer, img1.byteOffset, len); 43 | const b32 = new Uint32Array(img2.buffer, img2.byteOffset, len); 44 | let identical = true; 45 | 46 | for (let i = 0; i < len; i++) { 47 | if (a32[i] !== b32[i]) { identical = false; break; } 48 | } 49 | if (identical) { // fast path if identical 50 | if (output && !options.diffMask) { 51 | for (let i = 0; i < len; i++) drawGrayPixel(img1, 4 * i, options.alpha, output); 52 | } 53 | return 0; 54 | } 55 | 56 | // maximum acceptable square distance between two colors; 57 | // 35215 is the maximum possible value for the YIQ difference metric 58 | const maxDelta = 35215 * options.threshold * options.threshold; 59 | let diff = 0; 60 | 61 | // compare each pixel of one image against the other one 62 | for (let y = 0; y < height; y++) { 63 | for (let x = 0; x < width; x++) { 64 | 65 | const pos = (y * width + x) * 4; 66 | 67 | // squared YUV distance between colors at this pixel position, negative if the img2 pixel is darker 68 | const delta = colorDelta(img1, img2, pos, pos); 69 | 70 | // the color difference is above the threshold 71 | if (Math.abs(delta) > maxDelta) { 72 | // check it's a real rendering difference or just anti-aliasing 73 | if (!options.includeAA && (antialiased(img1, x, y, width, height, img2) || 74 | antialiased(img2, x, y, width, height, img1))) { 75 | // one of the pixels is anti-aliasing; draw as yellow and do not count as difference 76 | // note that we do not include such pixels in a mask 77 | if (output && !options.diffMask) drawPixel(output, pos, ...options.aaColor); 78 | 79 | } else { 80 | // found substantial difference not caused by anti-aliasing; draw it as such 81 | if (output) { 82 | drawPixel(output, pos, ...(delta < 0 && options.diffColorAlt || options.diffColor)); 83 | } 84 | diff++; 85 | } 86 | 87 | } else if (output) { 88 | // pixels are similar; draw background as grayscale image blended with white 89 | if (!options.diffMask) drawGrayPixel(img1, pos, options.alpha, output); 90 | } 91 | } 92 | } 93 | 94 | // return the number of different pixels 95 | return diff; 96 | } 97 | 98 | function isPixelData(arr) { 99 | // work around instanceof Uint8Array not working properly in some Jest environments 100 | return ArrayBuffer.isView(arr) && arr.constructor.BYTES_PER_ELEMENT === 1; 101 | } 102 | 103 | // check if a pixel is likely a part of anti-aliasing; 104 | // based on "Anti-aliased Pixel and Intensity Slope Detector" paper by V. Vysniauskas, 2009 105 | 106 | function antialiased(img, x1, y1, width, height, img2) { 107 | const x0 = Math.max(x1 - 1, 0); 108 | const y0 = Math.max(y1 - 1, 0); 109 | const x2 = Math.min(x1 + 1, width - 1); 110 | const y2 = Math.min(y1 + 1, height - 1); 111 | const pos = (y1 * width + x1) * 4; 112 | let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0; 113 | let min = 0; 114 | let max = 0; 115 | let minX, minY, maxX, maxY; 116 | 117 | // go through 8 adjacent pixels 118 | for (let x = x0; x <= x2; x++) { 119 | for (let y = y0; y <= y2; y++) { 120 | if (x === x1 && y === y1) continue; 121 | 122 | // brightness delta between the center pixel and adjacent one 123 | const delta = colorDelta(img, img, pos, (y * width + x) * 4, true); 124 | 125 | // count the number of equal, darker and brighter adjacent pixels 126 | if (delta === 0) { 127 | zeroes++; 128 | // if found more than 2 equal siblings, it's definitely not anti-aliasing 129 | if (zeroes > 2) return false; 130 | 131 | // remember the darkest pixel 132 | } else if (delta < min) { 133 | min = delta; 134 | minX = x; 135 | minY = y; 136 | 137 | // remember the brightest pixel 138 | } else if (delta > max) { 139 | max = delta; 140 | maxX = x; 141 | maxY = y; 142 | } 143 | } 144 | } 145 | 146 | // if there are no both darker and brighter pixels among siblings, it's not anti-aliasing 147 | if (min === 0 || max === 0) return false; 148 | 149 | // if either the darkest or the brightest pixel has 3+ equal siblings in both images 150 | // (definitely not anti-aliased), this pixel is anti-aliased 151 | return (hasManySiblings(img, minX, minY, width, height) && hasManySiblings(img2, minX, minY, width, height)) || 152 | (hasManySiblings(img, maxX, maxY, width, height) && hasManySiblings(img2, maxX, maxY, width, height)); 153 | } 154 | 155 | // check if a pixel has 3+ adjacent pixels of the same color. 156 | function hasManySiblings(img, x1, y1, width, height) { 157 | const x0 = Math.max(x1 - 1, 0); 158 | const y0 = Math.max(y1 - 1, 0); 159 | const x2 = Math.min(x1 + 1, width - 1); 160 | const y2 = Math.min(y1 + 1, height - 1); 161 | const pos = (y1 * width + x1) * 4; 162 | let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0; 163 | 164 | // go through 8 adjacent pixels 165 | for (let x = x0; x <= x2; x++) { 166 | for (let y = y0; y <= y2; y++) { 167 | if (x === x1 && y === y1) continue; 168 | 169 | const pos2 = (y * width + x) * 4; 170 | if (img[pos] === img[pos2] && 171 | img[pos + 1] === img[pos2 + 1] && 172 | img[pos + 2] === img[pos2 + 2] && 173 | img[pos + 3] === img[pos2 + 3]) zeroes++; 174 | 175 | if (zeroes > 2) return true; 176 | } 177 | } 178 | 179 | return false; 180 | } 181 | 182 | // calculate color difference according to the paper "Measuring perceived color difference 183 | // using YIQ NTSC transmission color space in mobile applications" by Y. Kotsarenko and F. Ramos 184 | 185 | function colorDelta(img1, img2, k, m, yOnly) { 186 | let r1 = img1[k + 0]; 187 | let g1 = img1[k + 1]; 188 | let b1 = img1[k + 2]; 189 | let a1 = img1[k + 3]; 190 | 191 | let r2 = img2[m + 0]; 192 | let g2 = img2[m + 1]; 193 | let b2 = img2[m + 2]; 194 | let a2 = img2[m + 3]; 195 | 196 | if (a1 === a2 && r1 === r2 && g1 === g2 && b1 === b2) return 0; 197 | 198 | if (a1 < 255) { 199 | a1 /= 255; 200 | r1 = blend(r1, a1); 201 | g1 = blend(g1, a1); 202 | b1 = blend(b1, a1); 203 | } 204 | 205 | if (a2 < 255) { 206 | a2 /= 255; 207 | r2 = blend(r2, a2); 208 | g2 = blend(g2, a2); 209 | b2 = blend(b2, a2); 210 | } 211 | 212 | const y1 = rgb2y(r1, g1, b1); 213 | const y2 = rgb2y(r2, g2, b2); 214 | const y = y1 - y2; 215 | 216 | if (yOnly) return y; // brightness difference only 217 | 218 | const i = rgb2i(r1, g1, b1) - rgb2i(r2, g2, b2); 219 | const q = rgb2q(r1, g1, b1) - rgb2q(r2, g2, b2); 220 | 221 | const delta = 0.5053 * y * y + 0.299 * i * i + 0.1957 * q * q; 222 | 223 | // encode whether the pixel lightens or darkens in the sign 224 | return y1 > y2 ? -delta : delta; 225 | } 226 | 227 | function rgb2y(r, g, b) { return r * 0.29889531 + g * 0.58662247 + b * 0.11448223; } 228 | function rgb2i(r, g, b) { return r * 0.59597799 - g * 0.27417610 - b * 0.32180189; } 229 | function rgb2q(r, g, b) { return r * 0.21147017 - g * 0.52261711 + b * 0.31114694; } 230 | 231 | // blend semi-transparent color with white 232 | function blend(c, a) { 233 | return 255 + (c - 255) * a; 234 | } 235 | 236 | function drawPixel(output, pos, r, g, b) { 237 | output[pos + 0] = r; 238 | output[pos + 1] = g; 239 | output[pos + 2] = b; 240 | output[pos + 3] = 255; 241 | } 242 | 243 | function drawGrayPixel(img, i, alpha, output) { 244 | const r = img[i + 0]; 245 | const g = img[i + 1]; 246 | const b = img[i + 2]; 247 | const val = blend(rgb2y(r, g, b), alpha * img[i + 3] / 255); 248 | drawPixel(output, i, val, val, val); 249 | } -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatter": { 3 | "indentStyle": "space" 4 | }, 5 | "linter": { 6 | "rules": { 7 | "style": { 8 | "useNamingConvention": {} 9 | }, 10 | "nursery": { 11 | "noUnusedImports": {} 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/comparison.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 23 | 24 | 25 |
26 |
27 |
28 |
29 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /examples/fonts.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 44 | 45 | 46 |
47 |
48 |
49 | 56 | protomaps-leaflet web fonts demo / 57 | Browse more variable fonts at Google Fonts 58 |
59 |
60 | 108 | 109 | -------------------------------------------------------------------------------- /examples/inset.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 15 | 16 | 17 | 18 | 45 | 46 | -------------------------------------------------------------------------------- /examples/labels.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 16 | 17 | 18 |
19 | 47 | 48 | -------------------------------------------------------------------------------- /examples/leaflet.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 16 | 17 | 18 |
19 | 33 | 34 | -------------------------------------------------------------------------------- /examples/multi_source.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 16 | 17 | 18 | 19 | 20 | 56 | 57 | -------------------------------------------------------------------------------- /examples/pmtiles.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 15 | 16 | 17 |
18 | 24 | 25 | -------------------------------------------------------------------------------- /examples/pmtiles_headers.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 16 | 17 | 18 |
19 | 29 | 30 | -------------------------------------------------------------------------------- /examples/sandwich.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 16 | 17 | 18 |
19 | 77 | 78 | -------------------------------------------------------------------------------- /examples/sprites.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 16 | 17 | 18 |
19 | 55 | 56 | -------------------------------------------------------------------------------- /examples/static.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 46 | 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "protomaps-leaflet", 3 | "version": "5.0.1", 4 | "files": [ 5 | "dist", 6 | "src" 7 | ], 8 | "type": "module", 9 | "main": "dist/cjs/index.cjs", 10 | "module": "dist/esm/index.js", 11 | "types": "dist/esm/index.d.ts", 12 | "exports": { 13 | "./package.json": "./package.json", 14 | ".": { 15 | "import": { 16 | "types": "./dist/esm/index.d.ts", 17 | "default": "./dist/esm/index.js" 18 | }, 19 | "require": { 20 | "types": "./dist/cjs/index.d.cts", 21 | "default": "./dist/cjs/index.cjs" 22 | } 23 | } 24 | }, 25 | "devDependencies": { 26 | "@biomejs/biome": "^1.5.3", 27 | "@maplibre/maplibre-gl-style-spec": "^23.1.0", 28 | "@types/leaflet": "^1.9.8", 29 | "@types/node": "^16.18.74", 30 | "tslib": "^2.3.0", 31 | "tsup": "^8.4.0", 32 | "tsx": "^4.19.3", 33 | "typescript": "^4.3.5" 34 | }, 35 | "dependencies": { 36 | "@mapbox/point-geometry": "^1.1.0", 37 | "@mapbox/vector-tile": "^2.0.2", 38 | "@protomaps/basemaps": "^5.0.0", 39 | "@types/css-font-loading-module": "^0.0.7", 40 | "@types/rbush": "^3.0.0", 41 | "color2k": "^2.0.3", 42 | "pbf": "^4.0.1", 43 | "pmtiles": "^3.1.0", 44 | "potpack": "^1.0.2", 45 | "rbush": "^3.0.1" 46 | }, 47 | "scripts": { 48 | "build": "tsup", 49 | "tsc": "tsc --noEmit --watch", 50 | "test": "tsx --test --test-reporter spec test/*.test.ts", 51 | "check": "biome check src test" 52 | }, 53 | "repository": { 54 | "type": "git", 55 | "url": "git://github.com/protomaps/protomaps-leaflet.git" 56 | }, 57 | "keywords": [ 58 | "gis", 59 | "map" 60 | ], 61 | "license": "BSD-3-Clause" 62 | } 63 | -------------------------------------------------------------------------------- /src/attribute.ts: -------------------------------------------------------------------------------- 1 | import { Feature } from "./tilecache"; 2 | 3 | export type AttrOption = T | ((z: number, f?: Feature) => T); 4 | 5 | export class StringAttr { 6 | str: AttrOption; 7 | perFeature: boolean; 8 | 9 | constructor(c: AttrOption | undefined, defaultValue: T) { 10 | this.str = c ?? defaultValue; 11 | this.perFeature = typeof this.str === "function" && this.str.length === 2; 12 | } 13 | 14 | public get(z: number, f?: Feature): T { 15 | if (typeof this.str === "function") { 16 | return this.str(z, f); 17 | } 18 | return this.str; 19 | } 20 | } 21 | 22 | export class NumberAttr { 23 | value: AttrOption; 24 | perFeature: boolean; 25 | 26 | constructor(c: AttrOption | undefined, defaultValue = 1) { 27 | this.value = c ?? defaultValue; 28 | this.perFeature = 29 | typeof this.value === "function" && this.value.length === 2; 30 | } 31 | 32 | public get(z: number, f?: Feature): number { 33 | if (typeof this.value === "function") { 34 | return this.value(z, f); 35 | } 36 | return this.value; 37 | } 38 | } 39 | 40 | export interface TextAttrOptions { 41 | labelProps?: AttrOption; 42 | textTransform?: AttrOption; 43 | } 44 | 45 | export class TextAttr { 46 | labelProps: AttrOption; 47 | textTransform?: AttrOption; 48 | 49 | constructor(options?: TextAttrOptions) { 50 | this.labelProps = options?.labelProps ?? ["name"]; 51 | this.textTransform = options?.textTransform; 52 | } 53 | 54 | public get(z: number, f: Feature): string | undefined { 55 | let retval: string | undefined; 56 | 57 | let labelProps: string[]; 58 | if (typeof this.labelProps === "function") { 59 | labelProps = this.labelProps(z, f); 60 | } else { 61 | labelProps = this.labelProps; 62 | } 63 | for (const property of labelProps) { 64 | if ( 65 | Object.prototype.hasOwnProperty.call(f.props, property) && 66 | typeof f.props[property] === "string" 67 | ) { 68 | retval = f.props[property] as string; 69 | break; 70 | } 71 | } 72 | let transform: string | ((z: number, f: Feature) => string) | undefined; 73 | if (typeof this.textTransform === "function") { 74 | transform = this.textTransform(z, f); 75 | } else { 76 | transform = this.textTransform; 77 | } 78 | if (retval && transform === "uppercase") retval = retval.toUpperCase(); 79 | else if (retval && transform === "lowercase") retval = retval.toLowerCase(); 80 | else if (retval && transform === "capitalize") { 81 | const wordsArray = retval.toLowerCase().split(" "); 82 | const capsArray = wordsArray.map((word: string) => { 83 | return word[0].toUpperCase() + word.slice(1); 84 | }); 85 | retval = capsArray.join(" "); 86 | } 87 | return retval; 88 | } 89 | } 90 | 91 | export interface FontAttrOptions { 92 | font?: AttrOption; 93 | fontFamily?: AttrOption; 94 | fontSize?: AttrOption; 95 | fontWeight?: AttrOption; 96 | fontStyle?: AttrOption; 97 | } 98 | 99 | export class FontAttr { 100 | family?: AttrOption; 101 | size?: AttrOption; 102 | weight?: AttrOption; 103 | style?: AttrOption; 104 | font?: AttrOption; 105 | 106 | constructor(options?: FontAttrOptions) { 107 | if (options?.font) { 108 | this.font = options.font; 109 | } else { 110 | this.family = options?.fontFamily ?? "sans-serif"; 111 | this.size = options?.fontSize ?? 12; 112 | this.weight = options?.fontWeight; 113 | this.style = options?.fontStyle; 114 | } 115 | } 116 | 117 | public get(z: number, f?: Feature) { 118 | if (this.font) { 119 | if (typeof this.font === "function") { 120 | return this.font(z, f); 121 | } 122 | return this.font; 123 | } 124 | let style = ""; 125 | if (this.style) { 126 | if (typeof this.style === "function") { 127 | style = `${this.style(z, f)} `; 128 | } else { 129 | style = `${this.style} `; 130 | } 131 | } 132 | 133 | let weight = ""; 134 | if (this.weight) { 135 | if (typeof this.weight === "function") { 136 | weight = `${this.weight(z, f)} `; 137 | } else { 138 | weight = `${this.weight} `; 139 | } 140 | } 141 | 142 | let size: number | ((z: number, f: Feature) => number) | undefined; 143 | if (typeof this.size === "function") { 144 | size = this.size(z, f); 145 | } else { 146 | size = this.size; 147 | } 148 | 149 | let family: string | ((z: number, f: Feature) => string) | undefined; 150 | if (typeof this.family === "function") { 151 | family = this.family(z, f); 152 | } else { 153 | family = this.family; 154 | } 155 | 156 | return `${style}${weight}${size}px ${family}`; 157 | } 158 | } 159 | 160 | export class ArrayAttr { 161 | value: AttrOption; 162 | perFeature: boolean; 163 | 164 | constructor(c: AttrOption, defaultValue: T[] = []) { 165 | this.value = c ?? defaultValue; 166 | this.perFeature = 167 | typeof this.value === "function" && this.value.length === 2; 168 | } 169 | 170 | public get(z: number, f?: Feature): T[] { 171 | if (typeof this.value === "function") { 172 | return this.value(z, f); 173 | } 174 | return this.value; 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/default_style/style.ts: -------------------------------------------------------------------------------- 1 | import { type Flavor } from "@protomaps/basemaps"; 2 | import { mix } from "color2k"; 3 | import { LabelRule } from "../labeler"; 4 | import { PaintRule } from "../painter"; 5 | import { 6 | CenteredTextSymbolizer, 7 | CircleSymbolizer, 8 | GroupSymbolizer, 9 | LineLabelSymbolizer, 10 | LineSymbolizer, 11 | OffsetTextSymbolizer, 12 | PolygonSymbolizer, 13 | exp, 14 | linear, 15 | } from "../symbolizer"; 16 | import { Feature, GeomType, JsonObject } from "../tilecache"; 17 | 18 | const getString = (props: JsonObject, key: string): string => { 19 | const val = props[key]; 20 | if (typeof val === "string") return val; 21 | return ""; 22 | }; 23 | 24 | const getNumber = (props: JsonObject, key: string): number => { 25 | const val = props[key]; 26 | if (typeof val === "number") return val; 27 | return 0; 28 | }; 29 | 30 | export const paintRules = (t: Flavor): PaintRule[] => { 31 | return [ 32 | { 33 | dataLayer: "earth", 34 | symbolizer: new PolygonSymbolizer({ 35 | fill: t.earth, 36 | }), 37 | }, 38 | ...(t.landcover 39 | ? [ 40 | { 41 | dataLayer: "landcover", 42 | symbolizer: new PolygonSymbolizer({ 43 | fill: (z, f) => { 44 | const landcover = t.landcover; 45 | if (!landcover || !f) return ""; 46 | const kind = getString(f.props, "kind"); 47 | if (kind === "grassland") return landcover.grassland; 48 | if (kind === "barren") return landcover.barren; 49 | if (kind === "urban_area") return landcover.urban_area; 50 | if (kind === "farmland") return landcover.farmland; 51 | if (kind === "glacier") return landcover.glacier; 52 | if (kind === "scrub") return landcover.scrub; 53 | return landcover.forest; 54 | }, 55 | opacity: (z, f) => { 56 | if (z === 8) return 0.5; 57 | return 1; 58 | }, 59 | }), 60 | }, 61 | ] 62 | : []), 63 | { 64 | dataLayer: "landuse", 65 | symbolizer: new PolygonSymbolizer({ 66 | fill: (z, f) => { 67 | return mix(t.park_a, t.park_b, Math.min(Math.max(z / 12.0, 12), 0)); 68 | }, 69 | }), 70 | filter: (z, f) => { 71 | const kind = getString(f.props, "kind"); 72 | return ["allotments", "village_green", "playground"].includes(kind); 73 | }, 74 | }, 75 | { 76 | dataLayer: "landuse", 77 | symbolizer: new PolygonSymbolizer({ 78 | fill: t.park_b, 79 | opacity: (z, f) => { 80 | if (z < 8) return 0; 81 | if (z === 8) return 0.5; 82 | return 1; 83 | }, 84 | }), 85 | filter: (z, f) => { 86 | const kind = getString(f.props, "kind"); 87 | return [ 88 | "national_park", 89 | "park", 90 | "cemetery", 91 | "protected_area", 92 | "nature_reserve", 93 | "forest", 94 | "golf_course", 95 | ].includes(kind); 96 | }, 97 | }, 98 | { 99 | dataLayer: "landuse", 100 | symbolizer: new PolygonSymbolizer({ 101 | fill: t.hospital, 102 | }), 103 | filter: (z, f) => { 104 | return f.props.kind === "hospital"; 105 | }, 106 | }, 107 | { 108 | dataLayer: "landuse", 109 | symbolizer: new PolygonSymbolizer({ 110 | fill: t.industrial, 111 | }), 112 | filter: (z, f) => { 113 | return f.props.kind === "industrial"; 114 | }, 115 | }, 116 | { 117 | dataLayer: "landuse", 118 | symbolizer: new PolygonSymbolizer({ 119 | fill: t.school, 120 | }), 121 | filter: (z, f) => { 122 | const kind = getString(f.props, "kind"); 123 | return ["school", "university", "college"].includes(kind); 124 | }, 125 | }, 126 | { 127 | dataLayer: "landuse", 128 | symbolizer: new PolygonSymbolizer({ 129 | fill: t.beach, 130 | }), 131 | filter: (z, f) => { 132 | return f.props.kind === "beach"; 133 | }, 134 | }, 135 | { 136 | dataLayer: "landuse", 137 | symbolizer: new PolygonSymbolizer({ 138 | fill: t.zoo, 139 | }), 140 | filter: (z, f) => { 141 | return f.props.kind === "zoo"; 142 | }, 143 | }, 144 | { 145 | dataLayer: "landuse", 146 | symbolizer: new PolygonSymbolizer({ 147 | fill: t.zoo, 148 | }), 149 | filter: (z, f) => { 150 | const kind = getString(f.props, "kind"); 151 | return ["military", "naval_base", "airfield"].includes(kind); 152 | }, 153 | }, 154 | { 155 | dataLayer: "landuse", 156 | symbolizer: new PolygonSymbolizer({ 157 | fill: (z, f) => { 158 | return mix(t.wood_a, t.wood_b, Math.min(Math.max(z / 12.0, 12), 0)); 159 | }, 160 | opacity: (z, f) => { 161 | if (z < 8) return 0; 162 | if (z === 8) return 0.5; 163 | return 1; 164 | }, 165 | }), 166 | filter: (z, f) => { 167 | const kind = getString(f.props, "kind"); 168 | return ["wood", "nature_reserve", "forest"].includes(kind); 169 | }, 170 | }, 171 | { 172 | dataLayer: "landuse", 173 | symbolizer: new PolygonSymbolizer({ 174 | fill: (z, f) => { 175 | return mix(t.scrub_a, t.scrub_b, Math.min(Math.max(z / 12.0, 12), 0)); 176 | }, 177 | }), 178 | filter: (z, f) => { 179 | const kind = getString(f.props, "kind"); 180 | return ["scrub", "grassland", "grass"].includes(kind); 181 | }, 182 | }, 183 | { 184 | dataLayer: "landuse", 185 | symbolizer: new PolygonSymbolizer({ 186 | fill: t.scrub_b, 187 | }), 188 | filter: (z, f) => { 189 | const kind = getString(f.props, "kind"); 190 | return ["scrub", "grassland", "grass"].includes(kind); 191 | }, 192 | }, 193 | { 194 | dataLayer: "landuse", 195 | symbolizer: new PolygonSymbolizer({ 196 | fill: t.glacier, 197 | }), 198 | filter: (z, f) => { 199 | return f.props.kind === "glacier"; 200 | }, 201 | }, 202 | { 203 | dataLayer: "landuse", 204 | symbolizer: new PolygonSymbolizer({ 205 | fill: t.sand, 206 | opacity: (z, f) => { 207 | if (z < 8) return 0; 208 | if (z === 8) return 0.5; 209 | return 1; 210 | }, 211 | }), 212 | filter: (z, f) => { 213 | return f.props.kind === "sand"; 214 | }, 215 | }, 216 | { 217 | dataLayer: "landuse", 218 | symbolizer: new PolygonSymbolizer({ 219 | fill: t.aerodrome, 220 | }), 221 | filter: (z, f) => { 222 | return f.props.kind === "aerodrome"; 223 | }, 224 | }, 225 | { 226 | dataLayer: "water", 227 | symbolizer: new PolygonSymbolizer({ 228 | fill: t.water, 229 | }), 230 | filter: (z, f) => { 231 | return f.geomType === GeomType.Polygon; 232 | }, 233 | }, 234 | { 235 | dataLayer: "roads", 236 | symbolizer: new LineSymbolizer({ 237 | color: t.runway, 238 | width: (z, f) => { 239 | return exp(1.6, [ 240 | [11, 0], 241 | [13, 4], 242 | [19, 30], 243 | ])(z); 244 | }, 245 | }), 246 | filter: (z, f) => { 247 | return f.props.kind_detail === "runway"; 248 | }, 249 | }, 250 | { 251 | dataLayer: "roads", 252 | symbolizer: new LineSymbolizer({ 253 | color: t.runway, 254 | width: (z, f) => { 255 | return exp(1.6, [ 256 | [14, 0], 257 | [14.5, 1], 258 | [16, 6], 259 | ])(z); 260 | }, 261 | }), 262 | filter: (z, f) => { 263 | return f.props.kind_detail === "taxiway"; 264 | }, 265 | }, 266 | { 267 | dataLayer: "roads", 268 | symbolizer: new LineSymbolizer({ 269 | color: t.pier, 270 | width: (z, f) => { 271 | return exp(1.6, [ 272 | [13, 0], 273 | [13.5, 0, 5], 274 | [21, 16], 275 | ])(z); 276 | }, 277 | }), 278 | filter: (z, f) => { 279 | return f.props.kind === "path" && f.props.kind_detail === "pier"; 280 | }, 281 | }, 282 | { 283 | dataLayer: "water", 284 | minzoom: 14, 285 | symbolizer: new LineSymbolizer({ 286 | color: t.water, 287 | width: (z, f) => { 288 | return exp(1.6, [ 289 | [9, 0], 290 | [9.5, 1.0], 291 | [18, 12], 292 | ])(z); 293 | }, 294 | }), 295 | filter: (z, f) => { 296 | return f.geomType === GeomType.Line && f.props.kind === "river"; 297 | }, 298 | }, 299 | { 300 | dataLayer: "water", 301 | minzoom: 14, 302 | symbolizer: new LineSymbolizer({ 303 | color: t.water, 304 | width: 0.5, 305 | }), 306 | filter: (z, f) => { 307 | return f.geomType === GeomType.Line && f.props.kind === "stream"; 308 | }, 309 | }, 310 | { 311 | dataLayer: "landuse", 312 | symbolizer: new PolygonSymbolizer({ 313 | fill: t.pedestrian, 314 | }), 315 | filter: (z, f) => { 316 | return f.props.kind === "pedestrian"; 317 | }, 318 | }, 319 | { 320 | dataLayer: "landuse", 321 | symbolizer: new PolygonSymbolizer({ 322 | fill: t.pier, 323 | }), 324 | filter: (z, f) => { 325 | return f.props.kind === "pier"; 326 | }, 327 | }, 328 | { 329 | dataLayer: "buildings", 330 | symbolizer: new PolygonSymbolizer({ 331 | fill: t.buildings, 332 | opacity: 0.5, 333 | }), 334 | }, 335 | { 336 | dataLayer: "roads", 337 | symbolizer: new LineSymbolizer({ 338 | color: t.major, 339 | width: (z, f) => { 340 | return exp(1.6, [ 341 | [14, 0], 342 | [20, 7], 343 | ])(z); 344 | }, 345 | }), 346 | filter: (z, f) => { 347 | const kind = getString(f.props, "kind"); 348 | return ["other", "path"].includes(kind); 349 | }, 350 | }, 351 | { 352 | dataLayer: "roads", 353 | symbolizer: new LineSymbolizer({ 354 | color: t.major, 355 | width: (z, f) => { 356 | return exp(1.6, [ 357 | [13, 0], 358 | [18, 8], 359 | ])(z); 360 | }, 361 | }), 362 | filter: (z, f) => { 363 | return f.props.kind === "minor_road"; 364 | }, 365 | }, 366 | { 367 | dataLayer: "roads", 368 | symbolizer: new LineSymbolizer({ 369 | color: t.major, 370 | width: (z, f) => { 371 | return exp(1.6, [ 372 | [6, 0], 373 | [12, 1.6], 374 | [15, 3], 375 | [18, 13], 376 | ])(z); 377 | }, 378 | }), 379 | filter: (z, f) => { 380 | return f.props.kind === "major_road"; 381 | }, 382 | }, 383 | { 384 | dataLayer: "roads", 385 | symbolizer: new LineSymbolizer({ 386 | color: t.major, 387 | width: (z, f) => { 388 | return exp(1.6, [ 389 | [3, 0], 390 | [6, 1.1], 391 | [12, 1.6], 392 | [15, 5], 393 | [18, 15], 394 | ])(z); 395 | }, 396 | }), 397 | filter: (z, f) => { 398 | return f.props.kind === "highway"; 399 | }, 400 | }, 401 | { 402 | dataLayer: "boundaries", 403 | symbolizer: new LineSymbolizer({ 404 | color: t.boundaries, 405 | width: 1, 406 | }), 407 | filter: (z, f) => { 408 | const minAdminLevel = f.props.kind_detail; 409 | return typeof minAdminLevel === "number" && minAdminLevel <= 2; 410 | }, 411 | }, 412 | { 413 | dataLayer: "roads", 414 | symbolizer: new LineSymbolizer({ 415 | dash: [0.3, 0.75], 416 | color: t.railway, 417 | dashWidth: (z, f) => { 418 | return exp(1.6, [ 419 | [4, 0], 420 | [7, 0.15], 421 | [19, 9], 422 | ])(z); 423 | }, 424 | opacity: 0.5, 425 | }), 426 | filter: (z, f) => { 427 | return f.props.kind === "rail"; 428 | }, 429 | }, 430 | { 431 | dataLayer: "boundaries", 432 | symbolizer: new LineSymbolizer({ 433 | color: t.boundaries, 434 | width: 0.5, 435 | }), 436 | filter: (z, f) => { 437 | const minAdminLevel = f.props.kind_detail; 438 | return typeof minAdminLevel === "number" && minAdminLevel > 2; 439 | }, 440 | }, 441 | ]; 442 | }; 443 | 444 | export const labelRules = (t: Flavor, lang: string): LabelRule[] => { 445 | const nametags = [`name:${lang}`, "name"]; 446 | 447 | return [ 448 | // { 449 | // id: "neighbourhood", 450 | // dataLayer: "places", 451 | // symbolizer: languageStack( 452 | // new CenteredTextSymbolizer({ 453 | // labelProps: nametags, 454 | // fill: params.neighbourhoodLabel, 455 | // font: "500 10px sans-serif", 456 | // textTransform: "uppercase", 457 | // }), 458 | // params.neighbourhoodLabel, 459 | // ), 460 | // filter: (z, f) => { 461 | // return f.props["kind"] === "neighbourhood"; 462 | // }, 463 | // }, 464 | { 465 | dataLayer: "roads", 466 | symbolizer: new LineLabelSymbolizer({ 467 | labelProps: nametags, 468 | fill: t.roads_label_minor, 469 | font: "400 12px sans-serif", 470 | width: 2, 471 | stroke: t.roads_label_minor_halo, 472 | }), 473 | // TODO: sort by minzoom 474 | minzoom: 16, 475 | filter: (z, f) => { 476 | const kind = getString(f.props, "kind"); 477 | return ["minor_road", "other", "path"].includes(kind); 478 | }, 479 | }, 480 | { 481 | dataLayer: "roads", 482 | symbolizer: new LineLabelSymbolizer({ 483 | labelProps: nametags, 484 | fill: t.roads_label_major, 485 | font: "400 12px sans-serif", 486 | width: 2, 487 | stroke: t.roads_label_major_halo, 488 | }), 489 | // TODO: sort by minzoom 490 | minzoom: 12, 491 | filter: (z, f) => { 492 | const kind = getString(f.props, "kind"); 493 | return ["highway", "major_road"].includes(kind); 494 | }, 495 | }, 496 | { 497 | dataLayer: "roads", 498 | symbolizer: new LineLabelSymbolizer({ 499 | labelProps: nametags, 500 | fill: t.roads_label_major, 501 | font: "400 12px sans-serif", 502 | width: 2, 503 | stroke: t.roads_label_major_halo, 504 | }), 505 | // TODO: sort by minzoom 506 | minzoom: 12, 507 | filter: (z, f) => { 508 | const kind = getString(f.props, "kind"); 509 | return ["highway", "major_road"].includes(kind); 510 | }, 511 | }, 512 | { 513 | dataLayer: "water", 514 | symbolizer: new CenteredTextSymbolizer({ 515 | labelProps: nametags, 516 | fill: t.ocean_label, 517 | lineHeight: 1.5, 518 | letterSpacing: 1, 519 | font: (z, f) => { 520 | const size = linear([ 521 | [3, 10], 522 | [10, 12], 523 | ])(z); 524 | return `400 ${size}px sans-serif`; 525 | }, 526 | textTransform: "uppercase", 527 | }), 528 | filter: (z, f) => { 529 | const kind = getString(f.props, "kind"); 530 | return ( 531 | f.geomType === GeomType.Point && 532 | ["ocean", "bay", "strait", "fjord"].includes(kind) 533 | ); 534 | }, 535 | }, 536 | { 537 | dataLayer: "water", 538 | symbolizer: new CenteredTextSymbolizer({ 539 | labelProps: nametags, 540 | fill: t.ocean_label, 541 | lineHeight: 1.5, 542 | letterSpacing: 1, 543 | font: (z, f) => { 544 | const size = linear([ 545 | [3, 10], 546 | [6, 12], 547 | [10, 12], 548 | ])(z); 549 | return `400 ${size}px sans-serif`; 550 | }, 551 | }), 552 | filter: (z, f) => { 553 | const kind = getString(f.props, "kind"); 554 | return ( 555 | f.geomType === GeomType.Point && 556 | ["sea", "lake", "water"].includes(kind) 557 | ); 558 | }, 559 | }, 560 | { 561 | dataLayer: "places", 562 | symbolizer: new CenteredTextSymbolizer({ 563 | labelProps: (z, f) => { 564 | if (z < 6) { 565 | return [`ref:${lang}`, "ref"]; 566 | } 567 | return nametags; 568 | }, 569 | fill: t.state_label, 570 | stroke: t.state_label_halo, 571 | width: 1, 572 | lineHeight: 1.5, 573 | font: (z: number, f?: Feature) => { 574 | return "400 12px sans-serif"; 575 | }, 576 | textTransform: "uppercase", 577 | }), 578 | filter: (z, f) => { 579 | return f.props.kind === "region"; 580 | }, 581 | }, 582 | { 583 | dataLayer: "places", 584 | symbolizer: new CenteredTextSymbolizer({ 585 | labelProps: nametags, 586 | fill: t.country_label, 587 | lineHeight: 1.5, 588 | font: (z: number, f?: Feature) => { 589 | if (z < 6) return "600 12px sans-serif"; 590 | return "600 12px sans-serif"; 591 | }, 592 | textTransform: "uppercase", 593 | }), 594 | filter: (z, f) => { 595 | return f.props.kind === "country"; 596 | }, 597 | }, 598 | { 599 | // places_locality 600 | dataLayer: "places", 601 | minzoom: 9, 602 | symbolizer: new CenteredTextSymbolizer({ 603 | labelProps: nametags, 604 | fill: t.city_label, 605 | lineHeight: 1.5, 606 | font: (z: number, f?: Feature) => { 607 | if (!f) return "400 12px sans-serif"; 608 | const minZoom = f.props.min_zoom; 609 | let weight = 400; 610 | if (minZoom && minZoom <= 5) { 611 | weight = 600; 612 | } 613 | let size = 12; 614 | const popRank = f.props.population_rank; 615 | if (popRank && popRank > 9) { 616 | size = 16; 617 | } 618 | return `${weight} ${size}px sans-serif`; 619 | }, 620 | }), 621 | sort: (a, b) => { 622 | const aRank = getNumber(a, "min_zoom"); 623 | const bRank = getNumber(b, "min_zoom"); 624 | return aRank - bRank; 625 | }, 626 | filter: (z, f) => { 627 | return f.props.kind === "locality"; 628 | }, 629 | }, 630 | { 631 | dataLayer: "places", 632 | maxzoom: 8, 633 | symbolizer: new GroupSymbolizer([ 634 | new CircleSymbolizer({ 635 | radius: 2, 636 | fill: t.city_label, 637 | stroke: t.city_label_halo, 638 | width: 1.5, 639 | }), 640 | new OffsetTextSymbolizer({ 641 | labelProps: nametags, 642 | fill: t.city_label, 643 | stroke: t.city_label_halo, 644 | width: 1, 645 | offsetX: 6, 646 | offsetY: 4.5, 647 | font: (z, f) => { 648 | return "400 12px sans-serif"; 649 | }, 650 | }), 651 | ]), 652 | filter: (z, f) => { 653 | return f.props.kind === "locality"; 654 | }, 655 | }, 656 | ]; 657 | }; 658 | -------------------------------------------------------------------------------- /src/frontends/leaflet.ts: -------------------------------------------------------------------------------- 1 | // biome-ignore lint: leaflet 1.x 2 | declare const L: any; 3 | 4 | import Point from "@mapbox/point-geometry"; 5 | 6 | import type { Coords } from "leaflet"; 7 | import { namedFlavor } from "@protomaps/basemaps"; 8 | import { PMTiles } from "pmtiles"; 9 | import { labelRules, paintRules } from "../default_style/style"; 10 | import { LabelRule, Labelers } from "../labeler"; 11 | import { PaintRule, paint } from "../painter"; 12 | import { PickedFeature } from "../tilecache"; 13 | import { PreparedTile, SourceOptions, sourcesToViews } from "../view"; 14 | 15 | const timer = (duration: number) => { 16 | return new Promise((resolve) => { 17 | setTimeout(() => { 18 | resolve(); 19 | }, duration); 20 | }); 21 | }; 22 | 23 | // replacement for Promise.allSettled (requires ES2020+) 24 | // this is called for every tile render, 25 | // so ensure font loading failure does not make map rendering fail 26 | type Status = { 27 | status: string; 28 | value?: unknown; 29 | reason: Error; 30 | }; 31 | 32 | const reflect = (promise: Promise) => { 33 | return promise.then( 34 | (v) => { 35 | return { status: "fulfilled", value: v }; 36 | }, 37 | (error) => { 38 | return { status: "rejected", reason: error }; 39 | }, 40 | ); 41 | }; 42 | 43 | type DoneCallback = (error?: Error, tile?: HTMLElement) => void; 44 | type KeyedHtmlCanvasElement = HTMLCanvasElement & { key: string }; 45 | 46 | export interface LeafletLayerOptions extends L.GridLayerOptions { 47 | bounds?: L.LatLngBoundsExpression; 48 | attribution?: string; 49 | debug?: string; 50 | lang?: string; 51 | tileDelay?: number; 52 | noWrap?: boolean; 53 | paintRules?: PaintRule[]; 54 | labelRules?: LabelRule[]; 55 | tasks?: Promise[]; 56 | maxDataZoom?: number; 57 | url?: PMTiles | string; 58 | sources?: Record; 59 | flavor?: string; 60 | backgroundColor?: string; 61 | } 62 | 63 | const leafletLayer = (options: LeafletLayerOptions = {}) => { 64 | class LeafletLayer extends L.GridLayer { 65 | public paintRules: PaintRule[]; 66 | public labelRules: LabelRule[]; 67 | public backgroundColor?: string; 68 | 69 | constructor(options: LeafletLayerOptions = {}) { 70 | if (options.noWrap && !options.bounds) 71 | options.bounds = [ 72 | [-90, -180], 73 | [90, 180], 74 | ]; 75 | if (options.attribution == null) 76 | options.attribution = 77 | 'Protomaps © OpenStreetMap'; 78 | super(options); 79 | 80 | if (options.flavor) { 81 | const flavor = namedFlavor(options.flavor); 82 | this.paintRules = paintRules(flavor); 83 | this.labelRules = labelRules(flavor, options.lang || "en"); 84 | this.backgroundColor = flavor.background; 85 | } else { 86 | this.paintRules = options.paintRules || []; 87 | this.labelRules = options.labelRules || []; 88 | this.backgroundColor = options.backgroundColor; 89 | } 90 | 91 | this.lastRequestedZ = undefined; 92 | this.tasks = options.tasks || []; 93 | 94 | this.views = sourcesToViews(options); 95 | 96 | this.debug = options.debug; 97 | const scratch = document.createElement("canvas").getContext("2d"); 98 | this.scratch = scratch; 99 | this.onTilesInvalidated = (tiles: Set) => { 100 | for (const t of tiles) { 101 | this.rerenderTile(t); 102 | } 103 | }; 104 | this.labelers = new Labelers( 105 | this.scratch, 106 | this.labelRules, 107 | 16, 108 | this.onTilesInvalidated, 109 | ); 110 | this.tileSize = 256 * window.devicePixelRatio; 111 | this.tileDelay = options.tileDelay || 3; 112 | this.lang = options.lang; 113 | } 114 | 115 | public async renderTile( 116 | coords: Coords, 117 | element: KeyedHtmlCanvasElement, 118 | key: string, 119 | done = () => {}, 120 | ) { 121 | this.lastRequestedZ = coords.z; 122 | 123 | const promises = []; 124 | for (const [k, v] of this.views) { 125 | const promise = v.getDisplayTile(coords); 126 | promises.push({ key: k, promise: promise }); 127 | } 128 | const tileResponses = await Promise.all( 129 | promises.map((o) => { 130 | return o.promise.then( 131 | (v: PreparedTile[]) => { 132 | return { status: "fulfilled", value: v, key: o.key }; 133 | }, 134 | (error: Error) => { 135 | return { status: "rejected", reason: error, key: o.key }; 136 | }, 137 | ); 138 | }), 139 | ); 140 | 141 | const preparedTilemap = new Map(); 142 | for (const tileResponse of tileResponses) { 143 | if (tileResponse.status === "fulfilled") { 144 | preparedTilemap.set(tileResponse.key, [tileResponse.value]); 145 | } else { 146 | if (tileResponse.reason.name === "AbortError") { 147 | // do nothing 148 | } else { 149 | console.error(tileResponse.reason); 150 | } 151 | } 152 | } 153 | 154 | if (element.key !== key) return; 155 | if (this.lastRequestedZ !== coords.z) return; 156 | 157 | await Promise.all(this.tasks.map(reflect)); 158 | 159 | if (element.key !== key) return; 160 | if (this.lastRequestedZ !== coords.z) return; 161 | 162 | const layoutTime = this.labelers.add(coords.z, preparedTilemap); 163 | 164 | if (element.key !== key) return; 165 | if (this.lastRequestedZ !== coords.z) return; 166 | 167 | const labelData = this.labelers.getIndex(coords.z); 168 | 169 | if (!this._map) return; // the layer has been removed from the map 170 | 171 | const center = this._map.getCenter().wrap(); 172 | const pixelBounds = this._getTiledPixelBounds(center); 173 | const tileRange = this._pxBoundsToTileRange(pixelBounds); 174 | const tileCenter = tileRange.getCenter(); 175 | const priority = coords.distanceTo(tileCenter) * this.tileDelay; 176 | 177 | await timer(priority); 178 | 179 | if (element.key !== key) return; 180 | if (this.lastRequestedZ !== coords.z) return; 181 | 182 | const buf = 16; 183 | const bbox = { 184 | minX: 256 * coords.x - buf, 185 | minY: 256 * coords.y - buf, 186 | maxX: 256 * (coords.x + 1) + buf, 187 | maxY: 256 * (coords.y + 1) + buf, 188 | }; 189 | const origin = new Point(256 * coords.x, 256 * coords.y); 190 | 191 | element.width = this.tileSize; 192 | element.height = this.tileSize; 193 | const ctx = element.getContext("2d"); 194 | if (!ctx) { 195 | console.error("Failed to get Canvas context"); 196 | return; 197 | } 198 | ctx.setTransform(this.tileSize / 256, 0, 0, this.tileSize / 256, 0, 0); 199 | ctx.clearRect(0, 0, 256, 256); 200 | 201 | if (this.backgroundColor) { 202 | ctx.save(); 203 | ctx.fillStyle = this.backgroundColor; 204 | ctx.fillRect(0, 0, 256, 256); 205 | ctx.restore(); 206 | } 207 | 208 | let paintingTime = 0; 209 | 210 | const paintRules = this.paintRules; 211 | 212 | paintingTime = paint( 213 | ctx, 214 | coords.z, 215 | preparedTilemap, 216 | this.xray ? null : labelData, 217 | paintRules, 218 | bbox, 219 | origin, 220 | false, 221 | this.debug, 222 | ); 223 | 224 | if (this.debug) { 225 | ctx.save(); 226 | ctx.fillStyle = this.debug; 227 | ctx.font = "600 12px sans-serif"; 228 | ctx.fillText(`${coords.z} ${coords.x} ${coords.y}`, 4, 14); 229 | 230 | ctx.font = "12px sans-serif"; 231 | let ypos = 28; 232 | for (const [k, v] of preparedTilemap) { 233 | const dt = v[0].dataTile; 234 | ctx.fillText(`${k + (k ? " " : "") + dt.z} ${dt.x} ${dt.y}`, 4, ypos); 235 | ypos += 14; 236 | } 237 | 238 | ctx.font = "600 10px sans-serif"; 239 | if (paintingTime > 8) { 240 | ctx.fillText(`${paintingTime.toFixed()} ms paint`, 4, ypos); 241 | ypos += 14; 242 | } 243 | 244 | if (layoutTime > 8) { 245 | ctx.fillText(`${layoutTime.toFixed()} ms layout`, 4, ypos); 246 | } 247 | ctx.strokeStyle = this.debug; 248 | 249 | ctx.lineWidth = 0.5; 250 | ctx.beginPath(); 251 | ctx.moveTo(0, 0); 252 | ctx.lineTo(0, 256); 253 | ctx.stroke(); 254 | 255 | ctx.lineWidth = 0.5; 256 | ctx.beginPath(); 257 | ctx.moveTo(0, 0); 258 | ctx.lineTo(256, 0); 259 | ctx.stroke(); 260 | 261 | ctx.restore(); 262 | } 263 | done(); 264 | } 265 | 266 | public rerenderTile(key: string) { 267 | for (const unwrappedK in this._tiles) { 268 | const wrappedCoord = this._wrapCoords( 269 | this._keyToTileCoords(unwrappedK), 270 | ); 271 | if (key === this._tileCoordsToKey(wrappedCoord)) { 272 | this.renderTile(wrappedCoord, this._tiles[unwrappedK].el, key); 273 | } 274 | } 275 | } 276 | 277 | // a primitive way to check the features at a certain point. 278 | // it does not support hover states, cursor changes, or changing the style of the selected feature, 279 | // so is only appropriate for debugging or very basic use cases. 280 | // those features are outside of the scope of this library: 281 | // for fully pickable, interactive features, use MapLibre GL JS instead. 282 | public queryTileFeaturesDebug( 283 | lng: number, 284 | lat: number, 285 | brushSize = 16, 286 | ): Map { 287 | const featuresBySourceName = new Map(); 288 | for (const [sourceName, view] of this.views) { 289 | featuresBySourceName.set( 290 | sourceName, 291 | view.queryFeatures(lng, lat, this._map.getZoom(), brushSize), 292 | ); 293 | } 294 | return featuresBySourceName; 295 | } 296 | 297 | public clearLayout() { 298 | this.labelers = new Labelers( 299 | this.scratch, 300 | this.labelRules, 301 | 16, 302 | this.onTilesInvalidated, 303 | ); 304 | } 305 | 306 | public rerenderTiles() { 307 | for (const unwrappedK in this._tiles) { 308 | const wrappedCoord = this._wrapCoords( 309 | this._keyToTileCoords(unwrappedK), 310 | ); 311 | const key = this._tileCoordsToKey(wrappedCoord); 312 | this.renderTile(wrappedCoord, this._tiles[unwrappedK].el, key); 313 | } 314 | } 315 | 316 | public createTile(coords: Coords, showTile: DoneCallback) { 317 | const element = L.DomUtil.create("canvas", "leaflet-tile"); 318 | element.lang = this.lang; 319 | 320 | const key = this._tileCoordsToKey(coords); 321 | element.key = key; 322 | 323 | this.renderTile(coords, element, key, () => { 324 | showTile(undefined, element); 325 | }); 326 | 327 | return element; 328 | } 329 | 330 | public _removeTile(key: string) { 331 | const tile = this._tiles[key]; 332 | if (!tile) { 333 | return; 334 | } 335 | tile.el.removed = true; 336 | tile.el.key = undefined; 337 | L.DomUtil.removeClass(tile.el, "leaflet-tile-loaded"); 338 | tile.el.width = tile.el.height = 0; 339 | L.DomUtil.remove(tile.el); 340 | delete this._tiles[key]; 341 | this.fire("tileunload", { 342 | tile: tile.el, 343 | coords: this._keyToTileCoords(key), 344 | }); 345 | } 346 | } 347 | return new LeafletLayer(options); 348 | }; 349 | 350 | export { leafletLayer }; 351 | -------------------------------------------------------------------------------- /src/frontends/static.ts: -------------------------------------------------------------------------------- 1 | import Point from "@mapbox/point-geometry"; 2 | 3 | import { namedFlavor } from "@protomaps/basemaps"; 4 | import { PMTiles } from "pmtiles"; 5 | import { labelRules, paintRules } from "../default_style/style"; 6 | import { LabelRule, Labeler } from "../labeler"; 7 | import { PaintRule, paint } from "../painter"; 8 | import { PreparedTile, SourceOptions, View, sourcesToViews } from "../view"; 9 | 10 | const R = 6378137; 11 | const MAX_LATITUDE = 85.0511287798; 12 | const MAXCOORD = R * Math.PI; 13 | 14 | const project = (latlng: Point): Point => { 15 | const d = Math.PI / 180; 16 | const constrainedLat = Math.max( 17 | Math.min(MAX_LATITUDE, latlng.y), 18 | -MAX_LATITUDE, 19 | ); 20 | const sin = Math.sin(constrainedLat * d); 21 | return new Point(R * latlng.x * d, (R * Math.log((1 + sin) / (1 - sin))) / 2); 22 | }; 23 | 24 | const unproject = (point: Point) => { 25 | const d = 180 / Math.PI; 26 | return { 27 | lat: (2 * Math.atan(Math.exp(point.y / R)) - Math.PI / 2) * d, 28 | lng: (point.x * d) / R, 29 | }; 30 | }; 31 | 32 | const instancedProject = (origin: Point, displayZoom: number) => { 33 | return (latlng: Point) => { 34 | const projected = project(latlng); 35 | const normalized = new Point( 36 | (projected.x + MAXCOORD) / (MAXCOORD * 2), 37 | 1 - (projected.y + MAXCOORD) / (MAXCOORD * 2), 38 | ); 39 | return normalized.mult(2 ** displayZoom * 256).sub(origin); 40 | }; 41 | }; 42 | 43 | const instancedUnproject = (origin: Point, displayZoom: number) => { 44 | return (point: Point) => { 45 | const normalized = new Point(point.x, point.y) 46 | .add(origin) 47 | .div(2 ** displayZoom * 256); 48 | const projected = new Point( 49 | normalized.x * (MAXCOORD * 2) - MAXCOORD, 50 | (1 - normalized.y) * (MAXCOORD * 2) - MAXCOORD, 51 | ); 52 | return unproject(projected); 53 | }; 54 | }; 55 | 56 | export const getZoom = (degreesLng: number, cssPixels: number): number => { 57 | const d = cssPixels * (360 / degreesLng); 58 | return Math.log2(d / 256); 59 | }; 60 | 61 | interface StaticOptions { 62 | debug?: string; 63 | lang?: string; 64 | maxDataZoom?: number; 65 | url?: PMTiles | string; 66 | sources?: Record; 67 | paintRules?: PaintRule[]; 68 | labelRules?: LabelRule[]; 69 | backgroundColor?: string; 70 | flavor?: string; 71 | } 72 | 73 | export class Static { 74 | paintRules: PaintRule[]; 75 | labelRules: LabelRule[]; 76 | views: Map; 77 | debug?: string; 78 | backgroundColor?: string; 79 | 80 | constructor(options: StaticOptions) { 81 | if (options.flavor) { 82 | const flavor = namedFlavor(options.flavor); 83 | this.paintRules = paintRules(flavor); 84 | this.labelRules = labelRules(flavor, options.lang || "en"); 85 | this.backgroundColor = flavor.background; 86 | } else { 87 | this.paintRules = options.paintRules || []; 88 | this.labelRules = options.labelRules || []; 89 | this.backgroundColor = options.backgroundColor; 90 | } 91 | 92 | this.views = sourcesToViews(options); 93 | this.debug = options.debug || ""; 94 | } 95 | 96 | async drawContext( 97 | ctx: CanvasRenderingContext2D, 98 | width: number, 99 | height: number, 100 | latlng: Point, 101 | displayZoom: number, 102 | ) { 103 | const center = project(latlng); 104 | const normalizedCenter = new Point( 105 | (center.x + MAXCOORD) / (MAXCOORD * 2), 106 | 1 - (center.y + MAXCOORD) / (MAXCOORD * 2), 107 | ); 108 | 109 | // the origin of the painter call in global Z coordinates 110 | const origin = normalizedCenter 111 | .clone() 112 | .mult(2 ** displayZoom * 256) 113 | .sub(new Point(width / 2, height / 2)); 114 | 115 | // the bounds of the painter call in global Z coordinates 116 | const bbox = { 117 | minX: origin.x, 118 | minY: origin.y, 119 | maxX: origin.x + width, 120 | maxY: origin.y + height, 121 | }; 122 | 123 | const promises = []; 124 | for (const [k, v] of this.views) { 125 | const promise = v.getBbox(displayZoom, bbox); 126 | promises.push({ key: k, promise: promise }); 127 | } 128 | const tileResponses = await Promise.all( 129 | promises.map((o) => { 130 | return o.promise.then( 131 | (v: PreparedTile[]) => { 132 | return { status: "fulfilled", value: v, key: o.key }; 133 | }, 134 | (error: Error) => { 135 | return { status: "rejected", value: [], reason: error, key: o.key }; 136 | }, 137 | ); 138 | }), 139 | ); 140 | 141 | const preparedTilemap = new Map(); 142 | for (const tileResponse of tileResponses) { 143 | if (tileResponse.status === "fulfilled") { 144 | preparedTilemap.set(tileResponse.key, tileResponse.value); 145 | } 146 | } 147 | 148 | const start = performance.now(); 149 | const labeler = new Labeler( 150 | displayZoom, 151 | ctx, 152 | this.labelRules, 153 | 16, 154 | undefined, 155 | ); // because need ctx to measure 156 | 157 | const layoutTime = labeler.add(preparedTilemap); 158 | 159 | if (this.backgroundColor) { 160 | ctx.save(); 161 | ctx.fillStyle = this.backgroundColor; 162 | ctx.fillRect(0, 0, width, height); 163 | ctx.restore(); 164 | } 165 | 166 | const paintRules = this.paintRules; 167 | 168 | const p = paint( 169 | ctx, 170 | displayZoom, 171 | preparedTilemap, 172 | labeler.index, 173 | paintRules, 174 | bbox, 175 | origin, 176 | true, 177 | this.debug, 178 | ); 179 | 180 | if (this.debug) { 181 | ctx.save(); 182 | ctx.translate(-origin.x, -origin.y); 183 | ctx.strokeStyle = this.debug; 184 | ctx.fillStyle = this.debug; 185 | ctx.font = "12px sans-serif"; 186 | let idx = 0; 187 | for (const [k, v] of preparedTilemap) { 188 | for (const preparedTile of v) { 189 | ctx.strokeRect( 190 | preparedTile.origin.x, 191 | preparedTile.origin.y, 192 | preparedTile.dim, 193 | preparedTile.dim, 194 | ); 195 | const dt = preparedTile.dataTile; 196 | ctx.fillText( 197 | `${k + (k ? " " : "") + dt.z} ${dt.x} ${dt.y}`, 198 | preparedTile.origin.x + 4, 199 | preparedTile.origin.y + 14 * (1 + idx), 200 | ); 201 | } 202 | idx++; 203 | } 204 | ctx.restore(); 205 | } 206 | 207 | // TODO this API isn't so elegant 208 | return { 209 | elapsed: performance.now() - start, 210 | project: instancedProject(origin, displayZoom), 211 | unproject: instancedUnproject(origin, displayZoom), 212 | }; 213 | } 214 | 215 | async drawCanvas( 216 | canvas: HTMLCanvasElement, 217 | latlng: Point, 218 | displayZoom: number, 219 | options: StaticOptions = {}, 220 | ) { 221 | const dpr = window.devicePixelRatio; 222 | const width = canvas.clientWidth; 223 | const height = canvas.clientHeight; 224 | if (!(canvas.width === width * dpr && canvas.height === height * dpr)) { 225 | canvas.width = width * dpr; 226 | canvas.height = height * dpr; 227 | } 228 | if (options.lang) canvas.lang = options.lang; 229 | const ctx = canvas.getContext("2d"); 230 | if (!ctx) { 231 | console.error("Failed to initialize canvas2d context."); 232 | return; 233 | } 234 | ctx.setTransform(dpr, 0, 0, dpr, 0, 0); 235 | return this.drawContext(ctx, width, height, latlng, displayZoom); 236 | } 237 | 238 | async drawContextBounds( 239 | ctx: CanvasRenderingContext2D, 240 | topLeft: Point, 241 | bottomRight: Point, 242 | width: number, 243 | height: number, 244 | ) { 245 | const deltaDegrees = bottomRight.x - topLeft.x; 246 | const center = new Point( 247 | (topLeft.x + bottomRight.x) / 2, 248 | (topLeft.y + bottomRight.y) / 2, 249 | ); 250 | return this.drawContext( 251 | ctx, 252 | width, 253 | height, 254 | center, 255 | getZoom(deltaDegrees, width), 256 | ); 257 | } 258 | 259 | async drawCanvasBounds( 260 | canvas: HTMLCanvasElement, 261 | topLeft: Point, 262 | bottomRight: Point, 263 | width: number, 264 | options: StaticOptions = {}, 265 | ) { 266 | const deltaDegrees = bottomRight.x - topLeft.x; 267 | const center = new Point( 268 | (topLeft.x + bottomRight.x) / 2, 269 | (topLeft.y + bottomRight.y) / 2, 270 | ); 271 | return this.drawCanvas( 272 | canvas, 273 | center, 274 | getZoom(deltaDegrees, width), 275 | options, 276 | ); 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./frontends/static"; 2 | export * from "./frontends/leaflet"; 3 | export * from "./symbolizer"; 4 | export * from "./task"; 5 | export * from "./default_style/style"; 6 | export * from "./painter"; 7 | export * from "./tilecache"; 8 | export * from "./view"; 9 | export * from "./labeler"; 10 | -------------------------------------------------------------------------------- /src/labeler.ts: -------------------------------------------------------------------------------- 1 | import Point from "@mapbox/point-geometry"; 2 | import rBush from "rbush"; 3 | import { Filter } from "./painter"; 4 | import { DrawExtra, LabelSymbolizer } from "./symbolizer"; 5 | import { Bbox, JsonObject, toIndex } from "./tilecache"; 6 | import { PreparedTile, transformGeom } from "./view"; 7 | 8 | type TileInvalidationCallback = (tiles: Set) => void; 9 | 10 | // the anchor should be contained within, or on the boundary of, 11 | // one of the bounding boxes. This is not enforced by library, 12 | // but is required for label deduplication. 13 | export interface Label { 14 | anchor: Point; 15 | bboxes: Bbox[]; 16 | draw: (ctx: CanvasRenderingContext2D, drawExtra?: DrawExtra) => void; 17 | deduplicationKey?: string; 18 | deduplicationDistance?: number; 19 | } 20 | 21 | export interface IndexedLabel { 22 | anchor: Point; 23 | bboxes: Bbox[]; 24 | draw: (ctx: CanvasRenderingContext2D) => void; 25 | order: number; 26 | tileKey: string; 27 | deduplicationKey?: string; 28 | deduplicationDistance?: number; 29 | } 30 | 31 | type TreeItem = Bbox & { indexedLabel: IndexedLabel }; 32 | 33 | export interface Layout { 34 | index: Index; 35 | order: number; 36 | scratch: CanvasRenderingContext2D; 37 | zoom: number; 38 | overzoom: number; 39 | } 40 | 41 | export interface LabelRule { 42 | id?: string; 43 | minzoom?: number; 44 | maxzoom?: number; 45 | dataSource?: string; 46 | dataLayer: string; 47 | symbolizer: LabelSymbolizer; 48 | filter?: Filter; 49 | visible?: boolean; 50 | sort?: (a: JsonObject, b: JsonObject) => number; 51 | } 52 | 53 | export const covering = ( 54 | displayZoom: number, 55 | tileWidth: number, 56 | bbox: Bbox, 57 | ) => { 58 | const res = 256; 59 | const f = tileWidth / res; 60 | 61 | const minx = Math.floor(bbox.minX / res); 62 | const miny = Math.floor(bbox.minY / res); 63 | const maxx = Math.floor(bbox.maxX / res); 64 | const maxy = Math.floor(bbox.maxY / res); 65 | const leveldiff = Math.log2(f); 66 | 67 | const retval = []; 68 | for (let x = minx; x <= maxx; x++) { 69 | const wrappedX = x % (1 << displayZoom); 70 | for (let y = miny; y <= maxy; y++) { 71 | retval.push({ 72 | display: toIndex({ z: displayZoom, x: wrappedX, y: y }), 73 | key: toIndex({ 74 | z: displayZoom - leveldiff, 75 | x: Math.floor(wrappedX / f), 76 | y: Math.floor(y / f), 77 | }), 78 | }); 79 | } 80 | } 81 | return retval; 82 | }; 83 | 84 | export class Index { 85 | tree: rBush; 86 | current: Map>; 87 | dim: number; 88 | maxLabeledTiles: number; 89 | 90 | constructor(dim: number, maxLabeledTiles: number) { 91 | this.tree = new rBush(); 92 | this.current = new Map(); 93 | this.dim = dim; 94 | this.maxLabeledTiles = maxLabeledTiles; 95 | } 96 | 97 | public hasPrefix(tileKey: string): boolean { 98 | for (const key of this.current.keys()) { 99 | if (key.startsWith(tileKey)) return true; 100 | } 101 | return false; 102 | } 103 | 104 | public has(tileKey: string): boolean { 105 | return this.current.has(tileKey); 106 | } 107 | 108 | public size(): number { 109 | return this.current.size; 110 | } 111 | 112 | public keys() { 113 | return this.current.keys(); 114 | } 115 | 116 | public searchBbox(bbox: Bbox, order: number): Set { 117 | const labels = new Set(); 118 | for (const match of this.tree.search(bbox)) { 119 | if (match.indexedLabel.order <= order) { 120 | labels.add(match.indexedLabel); 121 | } 122 | } 123 | return labels; 124 | } 125 | 126 | public searchLabel(label: Label, order: number): Set { 127 | const labels = new Set(); 128 | for (const bbox of label.bboxes) { 129 | for (const match of this.tree.search(bbox)) { 130 | if (match.indexedLabel.order <= order) { 131 | labels.add(match.indexedLabel); 132 | } 133 | } 134 | } 135 | return labels; 136 | } 137 | 138 | public bboxCollides(bbox: Bbox, order: number): boolean { 139 | for (const match of this.tree.search(bbox)) { 140 | if (match.indexedLabel.order <= order) return true; 141 | } 142 | return false; 143 | } 144 | 145 | public labelCollides(label: Label, order: number): boolean { 146 | for (const bbox of label.bboxes) { 147 | for (const match of this.tree.search(bbox)) { 148 | if (match.indexedLabel.order <= order) return true; 149 | } 150 | } 151 | return false; 152 | } 153 | 154 | public deduplicationCollides(label: Label): boolean { 155 | // create a bbox around anchor to find potential matches. 156 | // this is depending on precondition: (anchor is contained within, or on boundary of, a label bbox) 157 | if (!label.deduplicationKey || !label.deduplicationDistance) return false; 158 | const dist = label.deduplicationDistance; 159 | const testBbox = { 160 | minX: label.anchor.x - dist, 161 | minY: label.anchor.y - dist, 162 | maxX: label.anchor.x + dist, 163 | maxY: label.anchor.y + dist, 164 | }; 165 | for (const collision of this.tree.search(testBbox)) { 166 | if (collision.indexedLabel.deduplicationKey === label.deduplicationKey) { 167 | if (collision.indexedLabel.anchor.dist(label.anchor) < dist) { 168 | return true; 169 | } 170 | } 171 | } 172 | return false; 173 | } 174 | 175 | public makeEntry(tileKey: string) { 176 | if (this.current.get(tileKey)) { 177 | console.log("consistency error 1"); 178 | } 179 | const newSet = new Set(); 180 | this.current.set(tileKey, newSet); 181 | } 182 | 183 | // can put in multiple due to antimeridian wrapping 184 | public insert(label: Label, order: number, tileKey: string): void { 185 | const indexedLabel = { 186 | anchor: label.anchor, 187 | bboxes: label.bboxes, 188 | draw: label.draw, 189 | order: order, 190 | tileKey: tileKey, 191 | deduplicationKey: label.deduplicationKey, 192 | deduplicationDistance: label.deduplicationDistance, 193 | }; 194 | let entry = this.current.get(tileKey); 195 | if (!entry) { 196 | const newSet = new Set(); 197 | this.current.set(tileKey, newSet); 198 | entry = newSet; 199 | } 200 | entry.add(indexedLabel); 201 | 202 | let wrapsLeft = false; 203 | let wrapsRight = false; 204 | for (const bbox of label.bboxes) { 205 | this.tree.insert({ 206 | minX: bbox.minX, 207 | minY: bbox.minY, 208 | maxX: bbox.maxX, 209 | maxY: bbox.maxY, 210 | indexedLabel: indexedLabel, 211 | }); 212 | if (bbox.minX < 0) wrapsLeft = true; 213 | if (bbox.maxX > this.dim) wrapsRight = true; 214 | } 215 | 216 | if (wrapsLeft || wrapsRight) { 217 | const shift = wrapsLeft ? this.dim : -this.dim; 218 | 219 | const newBboxes = []; 220 | for (const bbox of label.bboxes) { 221 | newBboxes.push({ 222 | minX: bbox.minX + shift, 223 | minY: bbox.minY, 224 | maxX: bbox.maxX + shift, 225 | maxY: bbox.maxY, 226 | }); 227 | } 228 | const duplicateLabel = { 229 | anchor: new Point(label.anchor.x + shift, label.anchor.y), 230 | bboxes: newBboxes, 231 | draw: label.draw, 232 | order: order, 233 | tileKey: tileKey, 234 | }; 235 | const entry = this.current.get(tileKey); 236 | if (entry) entry.add(duplicateLabel); 237 | for (const bbox of newBboxes) { 238 | this.tree.insert({ 239 | minX: bbox.minX, 240 | minY: bbox.minY, 241 | maxX: bbox.maxX, 242 | maxY: bbox.maxY, 243 | indexedLabel: duplicateLabel, 244 | }); 245 | } 246 | } 247 | } 248 | 249 | public pruneOrNoop(keyAdded: string) { 250 | const added = keyAdded.split(":"); 251 | let maxKey = undefined; 252 | let maxDist = 0; 253 | let keysForDs = 0; 254 | 255 | for (const existingKey of this.current.keys()) { 256 | const existing = existingKey.split(":"); 257 | if (existing[3] === added[3]) { 258 | keysForDs++; 259 | const dist = Math.sqrt( 260 | (+existing[0] - +added[0]) ** 2 + (+existing[1] - +added[1]) ** 2, 261 | ); 262 | if (dist > maxDist) { 263 | maxDist = dist; 264 | maxKey = existingKey; 265 | } 266 | } 267 | 268 | if (maxKey && keysForDs > this.maxLabeledTiles) { 269 | this.pruneKey(maxKey); 270 | } 271 | } 272 | } 273 | 274 | public pruneKey(keyToRemove: string): void { 275 | const indexedLabels = this.current.get(keyToRemove); 276 | if (!indexedLabels) return; // TODO: not that clean... 277 | const entriesToDelete = []; 278 | for (const entry of this.tree.all()) { 279 | if (indexedLabels.has(entry.indexedLabel)) { 280 | entriesToDelete.push(entry); 281 | } 282 | } 283 | for (const entry of entriesToDelete) { 284 | this.tree.remove(entry); 285 | } 286 | this.current.delete(keyToRemove); 287 | } 288 | 289 | // NOTE: technically this is incorrect 290 | // with antimeridian wrapping, since we should also remove 291 | // the duplicate label; but i am having a hard time 292 | // imagining where this will happen in practical usage 293 | public removeLabel(labelToRemove: IndexedLabel): void { 294 | const entriesToDelete = []; 295 | for (const entry of this.tree.all()) { 296 | if (labelToRemove === entry.indexedLabel) { 297 | entriesToDelete.push(entry); 298 | } 299 | } 300 | for (const entry of entriesToDelete) { 301 | this.tree.remove(entry); 302 | } 303 | const c = this.current.get(labelToRemove.tileKey); 304 | if (c) c.delete(labelToRemove); 305 | } 306 | } 307 | 308 | export class Labeler { 309 | index: Index; 310 | z: number; 311 | scratch: CanvasRenderingContext2D; 312 | labelRules: LabelRule[]; 313 | callback?: TileInvalidationCallback; 314 | 315 | constructor( 316 | z: number, 317 | scratch: CanvasRenderingContext2D, 318 | labelRules: LabelRule[], 319 | maxLabeledTiles: number, 320 | callback?: TileInvalidationCallback, 321 | ) { 322 | this.index = new Index((256 * 1) << z, maxLabeledTiles); 323 | this.z = z; 324 | this.scratch = scratch; 325 | this.labelRules = labelRules; 326 | this.callback = callback; 327 | } 328 | 329 | private layout(preparedTilemap: Map): number { 330 | const start = performance.now(); 331 | 332 | const keysAdding = new Set(); 333 | // if it already exists... short circuit 334 | for (const [k, preparedTiles] of preparedTilemap) { 335 | for (const preparedTile of preparedTiles) { 336 | const key = `${toIndex(preparedTile.dataTile)}:${k}`; 337 | if (!this.index.has(key)) { 338 | this.index.makeEntry(key); 339 | keysAdding.add(key); 340 | } 341 | } 342 | } 343 | 344 | const tilesInvalidated = new Set(); 345 | for (const [order, rule] of this.labelRules.entries()) { 346 | if (rule.visible === false) continue; 347 | if (rule.minzoom && this.z < rule.minzoom) continue; 348 | if (rule.maxzoom && this.z > rule.maxzoom) continue; 349 | 350 | const dsName = rule.dataSource || ""; 351 | const preparedTiles = preparedTilemap.get(dsName); 352 | if (!preparedTiles) continue; 353 | 354 | for (const preparedTile of preparedTiles) { 355 | const key = `${toIndex(preparedTile.dataTile)}:${dsName}`; 356 | if (!keysAdding.has(key)) continue; 357 | 358 | const layer = preparedTile.data.get(rule.dataLayer); 359 | if (layer === undefined) continue; 360 | 361 | const feats = layer; 362 | if (rule.sort) 363 | feats.sort((a, b) => { 364 | if (rule.sort) { 365 | return rule.sort(a.props, b.props); 366 | } 367 | return 0; 368 | }); 369 | 370 | const layout = { 371 | index: this.index, 372 | zoom: this.z, 373 | scratch: this.scratch, 374 | order: order, 375 | overzoom: this.z - preparedTile.dataTile.z, 376 | }; 377 | for (const feature of feats) { 378 | if (rule.filter && !rule.filter(this.z, feature)) continue; 379 | const transformed = transformGeom( 380 | feature.geom, 381 | preparedTile.scale, 382 | preparedTile.origin, 383 | ); 384 | const labels = rule.symbolizer.place(layout, transformed, feature); 385 | if (!labels) continue; 386 | 387 | for (const label of labels) { 388 | let labelAdded = false; 389 | if ( 390 | label.deduplicationKey && 391 | this.index.deduplicationCollides(label) 392 | ) { 393 | continue; 394 | } 395 | 396 | // does the label collide with anything? 397 | if (this.index.labelCollides(label, Infinity)) { 398 | if (!this.index.labelCollides(label, order)) { 399 | const conflicts = this.index.searchLabel(label, Infinity); 400 | for (const conflict of conflicts) { 401 | this.index.removeLabel(conflict); 402 | for (const bbox of conflict.bboxes) { 403 | this.findInvalidatedTiles( 404 | tilesInvalidated, 405 | preparedTile.dim, 406 | bbox, 407 | key, 408 | ); 409 | } 410 | } 411 | this.index.insert(label, order, key); 412 | labelAdded = true; 413 | } 414 | // label not added. 415 | } else { 416 | this.index.insert(label, order, key); 417 | labelAdded = true; 418 | } 419 | 420 | if (labelAdded) { 421 | for (const bbox of label.bboxes) { 422 | if ( 423 | bbox.maxX > preparedTile.origin.x + preparedTile.dim || 424 | bbox.minX < preparedTile.origin.x || 425 | bbox.minY < preparedTile.origin.y || 426 | bbox.maxY > preparedTile.origin.y + preparedTile.dim 427 | ) { 428 | this.findInvalidatedTiles( 429 | tilesInvalidated, 430 | preparedTile.dim, 431 | bbox, 432 | key, 433 | ); 434 | } 435 | } 436 | } 437 | } 438 | } 439 | } 440 | } 441 | 442 | for (const key of keysAdding) { 443 | this.index.pruneOrNoop(key); 444 | } 445 | 446 | if (tilesInvalidated.size > 0 && this.callback) { 447 | this.callback(tilesInvalidated); 448 | } 449 | return performance.now() - start; 450 | } 451 | 452 | private findInvalidatedTiles( 453 | tilesInvalidated: Set, 454 | dim: number, 455 | bbox: Bbox, 456 | key: string, 457 | ) { 458 | const touched = covering(this.z, dim, bbox); 459 | for (const s of touched) { 460 | if (s.key !== key && this.index.hasPrefix(s.key)) { 461 | tilesInvalidated.add(s.display); 462 | } 463 | } 464 | } 465 | 466 | public add(preparedTilemap: Map): number { 467 | let allAdded = true; 468 | for (const [k, preparedTiles] of preparedTilemap) { 469 | for (const preparedTile of preparedTiles) { 470 | if (!this.index.has(`${toIndex(preparedTile.dataTile)}:${k}`)) 471 | allAdded = false; 472 | } 473 | } 474 | 475 | if (allAdded) { 476 | return 0; 477 | } 478 | const timing = this.layout(preparedTilemap); 479 | return timing; 480 | } 481 | } 482 | 483 | export class Labelers { 484 | labelers: Map; 485 | scratch: CanvasRenderingContext2D; 486 | labelRules: LabelRule[]; 487 | maxLabeledTiles: number; 488 | callback: TileInvalidationCallback; 489 | 490 | constructor( 491 | scratch: CanvasRenderingContext2D, 492 | labelRules: LabelRule[], 493 | maxLabeledTiles: number, 494 | callback: TileInvalidationCallback, 495 | ) { 496 | this.labelers = new Map(); 497 | this.scratch = scratch; 498 | this.labelRules = labelRules; 499 | this.maxLabeledTiles = maxLabeledTiles; 500 | this.callback = callback; 501 | } 502 | 503 | public add(z: number, preparedTilemap: Map): number { 504 | let labeler = this.labelers.get(z); 505 | if (labeler) { 506 | return labeler.add(preparedTilemap); 507 | } 508 | labeler = new Labeler( 509 | z, 510 | this.scratch, 511 | this.labelRules, 512 | this.maxLabeledTiles, 513 | this.callback, 514 | ); 515 | this.labelers.set(z, labeler); 516 | return labeler.add(preparedTilemap); 517 | } 518 | 519 | public getIndex(z: number) { 520 | const labeler = this.labelers.get(z); 521 | if (labeler) return labeler.index; // TODO cleanup 522 | } 523 | } 524 | -------------------------------------------------------------------------------- /src/line.ts: -------------------------------------------------------------------------------- 1 | import Point from "@mapbox/point-geometry"; 2 | 3 | export interface LabelableSegment { 4 | length: number; 5 | beginIndex: number; 6 | beginDistance: number; 7 | endIndex: number; 8 | endDistance: number; 9 | } 10 | 11 | // code from https://github.com/naturalatlas/linelabel (Apache2) 12 | const linelabel = ( 13 | pts: Point[], 14 | maxAngleDelta: number, 15 | targetLen: number, 16 | ): LabelableSegment[] => { 17 | const chunks = []; 18 | let a: Point; 19 | let b: Point; 20 | let c: Point; 21 | let i = 0; 22 | let n = 0; 23 | let d = 0; 24 | let abmag = 0; 25 | let bcmag = 0; 26 | let abx = 0; 27 | let aby = 0; 28 | let bcx = 0; 29 | let bcy = 0; 30 | let dt = 0; 31 | let iStart = 0; 32 | let dStart = 0; 33 | 34 | if (pts.length < 2) return []; 35 | if (pts.length === 2) { 36 | d = Math.sqrt((pts[1].x - pts[0].x) ** 2 + (pts[1].y - pts[0].y) ** 2); 37 | 38 | return [ 39 | { 40 | length: d, 41 | beginIndex: 0, 42 | beginDistance: 0, 43 | endIndex: 2, 44 | endDistance: d, 45 | }, 46 | ]; 47 | } 48 | 49 | abmag = Math.sqrt((pts[1].x - pts[0].x) ** 2 + (pts[1].y - pts[0].y) ** 2); 50 | for (i = 1, n = pts.length - 1; i < n; i++) { 51 | a = pts[i - 1]; 52 | b = pts[i]; 53 | c = pts[i + 1]; 54 | abx = b.x - a.x; 55 | aby = b.y - a.y; 56 | bcx = c.x - b.x; 57 | bcy = c.y - b.y; 58 | bcmag = Math.sqrt(bcx * bcx + bcy * bcy); 59 | d += abmag; 60 | 61 | dt = Math.acos((abx * bcx + aby * bcy) / (abmag * bcmag)); 62 | if (dt > maxAngleDelta || d - dStart > targetLen) { 63 | chunks.push({ 64 | length: d - dStart, 65 | beginDistance: dStart, 66 | beginIndex: iStart, 67 | endIndex: i + 1, 68 | endDistance: d, 69 | }); 70 | iStart = i; 71 | dStart = d; 72 | } 73 | abmag = bcmag; 74 | } 75 | 76 | if (i - iStart > 0) { 77 | chunks.push({ 78 | length: d - dStart + bcmag, 79 | beginIndex: iStart, 80 | beginDistance: dStart, 81 | endIndex: i + 1, 82 | endDistance: d + bcmag, 83 | }); 84 | } 85 | return chunks; 86 | }; 87 | 88 | export interface LabelCandidate { 89 | start: Point; 90 | end: Point; 91 | } 92 | 93 | export function simpleLabel( 94 | mls: Point[][], 95 | minimum: number, 96 | repeatDistance: number, 97 | cellSize: number, 98 | ): LabelCandidate[] { 99 | const candidates = []; 100 | 101 | for (const ls of mls) { 102 | const segments = linelabel(ls, Math.PI / 45, minimum); // 4 degrees, close to a straight line 103 | for (const segment of segments) { 104 | if (segment.length >= minimum + cellSize) { 105 | const start = new Point( 106 | ls[segment.beginIndex].x, 107 | ls[segment.beginIndex].y, 108 | ); 109 | const end = ls[segment.endIndex - 1]; 110 | const normalized = new Point( 111 | (end.x - start.x) / segment.length, 112 | (end.y - start.y) / segment.length, 113 | ); 114 | 115 | // offset from the start by cellSize to allow streets that meet at right angles 116 | // to both be labeled. 117 | for ( 118 | let i = cellSize; 119 | i < segment.length - minimum; 120 | i += repeatDistance 121 | ) { 122 | candidates.push({ 123 | start: start.add(normalized.mult(i)), 124 | end: start.add(normalized.mult(i + minimum)), 125 | }); 126 | } 127 | } 128 | } 129 | } 130 | 131 | return candidates; 132 | } 133 | 134 | export function lineCells(a: Point, b: Point, length: number, spacing: number) { 135 | // determine function of line 136 | const dx = b.x - a.x; 137 | const dy = b.y - a.y; 138 | const dist = Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2); 139 | 140 | const retval = []; 141 | // starting from the anchor, generate square cells, 142 | // guaranteeing to cover the endpoint 143 | for (let i = 0; i < length + spacing; i += 2 * spacing) { 144 | const factor = (i * 1) / dist; 145 | retval.push({ x: a.x + factor * dx, y: a.y + factor * dy }); 146 | } 147 | return retval; 148 | } 149 | -------------------------------------------------------------------------------- /src/painter.ts: -------------------------------------------------------------------------------- 1 | import Point from "@mapbox/point-geometry"; 2 | import { Index } from "./labeler"; 3 | import { PaintSymbolizer } from "./symbolizer"; 4 | import { Bbox, Feature } from "./tilecache"; 5 | import { PreparedTile, transformGeom } from "./view"; 6 | 7 | export type Filter = (zoom: number, feature: Feature) => boolean; 8 | 9 | export interface PaintRule { 10 | id?: string; 11 | minzoom?: number; 12 | maxzoom?: number; 13 | dataSource?: string; 14 | dataLayer: string; 15 | symbolizer: PaintSymbolizer; 16 | filter?: Filter; 17 | } 18 | 19 | export function paint( 20 | ctx: CanvasRenderingContext2D, 21 | z: number, 22 | preparedTilemap: Map, 23 | labelData: Index | null, 24 | rules: PaintRule[], 25 | bbox: Bbox, 26 | origin: Point, 27 | clip: boolean, 28 | debug?: string, 29 | ) { 30 | const start = performance.now(); 31 | ctx.save(); 32 | ctx.miterLimit = 2; 33 | 34 | for (const rule of rules) { 35 | if (rule.minzoom && z < rule.minzoom) continue; 36 | if (rule.maxzoom && z > rule.maxzoom) continue; 37 | const preparedTiles = preparedTilemap.get(rule.dataSource || ""); 38 | if (!preparedTiles) continue; 39 | for (const preparedTile of preparedTiles) { 40 | const layer = preparedTile.data.get(rule.dataLayer); 41 | if (layer === undefined) continue; 42 | if (rule.symbolizer.before) rule.symbolizer.before(ctx, preparedTile.z); 43 | 44 | const po = preparedTile.origin; 45 | const dim = preparedTile.dim; 46 | const ps = preparedTile.scale; 47 | ctx.save(); 48 | 49 | // apply clipping to the tile 50 | // find the smallest of all the origins 51 | if (clip) { 52 | ctx.beginPath(); 53 | const minX = Math.max(po.x - origin.x, bbox.minX - origin.x); // - 0.5; 54 | const minY = Math.max(po.y - origin.y, bbox.minY - origin.y); // - 0.5; 55 | const maxX = Math.min(po.x - origin.x + dim, bbox.maxX - origin.x); // + 0.5; 56 | const maxY = Math.min(po.y - origin.y + dim, bbox.maxY - origin.y); // + 0.5; 57 | ctx.rect(minX, minY, maxX - minX, maxY - minY); 58 | ctx.clip(); 59 | } 60 | 61 | ctx.translate(po.x - origin.x, po.y - origin.y); 62 | 63 | // TODO fix seams in static mode 64 | // if (clip) { 65 | // // small fudge factor in static mode to fix seams 66 | // ctx.translate(dim / 2, dim / 2); 67 | // ctx.scale(1 + 1 / dim, 1 + 1 / dim); 68 | // ctx.translate(-dim / 2, -dim / 2); 69 | // } 70 | 71 | for (const feature of layer) { 72 | let geom = feature.geom; 73 | const fbox = feature.bbox; 74 | if ( 75 | fbox.maxX * ps + po.x < bbox.minX || 76 | fbox.minX * ps + po.x > bbox.maxX || 77 | fbox.minY * ps + po.y > bbox.maxY || 78 | fbox.maxY * ps + po.y < bbox.minY 79 | ) { 80 | continue; 81 | } 82 | if (rule.filter && !rule.filter(preparedTile.z, feature)) continue; 83 | if (ps !== 1) { 84 | geom = transformGeom(geom, ps, new Point(0, 0)); 85 | } 86 | rule.symbolizer.draw(ctx, geom, preparedTile.z, feature); 87 | } 88 | ctx.restore(); 89 | } 90 | } 91 | 92 | if (clip) { 93 | ctx.beginPath(); 94 | ctx.rect( 95 | bbox.minX - origin.x, 96 | bbox.minY - origin.y, 97 | bbox.maxX - bbox.minX, 98 | bbox.maxY - bbox.minY, 99 | ); 100 | ctx.clip(); 101 | } 102 | 103 | if (labelData) { 104 | const matches = labelData.searchBbox(bbox, Infinity); 105 | for (const label of matches) { 106 | ctx.save(); 107 | ctx.translate(label.anchor.x - origin.x, label.anchor.y - origin.y); 108 | label.draw(ctx); 109 | ctx.restore(); 110 | if (debug) { 111 | ctx.lineWidth = 0.5; 112 | ctx.strokeStyle = debug; 113 | ctx.fillStyle = debug; 114 | ctx.globalAlpha = 1; 115 | ctx.fillRect( 116 | label.anchor.x - origin.x - 2, 117 | label.anchor.y - origin.y - 2, 118 | 4, 119 | 4, 120 | ); 121 | for (const bbox of label.bboxes) { 122 | ctx.strokeRect( 123 | bbox.minX - origin.x, 124 | bbox.minY - origin.y, 125 | bbox.maxX - bbox.minX, 126 | bbox.maxY - bbox.minY, 127 | ); 128 | } 129 | } 130 | } 131 | } 132 | ctx.restore(); 133 | return performance.now() - start; 134 | } 135 | -------------------------------------------------------------------------------- /src/symbolizer.ts: -------------------------------------------------------------------------------- 1 | import Point from "@mapbox/point-geometry"; 2 | import { 3 | ArrayAttr, 4 | AttrOption, 5 | FontAttr, 6 | FontAttrOptions, 7 | NumberAttr, 8 | StringAttr, 9 | TextAttr, 10 | TextAttrOptions, 11 | } from "./attribute"; 12 | import { Label, Layout } from "./labeler"; 13 | import { lineCells, simpleLabel } from "./line"; 14 | import { Sheet } from "./task"; 15 | import { linebreak } from "./text"; 16 | import { Bbox, Feature, GeomType } from "./tilecache"; 17 | 18 | export interface PaintSymbolizer { 19 | before?(ctx: CanvasRenderingContext2D, z: number): void; 20 | draw( 21 | ctx: CanvasRenderingContext2D, 22 | geom: Point[][], 23 | z: number, 24 | feature: Feature, 25 | ): void; 26 | } 27 | 28 | export enum Justify { 29 | Left = 1, 30 | Center = 2, 31 | Right = 3, 32 | } 33 | 34 | export enum TextPlacements { 35 | N = 1, 36 | Ne = 2, 37 | E = 3, 38 | Se = 4, 39 | S = 5, 40 | Sw = 6, 41 | W = 7, 42 | Nw = 8, 43 | } 44 | 45 | export interface DrawExtra { 46 | justify: Justify; 47 | } 48 | 49 | export interface LabelSymbolizer { 50 | /* the symbolizer can, but does not need to, inspect index to determine the right position 51 | * if return undefined, no label is added 52 | * return a label, but if the label collides it is not added 53 | */ 54 | place(layout: Layout, geom: Point[][], feature: Feature): Label[] | undefined; 55 | } 56 | 57 | export const createPattern = ( 58 | width: number, 59 | height: number, 60 | fn: (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => void, 61 | ) => { 62 | const canvas = document.createElement("canvas"); 63 | const ctx = canvas.getContext("2d"); 64 | canvas.width = width; 65 | canvas.height = height; 66 | if (ctx !== null) fn(canvas, ctx); 67 | return canvas; 68 | }; 69 | 70 | export class PolygonSymbolizer implements PaintSymbolizer { 71 | pattern?: CanvasImageSource; 72 | fill: StringAttr; 73 | opacity: NumberAttr; 74 | stroke: StringAttr; 75 | width: NumberAttr; 76 | perFeature: boolean; 77 | doStroke: boolean; 78 | 79 | constructor(options: { 80 | pattern?: CanvasImageSource; 81 | fill?: AttrOption; 82 | opacity?: AttrOption; 83 | stroke?: AttrOption; 84 | width?: AttrOption; 85 | perFeature?: boolean; 86 | }) { 87 | this.pattern = options.pattern; 88 | this.fill = new StringAttr(options.fill, "black"); 89 | this.opacity = new NumberAttr(options.opacity, 1); 90 | this.stroke = new StringAttr(options.stroke, "black"); 91 | this.width = new NumberAttr(options.width, 0); 92 | this.perFeature = 93 | (this.fill.perFeature || 94 | this.opacity.perFeature || 95 | this.stroke.perFeature || 96 | this.width.perFeature || 97 | options.perFeature) ?? 98 | false; 99 | this.doStroke = false; 100 | } 101 | 102 | public before(ctx: CanvasRenderingContext2D, z: number) { 103 | if (!this.perFeature) { 104 | ctx.globalAlpha = this.opacity.get(z); 105 | ctx.fillStyle = this.fill.get(z); 106 | ctx.strokeStyle = this.stroke.get(z); 107 | const width = this.width.get(z); 108 | if (width > 0) this.doStroke = true; 109 | ctx.lineWidth = width; 110 | } 111 | if (this.pattern) { 112 | const patten = ctx.createPattern(this.pattern, "repeat"); 113 | if (patten) ctx.fillStyle = patten; 114 | } 115 | } 116 | 117 | public draw( 118 | ctx: CanvasRenderingContext2D, 119 | geom: Point[][], 120 | z: number, 121 | f: Feature, 122 | ) { 123 | let doStroke = false; 124 | if (this.perFeature) { 125 | ctx.globalAlpha = this.opacity.get(z, f); 126 | ctx.fillStyle = this.fill.get(z, f); 127 | const width = this.width.get(z, f); 128 | if (width) { 129 | doStroke = true; 130 | ctx.strokeStyle = this.stroke.get(z, f); 131 | ctx.lineWidth = width; 132 | } 133 | } 134 | 135 | const drawPath = () => { 136 | ctx.fill(); 137 | if (doStroke || this.doStroke) { 138 | ctx.stroke(); 139 | } 140 | }; 141 | 142 | ctx.beginPath(); 143 | for (const poly of geom) { 144 | for (let p = 0; p < poly.length; p++) { 145 | const pt = poly[p]; 146 | if (p === 0) ctx.moveTo(pt.x, pt.y); 147 | else ctx.lineTo(pt.x, pt.y); 148 | } 149 | } 150 | drawPath(); 151 | } 152 | } 153 | 154 | export function arr(base: number, a: number[]): (z: number) => number { 155 | return (z) => { 156 | const b = z - base; 157 | if (b >= 0 && b < a.length) { 158 | return a[b]; 159 | } 160 | return 0; 161 | }; 162 | } 163 | 164 | function getStopIndex(input: number, stops: number[][]): number { 165 | let idx = 0; 166 | while (stops[idx + 1][0] < input) idx++; 167 | return idx; 168 | } 169 | 170 | function interpolate(factor: number, start: number, end: number): number { 171 | return factor * (end - start) + start; 172 | } 173 | 174 | function computeInterpolationFactor( 175 | z: number, 176 | idx: number, 177 | base: number, 178 | stops: number[][], 179 | ): number { 180 | const difference = stops[idx + 1][0] - stops[idx][0]; 181 | const progress = z - stops[idx][0]; 182 | if (difference === 0) return 0; 183 | if (base === 1) return progress / difference; 184 | return (base ** progress - 1) / (base ** difference - 1); 185 | } 186 | 187 | export function exp(base: number, stops: number[][]): (z: number) => number { 188 | return (z) => { 189 | if (stops.length < 1) return 0; 190 | if (z <= stops[0][0]) return stops[0][1]; 191 | if (z >= stops[stops.length - 1][0]) return stops[stops.length - 1][1]; 192 | const idx = getStopIndex(z, stops); 193 | const factor = computeInterpolationFactor(z, idx, base, stops); 194 | return interpolate(factor, stops[idx][1], stops[idx + 1][1]); 195 | }; 196 | } 197 | 198 | export type Stop = [number, number] | [number, string] | [number, boolean]; 199 | export function step( 200 | output0: number | string | boolean, 201 | stops: Stop[], 202 | ): (z: number) => number | string | boolean { 203 | // Step computes discrete results by evaluating a piecewise-constant 204 | // function defined by stops. 205 | // Returns the output value of the stop with a stop input value just less than 206 | // the input one. If the input value is less than the input of the first stop, 207 | // output0 is returned 208 | return (z) => { 209 | if (stops.length < 1) return 0; 210 | let retval = output0; 211 | for (let i = 0; i < stops.length; i++) { 212 | if (z >= stops[i][0]) retval = stops[i][1]; 213 | } 214 | return retval; 215 | }; 216 | } 217 | 218 | export function linear(stops: number[][]): (z: number) => number { 219 | return exp(1, stops); 220 | } 221 | 222 | export class LineSymbolizer implements PaintSymbolizer { 223 | color: StringAttr; 224 | width: NumberAttr; 225 | opacity: NumberAttr; 226 | dash: ArrayAttr | null; 227 | dashColor: StringAttr; 228 | dashWidth: NumberAttr; 229 | skip: boolean; 230 | perFeature: boolean; 231 | lineCap: StringAttr; 232 | lineJoin: StringAttr; 233 | 234 | constructor(options: { 235 | color?: AttrOption; 236 | width?: AttrOption; 237 | opacity?: AttrOption; 238 | dash?: number[]; 239 | dashColor?: AttrOption; 240 | dashWidth?: AttrOption; 241 | skip?: boolean; 242 | perFeature?: boolean; 243 | lineCap?: AttrOption; 244 | lineJoin?: AttrOption; 245 | }) { 246 | this.color = new StringAttr(options.color, "black"); 247 | this.width = new NumberAttr(options.width); 248 | this.opacity = new NumberAttr(options.opacity); 249 | this.dash = options.dash ? new ArrayAttr(options.dash) : null; 250 | this.dashColor = new StringAttr(options.dashColor, "black"); 251 | this.dashWidth = new NumberAttr(options.dashWidth, 1.0); 252 | this.lineCap = new StringAttr(options.lineCap, "butt"); 253 | this.lineJoin = new StringAttr(options.lineJoin, "miter"); 254 | this.skip = false; 255 | this.perFeature = !!( 256 | this.dash?.perFeature || 257 | this.color.perFeature || 258 | this.opacity.perFeature || 259 | this.width.perFeature || 260 | this.lineCap.perFeature || 261 | this.lineJoin.perFeature || 262 | options.perFeature 263 | ); 264 | } 265 | 266 | public before(ctx: CanvasRenderingContext2D, z: number) { 267 | if (!this.perFeature) { 268 | ctx.strokeStyle = this.color.get(z); 269 | ctx.lineWidth = this.width.get(z); 270 | ctx.globalAlpha = this.opacity.get(z); 271 | ctx.lineCap = this.lineCap.get(z); 272 | ctx.lineJoin = this.lineJoin.get(z); 273 | } 274 | } 275 | 276 | public draw( 277 | ctx: CanvasRenderingContext2D, 278 | geom: Point[][], 279 | z: number, 280 | f: Feature, 281 | ) { 282 | if (this.skip) return; 283 | 284 | const strokePath = () => { 285 | if (this.perFeature) { 286 | ctx.globalAlpha = this.opacity.get(z, f); 287 | ctx.lineCap = this.lineCap.get(z, f); 288 | ctx.lineJoin = this.lineJoin.get(z, f); 289 | } 290 | if (this.dash) { 291 | ctx.save(); 292 | if (this.perFeature) { 293 | ctx.lineWidth = this.dashWidth.get(z, f); 294 | ctx.strokeStyle = this.dashColor.get(z, f); 295 | ctx.setLineDash(this.dash.get(z, f)); 296 | } else { 297 | ctx.setLineDash(this.dash.get(z)); 298 | } 299 | ctx.stroke(); 300 | ctx.restore(); 301 | } else { 302 | ctx.save(); 303 | if (this.perFeature) { 304 | ctx.lineWidth = this.width.get(z, f); 305 | ctx.strokeStyle = this.color.get(z, f); 306 | } 307 | ctx.stroke(); 308 | ctx.restore(); 309 | } 310 | }; 311 | 312 | ctx.beginPath(); 313 | for (const ls of geom) { 314 | for (let p = 0; p < ls.length; p++) { 315 | const pt = ls[p]; 316 | if (p === 0) ctx.moveTo(pt.x, pt.y); 317 | else ctx.lineTo(pt.x, pt.y); 318 | } 319 | } 320 | strokePath(); 321 | } 322 | } 323 | 324 | export interface IconSymbolizerOptions { 325 | name: string; 326 | sheet: Sheet; 327 | } 328 | export class IconSymbolizer implements LabelSymbolizer { 329 | name: string; 330 | sheet: Sheet; 331 | dpr: number; 332 | 333 | constructor(options: IconSymbolizerOptions) { 334 | this.name = options.name; 335 | this.sheet = options.sheet; 336 | this.dpr = window.devicePixelRatio; 337 | } 338 | 339 | public place(layout: Layout, geom: Point[][], feature: Feature) { 340 | const pt = geom[0]; 341 | const a = new Point(geom[0][0].x, geom[0][0].y); 342 | const loc = this.sheet.get(this.name); 343 | const width = loc.w / this.dpr; 344 | const height = loc.h / this.dpr; 345 | 346 | const bbox = { 347 | minX: a.x - width / 2, 348 | minY: a.y - height / 2, 349 | maxX: a.x + width / 2, 350 | maxY: a.y + height / 2, 351 | }; 352 | 353 | const draw = (ctx: CanvasRenderingContext2D) => { 354 | ctx.globalAlpha = 1; 355 | ctx.drawImage( 356 | this.sheet.canvas, 357 | loc.x, 358 | loc.y, 359 | loc.w, 360 | loc.h, 361 | -loc.w / 2 / this.dpr, 362 | -loc.h / 2 / this.dpr, 363 | loc.w / 2, 364 | loc.h / 2, 365 | ); 366 | }; 367 | return [{ anchor: a, bboxes: [bbox], draw: draw }]; 368 | } 369 | } 370 | 371 | export class CircleSymbolizer implements LabelSymbolizer, PaintSymbolizer { 372 | radius: NumberAttr; 373 | fill: StringAttr; 374 | stroke: StringAttr; 375 | width: NumberAttr; 376 | opacity: NumberAttr; 377 | 378 | constructor(options: { 379 | radius?: AttrOption; 380 | fill?: AttrOption; 381 | stroke?: AttrOption; 382 | width?: AttrOption; 383 | opacity?: AttrOption; 384 | }) { 385 | this.radius = new NumberAttr(options.radius, 3); 386 | this.fill = new StringAttr(options.fill, "black"); 387 | this.stroke = new StringAttr(options.stroke, "white"); 388 | this.width = new NumberAttr(options.width, 0); 389 | this.opacity = new NumberAttr(options.opacity); 390 | } 391 | 392 | public draw( 393 | ctx: CanvasRenderingContext2D, 394 | geom: Point[][], 395 | z: number, 396 | f: Feature, 397 | ) { 398 | ctx.globalAlpha = this.opacity.get(z, f); 399 | 400 | const radius = this.radius.get(z, f); 401 | const width = this.width.get(z, f); 402 | if (width > 0) { 403 | ctx.strokeStyle = this.stroke.get(z, f); 404 | ctx.lineWidth = width; 405 | ctx.beginPath(); 406 | ctx.arc(geom[0][0].x, geom[0][0].y, radius + width / 2, 0, 2 * Math.PI); 407 | ctx.stroke(); 408 | } 409 | 410 | ctx.fillStyle = this.fill.get(z, f); 411 | ctx.beginPath(); 412 | ctx.arc(geom[0][0].x, geom[0][0].y, radius, 0, 2 * Math.PI); 413 | ctx.fill(); 414 | } 415 | 416 | public place(layout: Layout, geom: Point[][], feature: Feature) { 417 | const pt = geom[0]; 418 | const a = new Point(geom[0][0].x, geom[0][0].y); 419 | const radius = this.radius.get(layout.zoom, feature); 420 | const bbox = { 421 | minX: a.x - radius, 422 | minY: a.y - radius, 423 | maxX: a.x + radius, 424 | maxY: a.y + radius, 425 | }; 426 | 427 | const draw = (ctx: CanvasRenderingContext2D) => { 428 | this.draw(ctx, [[new Point(0, 0)]], layout.zoom, feature); 429 | }; 430 | return [{ anchor: a, bboxes: [bbox], draw }]; 431 | } 432 | } 433 | 434 | export class ShieldSymbolizer implements LabelSymbolizer { 435 | font: FontAttr; 436 | text: TextAttr; 437 | background: StringAttr; 438 | fill: StringAttr; 439 | padding: NumberAttr; 440 | 441 | constructor( 442 | options: { 443 | fill?: AttrOption; 444 | background?: AttrOption; 445 | padding?: AttrOption; 446 | } & FontAttrOptions & 447 | TextAttrOptions, 448 | ) { 449 | this.font = new FontAttr(options); 450 | this.text = new TextAttr(options); 451 | this.fill = new StringAttr(options.fill, "black"); 452 | this.background = new StringAttr(options.background, "white"); 453 | this.padding = new NumberAttr(options.padding, 0); // TODO check falsy 454 | } 455 | 456 | public place(layout: Layout, geom: Point[][], f: Feature) { 457 | const property = this.text.get(layout.zoom, f); 458 | if (!property) return undefined; 459 | const font = this.font.get(layout.zoom, f); 460 | layout.scratch.font = font; 461 | const metrics = layout.scratch.measureText(property); 462 | 463 | const width = metrics.width; 464 | const ascent = metrics.actualBoundingBoxAscent; 465 | const descent = metrics.actualBoundingBoxDescent; 466 | 467 | const pt = geom[0]; 468 | const a = new Point(geom[0][0].x, geom[0][0].y); 469 | const p = this.padding.get(layout.zoom, f); 470 | const bbox = { 471 | minX: a.x - width / 2 - p, 472 | minY: a.y - ascent - p, 473 | maxX: a.x + width / 2 + p, 474 | maxY: a.y + descent + p, 475 | }; 476 | 477 | const draw = (ctx: CanvasRenderingContext2D) => { 478 | ctx.globalAlpha = 1; 479 | ctx.fillStyle = this.background.get(layout.zoom, f); 480 | ctx.fillRect( 481 | -width / 2 - p, 482 | -ascent - p, 483 | width + 2 * p, 484 | ascent + descent + 2 * p, 485 | ); 486 | ctx.fillStyle = this.fill.get(layout.zoom, f); 487 | ctx.font = font; 488 | ctx.fillText(property, -width / 2, 0); 489 | }; 490 | return [{ anchor: a, bboxes: [bbox], draw: draw }]; 491 | } 492 | } 493 | 494 | // TODO make me work with multiple anchors 495 | export class FlexSymbolizer implements LabelSymbolizer { 496 | list: LabelSymbolizer[]; 497 | 498 | constructor(list: LabelSymbolizer[]) { 499 | this.list = list; 500 | } 501 | 502 | public place(layout: Layout, geom: Point[][], feature: Feature) { 503 | let labels = this.list[0].place(layout, geom, feature); 504 | if (!labels) return undefined; 505 | let label = labels[0]; 506 | const anchor = label.anchor; 507 | let bbox = label.bboxes[0]; 508 | const height = bbox.maxY - bbox.minY; 509 | const draws = [{ draw: label.draw, translate: { x: 0, y: 0 } }]; 510 | 511 | const newGeom = [[new Point(geom[0][0].x, geom[0][0].y + height)]]; 512 | for (let i = 1; i < this.list.length; i++) { 513 | labels = this.list[i].place(layout, newGeom, feature); 514 | if (labels) { 515 | label = labels[0]; 516 | bbox = mergeBbox(bbox, label.bboxes[0]); 517 | draws.push({ draw: label.draw, translate: { x: 0, y: height } }); 518 | } 519 | } 520 | 521 | const draw = (ctx: CanvasRenderingContext2D) => { 522 | for (const sub of draws) { 523 | ctx.save(); 524 | ctx.translate(sub.translate.x, sub.translate.y); 525 | sub.draw(ctx); 526 | ctx.restore(); 527 | } 528 | }; 529 | 530 | return [{ anchor: anchor, bboxes: [bbox], draw: draw }]; 531 | } 532 | } 533 | 534 | const mergeBbox = (b1: Bbox, b2: Bbox) => { 535 | return { 536 | minX: Math.min(b1.minX, b2.minX), 537 | minY: Math.min(b1.minY, b2.minY), 538 | maxX: Math.max(b1.maxX, b2.maxX), 539 | maxY: Math.max(b1.maxY, b2.maxY), 540 | }; 541 | }; 542 | 543 | export class GroupSymbolizer implements LabelSymbolizer { 544 | list: LabelSymbolizer[]; 545 | 546 | constructor(list: LabelSymbolizer[]) { 547 | this.list = list; 548 | } 549 | 550 | public place(layout: Layout, geom: Point[][], feature: Feature) { 551 | const first = this.list[0]; 552 | if (!first) return undefined; 553 | let labels = first.place(layout, geom, feature); 554 | if (!labels) return undefined; 555 | let label = labels[0]; 556 | const anchor = label.anchor; 557 | let bbox = label.bboxes[0]; 558 | const draws = [label.draw]; 559 | 560 | for (let i = 1; i < this.list.length; i++) { 561 | labels = this.list[i].place(layout, geom, feature); 562 | if (!labels) return undefined; 563 | label = labels[0]; 564 | bbox = mergeBbox(bbox, label.bboxes[0]); 565 | draws.push(label.draw); 566 | } 567 | const draw = (ctx: CanvasRenderingContext2D) => { 568 | for (const d of draws) { 569 | d(ctx); 570 | } 571 | }; 572 | 573 | return [{ anchor: anchor, bboxes: [bbox], draw: draw }]; 574 | } 575 | } 576 | 577 | export class CenteredSymbolizer implements LabelSymbolizer { 578 | symbolizer: LabelSymbolizer; 579 | 580 | constructor(symbolizer: LabelSymbolizer) { 581 | this.symbolizer = symbolizer; 582 | } 583 | 584 | public place(layout: Layout, geom: Point[][], feature: Feature) { 585 | const a = geom[0][0]; 586 | const placed = this.symbolizer.place(layout, [[new Point(0, 0)]], feature); 587 | if (!placed || placed.length === 0) return undefined; 588 | const firstLabel = placed[0]; 589 | const bbox = firstLabel.bboxes[0]; 590 | const width = bbox.maxX - bbox.minX; 591 | const height = bbox.maxY - bbox.minY; 592 | const centered = { 593 | minX: a.x - width / 2, 594 | maxX: a.x + width / 2, 595 | minY: a.y - height / 2, 596 | maxY: a.y + height / 2, 597 | }; 598 | 599 | const draw = (ctx: CanvasRenderingContext2D) => { 600 | ctx.translate(-width / 2, height / 2 - bbox.maxY); 601 | firstLabel.draw(ctx, { justify: Justify.Center }); 602 | }; 603 | 604 | return [{ anchor: a, bboxes: [centered], draw: draw }]; 605 | } 606 | } 607 | 608 | export class Padding implements LabelSymbolizer { 609 | symbolizer: LabelSymbolizer; 610 | padding: NumberAttr; 611 | 612 | constructor(padding: number, symbolizer: LabelSymbolizer) { 613 | this.padding = new NumberAttr(padding, 0); 614 | this.symbolizer = symbolizer; 615 | } 616 | 617 | public place(layout: Layout, geom: Point[][], feature: Feature) { 618 | const placed = this.symbolizer.place(layout, geom, feature); 619 | if (!placed || placed.length === 0) return undefined; 620 | const padding = this.padding.get(layout.zoom, feature); 621 | for (const label of placed) { 622 | for (const bbox of label.bboxes) { 623 | bbox.minX -= padding; 624 | bbox.minY -= padding; 625 | bbox.maxX += padding; 626 | bbox.maxY += padding; 627 | } 628 | } 629 | return placed; 630 | } 631 | } 632 | 633 | export interface TextSymbolizerOptions 634 | extends FontAttrOptions, 635 | TextAttrOptions { 636 | fill?: AttrOption; 637 | stroke?: AttrOption; 638 | width?: AttrOption; 639 | lineHeight?: AttrOption; 640 | letterSpacing?: AttrOption; 641 | maxLineChars?: AttrOption; 642 | justify?: Justify; 643 | } 644 | 645 | export class TextSymbolizer implements LabelSymbolizer { 646 | font: FontAttr; 647 | text: TextAttr; 648 | fill: StringAttr; 649 | stroke: StringAttr; 650 | width: NumberAttr; 651 | lineHeight: NumberAttr; // in ems 652 | letterSpacing: NumberAttr; // in px 653 | maxLineCodeUnits: NumberAttr; 654 | justify?: Justify; 655 | 656 | constructor(options: TextSymbolizerOptions) { 657 | this.font = new FontAttr(options); 658 | this.text = new TextAttr(options); 659 | 660 | this.fill = new StringAttr(options.fill, "black"); 661 | this.stroke = new StringAttr(options.stroke, "black"); 662 | this.width = new NumberAttr(options.width, 0); 663 | this.lineHeight = new NumberAttr(options.lineHeight, 1); 664 | this.letterSpacing = new NumberAttr(options.letterSpacing, 0); 665 | this.maxLineCodeUnits = new NumberAttr(options.maxLineChars, 15); 666 | this.justify = options.justify; 667 | } 668 | 669 | public place(layout: Layout, geom: Point[][], feature: Feature) { 670 | const property = this.text.get(layout.zoom, feature); 671 | if (!property) return undefined; 672 | const font = this.font.get(layout.zoom, feature); 673 | layout.scratch.font = font; 674 | 675 | const letterSpacing = this.letterSpacing.get(layout.zoom, feature); 676 | 677 | // line breaking 678 | const lines = linebreak( 679 | property, 680 | this.maxLineCodeUnits.get(layout.zoom, feature), 681 | ); 682 | let longestLine = ""; 683 | let longestLineLen = 0; 684 | for (const line of lines) { 685 | if (line.length > longestLineLen) { 686 | longestLineLen = line.length; 687 | longestLine = line; 688 | } 689 | } 690 | 691 | const metrics = layout.scratch.measureText(longestLine); 692 | const width = metrics.width + letterSpacing * (longestLineLen - 1); 693 | 694 | const ascent = metrics.actualBoundingBoxAscent; 695 | const descent = metrics.actualBoundingBoxDescent; 696 | const lineHeight = 697 | (ascent + descent) * this.lineHeight.get(layout.zoom, feature); 698 | 699 | const a = new Point(geom[0][0].x, geom[0][0].y); 700 | const bbox = { 701 | minX: a.x, 702 | minY: a.y - ascent, 703 | maxX: a.x + width, 704 | maxY: a.y + descent + (lines.length - 1) * lineHeight, 705 | }; 706 | 707 | // inside draw, the origin is the anchor 708 | // and the anchor is the typographic baseline of the first line 709 | const draw = (ctx: CanvasRenderingContext2D, extra?: DrawExtra) => { 710 | ctx.globalAlpha = 1; 711 | ctx.font = font; 712 | ctx.fillStyle = this.fill.get(layout.zoom, feature); 713 | const textStrokeWidth = this.width.get(layout.zoom, feature); 714 | 715 | let y = 0; 716 | for (const line of lines) { 717 | let startX = 0; 718 | if ( 719 | this.justify === Justify.Center || 720 | (extra && extra.justify === Justify.Center) 721 | ) { 722 | startX = (width - ctx.measureText(line).width) / 2; 723 | } else if ( 724 | this.justify === Justify.Right || 725 | (extra && extra.justify === Justify.Right) 726 | ) { 727 | startX = width - ctx.measureText(line).width; 728 | } 729 | if (textStrokeWidth) { 730 | ctx.lineWidth = textStrokeWidth * 2; // centered stroke 731 | ctx.strokeStyle = this.stroke.get(layout.zoom, feature); 732 | if (letterSpacing > 0) { 733 | let xPos = startX; 734 | for (const letter of line) { 735 | ctx.strokeText(letter, xPos, y); 736 | xPos += ctx.measureText(letter).width + letterSpacing; 737 | } 738 | } else { 739 | ctx.strokeText(line, startX, y); 740 | } 741 | } 742 | if (letterSpacing > 0) { 743 | let xPos = startX; 744 | for (const letter of line) { 745 | ctx.fillText(letter, xPos, y); 746 | xPos += ctx.measureText(letter).width + letterSpacing; 747 | } 748 | } else { 749 | ctx.fillText(line, startX, y); 750 | } 751 | y += lineHeight; 752 | } 753 | }; 754 | return [{ anchor: a, bboxes: [bbox], draw: draw }]; 755 | } 756 | } 757 | 758 | export class CenteredTextSymbolizer implements LabelSymbolizer { 759 | centered: LabelSymbolizer; 760 | 761 | constructor(options: TextSymbolizerOptions) { 762 | this.centered = new CenteredSymbolizer(new TextSymbolizer(options)); 763 | } 764 | 765 | public place(layout: Layout, geom: Point[][], feature: Feature) { 766 | return this.centered.place(layout, geom, feature); 767 | } 768 | } 769 | 770 | export interface OffsetSymbolizerValues { 771 | offsetX?: number; 772 | offsetY?: number; 773 | placements?: TextPlacements[]; 774 | justify?: Justify; 775 | } 776 | 777 | export type DataDrivenOffsetSymbolizer = ( 778 | zoom: number, 779 | feature: Feature, 780 | ) => OffsetSymbolizerValues; 781 | 782 | export interface OffsetSymbolizerOptions { 783 | offsetX?: AttrOption; 784 | offsetY?: AttrOption; 785 | justify?: Justify; 786 | placements?: TextPlacements[]; 787 | ddValues?: DataDrivenOffsetSymbolizer; 788 | } 789 | 790 | export class OffsetSymbolizer implements LabelSymbolizer { 791 | symbolizer: LabelSymbolizer; 792 | offsetX: NumberAttr; 793 | offsetY: NumberAttr; 794 | justify?: Justify; 795 | placements: TextPlacements[]; 796 | ddValues: DataDrivenOffsetSymbolizer; 797 | 798 | constructor(symbolizer: LabelSymbolizer, options: OffsetSymbolizerOptions) { 799 | this.symbolizer = symbolizer; 800 | this.offsetX = new NumberAttr(options.offsetX, 0); 801 | this.offsetY = new NumberAttr(options.offsetY, 0); 802 | this.justify = options.justify ?? undefined; 803 | this.placements = options.placements ?? [ 804 | TextPlacements.Ne, 805 | TextPlacements.Sw, 806 | TextPlacements.Nw, 807 | TextPlacements.Se, 808 | TextPlacements.N, 809 | TextPlacements.E, 810 | TextPlacements.S, 811 | TextPlacements.W, 812 | ]; 813 | this.ddValues = 814 | options.ddValues ?? 815 | (() => { 816 | return {}; 817 | }); 818 | } 819 | 820 | public place(layout: Layout, geom: Point[][], feature: Feature) { 821 | if (feature.geomType !== GeomType.Point) return undefined; 822 | const anchor = geom[0][0]; 823 | const placed = this.symbolizer.place(layout, [[new Point(0, 0)]], feature); 824 | if (!placed || placed.length === 0) return undefined; 825 | const firstLabel = placed[0]; 826 | const fb = firstLabel.bboxes[0]; 827 | 828 | // Overwrite options values via the data driven function if exists 829 | let offsetXvalue = this.offsetX; 830 | let offsetYvalue = this.offsetY; 831 | let justifyValue = this.justify; 832 | let placements = this.placements; 833 | const { 834 | offsetX: ddOffsetX, 835 | offsetY: ddOffsetY, 836 | justify: ddJustify, 837 | placements: ddPlacements, 838 | } = this.ddValues(layout.zoom, feature) || {}; 839 | if (ddOffsetX) offsetXvalue = new NumberAttr(ddOffsetX, 0); 840 | if (ddOffsetY) offsetYvalue = new NumberAttr(ddOffsetY, 0); 841 | if (ddJustify) justifyValue = ddJustify; 842 | if (ddPlacements) placements = ddPlacements; 843 | 844 | const offsetX = offsetXvalue.get(layout.zoom, feature); 845 | const offsetY = offsetYvalue.get(layout.zoom, feature); 846 | 847 | const getBbox = (a: Point, o: Point) => { 848 | return { 849 | minX: a.x + o.x + fb.minX, 850 | minY: a.y + o.y + fb.minY, 851 | maxX: a.x + o.x + fb.maxX, 852 | maxY: a.y + o.y + fb.maxY, 853 | }; 854 | }; 855 | 856 | let origin = new Point(offsetX, offsetY); 857 | let justify: Justify; 858 | const draw = (ctx: CanvasRenderingContext2D) => { 859 | ctx.translate(origin.x, origin.y); 860 | firstLabel.draw(ctx, { justify: justify }); 861 | }; 862 | 863 | const placeLabelInPoint = (a: Point, o: Point) => { 864 | const bbox = getBbox(a, o); 865 | if (!layout.index.bboxCollides(bbox, layout.order)) 866 | return [{ anchor: anchor, bboxes: [bbox], draw: draw }]; 867 | }; 868 | 869 | for (const placement of placements) { 870 | const xAxisOffset = this.computeXaxisOffset(offsetX, fb, placement); 871 | const yAxisOffset = this.computeYaxisOffset(offsetY, fb, placement); 872 | justify = this.computeJustify(justifyValue, placement); 873 | origin = new Point(xAxisOffset, yAxisOffset); 874 | return placeLabelInPoint(anchor, origin); 875 | } 876 | 877 | return undefined; 878 | } 879 | 880 | computeXaxisOffset(offsetX: number, fb: Bbox, placement: TextPlacements) { 881 | const labelWidth = fb.maxX; 882 | const labelHalfWidth = labelWidth / 2; 883 | if ([TextPlacements.N, TextPlacements.S].includes(placement)) 884 | return offsetX - labelHalfWidth; 885 | if ( 886 | [TextPlacements.Nw, TextPlacements.W, TextPlacements.Sw].includes( 887 | placement, 888 | ) 889 | ) 890 | return offsetX - labelWidth; 891 | return offsetX; 892 | } 893 | 894 | computeYaxisOffset(offsetY: number, fb: Bbox, placement: TextPlacements) { 895 | const labelHalfHeight = Math.abs(fb.minY); 896 | const labelBottom = fb.maxY; 897 | const labelCenterHeight = (fb.minY + fb.maxY) / 2; 898 | if ([TextPlacements.E, TextPlacements.W].includes(placement)) 899 | return offsetY - labelCenterHeight; 900 | if ( 901 | [TextPlacements.Nw, TextPlacements.Ne, TextPlacements.N].includes( 902 | placement, 903 | ) 904 | ) 905 | return offsetY - labelBottom; 906 | if ( 907 | [TextPlacements.Sw, TextPlacements.Se, TextPlacements.S].includes( 908 | placement, 909 | ) 910 | ) 911 | return offsetY + labelHalfHeight; 912 | return offsetY; 913 | } 914 | 915 | computeJustify(fixedJustify: Justify | undefined, placement: TextPlacements) { 916 | if (fixedJustify) return fixedJustify; 917 | if ([TextPlacements.N, TextPlacements.S].includes(placement)) 918 | return Justify.Center; 919 | if ( 920 | [TextPlacements.Ne, TextPlacements.E, TextPlacements.Se].includes( 921 | placement, 922 | ) 923 | ) 924 | return Justify.Left; 925 | return Justify.Right; 926 | } 927 | } 928 | 929 | export class OffsetTextSymbolizer implements LabelSymbolizer { 930 | symbolizer: LabelSymbolizer; 931 | 932 | constructor(options: OffsetSymbolizerOptions & TextSymbolizerOptions) { 933 | this.symbolizer = new OffsetSymbolizer( 934 | new TextSymbolizer(options), 935 | options, 936 | ); 937 | } 938 | 939 | public place(layout: Layout, geom: Point[][], feature: Feature) { 940 | return this.symbolizer.place(layout, geom, feature); 941 | } 942 | } 943 | 944 | export enum LineLabelPlacement { 945 | Above = 1, 946 | Center = 2, 947 | Below = 3, 948 | } 949 | 950 | export class LineLabelSymbolizer implements LabelSymbolizer { 951 | font: FontAttr; 952 | text: TextAttr; 953 | 954 | fill: StringAttr; 955 | stroke: StringAttr; 956 | width: NumberAttr; 957 | offset: NumberAttr; 958 | position: LineLabelPlacement; 959 | maxLabelCodeUnits: NumberAttr; 960 | repeatDistance: NumberAttr; 961 | 962 | constructor( 963 | options: { 964 | radius?: AttrOption; 965 | fill?: AttrOption; 966 | stroke?: AttrOption; 967 | width?: AttrOption; 968 | offset?: AttrOption; 969 | maxLabelChars?: AttrOption; 970 | repeatDistance?: AttrOption; 971 | position?: LineLabelPlacement; 972 | } & TextAttrOptions & 973 | FontAttrOptions, 974 | ) { 975 | this.font = new FontAttr(options); 976 | this.text = new TextAttr(options); 977 | 978 | this.fill = new StringAttr(options.fill, "black"); 979 | this.stroke = new StringAttr(options.stroke, "black"); 980 | this.width = new NumberAttr(options.width, 0); 981 | this.offset = new NumberAttr(options.offset, 0); 982 | this.position = options.position ?? LineLabelPlacement.Above; 983 | this.maxLabelCodeUnits = new NumberAttr(options.maxLabelChars, 40); 984 | this.repeatDistance = new NumberAttr(options.repeatDistance, 250); 985 | } 986 | 987 | public place(layout: Layout, geom: Point[][], feature: Feature) { 988 | const name = this.text.get(layout.zoom, feature); 989 | if (!name) return undefined; 990 | if (name.length > this.maxLabelCodeUnits.get(layout.zoom, feature)) 991 | return undefined; 992 | 993 | const minLabelableDim = 20; 994 | const fbbox = feature.bbox; 995 | if ( 996 | fbbox.maxY - fbbox.minY < minLabelableDim && 997 | fbbox.maxX - fbbox.minX < minLabelableDim 998 | ) 999 | return undefined; 1000 | 1001 | const font = this.font.get(layout.zoom, feature); 1002 | layout.scratch.font = font; 1003 | const metrics = layout.scratch.measureText(name); 1004 | const width = metrics.width; 1005 | const height = 1006 | metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; 1007 | 1008 | let repeatDistance = this.repeatDistance.get(layout.zoom, feature); 1009 | if (layout.overzoom > 4) repeatDistance *= 1 << (layout.overzoom - 4); 1010 | 1011 | const cellSize = height * 2; 1012 | 1013 | const labelCandidates = simpleLabel(geom, width, repeatDistance, cellSize); 1014 | if (labelCandidates.length === 0) return undefined; 1015 | 1016 | const labels = []; 1017 | for (const candidate of labelCandidates) { 1018 | const dx = candidate.end.x - candidate.start.x; 1019 | const dy = candidate.end.y - candidate.start.y; 1020 | 1021 | const cells = lineCells( 1022 | candidate.start, 1023 | candidate.end, 1024 | width, 1025 | cellSize / 2, 1026 | ); 1027 | const bboxes = cells.map((c) => { 1028 | return { 1029 | minX: c.x - cellSize / 2, 1030 | minY: c.y - cellSize / 2, 1031 | maxX: c.x + cellSize / 2, 1032 | maxY: c.y + cellSize / 2, 1033 | }; 1034 | }); 1035 | 1036 | const draw = (ctx: CanvasRenderingContext2D) => { 1037 | ctx.globalAlpha = 1; 1038 | // ctx.beginPath(); 1039 | // ctx.moveTo(0, 0); 1040 | // ctx.lineTo(dx, dy); 1041 | // ctx.strokeStyle = "red"; 1042 | // ctx.stroke(); 1043 | ctx.rotate(Math.atan2(dy, dx)); 1044 | if (dx < 0) { 1045 | ctx.scale(-1, -1); 1046 | ctx.translate(-width, 0); 1047 | } 1048 | let heightPlacement = 0; 1049 | if (this.position === LineLabelPlacement.Below) 1050 | heightPlacement += height; 1051 | else if (this.position === LineLabelPlacement.Center) 1052 | heightPlacement += height / 2; 1053 | ctx.translate( 1054 | 0, 1055 | heightPlacement - this.offset.get(layout.zoom, feature), 1056 | ); 1057 | ctx.font = font; 1058 | const lineWidth = this.width.get(layout.zoom, feature); 1059 | if (lineWidth) { 1060 | ctx.lineWidth = lineWidth; 1061 | ctx.strokeStyle = this.stroke.get(layout.zoom, feature); 1062 | ctx.strokeText(name, 0, 0); 1063 | } 1064 | ctx.fillStyle = this.fill.get(layout.zoom, feature); 1065 | ctx.fillText(name, 0, 0); 1066 | }; 1067 | labels.push({ 1068 | anchor: candidate.start, 1069 | bboxes: bboxes, 1070 | draw: draw, 1071 | deduplicationKey: name, 1072 | deduplicationDistance: repeatDistance, 1073 | }); 1074 | } 1075 | 1076 | return labels; 1077 | } 1078 | } 1079 | -------------------------------------------------------------------------------- /src/task.ts: -------------------------------------------------------------------------------- 1 | import potpack from "potpack"; 2 | 3 | // https://github.com/tangrams/tangram/blob/master/src/styles/text/font_manager.js 4 | export const Font = (name: string, url: string, weight: string) => { 5 | const ff = new FontFace(name, `url(${url})`, { weight: weight }); 6 | document.fonts.add(ff); 7 | return ff.load(); 8 | }; 9 | 10 | interface Sprite { 11 | x: number; 12 | y: number; 13 | w: number; 14 | h: number; 15 | } 16 | 17 | interface PotPackInput { 18 | x?: number; 19 | y?: number; 20 | w: number; 21 | h: number; 22 | id: string; 23 | img: HTMLImageElement; 24 | } 25 | 26 | const mkimg = async (src: string): Promise => { 27 | return new Promise((resolve, reject) => { 28 | const img = new Image(); 29 | img.onload = () => resolve(img); 30 | img.onerror = () => reject("Invalid SVG"); 31 | img.src = src; 32 | }); 33 | }; 34 | 35 | const MISSING = ` 36 | 37 | 38 | 39 | 40 | 41 | 42 | `; 43 | 44 | export class Sheet { 45 | src: string; 46 | canvas: HTMLCanvasElement; 47 | mapping: Map; 48 | missingBox: Sprite; 49 | 50 | constructor(src: string) { 51 | this.src = src; 52 | this.canvas = document.createElement("canvas"); 53 | this.mapping = new Map(); 54 | this.missingBox = { x: 0, y: 0, w: 0, h: 0 }; 55 | } 56 | 57 | async load() { 58 | let src = this.src; 59 | const scale = window.devicePixelRatio; 60 | if (src.endsWith(".html")) { 61 | const c = await fetch(src); 62 | src = await c.text(); 63 | } 64 | const tree = new window.DOMParser().parseFromString(src, "text/html"); 65 | const icons = Array.from(tree.body.children); 66 | 67 | const missingImg = await mkimg( 68 | `data:image/svg+xml;base64,${btoa(MISSING)}`, 69 | ); 70 | 71 | const boxes: PotPackInput[] = [ 72 | { 73 | w: missingImg.width * scale, 74 | h: missingImg.height * scale, 75 | img: missingImg, 76 | id: "", 77 | }, 78 | ]; 79 | 80 | const serializer = new XMLSerializer(); 81 | for (const ps of icons) { 82 | const svg64 = btoa(serializer.serializeToString(ps)); 83 | const image64 = `data:image/svg+xml;base64,${svg64}`; 84 | const img = await mkimg(image64); 85 | boxes.push({ 86 | w: img.width * scale, 87 | h: img.height * scale, 88 | img: img, 89 | id: ps.id, 90 | }); 91 | } 92 | 93 | const packresult = potpack(boxes); 94 | this.canvas.width = packresult.w; 95 | this.canvas.height = packresult.h; 96 | const ctx = this.canvas.getContext("2d"); 97 | if (ctx) { 98 | for (const box of boxes) { 99 | if (box.x !== undefined && box.y !== undefined) { 100 | ctx.drawImage(box.img, box.x, box.y, box.w, box.h); 101 | if (box.id) 102 | this.mapping.set(box.id, { 103 | x: box.x, 104 | y: box.y, 105 | w: box.w, 106 | h: box.h, 107 | }); 108 | else this.missingBox = { x: box.x, y: box.y, w: box.w, h: box.h }; 109 | } 110 | } 111 | } 112 | return this; 113 | } 114 | 115 | get(name: string): Sprite { 116 | let result = this.mapping.get(name); 117 | if (!result) result = this.missingBox; 118 | return result; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/text.ts: -------------------------------------------------------------------------------- 1 | // TODO should be visual length in pixels, not strlen 2 | export function linebreak(str: string, maxUnits: number): string[] { 3 | if (str.length <= maxUnits) return [str]; 4 | const endIndex = maxUnits - 1; 5 | const spaceBefore = str.lastIndexOf(" ", endIndex); 6 | const spaceAfter = str.indexOf(" ", endIndex); 7 | if (spaceBefore === -1 && spaceAfter === -1) { 8 | return [str]; 9 | } 10 | let first: string; 11 | let after: string; 12 | if ( 13 | spaceAfter === -1 || 14 | (spaceBefore >= 0 && endIndex - spaceBefore < spaceAfter - endIndex) 15 | ) { 16 | first = str.substring(0, spaceBefore); 17 | after = str.substring(spaceBefore + 1, str.length); 18 | } else { 19 | first = str.substring(0, spaceAfter); 20 | after = str.substring(spaceAfter + 1, str.length); 21 | } 22 | return [first, ...linebreak(after, maxUnits)]; 23 | } 24 | 25 | const CJK_CHARS = 26 | "\u3005-\u3007\u3021-\u3029\u3031-\u3035\u3038-\u303C\u3220-\u3229\u3248-\u324F\u3251-\u325F\u3280-\u3289\u32B1-\u32BF\u3400-\u4DB5\u4E00-\u9FEA\uF900-\uFA6D\uFA70-\uFAD9\u2000"; 27 | const cjkTest = new RegExp(`^[${CJK_CHARS}]+$`); 28 | 29 | export function isCjk(s: string) { 30 | return cjkTest.test(s); 31 | } 32 | -------------------------------------------------------------------------------- /src/tilecache.ts: -------------------------------------------------------------------------------- 1 | import Point from "@mapbox/point-geometry"; 2 | import { VectorTile } from "@mapbox/vector-tile"; 3 | import Protobuf from "pbf"; 4 | import { PMTiles } from "pmtiles"; 5 | 6 | export type JsonValue = 7 | | boolean 8 | | number 9 | | string 10 | | null 11 | | JsonArray 12 | | JsonObject; 13 | export interface JsonObject { 14 | [key: string]: JsonValue; 15 | } 16 | export interface JsonArray extends Array {} 17 | 18 | export enum GeomType { 19 | Point = 1, 20 | Line = 2, 21 | Polygon = 3, 22 | } 23 | 24 | export interface Bbox { 25 | minX: number; 26 | minY: number; 27 | maxX: number; 28 | maxY: number; 29 | } 30 | 31 | export interface Feature { 32 | readonly props: JsonObject; 33 | readonly bbox: Bbox; 34 | readonly geomType: GeomType; 35 | readonly geom: Point[][]; 36 | readonly numVertices: number; 37 | } 38 | 39 | export interface Zxy { 40 | readonly z: number; 41 | readonly x: number; 42 | readonly y: number; 43 | } 44 | 45 | export function toIndex(c: Zxy): string { 46 | return `${c.x}:${c.y}:${c.z}`; 47 | } 48 | 49 | export interface TileSource { 50 | get(c: Zxy, tileSize: number): Promise>; 51 | } 52 | 53 | interface ZoomAbort { 54 | z: number; 55 | controller: AbortController; 56 | } 57 | 58 | // reimplement loadGeometry with a scalefactor 59 | // so the general tile rendering case does not need rescaling. 60 | const loadGeomAndBbox = (pbf: Protobuf, geometry: number, scale: number) => { 61 | pbf.pos = geometry; 62 | const end = pbf.readVarint() + pbf.pos; 63 | let cmd = 1; 64 | let length = 0; 65 | let x = 0; 66 | let y = 0; 67 | let x1 = Infinity; 68 | let x2 = -Infinity; 69 | let y1 = Infinity; 70 | let y2 = -Infinity; 71 | 72 | const lines: Point[][] = []; 73 | let line: Point[] = []; 74 | while (pbf.pos < end) { 75 | if (length <= 0) { 76 | const cmdLen = pbf.readVarint(); 77 | cmd = cmdLen & 0x7; 78 | length = cmdLen >> 3; 79 | } 80 | length--; 81 | if (cmd === 1 || cmd === 2) { 82 | x += pbf.readSVarint() * scale; 83 | y += pbf.readSVarint() * scale; 84 | if (x < x1) x1 = x; 85 | if (x > x2) x2 = x; 86 | if (y < y1) y1 = y; 87 | if (y > y2) y2 = y; 88 | if (cmd === 1) { 89 | if (line.length > 0) lines.push(line); 90 | line = []; 91 | } 92 | line.push(new Point(x, y)); 93 | } else if (cmd === 7) { 94 | if (line) line.push(line[0].clone()); 95 | } else throw new Error(`unknown command ${cmd}`); 96 | } 97 | if (line) lines.push(line); 98 | return { geom: lines, bbox: { minX: x1, minY: y1, maxX: x2, maxY: y2 } }; 99 | }; 100 | 101 | function parseTile( 102 | buffer: ArrayBuffer, 103 | tileSize: number, 104 | ): Map { 105 | const v = new VectorTile(new Protobuf(buffer)); 106 | const result = new Map(); 107 | for (const [key, value] of Object.entries(v.layers)) { 108 | const features = []; 109 | // biome-ignore lint: need to use private fields of vector-tile 110 | const layer = value as any; 111 | for (let i = 0; i < layer.length; i++) { 112 | const loaded = loadGeomAndBbox( 113 | layer.feature(i)._pbf, 114 | layer.feature(i)._geometry, 115 | tileSize / layer.extent, 116 | ); 117 | let numVertices = 0; 118 | for (const part of loaded.geom) numVertices += part.length; 119 | features.push({ 120 | id: layer.feature(i).id, 121 | geomType: layer.feature(i).type, 122 | geom: loaded.geom, 123 | numVertices: numVertices, 124 | bbox: loaded.bbox, 125 | props: layer.feature(i).properties, 126 | }); 127 | } 128 | result.set(key, features); 129 | } 130 | return result; 131 | } 132 | 133 | export class PmtilesSource implements TileSource { 134 | p: PMTiles; 135 | zoomaborts: ZoomAbort[]; 136 | shouldCancelZooms: boolean; 137 | 138 | constructor(url: string | PMTiles, shouldCancelZooms: boolean) { 139 | if (typeof url === "string") { 140 | this.p = new PMTiles(url); 141 | } else { 142 | this.p = url; 143 | } 144 | this.zoomaborts = []; 145 | this.shouldCancelZooms = shouldCancelZooms; 146 | } 147 | 148 | public async get(c: Zxy, tileSize: number): Promise> { 149 | if (this.shouldCancelZooms) { 150 | this.zoomaborts = this.zoomaborts.filter((za) => { 151 | if (za.z !== c.z) { 152 | za.controller.abort(); 153 | return false; 154 | } 155 | return true; 156 | }); 157 | } 158 | const controller = new AbortController(); 159 | this.zoomaborts.push({ z: c.z, controller: controller }); 160 | const signal = controller.signal; 161 | 162 | const result = await this.p.getZxy(c.z, c.x, c.y, signal); 163 | 164 | if (result) { 165 | return parseTile(result.data, tileSize); 166 | } 167 | return new Map(); 168 | } 169 | } 170 | 171 | export class ZxySource implements TileSource { 172 | url: string; 173 | zoomaborts: ZoomAbort[]; 174 | shouldCancelZooms: boolean; 175 | 176 | constructor(url: string, shouldCancelZooms: boolean) { 177 | this.url = url; 178 | this.zoomaborts = []; 179 | this.shouldCancelZooms = shouldCancelZooms; 180 | } 181 | 182 | public async get(c: Zxy, tileSize: number): Promise> { 183 | if (this.shouldCancelZooms) { 184 | this.zoomaborts = this.zoomaborts.filter((za) => { 185 | if (za.z !== c.z) { 186 | za.controller.abort(); 187 | return false; 188 | } 189 | return true; 190 | }); 191 | } 192 | const url = this.url 193 | .replace("{z}", c.z.toString()) 194 | .replace("{x}", c.x.toString()) 195 | .replace("{y}", c.y.toString()); 196 | const controller = new AbortController(); 197 | this.zoomaborts.push({ z: c.z, controller: controller }); 198 | const signal = controller.signal; 199 | return new Promise((resolve, reject) => { 200 | fetch(url, { signal: signal }) 201 | .then((resp) => { 202 | return resp.arrayBuffer(); 203 | }) 204 | .then((buffer) => { 205 | const result = parseTile(buffer, tileSize); 206 | resolve(result); 207 | }) 208 | .catch((e) => { 209 | reject(e); 210 | }); 211 | }); 212 | } 213 | } 214 | 215 | interface CacheEntry { 216 | used: number; 217 | data: Map; 218 | } 219 | 220 | interface PromiseOptions { 221 | resolve: (result: Map) => void; 222 | reject: (e: Error) => void; 223 | } 224 | 225 | export interface PickedFeature { 226 | feature: Feature; 227 | layerName: string; 228 | } 229 | 230 | const R = 6378137; 231 | const MAX_LATITUDE = 85.0511287798; 232 | const MAXCOORD = R * Math.PI; 233 | 234 | const project = (latlng: number[]) => { 235 | const d = Math.PI / 180; 236 | const constrainedLat = Math.max( 237 | Math.min(MAX_LATITUDE, latlng[0]), 238 | -MAX_LATITUDE, 239 | ); 240 | const sin = Math.sin(constrainedLat * d); 241 | return new Point( 242 | R * latlng[1] * d, 243 | (R * Math.log((1 + sin) / (1 - sin))) / 2, 244 | ); 245 | }; 246 | 247 | function sqr(x: number) { 248 | return x * x; 249 | } 250 | 251 | function dist2(v: Point, w: Point) { 252 | return sqr(v.x - w.x) + sqr(v.y - w.y); 253 | } 254 | 255 | function distToSegmentSquared(p: Point, v: Point, w: Point) { 256 | const l2 = dist2(v, w); 257 | if (l2 === 0) return dist2(p, v); 258 | let t = ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y)) / l2; 259 | t = Math.max(0, Math.min(1, t)); 260 | return dist2(p, new Point(v.x + t * (w.x - v.x), v.y + t * (w.y - v.y))); 261 | } 262 | 263 | export function isInRing(point: Point, ring: Point[]): boolean { 264 | let inside = false; 265 | for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { 266 | const xi = ring[i].x; 267 | const yi = ring[i].y; 268 | const xj = ring[j].x; 269 | const yj = ring[j].y; 270 | const intersect = 271 | yi > point.y !== yj > point.y && 272 | point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi; 273 | if (intersect) inside = !inside; 274 | } 275 | return inside; 276 | } 277 | 278 | export function isCcw(ring: Point[]): boolean { 279 | let area = 0; 280 | for (let i = 0; i < ring.length; i++) { 281 | const j = (i + 1) % ring.length; 282 | area += ring[i].x * ring[j].y; 283 | area -= ring[j].x * ring[i].y; 284 | } 285 | return area < 0; 286 | } 287 | 288 | export function pointInPolygon(point: Point, geom: Point[][]): boolean { 289 | let isInCurrentExterior = false; 290 | for (const ring of geom) { 291 | if (isCcw(ring)) { 292 | // it is an interior ring 293 | if (isInRing(point, ring)) isInCurrentExterior = false; 294 | } else { 295 | // it is an exterior ring 296 | if (isInCurrentExterior) return true; 297 | if (isInRing(point, ring)) isInCurrentExterior = true; 298 | } 299 | } 300 | return isInCurrentExterior; 301 | } 302 | 303 | export function pointMinDistToPoints(point: Point, geom: Point[][]): number { 304 | let min = Infinity; 305 | for (const l of geom) { 306 | const dist = Math.sqrt(dist2(point, l[0])); 307 | if (dist < min) min = dist; 308 | } 309 | return min; 310 | } 311 | 312 | export function pointMinDistToLines(point: Point, geom: Point[][]): number { 313 | let min = Infinity; 314 | for (const l of geom) { 315 | for (let i = 0; i < l.length - 1; i++) { 316 | const dist = Math.sqrt(distToSegmentSquared(point, l[i], l[i + 1])); 317 | if (dist < min) min = dist; 318 | } 319 | } 320 | return min; 321 | } 322 | 323 | export class TileCache { 324 | source: TileSource; 325 | cache: Map; 326 | inflight: Map; 327 | tileSize: number; 328 | 329 | constructor(source: TileSource, tileSize: number) { 330 | this.source = source; 331 | this.cache = new Map(); 332 | this.inflight = new Map(); 333 | this.tileSize = tileSize; 334 | } 335 | 336 | public async get(c: Zxy): Promise> { 337 | const idx = toIndex(c); 338 | return new Promise((resolve, reject) => { 339 | const entry = this.cache.get(idx); 340 | if (entry) { 341 | entry.used = performance.now(); 342 | resolve(entry.data); 343 | } else { 344 | const ifentry = this.inflight.get(idx); 345 | if (ifentry) { 346 | ifentry.push({ resolve: resolve, reject: reject }); 347 | } else { 348 | this.inflight.set(idx, []); 349 | this.source 350 | .get(c, this.tileSize) 351 | .then((tile) => { 352 | this.cache.set(idx, { used: performance.now(), data: tile }); 353 | 354 | const ifentry2 = this.inflight.get(idx); 355 | if (ifentry2) { 356 | for (const f of ifentry2) { 357 | f.resolve(tile); 358 | } 359 | } 360 | this.inflight.delete(idx); 361 | resolve(tile); 362 | 363 | if (this.cache.size >= 64) { 364 | let minUsed = +Infinity; 365 | let minKey = undefined; 366 | this.cache.forEach((value, key) => { 367 | if (value.used < minUsed) { 368 | minUsed = value.used; 369 | minKey = key; 370 | } 371 | }); 372 | if (minKey) this.cache.delete(minKey); 373 | } 374 | }) 375 | .catch((e) => { 376 | const ifentry2 = this.inflight.get(idx); 377 | if (ifentry2) { 378 | for (const f of ifentry2) { 379 | f.reject(e); 380 | } 381 | } 382 | this.inflight.delete(idx); 383 | reject(e); 384 | }); 385 | } 386 | } 387 | }); 388 | } 389 | 390 | public queryFeatures( 391 | lng: number, 392 | lat: number, 393 | zoom: number, 394 | brushSize: number, 395 | ): PickedFeature[] { 396 | const projected = project([lat, lng]); 397 | const normalized = new Point( 398 | (projected.x + MAXCOORD) / (MAXCOORD * 2), 399 | 1 - (projected.y + MAXCOORD) / (MAXCOORD * 2), 400 | ); 401 | if (normalized.x > 1) 402 | normalized.x = normalized.x - Math.floor(normalized.x); 403 | const onZoom = normalized.mult(1 << zoom); 404 | const tileX = Math.floor(onZoom.x); 405 | const tileY = Math.floor(onZoom.y); 406 | const idx = toIndex({ z: zoom, x: tileX, y: tileY }); 407 | const retval: PickedFeature[] = []; 408 | const entry = this.cache.get(idx); 409 | if (entry) { 410 | const center = new Point( 411 | (onZoom.x - tileX) * this.tileSize, 412 | (onZoom.y - tileY) * this.tileSize, 413 | ); 414 | for (const [layerName, layerArr] of entry.data.entries()) { 415 | for (const feature of layerArr) { 416 | if (feature.geomType === GeomType.Point) { 417 | if (pointMinDistToPoints(center, feature.geom) < brushSize) { 418 | retval.push({ feature, layerName: layerName }); 419 | } 420 | } else if (feature.geomType === GeomType.Line) { 421 | if (pointMinDistToLines(center, feature.geom) < brushSize) { 422 | retval.push({ feature, layerName: layerName }); 423 | } 424 | } else { 425 | if (pointInPolygon(center, feature.geom)) { 426 | retval.push({ feature, layerName: layerName }); 427 | } 428 | } 429 | } 430 | } 431 | } 432 | return retval; 433 | } 434 | } 435 | -------------------------------------------------------------------------------- /src/types/unitbezier.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@mapbox/unitbezier"; 2 | -------------------------------------------------------------------------------- /src/view.ts: -------------------------------------------------------------------------------- 1 | import Point from "@mapbox/point-geometry"; 2 | import { PMTiles } from "pmtiles"; 3 | import { 4 | Bbox, 5 | Feature, 6 | PmtilesSource, 7 | TileCache, 8 | TileSource, 9 | Zxy, 10 | ZxySource, 11 | } from "./tilecache"; 12 | 13 | /* 14 | * PreparedTile 15 | * For a given display Z: 16 | * layers: map of names-> features with coordinates in CSS pixel units. 17 | * translate: how to get layers coordinates to global Z coordinates. 18 | * dataTile: the Z,X,Y of the data tile. 19 | * window? if present, use as bounding box or canvas clipping area. 20 | */ 21 | export interface PreparedTile { 22 | z: number; // the display zoom level that it is for 23 | origin: Point; // the top-left corner in global CSS pixel coordinates 24 | data: Map; // return a map to Iterable 25 | scale: number; // over or underzooming scale 26 | dim: number; // the effective size of this tile on the zoom level 27 | dataTile: Zxy; // the key of the raw tile 28 | } 29 | 30 | export interface TileTransform { 31 | dataTile: Zxy; 32 | origin: Point; 33 | scale: number; 34 | dim: number; 35 | } 36 | 37 | // TODO make this lazy 38 | export const transformGeom = ( 39 | geom: Array>, 40 | scale: number, 41 | translate: Point, 42 | ) => { 43 | const retval = []; 44 | for (const arr of geom) { 45 | const loop = []; 46 | for (const coord of arr) { 47 | loop.push(coord.clone().mult(scale).add(translate)); 48 | } 49 | retval.push(loop); 50 | } 51 | return retval; 52 | }; 53 | 54 | export const wrap = (val: number, z: number) => { 55 | const dim = 1 << z; 56 | if (val < 0) return dim + val; 57 | if (val >= dim) return val % dim; 58 | return val; 59 | }; 60 | 61 | /* 62 | * @class View 63 | * expresses relationship between canvas coordinates and data tiles. 64 | */ 65 | export class View { 66 | levelDiff: number; 67 | tileCache: TileCache; 68 | maxDataLevel: number; 69 | 70 | constructor(tileCache: TileCache, maxDataLevel: number, levelDiff: number) { 71 | this.tileCache = tileCache; 72 | this.maxDataLevel = maxDataLevel; 73 | this.levelDiff = levelDiff; 74 | } 75 | 76 | public dataTilesForBounds( 77 | displayZoom: number, 78 | bounds: Bbox, 79 | ): Array { 80 | const fractional = 2 ** displayZoom / 2 ** Math.ceil(displayZoom); 81 | const needed = []; 82 | let scale = 1; 83 | const dim = this.tileCache.tileSize; 84 | if (displayZoom < this.levelDiff) { 85 | scale = (1 / (1 << (this.levelDiff - displayZoom))) * fractional; 86 | needed.push({ 87 | dataTile: { z: 0, x: 0, y: 0 }, 88 | origin: new Point(0, 0), 89 | scale: scale, 90 | dim: dim * scale, 91 | }); 92 | } else if (displayZoom <= this.levelDiff + this.maxDataLevel) { 93 | const f = 1 << this.levelDiff; 94 | 95 | const basetileSize = 256 * fractional; 96 | 97 | const dataZoom = Math.ceil(displayZoom) - this.levelDiff; 98 | 99 | const mintileX = Math.floor(bounds.minX / f / basetileSize); 100 | const mintileY = Math.floor(bounds.minY / f / basetileSize); 101 | const maxtileX = Math.floor(bounds.maxX / f / basetileSize); 102 | const maxtileY = Math.floor(bounds.maxY / f / basetileSize); 103 | for (let tx = mintileX; tx <= maxtileX; tx++) { 104 | for (let ty = mintileY; ty <= maxtileY; ty++) { 105 | const origin = new Point( 106 | tx * f * basetileSize, 107 | ty * f * basetileSize, 108 | ); 109 | needed.push({ 110 | dataTile: { 111 | z: dataZoom, 112 | x: wrap(tx, dataZoom), 113 | y: wrap(ty, dataZoom), 114 | }, 115 | origin: origin, 116 | scale: fractional, 117 | dim: dim * fractional, 118 | }); 119 | } 120 | } 121 | } else { 122 | const f = 1 << this.levelDiff; 123 | scale = 124 | (1 << (Math.ceil(displayZoom) - this.maxDataLevel - this.levelDiff)) * 125 | fractional; 126 | const mintileX = Math.floor(bounds.minX / f / 256 / scale); 127 | const mintileY = Math.floor(bounds.minY / f / 256 / scale); 128 | const maxtileX = Math.floor(bounds.maxX / f / 256 / scale); 129 | const maxtileY = Math.floor(bounds.maxY / f / 256 / scale); 130 | for (let tx = mintileX; tx <= maxtileX; tx++) { 131 | for (let ty = mintileY; ty <= maxtileY; ty++) { 132 | const origin = new Point(tx * f * 256 * scale, ty * f * 256 * scale); 133 | needed.push({ 134 | dataTile: { 135 | z: this.maxDataLevel, 136 | x: wrap(tx, this.maxDataLevel), 137 | y: wrap(ty, this.maxDataLevel), 138 | }, 139 | origin: origin, 140 | scale: scale, 141 | dim: dim * scale, 142 | }); 143 | } 144 | } 145 | } 146 | return needed; 147 | } 148 | 149 | public dataTileForDisplayTile(displayTile: Zxy): TileTransform { 150 | let dataTile: Zxy; 151 | let scale = 1; 152 | let dim = this.tileCache.tileSize; 153 | let origin: Point; 154 | if (displayTile.z < this.levelDiff) { 155 | dataTile = { z: 0, x: 0, y: 0 }; 156 | scale = 1 / (1 << (this.levelDiff - displayTile.z)); 157 | origin = new Point(0, 0); 158 | dim = dim * scale; 159 | } else if (displayTile.z <= this.levelDiff + this.maxDataLevel) { 160 | const f = 1 << this.levelDiff; 161 | dataTile = { 162 | z: displayTile.z - this.levelDiff, 163 | x: Math.floor(displayTile.x / f), 164 | y: Math.floor(displayTile.y / f), 165 | }; 166 | origin = new Point(dataTile.x * f * 256, dataTile.y * f * 256); 167 | } else { 168 | scale = 1 << (displayTile.z - this.maxDataLevel - this.levelDiff); 169 | const f = 1 << this.levelDiff; 170 | dataTile = { 171 | z: this.maxDataLevel, 172 | x: Math.floor(displayTile.x / f / scale), 173 | y: Math.floor(displayTile.y / f / scale), 174 | }; 175 | origin = new Point( 176 | dataTile.x * f * scale * 256, 177 | dataTile.y * f * scale * 256, 178 | ); 179 | dim = dim * scale; 180 | } 181 | return { dataTile: dataTile, scale: scale, origin: origin, dim: dim }; 182 | } 183 | 184 | public async getBbox( 185 | displayZoom: number, 186 | bounds: Bbox, 187 | ): Promise> { 188 | const needed = this.dataTilesForBounds(displayZoom, bounds); 189 | const result = await Promise.all( 190 | needed.map((tt) => this.tileCache.get(tt.dataTile)), 191 | ); 192 | return result.map((data, i) => { 193 | const tt = needed[i]; 194 | return { 195 | data: data, 196 | z: displayZoom, 197 | dataTile: tt.dataTile, 198 | scale: tt.scale, 199 | dim: tt.dim, 200 | origin: tt.origin, 201 | }; 202 | }); 203 | } 204 | 205 | public async getDisplayTile(displayTile: Zxy): Promise { 206 | const tt = this.dataTileForDisplayTile(displayTile); 207 | const data = await this.tileCache.get(tt.dataTile); 208 | return { 209 | data: data, 210 | z: displayTile.z, 211 | dataTile: tt.dataTile, 212 | scale: tt.scale, 213 | origin: tt.origin, 214 | dim: tt.dim, 215 | }; 216 | } 217 | 218 | public queryFeatures( 219 | lng: number, 220 | lat: number, 221 | displayZoom: number, 222 | brushSize: number, 223 | ) { 224 | const roundedZoom = Math.round(displayZoom); 225 | const dataZoom = Math.min(roundedZoom - this.levelDiff, this.maxDataLevel); 226 | const brushSizeAtZoom = brushSize / (1 << (roundedZoom - dataZoom)); 227 | return this.tileCache.queryFeatures(lng, lat, dataZoom, brushSizeAtZoom); 228 | } 229 | } 230 | 231 | export interface SourceOptions { 232 | levelDiff?: number; 233 | maxDataZoom?: number; 234 | url?: PMTiles | string; 235 | sources?: Record; 236 | } 237 | 238 | export const sourcesToViews = (options: SourceOptions) => { 239 | const sourceToViews = (o: SourceOptions): View => { 240 | const levelDiff = o.levelDiff === undefined ? 1 : o.levelDiff; 241 | const maxDataZoom = o.maxDataZoom || 15; 242 | let source: TileSource; 243 | if (typeof o.url === "string") { 244 | if (new URL(o.url, "http://example.com").pathname.endsWith(".pmtiles")) { 245 | source = new PmtilesSource(o.url, true); 246 | } else { 247 | source = new ZxySource(o.url, true); 248 | } 249 | } else if (o.url) { 250 | source = new PmtilesSource(o.url, true); 251 | } else { 252 | throw new Error(`Invalid source ${o.url}`); 253 | } 254 | 255 | const cache = new TileCache(source, (256 * 1) << levelDiff); 256 | return new View(cache, maxDataZoom, levelDiff); 257 | }; 258 | 259 | const sources = new Map(); 260 | if (options.sources) { 261 | for (const key in options.sources) { 262 | sources.set(key, sourceToViews(options.sources[key])); 263 | } 264 | } else { 265 | sources.set("", sourceToViews(options)); 266 | } 267 | return sources; 268 | }; 269 | -------------------------------------------------------------------------------- /test/attribute.test.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import { test } from "node:test"; 3 | import { 4 | ArrayAttr, 5 | FontAttr, 6 | NumberAttr, 7 | StringAttr, 8 | TextAttr, 9 | } from "../src/attribute"; 10 | import { GeomType } from "../src/tilecache"; 11 | 12 | export const emptyFeature = { 13 | props: {}, 14 | geomType: GeomType.Point, 15 | numVertices: 0, 16 | geom: [], 17 | bbox: { minX: 0, minY: 0, maxX: 0, maxY: 0 }, 18 | }; 19 | 20 | test("numberattr", async () => { 21 | let n = new NumberAttr(undefined, undefined); 22 | assert.equal(n.get(1), 1); 23 | 24 | n = new NumberAttr(2, undefined); 25 | assert.equal(n.get(1), 2); 26 | 27 | n = new NumberAttr(undefined, 3); 28 | assert.equal(n.get(1), 3); 29 | 30 | n = new NumberAttr(undefined, 0); 31 | assert.equal(n.get(1), 0); 32 | 33 | n = new NumberAttr((z, f) => { 34 | return z; 35 | }, 0); 36 | assert.equal(n.get(2), 2); 37 | assert.equal(n.get(3), 3); 38 | 39 | n = new NumberAttr(1); 40 | assert.equal(n.perFeature, false); 41 | n = new NumberAttr((z) => { 42 | return z; 43 | }); 44 | assert.equal(n.perFeature, false); 45 | n = new NumberAttr((z, f) => { 46 | return z; 47 | }); 48 | assert.equal(n.perFeature, true); 49 | }); 50 | 51 | test("stringattr", async () => { 52 | const c1 = new StringAttr(undefined, ""); 53 | assert.equal(c1.get(1), ""); 54 | 55 | const c2 = new StringAttr(undefined, "red"); 56 | assert.equal(c2.get(1), "red"); 57 | 58 | const c3 = new StringAttr("blue", "green"); 59 | assert.equal(c3.get(1), "blue"); 60 | 61 | const c4 = new StringAttr((z, f) => { 62 | if (z < 4) return "green"; 63 | return "aquamarine"; 64 | }, undefined); 65 | assert.equal(c4.get(3), "green"); 66 | assert.equal(c4.get(5), "aquamarine"); 67 | assert.equal(c4.perFeature, true); 68 | }); 69 | 70 | test("fontattr", async () => { 71 | let f = new FontAttr({ font: "12px serif" }); 72 | assert.equal(f.get(1), "12px serif"); 73 | 74 | f = new FontAttr({ 75 | font: (z) => { 76 | return z === 1 ? "12px serif" : "14px serif"; 77 | }, 78 | }); 79 | assert.equal(f.get(1), "12px serif"); 80 | assert.equal(f.get(2), "14px serif"); 81 | 82 | f = new FontAttr({ 83 | fontFamily: "serif", 84 | fontWeight: 500, 85 | fontStyle: "italic", 86 | fontSize: 14, 87 | }); 88 | assert.equal(f.get(1), "italic 500 14px serif"); 89 | 90 | f = new FontAttr({}); 91 | assert.equal(f.get(1), "12px sans-serif"); 92 | 93 | f = new FontAttr({ 94 | fontWeight: (z) => { 95 | return z === 1 ? 400 : 600; 96 | }, 97 | }); 98 | assert.equal(f.get(1), "400 12px sans-serif"); 99 | assert.equal(f.get(2), "600 12px sans-serif"); 100 | 101 | f = new FontAttr({ 102 | fontSize: (z) => { 103 | return z === 1 ? 12 : 14; 104 | }, 105 | }); 106 | assert.equal(f.get(1), "12px sans-serif"); 107 | assert.equal(f.get(2), "14px sans-serif"); 108 | 109 | f = new FontAttr({ 110 | fontStyle: (z) => { 111 | return z === 1 ? "normal" : "italic"; 112 | }, 113 | }); 114 | assert.equal(f.get(1), "normal 12px sans-serif"); 115 | assert.equal(f.get(2), "italic 12px sans-serif"); 116 | 117 | f = new FontAttr({ 118 | fontFamily: (z) => { 119 | return z === 1 ? "sans-serif" : "serif"; 120 | }, 121 | }); 122 | assert.equal(f.get(1), "12px sans-serif"); 123 | assert.equal(f.get(2), "12px serif"); 124 | }); 125 | 126 | test("textattr", async () => { 127 | let t = new TextAttr(); 128 | assert.equal(t.get(0, { ...emptyFeature, props: { name: "臺北" } }), "臺北"); 129 | t = new TextAttr({ labelProps: ["name:en"] }); 130 | assert.equal( 131 | t.get(0, { 132 | ...emptyFeature, 133 | props: { "name:en": "Taipei", name: "臺北" }, 134 | }), 135 | "Taipei", 136 | ); 137 | t = new TextAttr({ labelProps: ["name:en"], textTransform: "uppercase" }); 138 | assert.equal( 139 | t.get(0, { ...emptyFeature, props: { "name:en": "Taipei" } }), 140 | "TAIPEI", 141 | ); 142 | t = new TextAttr({ labelProps: ["name:en"], textTransform: "lowercase" }); 143 | assert.equal( 144 | t.get(0, { 145 | ...emptyFeature, 146 | props: { "name:en": "Taipei" }, 147 | geomType: GeomType.Point, 148 | }), 149 | "taipei", 150 | ); 151 | t = new TextAttr({ labelProps: ["name:en"], textTransform: "capitalize" }); 152 | assert.equal( 153 | t.get(0, { 154 | ...emptyFeature, 155 | props: { "name:en": "from Berga to Taipei" }, 156 | geomType: GeomType.Point, 157 | }), 158 | "From Berga To Taipei", 159 | ); 160 | t = new TextAttr({ labelProps: ["name:en"], textTransform: "uppercase" }); 161 | assert.equal(t.get(0, { ...emptyFeature, props: {} }), undefined); 162 | 163 | t = new TextAttr({ 164 | labelProps: ["name:en"], 165 | textTransform: (z) => "uppercase", 166 | }); 167 | assert.equal( 168 | t.get(0, { 169 | ...emptyFeature, 170 | props: { "name:en": "Taipei" }, 171 | geomType: GeomType.Point, 172 | }), 173 | "TAIPEI", 174 | ); 175 | t = new TextAttr({ 176 | labelProps: ["name:en"], 177 | textTransform: (z) => "lowercase", 178 | }); 179 | assert.equal( 180 | t.get(0, { 181 | ...emptyFeature, 182 | props: { "name:en": "Taipei" }, 183 | geomType: GeomType.Point, 184 | }), 185 | "taipei", 186 | ); 187 | t = new TextAttr({ 188 | labelProps: ["name:en"], 189 | textTransform: (z) => "capitalize", 190 | }); 191 | assert.equal( 192 | t.get(0, { 193 | ...emptyFeature, 194 | props: { "name:en": "from Berga to Taipei" }, 195 | geomType: GeomType.Point, 196 | }), 197 | "From Berga To Taipei", 198 | ); 199 | 200 | t = new TextAttr({ 201 | labelProps: (z, f) => { 202 | if (z < 8) return ["abbr", "name"]; 203 | return ["name"]; 204 | }, 205 | textTransform: "uppercase", 206 | }); 207 | assert.equal( 208 | t.get(0, { ...emptyFeature, props: { name: "台北", abbr: "TPE" } }), 209 | "TPE", 210 | ); 211 | assert.equal( 212 | t.get(9, { ...emptyFeature, props: { name: "台北", abbr: "TPE" } }), 213 | "台北", 214 | ); 215 | }); 216 | 217 | test("arrayattr", async () => { 218 | let n: ArrayAttr | ArrayAttr = new ArrayAttr([], undefined); 219 | assert.equal(n.get(1).length, 0); 220 | 221 | n = new ArrayAttr([2], undefined); 222 | assert.equal(n.get(1)[0], 2); 223 | 224 | n = new ArrayAttr(undefined, [3]); 225 | assert.equal(n.get(1)[0], 3); 226 | 227 | n = new ArrayAttr(undefined, [0]); 228 | assert.equal(n.get(1)[0], 0); 229 | 230 | n = new ArrayAttr( 231 | (z, f) => { 232 | return [z, z]; 233 | }, 234 | [0], 235 | ); 236 | assert.equal(n.get(2)[0], 2); 237 | assert.equal(n.get(2)[1], 2); 238 | assert.equal(n.get(3)[0], 3); 239 | assert.equal(n.get(3)[1], 3); 240 | 241 | n = new ArrayAttr([1]); 242 | assert.equal(n.perFeature, false); 243 | n = new ArrayAttr((z) => { 244 | return [z]; 245 | }); 246 | assert.equal(n.perFeature, false); 247 | n = new ArrayAttr((z, f) => { 248 | return [z]; 249 | }); 250 | assert.equal(n.perFeature, true); 251 | }); 252 | -------------------------------------------------------------------------------- /test/labeler.test.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import { test } from "node:test"; 3 | import Point from "@mapbox/point-geometry"; 4 | import { Index, covering } from "../src/labeler"; 5 | 6 | test("covering", async () => { 7 | const result = covering(3, 1024, { 8 | minX: 256, 9 | minY: 256 * 2, 10 | maxX: 256 + 1, 11 | maxY: 256 * 2 + 1, 12 | }); 13 | assert.deepEqual(result, [{ display: "1:2:3", key: "0:0:1" }]); 14 | }); 15 | 16 | test("covering with antimeridian crossing", async () => { 17 | const result = covering(3, 1024, { 18 | minX: 2000, 19 | minY: 256 * 2, 20 | maxX: 2050, 21 | maxY: 256 * 2 + 1, 22 | }); 23 | assert.deepEqual(result, [ 24 | { display: "7:2:3", key: "1:0:1" }, 25 | { display: "0:2:3", key: "0:0:1" }, 26 | ]); 27 | }); 28 | 29 | test("inserting label into index", async () => { 30 | const index = new Index(1024); 31 | index.insert( 32 | { 33 | anchor: new Point(100, 100), 34 | bboxes: [{ minX: 100, minY: 100, maxX: 200, maxY: 200 }], 35 | draw: (c) => {}, 36 | }, 37 | 1, 38 | "abcd", 39 | ); 40 | const result = index.searchBbox( 41 | { minX: 90, maxX: 110, minY: 90, maxY: 110 }, 42 | Infinity, 43 | ); 44 | assert.equal(result.size, 1); 45 | }); 46 | 47 | test("inserting label with antimeridian wrapping", async () => { 48 | let index = new Index(1024); 49 | index.insert( 50 | { 51 | anchor: new Point(1000, 100), 52 | bboxes: [{ minX: 1000, minY: 100, maxX: 1050, maxY: 200 }], 53 | draw: (c) => {}, 54 | }, 55 | 1, 56 | "abcd", 57 | ); 58 | let result = index.searchBbox( 59 | { minX: 0, maxX: 100, minY: 90, maxY: 110 }, 60 | Infinity, 61 | ); 62 | assert.equal(result.size, 1); 63 | 64 | index = new Index(1024); 65 | index.insert( 66 | { 67 | anchor: new Point(-100, 100), 68 | bboxes: [{ minX: -100, minY: 100, maxX: 100, maxY: 200 }], 69 | draw: (c) => {}, 70 | }, 71 | 1, 72 | "abcd", 73 | ); 74 | result = index.searchBbox( 75 | { minX: 1000, maxX: 1024, minY: 90, maxY: 110 }, 76 | Infinity, 77 | ); 78 | assert.equal(result.size, 1); 79 | }); 80 | 81 | test("label with multiple bboxes", async () => { 82 | const index = new Index(1024); 83 | index.insert( 84 | { 85 | anchor: new Point(100, 100), 86 | bboxes: [ 87 | { minX: 100, minY: 100, maxX: 110, maxY: 200 }, 88 | { minX: 110, minY: 100, maxX: 120, maxY: 200 }, 89 | ], 90 | draw: (c) => {}, 91 | }, 92 | 1, 93 | "abcd", 94 | ); 95 | const result = index.searchBbox( 96 | { minX: 90, maxX: 130, minY: 90, maxY: 110 }, 97 | Infinity, 98 | ); 99 | assert.equal(result.size, 1); 100 | }); 101 | 102 | test("label order", async () => { 103 | const index = new Index(1024); 104 | index.insert( 105 | { 106 | anchor: new Point(100, 100), 107 | bboxes: [{ minX: 100, minY: 100, maxX: 200, maxY: 200 }], 108 | draw: (c) => {}, 109 | }, 110 | 2, 111 | "abcd", 112 | ); 113 | let result = index.searchBbox( 114 | { minX: 90, maxX: 110, minY: 90, maxY: 110 }, 115 | 1, 116 | ); 117 | assert.equal(result.size, 0); 118 | result = index.searchBbox({ minX: 90, maxX: 110, minY: 90, maxY: 110 }, 3); 119 | assert.equal(result.size, 1); 120 | }); 121 | 122 | test("pruning", async () => { 123 | const index = new Index(1024); 124 | index.insert( 125 | { 126 | anchor: new Point(100, 100), 127 | bboxes: [{ minX: 100, minY: 100, maxX: 200, maxY: 200 }], 128 | draw: (c) => {}, 129 | }, 130 | 1, 131 | "abcd", 132 | ); 133 | assert.equal(index.tree.all().length, 1); 134 | assert.equal(index.has("abcd"), true); 135 | index.pruneKey("abcd"); 136 | assert.equal(index.current.size, 0); 137 | assert.equal(index.tree.all().length, 0); 138 | }); 139 | 140 | test("tile prefixes", async () => { 141 | const index = new Index(1024); 142 | assert.equal(index.hasPrefix("my_key"), false); 143 | index.insert( 144 | { 145 | anchor: new Point(100, 100), 146 | bboxes: [{ minX: 100, minY: 100, maxX: 200, maxY: 200 }], 147 | draw: (c) => {}, 148 | }, 149 | 1, 150 | "my_key:123", 151 | ); 152 | assert.equal(index.hasPrefix("my_key"), true); 153 | }); 154 | 155 | test("remove an individual label", async () => { 156 | const index = new Index(1024); 157 | index.insert( 158 | { 159 | anchor: new Point(100, 100), 160 | bboxes: [{ minX: 100, minY: 100, maxX: 200, maxY: 200 }], 161 | draw: (c) => {}, 162 | }, 163 | 1, 164 | "abcd", 165 | ); 166 | assert.equal(index.tree.all().length, 1); 167 | assert.equal(index.current.get("abcd").size, 1); 168 | const theLabel = index.tree.all()[0].indexedLabel; 169 | index.removeLabel(theLabel); 170 | assert.equal(index.current.size, 1); 171 | assert.equal(index.current.get("abcd").size, 0); 172 | assert.equal(index.tree.all().length, 0); 173 | }); 174 | 175 | test("basic label deduplication", async () => { 176 | const index = new Index(1024); 177 | const label1 = { 178 | anchor: new Point(100, 100), 179 | bboxes: [{ minX: 100, minY: 100, maxX: 110, maxY: 110 }], 180 | draw: (c) => {}, 181 | deduplicationKey: "Mulholland Dr.", 182 | deduplicationDistance: 100, 183 | }; 184 | index.insert(label1, 1, "abcd"); 185 | 186 | const repeatedLabel = { 187 | anchor: new Point(200, 100), 188 | bboxes: [{ minX: 200, minY: 100, maxX: 210, maxY: 110 }], 189 | draw: (c) => {}, 190 | deduplicationKey: "Mulholland Dr.", 191 | deduplicationDistance: 100, 192 | }; 193 | 194 | assert.equal(index.deduplicationCollides(repeatedLabel), false); 195 | 196 | const toocloseLabel = { 197 | anchor: new Point(199, 100), 198 | bboxes: [{ minX: 199, minY: 100, maxX: 210, maxY: 110 }], 199 | draw: (c) => {}, 200 | deduplicationKey: "Mulholland Dr.", 201 | deduplicationDistance: 100, 202 | }; 203 | 204 | assert.equal(index.deduplicationCollides(toocloseLabel), true); 205 | }); 206 | -------------------------------------------------------------------------------- /test/line.test.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import { test } from "node:test"; 3 | 4 | import { lineCells, simpleLabel } from "../src/line"; 5 | 6 | test("simple line labeler", async () => { 7 | const mls = [ 8 | [ 9 | { x: 0, y: 0 }, 10 | { x: 100, y: 0 }, 11 | ], 12 | ]; 13 | const results = simpleLabel(mls, 10, 250, 0); 14 | assert.deepEqual(results[0].start, { x: 0, y: 0 }); 15 | assert.deepEqual(results[0].end, { x: 10, y: 0 }); 16 | }); 17 | 18 | test("simple line labeler tolerance", async () => { 19 | const mls = [ 20 | [ 21 | { x: 0, y: 0 }, 22 | { x: 20, y: 0.5 }, 23 | { x: 150, y: 0 }, 24 | ], 25 | ]; 26 | const results = simpleLabel(mls, 100, 250, 0); 27 | assert.equal(results.length, 1); 28 | assert.deepEqual(results[0].start, { x: 0, y: 0 }); 29 | assert.equal(results[0].end.x > 99, true); 30 | assert.equal(results[0].end.x < 100, true); 31 | }); 32 | 33 | test("simple line labeler - very gradual angles - multiple labels", async () => { 34 | const mls = [ 35 | [ 36 | { x: 0, y: 0 }, 37 | { x: 10, y: 0.5 }, // about 2 degrees 38 | { x: 20, y: 1.5 }, 39 | { x: 30, y: 3.0 }, 40 | ], 41 | ]; 42 | const results = simpleLabel(mls, 10, 250, 0); 43 | assert.equal(results.length, 3); 44 | }); 45 | 46 | test("simple line labeler - one candidate, multiple labels based on repeatDistance", async () => { 47 | const mls = [ 48 | [ 49 | { x: 0, y: 0 }, 50 | { x: 500, y: 0 }, 51 | ], 52 | ]; 53 | const results = simpleLabel(mls, 100, 250, 0); 54 | assert.equal(results.length, 2); 55 | assert.deepEqual(results[0].start, { x: 0, y: 0 }); 56 | assert.deepEqual(results[0].end, { x: 100, y: 0 }); 57 | assert.deepEqual(results[1].start, { x: 250, y: 0 }); 58 | assert.deepEqual(results[1].end, { x: 350, y: 0 }); 59 | }); 60 | 61 | test("too small", async () => { 62 | const mls = [ 63 | [ 64 | { x: 0, y: 0 }, 65 | { x: 10, y: 0 }, 66 | ], 67 | ]; 68 | const results = simpleLabel(mls, 20, 250, 0); 69 | assert.equal(results.length, 0); 70 | }); 71 | 72 | test("line cells", async () => { 73 | const result = lineCells({ x: 0, y: 0 }, { x: 100, y: 0 }, 20, 5); 74 | assert.deepEqual(result, [ 75 | { x: 0, y: 0 }, 76 | { x: 10, y: 0 }, 77 | { x: 20, y: 0 }, 78 | ]); 79 | }); 80 | -------------------------------------------------------------------------------- /test/symbolizer.test.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import { test } from "node:test"; 3 | import { exp, linear, step } from "../src/symbolizer"; 4 | 5 | test("exp", async () => { 6 | let result = exp(1.4, [])(5); 7 | assert.equal(result, 0); 8 | result = exp(1.4, [ 9 | [5, 1.5], 10 | [11, 4], 11 | [16, 30], 12 | ])(5); 13 | assert.equal(result, 1.5); 14 | result = exp(1.4, [ 15 | [5, 1.5], 16 | [11, 4], 17 | [16, 30], 18 | ])(11); 19 | assert.equal(result, 4); 20 | result = exp(1.4, [ 21 | [5, 1.5], 22 | [11, 4], 23 | [16, 30], 24 | ])(16); 25 | assert.equal(result, 30); 26 | result = exp(1, [ 27 | [5, 1.5], 28 | [11, 4], 29 | [13, 6], 30 | ])(12); 31 | assert.equal(result, 5); 32 | }); 33 | 34 | test("step", async () => { 35 | let f = step(0, []); 36 | assert.equal(0, f(0)); 37 | f = step(5, [[4, 10]]); 38 | assert.equal(5, f(0)); 39 | assert.equal(10, f(4)); 40 | assert.equal(10, f(5)); 41 | f = step(5, [ 42 | [4, 10], 43 | [5, 15], 44 | ]); 45 | assert.equal(5, f(0)); 46 | assert.equal(10, f(4)); 47 | assert.equal(15, f(5)); 48 | f = step(5, [ 49 | [4, 10], 50 | [5, 12], 51 | [6, 15], 52 | ]); 53 | assert.equal(5, f(0)); 54 | assert.equal(10, f(4)); 55 | assert.equal(12, f(5)); 56 | assert.equal(15, f(6)); 57 | assert.equal(15, f(7)); 58 | }); 59 | 60 | test("linear", async () => { 61 | let f = linear([]); 62 | assert.equal(0, f(0)); 63 | f = linear([[4, 10]]); 64 | assert.equal(10, f(4)); 65 | assert.equal(10, f(5)); 66 | f = linear([ 67 | [4, 10], 68 | [6, 20], 69 | ]); 70 | assert.equal(10, f(3)); 71 | assert.equal(10, f(4)); 72 | assert.equal(15, f(5)); 73 | assert.equal(20, f(6)); 74 | assert.equal(20, f(7)); 75 | }); 76 | 77 | const precisionMatch = 0.0001; 78 | const almostEqual = (a, b) => Math.abs(a - b) <= precisionMatch; 79 | -------------------------------------------------------------------------------- /test/test_helpers.ts: -------------------------------------------------------------------------------- 1 | import { Feature, TileSource, Zxy } from "../src/tilecache"; 2 | 3 | export class StubSource implements TileSource { 4 | public async get(c: Zxy): Promise> { 5 | return new Map(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/text.test.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import { test } from "node:test"; 3 | import { linebreak } from "../src/text"; 4 | 5 | test("trivial", async () => { 6 | const lines = linebreak("foo", 15); 7 | assert.deepEqual(lines, ["foo"]); 8 | }); 9 | test("no break", async () => { 10 | const lines = linebreak("'s-Hertogenbosch", 15); 11 | assert.deepEqual(lines, ["'s-Hertogenbosch"]); 12 | }); 13 | test("break before", async () => { 14 | const lines = linebreak("Idlewild Airport", 15); 15 | assert.deepEqual(lines, ["Idlewild", "Airport"]); 16 | }); 17 | test("break after", async () => { 18 | const lines = linebreak("Idlewildgenbosch Airport", 15); 19 | assert.deepEqual(lines, ["Idlewildgenbosch", "Airport"]); 20 | }); 21 | 22 | test("multiple breaks", async () => { 23 | const lines = linebreak( 24 | "Idlewildgenbosch Idlewildgenbosch Idlewildgenbosch", 25 | 15, 26 | ); 27 | assert.deepEqual(lines, [ 28 | "Idlewildgenbosch", 29 | "Idlewildgenbosch", 30 | "Idlewildgenbosch", 31 | ]); 32 | }); 33 | 34 | test("very long break", async () => { 35 | const lines = linebreak( 36 | "Dorotheenstädtisch-Friedrichswerderscher und Französischer Friedhof", 37 | 15, 38 | ); 39 | assert.deepEqual(lines, [ 40 | "Dorotheenstädtisch-Friedrichswerderscher", 41 | "und Französischer", 42 | "Friedhof", 43 | ]); 44 | }); 45 | -------------------------------------------------------------------------------- /test/tilecache.test.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import { test } from "node:test"; 3 | import { 4 | isCcw, 5 | isInRing, 6 | pointInPolygon, 7 | pointMinDistToLines, 8 | pointMinDistToPoints, 9 | } from "../src/tilecache"; 10 | 11 | test("basic", async () => { 12 | // cache.get({z:0,x:0,y:0}).then(f => { 13 | // console.log(f) 14 | // }) 15 | }); 16 | 17 | test("point to point", async () => { 18 | assert.equal( 19 | pointMinDistToPoints({ x: 0, y: 0 }, [[{ x: 8, y: 0 }], [{ x: 4, y: 0 }]]), 20 | 4, 21 | ); 22 | }); 23 | 24 | test("point to line", async () => { 25 | assert.equal( 26 | pointMinDistToLines({ x: 0, y: 4 }, [ 27 | [ 28 | { x: 0, y: 0 }, 29 | { x: 10, y: 0 }, 30 | ], 31 | ]), 32 | 4, 33 | ); 34 | }); 35 | 36 | test("is in ring", async () => { 37 | assert.equal( 38 | true, 39 | isInRing({ x: 5, y: 5 }, [ 40 | { x: 0, y: 0 }, 41 | { x: 10, y: 0 }, 42 | { x: 10, y: 10 }, 43 | { x: 0, y: 10 }, 44 | { x: 0, y: 0 }, 45 | ]), 46 | ); 47 | 48 | // works for CCW 49 | assert.equal( 50 | true, 51 | isInRing({ x: 5, y: 5 }, [ 52 | { x: 0, y: 0 }, 53 | { x: 0, y: 10 }, 54 | { x: 10, y: 10 }, 55 | { x: 10, y: 0 }, 56 | { x: 0, y: 0 }, 57 | ]), 58 | ); 59 | }); 60 | 61 | test("is ccw", async () => { 62 | assert.equal( 63 | false, 64 | isCcw([ 65 | { x: 0, y: 0 }, 66 | { x: 10, y: 0 }, 67 | { x: 10, y: 10 }, 68 | { x: 0, y: 10 }, 69 | { x: 0, y: 0 }, 70 | ]), 71 | ); 72 | assert.equal( 73 | true, 74 | isCcw([ 75 | { x: 0, y: 0 }, 76 | { x: 0, y: 10 }, 77 | { x: 10, y: 10 }, 78 | { x: 10, y: 0 }, 79 | { x: 0, y: 0 }, 80 | ]), 81 | ); 82 | }); 83 | 84 | test("point in polygon", async () => { 85 | // simple case 86 | assert.equal( 87 | true, 88 | pointInPolygon({ x: 5, y: 5 }, [ 89 | [ 90 | { x: 0, y: 0 }, 91 | { x: 10, y: 0 }, 92 | { x: 10, y: 10 }, 93 | { x: 0, y: 10 }, 94 | { x: 0, y: 0 }, 95 | ], 96 | ]), 97 | ); 98 | 99 | // multiple exterior rings 100 | assert.equal( 101 | true, 102 | pointInPolygon({ x: 25, y: 25 }, [ 103 | [ 104 | { x: 0, y: 0 }, 105 | { x: 10, y: 0 }, 106 | { x: 10, y: 10 }, 107 | { x: 0, y: 10 }, 108 | { x: 0, y: 0 }, 109 | ], 110 | [ 111 | { x: 20, y: 20 }, 112 | { x: 30, y: 20 }, 113 | { x: 30, y: 30 }, 114 | { x: 20, y: 30 }, 115 | { x: 20, y: 20 }, 116 | ], 117 | ]), 118 | ); 119 | 120 | // simple case with holes 121 | assert.equal( 122 | false, 123 | pointInPolygon({ x: 5, y: 5 }, [ 124 | [ 125 | { x: 0, y: 0 }, 126 | { x: 10, y: 0 }, 127 | { x: 10, y: 10 }, 128 | { x: 0, y: 10 }, 129 | { x: 0, y: 0 }, 130 | ], 131 | [ 132 | { x: 7, y: 7 }, 133 | { x: 7, y: 3 }, 134 | { x: 3, y: 3 }, 135 | { x: 3, y: 7 }, 136 | { x: 7, y: 7 }, 137 | ], 138 | ]), 139 | ); 140 | }); 141 | -------------------------------------------------------------------------------- /test/view.test.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import { test } from "node:test"; 3 | import Point from "@mapbox/point-geometry"; 4 | import { PmtilesSource, TileCache } from "../src/tilecache"; 5 | import { View, sourcesToViews, wrap } from "../src/view"; 6 | import { StubSource } from "./test_helpers"; 7 | 8 | const cache = new TileCache(new StubSource(), 1024); 9 | 10 | test("basic, level diff = 0", async () => { 11 | const view = new View(cache, 3, 0); 12 | let result = view.dataTileForDisplayTile({ z: 3, x: 4, y: 1 }); 13 | assert.deepEqual(result.dataTile, { z: 3, x: 4, y: 1 }); 14 | assert.equal(result.scale, 1); 15 | assert.deepEqual(result.origin, new Point(256 * 4, 256 * 1)); 16 | assert.equal(result.dim, 1024); 17 | 18 | result = view.dataTileForDisplayTile({ z: 4, x: 4, y: 2 }); 19 | assert.deepEqual(result.dataTile, { z: 3, x: 2, y: 1 }); 20 | assert.equal(result.scale, 2); 21 | assert.deepEqual(result.origin, new Point(256 * 4, 256 * 2)); 22 | assert.equal(result.dim, 2048); 23 | }); 24 | 25 | test("level diff = 1", async () => { 26 | const view = new View(cache, 3, 1); 27 | let result = view.dataTileForDisplayTile({ z: 3, x: 4, y: 1 }); 28 | assert.deepEqual(result.dataTile, { z: 2, x: 2, y: 0 }); 29 | assert.equal(result.scale, 1); 30 | assert.deepEqual(result.origin, new Point(256 * 4, 256 * 0)); 31 | assert.equal(result.dim, 1024); 32 | 33 | result = view.dataTileForDisplayTile({ z: 1, x: 0, y: 0 }); 34 | assert.deepEqual(result.dataTile, { z: 0, x: 0, y: 0 }); 35 | assert.equal(result.scale, 1); 36 | assert.deepEqual(result.origin, new Point(256 * 0, 256 * 0)); 37 | 38 | result = view.dataTileForDisplayTile({ z: 0, x: 0, y: 0 }); 39 | assert.deepEqual(result.dataTile, { z: 0, x: 0, y: 0 }); 40 | assert.equal(result.scale, 0.5); 41 | assert.deepEqual(result.origin, new Point(256 * 0, 256 * 0)); 42 | assert.equal(result.dim, 512); 43 | }); 44 | 45 | test("level diff = 2", async () => { 46 | const view = new View(cache, 3, 2); 47 | const result = view.dataTileForDisplayTile({ z: 6, x: 9, y: 13 }); 48 | assert.deepEqual(result.dataTile, { z: 3, x: 1, y: 1 }); 49 | assert.equal(result.scale, 2); 50 | assert.deepEqual(result.origin, new Point(256 * 8, 256 * 8)); 51 | }); 52 | 53 | test("get center no level diff", async () => { 54 | const view = new View(cache, 3, 0); 55 | const result = view.dataTilesForBounds(3, { 56 | minX: 100, 57 | minY: 100, 58 | maxX: 400, 59 | maxY: 400, 60 | }); 61 | assert.equal(result.length, 4); 62 | }); 63 | 64 | test("get center level diff = 2", async () => { 65 | const view = new View(cache, 3, 2); 66 | const result = view.dataTilesForBounds(6, { 67 | minX: 100, 68 | minY: 100, 69 | maxX: 400, 70 | maxY: 400, 71 | }); 72 | assert.equal(result.length, 1); 73 | }); 74 | 75 | test("wrap tile coordinates", async () => { 76 | const view = new View(cache, 3, 2); 77 | const result = view.dataTilesForBounds(6, { 78 | minX: -100, 79 | minY: 100, 80 | maxX: 400, 81 | maxY: 400, 82 | }); 83 | assert.equal(result.length, 2); 84 | assert.deepEqual(result[0].dataTile, { z: 3, x: 7, y: 0 }); 85 | assert.deepEqual(result[1].dataTile, { z: 3, x: 0, y: 0 }); 86 | }); 87 | 88 | test("wrap", async () => { 89 | assert.equal(wrap(-1, 3), 7); 90 | assert.equal(wrap(8, 3), 0); 91 | }); 92 | 93 | test("sources to views", async () => { 94 | let v = sourcesToViews({ url: "http://example.com/{z}/{x}/{y}.mvt" }); 95 | assert.equal(v.get("").levelDiff, 1); 96 | v = sourcesToViews({ 97 | sources: { 98 | source1: { 99 | url: "http://example.com/{z}/{x}/{y}.mvt", 100 | }, 101 | }, 102 | }); 103 | assert.equal(v.get("source1").levelDiff, 1); 104 | }); 105 | 106 | test("sources to views with pmtiles parameters", async () => { 107 | const v = sourcesToViews({ url: "http://example.com/foo.pmtiles?k=v" }); 108 | assert.ok(v.get("").tileCache.source instanceof PmtilesSource); 109 | }); 110 | 111 | test("sources to views local param", async () => { 112 | const v = sourcesToViews({ url: "foo.pmtiles" }); 113 | assert.ok(v.get("").tileCache.source instanceof PmtilesSource); 114 | }); 115 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "target": "es6", 5 | "lib": ["es2020", "dom"], 6 | "strict": true, 7 | "moduleResolution": "node", 8 | "paths": { 9 | "@mapbox/unitbezier": ["./src/types/unitbezier.d.ts"] 10 | }, 11 | "types": ["node", "@types/css-font-loading-module"] 12 | }, 13 | "include": ["src/**/*"] 14 | } 15 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, type Options } from "tsup"; 2 | 3 | const baseOptions: Options = { 4 | clean: true, 5 | minify: true, 6 | skipNodeModulesBundle: true, 7 | sourcemap: true, 8 | target: "es6", 9 | tsconfig: "./tsconfig.json", 10 | keepNames: true, 11 | cjsInterop: true, 12 | splitting: true, 13 | }; 14 | 15 | export default [ 16 | defineConfig({ 17 | ...baseOptions, 18 | entry: ["src/index.ts"], 19 | outDir: "dist/cjs", 20 | format: "cjs", 21 | dts: true, 22 | }), 23 | defineConfig({ 24 | ...baseOptions, 25 | entry: ["src/index.ts"], 26 | outDir: "dist/esm", 27 | format: "esm", 28 | dts: true, 29 | }), 30 | defineConfig({ 31 | ...baseOptions, 32 | outdir: "dist", 33 | format: "iife", 34 | globalName: "protomapsL", 35 | entry: { 36 | "protomaps-leaflet": "src/index.ts", 37 | }, 38 | outExtension: () => { 39 | return { js: ".js" }; 40 | }, 41 | }), 42 | ]; 43 | --------------------------------------------------------------------------------