@expofp/offline 3.3.8 → 3.4.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 +31 -5
- package/dist/cli.js +44 -1
- package/dist/lib/abort-signal.d.ts +2 -0
- package/dist/lib/abort-signal.js +6 -0
- package/dist/lib/data-to-files.js +4 -1
- package/dist/lib/download-offline-zip.js +4 -2
- package/dist/lib/exec-script-in-sandbox.d.ts +3 -0
- package/dist/lib/exec-script-in-sandbox.js +44 -0
- package/dist/lib/generate-offline-data-legacy.js +3 -20
- package/dist/lib/generate-offline-data.js +39 -4
- package/dist/lib/generate-offline-map-data.d.ts +19 -0
- package/dist/lib/generate-offline-map-data.js +503 -0
- package/dist/lib/offlinize-assets-in-place.js +0 -1
- package/dist/lib/types.d.ts +5 -0
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -10,11 +10,14 @@ npx @expofp/offline@<tag-or-version> <manifest-url> [options]
|
|
|
10
10
|
|
|
11
11
|
Use a dist-tag or exact version to match the deployed floor plan (e.g. `@latest`, `@stable`, `@customer1`, `@3.0.0-alpha.9`).
|
|
12
12
|
|
|
13
|
-
| Option
|
|
14
|
-
|
|
|
15
|
-
| `-o`, `--output`
|
|
16
|
-
|
|
|
17
|
-
|
|
|
13
|
+
| Option | Description | Default |
|
|
14
|
+
| ------------------ | ------------------------------------------------------------------------ | -------------------------------------- |
|
|
15
|
+
| `-o`, `--output` | Output file path | `offline.zip` |
|
|
16
|
+
| `--map-debug` | Enable debug-only map flags. Do not use in production. | |
|
|
17
|
+
| `--no-offline-map` | Disable automatic MapLibre basemap detection. Requires `--map-debug`. | |
|
|
18
|
+
| `--map-source` | Global PMTiles source URL/path for offline maps. Requires `--map-debug`. | Latest available Protomaps daily build |
|
|
19
|
+
| `-h`, `--help` | Show help message | |
|
|
20
|
+
| `-v`, `--version` | Show version number | |
|
|
18
21
|
|
|
19
22
|
### Example
|
|
20
23
|
|
|
@@ -22,6 +25,29 @@ Use a dist-tag or exact version to match the deployed floor plan (e.g. `@latest`
|
|
|
22
25
|
npx @expofp/offline@stable https://demo.expofp.com/manifest.json -o offline.zip
|
|
23
26
|
```
|
|
24
27
|
|
|
28
|
+
Offline MapLibre basemaps are inferred automatically from `__fpGeo.properties.config`:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx @expofp/offline@next https://demo.expofp.com/manifest.json -o offline.zip
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
To skip automatic offline map generation for debugging only:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npx @expofp/offline@next https://demo.expofp.com/manifest.json \
|
|
38
|
+
--map-debug \
|
|
39
|
+
--no-offline-map \
|
|
40
|
+
-o offline.zip
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
If `--map-source` is omitted, the CLI checks `https://build.protomaps.com/YYYYMMDD.pmtiles`
|
|
44
|
+
starting from the current date and falls back to previous dates for up to 7 days.
|
|
45
|
+
For debugging only, pass `--map-debug --map-source` explicitly to use a specific PMTiles URL or local file.
|
|
46
|
+
|
|
47
|
+
Offline map extraction requires the `pmtiles` CLI binary to be available in `PATH`.
|
|
48
|
+
This is separate from the `pmtiles` npm package used by the browser runtime.
|
|
49
|
+
On macOS, install the CLI with `brew install pmtiles`.
|
|
50
|
+
|
|
25
51
|
### Debugging
|
|
26
52
|
|
|
27
53
|
Enable debug output with the `DEBUG` env variable:
|
package/dist/cli.js
CHANGED
|
@@ -13,14 +13,33 @@ Arguments:
|
|
|
13
13
|
|
|
14
14
|
Options:
|
|
15
15
|
-o, --output Output file path (default: offline.zip)
|
|
16
|
+
--map-debug
|
|
17
|
+
Enable debug-only map flags. Do not use in production.
|
|
18
|
+
--no-offline-map
|
|
19
|
+
Disable automatic offline MapLibre basemap detection (requires --map-debug)
|
|
20
|
+
--map-source <source>
|
|
21
|
+
Global PMTiles source URL/path used for the offline map
|
|
22
|
+
Defaults to the latest available Protomaps daily build (requires --map-debug)
|
|
16
23
|
-h, --help Show this help message
|
|
17
24
|
-v, --version Show version number
|
|
18
25
|
`.trim();
|
|
19
26
|
async function main() {
|
|
27
|
+
const abortController = new AbortController();
|
|
28
|
+
const abortOnSigint = () => {
|
|
29
|
+
console.error('\nAborting offline ZIP creation...');
|
|
30
|
+
abortController.abort(new Error('Aborted by SIGINT'));
|
|
31
|
+
};
|
|
32
|
+
process.once('SIGINT', abortOnSigint);
|
|
33
|
+
const rawArgs = process.argv.slice(2);
|
|
34
|
+
const noOfflineMap = rawArgs.includes('--no-offline-map');
|
|
20
35
|
const { values, positionals } = parseArgs({
|
|
36
|
+
args: rawArgs.filter((arg) => arg !== '--no-offline-map'),
|
|
21
37
|
allowPositionals: true,
|
|
38
|
+
allowNegative: true,
|
|
22
39
|
options: {
|
|
23
40
|
output: { type: 'string', short: 'o', default: 'offline.zip' },
|
|
41
|
+
'map-debug': { type: 'boolean', default: false },
|
|
42
|
+
'map-source': { type: 'string' },
|
|
24
43
|
help: { type: 'boolean', short: 'h', default: false },
|
|
25
44
|
version: { type: 'boolean', short: 'v', default: false },
|
|
26
45
|
},
|
|
@@ -54,10 +73,34 @@ async function main() {
|
|
|
54
73
|
console.error('Error: output path must end with .zip\n');
|
|
55
74
|
process.exit(1);
|
|
56
75
|
}
|
|
76
|
+
if (!values['map-debug'] && noOfflineMap) {
|
|
77
|
+
console.error('Error: --no-offline-map requires --map-debug and is for debug only\n');
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
if (!values['map-debug'] && values['map-source']) {
|
|
81
|
+
console.error('Error: --map-source requires --map-debug and is for debug only\n');
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
if (noOfflineMap && values['map-source']) {
|
|
85
|
+
console.error('Error: --map-source cannot be used with --no-offline-map\n');
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
const offlineMap = values['map-source'] ? { mapSource: values['map-source'] } : undefined;
|
|
89
|
+
const inferOfflineMap = !noOfflineMap;
|
|
57
90
|
console.log(`Resolving @expofp/floorplan runtime...`);
|
|
58
91
|
const runtimeBaseUrl = await resolveFloorplanDir();
|
|
59
92
|
console.log(`Creating offline ZIP from ${manifestUrl}...`);
|
|
60
|
-
|
|
93
|
+
try {
|
|
94
|
+
await saveOfflineZip({ $ref: manifestUrl }, outputPath, {
|
|
95
|
+
runtimeBaseUrl,
|
|
96
|
+
offlineMap,
|
|
97
|
+
inferOfflineMap,
|
|
98
|
+
signal: abortController.signal,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
process.removeListener('SIGINT', abortOnSigint);
|
|
103
|
+
}
|
|
61
104
|
console.log(`Done! Offline ZIP saved to ${outputPath}`);
|
|
62
105
|
}
|
|
63
106
|
main().catch((err) => {
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { readFileFromUrl } from '@expofp/utils';
|
|
2
2
|
import debug from 'debug';
|
|
3
|
+
import { withTimeout } from './abort-signal.js';
|
|
3
4
|
const log = debug('efp:offline:data-to-files');
|
|
5
|
+
const FILE_FETCH_TIMEOUT_MS = 60_000;
|
|
4
6
|
export async function* dataToFiles(data, options) {
|
|
5
7
|
for await (const file of data) {
|
|
6
8
|
if ('url' in file) {
|
|
7
|
-
const
|
|
9
|
+
const signal = withTimeout(options?.signal, FILE_FETCH_TIMEOUT_MS);
|
|
10
|
+
const response = await readFileFromUrl(file.url, { signal });
|
|
8
11
|
if (!response.ok) {
|
|
9
12
|
console.warn(`Skipping file ${file.url} as it could not be downloaded`);
|
|
10
13
|
}
|
|
@@ -4,9 +4,11 @@ export async function downloadOfflineZip(manifest, options) {
|
|
|
4
4
|
throw new Error('downloadOfflineZip can only be used in a browser environment');
|
|
5
5
|
}
|
|
6
6
|
const blob = await buildOfflineZip(manifest, options);
|
|
7
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
7
8
|
const a = document.createElement('a');
|
|
8
|
-
a.href =
|
|
9
|
+
a.href = objectUrl;
|
|
9
10
|
a.download = 'offline.zip';
|
|
10
11
|
a.click();
|
|
11
|
-
|
|
12
|
+
// revoke to prevent race conditions with the download
|
|
13
|
+
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
|
12
14
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { fetchWithRetry } from '@expofp/utils';
|
|
2
|
+
import { withTimeout } from './abort-signal.js';
|
|
3
|
+
const SCRIPT_FETCH_TIMEOUT_MS = 60_000;
|
|
4
|
+
const SCRIPT_EXECUTION_TIMEOUT_MS = 5_000;
|
|
5
|
+
const LEGACY_SCRIPT_ALLOWED_ORIGINS_ENV = 'EXPOFP_OFFLINE_LEGACY_SCRIPT_ORIGINS';
|
|
6
|
+
/** Fetch a legacy data script and execute it in a compatibility vm context. */
|
|
7
|
+
export async function execScriptInSandbox(url, signal) {
|
|
8
|
+
assertTrustedLegacyScriptUrl(url);
|
|
9
|
+
const fetchSignal = withTimeout(signal, SCRIPT_FETCH_TIMEOUT_MS);
|
|
10
|
+
const response = await fetchWithRetry(url, { signal: fetchSignal });
|
|
11
|
+
if (!response.ok) {
|
|
12
|
+
throw new Error(`Failed to fetch script (HTTP ${response.status}): ${url}`);
|
|
13
|
+
}
|
|
14
|
+
const code = await response.text();
|
|
15
|
+
const { runInNewContext } = await import('node:vm');
|
|
16
|
+
const sandbox = {};
|
|
17
|
+
sandbox.window = sandbox; // legacy scripts expect window === globalThis
|
|
18
|
+
// The vm context is not a security boundary; origin validation above is required.
|
|
19
|
+
runInNewContext(code, sandbox, { timeout: SCRIPT_EXECUTION_TIMEOUT_MS });
|
|
20
|
+
return sandbox;
|
|
21
|
+
}
|
|
22
|
+
function assertTrustedLegacyScriptUrl(url) {
|
|
23
|
+
const parsed = new URL(url);
|
|
24
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
25
|
+
throw new Error(`Legacy script URL must use http or https: ${url}`);
|
|
26
|
+
}
|
|
27
|
+
const allowedOrigins = getAllowedLegacyScriptOrigins();
|
|
28
|
+
if (isDefaultAllowedHost(parsed.hostname) || allowedOrigins.includes(parsed.origin))
|
|
29
|
+
return;
|
|
30
|
+
throw new Error(`Legacy script host is not allowed: ${parsed.hostname}. Add ${parsed.origin} to ${LEGACY_SCRIPT_ALLOWED_ORIGINS_ENV} to allow it.`);
|
|
31
|
+
}
|
|
32
|
+
function getAllowedLegacyScriptOrigins() {
|
|
33
|
+
return (process.env[LEGACY_SCRIPT_ALLOWED_ORIGINS_ENV] ?? '')
|
|
34
|
+
.split(',')
|
|
35
|
+
.map((origin) => origin.trim())
|
|
36
|
+
.filter(Boolean);
|
|
37
|
+
}
|
|
38
|
+
function isDefaultAllowedHost(hostname) {
|
|
39
|
+
return (hostname === 'expofp.com' ||
|
|
40
|
+
hostname.endsWith('.expofp.com') ||
|
|
41
|
+
hostname === 'localhost' ||
|
|
42
|
+
hostname === '127.0.0.1' ||
|
|
43
|
+
hostname === '::1');
|
|
44
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolve } from '@expofp/resolve';
|
|
2
|
-
import { deepClone,
|
|
2
|
+
import { deepClone, makeLocalPath } from '@expofp/utils';
|
|
3
3
|
import debug from 'debug';
|
|
4
|
+
import { execScriptInSandbox } from './exec-script-in-sandbox.js';
|
|
4
5
|
import { offlinizeAssetsInPlace } from './offlinize-assets-in-place.js';
|
|
5
6
|
const log = debug('efp:offline:generate-offline-data-legacy');
|
|
6
7
|
export async function* generateOfflineDataLegacy(manifest, options) {
|
|
@@ -9,7 +10,7 @@ export async function* generateOfflineDataLegacy(manifest, options) {
|
|
|
9
10
|
}
|
|
10
11
|
const { signal } = options;
|
|
11
12
|
const legacyDataUrlBase = manifest.legacyDataUrlBase;
|
|
12
|
-
const version = await resolve(manifest.legacyDataVersion)
|
|
13
|
+
const version = await resolve(manifest.legacyDataVersion, { signal })
|
|
13
14
|
.then((json) => json?.version)
|
|
14
15
|
.catch(() => {
|
|
15
16
|
log('Could not resolve legacyDataVersion, proceeding without it');
|
|
@@ -46,24 +47,6 @@ export async function* generateOfflineDataLegacy(manifest, options) {
|
|
|
46
47
|
}
|
|
47
48
|
manifest.legacyDataUrlBase = localDataUrlBase;
|
|
48
49
|
}
|
|
49
|
-
/**
|
|
50
|
-
* Fetch a script and execute it in an isolated Node.js vm context.
|
|
51
|
-
* Returns the sandbox object containing any globals the script set.
|
|
52
|
-
* Uses dynamic import so the module can still be loaded in browser bundles
|
|
53
|
-
* (the function itself is Node-only).
|
|
54
|
-
*/
|
|
55
|
-
async function execScriptInSandbox(url, signal) {
|
|
56
|
-
const response = await fetchWithRetry(url, { signal });
|
|
57
|
-
if (!response.ok) {
|
|
58
|
-
throw new Error(`Failed to fetch script (HTTP ${response.status}): ${url}`);
|
|
59
|
-
}
|
|
60
|
-
const code = await response.text();
|
|
61
|
-
const { runInNewContext } = await import('node:vm');
|
|
62
|
-
const sandbox = {};
|
|
63
|
-
sandbox.window = sandbox; // legacy scripts expect window === globalThis
|
|
64
|
-
runInNewContext(code, sandbox);
|
|
65
|
-
return sandbox;
|
|
66
|
-
}
|
|
67
50
|
/** Strip a path prefix from all string values in a data tree (in place). */
|
|
68
51
|
function stripPathPrefix(node, prefix) {
|
|
69
52
|
if (Array.isArray(node)) {
|
|
@@ -3,21 +3,32 @@ import { buildZipArchive, deepClone } from '@expofp/utils';
|
|
|
3
3
|
import debug from 'debug';
|
|
4
4
|
import { dataToFiles } from './data-to-files.js';
|
|
5
5
|
import { generateOfflineDataLegacy } from './generate-offline-data-legacy.js';
|
|
6
|
+
import { generateOfflineMapData, inferOfflineMapOptions, validateOfflineMapOptions, } from './generate-offline-map-data.js';
|
|
6
7
|
import { generateRuntimeFilesData } from './generate-runtime-files-data.js';
|
|
7
8
|
import { offlinizeAssetsInPlace } from './offlinize-assets-in-place.js';
|
|
8
9
|
const log = debug('efp:offline:generate-offline-data');
|
|
9
10
|
export async function* generateOfflineData(inputManifest, options) {
|
|
10
|
-
const manifest = deepClone(await resolve(inputManifest));
|
|
11
|
+
const manifest = deepClone(await resolve(inputManifest, { signal: options.signal }));
|
|
11
12
|
log(`Generating offline files for expo: ${manifest.expo}`);
|
|
12
13
|
const pathPrefix = manifest.expo;
|
|
13
14
|
// this is a requirement of mobile SDK, paths in the ZIP must be prefixed with the expo name
|
|
14
15
|
// TODO: make sure SDK works with unprefixed paths, and remove the prefixing logic from this function
|
|
15
16
|
const prefixPath = (path) => `${pathPrefix}/${path}`;
|
|
17
|
+
const legacyDataUrlBase = manifest.legacyDataUrlBase;
|
|
18
|
+
let maplibre;
|
|
19
|
+
const offlineMap = await resolveOfflineMapOptions(manifest, options);
|
|
16
20
|
if (manifest.legacyDataUrlBase) {
|
|
17
21
|
for await (const d of generateOfflineDataLegacy(manifest, options)) {
|
|
18
22
|
yield { ...d, targetFilePath: prefixPath(d.targetFilePath) };
|
|
19
23
|
}
|
|
20
24
|
}
|
|
25
|
+
if (offlineMap) {
|
|
26
|
+
const mapData = generateOfflineMapData({ ...manifest, legacyDataUrlBase }, { ...offlineMap, signal: options.signal });
|
|
27
|
+
for await (const d of mapData.files) {
|
|
28
|
+
yield { ...d, targetFilePath: prefixPath(d.targetFilePath) };
|
|
29
|
+
}
|
|
30
|
+
maplibre = mapData.maplibre;
|
|
31
|
+
}
|
|
21
32
|
// transform entire manifest + assets
|
|
22
33
|
const data = await offlinizeAssetsInPlace(manifest, options);
|
|
23
34
|
for (const d of data) {
|
|
@@ -36,10 +47,19 @@ export async function* generateOfflineData(inputManifest, options) {
|
|
|
36
47
|
const { entry } = runtimeResult.value;
|
|
37
48
|
// generate index.html
|
|
38
49
|
yield {
|
|
39
|
-
text: getIndexHtml(entry, manifest),
|
|
50
|
+
text: getIndexHtml(entry, manifest, maplibre),
|
|
40
51
|
targetFilePath: prefixPath('index.html'),
|
|
41
52
|
};
|
|
42
53
|
}
|
|
54
|
+
async function resolveOfflineMapOptions(manifest, options) {
|
|
55
|
+
if (options.inferOfflineMap) {
|
|
56
|
+
return await inferOfflineMapOptions(manifest, options.offlineMap, options.signal);
|
|
57
|
+
}
|
|
58
|
+
if (options.offlineMap) {
|
|
59
|
+
return await validateOfflineMapOptions(options.offlineMap, options.signal);
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
43
63
|
/** Build a complete offline ZIP archive from a manifest. */
|
|
44
64
|
export async function buildOfflineZip(manifest, options) {
|
|
45
65
|
const data = generateOfflineData(manifest, options);
|
|
@@ -48,7 +68,7 @@ export async function buildOfflineZip(manifest, options) {
|
|
|
48
68
|
}
|
|
49
69
|
/** Known path — file lives in `packages/floorplan/public/` and is copied to dist by Vite. */
|
|
50
70
|
const COMPAT_HELPER = 'compat-helper.js';
|
|
51
|
-
function getIndexHtml(entry, manifest) {
|
|
71
|
+
function getIndexHtml(entry, manifest, maplibre) {
|
|
52
72
|
// entry is "./runtime/index.js", derive the runtime base path
|
|
53
73
|
const runtimeBase = entry.slice(0, entry.lastIndexOf('/') + 1);
|
|
54
74
|
return `\
|
|
@@ -56,7 +76,22 @@ function getIndexHtml(entry, manifest) {
|
|
|
56
76
|
<script src="${runtimeBase}${COMPAT_HELPER}"></script>
|
|
57
77
|
<script type="module">
|
|
58
78
|
import { load } from ${JSON.stringify(entry)};
|
|
59
|
-
|
|
79
|
+
const options = ${JSON.stringify({ ...manifest, maplibre })};
|
|
80
|
+
if (options.maplibre && options.maplibre.offline && options.maplibre.styleUrl) {
|
|
81
|
+
const styleUrl = new URL(options.maplibre.styleUrl, window.location.href);
|
|
82
|
+
const style = await fetch(styleUrl).then((response) => response.json());
|
|
83
|
+
const resolveStyleUrl = (url) => new URL(url, styleUrl).href.replaceAll('%7B', '{').replaceAll('%7D', '}');
|
|
84
|
+
if (typeof style.sprite === 'string') style.sprite = resolveStyleUrl(style.sprite);
|
|
85
|
+
if (typeof style.glyphs === 'string') style.glyphs = resolveStyleUrl(style.glyphs);
|
|
86
|
+
for (const source of Object.values(style.sources || {})) {
|
|
87
|
+
if (source && typeof source.url === 'string' && source.url.startsWith('pmtiles://')) {
|
|
88
|
+
source.url = 'pmtiles://' + new URL(source.url.slice('pmtiles://'.length), styleUrl).href;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
options.maplibre.style = style;
|
|
92
|
+
delete options.maplibre.styleUrl;
|
|
93
|
+
}
|
|
94
|
+
await load(options);
|
|
60
95
|
</script>
|
|
61
96
|
`;
|
|
62
97
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Manifest } from '@expofp/data';
|
|
2
|
+
import type { MaplibreOptions } from '@expofp/floorplan';
|
|
3
|
+
import type { LocalData, OfflineMapOptions } from './types.js';
|
|
4
|
+
type FpSvgContext = Record<string, unknown>;
|
|
5
|
+
export type ValidatedOfflineMapOptions = OfflineMapOptions & {
|
|
6
|
+
mapSource: string;
|
|
7
|
+
fpSvgContext?: FpSvgContext;
|
|
8
|
+
};
|
|
9
|
+
export interface OfflineMapResult {
|
|
10
|
+
files: AsyncIterable<LocalData>;
|
|
11
|
+
maplibre: MaplibreOptions;
|
|
12
|
+
}
|
|
13
|
+
export declare function generateOfflineMapData(manifest: Manifest, options: ValidatedOfflineMapOptions & {
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
}): OfflineMapResult;
|
|
16
|
+
export declare function validateOfflineMapOptions(options: OfflineMapOptions, signal?: AbortSignal): Promise<ValidatedOfflineMapOptions>;
|
|
17
|
+
export declare function inferOfflineMapOptions(manifest: Manifest, options: OfflineMapOptions | undefined, signal?: AbortSignal): Promise<ValidatedOfflineMapOptions | undefined>;
|
|
18
|
+
export {};
|
|
19
|
+
//# sourceMappingURL=generate-offline-map-data.d.ts.map
|
|
@@ -0,0 +1,503 @@
|
|
|
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
|
+
const tempDirExitCleanups = new Map();
|
|
25
|
+
export function generateOfflineMapData(manifest, options) {
|
|
26
|
+
const maplibre = {
|
|
27
|
+
styleUrl: `./${MAP_STYLE_PATH}`,
|
|
28
|
+
offline: true,
|
|
29
|
+
};
|
|
30
|
+
async function* files() {
|
|
31
|
+
if (!manifest.legacyDataUrlBase) {
|
|
32
|
+
throw new Error('Offline map generation requires legacyDataUrlBase in the manifest.');
|
|
33
|
+
}
|
|
34
|
+
const bbox = await derivePaddedVenueBbox(manifest, options.fpSvgContext, options.signal);
|
|
35
|
+
log('Extracting PMTiles for bbox', bbox.join(','));
|
|
36
|
+
const pmtilesFile = await extractPmtiles(options.mapSource, bbox, options.signal);
|
|
37
|
+
try {
|
|
38
|
+
yield {
|
|
39
|
+
url: pmtilesFile.url,
|
|
40
|
+
targetFilePath: MAP_PMTILES_PATH,
|
|
41
|
+
};
|
|
42
|
+
yield {
|
|
43
|
+
text: JSON.stringify(createMapStyle(), null, 2),
|
|
44
|
+
targetFilePath: MAP_STYLE_PATH,
|
|
45
|
+
};
|
|
46
|
+
yield* getSpriteAssets();
|
|
47
|
+
yield* getGlyphAssets();
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await cleanupTempDir(pmtilesFile.tempDir);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { files: files(), maplibre };
|
|
54
|
+
}
|
|
55
|
+
export async function validateOfflineMapOptions(options, signal) {
|
|
56
|
+
let mapSource = options.mapSource;
|
|
57
|
+
if (!mapSource) {
|
|
58
|
+
console.log('Looking for latest Protomaps map source...');
|
|
59
|
+
mapSource = await resolveDefaultMapSource(signal);
|
|
60
|
+
}
|
|
61
|
+
await assertValidMapSource(mapSource);
|
|
62
|
+
console.log(`Using map source ${mapSource}`);
|
|
63
|
+
await assertPmtilesCliAvailable(signal);
|
|
64
|
+
return { ...options, mapSource };
|
|
65
|
+
}
|
|
66
|
+
export async function inferOfflineMapOptions(manifest, options, signal) {
|
|
67
|
+
if (!manifest.legacyDataUrlBase)
|
|
68
|
+
return undefined;
|
|
69
|
+
const fpSvgContext = await resolveFpSvgContext(manifest, signal);
|
|
70
|
+
const fpGeo = fpSvgContext.__fpGeo;
|
|
71
|
+
if (!fpGeo?.properties?.config)
|
|
72
|
+
return undefined;
|
|
73
|
+
return {
|
|
74
|
+
...(await validateOfflineMapOptions(options ?? {}, signal)),
|
|
75
|
+
fpSvgContext,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
async function resolveDefaultMapSource(signal) {
|
|
79
|
+
for (let dayOffset = 0; dayOffset < DEFAULT_MAP_SOURCE_LOOKBACK_DAYS; dayOffset++) {
|
|
80
|
+
const source = createDefaultMapSource(dayOffset);
|
|
81
|
+
const response = await fetch(source, { method: 'HEAD', signal });
|
|
82
|
+
if (response.ok)
|
|
83
|
+
return source;
|
|
84
|
+
if (response.status !== 404) {
|
|
85
|
+
throw new Error(`Unable to check default offline map source ${source}: HTTP ${response.status}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
throw new Error(`Could not find a default Protomaps PMTiles build from the last ${DEFAULT_MAP_SOURCE_LOOKBACK_DAYS} days. Specify --map-source explicitly.`);
|
|
89
|
+
}
|
|
90
|
+
function createDefaultMapSource(dayOffset) {
|
|
91
|
+
const date = new Date();
|
|
92
|
+
date.setUTCDate(date.getUTCDate() - dayOffset);
|
|
93
|
+
const year = date.getUTCFullYear();
|
|
94
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
95
|
+
const day = String(date.getUTCDate()).padStart(2, '0');
|
|
96
|
+
return `${DEFAULT_MAP_SOURCE_BASE}/${year}${month}${day}.pmtiles`;
|
|
97
|
+
}
|
|
98
|
+
async function derivePaddedVenueBbox(manifest, fpSvgContext, signal) {
|
|
99
|
+
const ctx = fpSvgContext ?? (await resolveFpSvgContext(manifest, signal));
|
|
100
|
+
const fpGeo = ctx.__fpGeo;
|
|
101
|
+
const config = fpGeo?.properties?.config;
|
|
102
|
+
if (!config) {
|
|
103
|
+
throw new Error('Offline map generation requires fpGeo.properties.config.');
|
|
104
|
+
}
|
|
105
|
+
const svgArea = deriveSvgArea(ctx);
|
|
106
|
+
const corners = [
|
|
107
|
+
[svgArea.x1, svgArea.y1],
|
|
108
|
+
[svgArea.x2, svgArea.y1],
|
|
109
|
+
[svgArea.x2, svgArea.y2],
|
|
110
|
+
[svgArea.x1, svgArea.y2],
|
|
111
|
+
].map(([x, y]) => convertLocalToGps(x, y, config));
|
|
112
|
+
if (!corners.every(([lng, lat]) => Number.isFinite(lng) && Number.isFinite(lat))) {
|
|
113
|
+
throw new Error('Offline map generation produced invalid venue GPS bounds.');
|
|
114
|
+
}
|
|
115
|
+
const lngs = corners.map(([lng]) => lng);
|
|
116
|
+
const lats = corners.map(([, lat]) => lat);
|
|
117
|
+
const minLng = Math.min(...lngs);
|
|
118
|
+
const maxLng = Math.max(...lngs);
|
|
119
|
+
const minLat = Math.min(...lats);
|
|
120
|
+
const maxLat = Math.max(...lats);
|
|
121
|
+
const lngPadding = Math.max((maxLng - minLng) * MAP_BOUNDS_PADDING_FACTOR, MAP_BOUNDS_MIN_PADDING_DEGREES);
|
|
122
|
+
const latPadding = Math.max((maxLat - minLat) * MAP_BOUNDS_PADDING_FACTOR, MAP_BOUNDS_MIN_PADDING_DEGREES);
|
|
123
|
+
return [minLng - lngPadding, minLat - latPadding, maxLng + lngPadding, maxLat + latPadding];
|
|
124
|
+
}
|
|
125
|
+
async function resolveFpSvgContext(manifest, signal) {
|
|
126
|
+
if (!manifest.legacyDataUrlBase) {
|
|
127
|
+
throw new Error('Offline map generation requires legacyDataUrlBase in the manifest.');
|
|
128
|
+
}
|
|
129
|
+
const version = await resolve(manifest.legacyDataVersion, { signal })
|
|
130
|
+
.then((json) => json?.version)
|
|
131
|
+
.catch(() => Date.now().toString());
|
|
132
|
+
return await execScriptInSandbox(`${manifest.legacyDataUrlBase}fp.svg.js?v=${version}`, signal);
|
|
133
|
+
}
|
|
134
|
+
function deriveSvgArea(ctx) {
|
|
135
|
+
const viewboxObj = ctx.__viewbox;
|
|
136
|
+
if (viewboxObj) {
|
|
137
|
+
return fromXywh(viewboxObj.x, viewboxObj.y, viewboxObj.width, viewboxObj.height);
|
|
138
|
+
}
|
|
139
|
+
const fpSvg = ctx.__fp;
|
|
140
|
+
if (typeof fpSvg !== 'string') {
|
|
141
|
+
throw new Error('Offline map generation requires __fp SVG data.');
|
|
142
|
+
}
|
|
143
|
+
const viewboxRect = getSvgRectById(fpSvg, 'VIEWBOX');
|
|
144
|
+
if (viewboxRect)
|
|
145
|
+
return viewboxRect;
|
|
146
|
+
const svgViewBox = getSvgViewBox(fpSvg);
|
|
147
|
+
if (!svgViewBox) {
|
|
148
|
+
throw new Error('Offline map generation could not derive the SVG viewBox.');
|
|
149
|
+
}
|
|
150
|
+
return withPadding(svgViewBox, -width(svgViewBox) * 0.05, -height(svgViewBox) * 0.05);
|
|
151
|
+
}
|
|
152
|
+
function getSvgViewBox(svg) {
|
|
153
|
+
const match = svg.match(/\bviewBox\s*=\s*["']([^"']+)["']/i);
|
|
154
|
+
if (!match)
|
|
155
|
+
return null;
|
|
156
|
+
const [x, y, w, h] = match[1]
|
|
157
|
+
.trim()
|
|
158
|
+
.split(/[\s,]+/)
|
|
159
|
+
.map(Number);
|
|
160
|
+
if (![x, y, w, h].every(Number.isFinite))
|
|
161
|
+
return null;
|
|
162
|
+
return fromXywh(x, y, w, h);
|
|
163
|
+
}
|
|
164
|
+
function getSvgRectById(svg, id) {
|
|
165
|
+
const rectMatch = svg.match(new RegExp(`<rect\\b(?=[^>]*\\bid=["']${id}["'])[^>]*/?>`, 'i'));
|
|
166
|
+
if (!rectMatch)
|
|
167
|
+
return null;
|
|
168
|
+
const rect = rectMatch[0];
|
|
169
|
+
const x = Number(getSvgAttribute(rect, 'x') ?? 0);
|
|
170
|
+
const y = Number(getSvgAttribute(rect, 'y') ?? 0);
|
|
171
|
+
const w = Number(getSvgAttribute(rect, 'width'));
|
|
172
|
+
const h = Number(getSvgAttribute(rect, 'height'));
|
|
173
|
+
if (![x, y, w, h].every(Number.isFinite))
|
|
174
|
+
return null;
|
|
175
|
+
return fromXywh(x, y, w, h);
|
|
176
|
+
}
|
|
177
|
+
function getSvgAttribute(element, name) {
|
|
178
|
+
return element.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, 'i'))?.[1];
|
|
179
|
+
}
|
|
180
|
+
async function assertPmtilesCliAvailable(signal) {
|
|
181
|
+
try {
|
|
182
|
+
await runPmtiles(['version'], signal);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (signal?.aborted)
|
|
186
|
+
throw error;
|
|
187
|
+
throw new Error(`Offline map extraction requires the pmtiles CLI. Install it with "brew install pmtiles" and try again.`, { cause: error });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async function assertValidMapSource(mapSource) {
|
|
191
|
+
const source = mapSource.trim();
|
|
192
|
+
if (!source || source !== mapSource || source.startsWith('-')) {
|
|
193
|
+
throw new Error('Offline map --map-source must be an http(s)/file URL or absolute path.');
|
|
194
|
+
}
|
|
195
|
+
const path = await import('node:path');
|
|
196
|
+
if (path.isAbsolute(source) || path.win32.isAbsolute(source))
|
|
197
|
+
return;
|
|
198
|
+
try {
|
|
199
|
+
const url = new URL(source);
|
|
200
|
+
if (url.protocol === 'https:' || url.protocol === 'http:' || url.protocol === 'file:')
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// Fall through to the consistent validation error below.
|
|
205
|
+
}
|
|
206
|
+
throw new Error('Offline map --map-source must be an http(s)/file URL or absolute path.');
|
|
207
|
+
}
|
|
208
|
+
async function extractPmtiles(mapSource, bbox, signal) {
|
|
209
|
+
const { mkdtemp } = await import('node:fs/promises');
|
|
210
|
+
const { tmpdir } = await import('node:os');
|
|
211
|
+
const path = await import('node:path');
|
|
212
|
+
const { pathToFileURL } = await import('node:url');
|
|
213
|
+
throwIfAborted(signal);
|
|
214
|
+
const tempDir = await mkdtemp(path.join(tmpdir(), 'expofp-offline-map-'));
|
|
215
|
+
await registerTempDirExitCleanup(tempDir);
|
|
216
|
+
const targetPath = path.join(tempDir, 'basemap.pmtiles');
|
|
217
|
+
try {
|
|
218
|
+
throwIfAborted(signal);
|
|
219
|
+
await runPmtiles(['extract', mapSource, targetPath, `--bbox=${bbox.join(',')}`], signal);
|
|
220
|
+
return { url: pathToFileURL(targetPath), tempDir };
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
await cleanupTempDir(tempDir);
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async function cleanupTempDir(tempDir) {
|
|
228
|
+
const { rm } = await import('node:fs/promises');
|
|
229
|
+
tempDirExitCleanups.get(tempDir)?.();
|
|
230
|
+
tempDirExitCleanups.delete(tempDir);
|
|
231
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
232
|
+
}
|
|
233
|
+
async function registerTempDirExitCleanup(tempDir) {
|
|
234
|
+
if (typeof process === 'undefined' || !process.versions?.node)
|
|
235
|
+
return;
|
|
236
|
+
const { rmSync } = await import('node:fs');
|
|
237
|
+
const cleanup = () => rmSync(tempDir, { recursive: true, force: true });
|
|
238
|
+
tempDirExitCleanups.set(tempDir, () => process.removeListener('exit', cleanup));
|
|
239
|
+
process.once('exit', cleanup);
|
|
240
|
+
}
|
|
241
|
+
function throwIfAborted(signal) {
|
|
242
|
+
if (!signal?.aborted)
|
|
243
|
+
return;
|
|
244
|
+
throw signal.reason instanceof Error ? signal.reason : new Error('Operation aborted.');
|
|
245
|
+
}
|
|
246
|
+
async function runPmtiles(args, signal) {
|
|
247
|
+
const { execFile } = await import('node:child_process');
|
|
248
|
+
await new Promise((resolvePromise, reject) => {
|
|
249
|
+
execFile('pmtiles', args, { signal }, (error, stdout, stderr) => {
|
|
250
|
+
if (stdout)
|
|
251
|
+
log(stdout.trim());
|
|
252
|
+
if (stderr)
|
|
253
|
+
log(stderr.trim());
|
|
254
|
+
if (error) {
|
|
255
|
+
reject(error);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
resolvePromise();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
function* getSpriteAssets() {
|
|
263
|
+
const targetBase = `${MAPS_BASE_PATH}sprites/protomaps-white`;
|
|
264
|
+
yield { url: new URL(`${PROTOMAPS_SPRITE_BASE}.json`), targetFilePath: `${targetBase}.json` };
|
|
265
|
+
yield { url: new URL(`${PROTOMAPS_SPRITE_BASE}.png`), targetFilePath: `${targetBase}.png` };
|
|
266
|
+
yield {
|
|
267
|
+
url: new URL(`${PROTOMAPS_SPRITE_BASE}@2x.json`),
|
|
268
|
+
targetFilePath: `${targetBase}@2x.json`,
|
|
269
|
+
};
|
|
270
|
+
yield { url: new URL(`${PROTOMAPS_SPRITE_BASE}@2x.png`), targetFilePath: `${targetBase}@2x.png` };
|
|
271
|
+
}
|
|
272
|
+
function* getGlyphAssets() {
|
|
273
|
+
const encodedFontstack = encodeURIComponent(GLYPH_FONTSTACK);
|
|
274
|
+
for (const range of GLYPH_RANGES) {
|
|
275
|
+
yield {
|
|
276
|
+
url: new URL(`${PROTOMAPS_FONT_BASE}/${encodedFontstack}/${range}.pbf`),
|
|
277
|
+
targetFilePath: `${MAPS_BASE_PATH}fonts/${GLYPH_FONTSTACK}/${range}.pbf`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function createGlyphRanges(min, max) {
|
|
282
|
+
const ranges = [];
|
|
283
|
+
for (let start = min; start <= max; start += 256) {
|
|
284
|
+
ranges.push(`${start}-${start + 255}`);
|
|
285
|
+
}
|
|
286
|
+
return ranges;
|
|
287
|
+
}
|
|
288
|
+
function createMapStyle() {
|
|
289
|
+
return {
|
|
290
|
+
version: 8,
|
|
291
|
+
sources: {
|
|
292
|
+
protomaps: {
|
|
293
|
+
type: 'vector',
|
|
294
|
+
attribution: '<a href="https://github.com/protomaps/basemaps">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>',
|
|
295
|
+
url: `pmtiles://${MAP_PMTILES_STYLE_URL}`,
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
sprite: './sprites/protomaps-white',
|
|
299
|
+
glyphs: './fonts/{fontstack}/{range}.pbf',
|
|
300
|
+
layers: [
|
|
301
|
+
{
|
|
302
|
+
id: 'background',
|
|
303
|
+
type: 'background',
|
|
304
|
+
paint: { 'background-color': '#f8f8f6' },
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
id: 'earth',
|
|
308
|
+
type: 'fill',
|
|
309
|
+
source: 'protomaps',
|
|
310
|
+
'source-layer': 'earth',
|
|
311
|
+
paint: { 'fill-color': '#f8f8f6' },
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
id: 'landuse',
|
|
315
|
+
type: 'fill',
|
|
316
|
+
source: 'protomaps',
|
|
317
|
+
'source-layer': 'landuse',
|
|
318
|
+
paint: { 'fill-color': '#eeeeea', 'fill-opacity': 0.7 },
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
id: 'water',
|
|
322
|
+
type: 'fill',
|
|
323
|
+
source: 'protomaps',
|
|
324
|
+
'source-layer': 'water',
|
|
325
|
+
paint: { 'fill-color': '#d8e7ef' },
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
id: 'buildings',
|
|
329
|
+
type: 'fill',
|
|
330
|
+
source: 'protomaps',
|
|
331
|
+
'source-layer': 'buildings',
|
|
332
|
+
minzoom: 13,
|
|
333
|
+
paint: { 'fill-color': '#dedbd4', 'fill-opacity': 0.65 },
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
id: 'roads-minor',
|
|
337
|
+
type: 'line',
|
|
338
|
+
source: 'protomaps',
|
|
339
|
+
'source-layer': 'roads',
|
|
340
|
+
minzoom: 12,
|
|
341
|
+
filter: ['in', ['get', 'kind'], ['literal', ['minor_road', 'path', 'other']]],
|
|
342
|
+
paint: {
|
|
343
|
+
'line-color': '#ffffff',
|
|
344
|
+
'line-width': ['interpolate', ['linear'], ['zoom'], 12, 0.5, 16, 2.5, 19, 8],
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
id: 'roads-major',
|
|
349
|
+
type: 'line',
|
|
350
|
+
source: 'protomaps',
|
|
351
|
+
'source-layer': 'roads',
|
|
352
|
+
filter: ['in', ['get', 'kind'], ['literal', ['highway', 'major_road']]],
|
|
353
|
+
paint: {
|
|
354
|
+
'line-color': '#ffffff',
|
|
355
|
+
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.8, 14, 3, 18, 12],
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
id: 'road-labels',
|
|
360
|
+
type: 'symbol',
|
|
361
|
+
source: 'protomaps',
|
|
362
|
+
'source-layer': 'roads',
|
|
363
|
+
minzoom: 14,
|
|
364
|
+
filter: ['has', 'name'],
|
|
365
|
+
layout: {
|
|
366
|
+
'symbol-placement': 'line',
|
|
367
|
+
'text-field': ENGLISH_LABEL_FIELD,
|
|
368
|
+
'text-font': [GLYPH_FONTSTACK],
|
|
369
|
+
'text-size': ['interpolate', ['linear'], ['zoom'], 14, 10, 18, 13],
|
|
370
|
+
},
|
|
371
|
+
paint: {
|
|
372
|
+
'text-color': '#707070',
|
|
373
|
+
'text-halo-color': '#ffffff',
|
|
374
|
+
'text-halo-width': 1,
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
id: 'place-labels',
|
|
379
|
+
type: 'symbol',
|
|
380
|
+
source: 'protomaps',
|
|
381
|
+
'source-layer': 'places',
|
|
382
|
+
filter: ['has', 'name'],
|
|
383
|
+
layout: {
|
|
384
|
+
'icon-image': [
|
|
385
|
+
'step',
|
|
386
|
+
['zoom'],
|
|
387
|
+
['case', ['==', ['get', 'capital'], 'yes'], 'capital', 'townspot'],
|
|
388
|
+
8,
|
|
389
|
+
'',
|
|
390
|
+
],
|
|
391
|
+
'icon-size': 0.7,
|
|
392
|
+
'text-field': ENGLISH_LABEL_FIELD,
|
|
393
|
+
'text-font': [GLYPH_FONTSTACK],
|
|
394
|
+
'text-size': ['interpolate', ['linear'], ['zoom'], 6, 11, 14, 16],
|
|
395
|
+
'text-anchor': 'center',
|
|
396
|
+
'text-offset': [0, 0.8],
|
|
397
|
+
},
|
|
398
|
+
paint: {
|
|
399
|
+
'text-color': '#545454',
|
|
400
|
+
'text-halo-color': '#ffffff',
|
|
401
|
+
'text-halo-width': 1.25,
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
],
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
function fromXywh(x, y, w, h) {
|
|
408
|
+
return normalizeRect({ x1: x, y1: y, x2: x + w, y2: y + h });
|
|
409
|
+
}
|
|
410
|
+
function normalizeRect(rect) {
|
|
411
|
+
return {
|
|
412
|
+
x1: Math.min(rect.x1, rect.x2),
|
|
413
|
+
y1: Math.min(rect.y1, rect.y2),
|
|
414
|
+
x2: Math.max(rect.x1, rect.x2),
|
|
415
|
+
y2: Math.max(rect.y1, rect.y2),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
function width(rect) {
|
|
419
|
+
return Math.abs(rect.x2 - rect.x1);
|
|
420
|
+
}
|
|
421
|
+
function height(rect) {
|
|
422
|
+
return Math.abs(rect.y2 - rect.y1);
|
|
423
|
+
}
|
|
424
|
+
function withPadding(rect, x, y = x) {
|
|
425
|
+
if (width(rect) < x * 2 || height(rect) < y * 2)
|
|
426
|
+
return rect;
|
|
427
|
+
const cx = (rect.x1 + rect.x2) / 2;
|
|
428
|
+
const cy = (rect.y1 + rect.y2) / 2;
|
|
429
|
+
const w = width(rect) - x * 2;
|
|
430
|
+
const h = height(rect) - y * 2;
|
|
431
|
+
return normalizeRect({ x1: cx - w / 2, y1: cy - h / 2, x2: cx + w / 2, y2: cy + h / 2 });
|
|
432
|
+
}
|
|
433
|
+
const earthRadius = 6371000.0;
|
|
434
|
+
const minLongitude = -180.0;
|
|
435
|
+
const maxLongitude = 180.0;
|
|
436
|
+
const toRad = (value) => (value * Math.PI) / 180;
|
|
437
|
+
const toDeg = (value) => (value * 180) / Math.PI;
|
|
438
|
+
function convertLocalToGps(x, y, geoConfig) {
|
|
439
|
+
const diagAngle = -getAngle(geoConfig.p0, geoConfig.p2, {
|
|
440
|
+
x: geoConfig.p0.x + 10000,
|
|
441
|
+
y: geoConfig.p0.y,
|
|
442
|
+
});
|
|
443
|
+
const pointAngle = -getAngle(geoConfig.p0, { x, y }, { x: geoConfig.p0.x + 10000, y: geoConfig.p0.y });
|
|
444
|
+
const delta = pointAngle - diagAngle;
|
|
445
|
+
const diagLen = lineLength(geoConfig.p2, geoConfig.p0);
|
|
446
|
+
const pointLen = lineLength(geoConfig.p0, { x, y });
|
|
447
|
+
const perc = pointLen / diagLen;
|
|
448
|
+
const fullDistance = distance(geoConfig.p0.lat, geoConfig.p0.lng, geoConfig.p2.lat, geoConfig.p2.lng);
|
|
449
|
+
const pointDist = perc * fullDistance;
|
|
450
|
+
const baseBearing = bearing(geoConfig.p0.lat, geoConfig.p0.lng, geoConfig.p2.lat, geoConfig.p2.lng);
|
|
451
|
+
return destinationPoint(geoConfig.p0.lat, geoConfig.p0.lng, pointDist, baseBearing + delta);
|
|
452
|
+
}
|
|
453
|
+
function lineLength(p1, p2) {
|
|
454
|
+
return Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2);
|
|
455
|
+
}
|
|
456
|
+
function getAngle(centerPoint, startPoint, endPoint) {
|
|
457
|
+
const a = lineLength(startPoint, centerPoint);
|
|
458
|
+
const b = lineLength(endPoint, centerPoint);
|
|
459
|
+
const c = lineLength(startPoint, endPoint);
|
|
460
|
+
const cos = (a ** 2 + b ** 2 - c ** 2) / (2 * a * b);
|
|
461
|
+
const direction = getDirection(centerPoint, startPoint, endPoint);
|
|
462
|
+
return (direction * (Math.acos(Math.max(-1, Math.min(1, cos))) * 180)) / Math.PI || 0;
|
|
463
|
+
}
|
|
464
|
+
function getDirection(centerPoint, startPoint, endPoint) {
|
|
465
|
+
return (startPoint.x - centerPoint.x) * (endPoint.y - centerPoint.y) -
|
|
466
|
+
(startPoint.y - centerPoint.y) * (endPoint.x - centerPoint.x) <
|
|
467
|
+
0
|
|
468
|
+
? -1
|
|
469
|
+
: 1;
|
|
470
|
+
}
|
|
471
|
+
function distance(lat1, lng1, lat2, lng2) {
|
|
472
|
+
const phi1 = toRad(lat1);
|
|
473
|
+
const phi2 = toRad(lat2);
|
|
474
|
+
const deltaPhi = toRad(lat2 - lat1);
|
|
475
|
+
const deltaLambda = toRad(lng2 - lng1);
|
|
476
|
+
const a = Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
|
|
477
|
+
Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2);
|
|
478
|
+
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
479
|
+
return earthRadius * c;
|
|
480
|
+
}
|
|
481
|
+
function bearing(lat1, lng1, lat2, lng2) {
|
|
482
|
+
const phi1 = toRad(lat1);
|
|
483
|
+
const phi2 = toRad(lat2);
|
|
484
|
+
const deltaLambda = toRad(lng2 - lng1);
|
|
485
|
+
const y = Math.sin(deltaLambda) * Math.cos(phi2);
|
|
486
|
+
const x = Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(deltaLambda);
|
|
487
|
+
return (toDeg(Math.atan2(y, x)) + 360) % 360;
|
|
488
|
+
}
|
|
489
|
+
function destinationPoint(lat, lng, distanceMeters, pointBearing) {
|
|
490
|
+
const delta = distanceMeters / earthRadius;
|
|
491
|
+
const theta = toRad(pointBearing);
|
|
492
|
+
const phi1 = toRad(lat);
|
|
493
|
+
const lambda1 = toRad(lng);
|
|
494
|
+
const phi2 = Math.asin(Math.sin(phi1) * Math.cos(delta) + Math.cos(phi1) * Math.sin(delta) * Math.cos(theta));
|
|
495
|
+
let lambda2 = lambda1 +
|
|
496
|
+
Math.atan2(Math.sin(theta) * Math.sin(delta) * Math.cos(phi1), Math.cos(delta) - Math.sin(phi1) * Math.sin(phi2));
|
|
497
|
+
let longitude = toDeg(lambda2);
|
|
498
|
+
if (longitude < minLongitude || longitude > maxLongitude) {
|
|
499
|
+
lambda2 = ((lambda2 + 3 * Math.PI) % (2 * Math.PI)) - Math.PI;
|
|
500
|
+
longitude = toDeg(lambda2);
|
|
501
|
+
}
|
|
502
|
+
return [longitude, toDeg(phi2)];
|
|
503
|
+
}
|
|
@@ -9,7 +9,6 @@ const CSS_ASSET_TEXT_SUFFIX = 'CssAssetText';
|
|
|
9
9
|
const LEGACY_ASSET_URL_FIELDS = ['logo', 'gallery', 'leadingImageUrl', 'photoFile', 'logoFile'];
|
|
10
10
|
const LEGACY_EXHIBITOR_LOGO_VARIANTS = ['__small', '__tiny'];
|
|
11
11
|
const LEGACY_CSS_ASSET_TEXT_FIELD = 'customCss';
|
|
12
|
-
// TODO: add AbortSignal support
|
|
13
12
|
export async function offlinizeAssetsInPlace(data, opts) {
|
|
14
13
|
const { legacyDataUrlBase } = opts;
|
|
15
14
|
const fetches = [];
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -10,5 +10,10 @@ export interface LocalDataText {
|
|
|
10
10
|
export interface OfflineOptions {
|
|
11
11
|
signal?: AbortSignal;
|
|
12
12
|
runtimeBaseUrl: string;
|
|
13
|
+
offlineMap?: OfflineMapOptions;
|
|
14
|
+
inferOfflineMap?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface OfflineMapOptions {
|
|
17
|
+
mapSource?: string;
|
|
13
18
|
}
|
|
14
19
|
//# sourceMappingURL=types.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@expofp/offline",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.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,9 +31,9 @@
|
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"debug": "^4.4.3",
|
|
33
33
|
"tslib": "^2.3.0",
|
|
34
|
-
"@expofp/data": "3.
|
|
35
|
-
"@expofp/floorplan": "3.
|
|
36
|
-
"@expofp/resolve": "3.
|
|
37
|
-
"@expofp/utils": "3.
|
|
34
|
+
"@expofp/data": "3.4.0",
|
|
35
|
+
"@expofp/floorplan": "3.4.0",
|
|
36
|
+
"@expofp/resolve": "3.4.0",
|
|
37
|
+
"@expofp/utils": "3.4.0"
|
|
38
38
|
}
|
|
39
39
|
}
|