@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.
- package/README.md +49 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +80 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/lib/abort-signal.d.ts +6 -0
- package/dist/lib/abort-signal.js +20 -0
- package/dist/lib/data-to-files.d.ts +8 -0
- package/dist/lib/data-to-files.js +36 -0
- package/dist/lib/download-offline-zip.d.ts +4 -0
- package/dist/lib/download-offline-zip.js +12 -0
- package/dist/lib/exec-script-in-sandbox.d.ts +3 -0
- package/dist/lib/exec-script-in-sandbox.js +50 -0
- package/dist/lib/generate-offline-data-legacy.d.ts +6 -0
- package/dist/lib/generate-offline-data-legacy.js +85 -0
- package/dist/lib/generate-offline-data.d.ts +6 -0
- package/dist/lib/generate-offline-data.js +90 -0
- package/dist/lib/generate-offline-map-data.d.ts +16 -0
- package/dist/lib/generate-offline-map-data.js +463 -0
- package/dist/lib/generate-runtime-files-data.d.ts +5 -0
- package/dist/lib/generate-runtime-files-data.js +16 -0
- package/dist/lib/offlinize-asset-url.d.ts +6 -0
- package/dist/lib/offlinize-asset-url.js +35 -0
- package/dist/lib/offlinize-assets-in-place.d.ts +6 -0
- package/dist/lib/offlinize-assets-in-place.js +139 -0
- package/dist/lib/offlinize-css-asset-text.d.ts +6 -0
- package/dist/lib/offlinize-css-asset-text.js +52 -0
- package/dist/lib/resolve-floorplan-dir.d.ts +8 -0
- package/dist/lib/resolve-floorplan-dir.js +34 -0
- package/dist/lib/save-offline-zip.d.ts +4 -0
- package/dist/lib/save-offline-zip.js +21 -0
- package/dist/lib/types.d.ts +18 -0
- package/dist/lib/types.js +1 -0
- 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,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,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,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
|
+
}
|