├── .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 | [](https://www.npmjs.com/package/protomaps-leaflet)
6 | [](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 |
29 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/examples/fonts.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
44 |
45 |
46 |
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 | Capture image
19 |
20 | +0.5
21 | -0.5
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 |
--------------------------------------------------------------------------------