@expofp/offline 0.0.0-experimental.d269d30

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.
Files changed (34) hide show
  1. package/README.md +49 -0
  2. package/dist/cli.d.ts +3 -0
  3. package/dist/cli.js +80 -0
  4. package/dist/index.d.ts +5 -0
  5. package/dist/index.js +3 -0
  6. package/dist/lib/abort-signal.d.ts +6 -0
  7. package/dist/lib/abort-signal.js +20 -0
  8. package/dist/lib/data-to-files.d.ts +8 -0
  9. package/dist/lib/data-to-files.js +36 -0
  10. package/dist/lib/download-offline-zip.d.ts +4 -0
  11. package/dist/lib/download-offline-zip.js +12 -0
  12. package/dist/lib/exec-script-in-sandbox.d.ts +3 -0
  13. package/dist/lib/exec-script-in-sandbox.js +50 -0
  14. package/dist/lib/generate-offline-data-legacy.d.ts +6 -0
  15. package/dist/lib/generate-offline-data-legacy.js +85 -0
  16. package/dist/lib/generate-offline-data.d.ts +6 -0
  17. package/dist/lib/generate-offline-data.js +90 -0
  18. package/dist/lib/generate-offline-map-data.d.ts +16 -0
  19. package/dist/lib/generate-offline-map-data.js +463 -0
  20. package/dist/lib/generate-runtime-files-data.d.ts +5 -0
  21. package/dist/lib/generate-runtime-files-data.js +16 -0
  22. package/dist/lib/offlinize-asset-url.d.ts +6 -0
  23. package/dist/lib/offlinize-asset-url.js +35 -0
  24. package/dist/lib/offlinize-assets-in-place.d.ts +6 -0
  25. package/dist/lib/offlinize-assets-in-place.js +139 -0
  26. package/dist/lib/offlinize-css-asset-text.d.ts +6 -0
  27. package/dist/lib/offlinize-css-asset-text.js +52 -0
  28. package/dist/lib/resolve-floorplan-dir.d.ts +8 -0
  29. package/dist/lib/resolve-floorplan-dir.js +34 -0
  30. package/dist/lib/save-offline-zip.d.ts +4 -0
  31. package/dist/lib/save-offline-zip.js +21 -0
  32. package/dist/lib/types.d.ts +18 -0
  33. package/dist/lib/types.js +1 -0
  34. package/package.json +39 -0
@@ -0,0 +1,463 @@
1
+ import { resolve } from '@expofp/resolve';
2
+ import debug from 'debug';
3
+ import { execScriptInSandbox } from './exec-script-in-sandbox.js';
4
+ const log = debug('efp:offline:generate-offline-map-data');
5
+ const MAPS_BASE_PATH = 'maps/';
6
+ const MAP_STYLE_PATH = `${MAPS_BASE_PATH}style.json`;
7
+ const MAP_PMTILES_PATH = `${MAPS_BASE_PATH}basemap.pmtiles`;
8
+ const MAP_PMTILES_STYLE_URL = './basemap.pmtiles';
9
+ const MAP_BOUNDS_PADDING_FACTOR = 3;
10
+ const MAP_BOUNDS_MIN_PADDING_DEGREES = 0.02;
11
+ const DEFAULT_MAP_SOURCE_BASE = 'https://build.protomaps.com';
12
+ const DEFAULT_MAP_SOURCE_LOOKBACK_DAYS = 7;
13
+ const PROTOMAPS_SPRITE_BASE = 'https://protomaps.github.io/basemaps-assets/sprites/v4/white';
14
+ const PROTOMAPS_FONT_BASE = 'https://protomaps.github.io/basemaps-assets/fonts';
15
+ const GLYPH_FONTSTACK = 'Noto Sans Regular';
16
+ const GLYPH_RANGES = createGlyphRanges(0, 8447);
17
+ const ENGLISH_LABEL_FIELD = [
18
+ 'coalesce',
19
+ ['get', 'name:en'],
20
+ ['get', 'name_en'],
21
+ ['get', 'name:latin'],
22
+ ['get', 'name'],
23
+ ];
24
+ export function generateOfflineMapData(manifest, options) {
25
+ const maplibre = {
26
+ styleUrl: `./${MAP_STYLE_PATH}`,
27
+ offline: true,
28
+ };
29
+ async function* files() {
30
+ if (!manifest.legacyDataUrlBase) {
31
+ throw new Error('Offline map generation requires legacyDataUrlBase in the manifest.');
32
+ }
33
+ const bbox = await derivePaddedVenueBbox(manifest, options.signal);
34
+ log('Extracting PMTiles for bbox', bbox.join(','));
35
+ const pmtilesFile = await extractPmtiles(options.mapSource, bbox);
36
+ try {
37
+ yield {
38
+ url: pmtilesFile.url,
39
+ targetFilePath: MAP_PMTILES_PATH,
40
+ };
41
+ yield {
42
+ text: JSON.stringify(createMapStyle(), null, 2),
43
+ targetFilePath: MAP_STYLE_PATH,
44
+ };
45
+ yield* getSpriteAssets();
46
+ yield* getGlyphAssets();
47
+ }
48
+ finally {
49
+ await cleanupTempDir(pmtilesFile.tempDir);
50
+ }
51
+ }
52
+ return { files: files(), maplibre };
53
+ }
54
+ export async function validateOfflineMapOptions(options) {
55
+ if (!options.mapSource) {
56
+ console.log('Looking for latest Protomaps map source...');
57
+ options.mapSource = await resolveDefaultMapSource();
58
+ }
59
+ await assertValidMapSource(options.mapSource);
60
+ console.log(`Using map source ${options.mapSource}`);
61
+ await assertPmtilesCliAvailable();
62
+ return options;
63
+ }
64
+ async function resolveDefaultMapSource() {
65
+ for (let dayOffset = 0; dayOffset < DEFAULT_MAP_SOURCE_LOOKBACK_DAYS; dayOffset++) {
66
+ const source = createDefaultMapSource(dayOffset);
67
+ const response = await fetch(source, { method: 'HEAD' });
68
+ if (response.ok)
69
+ return source;
70
+ if (response.status !== 404) {
71
+ throw new Error(`Unable to check default offline map source ${source}: HTTP ${response.status}`);
72
+ }
73
+ }
74
+ throw new Error(`Could not find a default Protomaps PMTiles build from the last ${DEFAULT_MAP_SOURCE_LOOKBACK_DAYS} days. Specify --map-source explicitly.`);
75
+ }
76
+ function createDefaultMapSource(dayOffset) {
77
+ const date = new Date();
78
+ date.setUTCDate(date.getUTCDate() - dayOffset);
79
+ const year = date.getUTCFullYear();
80
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
81
+ const day = String(date.getUTCDate()).padStart(2, '0');
82
+ return `${DEFAULT_MAP_SOURCE_BASE}/${year}${month}${day}.pmtiles`;
83
+ }
84
+ async function derivePaddedVenueBbox(manifest, signal) {
85
+ const version = await resolve(manifest.legacyDataVersion)
86
+ .then((json) => json?.version)
87
+ .catch(() => Date.now().toString());
88
+ const ctx = await execScriptInSandbox(`${manifest.legacyDataUrlBase}fp.svg.js?v=${version}`, signal);
89
+ const fpGeo = ctx.__fpGeo;
90
+ const config = fpGeo?.properties?.config;
91
+ if (!config) {
92
+ throw new Error('Offline map generation requires fpGeo.properties.config.');
93
+ }
94
+ const svgArea = deriveSvgArea(ctx);
95
+ const corners = [
96
+ [svgArea.x1, svgArea.y1],
97
+ [svgArea.x2, svgArea.y1],
98
+ [svgArea.x2, svgArea.y2],
99
+ [svgArea.x1, svgArea.y2],
100
+ ].map(([x, y]) => convertLocalToGps(x, y, config));
101
+ if (!corners.every(([lng, lat]) => Number.isFinite(lng) && Number.isFinite(lat))) {
102
+ throw new Error('Offline map generation produced invalid venue GPS bounds.');
103
+ }
104
+ const lngs = corners.map(([lng]) => lng);
105
+ const lats = corners.map(([, lat]) => lat);
106
+ const minLng = Math.min(...lngs);
107
+ const maxLng = Math.max(...lngs);
108
+ const minLat = Math.min(...lats);
109
+ const maxLat = Math.max(...lats);
110
+ const lngPadding = Math.max((maxLng - minLng) * MAP_BOUNDS_PADDING_FACTOR, MAP_BOUNDS_MIN_PADDING_DEGREES);
111
+ const latPadding = Math.max((maxLat - minLat) * MAP_BOUNDS_PADDING_FACTOR, MAP_BOUNDS_MIN_PADDING_DEGREES);
112
+ return [minLng - lngPadding, minLat - latPadding, maxLng + lngPadding, maxLat + latPadding];
113
+ }
114
+ function deriveSvgArea(ctx) {
115
+ const viewboxObj = ctx.__viewbox;
116
+ if (viewboxObj) {
117
+ return fromXywh(viewboxObj.x, viewboxObj.y, viewboxObj.width, viewboxObj.height);
118
+ }
119
+ const fpSvg = ctx.__fp;
120
+ if (typeof fpSvg !== 'string') {
121
+ throw new Error('Offline map generation requires __fp SVG data.');
122
+ }
123
+ const viewboxRect = getSvgRectById(fpSvg, 'VIEWBOX');
124
+ if (viewboxRect)
125
+ return viewboxRect;
126
+ const svgViewBox = getSvgViewBox(fpSvg);
127
+ if (!svgViewBox) {
128
+ throw new Error('Offline map generation could not derive the SVG viewBox.');
129
+ }
130
+ return withPadding(svgViewBox, -width(svgViewBox) * 0.05, -height(svgViewBox) * 0.05);
131
+ }
132
+ function getSvgViewBox(svg) {
133
+ const match = svg.match(/\bviewBox\s*=\s*["']([^"']+)["']/i);
134
+ if (!match)
135
+ return null;
136
+ const [x, y, w, h] = match[1]
137
+ .trim()
138
+ .split(/[\s,]+/)
139
+ .map(Number);
140
+ if (![x, y, w, h].every(Number.isFinite))
141
+ return null;
142
+ return fromXywh(x, y, w, h);
143
+ }
144
+ function getSvgRectById(svg, id) {
145
+ const rectMatch = svg.match(new RegExp(`<rect\\b(?=[^>]*\\bid=["']${id}["'])[^>]*/?>`, 'i'));
146
+ if (!rectMatch)
147
+ return null;
148
+ const rect = rectMatch[0];
149
+ const x = Number(getSvgAttribute(rect, 'x') ?? 0);
150
+ const y = Number(getSvgAttribute(rect, 'y') ?? 0);
151
+ const w = Number(getSvgAttribute(rect, 'width'));
152
+ const h = Number(getSvgAttribute(rect, 'height'));
153
+ if (![x, y, w, h].every(Number.isFinite))
154
+ return null;
155
+ return fromXywh(x, y, w, h);
156
+ }
157
+ function getSvgAttribute(element, name) {
158
+ return element.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, 'i'))?.[1];
159
+ }
160
+ async function assertPmtilesCliAvailable() {
161
+ try {
162
+ await runPmtiles(['version']);
163
+ }
164
+ catch (error) {
165
+ throw new Error(`Offline map extraction requires the pmtiles CLI. Install it with "brew install pmtiles" and try again.`, { cause: error });
166
+ }
167
+ }
168
+ async function assertValidMapSource(mapSource) {
169
+ const source = mapSource.trim();
170
+ if (!source || source !== mapSource || source.startsWith('-')) {
171
+ throw new Error('Offline map --map-source must be an http(s)/file URL or absolute path.');
172
+ }
173
+ const path = await import('node:path');
174
+ if (path.isAbsolute(source) || path.win32.isAbsolute(source))
175
+ return;
176
+ try {
177
+ const url = new URL(source);
178
+ if (url.protocol === 'https:' || url.protocol === 'http:' || url.protocol === 'file:')
179
+ return;
180
+ }
181
+ catch {
182
+ // Fall through to the consistent validation error below.
183
+ }
184
+ throw new Error('Offline map --map-source must be an http(s)/file URL or absolute path.');
185
+ }
186
+ async function extractPmtiles(mapSource, bbox) {
187
+ const { mkdtemp } = await import('node:fs/promises');
188
+ const { tmpdir } = await import('node:os');
189
+ const path = await import('node:path');
190
+ const { pathToFileURL } = await import('node:url');
191
+ const tempDir = await mkdtemp(path.join(tmpdir(), 'expofp-offline-map-'));
192
+ const targetPath = path.join(tempDir, 'basemap.pmtiles');
193
+ try {
194
+ await runPmtiles(['extract', mapSource, targetPath, `--bbox=${bbox.join(',')}`]);
195
+ return { url: pathToFileURL(targetPath), tempDir };
196
+ }
197
+ catch (error) {
198
+ await cleanupTempDir(tempDir);
199
+ throw error;
200
+ }
201
+ }
202
+ async function cleanupTempDir(tempDir) {
203
+ const { rm } = await import('node:fs/promises');
204
+ await rm(tempDir, { recursive: true, force: true });
205
+ }
206
+ async function runPmtiles(args) {
207
+ const { execFile } = await import('node:child_process');
208
+ await new Promise((resolvePromise, reject) => {
209
+ execFile('pmtiles', args, (error, stdout, stderr) => {
210
+ if (stdout)
211
+ log(stdout.trim());
212
+ if (stderr)
213
+ log(stderr.trim());
214
+ if (error) {
215
+ reject(error);
216
+ return;
217
+ }
218
+ resolvePromise();
219
+ });
220
+ });
221
+ }
222
+ function* getSpriteAssets() {
223
+ const targetBase = `${MAPS_BASE_PATH}sprites/protomaps-white`;
224
+ yield { url: new URL(`${PROTOMAPS_SPRITE_BASE}.json`), targetFilePath: `${targetBase}.json` };
225
+ yield { url: new URL(`${PROTOMAPS_SPRITE_BASE}.png`), targetFilePath: `${targetBase}.png` };
226
+ yield {
227
+ url: new URL(`${PROTOMAPS_SPRITE_BASE}@2x.json`),
228
+ targetFilePath: `${targetBase}@2x.json`,
229
+ };
230
+ yield { url: new URL(`${PROTOMAPS_SPRITE_BASE}@2x.png`), targetFilePath: `${targetBase}@2x.png` };
231
+ }
232
+ function* getGlyphAssets() {
233
+ const encodedFontstack = encodeURIComponent(GLYPH_FONTSTACK);
234
+ for (const range of GLYPH_RANGES) {
235
+ yield {
236
+ url: new URL(`${PROTOMAPS_FONT_BASE}/${encodedFontstack}/${range}.pbf`),
237
+ targetFilePath: `${MAPS_BASE_PATH}fonts/${GLYPH_FONTSTACK}/${range}.pbf`,
238
+ };
239
+ }
240
+ }
241
+ function createGlyphRanges(min, max) {
242
+ const ranges = [];
243
+ for (let start = min; start <= max; start += 256) {
244
+ ranges.push(`${start}-${start + 255}`);
245
+ }
246
+ return ranges;
247
+ }
248
+ function createMapStyle() {
249
+ return {
250
+ version: 8,
251
+ sources: {
252
+ protomaps: {
253
+ type: 'vector',
254
+ attribution: '<a href="https://github.com/protomaps/basemaps">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>',
255
+ url: `pmtiles://${MAP_PMTILES_STYLE_URL}`,
256
+ },
257
+ },
258
+ sprite: './sprites/protomaps-white',
259
+ glyphs: './fonts/{fontstack}/{range}.pbf',
260
+ layers: [
261
+ {
262
+ id: 'background',
263
+ type: 'background',
264
+ paint: { 'background-color': '#f8f8f6' },
265
+ },
266
+ {
267
+ id: 'earth',
268
+ type: 'fill',
269
+ source: 'protomaps',
270
+ 'source-layer': 'earth',
271
+ paint: { 'fill-color': '#f8f8f6' },
272
+ },
273
+ {
274
+ id: 'landuse',
275
+ type: 'fill',
276
+ source: 'protomaps',
277
+ 'source-layer': 'landuse',
278
+ paint: { 'fill-color': '#eeeeea', 'fill-opacity': 0.7 },
279
+ },
280
+ {
281
+ id: 'water',
282
+ type: 'fill',
283
+ source: 'protomaps',
284
+ 'source-layer': 'water',
285
+ paint: { 'fill-color': '#d8e7ef' },
286
+ },
287
+ {
288
+ id: 'buildings',
289
+ type: 'fill',
290
+ source: 'protomaps',
291
+ 'source-layer': 'buildings',
292
+ minzoom: 13,
293
+ paint: { 'fill-color': '#dedbd4', 'fill-opacity': 0.65 },
294
+ },
295
+ {
296
+ id: 'roads-minor',
297
+ type: 'line',
298
+ source: 'protomaps',
299
+ 'source-layer': 'roads',
300
+ minzoom: 12,
301
+ filter: ['in', ['get', 'kind'], ['literal', ['minor_road', 'path', 'other']]],
302
+ paint: {
303
+ 'line-color': '#ffffff',
304
+ 'line-width': ['interpolate', ['linear'], ['zoom'], 12, 0.5, 16, 2.5, 19, 8],
305
+ },
306
+ },
307
+ {
308
+ id: 'roads-major',
309
+ type: 'line',
310
+ source: 'protomaps',
311
+ 'source-layer': 'roads',
312
+ filter: ['in', ['get', 'kind'], ['literal', ['highway', 'major_road']]],
313
+ paint: {
314
+ 'line-color': '#ffffff',
315
+ 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.8, 14, 3, 18, 12],
316
+ },
317
+ },
318
+ {
319
+ id: 'road-labels',
320
+ type: 'symbol',
321
+ source: 'protomaps',
322
+ 'source-layer': 'roads',
323
+ minzoom: 14,
324
+ filter: ['has', 'name'],
325
+ layout: {
326
+ 'symbol-placement': 'line',
327
+ 'text-field': ENGLISH_LABEL_FIELD,
328
+ 'text-font': [GLYPH_FONTSTACK],
329
+ 'text-size': ['interpolate', ['linear'], ['zoom'], 14, 10, 18, 13],
330
+ },
331
+ paint: {
332
+ 'text-color': '#707070',
333
+ 'text-halo-color': '#ffffff',
334
+ 'text-halo-width': 1,
335
+ },
336
+ },
337
+ {
338
+ id: 'place-labels',
339
+ type: 'symbol',
340
+ source: 'protomaps',
341
+ 'source-layer': 'places',
342
+ filter: ['has', 'name'],
343
+ layout: {
344
+ 'icon-image': [
345
+ 'step',
346
+ ['zoom'],
347
+ ['case', ['==', ['get', 'capital'], 'yes'], 'capital', 'townspot'],
348
+ 8,
349
+ '',
350
+ ],
351
+ 'icon-size': 0.7,
352
+ 'text-field': ENGLISH_LABEL_FIELD,
353
+ 'text-font': [GLYPH_FONTSTACK],
354
+ 'text-size': ['interpolate', ['linear'], ['zoom'], 6, 11, 14, 16],
355
+ 'text-anchor': 'center',
356
+ 'text-offset': [0, 0.8],
357
+ },
358
+ paint: {
359
+ 'text-color': '#545454',
360
+ 'text-halo-color': '#ffffff',
361
+ 'text-halo-width': 1.25,
362
+ },
363
+ },
364
+ ],
365
+ };
366
+ }
367
+ function fromXywh(x, y, w, h) {
368
+ return normalizeRect({ x1: x, y1: y, x2: x + w, y2: y + h });
369
+ }
370
+ function normalizeRect(rect) {
371
+ return {
372
+ x1: Math.min(rect.x1, rect.x2),
373
+ y1: Math.min(rect.y1, rect.y2),
374
+ x2: Math.max(rect.x1, rect.x2),
375
+ y2: Math.max(rect.y1, rect.y2),
376
+ };
377
+ }
378
+ function width(rect) {
379
+ return Math.abs(rect.x2 - rect.x1);
380
+ }
381
+ function height(rect) {
382
+ return Math.abs(rect.y2 - rect.y1);
383
+ }
384
+ function withPadding(rect, x, y = x) {
385
+ if (width(rect) < x * 2 || height(rect) < y * 2)
386
+ return rect;
387
+ const cx = (rect.x1 + rect.x2) / 2;
388
+ const cy = (rect.y1 + rect.y2) / 2;
389
+ const w = width(rect) - x * 2;
390
+ const h = height(rect) - y * 2;
391
+ return normalizeRect({ x1: cx - w / 2, y1: cy - h / 2, x2: cx + w / 2, y2: cy + h / 2 });
392
+ }
393
+ const earthRadius = 6371000.0;
394
+ const minLongitude = -180.0;
395
+ const maxLongitude = 180.0;
396
+ const toRad = (value) => (value * Math.PI) / 180;
397
+ const toDeg = (value) => (value * 180) / Math.PI;
398
+ function convertLocalToGps(x, y, geoConfig) {
399
+ const diagAngle = -getAngle(geoConfig.p0, geoConfig.p2, {
400
+ x: geoConfig.p0.x + 10000,
401
+ y: geoConfig.p0.y,
402
+ });
403
+ const pointAngle = -getAngle(geoConfig.p0, { x, y }, { x: geoConfig.p0.x + 10000, y: geoConfig.p0.y });
404
+ const delta = pointAngle - diagAngle;
405
+ const diagLen = lineLength(geoConfig.p2, geoConfig.p0);
406
+ const pointLen = lineLength(geoConfig.p0, { x, y });
407
+ const perc = pointLen / diagLen;
408
+ const fullDistance = distance(geoConfig.p0.lat, geoConfig.p0.lng, geoConfig.p2.lat, geoConfig.p2.lng);
409
+ const pointDist = perc * fullDistance;
410
+ const baseBearing = bearing(geoConfig.p0.lat, geoConfig.p0.lng, geoConfig.p2.lat, geoConfig.p2.lng);
411
+ return destinationPoint(geoConfig.p0.lat, geoConfig.p0.lng, pointDist, baseBearing + delta);
412
+ }
413
+ function lineLength(p1, p2) {
414
+ return Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2);
415
+ }
416
+ function getAngle(centerPoint, startPoint, endPoint) {
417
+ const a = lineLength(startPoint, centerPoint);
418
+ const b = lineLength(endPoint, centerPoint);
419
+ const c = lineLength(startPoint, endPoint);
420
+ const cos = (a ** 2 + b ** 2 - c ** 2) / (2 * a * b);
421
+ const direction = getDirection(centerPoint, startPoint, endPoint);
422
+ return (direction * (Math.acos(Math.max(-1, Math.min(1, cos))) * 180)) / Math.PI || 0;
423
+ }
424
+ function getDirection(centerPoint, startPoint, endPoint) {
425
+ return (startPoint.x - centerPoint.x) * (endPoint.y - centerPoint.y) -
426
+ (startPoint.y - centerPoint.y) * (endPoint.x - centerPoint.x) <
427
+ 0
428
+ ? -1
429
+ : 1;
430
+ }
431
+ function distance(lat1, lng1, lat2, lng2) {
432
+ const phi1 = toRad(lat1);
433
+ const phi2 = toRad(lat2);
434
+ const deltaPhi = toRad(lat2 - lat1);
435
+ const deltaLambda = toRad(lng2 - lng1);
436
+ const a = Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
437
+ Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2);
438
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
439
+ return earthRadius * c;
440
+ }
441
+ function bearing(lat1, lng1, lat2, lng2) {
442
+ const phi1 = toRad(lat1);
443
+ const phi2 = toRad(lat2);
444
+ const deltaLambda = toRad(lng2 - lng1);
445
+ const y = Math.sin(deltaLambda) * Math.cos(phi2);
446
+ const x = Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(deltaLambda);
447
+ return (toDeg(Math.atan2(y, x)) + 360) % 360;
448
+ }
449
+ function destinationPoint(lat, lng, distanceMeters, pointBearing) {
450
+ const delta = distanceMeters / earthRadius;
451
+ const theta = toRad(pointBearing);
452
+ const phi1 = toRad(lat);
453
+ const lambda1 = toRad(lng);
454
+ const phi2 = Math.asin(Math.sin(phi1) * Math.cos(delta) + Math.cos(phi1) * Math.sin(delta) * Math.cos(theta));
455
+ let lambda2 = lambda1 +
456
+ Math.atan2(Math.sin(theta) * Math.sin(delta) * Math.cos(phi1), Math.cos(delta) - Math.sin(phi1) * Math.sin(phi2));
457
+ let longitude = toDeg(lambda2);
458
+ if (longitude < minLongitude || longitude > maxLongitude) {
459
+ lambda2 = ((lambda2 + 3 * Math.PI) % (2 * Math.PI)) - Math.PI;
460
+ longitude = toDeg(lambda2);
461
+ }
462
+ return [longitude, toDeg(phi2)];
463
+ }
@@ -0,0 +1,5 @@
1
+ import type { LocalDataFetch } from './types.js';
2
+ export declare function generateRuntimeFilesData(runtimeBaseUrl: string): AsyncGenerator<LocalDataFetch, {
3
+ entry: string;
4
+ }, void>;
5
+ //# sourceMappingURL=generate-runtime-files-data.d.ts.map
@@ -0,0 +1,16 @@
1
+ import { importJson } from '@expofp/utils';
2
+ import debug from 'debug';
3
+ const log = debug('efp:offline:generate-runtime-files-data');
4
+ export async function* generateRuntimeFilesData(runtimeBaseUrl) {
5
+ const bundleJsonUrl = new URL('bundle.json', runtimeBaseUrl).href;
6
+ const bundleJson = await importJson(bundleJsonUrl);
7
+ log(`Generating runtime files from ${bundleJsonUrl}, ${bundleJson.files.length} files found`);
8
+ const basePath = 'runtime/';
9
+ for (const file of bundleJson.files) {
10
+ yield {
11
+ url: new URL(file.file, runtimeBaseUrl),
12
+ targetFilePath: `${basePath}${file.file}`,
13
+ };
14
+ }
15
+ return { entry: `./${basePath}${bundleJson.entry}` };
16
+ }
@@ -0,0 +1,6 @@
1
+ import type { LocalDataFetch } from './types.js';
2
+ export declare function offlinizeAssetUrl(url: URL, variants?: string[]): Promise<{
3
+ url: string;
4
+ assets: LocalDataFetch[];
5
+ }>;
6
+ //# sourceMappingURL=offlinize-asset-url.d.ts.map
@@ -0,0 +1,35 @@
1
+ import { makeLocalPath } from '@expofp/utils';
2
+ import debug from 'debug';
3
+ const log = debug('efp:offline:offlinize-asset-url');
4
+ export async function offlinizeAssetUrl(url, variants = []) {
5
+ // main
6
+ const targetFilePath = await makeLocalPath(url);
7
+ const assets = [];
8
+ log(`Offlinized asset: ${url.href} -> ${targetFilePath}`);
9
+ assets.push({ url, targetFilePath });
10
+ // extra variants (don’t mutate JSON; just add extra fetches)
11
+ for (const variant of variants) {
12
+ const variantUrl = withVariant(url, variant);
13
+ const variantTargetFilePath = await makeLocalPath(url, variant);
14
+ assets.push({ url: variantUrl, targetFilePath: variantTargetFilePath });
15
+ log(`Offlinized variant: ${variantUrl.href} -> ${variantTargetFilePath}`);
16
+ }
17
+ return {
18
+ url: targetFilePath,
19
+ assets,
20
+ };
21
+ function withVariant(url, variant) {
22
+ const u = new URL(url); // clone
23
+ const pathname = u.pathname;
24
+ const lastSlash = pathname.lastIndexOf('/');
25
+ const filename = lastSlash >= 0 ? pathname.slice(lastSlash + 1) : pathname;
26
+ const dot = filename.lastIndexOf('.');
27
+ const nextFilename = dot > 0
28
+ ? `${filename.slice(0, dot)}${variant}${filename.slice(dot)}`
29
+ : `${filename}${variant}`;
30
+ u.pathname =
31
+ lastSlash >= 0 ? `${pathname.slice(0, lastSlash + 1)}${nextFilename}` : nextFilename;
32
+ // IMPORTANT: do not touch u.search
33
+ return u;
34
+ }
35
+ }
@@ -0,0 +1,6 @@
1
+ import type { LocalData } from './types.js';
2
+ export declare function offlinizeAssetsInPlace(data: unknown, opts: {
3
+ legacyDataUrlBase?: string;
4
+ signal?: AbortSignal;
5
+ }): Promise<LocalData[]>;
6
+ //# sourceMappingURL=offlinize-assets-in-place.d.ts.map
@@ -0,0 +1,139 @@
1
+ import { fetchWithRetry, makeLocalPath } from '@expofp/utils';
2
+ import debug from 'debug';
3
+ import { offlinizeAssetUrl } from './offlinize-asset-url.js';
4
+ import { offlinizeCssAssetText } from './offlinize-css-asset-text.js';
5
+ const log = debug('efp:offline:offlinize-assets-in-place');
6
+ const ASSET_URL_SUFFIX = 'AssetUrl';
7
+ const CSS_ASSET_TEXT_SUFFIX = 'CssAssetText';
8
+ // Legacy fields, remove in future versions
9
+ const LEGACY_ASSET_URL_FIELDS = ['logo', 'gallery', 'leadingImageUrl', 'photoFile', 'logoFile'];
10
+ const LEGACY_EXHIBITOR_LOGO_VARIANTS = ['__small', '__tiny'];
11
+ const LEGACY_CSS_ASSET_TEXT_FIELD = 'customCss';
12
+ // TODO: add AbortSignal support
13
+ export async function offlinizeAssetsInPlace(data, opts) {
14
+ const { legacyDataUrlBase } = opts;
15
+ const fetches = [];
16
+ const fetchedUrls = new Map(); // targetPath to LocalData
17
+ const mergeFetches = (newFetches) => {
18
+ // push only new fetches
19
+ // error if duplicate targetFilePath with different content
20
+ for (const nf of newFetches) {
21
+ const existing = fetchedUrls.get(nf.targetFilePath);
22
+ // test only if both have url (i.e., are not JSON)
23
+ if (existing && 'url' in nf && 'url' in existing) {
24
+ const isSame = existing.url.href === nf.url.href;
25
+ if (!isSame) {
26
+ throw new Error(`Conflicting fetch for targetFilePath ${nf.targetFilePath}: ${existing.url.href} vs ${nf.url.href}`);
27
+ }
28
+ }
29
+ else {
30
+ fetchedUrls.set(nf.targetFilePath, nf);
31
+ fetches.push(nf);
32
+ }
33
+ }
34
+ };
35
+ const processedRefs = new Map(); // Cache for $ref documents
36
+ const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
37
+ const toAbsoluteUrl = (value) => legacyDataUrlBase ? new URL(value, legacyDataUrlBase) : new URL(value);
38
+ const enableLegacy = !!legacyDataUrlBase;
39
+ async function walk(node) {
40
+ if (Array.isArray(node)) {
41
+ for (let i = 0; i < node.length; i++) {
42
+ const el = node[i];
43
+ if (el && typeof el === 'object') {
44
+ await walk(el);
45
+ }
46
+ }
47
+ return;
48
+ }
49
+ if (!isPlainObject(node))
50
+ return;
51
+ // Handle $ref field
52
+ if ('$ref' in node && typeof node.$ref === 'string') {
53
+ const refValue = node.$ref;
54
+ if (refValue) {
55
+ const url = toAbsoluteUrl(refValue);
56
+ const fragment = url.hash; // Preserve JSON pointer fragment
57
+ // Create URL without fragment for fetching
58
+ const urlWithoutFragment = new URL(url.href);
59
+ urlWithoutFragment.hash = '';
60
+ const urlKey = urlWithoutFragment.href;
61
+ const targetFilePath = await makeLocalPath(urlWithoutFragment);
62
+ // Update $ref to local path with preserved fragment
63
+ node.$ref = targetFilePath + fragment;
64
+ // Check if we've already processed this $ref URL
65
+ if (!processedRefs.has(urlKey)) {
66
+ // Mark as fetched to prevent duplicates
67
+ log(`Offlinizing $ref: ${refValue} -> ${targetFilePath}${fragment}`);
68
+ // Fetch and recursively process the referenced document
69
+ const response = await fetchWithRetry(urlKey, { signal: opts.signal });
70
+ if (response.ok) {
71
+ const referencedData = await response.json();
72
+ processedRefs.set(urlKey, referencedData);
73
+ log(`Processing referenced document: ${urlKey}`);
74
+ // Recursively offlinize assets in the referenced document
75
+ const refFetches = await offlinizeAssetsInPlace(referencedData, opts);
76
+ fetches.push(...refFetches);
77
+ // Store the transformed referenced data as a separate JSON file
78
+ fetches.push({
79
+ targetFilePath,
80
+ text: JSON.stringify(referencedData, null, 2),
81
+ });
82
+ }
83
+ else {
84
+ throw new Error(`Failed to fetch $ref URL: ${urlKey}, status: ${response.status}`);
85
+ }
86
+ }
87
+ else {
88
+ log(`Skipping already processed $ref: ${refValue}`);
89
+ }
90
+ }
91
+ }
92
+ for (const key of Object.keys(node)) {
93
+ const value = node[key];
94
+ // CSS asset text handling
95
+ if (typeof value === 'string' &&
96
+ (key.endsWith(CSS_ASSET_TEXT_SUFFIX) || key === LEGACY_CSS_ASSET_TEXT_FIELD)) {
97
+ const { css, assets } = await offlinizeCssAssetText(value);
98
+ node[key] = css;
99
+ mergeFetches(assets);
100
+ continue;
101
+ }
102
+ // URL asset handling
103
+ if (typeof value === 'string' && key.endsWith(ASSET_URL_SUFFIX)) {
104
+ const { url: localUrl, assets } = await offlinizeAssetUrl(toAbsoluteUrl(value));
105
+ node[key] = localUrl;
106
+ mergeFetches(assets);
107
+ continue;
108
+ }
109
+ // Legacy asset URL fields handling
110
+ if (enableLegacy && LEGACY_ASSET_URL_FIELDS.includes(key) && typeof value === 'string') {
111
+ let variants = [];
112
+ if (key === 'logo' || (value.includes('exhibitor/') && value.includes('/media/'))) {
113
+ variants = LEGACY_EXHIBITOR_LOGO_VARIANTS;
114
+ }
115
+ const { url: localUrl, assets } = await offlinizeAssetUrl(toAbsoluteUrl(value), variants);
116
+ node[key] = localUrl;
117
+ mergeFetches(assets);
118
+ continue;
119
+ }
120
+ // Legacy asset URL fields handling for arrays
121
+ if (enableLegacy && LEGACY_ASSET_URL_FIELDS.includes(key) && Array.isArray(value)) {
122
+ for (let i = 0; i < value.length; i++) {
123
+ const el = value[i];
124
+ if (typeof el === 'string') {
125
+ const { url: localUrl, assets } = await offlinizeAssetUrl(toAbsoluteUrl(el));
126
+ value[i] = localUrl;
127
+ mergeFetches(assets);
128
+ }
129
+ }
130
+ continue;
131
+ }
132
+ if (value && typeof value === 'object') {
133
+ await walk(value);
134
+ }
135
+ }
136
+ }
137
+ await walk(data);
138
+ return fetches;
139
+ }
@@ -0,0 +1,6 @@
1
+ import type { LocalDataFetch } from './types.js';
2
+ export declare function offlinizeCssAssetText(css: string): Promise<{
3
+ css: string;
4
+ assets: LocalDataFetch[];
5
+ }>;
6
+ //# sourceMappingURL=offlinize-css-asset-text.d.ts.map