feat(globe): port to CeliumJS

This commit is contained in:
gili8420 2026-08-03 22:12:32 +09:00
parent ec03425067
commit eb7698e034
51 changed files with 3521 additions and 1992 deletions

144
tests/e2e/globe.spec.ts Normal file
View file

@ -0,0 +1,144 @@
import { test, expect, login, sceneObjectCount } from './fixtures';
/**
* Regression guard for the Mercator -> globe migration.
*
* The MapLibre implementation tiled every GeoJSON source through
* @maplibre/geojson-vt, whose projectY() clamps its result to [0,1] so any
* vertex above 85.051129° collapsed onto that parallel and a polar trajectory
* rendered as a straight line along it. Cesium converts degrees straight to
* Cartesian3, so latitude must survive intact. If a Mercator-tiled render path
* is ever reintroduced, this test fails.
*/
const MERCATOR_LIMIT = 85.051129;
test.beforeEach(async ({ context }) => {
await login(context);
});
test('globe preserves latitude above the Mercator limit', async ({ page }) => {
// Cesium ships ~7 MB of workers/assets; first paint is slower than MapLibre.
test.setTimeout(90_000);
await page.goto('/predict');
await page
.locator('.map-container canvas')
.first()
.waitFor({ state: 'attached', timeout: 60_000 });
// Map.svelte exposes the raw Cesium Viewer on window._lsvMap in dev builds.
await page.waitForFunction(
() => (window as unknown as { _lsvMap?: { scene?: unknown } })._lsvMap?.scene !== undefined,
undefined,
{ timeout: 60_000 },
);
const latitudes: number[] = await page.evaluate((limit) => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const viewer = (window as any)._lsvMap;
// Cesium is an ES module, not a global. Reach its constructors through
// live objects the Viewer already holds: camera.position is a Cartesian3
// and positionCartographic is a Cartographic, both carrying the statics.
const Cartesian3: any = viewer.camera.position.constructor;
const Cartographic: any = viewer.camera.positionCartographic.constructor;
const wanted = [89.0, limit + 0.001, 89.99, 90.0];
const positions = Cartesian3.fromDegreesArray(wanted.flatMap((lat: number) => [10, lat]));
viewer.entities.add({ polyline: { positions, width: 3 } });
return positions.map(
(p: unknown) => (Cartographic.fromCartesian(p).latitude * 180) / Math.PI,
);
/* eslint-enable @typescript-eslint/no-explicit-any */
}, MERCATOR_LIMIT);
expect(latitudes).toHaveLength(4);
// Every vertex round-trips to the latitude it was given.
expect(latitudes[0]).toBeCloseTo(89.0, 6);
expect(latitudes[1]).toBeCloseTo(MERCATOR_LIMIT + 0.001, 6);
expect(latitudes[2]).toBeCloseTo(89.99, 6);
expect(latitudes[3]).toBeCloseTo(90.0, 6);
// And none got pinned to the old Mercator ceiling.
for (const lat of latitudes.slice(1)) {
expect(lat).toBeGreaterThan(MERCATOR_LIMIT);
}
});
/**
* The flight_path tuples carry altitude as an optional third element
* (domain/geo.ts: LatLngTuple), filled in by parsePrediction. MapLibre could
* not use it, so it sat unread; on a globe it must become real geometry
* otherwise a 30 km balloon flight renders as a flat line on the ground.
*/
test.describe('altitude', () => {
test.beforeEach(async ({ context }) => {
await login(context);
});
/** Heights (metres) of every vertex of the rendered workspace path. */
function pathHeights(page: import('@playwright/test').Page): Promise<number[]> {
return page.evaluate(() => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const v: any = (window as any)._lsvMap;
const C: any = v.camera.positionCartographic.constructor;
const e = v.entities.values.find((x: any) => String(x.id).endsWith('__path'));
if (!e) return [];
return e.polyline.positions
.getValue(v.clock.currentTime)
.map((p: unknown) => C.fromCartesian(p).height);
/* eslint-enable @typescript-eslint/no-explicit-any */
});
}
async function runPrediction(page: import('@playwright/test').Page) {
await page.goto('/');
await page.evaluate(() => localStorage.removeItem('workspaces'));
await page.goto('/predict');
await page
.locator('.map-container canvas')
.first()
.waitFor({ state: 'attached', timeout: 60_000 });
await page
.locator('.panel-container-right')
.locator('.workspace-row')
.first()
.getByRole('button', { name: /Рассчитать|Run/ })
.click();
await expect
.poll(() => sceneObjectCount(page, 'ws/'), {
timeout: 90_000,
intervals: [1000, 2000, 3000],
})
.toBeGreaterThan(0);
}
test('trajectory vertices carry real altitude, not ground level', async ({ page }) => {
test.setTimeout(150_000);
await runPrediction(page);
const heights = await pathHeights(page);
expect(heights.length).toBeGreaterThan(10);
// Burst is at 30 km; the apex must be somewhere near it, definitely not 0.
expect(Math.max(...heights)).toBeGreaterThan(10_000);
// Launch/landing sit on the ground, so the minimum stays low.
expect(Math.min(...heights)).toBeLessThan(1_000);
});
test('burst marker sits at its altitude, not on the ground', async ({ page }) => {
test.setTimeout(150_000);
await runPrediction(page);
const burstHeight = await page.evaluate(() => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const v: any = (window as any)._lsvMap;
const C: any = v.camera.positionCartographic.constructor;
const e = v.entities.values.find((x: any) => String(x.id).endsWith('__burst'));
if (!e) return null;
return C.fromCartesian(e.position.getValue(v.clock.currentTime)).height;
/* eslint-enable @typescript-eslint/no-explicit-any */
});
expect(burstHeight).not.toBeNull();
expect(burstHeight as number).toBeGreaterThan(10_000);
});
});