@mailwoman/map-tui 9.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/frame.ts +170 -0
- package/index.ts +13 -0
- package/mercator.ts +55 -0
- package/mvt.ts +54 -0
- package/out/frame.d.ts +54 -0
- package/out/frame.d.ts.map +1 -0
- package/out/frame.js +117 -0
- package/out/frame.js.map +1 -0
- package/out/index.d.ts +13 -0
- package/out/index.d.ts.map +1 -0
- package/out/index.js +13 -0
- package/out/index.js.map +1 -0
- package/out/mercator.d.ts +29 -0
- package/out/mercator.d.ts.map +1 -0
- package/out/mercator.js +38 -0
- package/out/mercator.js.map +1 -0
- package/out/mvt.d.ts +26 -0
- package/out/mvt.d.ts.map +1 -0
- package/out/mvt.js +29 -0
- package/out/mvt.js.map +1 -0
- package/out/raster.d.ts +61 -0
- package/out/raster.d.ts.map +1 -0
- package/out/raster.js +168 -0
- package/out/raster.js.map +1 -0
- package/out/renderer.d.ts +50 -0
- package/out/renderer.d.ts.map +1 -0
- package/out/renderer.js +168 -0
- package/out/renderer.js.map +1 -0
- package/out/style.d.ts +37 -0
- package/out/style.d.ts.map +1 -0
- package/out/style.js +28 -0
- package/out/style.js.map +1 -0
- package/out/tile-source.d.ts +28 -0
- package/out/tile-source.d.ts.map +1 -0
- package/out/tile-source.js +89 -0
- package/out/tile-source.js.map +1 -0
- package/out/tsconfig.test.tsbuildinfo +1 -0
- package/out/tsconfig.tsbuildinfo +1 -0
- package/package.json +48 -0
- package/raster.ts +213 -0
- package/renderer.ts +297 -0
- package/style.ts +62 -0
- package/tile-source.ts +130 -0
package/renderer.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software.
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Viewport-to-braille-frame renderer for map-tui's debug view.
|
|
9
|
+
*
|
|
10
|
+
* `MapRenderer.renderFrame` is the package's single public entry point: given a `Viewport` (center lon/lat, zoom, cell
|
|
11
|
+
* columns/rows), it fetches the covering tiles from a `TileSource`, rasterizes styled geometry (./style.ts,
|
|
12
|
+
* ./raster.ts) into a subpixel RGBA grid, converts that grid to braille cells (./frame.ts), then overlays collected
|
|
13
|
+
* labels and marker/ring annotations on top. Draw order is fill → line → label per the layer style table, with overlays
|
|
14
|
+
* (ring, labels, markers) layered afterward in that order — markers deliberately skip the label collision bitmap so a
|
|
15
|
+
* requested marker always wins the cell.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { type MapFrame, overlayText, rasterizeToFrame, rgbToPacked } from "./frame.ts"
|
|
19
|
+
import { lonLatToWorldPx, metersPerPixel, TILE_SIZE } from "./mercator.ts"
|
|
20
|
+
import type { DecodedFeature } from "./mvt.ts"
|
|
21
|
+
import { drawCircle, drawPolyline, fillPolygon, RGBAGrid } from "./raster.ts"
|
|
22
|
+
import { type LayerStyle, type RGB, stylesFor } from "./style.ts"
|
|
23
|
+
import type { DecodedTile, TileSource } from "./tile-source.ts"
|
|
24
|
+
|
|
25
|
+
export interface Viewport {
|
|
26
|
+
centerLon: number
|
|
27
|
+
centerLat: number
|
|
28
|
+
zoom: number
|
|
29
|
+
columns: number
|
|
30
|
+
rows: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MarkerSpec {
|
|
34
|
+
lon: number
|
|
35
|
+
lat: number
|
|
36
|
+
char?: string
|
|
37
|
+
color?: RGB
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RingSpec {
|
|
41
|
+
lon: number
|
|
42
|
+
lat: number
|
|
43
|
+
radiusMeters: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_MARKER_CHAR = "●"
|
|
47
|
+
const DEFAULT_MARKER_COLOR: RGB = [255, 80, 80]
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Subpixel dimensions per braille cell: 2 columns wide, 4 rows tall.
|
|
51
|
+
*/
|
|
52
|
+
const SUBPIXEL_COLUMNS_PER_CELL = 2
|
|
53
|
+
const SUBPIXEL_ROWS_PER_CELL = 4
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Minimum ring radius (in device pixels) worth drawing — smaller than this, the midpoint circle algorithm degenerates
|
|
57
|
+
* to a single point or nothing useful.
|
|
58
|
+
*/
|
|
59
|
+
const MIN_RING_RADIUS_PX = 2
|
|
60
|
+
|
|
61
|
+
interface ProjectedPoint {
|
|
62
|
+
x: number
|
|
63
|
+
y: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface PendingLabel {
|
|
67
|
+
text: string
|
|
68
|
+
column: number
|
|
69
|
+
row: number
|
|
70
|
+
color: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Origin of the render's subpixel grid, in world (Mercator) pixels — everything projected onto the grid is offset by
|
|
75
|
+
* this pair.
|
|
76
|
+
*/
|
|
77
|
+
interface GridOrigin {
|
|
78
|
+
x: number
|
|
79
|
+
y: number
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Everything a point projection needs, bundled so the per-feature rasterizers below take one parameter instead of four
|
|
84
|
+
* — that's what keeps `rasterizeFeature` under `max-params` (8) once `style`, `renderZoom`, and `pendingLabels` join
|
|
85
|
+
* it. Threaded through as a value rather than closed over so those rasterizers stay free functions (no nesting inside
|
|
86
|
+
* `renderFrame` deep enough to trip `max-depth`).
|
|
87
|
+
*/
|
|
88
|
+
interface TileProjection {
|
|
89
|
+
tileX: number
|
|
90
|
+
tileY: number
|
|
91
|
+
scale: number
|
|
92
|
+
origin: GridOrigin
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function clamp(value: number, min: number, max: number): number {
|
|
96
|
+
return Math.min(Math.max(value, min), max)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function projectPoint(projection: TileProjection, gx: number, gy: number): ProjectedPoint {
|
|
100
|
+
return {
|
|
101
|
+
x: projection.tileX * TILE_SIZE + gx * projection.scale - projection.origin.x,
|
|
102
|
+
y: projection.tileY * TILE_SIZE + gy * projection.scale - projection.origin.y,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function rasterizeFill(grid: RGBAGrid, feature: DecodedFeature, projection: TileProjection, color: RGB): void {
|
|
107
|
+
const rings = feature.geometry.map((ring) => ring.map((point) => projectPoint(projection, point.x, point.y)))
|
|
108
|
+
|
|
109
|
+
fillPolygon(grid, rings, color)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function rasterizeLine(
|
|
113
|
+
grid: RGBAGrid,
|
|
114
|
+
feature: DecodedFeature,
|
|
115
|
+
projection: TileProjection,
|
|
116
|
+
color: RGB,
|
|
117
|
+
width: number
|
|
118
|
+
): void {
|
|
119
|
+
for (const part of feature.geometry) {
|
|
120
|
+
const points = part.map((point) => projectPoint(projection, point.x, point.y))
|
|
121
|
+
|
|
122
|
+
drawPolyline(grid, points, color, width)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function collectLabels(
|
|
127
|
+
feature: DecodedFeature,
|
|
128
|
+
property: string,
|
|
129
|
+
color: RGB,
|
|
130
|
+
projection: TileProjection,
|
|
131
|
+
pendingLabels: PendingLabel[]
|
|
132
|
+
): void {
|
|
133
|
+
const text = String(feature.properties[property] ?? "")
|
|
134
|
+
|
|
135
|
+
if (!text.length) return
|
|
136
|
+
|
|
137
|
+
const packedColor = rgbToPacked(color)
|
|
138
|
+
|
|
139
|
+
for (const part of feature.geometry) {
|
|
140
|
+
for (const point of part) {
|
|
141
|
+
const projected = projectPoint(projection, point.x, point.y)
|
|
142
|
+
|
|
143
|
+
pendingLabels.push({
|
|
144
|
+
text,
|
|
145
|
+
column: Math.floor(projected.x / SUBPIXEL_COLUMNS_PER_CELL),
|
|
146
|
+
row: Math.floor(projected.y / SUBPIXEL_ROWS_PER_CELL),
|
|
147
|
+
color: packedColor,
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Rasterizes (or, for labels, collects) one feature under one style. The `style.kind` dispatch is the only branching
|
|
155
|
+
* here — the actual per-kind work lives in {@link rasterizeFill}/{@link rasterizeLine}/{@link collectLabels} so this
|
|
156
|
+
* stays a flat one-level `if`/`else` regardless of how deeply its caller is already nested.
|
|
157
|
+
*/
|
|
158
|
+
function rasterizeFeature(
|
|
159
|
+
grid: RGBAGrid,
|
|
160
|
+
feature: DecodedFeature,
|
|
161
|
+
style: LayerStyle,
|
|
162
|
+
renderZoom: number,
|
|
163
|
+
projection: TileProjection,
|
|
164
|
+
pendingLabels: PendingLabel[]
|
|
165
|
+
): void {
|
|
166
|
+
if (style.kind === "fill") {
|
|
167
|
+
rasterizeFill(grid, feature, projection, style.color)
|
|
168
|
+
} else if (style.kind === "line") {
|
|
169
|
+
rasterizeLine(grid, feature, projection, style.color, style.width(renderZoom))
|
|
170
|
+
} else {
|
|
171
|
+
collectLabels(feature, style.property, style.color, projection, pendingLabels)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Rasterizes every layer/style/feature in one tile matching `kind`. Pulled out of {@link MapRenderer.renderFrame} so
|
|
177
|
+
* that method's own tile loop doesn't accumulate this function's three nested loops on top of its own.
|
|
178
|
+
*/
|
|
179
|
+
function rasterizeTileForKind(
|
|
180
|
+
grid: RGBAGrid,
|
|
181
|
+
tile: DecodedTile,
|
|
182
|
+
tileX: number,
|
|
183
|
+
tileY: number,
|
|
184
|
+
kind: LayerStyle["kind"],
|
|
185
|
+
renderZoom: number,
|
|
186
|
+
origin: GridOrigin,
|
|
187
|
+
pendingLabels: PendingLabel[]
|
|
188
|
+
): void {
|
|
189
|
+
for (const layer of tile.layers) {
|
|
190
|
+
const styles = stylesFor(layer.name, renderZoom).filter((style) => style.kind === kind)
|
|
191
|
+
|
|
192
|
+
if (!styles.length) continue
|
|
193
|
+
|
|
194
|
+
const projection: TileProjection = { tileX, tileY, scale: TILE_SIZE / layer.extent, origin }
|
|
195
|
+
|
|
196
|
+
for (const style of styles) {
|
|
197
|
+
for (const feature of layer.features) {
|
|
198
|
+
rasterizeFeature(grid, feature, style, renderZoom, projection, pendingLabels)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Walks a viewport's rendering pipeline: tile fetch, style-ordered rasterization, braille conversion, then overlay
|
|
206
|
+
* annotations (ring, labels, markers). One `MapRenderer` can render any number of viewports against the same
|
|
207
|
+
* `TileSource` — it holds no per-frame state itself.
|
|
208
|
+
*/
|
|
209
|
+
export class MapRenderer {
|
|
210
|
+
private readonly source: TileSource
|
|
211
|
+
|
|
212
|
+
constructor(source: TileSource) {
|
|
213
|
+
this.source = source
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async renderFrame(viewport: Viewport, overlays?: { markers?: MarkerSpec[]; ring?: RingSpec }): Promise<MapFrame> {
|
|
217
|
+
const { centerLon, centerLat, columns, rows } = viewport
|
|
218
|
+
|
|
219
|
+
const subpixelW = columns * SUBPIXEL_COLUMNS_PER_CELL
|
|
220
|
+
const subpixelH = rows * SUBPIXEL_ROWS_PER_CELL
|
|
221
|
+
|
|
222
|
+
const renderZoom = clamp(Math.round(viewport.zoom), this.source.minZoom, this.source.maxZoom)
|
|
223
|
+
const center = lonLatToWorldPx(centerLon, centerLat, renderZoom)
|
|
224
|
+
const origin: GridOrigin = { x: center.x - subpixelW / 2, y: center.y - subpixelH / 2 }
|
|
225
|
+
|
|
226
|
+
const zoomTileCount = 2 ** renderZoom
|
|
227
|
+
const minTileX = clamp(Math.floor(origin.x / TILE_SIZE), 0, zoomTileCount - 1)
|
|
228
|
+
const maxTileX = clamp(Math.floor((origin.x + subpixelW) / TILE_SIZE), 0, zoomTileCount - 1)
|
|
229
|
+
const minTileY = clamp(Math.floor(origin.y / TILE_SIZE), 0, zoomTileCount - 1)
|
|
230
|
+
const maxTileY = clamp(Math.floor((origin.y + subpixelH) / TILE_SIZE), 0, zoomTileCount - 1)
|
|
231
|
+
|
|
232
|
+
const tileCoords: Array<{ x: number; y: number }> = []
|
|
233
|
+
|
|
234
|
+
for (let tileY = minTileY; tileY <= maxTileY; tileY++) {
|
|
235
|
+
for (let tileX = minTileX; tileX <= maxTileX; tileX++) {
|
|
236
|
+
tileCoords.push({ x: tileX, y: tileY })
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const tiles = await Promise.all(tileCoords.map((coord) => this.source.getTile(renderZoom, coord.x, coord.y)))
|
|
241
|
+
|
|
242
|
+
const grid = new RGBAGrid(subpixelW, subpixelH)
|
|
243
|
+
const pendingLabels: PendingLabel[] = []
|
|
244
|
+
|
|
245
|
+
for (const kind of ["fill", "line", "label"] as const) {
|
|
246
|
+
for (let i = 0; i < tileCoords.length; i++) {
|
|
247
|
+
const tile = tiles[i]
|
|
248
|
+
|
|
249
|
+
if (!tile) continue
|
|
250
|
+
|
|
251
|
+
const { x: tileX, y: tileY } = tileCoords[i]!
|
|
252
|
+
|
|
253
|
+
rasterizeTileForKind(grid, tile, tileX, tileY, kind, renderZoom, origin, pendingLabels)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (overlays?.ring) {
|
|
258
|
+
const { lon, lat, radiusMeters } = overlays.ring
|
|
259
|
+
const radiusPx = radiusMeters / metersPerPixel(lat, renderZoom)
|
|
260
|
+
|
|
261
|
+
if (radiusPx >= MIN_RING_RADIUS_PX) {
|
|
262
|
+
const ringCenterWorld = lonLatToWorldPx(lon, lat, renderZoom)
|
|
263
|
+
const ringColor: RGB = [255, 255, 255]
|
|
264
|
+
|
|
265
|
+
drawCircle(grid, ringCenterWorld.x - origin.x, ringCenterWorld.y - origin.y, radiusPx, ringColor)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const frame = rasterizeToFrame(grid, columns, rows, this.source.attribution)
|
|
270
|
+
|
|
271
|
+
const occupied = new Uint8Array(columns * rows)
|
|
272
|
+
|
|
273
|
+
for (const label of pendingLabels) {
|
|
274
|
+
overlayText(frame, label.column, label.row, label.text, label.color, occupied)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (overlays?.markers) {
|
|
278
|
+
for (const marker of overlays.markers) {
|
|
279
|
+
const markerWorld = lonLatToWorldPx(marker.lon, marker.lat, renderZoom)
|
|
280
|
+
const px = markerWorld.x - origin.x
|
|
281
|
+
const py = markerWorld.y - origin.y
|
|
282
|
+
const column = Math.floor(px / SUBPIXEL_COLUMNS_PER_CELL)
|
|
283
|
+
const row = Math.floor(py / SUBPIXEL_ROWS_PER_CELL)
|
|
284
|
+
|
|
285
|
+
overlayText(
|
|
286
|
+
frame,
|
|
287
|
+
column,
|
|
288
|
+
row,
|
|
289
|
+
marker.char ?? DEFAULT_MARKER_CHAR,
|
|
290
|
+
rgbToPacked(marker.color ?? DEFAULT_MARKER_COLOR)
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return frame
|
|
296
|
+
}
|
|
297
|
+
}
|
package/style.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software.
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Protomaps-basemap style table for the map-tui debug view.
|
|
9
|
+
*
|
|
10
|
+
* Defines fill, line, and label styles for each of the nine protomaps-basemap layers, gated by zoom level. Color
|
|
11
|
+
* palette calibrated for dark-terminal rendering: dim fills (read as stipple density via dithering), bright lines and
|
|
12
|
+
* labels.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type RGB = readonly [red: number, green: number, blue: number]
|
|
16
|
+
|
|
17
|
+
export interface FillStyle {
|
|
18
|
+
kind: "fill"
|
|
19
|
+
color: RGB
|
|
20
|
+
minZoom: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LineStyle {
|
|
24
|
+
kind: "line"
|
|
25
|
+
color: RGB
|
|
26
|
+
minZoom: number
|
|
27
|
+
width: (zoom: number) => number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LabelStyle {
|
|
31
|
+
kind: "label"
|
|
32
|
+
color: RGB
|
|
33
|
+
minZoom: number
|
|
34
|
+
property: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type LayerStyle = FillStyle | LineStyle | LabelStyle
|
|
38
|
+
|
|
39
|
+
// Zoom level at which roads render with 2px width instead of 1px.
|
|
40
|
+
const ROAD_WIDTH_THRESHOLD = 14
|
|
41
|
+
|
|
42
|
+
const STYLE_TABLE: Record<string, LayerStyle[]> = {
|
|
43
|
+
earth: [{ kind: "fill", color: [40, 44, 36], minZoom: 0 }],
|
|
44
|
+
landcover: [{ kind: "fill", color: [36, 52, 32], minZoom: 4 }],
|
|
45
|
+
landuse: [{ kind: "fill", color: [48, 48, 40], minZoom: 10 }],
|
|
46
|
+
water: [{ kind: "fill", color: [24, 48, 90], minZoom: 0 }],
|
|
47
|
+
buildings: [{ kind: "fill", color: [70, 66, 60], minZoom: 13 }],
|
|
48
|
+
boundaries: [{ kind: "line", color: [140, 110, 160], minZoom: 0, width: () => 1 }],
|
|
49
|
+
roads: [
|
|
50
|
+
{ kind: "line", color: [170, 170, 150], minZoom: 6, width: (zoom) => (zoom >= ROAD_WIDTH_THRESHOLD ? 2 : 1) },
|
|
51
|
+
],
|
|
52
|
+
places: [{ kind: "label", color: [235, 235, 220], minZoom: 2, property: "name" }],
|
|
53
|
+
pois: [{ kind: "label", color: [180, 200, 160], minZoom: 14, property: "name" }],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Styles applying to a protomaps-basemap layer at a zoom, draw-ordered (fills < lines < labels). Empty for
|
|
58
|
+
* unstyled/gated layers.
|
|
59
|
+
*/
|
|
60
|
+
export function stylesFor(layerName: string, zoom: number): LayerStyle[] {
|
|
61
|
+
return (STYLE_TABLE[layerName] ?? []).filter((style) => zoom >= style.minZoom)
|
|
62
|
+
}
|
package/tile-source.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software.
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* PMTiles archive reader for map-tui.
|
|
9
|
+
*
|
|
10
|
+
* TileSource wraps a local `.pmtiles` file (node:fs/promises FileHandle) behind the pmtiles `Source` interface, decodes
|
|
11
|
+
* each requested tile's MVT payload via ./mvt.ts, and keeps a small LRU cache of decoded tiles so repeated draws of the
|
|
12
|
+
* same viewport don't re-decode.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { type FileHandle, open } from "node:fs/promises"
|
|
16
|
+
|
|
17
|
+
import { PMTiles, type RangeResponse, type Source } from "pmtiles"
|
|
18
|
+
|
|
19
|
+
import { type DecodedLayer, decodeMVT } from "./mvt.ts"
|
|
20
|
+
|
|
21
|
+
export interface DecodedTile {
|
|
22
|
+
layers: DecodedLayer[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class FilePMTilesSource implements Source {
|
|
26
|
+
private readonly path: string
|
|
27
|
+
private readonly handle: FileHandle
|
|
28
|
+
|
|
29
|
+
constructor(path: string, handle: FileHandle) {
|
|
30
|
+
this.path = path
|
|
31
|
+
this.handle = handle
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
getKey(): string {
|
|
35
|
+
return this.path
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async getBytes(offset: number, length: number): Promise<RangeResponse> {
|
|
39
|
+
const buffer = Buffer.alloc(length)
|
|
40
|
+
const { bytesRead } = await this.handle.read(buffer, 0, length, offset)
|
|
41
|
+
|
|
42
|
+
return { data: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + bytesRead) }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const TILE_CACHE_LIMIT = 64
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A single LRU cache slot. Wrapping the decoded tile in an object lets `getTile` tell "cached and known absent" (`{
|
|
50
|
+
* tile: null }`) apart from "not yet cached" (no entry in the Map) using plain presence, with no comparison against
|
|
51
|
+
* `undefined` needed.
|
|
52
|
+
*/
|
|
53
|
+
interface CacheEntry {
|
|
54
|
+
tile: DecodedTile | null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class TileSource {
|
|
58
|
+
readonly minZoom: number
|
|
59
|
+
readonly maxZoom: number
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Plain-text attribution from archive metadata (HTML tags stripped); empty string when absent.
|
|
63
|
+
*/
|
|
64
|
+
readonly attribution: string
|
|
65
|
+
|
|
66
|
+
private readonly handle: FileHandle
|
|
67
|
+
private readonly pmtiles: PMTiles
|
|
68
|
+
private readonly cache = new Map<string, CacheEntry>()
|
|
69
|
+
|
|
70
|
+
private constructor(handle: FileHandle, pmtiles: PMTiles, minZoom: number, maxZoom: number, attribution: string) {
|
|
71
|
+
this.handle = handle
|
|
72
|
+
this.pmtiles = pmtiles
|
|
73
|
+
this.minZoom = minZoom
|
|
74
|
+
this.maxZoom = maxZoom
|
|
75
|
+
this.attribution = attribution
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
static async open(path: string): Promise<TileSource> {
|
|
79
|
+
const handle = await open(path, "r")
|
|
80
|
+
const pmtiles = new PMTiles(new FilePMTilesSource(path, handle))
|
|
81
|
+
|
|
82
|
+
const [header, metadata] = await Promise.all([pmtiles.getHeader(), pmtiles.getMetadata()])
|
|
83
|
+
|
|
84
|
+
const attribution =
|
|
85
|
+
typeof metadata === "object" &&
|
|
86
|
+
metadata !== null &&
|
|
87
|
+
"attribution" in metadata &&
|
|
88
|
+
typeof (metadata as { attribution: unknown }).attribution === "string"
|
|
89
|
+
? (metadata as { attribution: string }).attribution.replaceAll(/<[^>]+>/gu, "").trim()
|
|
90
|
+
: ""
|
|
91
|
+
|
|
92
|
+
return new TileSource(handle, pmtiles, header.minZoom, header.maxZoom, attribution)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Decoded tile, LRU-cached (64 entries). null = tile absent from the archive.
|
|
97
|
+
*/
|
|
98
|
+
async getTile(z: number, x: number, y: number): Promise<DecodedTile | null> {
|
|
99
|
+
const key = `${z}/${x}/${y}`
|
|
100
|
+
const cached = this.cache.get(key)
|
|
101
|
+
|
|
102
|
+
if (cached != null) {
|
|
103
|
+
// Refresh recency by re-inserting at the end of iteration order.
|
|
104
|
+
this.cache.delete(key)
|
|
105
|
+
this.cache.set(key, cached)
|
|
106
|
+
|
|
107
|
+
return cached.tile
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const response = await this.pmtiles.getZxy(z, x, y)
|
|
111
|
+
|
|
112
|
+
const tile: DecodedTile | null = response != null ? { layers: decodeMVT(new Uint8Array(response.data)) } : null
|
|
113
|
+
|
|
114
|
+
this.cache.set(key, { tile })
|
|
115
|
+
|
|
116
|
+
if (this.cache.size > TILE_CACHE_LIMIT) {
|
|
117
|
+
const oldestKey = this.cache.keys().next().value
|
|
118
|
+
|
|
119
|
+
if (typeof oldestKey === "string") {
|
|
120
|
+
this.cache.delete(oldestKey)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return tile
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async close(): Promise<void> {
|
|
128
|
+
await this.handle.close()
|
|
129
|
+
}
|
|
130
|
+
}
|