@expofp/offline 3.10.1 → 3.11.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.
@@ -7,8 +7,19 @@ const MAPS_BASE_PATH = 'maps/';
7
7
  const MAP_STYLE_PATH = `${MAPS_BASE_PATH}style.json`;
8
8
  const MAP_PMTILES_PATH = `${MAPS_BASE_PATH}basemap.pmtiles`;
9
9
  const MAP_PMTILES_STYLE_URL = './basemap.pmtiles';
10
- const MAP_BOUNDS_PADDING_FACTOR = 3;
11
- const MAP_BOUNDS_MIN_PADDING_DEGREES = 0.02;
10
+ // Pannable-bounds padding for the offline tile region — sized off the viewport at
11
+ // max zoom-out (not the canvas span), so a small area map is extended enough to be
12
+ // seen fully when zoomed out, while a large one only gets a rotation margin. The
13
+ // online runtime pans freely (no maxBounds), so this only bounds how far out
14
+ // offline tiles are pre-generated; a safety factor keeps that region comfortably
15
+ // covered across screen sizes and out to the panned edges.
16
+ const MAP_MAX_ZOOM_OUT = 14;
17
+ const MERCATOR_M_PER_PX_Z0 = 78271.5168;
18
+ const M_PER_DEG_LAT = 110540;
19
+ const OFFLINE_REF_VIEWPORT_W = 3840;
20
+ const OFFLINE_REF_VIEWPORT_H = 2160;
21
+ const MAP_BOUNDS_MIN_PADDING_FRACTION = 0.05;
22
+ const OFFLINE_PADDING_SAFETY = 1.5;
12
23
  const DEFAULT_MAP_SOURCE_BASE = 'https://build.protomaps.com';
13
24
  const DEFAULT_MAP_SOURCE_LOOKBACK_DAYS = 7;
14
25
  const PROTOMAPS_SPRITE_BASE = 'https://protomaps.github.io/basemaps-assets/sprites/v4/white';
@@ -103,19 +114,19 @@ async function derivePaddedVenueBbox(manifest, fpSvgContext, signal) {
103
114
  if (!config) {
104
115
  throw new Error('Offline map generation requires fpGeo.properties.config.');
105
116
  }
106
- const svgArea = deriveSvgArea(ctx);
117
+ const mapBounds = deriveMapBoundsRect(ctx);
107
118
  const [anchorA, anchorB] = geoAnchors(config);
108
119
  const corners = [
109
- [svgArea.min.x, svgArea.min.y],
110
- [svgArea.max.x, svgArea.min.y],
111
- [svgArea.max.x, svgArea.max.y],
112
- [svgArea.min.x, svgArea.max.y],
120
+ [mapBounds.min.x, mapBounds.min.y],
121
+ [mapBounds.max.x, mapBounds.min.y],
122
+ [mapBounds.max.x, mapBounds.max.y],
123
+ [mapBounds.min.x, mapBounds.max.y],
113
124
  ].map(([x, y]) => {
114
125
  const { lat, lng } = projectLocalToGps({ x, y }, anchorA, anchorB);
115
126
  return [lng, lat];
116
127
  });
117
128
  if (!corners.every(([lng, lat]) => Number.isFinite(lng) && Number.isFinite(lat))) {
118
- throw new Error('Offline map generation produced invalid venue GPS bounds.');
129
+ throw new Error('Offline map generation produced invalid map GPS bounds.');
119
130
  }
120
131
  const lngs = corners.map(([lng]) => lng);
121
132
  const lats = corners.map(([, lat]) => lat);
@@ -123,8 +134,27 @@ async function derivePaddedVenueBbox(manifest, fpSvgContext, signal) {
123
134
  const maxLng = Math.max(...lngs);
124
135
  const minLat = Math.min(...lats);
125
136
  const maxLat = Math.max(...lats);
126
- const lngPadding = Math.max((maxLng - minLng) * MAP_BOUNDS_PADDING_FACTOR, MAP_BOUNDS_MIN_PADDING_DEGREES);
127
- const latPadding = Math.max((maxLat - minLat) * MAP_BOUNDS_PADDING_FACTOR, MAP_BOUNDS_MIN_PADDING_DEGREES);
137
+ const centerLat = (minLat + maxLat) / 2;
138
+ const cosLat = Math.max(0.01, Math.cos((centerLat * Math.PI) / 180));
139
+ const mPerDegLng = 111320 * cosLat;
140
+ // Work in metres (Mercator is isotropic in metres). Content span = canvas AABB.
141
+ const contentW = (maxLng - minLng) * mPerDegLng;
142
+ const contentH = (maxLat - minLat) * M_PER_DEG_LAT;
143
+ // Viewport span at max zoom-out, for a generous reference screen.
144
+ const mPerPx = (MERCATOR_M_PER_PX_Z0 * cosLat) / 2 ** MAP_MAX_ZOOM_OUT;
145
+ const viewW = OFFLINE_REF_VIEWPORT_W * mPerPx;
146
+ const viewH = OFFLINE_REF_VIEWPORT_H * mPerPx;
147
+ // That viewport rotated by the view bearing has a larger axis-aligned footprint.
148
+ const bearing = config.bearing ?? 0;
149
+ const c = Math.abs(Math.cos((bearing * Math.PI) / 180));
150
+ const s = Math.abs(Math.sin((bearing * Math.PI) / 180));
151
+ const viewRotW = viewW * c + viewH * s;
152
+ const viewRotH = viewW * s + viewH * c;
153
+ // Same rule as online (extend small / constant for large / floor), times safety.
154
+ const padW = Math.max((viewRotW - contentW) / 2, (viewRotW - viewW) / 2, viewW * MAP_BOUNDS_MIN_PADDING_FRACTION) * OFFLINE_PADDING_SAFETY;
155
+ const padH = Math.max((viewRotH - contentH) / 2, (viewRotH - viewH) / 2, viewH * MAP_BOUNDS_MIN_PADDING_FRACTION) * OFFLINE_PADDING_SAFETY;
156
+ const lngPadding = padW / mPerDegLng;
157
+ const latPadding = padH / M_PER_DEG_LAT;
128
158
  return [minLng - lngPadding, minLat - latPadding, maxLng + lngPadding, maxLat + latPadding];
129
159
  }
130
160
  async function resolveFpSvgContext(manifest, signal) {
@@ -136,26 +166,36 @@ async function resolveFpSvgContext(manifest, signal) {
136
166
  .catch(() => Date.now().toString());
137
167
  return await execScriptInSandbox(`${manifest.legacyDataUrlBase}fp.svg.js?v=${version}`, signal);
138
168
  }
139
- function deriveSvgArea(ctx) {
169
+ // The offline map extent — the whole SVG canvas, NOT the fit box: the full
170
+ // viewBox unioned with the explicit authored area (`__viewbox` / `rect#VIEWBOX`).
171
+ // Basing this on the
172
+ // authored area alone would, for a small venue `__viewbox`, preload only the
173
+ // venue's tiles and leave the rest of the pannable area blank offline.
174
+ function deriveMapBoundsRect(ctx) {
175
+ const fpSvg = ctx.__fp;
176
+ const viewBox = typeof fpSvg === 'string' ? getSvgViewBox(fpSvg) : null;
177
+ const explicit = deriveExplicitArea(ctx, fpSvg);
178
+ if (viewBox && explicit)
179
+ return Box.fromUnion(viewBox, explicit);
180
+ if (viewBox)
181
+ return viewBox;
182
+ if (explicit)
183
+ return explicit;
184
+ throw new Error('Offline map generation could not derive the map bounds (no SVG viewBox or VIEWBOX rect).');
185
+ }
186
+ // The explicit author-drawn area, if any: the `__viewbox` global or a
187
+ // `<rect id="VIEWBOX">` element. Null when neither is present.
188
+ function deriveExplicitArea(ctx, fpSvg) {
140
189
  const viewboxObj = ctx.__viewbox;
141
190
  if (viewboxObj) {
142
191
  return new Box(viewboxObj.x, viewboxObj.y, viewboxObj.width, viewboxObj.height);
143
192
  }
144
- const fpSvg = ctx.__fp;
145
- if (typeof fpSvg !== 'string') {
146
- throw new Error('Offline map generation requires __fp SVG data.');
193
+ if (typeof fpSvg === 'string') {
194
+ const viewboxRect = getSvgRectById(fpSvg, 'VIEWBOX');
195
+ if (viewboxRect)
196
+ return viewboxRect;
147
197
  }
148
- const viewboxRect = getSvgRectById(fpSvg, 'VIEWBOX');
149
- if (viewboxRect)
150
- return viewboxRect;
151
- const svgViewBox = getSvgViewBox(fpSvg);
152
- if (!svgViewBox) {
153
- throw new Error('Offline map generation could not derive the SVG viewBox.');
154
- }
155
- // Inflate the viewBox by 10% about its centre (a 5% margin per side), matching
156
- // the legacy withPadding(svgViewBox, -w*0.05, -h*0.05): a NEGATIVE padding GREW
157
- // the extent per side.
158
- return svgViewBox.scale(1.1);
198
+ return null;
159
199
  }
160
200
  function getSvgViewBox(svg) {
161
201
  const match = svg.match(/\bviewBox\s*=\s*["']([^"']+)["']/i);
@@ -309,28 +349,45 @@ function createMapStyle() {
309
349
  {
310
350
  id: 'background',
311
351
  type: 'background',
312
- paint: { 'background-color': '#f8f8f6' },
352
+ paint: { 'background-color': '#f5f1ef' },
313
353
  },
314
354
  {
315
355
  id: 'earth',
316
356
  type: 'fill',
317
357
  source: 'protomaps',
318
358
  'source-layer': 'earth',
319
- paint: { 'fill-color': '#f8f8f6' },
359
+ paint: { 'fill-color': '#f5f1ef' },
320
360
  },
321
361
  {
322
362
  id: 'landuse',
323
363
  type: 'fill',
324
364
  source: 'protomaps',
325
365
  'source-layer': 'landuse',
326
- paint: { 'fill-color': '#eeeeea', 'fill-opacity': 0.7 },
366
+ paint: {
367
+ 'fill-color': [
368
+ 'match',
369
+ ['get', 'kind'],
370
+ ['park', 'nature_reserve', 'forest', 'wood', 'grass'],
371
+ '#bdebb3',
372
+ ['hospital'],
373
+ '#f2dfdf',
374
+ ['school', 'university'],
375
+ '#e6dccf',
376
+ ['industrial'],
377
+ '#e2e3eb',
378
+ ['commercial'],
379
+ '#f5efdf',
380
+ '#e3d9d4',
381
+ ],
382
+ 'fill-opacity': 0.75,
383
+ },
327
384
  },
328
385
  {
329
386
  id: 'water',
330
387
  type: 'fill',
331
388
  source: 'protomaps',
332
389
  'source-layer': 'water',
333
- paint: { 'fill-color': '#d8e7ef' },
390
+ paint: { 'fill-color': '#99ddff' },
334
391
  },
335
392
  {
336
393
  id: 'buildings',
@@ -338,7 +395,41 @@ function createMapStyle() {
338
395
  source: 'protomaps',
339
396
  'source-layer': 'buildings',
340
397
  minzoom: 13,
341
- paint: { 'fill-color': '#dedbd4', 'fill-opacity': 0.65 },
398
+ paint: { 'fill-color': '#ded6d3', 'fill-opacity': 0.75 },
399
+ },
400
+ {
401
+ id: 'roads-minor-casing',
402
+ type: 'line',
403
+ source: 'protomaps',
404
+ 'source-layer': 'roads',
405
+ minzoom: 12,
406
+ filter: ['in', ['get', 'kind'], ['literal', ['minor_road', 'path', 'other']]],
407
+ paint: {
408
+ 'line-color': '#d2d6df',
409
+ 'line-width': ['interpolate', ['linear'], ['zoom'], 12, 1, 16, 3.5, 19, 10],
410
+ },
411
+ },
412
+ {
413
+ id: 'roads-major-casing',
414
+ type: 'line',
415
+ source: 'protomaps',
416
+ 'source-layer': 'roads',
417
+ filter: ['==', ['get', 'kind'], 'major_road'],
418
+ paint: {
419
+ 'line-color': '#d2d6df',
420
+ 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 1.4, 14, 4.5, 18, 14],
421
+ },
422
+ },
423
+ {
424
+ id: 'roads-highway-casing',
425
+ type: 'line',
426
+ source: 'protomaps',
427
+ 'source-layer': 'roads',
428
+ filter: ['==', ['get', 'kind'], 'highway'],
429
+ paint: {
430
+ 'line-color': '#f9fafc',
431
+ 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 1.6, 14, 5, 18, 16],
432
+ },
342
433
  },
343
434
  {
344
435
  id: 'roads-minor',
@@ -357,12 +448,23 @@ function createMapStyle() {
357
448
  type: 'line',
358
449
  source: 'protomaps',
359
450
  'source-layer': 'roads',
360
- filter: ['in', ['get', 'kind'], ['literal', ['highway', 'major_road']]],
451
+ filter: ['==', ['get', 'kind'], 'major_road'],
361
452
  paint: {
362
453
  'line-color': '#ffffff',
363
454
  'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.8, 14, 3, 18, 12],
364
455
  },
365
456
  },
457
+ {
458
+ id: 'roads-highway',
459
+ type: 'line',
460
+ source: 'protomaps',
461
+ 'source-layer': 'roads',
462
+ filter: ['==', ['get', 'kind'], 'highway'],
463
+ paint: {
464
+ 'line-color': '#ffb366',
465
+ 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.8, 14, 3, 18, 12],
466
+ },
467
+ },
366
468
  {
367
469
  id: 'road-labels',
368
470
  type: 'symbol',
@@ -377,7 +479,7 @@ function createMapStyle() {
377
479
  'text-size': ['interpolate', ['linear'], ['zoom'], 14, 10, 18, 13],
378
480
  },
379
481
  paint: {
380
- 'text-color': '#707070',
482
+ 'text-color': '#000000',
381
483
  'text-halo-color': '#ffffff',
382
484
  'text-halo-width': 1,
383
485
  },
@@ -404,7 +506,7 @@ function createMapStyle() {
404
506
  'text-offset': [0, 0.8],
405
507
  },
406
508
  paint: {
407
- 'text-color': '#545454',
509
+ 'text-color': '#333333',
408
510
  'text-halo-color': '#ffffff',
409
511
  'text-halo-width': 1.25,
410
512
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expofp/offline",
3
- "version": "3.10.1",
3
+ "version": "3.11.0",
4
4
  "type": "module",
5
5
  "description": "CLI tool for creating offline copies of ExpoFP floor plans",
6
6
  "homepage": "https://developer.expofp.com/",
@@ -31,10 +31,10 @@
31
31
  "dependencies": {
32
32
  "debug": "^4.4.3",
33
33
  "tslib": "^2.3.0",
34
- "@expofp/data": "3.10.1",
35
- "@expofp/floorplan": "3.10.1",
36
- "@expofp/geometry": "3.10.1",
37
- "@expofp/resolve": "3.10.1",
38
- "@expofp/utils": "3.10.1"
34
+ "@expofp/data": "3.11.0",
35
+ "@expofp/floorplan": "3.11.0",
36
+ "@expofp/geometry": "3.11.0",
37
+ "@expofp/resolve": "3.11.0",
38
+ "@expofp/utils": "3.11.0"
39
39
  }
40
40
  }