@mailwoman/map-tui 9.1.1 → 9.2.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/renderer.ts CHANGED
@@ -19,8 +19,8 @@ import { type MapFrame, overlayText, rasterizeToFrame, rgbToPacked } from "./fra
19
19
  import { lonLatToWorldPx, metersPerPixel, TILE_SIZE } from "./mercator.ts"
20
20
  import type { DecodedFeature } from "./mvt.ts"
21
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"
22
+ import { type LayerStyle, type RGB, styleForFeatureKind, stylesFor } from "./style.ts"
23
+ import type { DecodedTile, TileProvider } from "./tile-source.ts"
24
24
 
25
25
  export interface Viewport {
26
26
  centerLon: number
@@ -84,22 +84,39 @@ interface GridOrigin {
84
84
  * — that's what keeps `rasterizeFeature` under `max-params` (8) once `style`, `renderZoom`, and `pendingLabels` join
85
85
  * it. Threaded through as a value rather than closed over so those rasterizers stay free functions (no nesting inside
86
86
  * `renderFrame` deep enough to trip `max-depth`).
87
+ *
88
+ * `worldX`/`worldY` are the tile's top-left corner in render-zoom world pixels — for a native tile that is `tileX *
89
+ * TILE_SIZE`, for an overzoomed parent it is scaled by the tile's span, so the projection needs no zoom arithmetic of
90
+ * its own.
87
91
  */
88
92
  interface TileProjection {
89
- tileX: number
90
- tileY: number
93
+ worldX: number
94
+ worldY: number
91
95
  scale: number
92
96
  origin: GridOrigin
93
97
  }
94
98
 
99
+ /**
100
+ * A tile chosen for a viewport slot: the native tile when the archive has one, otherwise the nearest ancestor that
101
+ * exists. `span` is how many render-zoom world pixels the tile covers (`TILE_SIZE << dz` for an ancestor `dz` levels
102
+ * up) — a spatially sparse archive (deep zooms only where people are) degrades to coarse geometry instead of blank
103
+ * cells.
104
+ */
105
+ interface ResolvedTile {
106
+ tile: DecodedTile
107
+ tileX: number
108
+ tileY: number
109
+ span: number
110
+ }
111
+
95
112
  function clamp(value: number, min: number, max: number): number {
96
113
  return Math.min(Math.max(value, min), max)
97
114
  }
98
115
 
99
116
  function projectPoint(projection: TileProjection, gx: number, gy: number): ProjectedPoint {
100
117
  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,
118
+ x: projection.worldX + gx * projection.scale - projection.origin.x,
119
+ y: projection.worldY + gy * projection.scale - projection.origin.y,
103
120
  }
104
121
  }
105
122
 
@@ -178,23 +195,28 @@ function rasterizeFeature(
178
195
  */
179
196
  function rasterizeTileForKind(
180
197
  grid: RGBAGrid,
181
- tile: DecodedTile,
182
- tileX: number,
183
- tileY: number,
198
+ resolved: ResolvedTile,
184
199
  kind: LayerStyle["kind"],
185
200
  renderZoom: number,
186
201
  origin: GridOrigin,
187
202
  pendingLabels: PendingLabel[]
188
203
  ): void {
189
- for (const layer of tile.layers) {
204
+ for (const layer of resolved.tile.layers) {
190
205
  const styles = stylesFor(layer.name, renderZoom).filter((style) => style.kind === kind)
191
206
 
192
207
  if (!styles.length) continue
193
208
 
194
- const projection: TileProjection = { tileX, tileY, scale: TILE_SIZE / layer.extent, origin }
209
+ const projection: TileProjection = {
210
+ worldX: resolved.tileX * resolved.span,
211
+ worldY: resolved.tileY * resolved.span,
212
+ scale: resolved.span / layer.extent,
213
+ origin,
214
+ }
215
+
216
+ for (const feature of layer.features) {
217
+ const style = styleForFeatureKind(styles, feature.properties.kind)
195
218
 
196
- for (const style of styles) {
197
- for (const feature of layer.features) {
219
+ if (style) {
198
220
  rasterizeFeature(grid, feature, style, renderZoom, projection, pendingLabels)
199
221
  }
200
222
  }
@@ -207,12 +229,48 @@ function rasterizeTileForKind(
207
229
  * `TileSource` — it holds no per-frame state itself.
208
230
  */
209
231
  export class MapRenderer {
210
- private readonly source: TileSource
232
+ private readonly source: TileProvider
211
233
 
212
- constructor(source: TileSource) {
234
+ constructor(source: TileProvider) {
213
235
  this.source = source
214
236
  }
215
237
 
238
+ /**
239
+ * Resolves each viewport slot to its native tile or, when the archive has none — a spatially sparse deep band, a
240
+ * zoom-capped extract — the nearest existing ancestor. Ancestors shared by several absent slots are deduplicated so
241
+ * their geometry rasterizes once, and coarse tiles sort first so native detail paints over the fallback wherever both
242
+ * cover a cell.
243
+ */
244
+ private async resolveTiles(
245
+ tileCoords: ReadonlyArray<{ x: number; y: number }>,
246
+ renderZoom: number
247
+ ): Promise<ResolvedTile[]> {
248
+ const resolved = new Map<string, ResolvedTile>()
249
+
250
+ await Promise.all(
251
+ tileCoords.map(async ({ x, y }) => {
252
+ for (let zoom = renderZoom; zoom >= this.source.minZoom; zoom--) {
253
+ const shift = renderZoom - zoom
254
+ const tileX = x >> shift
255
+ const tileY = y >> shift
256
+ const key = `${zoom}/${tileX}/${tileY}`
257
+
258
+ if (resolved.has(key)) return
259
+
260
+ const tile = await this.source.getTile(zoom, tileX, tileY)
261
+
262
+ if (tile) {
263
+ resolved.set(key, { tile, tileX, tileY, span: TILE_SIZE * 2 ** shift })
264
+
265
+ return
266
+ }
267
+ }
268
+ })
269
+ )
270
+
271
+ return [...resolved.values()].toSorted((a, b) => b.span - a.span)
272
+ }
273
+
216
274
  async renderFrame(viewport: Viewport, overlays?: { markers?: MarkerSpec[]; ring?: RingSpec }): Promise<MapFrame> {
217
275
  const { centerLon, centerLat, columns, rows } = viewport
218
276
 
@@ -237,20 +295,14 @@ export class MapRenderer {
237
295
  }
238
296
  }
239
297
 
240
- const tiles = await Promise.all(tileCoords.map((coord) => this.source.getTile(renderZoom, coord.x, coord.y)))
298
+ const resolvedTiles = await this.resolveTiles(tileCoords, renderZoom)
241
299
 
242
300
  const grid = new RGBAGrid(subpixelW, subpixelH)
243
301
  const pendingLabels: PendingLabel[] = []
244
302
 
245
303
  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)
304
+ for (const resolved of resolvedTiles) {
305
+ rasterizeTileForKind(grid, resolved, kind, renderZoom, origin, pendingLabels)
254
306
  }
255
307
  }
256
308
 
package/style.ts CHANGED
@@ -14,23 +14,28 @@
14
14
 
15
15
  export type RGB = readonly [red: number, green: number, blue: number]
16
16
 
17
- export interface FillStyle {
18
- kind: "fill"
17
+ /**
18
+ * Fields shared by every style entry. `featureKinds` scopes an entry to features whose `kind` tile attribute is in the
19
+ * list; an entry without it is the layer's catch-all. Entries are consulted in table order and the first match wins, so
20
+ * kind-scoped entries precede the catch-all.
21
+ */
22
+ interface StyleBase {
19
23
  color: RGB
20
24
  minZoom: number
25
+ featureKinds?: readonly string[]
26
+ }
27
+
28
+ export interface FillStyle extends StyleBase {
29
+ kind: "fill"
21
30
  }
22
31
 
23
- export interface LineStyle {
32
+ export interface LineStyle extends StyleBase {
24
33
  kind: "line"
25
- color: RGB
26
- minZoom: number
27
34
  width: (zoom: number) => number
28
35
  }
29
36
 
30
- export interface LabelStyle {
37
+ export interface LabelStyle extends StyleBase {
31
38
  kind: "label"
32
- color: RGB
33
- minZoom: number
34
39
  property: string
35
40
  }
36
41
 
@@ -39,15 +44,48 @@ export type LayerStyle = FillStyle | LineStyle | LabelStyle
39
44
  // Zoom level at which roads render with 2px width instead of 1px.
40
45
  const ROAD_WIDTH_THRESHOLD = 14
41
46
 
47
+ const roadWidth = (zoom: number) => (zoom >= ROAD_WIDTH_THRESHOLD ? 2 : 1)
48
+
49
+ // At braille scale every road is one dot wide and every fill is stipple, so CLASS has to ride on color instead of
50
+ // geometry: arteries brighten toward amber, paths dim toward the vegetation green, and urban landuse runs warmer and
51
+ // brighter than vegetation so a city reads as denser texture. Luminance is load-bearing — asciify's ordered dither
52
+ // turns it into stipple density.
53
+ const VEGETATION_KINDS = [
54
+ "allotments",
55
+ "cemetery",
56
+ "farmland",
57
+ "forest",
58
+ "garden",
59
+ "grass",
60
+ "meadow",
61
+ "nature_reserve",
62
+ "park",
63
+ "scrub",
64
+ "wetland",
65
+ "wood",
66
+ ] as const
67
+
42
68
  const STYLE_TABLE: Record<string, LayerStyle[]> = {
43
69
  earth: [{ kind: "fill", color: [40, 44, 36], minZoom: 0 }],
44
70
  landcover: [{ kind: "fill", color: [36, 52, 32], minZoom: 4 }],
45
- landuse: [{ kind: "fill", color: [48, 48, 40], minZoom: 10 }],
71
+ landuse: [
72
+ { kind: "fill", color: [66, 60, 52], minZoom: 10, featureKinds: ["residential"] },
73
+ { kind: "fill", color: [72, 62, 50], minZoom: 10, featureKinds: ["commercial"] },
74
+ { kind: "fill", color: [58, 60, 64], minZoom: 10, featureKinds: ["industrial"] },
75
+ { kind: "fill", color: [36, 52, 32], minZoom: 10, featureKinds: VEGETATION_KINDS },
76
+ { kind: "fill", color: [48, 48, 40], minZoom: 10 },
77
+ ],
46
78
  water: [{ kind: "fill", color: [24, 48, 90], minZoom: 0 }],
47
79
  buildings: [{ kind: "fill", color: [70, 66, 60], minZoom: 13 }],
48
80
  boundaries: [{ kind: "line", color: [140, 110, 160], minZoom: 0, width: () => 1 }],
49
81
  roads: [
50
- { kind: "line", color: [170, 170, 150], minZoom: 6, width: (zoom) => (zoom >= ROAD_WIDTH_THRESHOLD ? 2 : 1) },
82
+ { kind: "line", color: [255, 190, 90], minZoom: 6, featureKinds: ["highway"], width: roadWidth },
83
+ { kind: "line", color: [215, 210, 180], minZoom: 6, featureKinds: ["major_road"], width: roadWidth },
84
+ { kind: "line", color: [185, 182, 158], minZoom: 6, featureKinds: ["medium_road"], width: roadWidth },
85
+ { kind: "line", color: [150, 150, 132], minZoom: 6, featureKinds: ["minor_road"], width: roadWidth },
86
+ { kind: "line", color: [105, 115, 98], minZoom: 6, featureKinds: ["path"], width: roadWidth },
87
+ { kind: "line", color: [115, 125, 140], minZoom: 6, featureKinds: ["rail"], width: () => 1 },
88
+ { kind: "line", color: [170, 170, 150], minZoom: 6, width: roadWidth },
51
89
  ],
52
90
  places: [{ kind: "label", color: [235, 235, 220], minZoom: 2, property: "name" }],
53
91
  pois: [{ kind: "label", color: [180, 200, 160], minZoom: 14, property: "name" }],
@@ -60,3 +98,18 @@ const STYLE_TABLE: Record<string, LayerStyle[]> = {
60
98
  export function stylesFor(layerName: string, zoom: number): LayerStyle[] {
61
99
  return (STYLE_TABLE[layerName] ?? []).filter((style) => zoom >= style.minZoom)
62
100
  }
101
+
102
+ /**
103
+ * The one style painting a feature: the first entry whose `featureKinds` contains the feature's `kind` attribute, or
104
+ * the first catch-all. A feature is painted at most once per draw pass — kind-scoped entries recolor, they never
105
+ * double-paint.
106
+ */
107
+ export function styleForFeatureKind(styles: readonly LayerStyle[], featureKind: unknown): LayerStyle | null {
108
+ for (const style of styles) {
109
+ if (style.featureKinds == null) return style
110
+
111
+ if (typeof featureKind === "string" && style.featureKinds.includes(featureKind)) return style
112
+ }
113
+
114
+ return null
115
+ }
package/tile-source.ts CHANGED
@@ -22,6 +22,17 @@ export interface DecodedTile {
22
22
  layers: DecodedLayer[]
23
23
  }
24
24
 
25
+ /**
26
+ * What a renderer needs from a tile archive — the read surface of {@link TileSource}, separated so a renderer can be
27
+ * driven by any provider: a single archive, a stub in tests, or a composite over several archives.
28
+ */
29
+ export interface TileProvider {
30
+ readonly minZoom: number
31
+ readonly maxZoom: number
32
+ readonly attribution: string
33
+ getTile(z: number, x: number, y: number): Promise<DecodedTile | null>
34
+ }
35
+
25
36
  class FilePMTilesSource implements Source {
26
37
  private readonly path: string
27
38
  private readonly handle: FileHandle
@@ -45,6 +56,38 @@ class FilePMTilesSource implements Source {
45
56
 
46
57
  const TILE_CACHE_LIMIT = 64
47
58
 
59
+ /**
60
+ * The named entities attribution strings actually carry — archive metadata is HTML (`<a>&copy; OpenStreetMap</a>`), and
61
+ * a terminal shows entities raw unless they decode here.
62
+ */
63
+ const HTML_ENTITIES: Record<string, string> = {
64
+ "&amp;": "&",
65
+ "&copy;": "©",
66
+ "&gt;": ">",
67
+ "&lt;": "<",
68
+ "&quot;": '"',
69
+ "&#39;": "'",
70
+ }
71
+
72
+ /**
73
+ * Plain-text attribution out of archive metadata (HTML tags stripped, entities decoded); empty string when absent.
74
+ */
75
+ export function readAttribution(metadata: unknown): string {
76
+ if (
77
+ typeof metadata !== "object" ||
78
+ metadata === null ||
79
+ !("attribution" in metadata) ||
80
+ typeof (metadata as { attribution: unknown }).attribution !== "string"
81
+ ) {
82
+ return ""
83
+ }
84
+
85
+ return (metadata as { attribution: string }).attribution
86
+ .replaceAll(/<[^>]+>/gu, "")
87
+ .replaceAll(/&[a-z]+;|&#\d+;/gu, (entity) => HTML_ENTITIES[entity] ?? entity)
88
+ .trim()
89
+ }
90
+
48
91
  /**
49
92
  * A single LRU cache slot. Wrapping the decoded tile in an object lets `getTile` tell "cached and known absent" (`{
50
93
  * tile: null }`) apart from "not yet cached" (no entry in the Map) using plain presence, with no comparison against
@@ -54,7 +97,7 @@ interface CacheEntry {
54
97
  tile: DecodedTile | null
55
98
  }
56
99
 
57
- export class TileSource {
100
+ export class TileSource implements TileProvider {
58
101
  readonly minZoom: number
59
102
  readonly maxZoom: number
60
103
 
@@ -63,11 +106,20 @@ export class TileSource {
63
106
  */
64
107
  readonly attribution: string
65
108
 
66
- private readonly handle: FileHandle
109
+ /**
110
+ * Null for HTTP sources — fetch connections have no handle to hold or close.
111
+ */
112
+ private readonly handle: FileHandle | null
67
113
  private readonly pmtiles: PMTiles
68
114
  private readonly cache = new Map<string, CacheEntry>()
69
115
 
70
- private constructor(handle: FileHandle, pmtiles: PMTiles, minZoom: number, maxZoom: number, attribution: string) {
116
+ private constructor(
117
+ handle: FileHandle | null,
118
+ pmtiles: PMTiles,
119
+ minZoom: number,
120
+ maxZoom: number,
121
+ attribution: string
122
+ ) {
71
123
  this.handle = handle
72
124
  this.pmtiles = pmtiles
73
125
  this.minZoom = minZoom
@@ -75,21 +127,24 @@ export class TileSource {
75
127
  this.attribution = attribution
76
128
  }
77
129
 
78
- static async open(path: string): Promise<TileSource> {
79
- const handle = await open(path, "r")
80
- const pmtiles = new PMTiles(new FilePMTilesSource(path, handle))
130
+ /**
131
+ * Opens a local `.pmtiles` path, or an `http(s)://` URL read via range requests — a hosted archive needs no tile
132
+ * server, only a host honoring `Range` (any static file server or object store does).
133
+ */
134
+ static async open(pathOrURL: string): Promise<TileSource> {
135
+ if (/^https?:\/\//u.test(pathOrURL)) {
136
+ const pmtiles = new PMTiles(pathOrURL)
137
+ const [header, metadata] = await Promise.all([pmtiles.getHeader(), pmtiles.getMetadata()])
81
138
 
82
- const [header, metadata] = await Promise.all([pmtiles.getHeader(), pmtiles.getMetadata()])
139
+ return new TileSource(null, pmtiles, header.minZoom, header.maxZoom, readAttribution(metadata))
140
+ }
141
+
142
+ const handle = await open(pathOrURL, "r")
143
+ const pmtiles = new PMTiles(new FilePMTilesSource(pathOrURL, handle))
83
144
 
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
- : ""
145
+ const [header, metadata] = await Promise.all([pmtiles.getHeader(), pmtiles.getMetadata()])
91
146
 
92
- return new TileSource(handle, pmtiles, header.minZoom, header.maxZoom, attribution)
147
+ return new TileSource(handle, pmtiles, header.minZoom, header.maxZoom, readAttribution(metadata))
93
148
  }
94
149
 
95
150
  /**
@@ -125,6 +180,6 @@ export class TileSource {
125
180
  }
126
181
 
127
182
  async close(): Promise<void> {
128
- await this.handle.close()
183
+ await this.handle?.close()
129
184
  }
130
185
  }