mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge pull request #227: scoped formatting and package boundaries
Preserve the contributor commits and adopt scoped formatting with infrastructure export checks.
This commit is contained in:
20 files changed
+1251
-407
No files matched your search
@@ -42,6 +42,12 @@ jobs:
|
||||
- name: Run setup policy checks
|
||||
run: npm run doctor -- --json
|
||||
|
||||
- name: Check adopted formatting
|
||||
run: npm run format:check
|
||||
|
||||
- name: Check package boundaries
|
||||
run: npm run check:boundaries
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm test
|
||||
|
||||
@@ -70,6 +76,12 @@ jobs:
|
||||
- name: Run the Pinokio install path
|
||||
run: node scripts/pinokio-install.mjs
|
||||
|
||||
- name: Check adopted formatting
|
||||
run: npm run format:check
|
||||
|
||||
- name: Check package boundaries
|
||||
run: npm run check:boundaries
|
||||
|
||||
- name: Run focused onboarding tests
|
||||
run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
dist
|
||||
.gev-cache
|
||||
.gev-logs
|
||||
screenshots
|
||||
qa-shots
|
||||
output
|
||||
coverage
|
||||
3d-models
|
||||
pinokio/ENVIRONMENT
|
||||
pinokio/.installed
|
||||
src/data/local_data/
|
||||
*.geojson
|
||||
*.geojsonl
|
||||
|
||||
# Local configuration, generated assets and browser evidence
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules
|
||||
.vite
|
||||
.vite-*
|
||||
*.min.js
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -13,6 +13,13 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Development
|
||||
|
||||
- Adopt Prettier tooling contributed by RohanDaCoder (#227), with an explicit
|
||||
file scope, pinned formatter and Linux/Windows CI checks. Format the reusable
|
||||
infrastructure modules and their consumer tests. Package boundary checks keep
|
||||
those exports separate from app startup and local Node services.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Separate optional Google server credentials for Places and Street View from
|
||||
|
||||
@@ -51,6 +51,22 @@ The highest-leverage places to jump in:
|
||||
- Match the surrounding code — comment density, naming, and idiom.
|
||||
- Prefer small, reviewable commits. Conventional-commit-style prefixes (`feat:`, `fix:`, `perf:`, `docs:`) are appreciated but not required.
|
||||
|
||||
## Formatting and reusable components
|
||||
|
||||
Run `npm run format` before submitting changes to adopted modules, then
|
||||
`npm run format:check` and `npm run check:boundaries`. Formatting uses the
|
||||
explicit file list in `scripts/format-scope.json`; add newly extracted modules
|
||||
and tests there in a separate mechanical commit. Keep unadopted files consistent
|
||||
with their surrounding style. CI checks all adopted files and package boundaries
|
||||
on Linux and Windows.
|
||||
|
||||
Reusable package exports own their state and receive application operations
|
||||
through explicit callbacks. They must not import the standalone bootstrap or
|
||||
local Node services. Update `scripts/package-boundaries.json` and consumer tests
|
||||
when adding an export or expanding a component. See
|
||||
[formatting and component boundaries](docs/CODE-BOUNDARIES.md) for the current
|
||||
ownership and adoption process.
|
||||
|
||||
## Pull requests
|
||||
|
||||
1. Branch off `main`.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Formatting and component boundaries
|
||||
|
||||
Run `npm run format` to format the files in `scripts/format-scope.json` and
|
||||
`npm run format:check` to check that same list without writing. CI checks the
|
||||
entire adopted list on Linux and Windows. Prettier is pinned in the development
|
||||
dependencies; use the installed version so local and CI output agree. The shared
|
||||
configuration specifies two spaces, single quotes, semicolons and LF endings.
|
||||
|
||||
Add new reusable modules and their tests to the list as they are extracted.
|
||||
Keep mechanical formatting in its own commit after behavior is stable. Existing
|
||||
source-text regression assertions still apply; investigate failures and preserve
|
||||
their behavioral coverage when a move or line wrap changes a tested shape.
|
||||
Files outside the list retain their surrounding style until deliberately adopted.
|
||||
Generated output, local configuration, browser evidence and bundled datasets are
|
||||
excluded. The formatter validates every entry before writing any file.
|
||||
|
||||
## Current component ownership
|
||||
|
||||
| Surface | Owns | Receives from its caller |
|
||||
| -------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| `gods-eye-view/infrastructure` | Datacenter/dam definitions and fresh layer construction | Context, overlay and render operations |
|
||||
| `gods-eye-view/infrastructure/geojson` | Data loading, Cesium entities, selection handling and resource cleanup | A viewer and those same operations |
|
||||
| `gods-eye-view/infrastructure/lod` | Pure visibility budgets and selection policy | Position/visibility records and camera measurements |
|
||||
| `src/data/localGeojson.js` | Standalone compatibility wiring | The application's existing shared services |
|
||||
| `src/main.js` and `vite.config.js` | Standalone startup and local Node services | Local configuration |
|
||||
|
||||
The three scoped package exports are browser source modules. Use their documented
|
||||
exports instead of importing standalone startup or reaching into internal files.
|
||||
The application owns the viewer, context store, overlay host and render scheduler;
|
||||
layers use the supplied callbacks. See [the infrastructure contract](INFRASTRUCTURE-LAYERS.md).
|
||||
|
||||
`npm run check:boundaries` builds every declared package export, with app Vite
|
||||
configuration disabled. `scripts/package-boundaries.json` lists each export's
|
||||
component, owned modules and external runtime dependencies. A new export must be
|
||||
classified. Imports outside the declared modules fail, including unused and
|
||||
literal dynamic imports. Cesium stays external so the consuming application
|
||||
supplies the same compatible instance as its viewer. Existing consumer tests
|
||||
also check import-time inactivity and asset URLs under a non-root base.
|
||||
|
||||
These checks cover the declared exports, not every import in the application.
|
||||
They check build-time imports, not arbitrary runtime-generated module URLs.
|
||||
Keep runtime module discovery out of these exports. When extracting another
|
||||
component, add its ownership and consumer tests together. Node services must use
|
||||
separate entry points and their own checks when they become reusable; importing
|
||||
them into a browser component is not supported. No Node service is exported yet.
|
||||
|
||||
This setup establishes boundaries for the existing infrastructure component.
|
||||
Application construction, UI panels and individual source adapters remain future
|
||||
extractions; they should become smaller modules with explicit lifecycle owners
|
||||
as their callers migrate.
|
||||
@@ -1,5 +1,14 @@
|
||||
# God's Eye View Current State
|
||||
|
||||
## Scoped formatting and package checks
|
||||
|
||||
`npm run format` and `npm run format:check` operate on the explicit adopted-file
|
||||
list. `npm run check:boundaries` checks the browser dependency graph of all
|
||||
current package exports; infrastructure owns its three implementation modules
|
||||
and takes Cesium from the consumer. CI runs both checks on Linux and Windows.
|
||||
The standalone app, layer behavior and public export paths remain unchanged.
|
||||
See [component ownership and adoption](CODE-BOUNDARIES.md).
|
||||
|
||||
## Google browser and server keys
|
||||
|
||||
Local Places nearby/text search and the CCTV Street View fallback prefer
|
||||
|
||||
Generated
+17
@@ -17,6 +17,7 @@
|
||||
"satellite.js": "^6.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "3.9.6",
|
||||
"puppeteer": "^25.10.0",
|
||||
"sharp": "^0.35.4",
|
||||
"vite": "^6.0.0",
|
||||
@@ -2220,6 +2221,22 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.9.6",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
|
||||
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "8.8.0",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.8.0.tgz",
|
||||
|
||||
+6
-2
@@ -2,7 +2,7 @@
|
||||
"name": "gods-eye-view",
|
||||
"private": true,
|
||||
"version": "0.1.1",
|
||||
"description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
|
||||
"description": "A real-time intelligence console for planet Earth \u2014 photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bilawal Sidhu",
|
||||
@@ -33,6 +33,8 @@
|
||||
],
|
||||
"scripts": {
|
||||
"doctor": "node scripts/setup-doctor.mjs",
|
||||
"format": "node scripts/format.mjs --write",
|
||||
"format:check": "node scripts/format.mjs --check",
|
||||
"dev": "vite",
|
||||
"dev:secure": "./scripts/dev-secure.sh",
|
||||
"opensky:import": "./scripts/opensky-import-client.sh",
|
||||
@@ -40,7 +42,8 @@
|
||||
"preview": "vite preview",
|
||||
"test": "node scripts/run-unit-tests.mjs",
|
||||
"test:track": "node scripts/track-regression.mjs",
|
||||
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs"
|
||||
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
|
||||
"check:boundaries": "node scripts/check-package-boundaries.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
@@ -51,6 +54,7 @@
|
||||
"satellite.js": "^6.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "3.9.6",
|
||||
"puppeteer": "^25.10.0",
|
||||
"sharp": "^0.35.4",
|
||||
"vite": "^6.0.0",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { readFile, realpath } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { build, normalizePath } from 'vite';
|
||||
|
||||
/** Build each browser export group and reject imports outside its declared ownership. */
|
||||
export async function checkPackageBoundaries(root) {
|
||||
root = await realpath(root);
|
||||
const readJson = async (name) =>
|
||||
JSON.parse(await readFile(path.join(root, name), 'utf8'));
|
||||
const pkg = await readJson('package.json');
|
||||
const groups = await readJson('scripts/package-boundaries.json');
|
||||
const declaredExports = Object.keys(pkg.exports || {}).sort();
|
||||
const classifiedExports = Object.values(groups)
|
||||
.flatMap((group) => group.exports)
|
||||
.sort();
|
||||
if (
|
||||
!declaredExports.length ||
|
||||
JSON.stringify(declaredExports) !== JSON.stringify(classifiedExports)
|
||||
) {
|
||||
throw new Error(
|
||||
'Every package export must belong to exactly one boundary group',
|
||||
);
|
||||
}
|
||||
const reports = [];
|
||||
for (const [name, group] of Object.entries(groups)) {
|
||||
if (
|
||||
!Array.isArray(group.modules) ||
|
||||
!group.modules.length ||
|
||||
!Array.isArray(group.external)
|
||||
) {
|
||||
throw new Error(`Invalid package boundary: ${name}`);
|
||||
}
|
||||
const allowed = new Set();
|
||||
for (const module of group.modules) {
|
||||
if (
|
||||
typeof module !== 'string' ||
|
||||
path.isAbsolute(module) ||
|
||||
module.includes('\\') ||
|
||||
module.split('/').includes('..')
|
||||
) {
|
||||
throw new Error(`Boundary modules must be repository paths: ${name}`);
|
||||
}
|
||||
const resolved = await realpath(path.join(root, module));
|
||||
const relative = path.relative(root, resolved);
|
||||
if (
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new Error(`Boundary module escapes repository: ${name}`);
|
||||
}
|
||||
allowed.add(normalizePath(resolved));
|
||||
}
|
||||
for (const external of group.external) {
|
||||
if (
|
||||
!Object.hasOwn(pkg.dependencies || {}, external) &&
|
||||
!Object.hasOwn(pkg.peerDependencies || {}, external)
|
||||
) {
|
||||
throw new Error(
|
||||
`Boundary external must be a declared runtime dependency: ${external}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const input = group.exports.map((key) => {
|
||||
const target = pkg.exports[key];
|
||||
if (
|
||||
typeof target !== 'string' ||
|
||||
!target.startsWith('./') ||
|
||||
!allowed.has(normalizePath(path.resolve(root, target)))
|
||||
) {
|
||||
throw new Error(`Export must point to an owned module: ${key}`);
|
||||
}
|
||||
return path.resolve(root, target);
|
||||
});
|
||||
const seen = new Set();
|
||||
await build({
|
||||
root,
|
||||
configFile: false,
|
||||
envFile: false,
|
||||
publicDir: false,
|
||||
logLevel: 'silent',
|
||||
plugins: [
|
||||
{
|
||||
name: 'check-package-ownership',
|
||||
moduleParsed(info) {
|
||||
if (!allowed.has(info.id)) {
|
||||
throw new Error(
|
||||
`Package boundary ${name} imports an unowned module: ${path.relative(root, info.id)}`,
|
||||
);
|
||||
}
|
||||
seen.add(info.id);
|
||||
},
|
||||
},
|
||||
],
|
||||
build: {
|
||||
lib: { entry: input, formats: ['es'] },
|
||||
write: false,
|
||||
minify: false,
|
||||
assetsInlineLimit: 0,
|
||||
rollupOptions: {
|
||||
input,
|
||||
external: group.external,
|
||||
// Unused imports must still obey ownership; tree shaking is not a boundary.
|
||||
treeshake: false,
|
||||
preserveEntrySignatures: 'strict',
|
||||
},
|
||||
},
|
||||
});
|
||||
reports.push({ name, exports: input.length, modules: seen.size });
|
||||
}
|
||||
return reports;
|
||||
}
|
||||
|
||||
const invoked = process.argv[1]
|
||||
? pathToFileURL(path.resolve(process.argv[1])).href
|
||||
: '';
|
||||
if (import.meta.url === invoked) {
|
||||
try {
|
||||
const reports = await checkPackageBoundaries(
|
||||
fileURLToPath(new URL('../', import.meta.url)),
|
||||
);
|
||||
for (const report of reports)
|
||||
console.log(
|
||||
`Checked ${report.name}: ${report.exports} exports, ${report.modules} owned modules.`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
".prettierrc.json",
|
||||
"package.json",
|
||||
"scripts/format-scope.json",
|
||||
"scripts/package-boundaries.json",
|
||||
"scripts/format.mjs",
|
||||
"scripts/check-package-boundaries.mjs",
|
||||
"src/tooling/format.test.mjs",
|
||||
"src/tooling/packageBoundaries.test.mjs",
|
||||
"src/data/infrastructure.js",
|
||||
"src/data/infrastructure.test.mjs",
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js",
|
||||
"docs/CODE-BOUNDARIES.md"
|
||||
]
|
||||
@@ -0,0 +1,101 @@
|
||||
import { readFile, writeFile, realpath, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import * as prettier from 'prettier';
|
||||
|
||||
/** Check or format the explicitly adopted files, without scanning other source. */
|
||||
export async function formatAdoptedFiles(root, mode) {
|
||||
if (!['--check', '--write'].includes(mode)) {
|
||||
throw new Error('Usage: node scripts/format.mjs --check|--write');
|
||||
}
|
||||
root = await realpath(root);
|
||||
const scope = JSON.parse(
|
||||
await readFile(path.join(root, 'scripts/format-scope.json'), 'utf8'),
|
||||
);
|
||||
if (
|
||||
!Array.isArray(scope) ||
|
||||
!scope.length ||
|
||||
new Set(scope).size !== scope.length
|
||||
) {
|
||||
throw new Error(
|
||||
'Formatting scope must be a nonempty list of unique file paths',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate the whole scope before any write, including resolved symlink paths.
|
||||
const files = [];
|
||||
for (const name of scope) {
|
||||
if (
|
||||
typeof name !== 'string' ||
|
||||
!name ||
|
||||
name.includes('\\') ||
|
||||
path.isAbsolute(name) ||
|
||||
name.split('/').includes('..')
|
||||
) {
|
||||
throw new Error(
|
||||
'Formatting scope entries must be paths inside the repository',
|
||||
);
|
||||
}
|
||||
const file = path.join(root, name);
|
||||
const relative = path.relative(root, await realpath(file));
|
||||
if (
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative) ||
|
||||
!(await stat(file)).isFile()
|
||||
) {
|
||||
throw new Error(
|
||||
`Formatting scope must contain repository files: ${name}`,
|
||||
);
|
||||
}
|
||||
const info = await prettier.getFileInfo(file, {
|
||||
ignorePath: path.join(root, '.prettierignore'),
|
||||
});
|
||||
if (info.ignored || !info.inferredParser) {
|
||||
throw new Error(
|
||||
`Formatting scope contains an ignored or unsupported file: ${name}`,
|
||||
);
|
||||
}
|
||||
const options = { ...(await prettier.resolveConfig(file)), filepath: file };
|
||||
const source = await readFile(file, 'utf8');
|
||||
files.push({
|
||||
name,
|
||||
file,
|
||||
source,
|
||||
formatted: await prettier.format(source, options),
|
||||
});
|
||||
}
|
||||
|
||||
const changed = files.filter(({ source, formatted }) => source !== formatted);
|
||||
if (mode === '--write') {
|
||||
for (const { file, formatted } of changed) await writeFile(file, formatted);
|
||||
}
|
||||
return { count: files.length, changed: changed.map(({ name }) => name) };
|
||||
}
|
||||
|
||||
const invoked = process.argv[1]
|
||||
? pathToFileURL(path.resolve(process.argv[1])).href
|
||||
: '';
|
||||
if (import.meta.url === invoked) {
|
||||
try {
|
||||
if (process.argv.length !== 3)
|
||||
throw new Error('Usage: node scripts/format.mjs --check|--write');
|
||||
const mode = process.argv[2];
|
||||
const result = await formatAdoptedFiles(
|
||||
fileURLToPath(new URL('../', import.meta.url)),
|
||||
mode,
|
||||
);
|
||||
if (mode === '--check' && result.changed.length) {
|
||||
for (const name of result.changed)
|
||||
console.error(`Needs formatting: ${name}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(
|
||||
`${mode === '--write' ? 'Formatted' : 'Checked'} ${result.count} adopted files.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"infrastructure": {
|
||||
"exports": [
|
||||
"./infrastructure",
|
||||
"./infrastructure/geojson",
|
||||
"./infrastructure/lod"
|
||||
],
|
||||
"modules": [
|
||||
"src/data/infrastructure.js",
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
}
|
||||
}
|
||||
+34
-24
@@ -1,8 +1,12 @@
|
||||
import { createLocalGeoJsonLayer } from './localGeojsonCore.js';
|
||||
|
||||
// Resolved by Vite in builds and relative to this module in other consumers.
|
||||
const datacentersUrl = new URL('./local_data/datacenters/datacenters.geojsonl', import.meta.url).href;
|
||||
const damsUrl = new URL('./local_data/dams/dams.geojsonl', import.meta.url).href;
|
||||
const datacentersUrl = new URL(
|
||||
'./local_data/datacenters/datacenters.geojsonl',
|
||||
import.meta.url,
|
||||
).href;
|
||||
const damsUrl = new URL('./local_data/dams/dams.geojsonl', import.meta.url)
|
||||
.href;
|
||||
|
||||
/**
|
||||
* Create fresh datacenter and dam layers without starting or loading them.
|
||||
@@ -10,29 +14,35 @@ const damsUrl = new URL('./local_data/dams/dams.geojsonl', import.meta.url).href
|
||||
* @returns {object[]} Datacenters then dams, with stable standalone identities.
|
||||
*/
|
||||
export function createInfrastructureLayers(services) {
|
||||
const datacenters = createLocalGeoJsonLayer({
|
||||
id: 'local-datacenters',
|
||||
url: datacentersUrl,
|
||||
name: 'Datacenters',
|
||||
color: '#00ffff', // Cyan
|
||||
icon: '▣',
|
||||
source: 'Local',
|
||||
labels: true,
|
||||
labelMax: 700,
|
||||
labelGridPx: 138,
|
||||
}, services);
|
||||
const datacenters = createLocalGeoJsonLayer(
|
||||
{
|
||||
id: 'local-datacenters',
|
||||
url: datacentersUrl,
|
||||
name: 'Datacenters',
|
||||
color: '#00ffff', // Cyan
|
||||
icon: '▣',
|
||||
source: 'Local',
|
||||
labels: true,
|
||||
labelMax: 700,
|
||||
labelGridPx: 138,
|
||||
},
|
||||
services,
|
||||
);
|
||||
|
||||
const dams = createLocalGeoJsonLayer({
|
||||
id: 'local-dams',
|
||||
url: damsUrl,
|
||||
name: 'Dams',
|
||||
color: '#0088ff', // Blue
|
||||
icon: '▰',
|
||||
source: 'USACE',
|
||||
labels: true,
|
||||
labelMax: 900,
|
||||
labelGridPx: 132,
|
||||
}, services);
|
||||
const dams = createLocalGeoJsonLayer(
|
||||
{
|
||||
id: 'local-dams',
|
||||
url: damsUrl,
|
||||
name: 'Dams',
|
||||
color: '#0088ff', // Blue
|
||||
icon: '▰',
|
||||
source: 'USACE',
|
||||
labels: true,
|
||||
labelMax: 900,
|
||||
labelGridPx: 132,
|
||||
},
|
||||
services,
|
||||
);
|
||||
|
||||
return [datacenters, dams];
|
||||
}
|
||||
@@ -12,11 +12,18 @@ function services() {
|
||||
return {
|
||||
records,
|
||||
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
||||
registerEntityContext(entity, metadata) { records.set(metadata.id, { entity, ...metadata }); },
|
||||
selectEntityContext(entity) { selection = entity; },
|
||||
clearSelectedEntityContextForLayer() { selection = undefined; },
|
||||
registerEntityContext(entity, metadata) {
|
||||
records.set(metadata.id, { entity, ...metadata });
|
||||
},
|
||||
selectEntityContext(entity) {
|
||||
selection = entity;
|
||||
},
|
||||
clearSelectedEntityContextForLayer() {
|
||||
selection = undefined;
|
||||
},
|
||||
removeEntityContextsForLayer(id) {
|
||||
for (const [key, record] of records) if (record.layerId === id) records.delete(key);
|
||||
for (const [key, record] of records)
|
||||
if (record.layerId === id) records.delete(key);
|
||||
},
|
||||
governorRequestRender() {},
|
||||
selection: () => selection,
|
||||
@@ -24,7 +31,12 @@ function services() {
|
||||
}
|
||||
|
||||
test('package exports import without an application, DOM, fetch, or timers', () => {
|
||||
const result = spawnSync(process.execPath, ['--input-type=module', '-e', `
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'--input-type=module',
|
||||
'-e',
|
||||
`
|
||||
globalThis.fetch = () => { throw new Error('unexpected fetch'); };
|
||||
globalThis.setTimeout = () => { throw new Error('unexpected timer'); };
|
||||
for (const key of ['window', 'document']) {
|
||||
@@ -33,51 +45,99 @@ test('package exports import without an application, DOM, fetch, or timers', ()
|
||||
await import('gods-eye-view/infrastructure');
|
||||
await import('gods-eye-view/infrastructure/geojson');
|
||||
await import('gods-eye-view/infrastructure/lod');
|
||||
`], { cwd: new URL('../..', import.meta.url), encoding: 'utf8' });
|
||||
`,
|
||||
],
|
||||
{ cwd: new URL('../..', import.meta.url), encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
|
||||
test('infrastructure factory preserves identity and creates independent state without loading', t => {
|
||||
t.mock.method(globalThis, 'fetch', () => { throw new Error('factory must not fetch'); });
|
||||
test('infrastructure factory preserves identity and creates independent state without loading', (t) => {
|
||||
t.mock.method(globalThis, 'fetch', () => {
|
||||
throw new Error('factory must not fetch');
|
||||
});
|
||||
const first = createInfrastructureLayers(services());
|
||||
const second = createInfrastructureLayers(services());
|
||||
assert.deepEqual(first.map(({ id, name, source }) => ({ id, name, source })), [
|
||||
{ id: 'local-datacenters', name: 'Datacenters', source: 'Local' },
|
||||
{ id: 'local-dams', name: 'Dams', source: 'USACE' },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
first.map(({ id, name, source }) => ({ id, name, source })),
|
||||
[
|
||||
{ id: 'local-datacenters', name: 'Datacenters', source: 'Local' },
|
||||
{ id: 'local-dams', name: 'Dams', source: 'USACE' },
|
||||
],
|
||||
);
|
||||
first.forEach((layer, index) => {
|
||||
assert.notEqual(layer, second[index]);
|
||||
layer.destroy();
|
||||
assert.deepEqual(second[index].getStats(), { count: 0, lastUpdate: null, error: null });
|
||||
assert.deepEqual(second[index].getStats(), {
|
||||
count: 0,
|
||||
lastUpdate: null,
|
||||
error: null,
|
||||
});
|
||||
second[index].destroy();
|
||||
});
|
||||
});
|
||||
|
||||
test('dataset URLs still name the complete bundled sources', () => {
|
||||
for (const [file, count] of [['datacenters', 4351], ['dams', 704]]) {
|
||||
const lines = readFileSync(new URL(`./local_data/${file}/${file}.geojsonl`, import.meta.url), 'utf8')
|
||||
.split('\n').filter(line => line.trim());
|
||||
for (const [file, count] of [
|
||||
['datacenters', 4351],
|
||||
['dams', 704],
|
||||
]) {
|
||||
const lines = readFileSync(
|
||||
new URL(`./local_data/${file}/${file}.geojsonl`, import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
assert.equal(lines.length, count);
|
||||
}
|
||||
});
|
||||
|
||||
test('two viewers use their supplied contexts and dispose independently', async t => {
|
||||
t.mock.method(globalThis, 'fetch', async () => ({ ok: true, text: async () => JSON.stringify({
|
||||
type: 'Feature', id: 'same-id', properties: { name: 'Dam' },
|
||||
geometry: { type: 'Polygon', coordinates: [[[0, 0], [0.01, 0], [0, 0.01], [0, 0]]] },
|
||||
}) }));
|
||||
test('two viewers use their supplied contexts and dispose independently', async (t) => {
|
||||
t.mock.method(globalThis, 'fetch', async () => ({
|
||||
ok: true,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
type: 'Feature',
|
||||
id: 'same-id',
|
||||
properties: { name: 'Dam' },
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[0, 0],
|
||||
[0.01, 0],
|
||||
[0, 0.01],
|
||||
[0, 0],
|
||||
],
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
const hosts = [services(), services()];
|
||||
const instances = hosts.map(host => {
|
||||
const instances = hosts.map((host) => {
|
||||
const sources = new Cesium.DataSourceCollection();
|
||||
let click;
|
||||
const layer = createLocalGeoJsonLayer({
|
||||
id: 'local-dams', name: 'Dams', color: '#0088ff', url: '/dams.geojsonl',
|
||||
screenSpaceEventHandlerFactory: () => ({ setInputAction(fn) { click = fn; }, destroy() {} }),
|
||||
}, host);
|
||||
const layer = createLocalGeoJsonLayer(
|
||||
{
|
||||
id: 'local-dams',
|
||||
name: 'Dams',
|
||||
color: '#0088ff',
|
||||
url: '/dams.geojsonl',
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction(fn) {
|
||||
click = fn;
|
||||
},
|
||||
destroy() {},
|
||||
}),
|
||||
},
|
||||
host,
|
||||
);
|
||||
const viewer = {
|
||||
dataSources: sources,
|
||||
scene: {
|
||||
canvas: {}, preRender: new Cesium.Event(), requestRender() {},
|
||||
canvas: {},
|
||||
preRender: new Cesium.Event(),
|
||||
requestRender() {},
|
||||
screenSpaceCameraController: { enableInputs: true },
|
||||
pick: () => ({ id: sources.get(0).entities.values[0] }),
|
||||
},
|
||||
@@ -85,7 +145,9 @@ test('two viewers use their supplied contexts and dispose independently', async
|
||||
};
|
||||
return { layer, viewer, click: () => click({ position: {} }) };
|
||||
});
|
||||
t.after(() => instances.forEach(({ layer, viewer }) => layer.destroy(viewer)));
|
||||
t.after(() =>
|
||||
instances.forEach(({ layer, viewer }) => layer.destroy(viewer)),
|
||||
);
|
||||
await Promise.all(instances.map(({ layer, viewer }) => layer.enable(viewer)));
|
||||
assert.equal(hosts[0].records.size, 1);
|
||||
assert.equal(hosts[1].records.size, 1);
|
||||
@@ -109,22 +171,35 @@ test('consumer build includes only infrastructure code and resolves assets under
|
||||
write: false,
|
||||
assetsInlineLimit: 0,
|
||||
rollupOptions: {
|
||||
input: fileURLToPath(import.meta.resolve('gods-eye-view/infrastructure')),
|
||||
input: fileURLToPath(
|
||||
import.meta.resolve('gods-eye-view/infrastructure'),
|
||||
),
|
||||
external: ['cesium'],
|
||||
preserveEntrySignatures: 'strict',
|
||||
},
|
||||
},
|
||||
});
|
||||
const output = result.output;
|
||||
const entry = output.find(item => item.type === 'chunk' && item.isEntry);
|
||||
const sources = Object.keys(entry.modules).filter(id => id.endsWith('.js'));
|
||||
assert.deepEqual(sources.map(id => id.split('/').at(-1)).sort(), [
|
||||
'infrastructure.js', 'localGeojsonCore.js', 'localGeojsonLod.js',
|
||||
const entry = output.find((item) => item.type === 'chunk' && item.isEntry);
|
||||
const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js'));
|
||||
assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [
|
||||
'infrastructure.js',
|
||||
'localGeojsonCore.js',
|
||||
'localGeojsonLod.js',
|
||||
]);
|
||||
assert.deepEqual(entry.imports, ['cesium'], 'the viewer supplies the same Cesium dependency');
|
||||
assert.deepEqual(
|
||||
entry.imports,
|
||||
['cesium'],
|
||||
'the viewer supplies the same Cesium dependency',
|
||||
);
|
||||
for (const name of ['datacenters', 'dams']) {
|
||||
const asset = output.find(item => item.type === 'asset' && item.fileName.includes(name));
|
||||
const asset = output.find(
|
||||
(item) => item.type === 'asset' && item.fileName.includes(name),
|
||||
);
|
||||
assert.ok(asset, `${name} must be emitted`);
|
||||
assert.ok(entry.code.includes(`/example/${asset.fileName}`), `${name} must retain the consumer base path`);
|
||||
assert.ok(
|
||||
entry.code.includes(`/example/${asset.fileName}`),
|
||||
`${name} must retain the consumer base path`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -494,7 +494,7 @@ test('local infrastructure creates no native labels or per-frame geometry callba
|
||||
assert.doesNotMatch(source, /new Cesium\.CallbackProperty/);
|
||||
assert.match(source, /feature\.position = tip/);
|
||||
assert.match(source, /record\.entity\.position\.setValue\(record\.tip\)/);
|
||||
assert.match(source, /const stemPositionBuffers = \[\[base, tip\], \[base, tip\]\]/);
|
||||
assert.match(source, /const stemPositionBuffers = \[\s*\[base, tip\],\s*\[base, tip\],?\s*\]/);
|
||||
assert.match(source, /record\.entity\.polyline\.positions\.setValue\(stemPositions\)/);
|
||||
assert.match(source, /viewer\.camera\.moveEnd\.addEventListener/);
|
||||
assert.match(source, /if \(refreshStemGeometry \|\| terrainFloorChanged\)/);
|
||||
|
||||
+434
-299
@@ -15,7 +15,8 @@ const LOCAL_OVERLAY_COLLISION_CAPACITY = 96;
|
||||
const LOCAL_OVERLAY_CELL_SURPLUS = 2;
|
||||
const LOCAL_OVERLAY_MAX_DISTANCE_M = 14000000;
|
||||
const LOCAL_OVERLAY_FADE_START_M = 250000;
|
||||
const LOCAL_OVERLAY_FADE_START_RATIO = LOCAL_OVERLAY_FADE_START_M / LOCAL_OVERLAY_MAX_DISTANCE_M;
|
||||
const LOCAL_OVERLAY_FADE_START_RATIO =
|
||||
LOCAL_OVERLAY_FADE_START_M / LOCAL_OVERLAY_MAX_DISTANCE_M;
|
||||
// Stems are anchored at ellipsoid height 0, but high-elevation features
|
||||
// (e.g. dams in river canyons) sit hundreds of meters above the ellipsoid,
|
||||
// burying the short close-in stem inside the photoreal mesh. Once the
|
||||
@@ -63,8 +64,12 @@ export function localInfrastructureOverlayCopy(properties, layerId) {
|
||||
props.capacity,
|
||||
]);
|
||||
const line = [operator, capacity]
|
||||
.filter((value, index, values) => value && values.indexOf(value) === index)
|
||||
.filter((value) => value.toLocaleLowerCase() !== title.toLocaleLowerCase())
|
||||
.filter(
|
||||
(value, index, values) => value && values.indexOf(value) === index,
|
||||
)
|
||||
.filter(
|
||||
(value) => value.toLocaleLowerCase() !== title.toLocaleLowerCase(),
|
||||
)
|
||||
.join(' · ');
|
||||
if (line) details.push(clampCardLine(line));
|
||||
} else if (layerId === 'local-dams') {
|
||||
@@ -147,21 +152,29 @@ export function createLocalInfrastructureOverlayEntry({
|
||||
* @param {number} [options.cohortLimit=Infinity] Host-safe materialization cap.
|
||||
* @returns {object[]} Bounded overlay entries for shared-host arbitration.
|
||||
*/
|
||||
export function selectLocalInfrastructureOverlayCohort(records, {
|
||||
maxEntries,
|
||||
gridPx,
|
||||
width,
|
||||
height,
|
||||
project,
|
||||
cohortLimit = Number.POSITIVE_INFINITY,
|
||||
}) {
|
||||
export function selectLocalInfrastructureOverlayCohort(
|
||||
records,
|
||||
{
|
||||
maxEntries,
|
||||
gridPx,
|
||||
width,
|
||||
height,
|
||||
project,
|
||||
cohortLimit = Number.POSITIVE_INFINITY,
|
||||
},
|
||||
) {
|
||||
const sourceCap = Math.max(0, Math.floor(Number(maxEntries) || 0));
|
||||
const materializationCap = Number.isFinite(Number(cohortLimit))
|
||||
? Math.max(0, Math.floor(Number(cohortLimit)))
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const cap = Math.min(sourceCap, materializationCap);
|
||||
const cellSize = Math.max(1, Number(gridPx) || 1);
|
||||
if (!Array.isArray(records) || records.length === 0 || cap === 0 || typeof project !== 'function') {
|
||||
if (
|
||||
!Array.isArray(records) ||
|
||||
records.length === 0 ||
|
||||
cap === 0 ||
|
||||
typeof project !== 'function'
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -170,8 +183,13 @@ export function selectLocalInfrastructureOverlayCohort(records, {
|
||||
for (const record of records) {
|
||||
const screen = project(record);
|
||||
if (!Number.isFinite(screen?.x) || !Number.isFinite(screen?.y)) continue;
|
||||
if (screen.x < -padding || screen.x > width + padding
|
||||
|| screen.y < -padding || screen.y > height + padding) continue;
|
||||
if (
|
||||
screen.x < -padding ||
|
||||
screen.x > width + padding ||
|
||||
screen.y < -padding ||
|
||||
screen.y > height + padding
|
||||
)
|
||||
continue;
|
||||
const key = `${Math.floor(screen.x / cellSize)}:${Math.floor(screen.y / cellSize)}`;
|
||||
let contenders = cells.get(key);
|
||||
if (!contenders) {
|
||||
@@ -189,7 +207,8 @@ export function selectLocalInfrastructureOverlayCohort(records, {
|
||||
}
|
||||
primary.sort(compareLocalOverlayRecords);
|
||||
surplus.sort(compareLocalOverlayRecords);
|
||||
if (primary.length >= cap) return primary.slice(0, cap).map((record) => record.entry);
|
||||
if (primary.length >= cap)
|
||||
return primary.slice(0, cap).map((record) => record.entry);
|
||||
const candidates = primary.concat(surplus.slice(0, cap - primary.length));
|
||||
return candidates.map((record) => record.entry);
|
||||
}
|
||||
@@ -201,10 +220,7 @@ export function selectLocalInfrastructureOverlayCohort(records, {
|
||||
* @param {object} [options.host] Test seam for the three host lifecycle calls.
|
||||
* @returns {{show:function():void,publish:function(object[]):void,hide:function():void,destroy:function():void}}
|
||||
*/
|
||||
export function createLocalInfrastructureOverlayPublisher({
|
||||
sourceId,
|
||||
host,
|
||||
}) {
|
||||
export function createLocalInfrastructureOverlayPublisher({ sourceId, host }) {
|
||||
let visible = false;
|
||||
let published = false;
|
||||
let destroyed = false;
|
||||
@@ -227,17 +243,26 @@ export function createLocalInfrastructureOverlayPublisher({
|
||||
},
|
||||
publish(entries) {
|
||||
if (destroyed || !visible) return;
|
||||
if (lastPublication && entries.length === lastPublication.length
|
||||
&& entries.every((entry, index) => {
|
||||
if (
|
||||
lastPublication &&
|
||||
entries.length === lastPublication.length &&
|
||||
entries.every((entry, index) => {
|
||||
const previous = lastPublication[index];
|
||||
return entry === previous.entry
|
||||
&& entry.position?.x === previous.x
|
||||
&& entry.position?.y === previous.y
|
||||
&& entry.position?.z === previous.z;
|
||||
})) return;
|
||||
return (
|
||||
entry === previous.entry &&
|
||||
entry.position?.x === previous.x &&
|
||||
entry.position?.y === previous.y &&
|
||||
entry.position?.z === previous.z
|
||||
);
|
||||
})
|
||||
)
|
||||
return;
|
||||
host.setEntries(sourceId, entries, sourceOptions);
|
||||
lastPublication = entries.map(entry => ({
|
||||
entry, x: entry.position?.x, y: entry.position?.y, z: entry.position?.z,
|
||||
lastPublication = entries.map((entry) => ({
|
||||
entry,
|
||||
x: entry.position?.x,
|
||||
y: entry.position?.y,
|
||||
z: entry.position?.z,
|
||||
}));
|
||||
published = entries.length > 0;
|
||||
},
|
||||
@@ -288,26 +313,31 @@ export function localDatasetError(error) {
|
||||
* One live instance per layer id is allowed in a given viewer/context/overlay host.
|
||||
* Destroy the previous instance before replacing it. Importing creates no layers.
|
||||
*/
|
||||
export function createLocalGeoJsonLayer({
|
||||
id,
|
||||
url,
|
||||
name,
|
||||
color,
|
||||
icon = '📍',
|
||||
source = 'Local JSONL',
|
||||
labels = true,
|
||||
labelMax = DEFAULT_LABEL_MAX,
|
||||
labelGridPx = DEFAULT_LABEL_GRID_PX,
|
||||
screenSpaceEventHandlerFactory = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas),
|
||||
projectToWindow = (scene, position) => Cesium.SceneTransforms.worldToWindowCoordinates(scene, position),
|
||||
}, {
|
||||
overlayHost,
|
||||
registerEntityContext,
|
||||
selectEntityContext,
|
||||
clearSelectedEntityContextForLayer,
|
||||
removeEntityContextsForLayer,
|
||||
governorRequestRender,
|
||||
}) {
|
||||
export function createLocalGeoJsonLayer(
|
||||
{
|
||||
id,
|
||||
url,
|
||||
name,
|
||||
color,
|
||||
icon = '📍',
|
||||
source = 'Local JSONL',
|
||||
labels = true,
|
||||
labelMax = DEFAULT_LABEL_MAX,
|
||||
labelGridPx = DEFAULT_LABEL_GRID_PX,
|
||||
screenSpaceEventHandlerFactory = (canvas) =>
|
||||
new Cesium.ScreenSpaceEventHandler(canvas),
|
||||
projectToWindow = (scene, position) =>
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates(scene, position),
|
||||
},
|
||||
{
|
||||
overlayHost,
|
||||
registerEntityContext,
|
||||
selectEntityContext,
|
||||
clearSelectedEntityContextForLayer,
|
||||
removeEntityContextsForLayer,
|
||||
governorRequestRender,
|
||||
},
|
||||
) {
|
||||
let _dataSource = null;
|
||||
let _enabled = false;
|
||||
let _clickHandler = null;
|
||||
@@ -483,231 +513,279 @@ export function createLocalGeoJsonLayer({
|
||||
|
||||
// 1. Initialize data source
|
||||
if (!_dataSource) {
|
||||
if (!_loadPromise) _loadPromise = (async () => {
|
||||
_loadController = new AbortController();
|
||||
const baseColor = Cesium.Color.fromCssColorString(color);
|
||||
if (!_loadPromise)
|
||||
_loadPromise = (async () => {
|
||||
_loadController = new AbortController();
|
||||
const baseColor = Cesium.Color.fromCssColorString(color);
|
||||
|
||||
// Fetch and parse JSON Lines (.geojsonl) into a FeatureCollection.
|
||||
// The source is built into a local and committed to `_dataSource`
|
||||
// only once setup finishes: a half-built source published early would
|
||||
// make every later enable() skip this block, so the layer could never
|
||||
// clear its error or retry.
|
||||
_error = null;
|
||||
let loaded = null;
|
||||
// Whether the scene has actually accepted `loaded` — the two rollback
|
||||
// windows (before vs after the add settles) need different cleanup.
|
||||
let addedToScene = false;
|
||||
try {
|
||||
const response = await fetch(url, { signal: _loadController.signal });
|
||||
if (_destroyed) return;
|
||||
// A 404 returns an HTML body that would otherwise die in JSON.parse
|
||||
// one line later, reported as a parse error for a missing file.
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status ?? '?'}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
if (_destroyed) return;
|
||||
const lines = text.split('\n').filter(l => l.trim().length > 0);
|
||||
// Fetch and parse JSON Lines (.geojsonl) into a FeatureCollection.
|
||||
// The source is built into a local and committed to `_dataSource`
|
||||
// only once setup finishes: a half-built source published early would
|
||||
// make every later enable() skip this block, so the layer could never
|
||||
// clear its error or retry.
|
||||
_error = null;
|
||||
let loaded = null;
|
||||
// Whether the scene has actually accepted `loaded` — the two rollback
|
||||
// windows (before vs after the add settles) need different cleanup.
|
||||
let addedToScene = false;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: _loadController.signal,
|
||||
});
|
||||
if (_destroyed) return;
|
||||
// A 404 returns an HTML body that would otherwise die in JSON.parse
|
||||
// one line later, reported as a parse error for a missing file.
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status ?? '?'}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
if (_destroyed) return;
|
||||
const lines = text.split('\n').filter((l) => l.trim().length > 0);
|
||||
|
||||
const features = lines.map(line => JSON.parse(line));
|
||||
const features = lines.map((line) => JSON.parse(line));
|
||||
|
||||
const geojson = {
|
||||
type: 'FeatureCollection',
|
||||
features
|
||||
};
|
||||
const geojson = {
|
||||
type: 'FeatureCollection',
|
||||
features,
|
||||
};
|
||||
|
||||
// Natively parse into entities and use it as our _dataSource
|
||||
loaded = await Cesium.GeoJsonDataSource.load(geojson, {
|
||||
clampToGround: true,
|
||||
stroke: baseColor,
|
||||
fill: baseColor.withAlpha(0.3),
|
||||
strokeWidth: 2,
|
||||
markerSize: 8,
|
||||
markerColor: baseColor,
|
||||
});
|
||||
// Natively parse into entities and use it as our _dataSource
|
||||
loaded = await Cesium.GeoJsonDataSource.load(geojson, {
|
||||
clampToGround: true,
|
||||
stroke: baseColor,
|
||||
fill: baseColor.withAlpha(0.3),
|
||||
strokeWidth: 2,
|
||||
markerSize: 8,
|
||||
markerColor: baseColor,
|
||||
});
|
||||
|
||||
if (_destroyed) return;
|
||||
loaded.name = name;
|
||||
loaded.show = false;
|
||||
// Cesium's DataSourceCollection.add() returns a promise and only
|
||||
// inserts on a later microtask. Without this await, a throw during
|
||||
// post-processing would roll back a source the scene had not
|
||||
// accepted yet — and Cesium would then insert the "removed" source
|
||||
// anyway, leaving an orphan the retry would double up on. Awaiting
|
||||
// also routes an add() rejection into the error path below instead
|
||||
// of leaving it uncaught with healthy-looking stats.
|
||||
await viewer.dataSources.add(loaded);
|
||||
addedToScene = true;
|
||||
if (_destroyed) {
|
||||
viewer.dataSources.remove(loaded, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert parsed points into 3D stems or style polygons
|
||||
const entities = loaded.entities.values;
|
||||
_count = entities.length;
|
||||
_stemRecords = [];
|
||||
_stemGeometryDirty = true;
|
||||
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const feature = entities[i];
|
||||
feature.__localLayerId = id; // Tag it so our click handler knows it belongs to this layer
|
||||
|
||||
let pos = feature.position?.getValue(Cesium.JulianDate.now());
|
||||
|
||||
if (!pos) {
|
||||
// It's a polygon or line
|
||||
if (feature.polygon) {
|
||||
feature.polygon.outline = true;
|
||||
feature.polygon.outlineColor = baseColor;
|
||||
|
||||
// Calculate center point for the stem
|
||||
const hierarchy = feature.polygon.hierarchy?.getValue(Cesium.JulianDate.now());
|
||||
if (hierarchy && hierarchy.positions && hierarchy.positions.length > 0) {
|
||||
pos = Cesium.BoundingSphere.fromPoints(hierarchy.positions).center;
|
||||
}
|
||||
}
|
||||
if (_destroyed) return;
|
||||
loaded.name = name;
|
||||
loaded.show = false;
|
||||
// Cesium's DataSourceCollection.add() returns a promise and only
|
||||
// inserts on a later microtask. Without this await, a throw during
|
||||
// post-processing would roll back a source the scene had not
|
||||
// accepted yet — and Cesium would then insert the "removed" source
|
||||
// anyway, leaving an orphan the retry would double up on. Awaiting
|
||||
// also routes an add() rejection into the error path below instead
|
||||
// of leaving it uncaught with healthy-looking stats.
|
||||
await viewer.dataSources.add(loaded);
|
||||
addedToScene = true;
|
||||
if (_destroyed) {
|
||||
viewer.dataSources.remove(loaded, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pos) continue;
|
||||
// Convert parsed points into 3D stems or style polygons
|
||||
const entities = loaded.entities.values;
|
||||
_count = entities.length;
|
||||
_stemRecords = [];
|
||||
_stemGeometryDirty = true;
|
||||
|
||||
const carto = Cesium.Cartographic.fromCartesian(pos);
|
||||
const groundHeight = 0; // Ellipsoid surface until a scene sample lands
|
||||
const tipHeight = 2000; // Initial Stem height
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const feature = entities[i];
|
||||
feature.__localLayerId = id; // Tag it so our click handler knows it belongs to this layer
|
||||
|
||||
const base = Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, groundHeight);
|
||||
const tip = Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, tipHeight);
|
||||
const properties = propertyObject(feature);
|
||||
const recordId = String(feature.id ?? i);
|
||||
let pos = feature.position?.getValue(Cesium.JulianDate.now());
|
||||
|
||||
// Store references for bounded stem scaling and native picking.
|
||||
feature.__localBaseCarto = carto;
|
||||
feature.__localBaseCartesian = base;
|
||||
registerEntityContext(feature, {
|
||||
id: `${id}:${recordId}`,
|
||||
layerId: id,
|
||||
layerName: name,
|
||||
source,
|
||||
dataSource: loaded,
|
||||
label: featureLabelFromProperties(properties, id),
|
||||
properties,
|
||||
latitude: Number(Cesium.Math.toDegrees(carto.latitude).toFixed(6)),
|
||||
longitude: Number(Cesium.Math.toDegrees(carto.longitude).toFixed(6)),
|
||||
});
|
||||
if (!pos) {
|
||||
// It's a polygon or line
|
||||
if (feature.polygon) {
|
||||
feature.polygon.outline = true;
|
||||
feature.polygon.outlineColor = baseColor;
|
||||
|
||||
// Constant properties are refreshed on the existing 450 ms source
|
||||
// cadence. Cesium no longer evaluates 2-3 callbacks per entity on
|
||||
// every frame, while the point/stem pick surface stays native.
|
||||
feature.position = tip;
|
||||
const stemPositionBuffers = [[base, tip], [base, tip]];
|
||||
feature.polyline = new Cesium.PolylineGraphics({
|
||||
positions: stemPositionBuffers[0],
|
||||
width: 3.5,
|
||||
material: new Cesium.ColorMaterialProperty(baseColor),
|
||||
});
|
||||
feature.point = new Cesium.PointGraphics({
|
||||
pixelSize: 10,
|
||||
color: baseColor,
|
||||
outlineColor: Cesium.Color.BLACK,
|
||||
outlineWidth: 2,
|
||||
// Never depth-cull the anchor against the photoreal mesh —
|
||||
// globe-horizon culling is handled by the pre-render occluder.
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
// Calculate center point for the stem
|
||||
const hierarchy = feature.polygon.hierarchy?.getValue(
|
||||
Cesium.JulianDate.now(),
|
||||
);
|
||||
if (
|
||||
hierarchy &&
|
||||
hierarchy.positions &&
|
||||
hierarchy.positions.length > 0
|
||||
) {
|
||||
pos = Cesium.BoundingSphere.fromPoints(
|
||||
hierarchy.positions,
|
||||
).center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const priority = labelPriorityFromProperties(properties, id);
|
||||
_stemRecords.push({
|
||||
id: recordId,
|
||||
entity: feature,
|
||||
carto,
|
||||
base,
|
||||
tip,
|
||||
nextTip: Cesium.Cartesian3.clone(tip),
|
||||
stemPositionBuffers,
|
||||
stemPositionBufferIndex: 0,
|
||||
groundHeight,
|
||||
groundSampled: false,
|
||||
lastGroundSampleMs: 0,
|
||||
priority,
|
||||
entry: labels ? createLocalInfrastructureOverlayEntry({
|
||||
id: recordId,
|
||||
if (!pos) continue;
|
||||
|
||||
const carto = Cesium.Cartographic.fromCartesian(pos);
|
||||
const groundHeight = 0; // Ellipsoid surface until a scene sample lands
|
||||
const tipHeight = 2000; // Initial Stem height
|
||||
|
||||
const base = Cesium.Cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
groundHeight,
|
||||
);
|
||||
const tip = Cesium.Cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
tipHeight,
|
||||
);
|
||||
const properties = propertyObject(feature);
|
||||
const recordId = String(feature.id ?? i);
|
||||
|
||||
// Store references for bounded stem scaling and native picking.
|
||||
feature.__localBaseCarto = carto;
|
||||
feature.__localBaseCartesian = base;
|
||||
registerEntityContext(feature, {
|
||||
id: `${id}:${recordId}`,
|
||||
layerId: id,
|
||||
position: tip,
|
||||
layerName: name,
|
||||
source,
|
||||
dataSource: loaded,
|
||||
label: featureLabelFromProperties(properties, id),
|
||||
properties,
|
||||
latitude: Number(
|
||||
Cesium.Math.toDegrees(carto.latitude).toFixed(6),
|
||||
),
|
||||
longitude: Number(
|
||||
Cesium.Math.toDegrees(carto.longitude).toFixed(6),
|
||||
),
|
||||
});
|
||||
|
||||
// Constant properties are refreshed on the existing 450 ms source
|
||||
// cadence. Cesium no longer evaluates 2-3 callbacks per entity on
|
||||
// every frame, while the point/stem pick surface stays native.
|
||||
feature.position = tip;
|
||||
const stemPositionBuffers = [
|
||||
[base, tip],
|
||||
[base, tip],
|
||||
];
|
||||
feature.polyline = new Cesium.PolylineGraphics({
|
||||
positions: stemPositionBuffers[0],
|
||||
width: 3.5,
|
||||
material: new Cesium.ColorMaterialProperty(baseColor),
|
||||
});
|
||||
feature.point = new Cesium.PointGraphics({
|
||||
pixelSize: 10,
|
||||
color: baseColor,
|
||||
outlineColor: Cesium.Color.BLACK,
|
||||
outlineWidth: 2,
|
||||
// Never depth-cull the anchor against the photoreal mesh —
|
||||
// globe-horizon culling is handled by the pre-render occluder.
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
|
||||
const priority = labelPriorityFromProperties(properties, id);
|
||||
_stemRecords.push({
|
||||
id: recordId,
|
||||
entity: feature,
|
||||
carto,
|
||||
base,
|
||||
tip,
|
||||
nextTip: Cesium.Cartesian3.clone(tip),
|
||||
stemPositionBuffers,
|
||||
stemPositionBufferIndex: 0,
|
||||
groundHeight,
|
||||
groundSampled: false,
|
||||
lastGroundSampleMs: 0,
|
||||
priority,
|
||||
accent: color,
|
||||
}) : null,
|
||||
});
|
||||
}
|
||||
// Setup finished — publish it.
|
||||
_dataSource = loaded;
|
||||
_lastUpdate = Date.now();
|
||||
} catch (e) {
|
||||
// The dataset ships with the build, so this is a broken install,
|
||||
// not a blip — it has to reach the chip, not just the console.
|
||||
if (!_destroyed) _error = localDatasetError(e);
|
||||
// Roll the partial build back so a later enable() retries from
|
||||
// scratch instead of inheriting a half-populated source. Only the
|
||||
// post-add window has something in the scene to remove: a failure
|
||||
// before (or inside) add() never reached the collection, and
|
||||
// removing then would race Cesium's pending insert.
|
||||
if (addedToScene) {
|
||||
try { viewer?.dataSources?.remove(loaded, true); } catch { /* already gone */ }
|
||||
}
|
||||
if (_destroyed) return;
|
||||
removeEntityContextsForLayer(id);
|
||||
_count = 0;
|
||||
_stemRecords = [];
|
||||
console.error(`Failed to load ${id}:`, e);
|
||||
}
|
||||
|
||||
if (_destroyed) return;
|
||||
// 2. Install native global click handler
|
||||
if (!_clickHandler) {
|
||||
_clickHandler = screenSpaceEventHandlerFactory(viewer.scene.canvas);
|
||||
_clickHandler.setInputAction((click) => {
|
||||
if (!_enabled) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
|
||||
if (picked && picked.id && picked.id.__localLayerId === id) {
|
||||
const entity = picked.id;
|
||||
viewer.selectedEntity = entity;
|
||||
selectEntityContext(entity);
|
||||
|
||||
// We zoom to the surface base of the stem or the center of the polygon
|
||||
let targetPos = null;
|
||||
|
||||
if (entity.polyline) {
|
||||
// If it's a stem, fly to the base
|
||||
const positions = entity.polyline.positions.getValue(Cesium.JulianDate.now());
|
||||
if (positions && positions.length > 0) {
|
||||
targetPos = positions[0];
|
||||
}
|
||||
} else if (entity.polygon && entity.polygon.hierarchy) {
|
||||
// If it's a polygon, just fly to its center
|
||||
const hierarchy = entity.polygon.hierarchy.getValue(Cesium.JulianDate.now());
|
||||
if (hierarchy && hierarchy.positions.length > 0) {
|
||||
targetPos = Cesium.BoundingSphere.fromPoints(hierarchy.positions).center;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetPos) {
|
||||
const carto = Cesium.Cartographic.fromCartesian(targetPos);
|
||||
|
||||
// Disable interactions so Cesium doesn't magically cancel the flight
|
||||
viewer.scene.screenSpaceCameraController.enableInputs = false;
|
||||
|
||||
viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, 5000),
|
||||
duration: 1.5,
|
||||
complete: () => { viewer.scene.screenSpaceCameraController.enableInputs = true; },
|
||||
cancel: () => { viewer.scene.screenSpaceCameraController.enableInputs = true; },
|
||||
});
|
||||
entry: labels
|
||||
? createLocalInfrastructureOverlayEntry({
|
||||
id: recordId,
|
||||
layerId: id,
|
||||
position: tip,
|
||||
properties,
|
||||
priority,
|
||||
accent: color,
|
||||
})
|
||||
: null,
|
||||
});
|
||||
}
|
||||
// Setup finished — publish it.
|
||||
_dataSource = loaded;
|
||||
_lastUpdate = Date.now();
|
||||
} catch (e) {
|
||||
// The dataset ships with the build, so this is a broken install,
|
||||
// not a blip — it has to reach the chip, not just the console.
|
||||
if (!_destroyed) _error = localDatasetError(e);
|
||||
// Roll the partial build back so a later enable() retries from
|
||||
// scratch instead of inheriting a half-populated source. Only the
|
||||
// post-add window has something in the scene to remove: a failure
|
||||
// before (or inside) add() never reached the collection, and
|
||||
// removing then would race Cesium's pending insert.
|
||||
if (addedToScene) {
|
||||
try {
|
||||
viewer?.dataSources?.remove(loaded, true);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
})();
|
||||
if (_destroyed) return;
|
||||
removeEntityContextsForLayer(id);
|
||||
_count = 0;
|
||||
_stemRecords = [];
|
||||
console.error(`Failed to load ${id}:`, e);
|
||||
}
|
||||
|
||||
if (_destroyed) return;
|
||||
// 2. Install native global click handler
|
||||
if (!_clickHandler) {
|
||||
_clickHandler = screenSpaceEventHandlerFactory(
|
||||
viewer.scene.canvas,
|
||||
);
|
||||
_clickHandler.setInputAction((click) => {
|
||||
if (!_enabled) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
|
||||
if (picked && picked.id && picked.id.__localLayerId === id) {
|
||||
const entity = picked.id;
|
||||
viewer.selectedEntity = entity;
|
||||
selectEntityContext(entity);
|
||||
|
||||
// We zoom to the surface base of the stem or the center of the polygon
|
||||
let targetPos = null;
|
||||
|
||||
if (entity.polyline) {
|
||||
// If it's a stem, fly to the base
|
||||
const positions = entity.polyline.positions.getValue(
|
||||
Cesium.JulianDate.now(),
|
||||
);
|
||||
if (positions && positions.length > 0) {
|
||||
targetPos = positions[0];
|
||||
}
|
||||
} else if (entity.polygon && entity.polygon.hierarchy) {
|
||||
// If it's a polygon, just fly to its center
|
||||
const hierarchy = entity.polygon.hierarchy.getValue(
|
||||
Cesium.JulianDate.now(),
|
||||
);
|
||||
if (hierarchy && hierarchy.positions.length > 0) {
|
||||
targetPos = Cesium.BoundingSphere.fromPoints(
|
||||
hierarchy.positions,
|
||||
).center;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetPos) {
|
||||
const carto = Cesium.Cartographic.fromCartesian(targetPos);
|
||||
|
||||
// Disable interactions so Cesium doesn't magically cancel the flight
|
||||
viewer.scene.screenSpaceCameraController.enableInputs = false;
|
||||
|
||||
viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
5000,
|
||||
),
|
||||
duration: 1.5,
|
||||
complete: () => {
|
||||
viewer.scene.screenSpaceCameraController.enableInputs = true;
|
||||
},
|
||||
cancel: () => {
|
||||
viewer.scene.screenSpaceCameraController.enableInputs = true;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
})();
|
||||
try {
|
||||
await _loadPromise;
|
||||
} finally {
|
||||
@@ -741,7 +819,10 @@ export function createLocalGeoJsonLayer({
|
||||
const probe = shouldRecomputeInfraLod({
|
||||
nowMs: now,
|
||||
lastProbeMs: _lastLodProbeMs,
|
||||
movedSqM: Cesium.Cartesian3.distanceSquared(cameraPos, _lastLodCameraPos),
|
||||
movedSqM: Cesium.Cartesian3.distanceSquared(
|
||||
cameraPos,
|
||||
_lastLodCameraPos,
|
||||
),
|
||||
cameraHeightM: viewer.camera.positionCartographic?.height,
|
||||
});
|
||||
_lastLodProbeMs = probe.lastProbeMs;
|
||||
@@ -750,7 +831,10 @@ export function createLocalGeoJsonLayer({
|
||||
if (probe.recompute) _stemGeometryDirty = true;
|
||||
}
|
||||
|
||||
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, cameraPos);
|
||||
const occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
cameraPos,
|
||||
);
|
||||
const visibleOverlayRecords = [];
|
||||
const refreshStemGeometry = _stemGeometryDirty;
|
||||
|
||||
@@ -762,7 +846,8 @@ export function createLocalGeoJsonLayer({
|
||||
// Capability can arrive late (WebGL context restore, a tileset that
|
||||
// finally supports sampling). A parked camera has no moveEnd to
|
||||
// re-open a spent budget, so the false→true edge does it.
|
||||
if (canSampleGround && _lastGroundSampleCapability === false) _groundRetryArms = 0;
|
||||
if (canSampleGround && _lastGroundSampleCapability === false)
|
||||
_groundRetryArms = 0;
|
||||
_lastGroundSampleCapability = canSampleGround;
|
||||
let groundRetryPending = false;
|
||||
let groundSampleProgress = false;
|
||||
@@ -814,7 +899,8 @@ export function createLocalGeoJsonLayer({
|
||||
const record = _stemRecords[i];
|
||||
const isActive = !_lodComputed || _activeLodIds.has(record.id);
|
||||
const isVisible = isActive && occluder.isPointVisible(record.base);
|
||||
if (record.entity.show !== isVisible) record.entity.show = isVisible;
|
||||
if (record.entity.show !== isVisible)
|
||||
record.entity.show = isVisible;
|
||||
|
||||
// Records outside the globe-LOD active set carry no visible stem,
|
||||
// so skip every per-frame cost for them — geometry trig, the
|
||||
@@ -823,20 +909,32 @@ export function createLocalGeoJsonLayer({
|
||||
if (!isActive) continue;
|
||||
|
||||
const wasGroundSampled = record.groundSampled;
|
||||
const terrainFloorChanged = refreshLocalTerrainFloor(viewer, record);
|
||||
const terrainFloorChanged = refreshLocalTerrainFloor(
|
||||
viewer,
|
||||
record,
|
||||
);
|
||||
if (refreshStemGeometry || terrainFloorChanged) {
|
||||
updateLocalStemGeometry(viewer, record, now);
|
||||
} else if (canSampleGround && !record.groundSampled
|
||||
&& now - record.lastGroundSampleMs >= GROUND_SAMPLE_RETRY_MS) {
|
||||
} else if (
|
||||
canSampleGround &&
|
||||
!record.groundSampled &&
|
||||
now - record.lastGroundSampleMs >= GROUND_SAMPLE_RETRY_MS
|
||||
) {
|
||||
// Capability first: without it the distance below is pure waste,
|
||||
// once per ungrounded record per walk, forever.
|
||||
const distance = Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base);
|
||||
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M
|
||||
&& sampleLocalGroundHeight(viewer, record, now)) {
|
||||
const distance = Cesium.Cartesian3.distance(
|
||||
viewer.camera.positionWC,
|
||||
record.base,
|
||||
);
|
||||
if (
|
||||
distance < GROUND_SAMPLE_MAX_DISTANCE_M &&
|
||||
sampleLocalGroundHeight(viewer, record, now)
|
||||
) {
|
||||
updateLocalStemGeometry(viewer, record, now, distance);
|
||||
}
|
||||
}
|
||||
if (!wasGroundSampled && record.groundSampled) groundSampleProgress = true;
|
||||
if (!wasGroundSampled && record.groundSampled)
|
||||
groundSampleProgress = true;
|
||||
// Still unsampled AND close enough for a retry to succeed: this
|
||||
// layer has no hold and no periodic update, so under the idle
|
||||
// governor the retry's preRender never arrives on a parked camera
|
||||
@@ -845,9 +943,15 @@ export function createLocalGeoJsonLayer({
|
||||
// Gated on a sampleable scene and in-range records only, so a far
|
||||
// camera (or a keyless scene) stays fully idle; the distance is
|
||||
// only computed for still-unsampled stems.
|
||||
if (canSampleGround && !record.groundSampled && !groundRetryPending
|
||||
&& Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base)
|
||||
< GROUND_SAMPLE_MAX_DISTANCE_M) {
|
||||
if (
|
||||
canSampleGround &&
|
||||
!record.groundSampled &&
|
||||
!groundRetryPending &&
|
||||
Cesium.Cartesian3.distance(
|
||||
viewer.camera.positionWC,
|
||||
record.base,
|
||||
) < GROUND_SAMPLE_MAX_DISTANCE_M
|
||||
) {
|
||||
groundRetryPending = true;
|
||||
}
|
||||
if (isVisible && record.entry) visibleOverlayRecords.push(record);
|
||||
@@ -859,14 +963,17 @@ export function createLocalGeoJsonLayer({
|
||||
if (groundRetryPending) scheduleGroundRetryRender(viewer);
|
||||
|
||||
const canvas = viewer.scene.canvas;
|
||||
const cohort = selectLocalInfrastructureOverlayCohort(visibleOverlayRecords, {
|
||||
maxEntries: labelMax,
|
||||
gridPx: labelGridPx,
|
||||
width: canvas.clientWidth || canvas.width || 0,
|
||||
height: canvas.clientHeight || canvas.height || 0,
|
||||
cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT,
|
||||
project: (record) => projectToWindow(viewer.scene, record.tip),
|
||||
});
|
||||
const cohort = selectLocalInfrastructureOverlayCohort(
|
||||
visibleOverlayRecords,
|
||||
{
|
||||
maxEntries: labelMax,
|
||||
gridPx: labelGridPx,
|
||||
width: canvas.clientWidth || canvas.width || 0,
|
||||
height: canvas.clientHeight || canvas.height || 0,
|
||||
cohortLimit: LOCAL_OVERLAY_COHORT_LIMIT,
|
||||
project: (record) => projectToWindow(viewer.scene, record.tip),
|
||||
},
|
||||
);
|
||||
_overlayPublisher.publish(cohort);
|
||||
});
|
||||
}
|
||||
@@ -913,7 +1020,7 @@ export function createLocalGeoJsonLayer({
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_error = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -923,24 +1030,36 @@ function compareLocalOverlayRecords(a, b) {
|
||||
|
||||
function insertLocalCellContender(contenders, record) {
|
||||
let index = 0;
|
||||
while (index < contenders.length && compareLocalOverlayRecords(contenders[index], record) <= 0) {
|
||||
while (
|
||||
index < contenders.length &&
|
||||
compareLocalOverlayRecords(contenders[index], record) <= 0
|
||||
) {
|
||||
index++;
|
||||
}
|
||||
contenders.splice(index, 0, record);
|
||||
if (contenders.length > LOCAL_OVERLAY_CELL_SURPLUS) contenders.length = LOCAL_OVERLAY_CELL_SURPLUS;
|
||||
if (contenders.length > LOCAL_OVERLAY_CELL_SURPLUS)
|
||||
contenders.length = LOCAL_OVERLAY_CELL_SURPLUS;
|
||||
}
|
||||
|
||||
function refreshLocalTerrainFloor(viewer, record) {
|
||||
const globe = viewer.scene.globe;
|
||||
if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false) return false;
|
||||
if (Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base)
|
||||
>= GROUND_SAMPLE_MAX_DISTANCE_M) return false;
|
||||
if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false)
|
||||
return false;
|
||||
if (
|
||||
Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base) >=
|
||||
GROUND_SAMPLE_MAX_DISTANCE_M
|
||||
)
|
||||
return false;
|
||||
// Camera motion can refine terrain after the first successful depth sample.
|
||||
// Reuse the loaded mesh on existing bounded walks; never arm a timer or do
|
||||
// another GPU readback just to maintain this floor. Keep roof elevations.
|
||||
const height = globe.getHeight?.(record.carto);
|
||||
if (!Number.isFinite(height) || Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M
|
||||
|| height <= record.groundHeight) return false;
|
||||
if (
|
||||
!Number.isFinite(height) ||
|
||||
Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M ||
|
||||
height <= record.groundHeight
|
||||
)
|
||||
return false;
|
||||
setLocalGroundHeight(record, height);
|
||||
return true;
|
||||
}
|
||||
@@ -957,13 +1076,22 @@ function sampleLocalGroundHeight(viewer, record, now) {
|
||||
} catch {
|
||||
return false; // tiles not ready; retry on a later bounded update
|
||||
}
|
||||
if (!Number.isFinite(sampled) || Math.abs(sampled) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M) return false;
|
||||
if (
|
||||
!Number.isFinite(sampled) ||
|
||||
Math.abs(sampled) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M
|
||||
)
|
||||
return false;
|
||||
// A coarse scene-depth sample can lie below the terrain already loaded by
|
||||
// the visible globe. Keep that terrain as a floor while allowing roofs and
|
||||
// valid below-sea-level elevations. Photoreal scenes hide the globe, so its
|
||||
// inactive terrain must not constrain their geometry.
|
||||
const terrainHeight = globe?.show ? globe.getHeight?.(record.carto) : undefined;
|
||||
if (Number.isFinite(terrainHeight) && Math.abs(terrainHeight) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M) {
|
||||
const terrainHeight = globe?.show
|
||||
? globe.getHeight?.(record.carto)
|
||||
: undefined;
|
||||
if (
|
||||
Number.isFinite(terrainHeight) &&
|
||||
Math.abs(terrainHeight) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M
|
||||
) {
|
||||
sampled = Math.max(sampled, terrainHeight);
|
||||
}
|
||||
record.groundSampled = true;
|
||||
@@ -987,12 +1115,13 @@ function updateLocalStemGeometry(viewer, record, now, knownDistance = null) {
|
||||
const distance = Number.isFinite(knownDistance)
|
||||
? knownDistance
|
||||
: Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base);
|
||||
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M) sampleLocalGroundHeight(viewer, record, now);
|
||||
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M)
|
||||
sampleLocalGroundHeight(viewer, record, now);
|
||||
// Keep the intended screen-size scaling in close-up views too. A 5 km
|
||||
// minimum made a marker hundreds of metres tall beside a nearby building.
|
||||
const effectiveDistance = Math.max(distance, 1);
|
||||
const canvasHeight = viewer.scene.canvas.clientHeight || 1080;
|
||||
const fov = viewer.camera.frustum.fov || (Math.PI / 3);
|
||||
const fov = viewer.camera.frustum.fov || Math.PI / 3;
|
||||
const targetPx = 65;
|
||||
const fovFactor = 2 * Math.tan(fov / 2) * (targetPx / canvasHeight);
|
||||
const tipHeight = record.groundHeight + effectiveDistance * fovFactor;
|
||||
@@ -1003,12 +1132,16 @@ function updateLocalStemGeometry(viewer, record, now, knownDistance = null) {
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
record.nextTip,
|
||||
);
|
||||
if (Cesium.Cartesian3.distanceSquared(record.tip, record.nextTip) <= LOCAL_STEM_TIP_EPSILON_SQ) {
|
||||
if (
|
||||
Cesium.Cartesian3.distanceSquared(record.tip, record.nextTip) <=
|
||||
LOCAL_STEM_TIP_EPSILON_SQ
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
Cesium.Cartesian3.clone(record.nextTip, record.tip);
|
||||
record.stemPositionBufferIndex = 1 - record.stemPositionBufferIndex;
|
||||
const stemPositions = record.stemPositionBuffers[record.stemPositionBufferIndex];
|
||||
const stemPositions =
|
||||
record.stemPositionBuffers[record.stemPositionBufferIndex];
|
||||
stemPositions[0] = record.base;
|
||||
stemPositions[1] = record.tip;
|
||||
record.entity.position.setValue(record.tip);
|
||||
@@ -1050,9 +1183,10 @@ function labelPriorityFromProperties(props, layerId) {
|
||||
|
||||
function propertyObject(entity) {
|
||||
const source = entity?.properties;
|
||||
const raw = typeof source?.getValue === 'function'
|
||||
? source.getValue(Cesium.JulianDate.now())
|
||||
: source || {};
|
||||
const raw =
|
||||
typeof source?.getValue === 'function'
|
||||
? source.getValue(Cesium.JulianDate.now())
|
||||
: source || {};
|
||||
return unwrapProperties(raw);
|
||||
}
|
||||
|
||||
@@ -1061,9 +1195,10 @@ function unwrapProperties(value) {
|
||||
if (Array.isArray(value)) return value.map(unwrapProperties);
|
||||
const out = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
out[key] = entry && typeof entry.getValue === 'function'
|
||||
? unwrapProperties(entry.getValue(Cesium.JulianDate.now()))
|
||||
: unwrapProperties(entry);
|
||||
out[key] =
|
||||
entry && typeof entry.getValue === 'function'
|
||||
? unwrapProperties(entry.getValue(Cesium.JulianDate.now()))
|
||||
: unwrapProperties(entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
+92
-45
@@ -58,8 +58,10 @@ export function infraLodBudget(cameraHeightM) {
|
||||
const height = Number.isFinite(cameraHeightM)
|
||||
? Math.max(0, cameraHeightM)
|
||||
: INFRA_LOD_GLOBAL_HEIGHT_M;
|
||||
if (height >= INFRA_LOD_GLOBAL_HEIGHT_M) return { activeLimit: INFRA_LOD_ACTIVE_MIN };
|
||||
if (height >= INFRA_LOD_REGIONAL_HEIGHT_M) return { activeLimit: INFRA_LOD_ACTIVE_MID };
|
||||
if (height >= INFRA_LOD_GLOBAL_HEIGHT_M)
|
||||
return { activeLimit: INFRA_LOD_ACTIVE_MIN };
|
||||
if (height >= INFRA_LOD_REGIONAL_HEIGHT_M)
|
||||
return { activeLimit: INFRA_LOD_ACTIVE_MID };
|
||||
return { activeLimit: INFRA_LOD_ACTIVE_MAX };
|
||||
}
|
||||
|
||||
@@ -104,24 +106,33 @@ export const INFRA_LOD_MAX_DISTANCE_PENALTY = 200;
|
||||
* @param {number} [options.maxDistancePenalty]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function infraRankScore(priority, distanceM, isIncumbent, {
|
||||
incumbentBonus = INFRA_LOD_INCUMBENT_BONUS,
|
||||
farM = INFRA_LOD_FAR_M,
|
||||
maxDistancePenalty = INFRA_LOD_MAX_DISTANCE_PENALTY,
|
||||
} = {}) {
|
||||
export function infraRankScore(
|
||||
priority,
|
||||
distanceM,
|
||||
isIncumbent,
|
||||
{
|
||||
incumbentBonus = INFRA_LOD_INCUMBENT_BONUS,
|
||||
farM = INFRA_LOD_FAR_M,
|
||||
maxDistancePenalty = INFRA_LOD_MAX_DISTANCE_PENALTY,
|
||||
} = {},
|
||||
) {
|
||||
const p = Number.isFinite(priority) ? priority : 0;
|
||||
const far = Number.isFinite(farM) && farM > 0 ? farM : INFRA_LOD_FAR_M;
|
||||
const d = Number.isFinite(distanceM) && distanceM >= 0 ? distanceM : far;
|
||||
const penaltyCap = Number.isFinite(maxDistancePenalty) && maxDistancePenalty >= 0
|
||||
? maxDistancePenalty
|
||||
: INFRA_LOD_MAX_DISTANCE_PENALTY;
|
||||
const bonus = isIncumbent && Number.isFinite(incumbentBonus) ? incumbentBonus : 0;
|
||||
const penaltyCap =
|
||||
Number.isFinite(maxDistancePenalty) && maxDistancePenalty >= 0
|
||||
? maxDistancePenalty
|
||||
: INFRA_LOD_MAX_DISTANCE_PENALTY;
|
||||
const bonus =
|
||||
isIncumbent && Number.isFinite(incumbentBonus) ? incumbentBonus : 0;
|
||||
return p + bonus - penaltyCap * Math.min(1, d / far);
|
||||
}
|
||||
|
||||
/** Infinity-safe distance for a total sort order. */
|
||||
function sortableDistance(distanceM) {
|
||||
return Number.isFinite(distanceM) && distanceM >= 0 ? distanceM : Number.MAX_VALUE;
|
||||
return Number.isFinite(distanceM) && distanceM >= 0
|
||||
? distanceM
|
||||
: Number.MAX_VALUE;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,35 +153,49 @@ function sortableDistance(distanceM) {
|
||||
* @param {Iterable<string>|Set<string>} [options.incumbentIds] Ids that currently hold an active stem.
|
||||
* @returns {{activeIds:string[], budget:{activeLimit:number}}}
|
||||
*/
|
||||
export function selectInfraLod(candidates, { cameraHeightM, incumbentIds } = {}) {
|
||||
export function selectInfraLod(
|
||||
candidates,
|
||||
{ cameraHeightM, incumbentIds } = {},
|
||||
) {
|
||||
const budget = infraLodBudget(cameraHeightM);
|
||||
const incumbents = incumbentIds instanceof Set ? incumbentIds : new Set(incumbentIds || []);
|
||||
const incumbents =
|
||||
incumbentIds instanceof Set ? incumbentIds : new Set(incumbentIds || []);
|
||||
|
||||
const byId = new Map();
|
||||
for (const candidate of Array.isArray(candidates) ? candidates : []) {
|
||||
if (!candidate || typeof candidate.id !== 'string' || !candidate.id) continue;
|
||||
if (!candidate || typeof candidate.id !== 'string' || !candidate.id)
|
||||
continue;
|
||||
if (candidate.inView !== true) continue;
|
||||
const distanceM = Number.isFinite(candidate.distanceM) && candidate.distanceM >= 0
|
||||
? candidate.distanceM
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const distanceM =
|
||||
Number.isFinite(candidate.distanceM) && candidate.distanceM >= 0
|
||||
? candidate.distanceM
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const normalized = {
|
||||
id: candidate.id,
|
||||
distanceM,
|
||||
score: infraRankScore(candidate.priority, candidate.distanceM, incumbents.has(candidate.id)),
|
||||
score: infraRankScore(
|
||||
candidate.priority,
|
||||
candidate.distanceM,
|
||||
incumbents.has(candidate.id),
|
||||
),
|
||||
};
|
||||
const current = byId.get(normalized.id);
|
||||
if (!current
|
||||
|| normalized.score > current.score
|
||||
|| (normalized.score === current.score && normalized.distanceM < current.distanceM)) {
|
||||
if (
|
||||
!current ||
|
||||
normalized.score > current.score ||
|
||||
(normalized.score === current.score &&
|
||||
normalized.distanceM < current.distanceM)
|
||||
) {
|
||||
byId.set(normalized.id, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
const ranked = [...byId.values()].sort((a, b) => (
|
||||
b.score - a.score
|
||||
|| sortableDistance(a.distanceM) - sortableDistance(b.distanceM)
|
||||
|| a.id.localeCompare(b.id)
|
||||
));
|
||||
const ranked = [...byId.values()].sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
sortableDistance(a.distanceM) - sortableDistance(b.distanceM) ||
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
|
||||
return {
|
||||
activeIds: ranked.slice(0, budget.activeLimit).map((entry) => entry.id),
|
||||
@@ -220,7 +245,9 @@ export function applyInfraEvictionGrace({
|
||||
graceMs = INFRA_LOD_GRACE_MS,
|
||||
} = {}) {
|
||||
const selectedSet = new Set(
|
||||
(Array.isArray(selectedIds) ? selectedIds : []).filter((id) => typeof id === 'string' && id),
|
||||
(Array.isArray(selectedIds) ? selectedIds : []).filter(
|
||||
(id) => typeof id === 'string' && id,
|
||||
),
|
||||
);
|
||||
const keepIds = [...selectedSet];
|
||||
const evictIds = [];
|
||||
@@ -241,8 +268,13 @@ export function applyInfraEvictionGrace({
|
||||
|
||||
// Under cap pressure, grace-period stems go first: oldest-in-grace first
|
||||
// (longest chance to return), then more misses, then id for determinism.
|
||||
graced.sort((a, b) => a.since - b.since || b.misses - a.misses || a.id.localeCompare(b.id));
|
||||
const cap = Number.isFinite(activeLimit) ? Math.max(0, Math.floor(activeLimit)) : INFRA_LOD_ACTIVE_MAX;
|
||||
graced.sort(
|
||||
(a, b) =>
|
||||
a.since - b.since || b.misses - a.misses || a.id.localeCompare(b.id),
|
||||
);
|
||||
const cap = Number.isFinite(activeLimit)
|
||||
? Math.max(0, Math.floor(activeLimit))
|
||||
: INFRA_LOD_ACTIVE_MAX;
|
||||
const capacity = Math.max(0, cap - keepIds.length);
|
||||
const overflow = Math.max(0, graced.length - capacity);
|
||||
for (let i = 0; i < graced.length; i++) {
|
||||
@@ -251,7 +283,10 @@ export function applyInfraEvictionGrace({
|
||||
continue;
|
||||
}
|
||||
keepIds.push(graced[i].id);
|
||||
nextGrace.set(graced[i].id, { misses: graced[i].misses, since: graced[i].since });
|
||||
nextGrace.set(graced[i].id, {
|
||||
misses: graced[i].misses,
|
||||
since: graced[i].since,
|
||||
});
|
||||
}
|
||||
|
||||
return { keepIds, evictIds, graceState: nextGrace };
|
||||
@@ -307,13 +342,21 @@ export const INFRA_LOD_MOTION_EPSILON_MIN_M = 250;
|
||||
* @param {number} [options.minM]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function infraLodMotionEpsilonM(cameraHeightM, {
|
||||
ratio = INFRA_LOD_MOTION_EPSILON_RATIO,
|
||||
minM = INFRA_LOD_MOTION_EPSILON_MIN_M,
|
||||
} = {}) {
|
||||
const floor = Number.isFinite(minM) && minM >= 0 ? minM : INFRA_LOD_MOTION_EPSILON_MIN_M;
|
||||
const scale = Number.isFinite(ratio) && ratio >= 0 ? ratio : INFRA_LOD_MOTION_EPSILON_RATIO;
|
||||
const height = Number.isFinite(cameraHeightM) && cameraHeightM > 0 ? cameraHeightM : 0;
|
||||
export function infraLodMotionEpsilonM(
|
||||
cameraHeightM,
|
||||
{
|
||||
ratio = INFRA_LOD_MOTION_EPSILON_RATIO,
|
||||
minM = INFRA_LOD_MOTION_EPSILON_MIN_M,
|
||||
} = {},
|
||||
) {
|
||||
const floor =
|
||||
Number.isFinite(minM) && minM >= 0 ? minM : INFRA_LOD_MOTION_EPSILON_MIN_M;
|
||||
const scale =
|
||||
Number.isFinite(ratio) && ratio >= 0
|
||||
? ratio
|
||||
: INFRA_LOD_MOTION_EPSILON_RATIO;
|
||||
const height =
|
||||
Number.isFinite(cameraHeightM) && cameraHeightM > 0 ? cameraHeightM : 0;
|
||||
return Math.max(floor, height * scale);
|
||||
}
|
||||
|
||||
@@ -355,15 +398,19 @@ export function shouldRecomputeInfraLod({
|
||||
motionEpsilonM,
|
||||
} = {}) {
|
||||
const now = Number.isFinite(nowMs) ? nowMs : 0;
|
||||
const last = Number.isFinite(lastProbeMs) ? lastProbeMs : Number.NEGATIVE_INFINITY;
|
||||
const interval = Number.isFinite(probeIntervalMs) && probeIntervalMs >= 0
|
||||
? probeIntervalMs
|
||||
: INFRA_LOD_MOTION_PROBE_INTERVAL_MS;
|
||||
const last = Number.isFinite(lastProbeMs)
|
||||
? lastProbeMs
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
const interval =
|
||||
Number.isFinite(probeIntervalMs) && probeIntervalMs >= 0
|
||||
? probeIntervalMs
|
||||
: INFRA_LOD_MOTION_PROBE_INTERVAL_MS;
|
||||
if (now - last < interval) return { recompute: false, lastProbeMs: last };
|
||||
|
||||
const movedSq = Number.isFinite(movedSqM) && movedSqM > 0 ? movedSqM : 0;
|
||||
const epsilon = Number.isFinite(motionEpsilonM) && motionEpsilonM >= 0
|
||||
? motionEpsilonM
|
||||
: infraLodMotionEpsilonM(cameraHeightM);
|
||||
const epsilon =
|
||||
Number.isFinite(motionEpsilonM) && motionEpsilonM >= 0
|
||||
? motionEpsilonM
|
||||
: infraLodMotionEpsilonM(cameraHeightM);
|
||||
return { recompute: movedSq > epsilon * epsilon, lastProbeMs: now };
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
writeFile,
|
||||
readFile,
|
||||
rm,
|
||||
symlink,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { formatAdoptedFiles } from '../../scripts/format.mjs';
|
||||
|
||||
async function fixture(t, scope = ['adopted.js']) {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'gev-format-'));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
await mkdir(path.join(root, 'scripts'));
|
||||
await writeFile(
|
||||
path.join(root, 'scripts/format-scope.json'),
|
||||
JSON.stringify(scope),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(root, '.prettierrc.json'),
|
||||
JSON.stringify({
|
||||
singleQuote: true,
|
||||
semi: true,
|
||||
tabWidth: 2,
|
||||
endOfLine: 'lf',
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(root, '.prettierignore'), 'ignored.js\n');
|
||||
await writeFile(path.join(root, 'adopted.js'), 'const value="yes"\r\n');
|
||||
return root;
|
||||
}
|
||||
|
||||
test('format check is read-only; write only changes adopted files and is repeatable', async (t) => {
|
||||
const root = await fixture(t);
|
||||
const outside = 'const untouched="yes"';
|
||||
await writeFile(path.join(root, 'legacy.js'), outside);
|
||||
assert.deepEqual((await formatAdoptedFiles(root, '--check')).changed, [
|
||||
'adopted.js',
|
||||
]);
|
||||
assert.equal(
|
||||
await readFile(path.join(root, 'adopted.js'), 'utf8'),
|
||||
'const value="yes"\r\n',
|
||||
);
|
||||
await formatAdoptedFiles(root, '--write');
|
||||
assert.equal(
|
||||
await readFile(path.join(root, 'adopted.js'), 'utf8'),
|
||||
"const value = 'yes';\n",
|
||||
);
|
||||
assert.equal(await readFile(path.join(root, 'legacy.js'), 'utf8'), outside);
|
||||
assert.deepEqual((await formatAdoptedFiles(root, '--check')).changed, []);
|
||||
assert.deepEqual((await formatAdoptedFiles(root, '--write')).changed, []);
|
||||
});
|
||||
|
||||
test('invalid, ignored and missing scope entries fail before any file is written', async (t) => {
|
||||
for (const scope of [
|
||||
[],
|
||||
['adopted.js', 'adopted.js'],
|
||||
['adopted.js', '../escape.js'],
|
||||
['adopted.js', 'ignored.js'],
|
||||
['adopted.js', 'missing.js'],
|
||||
]) {
|
||||
const root = await fixture(t, scope);
|
||||
await writeFile(path.join(root, 'ignored.js'), 'const ignored=1');
|
||||
await assert.rejects(formatAdoptedFiles(root, '--write'));
|
||||
assert.equal(
|
||||
await readFile(path.join(root, 'adopted.js'), 'utf8'),
|
||||
'const value="yes"\r\n',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'formatting rejects a symlink outside the repository',
|
||||
{ skip: process.platform === 'win32' },
|
||||
async (t) => {
|
||||
const root = await fixture(t, ['adopted.js', 'outside.js']);
|
||||
const other = await fixture(t);
|
||||
await symlink(
|
||||
path.join(other, 'adopted.js'),
|
||||
path.join(root, 'outside.js'),
|
||||
);
|
||||
await assert.rejects(
|
||||
formatAdoptedFiles(root, '--write'),
|
||||
/repository files/,
|
||||
);
|
||||
assert.equal(
|
||||
await readFile(path.join(root, 'adopted.js'), 'utf8'),
|
||||
'const value="yes"\r\n',
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,77 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { checkPackageBoundaries } from '../../scripts/check-package-boundaries.mjs';
|
||||
|
||||
async function fixture(
|
||||
t,
|
||||
entry = 'export const value = 1;',
|
||||
extraExports = {},
|
||||
) {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'gev-boundaries-'));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
await mkdir(path.join(root, 'scripts'));
|
||||
await writeFile(
|
||||
path.join(root, 'package.json'),
|
||||
JSON.stringify({
|
||||
name: 'boundary-fixture',
|
||||
type: 'module',
|
||||
exports: { './feature': './entry.js', ...extraExports },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(root, 'scripts/package-boundaries.json'),
|
||||
JSON.stringify({
|
||||
feature: { exports: ['./feature'], modules: ['entry.js'], external: [] },
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(root, 'entry.js'), entry);
|
||||
return root;
|
||||
}
|
||||
|
||||
test('a declared browser export builds without running app setup', async (t) => {
|
||||
const root = await fixture(t);
|
||||
await writeFile(
|
||||
path.join(root, 'vite.config.js'),
|
||||
"throw new Error('must not load app config');",
|
||||
);
|
||||
assert.deepEqual(await checkPackageBoundaries(root), [
|
||||
{ name: 'feature', exports: 1, modules: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('an unclassified package export fails the boundary gate', async (t) => {
|
||||
const root = await fixture(t, undefined, { './another': './another.js' });
|
||||
await assert.rejects(
|
||||
checkPackageBoundaries(root),
|
||||
/exactly one boundary group/,
|
||||
);
|
||||
});
|
||||
|
||||
test('even an unused import of application code violates ownership', async (t) => {
|
||||
const root = await fixture(
|
||||
t,
|
||||
"import { app } from './startup.js'; export const value = 1;",
|
||||
);
|
||||
await writeFile(path.join(root, 'startup.js'), 'export const app = 2;');
|
||||
await assert.rejects(checkPackageBoundaries(root), /unowned module.*startup/);
|
||||
});
|
||||
|
||||
test('Node builtins cannot enter a browser export', async (t) => {
|
||||
const root = await fixture(
|
||||
t,
|
||||
"import fs from 'node:fs'; export const value = fs;",
|
||||
);
|
||||
await assert.rejects(checkPackageBoundaries(root), /unowned module|browser/);
|
||||
});
|
||||
|
||||
test('dynamic imports obey the same component ownership rule', async (t) => {
|
||||
const root = await fixture(
|
||||
t,
|
||||
"export const load = () => import('./startup.js');",
|
||||
);
|
||||
await writeFile(path.join(root, 'startup.js'), 'export const app = 2;');
|
||||
await assert.rejects(checkPackageBoundaries(root), /unowned module.*startup/);
|
||||
});
|
||||
Reference in new issue
Block a user