diff --git a/changelog.d/1512-world-clock-timeline.md b/changelog.d/1512-world-clock-timeline.md new file mode 100644 index 000000000..a43231150 --- /dev/null +++ b/changelog.d/1512-world-clock-timeline.md @@ -0,0 +1,5 @@ +- One world clock now drives everything with a time of its own: the sky, + the HUD clock, satellites, launches and weather. A timeline along the + bottom of the screen, shown while one of those layers is on, plays, pauses + and scrubs it, with a tick for each launch or frame and a way back to + live. Share links made away from live carry the time (samehkhamis, #1512). diff --git a/changelog.d/1513-timeline-launches-speed-buffering.md b/changelog.d/1513-timeline-launches-speed-buffering.md new file mode 100644 index 000000000..e84d8e205 --- /dev/null +++ b/changelog.d/1513-timeline-launches-speed-buffering.md @@ -0,0 +1,7 @@ +- The timeline brings launches up as the time passes them, offers play + speeds from real time to a week a second, and plays smoothly, holding only + when the next weather frame has yet to load, with a spinner by the time + while it waits. The time rides in a bubble over the handle. The track zooms + with a scroll or a pinch, drags along, and goes to the time under a tap, on + a phone as on a desktop. Zooming out on the globe stops while the globe is + still in view instead of leaving it a speck (samehkhamis, #1513). diff --git a/changelog.d/1634-layer-state-registered-only.md b/changelog.d/1634-layer-state-registered-only.md new file mode 100644 index 000000000..4d8eaa897 --- /dev/null +++ b/changelog.d/1634-layer-state-registered-only.md @@ -0,0 +1,3 @@ +- An application built with fewer layers no longer restores, saves or shares a + layer it does not have when a link or saved state from a full build names it + (samehkhamis, #1634). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8eacf04bb..edc345953 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1538,8 +1538,8 @@ HUD on, in its Tactical and Cyber layouts). Turning Live AIS Vessels off ends the bridge: the layer then has no subject (`getCockpitSubject` returns null). The bridge picture uses AIS alone, so no other feed being off makes it partial. Voice (`control_cockpit`) and the selection entry transaction stay aircraft-only; -voice `enter` on a bridge is refused before it touches tracking, with the -reason to exit the bridge first. +voice `enter` on a bridge or in an orbital view is refused before it touches +tracking, with the reason to exit it first. The vessel layer supplies the subject (`getCockpitSubject`), hides that ship's sprite, card, detection bracket and trail while it is held (`setCockpitSubject`), and @@ -1633,7 +1633,10 @@ frame, with no ground clamp. Entry opens in the plain optical vision style with Google 3D tiles shown, which stay coarse from hundreds of kilometres up, the map switches to Esri imagery, and exit puts it back unless the operator picked another map meanwhile; another owner already holding the map (an -imagery comparison) keeps it as it is. The Cockpit cloud pass is off in orbit +imagery comparison) keeps it as it is. While orbit holds the lease, Recent +Imagery cannot take it: turned on in orbit, the layer reports the comparison +in use until, after exit, it is turned off and on again or its shown days +are cleared and picked again. The Cockpit cloud pass is off in orbit (it fades out above 24 km); cloud cover from orbit is the Weather layer's, and the saved cloud preference stands for the next aircraft or bridge. The layer hides the satellite's dot, docked companions, its own orbit ring and its detection bracket, and draws every @@ -5830,6 +5833,13 @@ and unreachable upstream (502/504) separately from road geometry. explicit-empty form); unknown option tokens are ignored. Restoration settles independently per layer so one failed or unavailable source cannot block its siblings. +- An application that registers fewer layers (an embedder with a smaller + catalog) keeps only its own: `LayerStateCoordinator` drops enabled-layer ids + the application did not register before it normalizes a share, saved state or + explicit change, so such a layer is never restored, written to local storage + or carried into a link this application shares. Options that depend on a + dropped layer (a tracked selection) go with it. An application with every + layer registered is unchanged. - Layer tokens are permanent public compatibility identifiers. The reservation JSON ledger at `src/data/layerStateTokenReservations.json` pins all existing one-character assignments (all letters plus `0`, `1` and diff --git a/docs/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md index 5728d1ec3..bf69653f9 100644 --- a/docs/SHARE-LINK-FORMAT.md +++ b/docs/SHARE-LINK-FORMAT.md @@ -119,6 +119,7 @@ probability of about 255 in 256 (many of them already fail as base64url). | 25 | `scopeFeatherPct` | uint | 100 | 0–200 | | 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus | | 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs | +| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z | Field 1 absent means `thing` when field 13 is present and `place` otherwise, so v1 never writes it; a later view kind writes its code. @@ -132,6 +133,10 @@ style also extends the pinned style-defaults table. `hudVariant` code 1 (`operator`) is retired: the app no longer offers that layout and restores it as `tactical`. The code stays reserved. +Field 28 is the world time the link was made at, written only when it was away +from live. A link without it opens live. Unlike field 8 it may lie ahead of the +link's creation (a forecast moment), and it opens paused at that time. + Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3 `celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on). diff --git a/index.html b/index.html index 39ee7e01d..291b99708 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ with the sources: src/materialSymbolsSubset.test.mjs fails when a source names a glyph this list is missing, because a missing glyph does not draw a box — the ligature never forms and the element renders the literal word. --> - +
diff --git a/package.json b/package.json index f6362281f..3d107667a 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "./infrastructure/lod": "./src/data/localGeojsonLod.js", "./application": "./src/app/application.js", "./application/viewer": "./src/app/viewer.js", + "./time": "./src/time/worldClock.js", "./build/vite": { "node": "./build/vite.js" }, diff --git a/scripts/build-ucs-satellites.py b/scripts/build-ucs-satellites.py index d1a0b6fd8..86fc9063e 100644 --- a/scripts/build-ucs-satellites.py +++ b/scripts/build-ucs-satellites.py @@ -29,9 +29,6 @@ COLUMNS = { 'operator': 'Operator/Owner', 'operatorCountry': 'Country of Operator/Owner', 'purpose': 'Purpose', - 'detailedPurpose': 'Detailed Purpose', - 'users': 'Users', - 'orbitClass': 'Class of Orbit', } STARLINK = re.compile(r'\bstarlink\b', re.I) @@ -65,11 +62,12 @@ def build(xlsx: Path) -> dict: except (TypeError, ValueError): invalid_norad += 1 continue + if norad <= 0: + invalid_norad += 1 + continue record = [norad] for key, column in COLUMNS.items(): text = clean(row[header[column]]) - if key == 'orbitClass': - text = text.upper() if text not in index[key]: index[key][text] = len(tables[key]) tables[key].append(text) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5ed51e7fd..f9cb045ac 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -330,6 +330,7 @@ "src/ui/styles/cockpit.css", "src/ui/styles/cockpitBridge.css", "src/ui/styles/cockpitRadar.css", + "src/ui/styles/cockpitOrbital.css", "src/ui/styles/command-dock-compact.css", "src/app/stateChannel.js", "src/app/stateChannel.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d631fd21e..ee1e5a9b6 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -158,10 +158,19 @@ "src/ui/stores/recording.js", "src/ui/styleParameters.js", "src/ui/i18n.js", + "src/time/worldClock.js", + "src/ui/kit/UiIcon.vue", + "src/ui/stores/worldTime.js", + "src/ui/views/panels/WorldTimeline.vue", "src/ui/surfaceKeyboard.js" ], "external": ["vue", "pinia", "vue-i18n", "flag-icons"] }, + "time": { + "exports": ["./time"], + "modules": ["src/time/worldClock.js"], + "external": [] + }, "viewer": { "exports": ["./application/viewer"], "modules": [ @@ -1207,6 +1216,7 @@ "src/ui/stores/globeActions.js", "src/ui/stores/recording.js", "src/ui/i18n.js", + "src/time/worldClock.js", "src/ui/kit/UiSegmented.vue", "src/ui/wildlifeSightingCard.js", "src/layers/wildlifeSightings/records.js", @@ -1430,7 +1440,9 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/ui/unitLabels.js" + "src/ui/unitLabels.js", + "src/time/worldClock.js", + "src/time/julian.js" ], "external": ["cesium"] }, @@ -1488,7 +1500,9 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js", + "src/time/julian.js" ], "external": ["cesium"] }, @@ -1688,7 +1702,8 @@ "src/data/ucsSatellites.js", "src/data/imagingSensors.js", "src/data/bundledJson.js", - "src/data/retryableLoad.js" + "src/data/retryableLoad.js", + "src/time/worldClock.js" ], "external": ["cesium", "satellite.js"] }, @@ -1728,7 +1743,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js" ], "external": ["cesium"] }, @@ -2053,7 +2069,8 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/layers/streetLevel/providers/mapillary/policy.js" + "src/layers/streetLevel/providers/mapillary/policy.js", + "src/time/worldClock.js" ], "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] }, @@ -3337,6 +3354,9 @@ "src/ui/stores/globeActions.js", "src/ui/stores/recording.js", "src/ui/i18n.js", + "src/time/viewerClock.js", + "src/time/worldClock.js", + "src/time/julian.js", "src/annotations/outlineRungs.js", "src/sources/openFreeMapOutlines.js", "src/sources/nominatimOutlines.js", @@ -4006,6 +4026,8 @@ "src/ui/stores/bhoteKoshiPanel.js", "src/hudReadouts.js", "src/ui/i18n.js", + "src/time/worldClock.js", + "src/time/julian.js", "src/annotations/outlineRungs.js", "src/sources/openFreeMapOutlines.js", "src/sources/nominatimOutlines.js", @@ -4197,7 +4219,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js" ], "external": [] }, @@ -4754,6 +4777,7 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js", "src/overlays/worldOverlayChrome.js" ], "external": ["cesium", "egm96-universal", "pbf"] @@ -4807,7 +4831,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js" ], "external": ["cesium", "pbf"] }, @@ -4930,7 +4955,8 @@ "src/data/detectionPolicy.js", "src/scopeMaskPolicy.js", "src/i18n/marker.js", - "src/visualStylePolicy.js" + "src/visualStylePolicy.js", + "src/time/worldClock.js" ], "external": [] }, diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index f368d260e..a76b37bd4 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -1,5 +1,6 @@ import { resolveCatalogSources } from './sourceComposition.js'; import { createWeatherClock } from '../layers/weather/clock.js'; +import { worldClock } from '../time/worldClock.js'; import { createWeatherLayer } from '../layers/weather/index.js'; import { createApplicationCyclones } from './layers/cyclones.js'; import { createWindLayer } from '../layers/wind/index.js'; @@ -78,7 +79,7 @@ export function createApplicationCatalog({ const sourceComposition = resolveCatalogSources(sources); sources = sourceComposition.sources; const militaryRegistry = createMilitaryRegistry(); - const weatherClock = createWeatherClock(); + const weatherClock = createWeatherClock({ world: worldClock }); const dispose = () => { signal.removeEventListener('abort', dispose); militaryRegistry.dispose(); diff --git a/src/app/layers/gnss.js b/src/app/layers/gnss.js index 46983953b..72071e86a 100644 --- a/src/app/layers/gnss.js +++ b/src/app/layers/gnss.js @@ -1,6 +1,10 @@ import { createGnssIntegrityLayer } from '../../layers/gnss/index.js'; +import { worldTimeFor } from '../../time/worldClock.js'; /** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */ export function createApplicationGnssIntegrity(options) { - return createGnssIntegrityLayer(options); + return createGnssIntegrityLayer({ + worldNow: worldTimeFor('gnss-integrity'), + ...options, + }); } diff --git a/src/app/layers/recentImagery.js b/src/app/layers/recentImagery.js index cc4659727..6a730bc7c 100644 --- a/src/app/layers/recentImagery.js +++ b/src/app/layers/recentImagery.js @@ -2,6 +2,9 @@ import { createRecentImageryLayer } from '../../layers/recentImagery/index.js'; import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js'; import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js'; import { governorRequestRender } from '../../renderGovernor.js'; +import { worldTimeFor } from '../../time/worldClock.js'; + +const imageryTime = worldTimeFor('recent-imagery'); /** Construct imagery presentation over one explicitly supplied acquisition source. */ export function createApplicationRecentImagery({ source }) { @@ -15,5 +18,6 @@ export function createApplicationRecentImagery({ source }) { thumbnails: createThumbnailLoader({ requestThumbnail: (...args) => source.getThumbnail(...args), }), + now: () => new Date(imageryTime()), }); } diff --git a/src/app/scene.js b/src/app/scene.js index 8792a1f4d..b9799b8ac 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -15,6 +15,8 @@ import { governorRequestRender, } from '../renderGovernor.js'; import { describeError } from './errors.js'; +import { worldClock, worldTimeFor } from '../time/worldClock.js'; +import { followWorldTime } from '../time/viewerClock.js'; import { onLocaleChange, t } from '../i18n/index.js'; /** Construct the application globe using the caller's local configuration. */ @@ -59,6 +61,12 @@ export async function createApplicationScene({ if (!viewer.isDestroyed()) viewer.destroy(); }); defer(installTrackpadPinchZoom(viewer)); + defer( + followWorldTime(viewer, worldTimeFor('sky'), { + subscribe: worldClock.subscribe, + requestRender: () => governorRequestRender('world-time'), + }), + ); defer(installFreeLookHeightLimit(viewer)); registerDataCredits(viewer, credits); configureCreditKeyboardAccess(document); diff --git a/src/app/viewer.js b/src/app/viewer.js index fc75c0498..e4288e8a3 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -7,6 +7,48 @@ import { isFreeGlobeCamera, } from '../globeInView.js'; +/** + * At the farthest a hand can zoom out, the globe is this share of the + * screen's height across. Cesium sets no limit, so zooming on left the globe + * a speck among the stars. + */ +const MIN_GLOBE_SHARE = 0.3; + +/** + * The farthest a hand can zoom out, in metres above the ellipsoid, for a + * vertical field of view. It follows the screen's shape, as the app's own + * whole-globe view does, so that view always lies within it. + * @param {number} fovy Vertical field of view, radians. + */ +export function zoomOutLimit(fovy) { + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const angular = Math.atan(MIN_GLOBE_SHARE * Math.tan(fovy / 2)); + return radius / Math.sin(angular) - radius; +} + +/** + * Hold a hand's zoom out to `zoomOutLimit` on the globe, each frame, since the + * field of view follows the screen's shape. The limit never sits below the + * camera: one already past it (a flight, a screen turned) stops there rather + * than jumping in at the next zoom. Flat maps keep Cesium's own, unlimited. + * @returns {() => void} Stop limiting. + */ +export function limitZoomOut(viewer) { + const { scene, camera } = viewer; + return scene.preRender.addEventListener(() => { + const controller = scene.screenSpaceCameraController; + const fovy = camera.frustum.fovy; + if (scene.mode !== Cesium.SceneMode.SCENE3D || !Number.isFinite(fovy)) { + controller.maximumZoomDistance = Number.POSITIVE_INFINITY; + return; + } + controller.maximumZoomDistance = Math.max( + zoomOutLimit(fovy), + camera.positionCartographic?.height ?? 0, + ); + }); +} + const PINCH_ZOOM_MULTIPLIER = 8; const MAX_PINCH_PIXEL_DELTA = 120; @@ -372,6 +414,7 @@ export function createApplicationViewer({ container, creditContainer }) { // required on touch-first screens. Mouse/trackpad sensitivity stays native. if (globalThis.matchMedia?.('(pointer: coarse)').matches) viewer.scene.screenSpaceCameraController.zoomFactor *= 3; + limitZoomOut(viewer); // Before any tile builds a draw command: Cesium's per-vertex model // atmosphere fails to LINK on Apple's Metal backend and kills the // render loop. See app/atmosphereCompat.js. diff --git a/src/app/viewer.test.mjs b/src/app/viewer.test.mjs new file mode 100644 index 000000000..94d80e20a --- /dev/null +++ b/src/app/viewer.test.mjs @@ -0,0 +1,73 @@ +// A hand zooming out stops while the globe is still a fair share of the +// screen, on any screen's shape, rather than leaving it a speck. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { limitZoomOut, zoomOutLimit } from './viewer.js'; + +// The share of the screen's height the globe spans from `altitude`. +function globeShare(altitude, fovy) { + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const angular = Math.asin(radius / (radius + altitude)); + return Math.tan(angular) / Math.tan(fovy / 2); +} + +test("zooming out stops with the globe 30% of the screen's height across", () => { + for (const fovy of [Math.PI / 3, 0.4, 0.2]) { + const limit = zoomOutLimit(fovy); + assert.ok(Math.abs(globeShare(limit, fovy) - 0.3) < 1e-9, `fovy ${fovy}`); + } +}); + +test('a wide screen, whose view is narrower, may zoom out farther', () => { + assert.ok(zoomOutLimit(0.4) > zoomOutLimit(Math.PI / 3)); + assert.ok(zoomOutLimit(Math.PI / 3) > 20_000_000, 'past the views a link opens'); +}); + +function fakeViewer({ fovy = Math.PI / 3, height = 10_000_000 } = {}) { + const scene = { + mode: Cesium.SceneMode.SCENE3D, + preRender: new Cesium.Event(), + screenSpaceCameraController: { maximumZoomDistance: Infinity }, + }; + const camera = { + frustum: { fovy }, + positionCartographic: { height }, + }; + return { scene, camera }; +} + +test('the limit follows the screen, and holds a camera already past it', () => { + const viewer = fakeViewer(); + const controller = viewer.scene.screenSpaceCameraController; + const stop = limitZoomOut(viewer); + viewer.scene.preRender.raiseEvent(); + assert.equal(controller.maximumZoomDistance, zoomOutLimit(Math.PI / 3)); + + // A wider screen narrows the view, and the globe may go farther. + viewer.camera.frustum.fovy = 0.4; + viewer.scene.preRender.raiseEvent(); + assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4)); + + // Flown past it, the next zoom out stops there instead of jumping in. + viewer.camera.positionCartographic.height = zoomOutLimit(0.4) * 2; + viewer.scene.preRender.raiseEvent(); + assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4) * 2); + + stop(); + viewer.camera.positionCartographic.height = 0; + viewer.scene.preRender.raiseEvent(); + assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4) * 2); +}); + +test('flat maps keep zooming out unlimited', () => { + const viewer = fakeViewer(); + const controller = viewer.scene.screenSpaceCameraController; + limitZoomOut(viewer); + viewer.scene.preRender.raiseEvent(); + assert.ok(Number.isFinite(controller.maximumZoomDistance)); + viewer.scene.mode = Cesium.SceneMode.SCENE2D; + viewer.camera.frustum = {}; + viewer.scene.preRender.raiseEvent(); + assert.equal(controller.maximumZoomDistance, Infinity); +}); diff --git a/src/celestialRing.js b/src/celestialRing.js index c031593d4..525ded44e 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -1,5 +1,9 @@ import * as Cesium from 'cesium'; import { governorRequestRender } from './renderGovernor.js'; +import { worldClock, worldTimeFor } from './time/worldClock.js'; + +// The ring shows the sun and moon at the time the sky is drawn. +const skyTime = worldTimeFor('sky'); /** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */ export const KEYHOLE_OUTER_RADIUS = 1.05; @@ -28,6 +32,9 @@ export const KEYHOLE_LABEL_FEATHER_MAX_RATIO = 0.4; */ export const KEYHOLE_OUTSIDE_OPACITY_DEFAULT = 0.01; +// How far the world time moves before the sun and moon move with it: a +// minute moves the sun a quarter of a degree. +const EPHEMERIS_WORLD_STEP_MS = 60_000; const RING_INSET_PX = 11; const MARKER_INSET_PX = 36; const COLLIDING_MARKER_EXTRA_INSET_PX = 32; @@ -445,6 +452,20 @@ export class CelestialRing { if (typeof document !== 'undefined' && document.hidden) return; governorRequestRender('celestial-ephemeris'); }, 60_000); + // A jump in the world time moves the sun and moon at once. + this._skyDate = new Date(); + this._skyTime = new Cesium.JulianDate(); + // Played through, the sky moves on each minute of world time it passes. + this._ephemerisWorldMs = null; + this._unsubscribeWorldTime = worldClock.subscribe(({ timeMs }) => { + if ( + this._ephemerisWorldMs !== null && + Math.abs(timeMs - this._ephemerisWorldMs) < EPHEMERIS_WORLD_STEP_MS + ) + return; + this._ephemerisWorldMs = timeMs; + this._ephemerisDirty = true; + }); this.setEnabled(this.enabled); } @@ -801,7 +822,8 @@ export class CelestialRing { return; } - const time = Cesium.JulianDate.now(); + this._skyDate.setTime(skyTime()); + const time = Cesium.JulianDate.fromDate(this._skyDate, this._skyTime); if (!this._updateEphemeris(time)) return; this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) }); const camera = this.viewer.camera; @@ -890,6 +912,8 @@ export class CelestialRing { clearInterval(this._ephemerisTimer); this._ephemerisTimer = null; } + this._unsubscribeWorldTime?.(); + this._unsubscribeWorldTime = null; if (this._removePostRender) this._removePostRender(); this._removePostRender = null; if (this._shown()) { diff --git a/src/cockpitOrbital.js b/src/cockpitOrbital.js index 2ea604fcf..cd3db0418 100644 --- a/src/cockpitOrbital.js +++ b/src/cockpitOrbital.js @@ -448,6 +448,21 @@ export function offNadirAngleDeg({ northDeg = 0, eastDeg = 0 } = {}) { return (Math.acos(cosine) * 180) / Math.PI; } +/** + * Whether the view's centre, tilted off nadir, still meets the Earth: its + * angle from nadir (`offNadirAngleDeg`) is within the Earth's angular radius + * from the satellite, asin(R / (R + altitude)). + * @param {number} altitudeM + * @param {{northDeg?: number, eastDeg?: number}} [offset] + * @returns {boolean} + */ +export function offNadirCentreMeetsEarth(altitudeM, offset = {}) { + if (!Number.isFinite(altitudeM) || altitudeM <= 0) return false; + const R = ORBITAL_EARTH_RADIUS_M; + const limbDeg = (Math.asin(R / (R + altitudeM)) * 180) / Math.PI; + return offNadirAngleDeg(offset) < limbDeg; +} + /** * Roughly how much wider the ground across the frame is at the centre of a * tilted view than at nadir, on a flat Earth: tilting across the frame (east, diff --git a/src/cockpitOrbital.test.mjs b/src/cockpitOrbital.test.mjs index 944cb5356..c40b9a342 100644 --- a/src/cockpitOrbital.test.mjs +++ b/src/cockpitOrbital.test.mjs @@ -14,6 +14,7 @@ import { nadirOrientation, nextOrbitalZoom, offNadirAngleDeg, + offNadirCentreMeetsEarth, offNadirSwathScale, orbitalMaxZoom, sensorStripFraction, @@ -572,3 +573,16 @@ test('the tiles ahead are the ones the points fall in, bounded and new', () => { ); assert.deepEqual(tilesForPoints({}, points, 13), []); }); + +test('a view tilted past the limb no longer meets the Earth', () => { + // GEO: the Earth spans about 8.7 degrees either side of nadir. + assert.equal(offNadirCentreMeetsEarth(35_786_000, { northDeg: 5 }), true); + assert.equal(offNadirCentreMeetsEarth(35_786_000, { northDeg: 15 }), false); + // LEO: a 30 degree tilt still lands on the ground. + assert.equal( + offNadirCentreMeetsEarth(420_000, { northDeg: 20, eastDeg: 20 }), + true, + ); + assert.equal(offNadirCentreMeetsEarth(420_000, { eastDeg: 80 }), false); + assert.equal(offNadirCentreMeetsEarth(NaN, {}), false); +}); diff --git a/src/creditAttribution.test.mjs b/src/creditAttribution.test.mjs index 3032a25fe..dafc51df1 100644 --- a/src/creditAttribution.test.mjs +++ b/src/creditAttribution.test.mjs @@ -229,13 +229,30 @@ const GUARDED_PROPS = new Set([ // Custom properties allowed inside a modelled offset. Each is a pinned-tray // stack height, proven non-negative below, so evaluating them at 0 is a -// conservative floor rather than a guess. +// conservative floor rather than a guess. The world timeline's lift raises +// the dock, its trays and the credit together, so it moves no gap either. const VETTED_VARS = new Set([ '--dock-pinned-stack-height', '--dock-lower-pinned-height', '--dock-location-pinned-height', '--dock-presets-pinned-height', + '--world-timeline-lift', ]); +// Custom properties CSS sets to a fixed length besides their 0 default. The +// clearance checks run at each length CSS gives them, not only at 0. +const FIXED_LENGTH_VARS = new Set(['--world-timeline-lift']); +const LIFTS_PX = [0, 66, 80]; +let liftPx = 0; +function eachLift(check) { + for (const lift of LIFTS_PX) { + liftPx = lift; + try { + check(lift); + } finally { + liftPx = 0; + } + } +} /** Rules whose final compound targets a modelled element (pseudo-elements aside). */ function ownBoxEntries() { @@ -315,6 +332,7 @@ function toPx(value, viewportHeight, where) { if (term.startsWith('var(')) { const name = term.slice(4, term.lastIndexOf(')')).split(',')[0].trim(); assert.ok(VETTED_VARS.has(name), `unvetted custom property ${name} in ${where}: "${value}"`); + if (name === '--world-timeline-lift') total += liftPx; continue; // proven non-negative; 0 is the conservative floor } assert.match(term, /^-?[\d.]+(px|rem|vh)$/, `unmodelled length term "${term}" in ${where}: "${value}"`); @@ -444,7 +462,8 @@ test('custom properties inside modelled offsets are provably non-negative', () = assert.ok(declared.length >= VETTED_VARS.size, 'every vetted custom property needs a CSS default'); for (const decl of declared) { assert.doesNotMatch(decl.value, /-\s*\d/, `${decl.prop} has a negative CSS default: ${decl.value}`); - assert.match(decl.value, /^(0|0px)$/, `${decl.prop} default is not a vetted shape: ${decl.value}`); + const shape = FIXED_LENGTH_VARS.has(decl.prop) ? /^(0|\d+px)$/ : /^(0|0px)$/; + assert.match(decl.value, shape, `${decl.prop} default is not a vetted shape: ${decl.value}`); } const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8'); const start = layout.indexOf('_updateCommandDockTrayStack() {'); @@ -517,16 +536,18 @@ test('every open dock tray clears the required credit at every modelled viewport // number: the dock is anchored at 2vh down to 721px and re-anchors to a flat // 8px at 720px, while the credit keeps its 2vh base throughout. const failures = []; - for (const scenario of TRAY_SCENARIOS) { - for (const width of WIDTHS.filter((w) => w <= 900)) { - for (const height of HEIGHTS) { - const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height); - if (clearance < MIN_CLEARANCE_PX) { - failures.push(`${scenario.name} @ ${width}x${height}: ${clearance.toFixed(1)}px`); + eachLift((lift) => { + for (const scenario of TRAY_SCENARIOS) { + for (const width of WIDTHS.filter((w) => w <= 900)) { + for (const height of HEIGHTS) { + const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height); + if (clearance < MIN_CLEARANCE_PX) { + failures.push(`${scenario.name} @ ${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`); + } } } } - } + }); assert.deepEqual(failures, [], `a dock tray re-enters the credit band:\n ${failures.join('\n ')}\n`); }); @@ -566,21 +587,24 @@ test('the full-width context rail clears the required credit at every modelled v 'none', `at ${width}px the rail is height-capped, so its bottom anchor no longer decides its floor`, ); - for (const height of HEIGHTS) { - const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail'); - const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height); - if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height}: ${clearance.toFixed(1)}px`); - } + eachLift((lift) => { + for (const height of HEIGHTS) { + const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail'); + const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height); + if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`); + } + }); } assert.deepEqual(failures, [], `context rail re-enters the credit band at ${failures.join(', ')}`); }); test('the dock anchor changes at 720px — the 2vh cancellation is band-limited', () => { - assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, '2vh'); - assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, '8px'); + const lift = 'var(--world-timeline-lift, 0px)'; + assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, `calc(2vh + ${lift})`); + assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, `calc(8px + ${lift})`); assert.equal( resolve(CREDIT_SELECTORS, 'bottom', 720, 'credit').decl.value, - 'calc(2vh + 5rem)', + `calc(2vh + 5rem + ${lift})`, 'the credit keeps its 2vh base below 720px — that asymmetry is the whole hazard', ); }); diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 7deb73e7f..dc7e679b7 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -3156,3 +3156,70 @@ test('Active Fires: the codec accepts whole megawatts in bounds and the three co confidence: 'nominal', }); }); + +// An application built without a layer (a smaller catalog) receives a link or +// saved state from a build that has it: the layer is neither restored nor +// stored nor carried into the links this application shares. +function managerWithout(withheld) { + const manager = new DataLayerManager({}); + for (const id of REGISTERED_LAYER_IDS) + if (!withheld.includes(id)) manager.register(fakeLayer(id)); + manager.finalizeRegistrations( + LAYER_STATE_REGISTRY.filter(({ id }) => !withheld.includes(id)), + ); + return manager; +} + +test('layers the application did not register are never restored, stored or shared', async () => { + const manager = managerWithout(['earthquakes']); + const storage = memoryStorage(); + const share = shareSink(); + const coordinator = new LayerStateCoordinator(manager, share, { storage }); + const incoming = createDefaultLayerState(); + incoming.enabledLayerIds = ['earthquakes', 'traffic']; + await coordinator.start({ shareLayerState: incoming }); + + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['traffic']); + assert.deepEqual(share.provider().enabledLayerIds, ['traffic']); + assert.equal(manager.layers.has('earthquakes'), false); + + await manager.setEnabled('cctv', true, { origin: 'user' }); + const [[, written]] = storage.writes; + assert.deepEqual(parseStoredLayerState(written).enabledLayerIds, [ + 'cctv', + 'traffic', + ]); + assert.deepEqual(share.provider().enabledLayerIds, ['cctv', 'traffic']); + + await coordinator.applyShareLayerState({ shareLayerState: incoming }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['traffic']); + coordinator.destroy(); + + const saved = createDefaultLayerState(); + saved.enabledLayerIds = ['earthquakes', 'traffic']; + const local = new LayerStateCoordinator( + managerWithout(['earthquakes']), + shareSink(), + { storage: memoryStorage(serializeStoredLayerState(saved)) }, + ); + await local.start(); + assert.equal(local.source, 'local'); + assert.deepEqual(local.getDurableState().enabledLayerIds, ['traffic']); + local.destroy(); +}); + +test('an application with every layer registered keeps every requested layer', async () => { + const coordinator = new LayerStateCoordinator( + productionManager(), + shareSink(), + { storage: memoryStorage() }, + ); + const incoming = createDefaultLayerState(); + incoming.enabledLayerIds = ['earthquakes', 'traffic']; + await coordinator.start({ shareLayerState: incoming }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [ + 'earthquakes', + 'traffic', + ]); + coordinator.destroy(); +}); diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index 40208831a..7cd554539 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -106,9 +106,7 @@ export class LayerStateCoordinator { } = {}) { if (this._destroyed) throw new Error('Layer-state coordinator is destroyed'); - let selected = shareLayerState - ? normalizeLayerState(shareLayerState) - : null; + let selected = shareLayerState ? this._normalize(shareLayerState) : null; if (selected) { this._source = 'share'; this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs) @@ -124,7 +122,7 @@ export class LayerStateCoordinator { /* best effort */ } if (stored) { - selected = stored; + selected = this._normalize(stored); this._source = 'local'; } } else { @@ -163,9 +161,7 @@ export class LayerStateCoordinator { for (const controller of this._restoreControllers.values()) controller.abort('superseded-by-share-navigation'); this._revokePendingTrackingWatch('superseded-by-share-navigation'); - const selected = shareLayerState - ? normalizeLayerState(shareLayerState) - : null; + const selected = shareLayerState ? this._normalize(shareLayerState) : null; this._source = selected ? 'share' : 'legacy-share'; this._shareCreatedAtMs = selected && Number.isFinite(shareCreatedAtMs) ? shareCreatedAtMs : null; @@ -182,6 +178,25 @@ export class LayerStateCoordinator { return this._source; } + /** + * Canonical layer state holding only the layers this application + * registered. An application built without a layer never restores, stores + * or shares it, even when a link or saved state from a build that has it + * names it. Filtering before normalizing lets normalization drop options + * that depend on a layer that is not here (a tracked selection). + */ + _normalize(candidate) { + const input = candidate && typeof candidate === 'object' ? candidate : {}; + return normalizeLayerState({ + ...input, + enabledLayerIds: Array.isArray(input.enabledLayerIds) + ? input.enabledLayerIds.filter((id) => + this.dataManager.layers.has(String(id)), + ) + : [], + }); + } + getDurableState() { return cloneLayerState(this._durableState); } @@ -345,7 +360,7 @@ export class LayerStateCoordinator { } _commitExplicit(candidate) { - this._durableState = normalizeLayerState(candidate); + this._durableState = this._normalize(candidate); const serialized = serializeStoredLayerState(this._durableState); try { if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) { @@ -478,7 +493,7 @@ export class LayerStateCoordinator { ...this._durableState.options[selected.optionOwner], [selected.optionKey]: null, }; - this._durableState = normalizeLayerState({ + this._durableState = this._normalize({ ...this._durableState, options: { ...this._durableState.options, diff --git a/src/data/local_data/ucs_satellites/README.md b/src/data/local_data/ucs_satellites/README.md index 26cb06607..1cd3c63f8 100644 --- a/src/data/local_data/ucs_satellites/README.md +++ b/src/data/local_data/ucs_satellites/README.md @@ -34,18 +34,17 @@ DATA_SOURCES.md. **Curation:** -- Kept columns: Operator/Owner, Country of Operator/Owner, Purpose, Detailed - Purpose, Users, Class of Orbit (upper-cased, which folds the source's one - `LEo`). Everything else (masses, contractors, launch data, sources) is left - out. +- Kept columns: Operator/Owner, Country of Operator/Owner and Purpose, the + ones the layer reads. Everything else (detailed purpose, users, orbits, + masses, contractors, launch data, sources) is left out. - Repeated text is stored once in `strings`; each record is - `[noradId, operator, operatorCountry, purpose, detailedPurpose, users, - orbitClass]` with string-table indexes, `0` meaning empty. + `[noradId, operator, operatorCountry, purpose]` with string-table indexes, + `0` meaning empty. - The 3,996 Starlink rows are dropped: every Starlink object is classed and credited to SpaceX by name, so they would only add size. -- Rows without a readable NORAD number are dropped and counted apart from - duplicate NORAD numbers (the first row of a duplicate is kept); both counts - are 0 in this release. +- Rows without a readable, positive NORAD number are dropped and counted apart + from duplicate NORAD numbers (the first row of a duplicate is kept); both + counts are 0 in this release. - Owner countries stay as UCS writes them ("USA", "USA/Japan", "ESA", "Multinational"); `src/layers/satellites/classes.js` normalises them, together with CelesTrak SATCAT owner codes, in one table. diff --git a/src/data/local_data/ucs_satellites/ucs_satellites.json b/src/data/local_data/ucs_satellites/ucs_satellites.json index f27ee92d8..33ab8776f 100644 --- a/src/data/local_data/ucs_satellites/ucs_satellites.json +++ b/src/data/local_data/ucs_satellites/ucs_satellites.json @@ -1 +1 @@ -{"meta":{"source":"Union of Concerned Scientists Satellite Database","version":"UCS-Satellite-Database 5-1-2023","url":"https://www.ucsusa.org/satellite_database","dataCurrentTo":"2023-05-01","license":"Free, unrestricted use; acknowledgement requested (UCS database manual, section 3)","curation":{"sourceRows":7560,"starlinkRowsDropped":3996,"invalidNoradDropped":0,"duplicateNoradDropped":0,"columns":{"operator":"Operator/Owner","operatorCountry":"Country of Operator/Owner","purpose":"Purpose","detailedPurpose":"Detailed Purpose","users":"Users","orbitClass":"Class of Orbit"}},"recordCount":3564},"fields":["noradId","operator","operatorCountry","purpose","detailedPurpose","users","orbitClass"],"strings":{"operator":["","Hera Systems","AAC Clyde Space","Aalto University","University of Aalborg","Asia Broadcast Satellite Ltd.","Asher Space Research Institute at Technion/Israeli Aircraft Industries","Austrian Space Forum (OEWF)/Spire","National Reconnaissance Office (NRO)","US Air Force","Department of Homeland Security","European Space Agency (ESA)","Aerospace Corporation","EUTELSAT S.A.","Aurora Insight","Center for Atmospheric Sciences, Hampton University/NASA","Norwegian Coastal Admnistration","AISTech","Al Yah Satellite Communications Co. (YAHSAT)","Al-Farabi Kazakh National University","Algerian Space Agency (ASAL)","Astro Live Experiences","University of Brasilia","US Air Force Institute of Technology","Algerian Space Agency (ASAL)/UK Space Agency","Hispamar (subsidiary of Hispasat - Spain)","National Institute for Space Research (INPE)","SES S.A.","Moscow State University","Space-Communication Ltd.","AMSAT-NA","Agency for Defense Development","ANDESITE - Boston University","Centre National d'Etudes Spatiales (CNES)","AngoSat","Telesat Canada Ltd. (BCE, Inc.)","Aprize Satellite, Argentina","APT Satellite Holdings Ltd.","Arab Satellite Communications Org. (ASCO)","University of South Florida, Institute of Applied Engineering (IAE).","Norwegian Defence Research Establishment","Aoyama Gakuin University in Tokyo","Naval Physical and Oceanographic Laboratory","Planetary Resources","ArSat","Aerospacelab","Istanbul Technical University","Asia Satellite Telecommunications Co. Ltd.","1Worldspace","Japan Space Systems","Chiba Institute of Technology","Astranis","Astrocast","Telemetry Tracking and Command Network (ISTRAC) Centre","DirecTV, Inc.","PointView Tech","Centre National d'Etudes Spatiales (CNES)/Agenzia Spaziale Italiana (ASI)","Microspace Rapid Pvt Ltd.","Azercosmos","Bangladesh Telecommunication Regulatory Commission (BTRC)","Tianyi Research Institute","Ministry of Defense","Space Development Agency","Central European Institute of Technology","Technical University Berlin","Chinese Ministry of National Defense","21st Century Aerospace Technology (21AT)","Beijing Landview Mapping Information Technology Co. Ltd (BLMIT)","Changsha Tianyi space science and Technology Research Institute Co., Ltd. (Spacety)","Belintersat","Institute of Space Sensor Technology and Planetary Exploration","Norwegian Defence Research Establishment (FFI)/","German Aerospace Center (DLR)","Salish Kootenai College","National Academy of Sciences","BlackSky Global","NanoAvionics","AST SpaceMobile","Embratel Star One","SpaceQuest, Ltd.","Bank Rakyat Indonesia (BRI)","University of Toronto, Institute for Aerospace Studies","BRITE Consortium","UnseenLabs","AstroForge","Broadcasting Satellite System Corp.","Broadcasting Satellite System Corp./Sky Perfect JSAT Corporation","Defence Science and Technology Group/University of New South Wales","China Academy of Space Technology (CAST)","Satellogic S.A.","Bulsatcom","Capital Technology University","Centre National d'Etudes Spatiales (CNES)/NASA","University of Louisiana at Lafayette","Capella Space","Indian Space Research Organization (ISRO)","Korea Aerospace Research Institute (KARI)","Canadian Space Agency","China National Space Administration (CNSA)/National Institute for Space Research (Brazil)","Surrey Satellite Technology Ltd.","Earth-i/Surrey Satellite Technology Ltd.","Canon Electronics","Air Force Research Laboratory","Fleet Space Technologies","Beijing Future Navigation Technology Co. Ltd.","NASA Goddard Space Flight Center","Ministry of Armed Forces","Defense Innovation Unit/Cesium Astro","China National Space Administration (CNSA)/National Centre for Space Studies (CNES)","TELNET Holding","National Aeronautics and Space Administration (NASA) Goddard Space Flight Center","Spacety Aerospace Company","Shanghai Engineering Center for Microsatellites","Spacety Co. Ltd.","Nanjing University","Epic Aerospace","University of Florida","Chinese Academy of Sciences","GeoOptics Inc.","GeoOptics, Inc.","Ciel Satellite Group","University of California-Berkeley/Imperial College-London","Laboratory for Atmospheric and Space Physics, University of Colorado, Boulder","Utah State University","Unknown US agency","National Aeronautics and Space Administration (NASA)/Colorado State University","Department of Defense/Missile Defense Agency","FH Aachen","Armed Forces","Plan-S","US Air Force/ US Navy/NASA","Astro Digital","Taiwan's National Space Organization/National Oceanographic and Atmospheric Admistration (NOAA)","National Space Program Office (NSPO)/University Corporation for Atmospheric Research (UCAR) Boulder, CO","Italian Space Agency/Ministry of Defense","California Polytechnic State University/NASA JPL","University of Colorado","Directorate General of Armaments (DGA)","Argentinian Ministry of Science, Technology and Productive Innovation/Radio Club Bariloche","University of Illinois Urbana-Champaign","Department of Astronautics, University of Tokyo","National Aeronautics and Space Administration (NASA)","Department of Astrophysical and Planetary Science, UC Boulder/National Aeronautics and Space Administration (NASA)","University of Michigan/NASA Earth Science Technology Office","German Orbital Systems","Mexican Space Agency/NanoAvionics","Japan Aerospace Exploration Agency (JAXA)","North Idaho STEM Charter Academy","ADA Space (?)","China Amateur Satellite - CAMSAT","Deimos Imaging/DMC International Imaging (DMCII)","Technical University, Delft","Massachusetts Institute of Technology","Dubai Electricity and Water Authority (DEWA)","Khalifa University","DARPA (Defense Advanced Research Projects Agency)","Sky and Space Global, UK","SpacePharma","IT University of Copenhagen, University of Southern Denmark, Aalborg University and Aarhus University","Philippines’ Department of Science and Technology, University of the Philippines Diliman, and Japan’s Hokkaido University and Tohoku University","Deutsches Zentrum für Luft- und Raumfahrt","Mohammend Bin Rashid Space Centre (MBRSC)","DoD/NOAA","University of Southern California, Space Engineering Research Center/Lockheed Martin","Planet Labs, Inc.","China National Space Administration","Kawasaki Heavy Industries","Singapore Armed Forces","Emirates Institution for Advanced Science & Technology (EIAST)","Academy of Military Science","ESPACE","Global Eagle Entertainment","OHB Italia","Echostar Satellite Services, LLC","HughesNet leased from Echostar Technologies, LLC","Echostar Satellite Services, LLC/Intelsat","Equator Technological University","Airbus","National Authority for Remote Sensing and Space Science (NARSS)","Momentus","Roshydromet - Planeta","University of California","DGA (Arms Procurement Agency)/Centre National d'Etudes Spatiales (CNES)","Clyde Space Ltd./EUTELSAT","Astroscale","Defence Research and Development Organization","EOS Data Analytics","Earth Sciences Enterprise (NASA)","Goddard Space Flight Center/EOS Data and Operations System","National Aeronautics and Space Administration (NASA) - Earth Science Enterprise/Japan Meteorological Agency/Brazilian Space Agency","Defense Ministry","ImageSat International, NV/Ministry of Defense","EUTELSAT S.A./Es'hailSat","Es’hailSat","ESA/European Maritime Safety Agency","European University Network","Ethiopian Space Science and Technology Institute","EUTELSAT Americas","EUTELSAT S.A./Nilesat","exactEarth","Defense Advanced Research Projects Agency (DARPA)","California Polytechnic Institute","Russian Satellite Communications Company","Exseed Space","CNES (Centre national d'études spatiales)","Colombian Air Force","US Air Force Academy","InSpace","University of Texas - Austin","Link Space Aerospace Technology","China Meteorological Administration","National Aeronautics and Space Administration (NASA)/GSFC","University of Stuttgart","US Navy","National Space Organization","Los Alamos National Laboratory","Jihua Laboratory","Copernicus Land and Marine Environment services","AMSAT-UK","PanAmSat (Intelsat S.A.)","PanAmSat (Intelsat S.A.)/Sky Perfect JSAT Corp.","Intelsat S.A.","PanAmSat (Intelsat S.A.)/DirecTV, Inc.","Gama","China National Academy of Sciences (CNSAS)","China’s Ministry of Land and Resources, Ministry of Environmental Protection, and Ministry of Agriculture","State Oceanic Administration (SOA)","China's Ministry of Land and Resources, Ministry of Environmental Protection, and Ministry of Agriculture","Ministry of Natural Resources/Ministry of Housing and Urban-Rural Development/National Bureau of Statistics","People's Liberation Army (C41)","GomSpace ApS","Air Force Research Laboratory/Globalstar","NearSpace Launch/Air Force Research Laboratory","Geespace","Bigelow Aerospace","Korea Meteorological Administration/National Meteorological Satellite Center","DigitalGlobe Corporation","Institute of Space and Astronautical Science (ISAS)/NASA/ESA","Marshall Intec","GHGSat, Inc.","Orbital Sidekick","Globalstar","OHB SE (Orbitale Hochtechnologie Bremen)","PlanetiQ","NOAA (National Oceanographic and Atmospheric Administration)","Turkish Ministry of National Defense","Gonets Satcom","JAXA, Ministry of Environment, National Institute of Environmental Studies","Lunasonde","LuxGovSat","National Aeronautics and Space Administration (NASA)/JAXA","NASA/German Research Centre for Geosciences (GFZ)","Axelspace","Indian Navy","Indian Air Force","Air Force Satellite Control Network","U.S. Army’s Space and Missile Defense Command","Department of Defense/Army Space and Missile Defense Command","Sanya Institute of Remote Sensing","Xiamen University/Aerospace Dongfanghong Satellite Co. Ltd. {CASC)","Department of Defense/Space Test Program","Hunan Hangsheng Satellite Technology Co., Ltd.","US Army Space and Missile Defense Command","HawkEye 360","HEAD Aerospace","Centre National d'Etudes Spatiales (CNES)/Délégation Générale de l'Armement (DGA)","Hellas-Sat Consortium Ltd.","Hellas-Sat Consortium Ltd./INMARSAT","Tokyo Institute of Technology","Hiber Global","Japan Meteorological Agency/Meteorological Satellite Center (MSC)","Institute of Space and Aeronautical Science/NASA","Spacety","Hispasat","National Remote Sensing Center (NRSCC)","China Centre for Resources Satellite Data and Application (CRESDA)","University of Tokyo and NESTRA","PIESAT","China Aerospace Science and Technology Corporation (CASC)","China Aerospace Science and Industry Corporation (CASIC)","Horizons 2 Satellite, LLC (Intelsat, Sky Perfect JSAT Corporation)","Egyptian Space Agency","Kyushu Institute of Technology (KIT)","Harris Corporation","Shanghai Lizheng Satellite Application Technology","Ministry of Ecology and Environment/Ministry of Emergency Management","European Space Agency (ESA)/NASA","Huaian Youth Comprehensive Development Base","National Space Science Center (NSSC)","Avanti Communications, PLC","ICEYE Ltd.","Cabinet Satellite Intelligence Center (CSIC)","TÜBİTAK Space Technologies Research Institute","INMARSAT, Ltd.","INMARSAT, Ltd./European Space Agency (ESA)","Astronautic Technology Sdn. Bhd (ATSB)","University of Colorado’s Laboratory for Atmospheric and Space Physics/Indian Institute of Space Science and Technology","NRS Lab. Atmospheres, Milieux, Observations Spatiales (Verrieres)","European Space Operations Centre (ESOC)/NASA/Russia","Intelsat S.A./Sky Perfect JSAT Corp.","National Aeronautics and Space Administration (NASA)/Multinational","National Aeronautics and Space Administration (NASA)/Goddard Space Flight Center","D-Orbit","Iridium Communications, Inc.","National Center for Atmospheric Research (NCKU)","NASA Small Satellite Technology Program","iSky Technology","NASA/CalPoly","TrustPoint, Inc.","ITU Space Systems Design & Test Laboratory","Radio Amateur Society of Thailand (RAST)","National Aeronautics and Space Administration (NASA)/Centre National d'Etudes Spatiales (CNES)/NOAA/EUMETSAT","Sky Perfect JSAT Corporation","Sky Perfect JSAT Corporation/Kacific","Space OK (Shanghai Ok Space Co Ltd.)","Chang Guang Satellite Technology Co. Ltd.","Beijing ZeroG Space Technology Co., Ltd./Hong Kong Aerospace Tech Group","Shanghai Jiao Tong University/APSCO","Gang Hang Ke","Hong Kong Aerospace Technology Group","Chinese University Corporation for Polar Research","Crown Prince Foundation","China Aerospace Science and Technology Corp. (CASC)","Scientific Production Corporation (joint stock creation of Russian Space Agency)","Roscosmos State Corporation","Kazcosmos","JSC KazSat","Ghalam LLP","National University of Singapore","Kepler Communications","US Army Space and Missile Defense Command/Army Forces Strategic Command","Revolutionary Guard Corps","GUMUSH AeroSpace/University of Turkish Aeronautical Association (UTAA)","Tsinghua University","Tokyo Metropolitan College of Industrial Technology","KLEO Connect","Chosun University","KT Corporation/Korean Agency for Defense Development (ADD)","KT Corporation","National Institute of Technology at Kochi College","Kleos Space","College of Engineering, King Saud University","Kuwait University","Lacuna Space","Lacuna Space/NanoAvionics","Beijing Commsat Technology Development Co. Ltd.","National Aeronautics and Space Administration (NASA)/US Geological Survey","Laos National Authority for Science and Technology","Indonesian National Aeronautics and Space Agency (Lembaga Penerbangan dan Antariksa Nasional - LAPAN)","Aprize Satellite Argentina","US Space Force","Sapienza","Spire Global Inc.","California Polytechnic State University","Harbin Institute/China Amateur Satellite - CAMSAT","General Atomics Electromagnetic Systems","Xinwei Telecom Technology Company","Beijing ZeroG Technology Co. Ltd.","Nanjing and Hong Kong Universities","Changchun Institute of Optics","Russian Federal Space Agency","Federal Security Service (FSB)","SkyFox Labs","Shanghai Academy of Space Technology","University of Vigo/Alén Space","Wuhan University","Lynk Global Inc.","University of Michigan/Montana University","Defence Research and Development Canada (DRDC)/Canadian Space Agency","CTRS Morocco/Institut für Luft-und Raumfahrttechnik (Berlin)","Max Valier school Bolzano, Italy, Oskar von Miller school, Merano, Italy","Cape Peninsula University of Technology (CPUT)","MEASAT Satellite Systems Sdn. Bhd.","Indian Space Research Organization (ISRO)/Centre National d'Etudes Spatiales (CNES)","Open Cosmos","PT Telkomunikasi","National Reconnaissance Office (NRO)/US Air Force","Military Space Forces (VKS)","Analytical Space","Russian Federal Service For Hydrometeorology and Environmental Monitoring (ROSHYDROMET)","EUMETSAT (European Organization for the Exploitation of Meteorological Satellites)","ESA/EUMETSAT (European Organization for the Exploitation of Meterological Satellites)","Space Logistics LLC","Mexico Telecomm","Masdar Institute of Science and Technology","Vietnam National Space Center","MIT/Lincoln Laboratory-University of Massachusetts Amherst","Kingdom of Morocco","Technical University of Munich","Mozhaisky Space Engineering Academy","Mobile Satellite Ventures","DoD/US Navy","ACMEO","Tyvak Nanosatellite Systems, Inc.","Instituto Nacional de Técnia Aeroespacial (INTA)","Brazilian National Institute of Space Research (INPE/MCTI), Federal University of Santa Maria (UFSM)","Shanghai Weixing Keji","Thai Air Force","National Authority for Remote Sensing and Space Sciences","DoD/US Air Force","Center of Excellence for Space Sciences and Technologies (SPACE-SI)","Origin Space","Deimos","Unknown","Würzburg Center for Telematics","Korea Advanced Institute of Science and Technology (KAIST)","Nihon University","NigComSat","National Space Research and Development Agency (NASRDA)","Egyptian Radio and TV Union","Echostar Corporation (entire payload leased from Telesat Canada Ltd.)","Ningxia Jingui Information Technology Co. Ltd.","Noorul Islam University","PT Pasifik Satelit Nntara","National Oceanographic and Atmospheric Administration (NOAA) (part of international program)","Novosibirsk State University","Norwegian Space Center","Norwegian Space Center/Statsat","UK Government/Surrey Satellite Technologies","National Oceanographic and Atmospheric Administration (NOAA)/NASA","F Space Test Office","NSLComm","National Aeronautics and Space Administration (NASA)-Ames Research Center/Stanford University","O3b Networks Ltd.","Michigan Technological University/Air Force Research Laboratory","Swedish National Space Board","U.S. Space Force","OneWeb Satellites","Spanish Space Agency","Italian Defense Ministry","Optus Communications (Parent: Singapore Telecom)","Optus Communications (Parent: Singapore Telecom)/Australian Ministry of Defense","ORBCOMM Inc.","DARPA/Tethers Unlimited","Portland State Aerospace Society","F /ORSO (Operationally Responsive Space Office)","General Atomics - Electromagnetic Systems Group","Mexico Secretariat of National Defense (SEDENA)","Pakistan Space and Upper Atmosphere Research Commission (SUPARCO)/PakSat International","Pakistan Space and Upper Atmosphere Research Commission (SUPARCO)","National Reconnaissance Office","National Aeronautics and Space Administration (NASA)/Cornell University","US Naval Academy","Helios Wire/Echostar Global Australia","Hisdesat","Tiger Innovations","Netherlands Organization for Applied Scientific Research (NTO) consortium","Dauria Aerospace","Peruvian Space Agency","Rocket Labs","Brigham Young University","German Aerospace Center (DLR-IKN)/TESAT Spacecom","University of Chile for Aerospace Investigation)","Ministry of Defense/Centre National d'Etudes Spatiales (CNES) - cooperation with Austria, Belgium, Spain, Sweden","Ministry of Defense//Centre National d/Etudes Spatiales (CNES) - cooperation with Austria, Belgium, Spain, Sweden","Airbus Defense and Space","US Coast Guard","University of Colorado, Boulder","National Technical University","St. Petersburg Polytechnic University","Italian Space Agency","Naval Post-Graduate School","Georgia Institute of Technology","Shanghai Academy of Spaceflight Technology","Astrodynamics and Control Laboratory of Yonsei University","Korean Aerospace Research Institute (KARI)","Von Karman Institute (VKI)","Qiansheng Exploration Technology Co. Ltd.","Shandong Institutes of Industrial Technology","Orbital Space","iQPS","University of Science and Technology","SES S.A. -- total capacity leased to subsidiary of EchoStar Corp.","University of New South Wales/Department of Defence","MDA Corporation","C3S","Vanderbilt University/AMSAT","Robertsville Middle School, Oak Ridge, Tennessee","Korea Advanced Institute of Science and Technology.","Space Technologies Research Institute","Regional African Satellite Communications Organization (RASCOM)","Applied Physics Laboratory/NASA","Reaktor Space Lab","Institute of Space and Astronautical Science (ISAS)/Japan Aerospace Exploration Agency (JAXA)","Skobeltsyn Institute of Nuclear Physics","Indian Space Research Organization (ISRO)/exactEarth","Tohoku University","Tohoku University/Hokkaido University","University of Montpellier","Orbital Solutions Monaco","European Space Agency (ESA) (and 250 international scientific investigators)","National Autonomous University of Mexico","AMSAT/Nepal","National Space Activities Commission (CONAE)","Stanford Student Space Initiative","Department of National Defense","EASF/Bundeswehr","Sateliot IoT","Department of Computer Science and the Faculty of Engineering, Ariel University","Riyadh Space Research Institute","Space Research Institute, King Abdulaziz City for Science and Technology","National Reconnaissance Office (NRO)/US Navy","Strategic Space Command/Space Surveillance Network","Instituto Nacional de Pesquisas Espaciais (INPE)","University of North Carolina - Wilmington","SES S.A./EchoStar Satellite Services, LLC","Telebras/Brazilian Air Force","Teen Satellite Project","NASA Langley Research Center","China Satellite Communication Corp. (China Satcom)","Chinese Academy of Launch Vehicle Technology (CALT)","Shanghai Institute of Satellite Engineering","Chinese Academy of Space Techology (CAST)","National Space Program","Italian Defense Ministry/Telespazio","Italian Defense Ministry/French Defense Ministry","ESA/Royal Meteorological Institute, Belgium","SRI International","Sirius XM Holdings","XM Satellite Radio","China Siwei Surveying and Mapping Technology Co. Ltd.","Sky Mexico","NBN Co (operated by Optus)","DirecTV Latin America/Intelsat","Skykraft","Intelsat/Paradigm Secure Communications (wholly owned by EADS Astrium)","Ministry of Defense/Paradigm Secure Communications (wholly owned by EADS Astrium)","LightSquared","Centre National d'Etudes Spatiales (CNES)/European Space Agency (ESA)","US Southern Command","Seoul National University","National Institute of Information and Communications Technology (JPN)","Astrotech SOHLA","Technical University Dresden","University of Würzburg","National Aeronautics and Space Administration (NASA) Earth Science Office/Laboratory for Atmospheric and Space Physics, Univ. of Colorado","COSMIAC (Configurable Space Microsystems Innovations & Applications Center)","South Asian Association for Regional Cooperation","Swarm Technologies","Hughes Space and Communications Co.","Hisdesat/Ministry of Defense","Air Force Research Laboratory/Swedish Defense Materiel Administration","Omnispace","Endurosat","Astro Space Center of Moscow/Russian Academy of Sciences","Ministry of Defense/General Delegation for Ordnance (DGA)","Spot Image","SRM University","Government of Sudan","Chilean Air Force","Beihang University/APSCO","Asia-Pacific Space Cooperation Organization (APSCO)","Singapore Telecommunications Ltd. (SingTel)/Chunghwa Telecom Co. Ltd.","Sony","Scanway/German Orbital Systems","SatRevolution/Steamjet","Sternula","SatRevolution","Atlas 5","Synspective","Missile Defense Agency (MDA)","University of Chile","Sky Perfect JSAT Corporation/DSN Corp.","Beijing Space View Technology","Goddard Space Flight Center/Penn State University","Ecole Polytechnique Fédérale de Lanne (EPFL)","Naval Research Laboratory","Operational Responsive Space (ORS) Office","Kenya Space Agency","Beijing MinoSpace Technology Co., Ltd.","Minospace","German Aerospace Center (DLR)/Astrium","Alibaba","Orbit Fab","Herzliya Science Center","National Aeronautics and Space Administration (NASA)/SES Americom (SES [Société Européenne des Satellites (SES)])","National Aeronautics and Space Administration (NASA)/Ames Research Center","Teikyo University","AgilSpace","Telesat Canada Ltd. (BCE, Inc.)/APT Satellite Holdings Ltd.","Colorado State University","German Aerospace Center (DLR)/Infoterra","TerraStar Corporation","U.S. Space Force/Other Transaction Authority","Thaicom Public Company Ltd.","National Aeronautics and Space Administration (NASA)/University of California, Berkeley (in partnership with Germany, France, Austria, Canada)","Geo-Informatics and Space Technology Development Agency (GISTDA)","Telenor Satellite Broadcasting","Thuraya Satellite Communications","China Manned Space Engineering Office (CMSEO)","Tianjin University","Chinese Academy of Launch Vehicle Technology (CASIC)","Xiyong Microelectrics Park","Guodian Gaoke","GasTianta and GZH-HNJ BDS AGR Co., Ltd.","Sun Yat-sen University","Insight Position Digital Intelligence Technology Service Co., Ltd.","China Telecom","National University of Defense Technology","China Electronic Technology Group","CASIOM","Spacety Aerospace Company/ThrustMe","Spacety Aerospace Company/Changsha Gaoxinqu Tianyi Research Institute","Chinese Academy of Sciences (CAS)/China Academy of Space Technology","Institute of Software Chinese Academy of Sciences","La Sapienza University of Rome","National Aeronautics and Space Administration (NASA)/Applied Physics Laboratory, Johns Hopkins","Bolivia Aerospace Bureau","Tomorrow.io","University of Malibor/SkyLabs","MyRadar","Tallinn University of Technology","Technical University of Graz","Turkmenistan Ministry of Communications/SSI-Monaco","Turksat","Elbit Systems","UK/DMC International Imaging (DMCII)","UMBRA","Alba Orbital","Wakayama University (UNIFORM consortium)","Polytechnic University of Madrid","University of Versailles Saint-Quentin-en-Yvelines (UVSQ)/National Centre for Scientific Research (CNRS)","NASA/Carnegie Mellon University","National Aeronautics and Space Administration (NASA)/Johns Hopkins University Applied Physics Laboratory","VISIONA Tecnologia Espacial S.A.","Care Weather Technologies","Nanyang Technological University","Centre National d'Etudes Spatiales (CNES)/Israel Space Agency","ViaSat, Inc.","Momentus, Inc.","Vietnamese Posts and Telecommunications Group (VNPT)","C3S LLC","Space Technology Institute-Vietnam Academy of Science and Technology (STI-VAST)","Bolivarian Agency for Space Activities","Shanghai Micro Satellite Engineering Center","CENTI (China-Europe Networks of technology and Innovation)","Military Satellite Communications - US Air Force","WildBlue Communications","La Sapienza University of Rome/Machakos University/University of Nairobi, Kenya","Weathernews, Inc.","Maxar Technologies Inc.","Centre for Research in Satellite Technology (CREST)","MinoSpace Technology","Chengdu Guoxing Aerospace Technology Co. Ltd.","Xingyun Satellite Co.","DFH Satellite Co. Ltd.","European Space Operations Centre (ESOC)","Ministry of Defense/XTAR","DFH Satellite Co. Ltd./AMSAT-China","Chinese Amateur Satellite (CAMSAT)","Loft Orbital/Fugro","Loft Orbital","Gazprom Space Systems","Bauman Moscow State Technical University","GalaxySpace","Galaxy Space","Hubei University of Technology (HUT)","Mitsubishi Heavy Industries","French South African Institute of Technology/Cape Peninsula University of Technology","QOSMOSYS","China Earthquake Administration/Italian Institute for Nuclear Physics","Zhejiang University","Zhuhai Orbita Control Engineering Co. Ltd.","Zhuhai Orbita Aerospace Science and Technology Co."],"operatorCountry":["","USA","United Kingdom","Finland","Denmark","Multinational","Israel","Austria","ESA","Lithuania","Norway","Spain","United Arab Emirates","Kazakhstan","Algeria","Japan","Brazil","Russia","South Korea","Angola","Canada","Argentina","USA/Argentina","China","Belgium","Turkey","Luxembourg","Switzerland","India","France/Italy","Singapore","Azerbaijan","Bangladesh","Czech Republic","Germany","Belarus","Netherlands","Indonesia","France","Australia","Bulgaria","China/Brazil","China/France","Tunisia","Taiwan","Italy","Mexico","Ecuador","Egypt","USA/Canada/Japan","USA/Japan/Brazil","Ethiopia","Colombia","USA/Japan","USA/Germany","France/Italy/Belgium/Spain/Greece","Greece","Greece/United Kingdom","United Kingdom/ESA","Malaysia","USA/India/Singapore/Taiwan","ESA/Russia","Thailand","USA/France","Japan/Singapore","Jordan","Iran","Saudi Arabia","Kuwait","United Kingdom/Netherlands","Laos","Morocco/Germany","South Africa","India/France","Vietnam","Morocco","USA/Canada","Slovenia","Sinapore","Nigeria","Sweden","Pakistan","Peru","New Zealand","Chile","Ukraine","Hungary","India/Canada","Monaco","Nepal","France/Belgium/Sweden","Sudan","Singapore/Taiwan","USA/Poland","Poland/UK","Poland","USA/United Kingdom/Italy","Kenya","Iraq","Bolivia","Estonia","Turkmenistan/Monaco","France/Israel","Venezuela","China/Italy"],"purpose":["","Earth Observation","Technology Development","Communications","Earth Science","Space Science","Space Science/Technology Demonstration","Communications/Technology Development","Communications/Maritime Tracking","Technology Demonstration","Unknown","Navigation/Global Positioning","Earth Observation/Technology Development","Earth Observation/Communications","Earth/Space Observation","Educational","Earth Observation/Earth Science","Platform","Earth Observation/Space Science","Earth Observation/Navigation","Communications/Navigation","Space Observation","Surveillance","Navigation/Regional Positioning","Space Science/Technology Development","Mission Extension Technology","Earth Science/Earth Observation","Earth Observation/Communications/Space Science","Meteorological","Technology Development/Educational","Satellite Positioning"],"detailedPurpose":["","Infrared Imaging","Automatic Identification System (AIS)","Electronic Intelligence","Earth Science","Optical Imaging","Meteorology","Radar Imaging","Amateur Radio","Radar Surveillance","Signals Intelligence","Maritime Surveillance","Radar Imaging (SAR)","Multispectral Imaging","Optical Imaging/Meterology","Optical Imaging/Meteorology","Internet of Things (IoT)/Amateur Radio","Radar Imaging/Earth Science","AI Remote Sensing","Earth Science/Meterology","Early Warning","Hyperspectral Imaging","Optical Stereo Imaging","Subsurface Imaging","Imaging","Maritime Observation","Remote Sensing","Laser Imaging","Thermal Imaging","ADS-B Receiver","Internet of Things (IoT)","Optical Imaging (video)","Video Imaging","Optical Imaging/Automatic Identification System (AIS)","Radio Frequency Monitoring","Meteorology, Automatic Identification System (AIS)","X-ray Astronomy","Technology Development","Synthetic Aperture Radar (SAR)","Optical/Infrared Imaging","Optical/Video Imaging","Radio Spectrum Monitoring","Surveillance","Optical/Hyperspectral Imaging","Situational Awareness","Radar Imaging/Electronic Intelligence","Microwave Radiometer","Meteorology/Earth Science","Data Relay","Optical, Near-Infrared","Synthetic Aperture Imaging","Meteorology/Radar","Navigation","Optical Imaging/Infrared Imaging"],"users":["","Commercial","Civil","Government","Military","Military/Commercial","Government/Military","Military/Government","Government/Civil","Military/Civil","Commercial/Civil","Civil/Commercial","Government/Commercial","Commercial/Government","Government/Commercial/Military","Civil/Government","Civil/Military","Commercial/Military"],"orbitClass":["","LEO","GEO","ELLIPTICAL","MEO"]},"records":[[7530,30,1,3,0,2,1],[19548,141,1,3,0,3,2],[20253,213,1,3,0,4,2],[20580,286,8,5,0,3,1],[20776,525,2,3,0,4,2],[21639,27,1,3,0,12,2],[22049,237,5,5,0,3,3],[22314,575,1,3,0,12,2],[22491,503,16,1,47,3,1],[22787,213,1,3,0,4,2],[22988,9,1,3,0,4,2],[23223,376,1,1,3,4,2],[23467,213,1,3,0,4,2],[23553,390,76,3,0,1,2],[23613,141,1,3,0,3,2],[23628,9,1,3,0,4,2],[23855,376,1,1,3,4,2],[23893,8,1,3,0,4,3],[24674,293,2,3,0,1,2],[24753,162,1,1,19,4,1],[24812,221,1,3,0,1,2],[24876,399,1,11,0,5,4],[24916,221,1,3,0,1,2],[24920,215,1,1,7,3,1],[24936,171,1,3,0,1,2],[25019,9,1,3,0,4,2],[25034,376,1,1,3,4,3],[25071,27,26,3,0,1,2],[25112,432,1,3,0,1,1],[25113,432,1,3,0,1,1],[25114,432,1,3,0,1,1],[25115,432,1,3,0,1,1],[25116,432,1,3,0,1,1],[25117,432,1,3,0,1,1],[25118,432,1,3,0,1,1],[25119,432,1,3,0,1,1],[25153,293,2,3,0,1,2],[25159,432,1,3,0,1,1],[25258,213,1,3,0,4,2],[25336,8,1,1,3,4,2],[25338,414,1,1,19,3,1],[25414,432,1,3,0,1,1],[25415,432,1,3,0,1,1],[25416,432,1,3,0,1,1],[25418,432,1,3,0,1,1],[25419,432,1,3,0,1,1],[25420,432,1,3,0,1,1],[25462,27,26,3,0,1,2],[25475,432,1,3,0,1,1],[25477,432,1,3,0,1,1],[25481,432,1,3,0,1,1],[25504,503,16,1,47,3,1],[25544,300,5,5,0,3,1],[25639,61,2,3,0,4,2],[25652,241,1,3,0,1,1],[25682,346,1,1,4,3,1],[25757,160,34,4,0,3,1],[25867,110,1,5,0,3,3],[25924,5,5,3,0,1,2],[25933,399,1,11,0,5,4],[25967,213,1,3,0,4,2],[25980,432,1,3,0,1,1],[25981,432,1,3,0,1,1],[25982,432,1,3,0,1,1],[25984,432,1,3,0,1,1],[25985,432,1,3,0,1,1],[25989,646,8,5,0,3,3],[25991,162,1,1,19,4,1],[25994,187,49,1,4,3,1],[26038,219,1,3,0,1,2],[26052,9,1,3,0,4,2],[26107,48,1,3,0,1,2],[26356,9,1,1,1,4,2],[26360,399,1,11,0,5,4],[26369,13,5,3,0,1,2],[26388,141,1,3,0,3,2],[26390,518,1,3,0,1,3],[26407,399,1,11,0,5,4],[26410,490,8,5,0,3,3],[26411,490,8,5,0,3,3],[26451,219,1,3,0,1,2],[26463,490,8,5,0,3,3],[26464,490,8,5,0,3,3],[26469,78,16,3,0,1,2],[26473,8,1,1,7,4,1],[26483,518,1,3,0,1,3],[26554,27,36,3,0,1,2],[26575,9,1,3,0,4,2],[26580,27,1,3,0,1,2],[26590,221,1,3,0,1,2],[26605,399,1,11,0,5,4],[26608,219,1,3,0,1,2],[26624,35,20,3,0,1,2],[26626,518,1,3,0,1,3],[26638,27,26,3,0,1,2],[26690,399,1,11,0,5,4],[26694,429,45,3,0,4,2],[26702,425,80,1,4,3,1],[26715,9,1,3,0,4,2],[26719,13,5,3,0,1,2],[26766,219,1,3,0,1,2],[26824,221,1,3,0,1,2],[26853,27,26,3,0,1,2],[26880,9,1,1,1,4,2],[26900,221,1,3,0,1,2],[26958,11,8,9,0,3,1],[26959,70,34,12,5,8,1],[26998,606,1,5,0,8,1],[27004,369,71,2,0,3,1],[27056,61,17,3,0,4,1],[27057,61,17,3,0,4,1],[27168,9,1,3,0,4,2],[27378,173,1,3,0,1,2],[27380,221,1,3,0,1,2],[27389,141,1,3,0,3,2],[27399,312,15,3,0,1,2],[27400,27,26,3,0,1,2],[27414,27,36,3,0,1,2],[27424,189,50,1,4,3,1],[27426,54,1,3,0,1,2],[27434,61,6,1,5,4,1],[27438,221,1,3,0,1,2],[27445,222,1,3,0,1,2],[27460,13,5,3,0,1,2],[27464,61,17,3,0,4,1],[27465,61,17,3,0,4,1],[27509,380,5,1,19,8,2],[27513,221,1,3,0,1,2],[27528,273,11,3,0,14,2],[27540,298,61,5,0,3,3],[27566,141,1,3,0,3,2],[27603,27,36,3,0,1,2],[27606,349,21,3,0,1,1],[27607,500,67,3,8,3,1],[27612,349,21,3,0,1,1],[27640,130,1,1,0,4,1],[27651,535,1,5,0,8,1],[27663,399,1,11,0,5,4],[27691,9,1,3,0,4,2],[27704,399,1,11,0,5,4],[27711,9,1,3,0,4,2],[27715,219,1,3,0,1,2],[27811,266,56,3,0,1,2],[27825,588,12,3,0,1,2],[27831,431,39,3,0,5,2],[27843,97,20,5,0,3,1],[27848,140,15,2,0,2,1],[27854,175,1,3,0,1,2],[27858,97,20,5,0,3,1],[27868,61,17,3,0,4,1],[27869,61,17,3,0,4,1],[27875,9,1,3,0,4,2],[27937,8,1,1,3,4,2],[27939,389,17,2,0,8,1],[27954,220,53,3,0,1,2],[28051,95,28,1,5,3,1],[28054,162,1,1,19,4,1],[28058,117,23,3,0,3,1],[28089,652,17,3,0,1,2],[28117,213,1,3,0,4,2],[28158,9,1,1,1,4,2],[28184,5,5,3,0,1,2],[28187,13,5,3,0,1,2],[28190,399,1,11,0,5,4],[28220,88,23,1,0,3,1],[28252,27,1,3,0,1,2],[28358,221,1,3,0,1,2],[28366,36,22,7,0,1,1],[28369,499,67,2,0,1,1],[28370,499,67,2,0,1,1],[28371,499,67,1,5,3,1],[28372,36,21,7,0,1,1],[28376,188,1,1,4,3,1],[28378,35,20,3,0,1,2],[28384,376,1,3,48,4,3],[28413,88,23,5,0,3,1],[28414,88,23,5,0,3,1],[28419,61,17,3,0,4,1],[28420,61,17,3,0,4,1],[28446,27,1,3,0,1,2],[28470,88,23,1,5,4,1],[28474,399,1,11,0,5,4],[28485,564,96,5,0,8,1],[28492,265,55,1,5,4,1],[28493,394,11,3,0,3,1],[28526,27,36,3,0,1,2],[28537,501,1,1,3,4,1],[28541,501,1,1,3,4,1],[28542,647,11,3,0,7,2],[28626,519,1,3,0,1,2],[28628,293,2,3,0,1,2],[28646,8,1,1,7,4,1],[28649,95,28,1,5,3,1],[28654,414,1,1,6,3,1],[28659,54,1,3,0,1,2],[28702,221,1,3,0,1,2],[28707,202,17,3,0,1,2],[28737,88,23,5,0,3,1],[28786,584,62,3,0,1,2],[28790,219,1,3,0,1,2],[28810,483,15,2,0,3,1],[28868,35,20,3,0,1,2],[28874,399,1,11,0,5,4],[28884,219,1,3,0,1,2],[28885,190,38,3,0,4,2],[28888,8,1,1,5,4,1],[28890,67,23,1,5,1,1],[28899,293,2,3,0,1,2],[28903,54,1,3,0,1,2],[28908,61,17,3,0,4,1],[28912,380,5,1,19,8,2],[28924,13,5,3,0,1,2],[28935,173,1,3,0,1,2],[28945,13,5,3,0,1,2],[28946,540,11,3,0,4,2],[29045,312,15,3,0,1,2],[29047,133,44,1,6,8,1],[29048,133,44,1,6,8,1],[29050,133,44,1,6,8,1],[29051,133,44,1,6,8,1],[29052,133,44,1,6,8,1],[29055,27,26,3,0,1,2],[29079,191,6,1,5,5,1],[29107,125,1,1,7,8,1],[29108,92,38,4,0,3,1],[29155,9,1,1,19,4,2],[29162,197,5,3,0,1,2],[29236,219,1,3,0,1,2],[29249,8,1,1,3,4,3],[29252,234,1,2,0,1,1],[29268,96,18,12,5,12,1],[29270,13,5,3,0,1,2],[29272,312,15,3,0,1,2],[29273,190,38,3,0,4,2],[29349,337,18,3,0,5,2],[29479,271,5,5,0,3,1],[29486,399,1,11,0,5,4],[29494,54,1,3,0,1,2],[29495,430,39,3,0,1,2],[29505,88,23,5,0,3,1],[29506,88,23,5,0,3,1],[29520,519,1,3,0,1,2],[29522,162,1,1,19,4,1],[29526,38,5,3,0,3,2],[29601,399,1,11,0,5,4],[29643,637,1,3,0,1,2],[29644,27,1,3,0,1,2],[29658,128,34,1,7,4,1],[29670,61,17,11,0,5,4],[29671,61,17,11,0,5,4],[29709,348,37,12,5,3,1],[29710,95,28,1,5,3,1],[30580,585,5,5,0,8,3],[30776,206,1,2,0,9,1],[30794,526,2,3,0,4,2],[30797,585,5,5,0,8,3],[30798,585,5,5,0,8,3],[31102,35,20,3,0,1,2],[31118,499,67,1,5,3,1],[31119,499,67,3,0,1,1],[31121,499,67,3,0,1,1],[31124,499,67,3,0,1,1],[31125,499,67,3,0,1,1],[31127,499,67,3,0,1,1],[31304,15,1,1,4,3,1],[31306,27,26,3,0,1,2],[31307,221,1,3,0,1,2],[31571,241,1,3,0,1,1],[31573,241,1,3,0,1,1],[31574,241,1,3,0,1,1],[31576,241,1,3,0,1,1],[31598,134,45,1,7,9,1],[31601,61,6,1,0,4,1],[31698,581,34,1,7,12,1],[31701,501,1,1,3,4,1],[31708,501,1,1,3,4,1],[31789,234,1,2,0,1,1],[31792,61,17,1,3,4,1],[31797,128,34,1,7,4,1],[31800,509,23,3,0,12,2],[31862,54,1,3,0,1,2],[32018,539,1,3,0,1,2],[32019,85,15,3,0,1,2],[32060,640,1,1,5,1,1],[32252,430,39,3,0,1,2],[32253,219,1,3,0,1,2],[32258,636,1,3,0,4,2],[32260,399,1,11,0,5,4],[32263,241,1,3,0,1,1],[32264,241,1,3,0,1,1],[32265,241,1,3,0,1,1],[32275,61,17,11,0,5,4],[32276,61,17,11,0,5,4],[32283,128,34,1,7,4,1],[32293,78,16,3,0,1,2],[32294,526,2,3,0,4,2],[32299,27,80,3,0,1,2],[32376,134,45,1,7,7,1],[32378,376,1,3,48,4,3],[32382,474,20,1,7,1,1],[32384,399,1,11,0,5,4],[32388,280,53,3,0,1,2],[32393,61,17,11,0,5,4],[32395,61,17,11,0,5,4],[32404,588,12,3,0,1,2],[32476,190,6,1,7,4,1],[32478,202,17,3,0,1,2],[32487,587,10,3,0,1,2],[32706,8,1,1,3,4,3],[32711,399,1,11,0,5,4],[32729,54,1,3,0,1,2],[32750,128,34,1,7,4,1],[32763,173,1,3,0,1,2],[32767,630,74,3,0,3,2],[32768,78,16,3,0,1,2],[32779,322,23,3,37,3,2],[32783,95,28,1,5,4,1],[32789,151,36,2,0,2,1],[32790,81,20,2,0,8,1],[32791,406,15,2,0,2,1],[32794,29,6,3,0,5,2],[32951,221,1,3,0,1,2],[32954,61,17,3,0,4,1],[32955,61,17,3,0,4,1],[32956,61,17,3,0,4,1],[33051,509,23,3,0,3,2],[33053,211,1,5,0,3,1],[33055,526,2,3,0,4,2],[33056,614,25,3,0,1,2],[33153,221,1,3,0,1,2],[33154,38,5,3,0,3,2],[33207,173,1,3,0,1,2],[33244,128,34,1,7,4,1],[33274,312,15,3,0,1,2],[33275,27,1,3,0,1,2],[33278,293,2,3,0,1,2],[33320,274,23,1,5,3,1],[33321,274,23,1,5,3,1],[33331,236,1,1,5,12,1],[33373,35,20,3,0,1,2],[33376,221,1,3,0,1,2],[33396,586,62,1,5,3,1],[33401,301,1,5,0,3,3],[33408,88,23,5,0,3,1],[33409,88,23,5,0,3,1],[33412,134,45,1,7,7,1],[33433,88,23,1,0,3,1],[33434,117,23,1,14,3,1],[33436,27,26,3,0,1,2],[33446,65,23,1,5,3,1],[33453,120,20,3,0,1,2],[33459,13,5,3,0,1,2],[33460,13,5,3,0,1,2],[33490,8,1,1,3,4,2],[33492,146,15,1,4,3,1],[33496,532,15,2,0,2,1],[33499,334,15,2,0,2,1],[33591,414,1,1,6,3,1],[33595,202,17,3,0,1,2],[33749,27,36,3,0,1,2],[33750,13,5,3,0,1,2],[33751,545,38,2,1,4,3],[33752,545,38,2,1,4,3],[34111,35,20,3,0,1,2],[34710,13,5,3,0,1,2],[34713,636,1,3,0,4,2],[34810,514,45,3,0,5,2],[34839,65,23,1,5,3,1],[34903,560,1,2,0,4,1],[34941,27,26,3,0,1,2],[35362,372,59,3,0,1,2],[35491,244,1,1,19,3,2],[35493,518,1,3,0,1,2],[35496,582,1,3,0,1,2],[35498,61,17,3,0,4,1],[35500,61,17,3,0,4,1],[35681,150,11,1,5,3,1],[35683,616,2,1,5,3,1],[35684,36,22,8,2,1,1],[35685,394,11,7,0,3,1],[35686,36,22,8,2,1,1],[35696,47,23,3,0,1,2],[35752,399,1,11,0,5,4],[35755,312,15,3,0,1,2],[35756,430,39,3,0,1,2],[35815,440,1,1,3,4,2],[35873,410,20,3,0,1,2],[35932,565,27,2,0,2,1],[35935,309,25,2,0,2,1],[35942,25,11,3,5,1,2],[35943,128,34,3,0,4,2],[35946,640,1,1,5,1,1],[35951,162,1,1,19,4,1],[36032,27,36,3,0,1,2],[36033,587,10,3,0,1,2],[36036,528,8,1,4,3,1],[36037,11,8,9,0,3,1],[36088,88,23,5,0,3,1],[36095,61,17,1,3,4,1],[36097,221,1,3,0,1,2],[36101,13,5,3,0,1,2],[36106,299,1,3,0,1,2],[36108,636,1,3,0,4,2],[36110,65,23,1,5,3,1],[36111,61,17,11,0,5,4],[36112,61,17,11,0,5,4],[36121,65,23,1,5,3,1],[36122,648,23,3,0,2,1],[36124,265,55,1,5,4,1],[36131,54,1,3,0,1,2],[36358,61,17,3,0,4,2],[36395,141,1,5,0,3,2],[36397,221,1,3,0,1,2],[36400,61,17,11,0,5,4],[36401,61,17,11,0,5,4],[36402,61,17,11,0,5,4],[36411,244,1,1,19,3,2],[36413,65,23,1,3,3,1],[36414,65,23,1,3,3,1],[36415,65,23,1,3,3,1],[36499,173,1,3,0,1,2],[36508,11,8,1,7,3,1],[36516,27,26,3,0,1,2],[36581,27,26,3,0,1,2],[36582,128,34,3,0,4,2],[36585,399,1,11,0,5,4],[36592,38,5,3,0,3,2],[36596,88,23,2,0,3,1],[36605,571,34,1,7,3,1],[36608,61,6,1,5,4,1],[36744,96,18,13,15,3,2],[36745,38,5,3,0,3,2],[36792,173,1,3,0,1,2],[36795,95,28,1,5,3,1],[36797,16,10,3,2,3,1],[36798,20,14,1,5,3,1],[36828,65,23,11,0,7,2],[36830,409,48,3,0,3,2],[36831,480,5,3,0,1,2],[36834,65,23,1,7,4,1],[36985,322,23,1,5,3,1],[37139,61,17,11,0,5,4],[37150,509,23,3,0,1,2],[37152,61,17,3,0,4,1],[37154,61,17,3,0,4,1],[37158,146,15,23,0,3,2],[37162,8,1,1,7,4,1],[37165,65,23,1,0,4,1],[37166,661,23,5,0,2,1],[37167,661,23,5,0,2,1],[37168,502,1,21,0,4,1],[37179,88,23,1,0,3,1],[37180,88,23,1,0,3,1],[37185,518,1,3,0,1,2],[37188,241,1,3,0,1,1],[37189,241,1,3,0,1,1],[37190,241,1,3,0,1,1],[37191,241,1,3,0,1,1],[37192,241,1,3,0,1,1],[37193,241,1,3,0,1,1],[37207,85,15,3,0,1,2],[37210,65,23,11,0,7,2],[37212,377,17,3,0,4,3],[37214,210,23,1,6,3,1],[37216,134,45,1,7,7,1],[37218,527,1,3,0,1,2],[37224,422,1,5,0,8,1],[37227,208,1,2,0,9,1],[37232,8,1,1,3,4,2],[37234,229,23,3,0,4,2],[37237,289,2,3,0,1,2],[37238,221,1,3,0,1,2],[37256,65,23,11,0,7,2],[37258,13,5,3,0,1,2],[37264,273,11,3,0,1,2],[37265,338,18,3,0,1,2],[37344,180,17,1,6,3,2],[37348,8,1,1,5,4,1],[37364,8,1,2,0,4,1],[37372,61,17,11,0,5,4],[37377,376,1,3,48,4,2],[37384,65,23,11,0,7,2],[37386,501,1,1,3,4,1],[37387,485,87,1,5,3,1],[37388,95,28,5,0,3,1],[37389,641,30,1,5,3,1],[37391,501,1,1,3,4,1],[37392,221,1,3,0,1,2],[37393,18,12,3,0,5,2],[37398,377,17,3,0,4,3],[37481,9,1,1,1,4,2],[37602,35,20,3,0,1,2],[37605,95,28,20,0,3,2],[37606,552,92,3,0,1,2],[37677,509,23,3,0,3,2],[37730,88,23,2,0,3,1],[37737,322,23,3,37,3,2],[37739,241,1,3,0,1,1],[37740,241,1,3,0,1,1],[37741,241,1,3,0,1,1],[37742,241,1,3,0,1,1],[37743,241,1,3,0,1,1],[37744,241,1,3,0,1,1],[37746,95,28,3,0,3,2],[37748,27,26,3,0,1,2],[37749,326,13,3,0,1,2],[37753,399,1,11,0,5,4],[37755,544,5,5,0,3,3],[37763,65,23,11,0,7,2],[37765,88,23,2,0,3,1],[37775,27,26,3,0,1,2],[37776,86,15,3,0,1,2],[37779,438,81,3,0,12,2],[37781,226,23,1,6,3,1],[37789,408,79,1,5,3,1],[37790,408,79,12,5,3,1],[37791,479,25,1,5,3,1],[37792,36,20,3,2,1,1],[37793,199,20,3,2,1,1],[37804,229,23,3,0,4,2],[37809,27,26,3,0,1,2],[37810,38,5,3,0,3,2],[37813,291,15,1,5,3,1],[37816,198,5,3,0,1,2],[37818,566,1,2,0,4,3],[37826,472,1,3,0,1,2],[37834,221,1,3,0,1,2],[37836,13,5,3,0,1,2],[37838,373,73,1,4,3,1],[37841,547,28,2,0,2,1],[37843,628,1,3,0,1,2],[37846,11,8,11,0,1,4],[37847,11,8,11,0,1,4],[37849,419,1,1,6,3,1],[37855,367,1,2,0,2,1],[37867,61,17,11,0,5,4],[37868,61,17,11,0,5,4],[37869,61,17,11,0,5,4],[37875,65,23,1,0,4,1],[37930,117,23,1,14,3,1],[37931,88,23,1,0,3,1],[37933,47,23,3,0,1,2],[37941,65,23,1,3,4,1],[37948,65,23,11,0,7,2],[37951,360,17,3,0,3,2],[37954,291,15,1,7,3,1],[38007,182,38,1,3,4,1],[38008,182,38,1,3,4,1],[38009,182,38,1,3,4,1],[38010,182,38,1,3,4,1],[38011,549,84,1,5,6,1],[38012,453,29,1,5,3,1],[38014,407,79,3,0,1,2],[38038,275,23,1,5,3,1],[38040,241,1,3,0,1,1],[38041,241,1,3,0,1,1],[38042,241,1,3,0,1,1],[38043,241,1,3,0,1,1],[38044,241,1,3,0,1,1],[38045,241,1,3,0,1,1],[38046,275,23,1,5,3,1],[38049,210,23,1,4,3,2],[38070,636,1,3,0,4,2],[38087,27,26,3,0,1,2],[38091,65,23,11,0,7,2],[38093,391,1,3,0,4,2],[38098,221,1,3,0,1,2],[38107,37,23,3,0,1,2],[38109,8,1,1,7,4,1],[38245,18,12,3,0,5,2],[38248,61,28,1,7,4,1],[38250,65,23,11,0,7,4],[38251,65,23,11,0,7,4],[38254,9,1,3,0,4,2],[38256,322,23,1,5,3,1],[38257,65,23,1,7,4,1],[38331,312,15,3,0,1,2],[38332,630,74,3,0,3,2],[38337,146,53,1,4,3,1],[38338,96,18,1,5,12,1],[38339,146,15,2,0,3,1],[38340,282,15,2,0,2,1],[38342,35,20,3,0,1,2],[38352,229,23,3,0,7,2],[38354,65,23,1,5,4,1],[38356,221,1,3,0,1,2],[38358,141,1,5,0,3,1],[38466,376,1,3,48,4,2],[38528,8,1,1,3,4,2],[38551,173,1,3,0,1,2],[38552,380,5,1,19,8,2],[38652,27,26,3,0,1,2],[38707,323,17,1,5,3,1],[38708,74,35,1,5,3,1],[38710,72,34,2,0,1,1],[38730,322,23,3,0,3,2],[38733,61,17,3,0,4,1],[38740,221,1,3,0,1,2],[38741,289,2,3,0,1,2],[38749,221,1,3,0,1,2],[38752,623,1,1,4,3,3],[38753,623,1,1,4,3,3],[38755,546,90,1,5,1,1],[38758,501,1,1,3,4,1],[38759,262,1,2,0,4,1],[38760,10,1,2,0,3,1],[38764,121,1,5,0,2,1],[38766,262,1,2,0,4,1],[38771,381,5,1,19,8,1],[38773,501,1,1,3,4,1],[38775,65,23,11,0,7,4],[38778,27,26,3,0,1,2],[38779,95,28,3,0,3,2],[38782,633,103,1,5,3,1],[38833,399,1,11,0,5,4],[38857,11,8,11,0,1,4],[38858,11,8,11,0,1,4],[38860,463,23,2,0,3,1],[38861,463,23,2,0,3,1],[38867,221,1,3,0,1,2],[38908,200,1,2,0,4,1],[38953,65,23,11,0,7,2],[38977,360,17,3,0,3,2],[38978,652,17,3,0,1,2],[38991,78,16,3,0,1,2],[38992,13,5,3,0,1,2],[38995,377,17,3,0,4,3],[38998,88,23,2,0,3,1],[38999,645,23,2,0,3,1],[39008,173,1,3,0,1,2],[39011,65,23,1,3,4,1],[39012,65,23,1,3,4,1],[39013,65,23,1,3,4,1],[39017,509,23,3,0,3,2],[39019,454,38,1,5,1,1],[39020,13,5,3,0,1,2],[39022,652,17,3,0,1,2],[39030,245,25,1,5,4,1],[39034,526,2,3,0,4,2],[39035,383,46,3,0,6,2],[39057,61,17,3,0,4,1],[39058,61,17,3,0,4,1],[39059,61,17,3,0,4,1],[39061,291,15,1,7,3,1],[39062,291,15,1,5,3,1],[39068,96,18,2,0,3,1],[39070,301,1,3,0,3,2],[39072,241,1,3,0,1,1],[39073,241,1,3,0,1,1],[39074,241,1,3,0,1,1],[39075,241,1,3,0,1,1],[39076,241,1,3,0,1,1],[39077,241,1,3,0,1,1],[39078,25,11,3,0,1,2],[39079,58,31,3,0,3,2],[39084,346,1,1,4,3,1],[39086,373,73,1,4,3,1],[39088,495,20,21,0,4,1],[39089,97,20,21,0,3,1],[39091,612,7,2,0,2,1],[39120,9,1,1,1,4,2],[39122,197,5,3,0,1,2],[39127,35,20,3,0,1,2],[39150,224,23,1,5,3,1],[39155,61,17,11,0,5,4],[39157,509,23,3,0,1,2],[39159,11,8,1,4,3,1],[39160,632,74,1,5,3,1],[39163,13,5,3,0,1,2],[39166,399,1,11,0,5,4],[39168,636,1,3,0,4,2],[39172,27,26,3,0,1,2],[39177,61,17,1,5,4,1],[39186,360,17,1,5,12,1],[39188,423,2,3,0,1,4],[39189,423,2,3,0,1,4],[39190,423,2,3,0,1,4],[39191,423,2,3,0,1,4],[39197,141,1,5,0,3,1],[39199,95,28,23,0,3,2],[39202,88,23,2,0,3,1],[39206,391,1,3,0,4,2],[39208,88,23,2,0,4,1],[39209,117,23,2,0,3,1],[39210,88,23,2,0,3,1],[39215,294,58,7,0,12,2],[39216,95,28,1,6,3,2],[39222,636,1,3,0,4,2],[39227,96,18,1,7,12,1],[39232,8,1,1,5,4,1],[39233,192,5,3,0,1,2],[39234,253,28,3,0,4,2],[39237,29,6,3,0,5,2],[39239,65,23,1,3,4,1],[39240,65,23,1,3,4,1],[39241,65,23,1,3,4,1],[39249,246,17,3,0,1,1],[39250,246,17,3,0,1,1],[39251,246,17,3,0,1,1],[39253,146,15,5,0,3,3],[39256,9,1,3,0,4,2],[39260,210,23,1,6,3,1],[39265,97,20,4,0,3,1],[39285,27,26,3,0,1,2],[39358,88,23,2,0,3,1],[39360,518,1,3,0,1,2],[39375,61,17,3,0,4,2],[39410,65,23,1,5,4,1],[39416,36,20,3,2,1,1],[39417,658,72,29,0,2,1],[39418,164,1,1,5,1,1],[39419,168,12,1,5,3,1],[39420,428,11,2,0,3,1],[39423,639,15,1,5,1,1],[39425,36,22,8,2,1,1],[39430,230,4,2,0,1,1],[39440,138,21,2,0,2,1],[39444,218,36,3,0,2,1],[39451,11,8,1,4,3,1],[39452,11,8,1,4,3,1],[39453,11,8,1,4,3,1],[39455,88,23,1,0,3,1],[39460,27,26,3,0,1,2],[39462,8,1,1,7,4,1],[39467,23,1,2,0,4,1],[39468,529,1,3,0,4,1],[39472,262,1,2,0,4,1],[39473,567,1,2,0,4,1],[39474,262,1,2,0,4,1],[39476,293,2,3,0,1,2],[39481,607,99,3,0,3,2],[39483,61,17,3,0,4,1],[39484,61,17,3,0,4,1],[39485,61,17,3,0,4,1],[39487,202,17,3,0,1,2],[39498,95,28,3,0,3,2],[39500,584,62,3,0,1,2],[39504,141,1,3,0,3,2],[39508,5,5,3,0,1,2],[39509,56,29,3,0,6,2],[39522,614,25,3,0,1,2],[39533,399,1,11,0,5,4],[39574,250,53,1,4,3,1],[39612,202,17,3,0,1,2],[39613,202,17,3,0,1,2],[39616,25,11,3,0,1,2],[39617,27,26,3,0,1,2],[39624,88,23,2,0,3,1],[39634,380,8,1,4,3,1],[39635,95,28,23,0,3,2],[39650,61,6,1,7,4,1],[39652,376,1,1,3,4,2],[39727,360,17,3,0,3,2],[39728,326,13,3,0,1,2],[39731,325,13,1,5,3,1],[39741,399,1,11,0,5,4],[39751,376,1,3,48,4,2],[39761,61,17,3,0,4,1],[39762,61,17,3,0,4,1],[39763,61,17,3,0,4,1],[39766,146,15,1,7,3,1],[39767,619,15,1,5,2,1],[39768,531,15,2,0,1,1],[39769,487,15,1,5,2,1],[39770,406,15,2,0,2,1],[39773,13,5,3,0,1,2],[40001,61,17,11,0,5,4],[40010,325,13,1,5,3,1],[40011,276,15,2,0,3,1],[40013,150,11,1,5,3,1],[40014,89,21,2,0,1,1],[40015,276,15,2,0,3,1],[40018,36,1,8,2,1,1],[40019,36,22,8,2,1,1],[40020,81,20,5,0,2,1],[40037,447,17,3,2,1,1],[40039,447,17,3,2,1,1],[40042,458,85,2,0,2,1],[40043,605,98,1,4,2,1],[40044,352,1,2,0,1,1],[40045,12,1,2,0,1,1],[40046,12,1,2,0,1,1],[40053,546,90,1,5,1,1],[40055,81,20,2,0,8,1],[40056,81,20,2,0,8,1],[40059,141,1,1,4,3,1],[40061,246,17,3,0,1,1],[40062,246,17,3,0,1,1],[40063,246,17,3,0,1,1],[40069,379,17,1,6,3,1],[40070,484,17,1,4,3,1],[40072,164,1,1,5,1,1],[40075,16,10,3,2,3,1],[40079,423,2,3,0,1,4],[40080,423,2,3,0,1,4],[40081,423,2,3,0,1,4],[40082,423,2,3,0,1,4],[40086,432,1,3,0,1,1],[40087,432,1,3,0,1,1],[40091,432,1,3,0,1,1],[40100,255,1,21,24,4,2],[40105,399,1,11,0,5,4],[40107,47,23,3,0,1,2],[40109,65,23,1,5,4,1],[40110,65,23,1,5,4,1],[40111,65,23,1,5,4,1],[40115,640,1,1,53,1,1],[40118,224,23,1,5,3,1],[40119,82,5,5,0,3,1],[40128,11,8,11,0,1,3],[40129,11,8,11,0,1,3],[40136,356,23,2,0,1,1],[40137,117,23,1,14,3,1],[40141,47,23,3,0,1,2],[40143,65,23,1,7,4,1],[40144,598,23,13,2,3,1],[40146,430,39,3,0,1,2],[40147,372,59,3,0,1,2],[40208,124,1,3,0,4,2],[40258,61,17,3,0,4,2],[40261,88,23,2,0,3,1],[40267,270,15,1,6,3,2],[40269,95,28,23,0,3,2],[40271,221,1,3,0,1,2],[40272,44,21,3,0,1,2],[40275,65,23,1,5,4,1],[40277,202,17,3,0,1,2],[40286,88,23,2,0,3,1],[40294,399,1,11,0,5,4],[40296,377,17,3,0,4,3],[40298,49,15,1,5,3,1],[40301,282,15,1,4,2,1],[40305,65,23,1,7,4,1],[40310,65,23,1,5,4,1],[40315,61,17,11,0,5,4],[40332,95,28,3,0,3,2],[40333,54,1,3,0,1,2],[40336,98,41,1,5,3,1],[40338,65,23,1,3,4,1],[40339,65,23,1,3,4,1],[40340,65,23,1,3,4,1],[40344,8,1,1,3,4,3],[40345,652,17,3,0,1,2],[40348,423,2,3,0,1,4],[40349,423,2,3,0,1,4],[40350,423,2,3,0,1,4],[40351,423,2,3,0,1,4],[40358,61,17,1,3,4,1],[40360,360,17,27,33,12,1],[40362,65,23,1,3,4,1],[40364,27,26,3,0,1,2],[40367,210,23,1,4,3,2],[40374,391,1,3,0,4,2],[40376,141,1,1,4,3,1],[40380,135,1,1,4,8,1],[40381,291,15,1,7,3,1],[40384,293,2,3,0,1,2],[40420,61,17,1,3,4,1],[40424,5,5,3,0,1,2],[40425,13,5,3,0,1,2],[40482,141,1,5,0,3,3],[40483,141,1,5,0,3,3],[40484,141,1,5,0,3,3],[40485,141,1,5,0,3,3],[40505,202,17,3,0,1,2],[40534,399,1,11,0,5,4],[40536,96,18,1,5,12,1],[40538,291,15,1,5,3,1],[40544,11,8,11,0,1,4],[40545,11,8,11,0,1,4],[40547,95,28,23,0,3,2],[40549,65,23,11,0,7,2],[40552,246,17,3,0,1,1],[40553,246,17,3,0,1,1],[40554,246,17,3,0,1,1],[40555,61,17,2,0,4,1],[40613,587,10,3,0,1,2],[40614,515,29,3,0,4,2],[40617,613,101,3,0,12,2],[40657,232,1,2,0,3,1],[40663,54,1,3,0,1,2],[40664,521,46,3,0,1,2],[40697,380,8,1,4,3,1],[40699,61,17,1,5,4,1],[40701,229,23,1,5,4,1],[40715,99,2,1,5,1,1],[40716,99,2,1,5,1,1],[40717,99,2,1,5,1,1],[40718,99,2,2,0,1,1],[40730,399,1,11,0,5,4],[40732,380,5,1,19,8,2],[40733,78,16,3,0,1,2],[40746,636,1,3,0,4,2],[40748,65,23,11,0,7,4],[40749,65,23,11,0,7,4],[40874,221,1,3,0,1,2],[40875,198,5,3,0,1,2],[40878,65,23,1,3,4,1],[40880,95,28,3,0,12,2],[40882,293,2,3,0,1,2],[40887,391,1,3,0,4,2],[40889,11,8,11,0,1,4],[40890,11,8,11,0,1,4],[40892,88,23,2,3,3,2],[40894,224,23,1,5,3,1],[40895,202,17,3,0,1,2],[40899,598,23,2,0,3,1],[40901,661,23,2,0,2,1],[40902,661,23,2,0,2,1],[40903,649,23,3,0,2,1],[40904,322,23,2,0,3,1],[40905,333,23,2,0,2,1],[40906,649,23,3,0,2,1],[40907,649,23,3,0,2,1],[40908,354,23,2,0,2,1],[40910,649,23,3,0,2,1],[40911,649,23,3,0,2,1],[40912,149,23,2,0,2,1],[40914,333,23,2,0,2,1],[40920,61,17,3,0,4,1],[40921,61,17,3,0,4,1],[40922,61,17,3,0,4,1],[40925,463,23,2,0,3,1],[40930,53,28,5,0,3,1],[40931,348,37,1,4,3,1],[40932,352,1,1,35,1,1],[40933,352,1,1,35,1,1],[40934,352,1,1,35,1,1],[40935,352,1,1,35,1,1],[40936,199,20,3,2,1,1],[40938,65,23,11,0,7,2],[40940,522,39,3,0,1,2],[40941,44,21,3,0,1,2],[40946,383,46,3,0,3,2],[40958,359,23,1,37,1,1],[40959,315,23,1,31,1,1],[40960,315,23,1,31,1,1],[40961,315,23,1,5,1,1],[40964,501,1,1,3,4,1],[40965,12,1,2,0,1,1],[40966,12,1,2,0,1,1],[40968,73,1,12,5,2,1],[40970,262,1,2,0,4,1],[40972,262,1,2,0,4,1],[40973,461,1,5,0,4,1],[40974,517,1,9,0,1,1],[40975,262,1,2,0,4,1],[40976,461,1,5,0,4,1],[40977,517,1,9,0,1,1],[40981,501,1,1,3,4,1],[40982,37,23,3,0,1,2],[40984,614,25,3,0,1,2],[40988,322,23,1,5,3,1],[41019,399,1,11,0,5,4],[41021,509,23,3,0,12,2],[41026,65,23,1,3,4,1],[41028,95,28,3,0,3,2],[41029,38,5,3,0,3,2],[41032,61,17,1,20,4,3],[41034,347,70,3,0,3,2],[41036,35,20,3,0,1,2],[41038,65,23,1,3,4,1],[41103,509,23,3,0,1,2],[41105,180,17,1,6,3,2],[41121,61,17,3,0,4,2],[41166,626,30,2,0,2,1],[41167,328,30,1,5,2,1],[41168,57,30,2,0,1,1],[41169,578,30,1,5,1,1],[41171,626,30,2,0,2,1],[41173,117,23,5,0,3,1],[41174,11,8,11,0,1,4],[41175,11,8,11,0,1,4],[41179,432,1,3,0,1,1],[41182,432,1,3,0,1,1],[41183,432,1,3,0,1,1],[41184,432,1,3,0,1,1],[41185,432,1,3,0,1,1],[41186,432,1,3,0,1,1],[41187,432,1,3,0,1,1],[41188,432,1,3,0,1,1],[41189,432,1,3,0,1,1],[41191,202,17,3,4,1,2],[41194,224,23,1,5,3,2],[41238,69,35,3,0,3,2],[41240,311,63,1,4,3,1],[41241,95,28,23,0,3,2],[41310,13,5,3,0,1,2],[41328,399,1,11,0,5,4],[41330,61,17,11,0,5,4],[41334,8,1,1,7,4,1],[41335,380,8,1,4,3,1],[41380,27,26,3,0,1,2],[41382,13,5,3,0,1,2],[41384,95,28,23,0,3,2],[41386,360,17,1,0,12,1],[41394,61,17,1,3,4,1],[41434,65,23,11,0,7,2],[41456,380,8,1,4,3,1],[41460,4,4,1,2,2,1],[41464,28,17,5,0,15,1],[41469,95,28,23,0,3,2],[41471,312,15,3,0,1,2],[41473,65,23,1,3,4,1],[41549,11,8,11,0,1,4],[41550,11,8,11,0,1,4],[41552,584,62,3,0,1,2],[41554,61,17,11,0,5,4],[41556,275,23,1,5,3,1],[41579,61,17,4,0,4,1],[41581,221,1,3,0,1,2],[41584,8,1,1,3,4,2],[41586,65,23,11,0,7,2],[41588,5,5,3,0,1,2],[41589,197,5,3,0,1,2],[41591,80,37,3,0,1,2],[41592,173,1,3,0,1,2],[41599,95,28,1,5,3,1],[41601,164,1,1,5,1,1],[41602,239,20,4,0,1,1],[41603,348,37,1,2,3,1],[41604,72,34,1,5,3,1],[41605,368,20,1,2,3,1],[41622,391,1,3,0,4,2],[41634,88,23,2,0,3,1],[41724,376,1,3,48,4,2],[41725,597,23,3,0,3,2],[41727,226,23,1,7,3,1],[41729,312,15,3,0,1,2],[41731,471,23,5,0,3,1],[41744,255,1,21,24,4,2],[41745,255,1,21,24,4,2],[41747,221,1,3,0,1,2],[41748,221,1,3,0,1,2],[41752,95,28,1,6,3,2],[41759,61,6,1,5,4,1],[41770,448,82,1,5,3,1],[41771,164,1,1,5,1,1],[41772,164,1,1,5,1,1],[41773,164,1,1,5,1,1],[41774,164,1,1,5,1,1],[41784,95,28,2,5,3,1],[41785,20,14,1,5,3,1],[41786,20,14,1,5,3,1],[41787,75,1,1,5,1,1],[41788,81,20,2,0,8,1],[41789,24,14,2,0,3,1],[41790,95,28,1,47,3,1],[41793,95,28,3,0,3,2],[41794,522,39,3,0,1,2],[41836,270,15,1,6,3,2],[41838,509,23,2,0,3,2],[41841,322,23,2,0,3,1],[41849,481,1,2,0,3,1],[41850,102,1,2,0,4,1],[41852,12,1,2,0,1,1],[41853,12,1,2,0,1,1],[41854,215,1,2,0,4,1],[41855,215,1,2,0,4,1],[41857,463,23,1,0,3,1],[41859,11,8,11,0,1,4],[41860,11,8,11,0,1,4],[41861,11,8,11,0,1,4],[41862,11,8,11,0,1,4],[41866,244,1,1,19,3,2],[41869,322,23,3,2,3,2],[41871,352,1,1,35,1,1],[41872,352,1,1,35,1,1],[41873,352,1,1,35,1,1],[41874,352,1,1,35,1,1],[41875,245,25,1,5,4,1],[41877,95,28,1,5,3,1],[41879,636,1,3,0,4,2],[41882,210,23,1,6,3,2],[41884,143,1,1,17,3,1],[41885,143,1,1,17,3,1],[41886,143,1,1,17,3,1],[41887,143,1,1,17,3,1],[41888,143,1,1,17,3,1],[41889,143,1,1,17,3,1],[41890,143,1,1,17,3,1],[41891,143,1,1,17,3,1],[41893,174,1,3,0,1,2],[41896,146,15,5,0,3,3],[41898,210,23,1,4,3,1],[41899,112,23,1,13,3,1],[41900,112,23,1,21,3,1],[41901,112,23,1,21,3,1],[41903,312,15,3,0,1,2],[41904,78,16,3,0,1,2],[41907,563,23,1,5,1,1],[41908,563,23,1,5,1,1],[41911,463,23,2,0,3,2],[41913,322,23,2,0,3,1],[41914,315,23,1,5,1,1],[41917,303,1,3,0,12,1],[41918,303,1,3,0,12,1],[41919,303,1,3,0,12,1],[41920,303,1,3,0,12,1],[41921,303,1,3,0,12,1],[41922,303,1,3,0,12,1],[41923,303,1,3,0,12,1],[41924,303,1,3,0,12,1],[41925,303,1,3,0,12,1],[41926,303,1,3,0,12,1],[41937,9,1,1,1,4,2],[41940,61,15,3,0,4,2],[41942,273,11,3,0,1,2],[41944,375,37,3,0,1,2],[41945,523,16,3,0,1,2],[41948,95,28,1,5,3,1],[41949,95,28,1,5,3,1],[41954,95,28,1,5,3,1],[41965,164,1,1,5,1,1],[41966,164,1,1,5,1,1],[41968,164,1,1,5,1,1],[41972,164,1,1,5,1,1],[41975,164,1,1,5,1,1],[41977,164,1,1,5,1,1],[41978,164,1,1,5,1,1],[41988,164,1,1,5,1,1],[41990,164,1,1,5,1,1],[41991,352,1,1,35,1,1],[41992,352,1,1,35,1,1],[41993,352,1,1,35,1,1],[41994,352,1,1,35,1,1],[41995,352,1,1,35,1,1],[41996,352,1,1,35,1,1],[41997,352,1,1,35,1,1],[41998,352,1,1,35,1,1],[42000,157,27,2,0,1,1],[42001,164,1,1,5,1,1],[42009,164,1,1,5,1,1],[42014,164,1,1,5,1,1],[42015,446,36,2,0,3,1],[42018,164,1,1,5,1,1],[42019,164,1,1,5,1,1],[42023,164,1,1,5,1,1],[42026,164,1,1,5,1,1],[42027,164,1,1,5,1,1],[42028,164,1,1,5,1,1],[42029,164,1,1,5,1,1],[42031,164,1,1,5,1,1],[42034,164,1,1,5,1,1],[42041,164,1,1,5,1,1],[42048,164,1,1,5,1,1],[42058,501,1,1,3,4,1],[42061,591,23,12,0,3,1],[42063,380,8,1,4,3,1],[42065,501,1,1,3,4,1],[42070,173,1,3,0,1,2],[42072,291,15,1,7,3,1],[42075,636,1,3,0,4,2],[42432,27,26,3,0,1,2],[42662,88,23,2,0,3,2],[42689,8,1,2,0,4,1],[42691,338,18,3,0,1,2],[42692,506,16,3,0,17,2],[42695,537,28,3,0,3,2],[42698,293,2,3,0,1,2],[42709,27,26,3,0,1,2],[42719,61,17,1,20,4,3],[42738,146,15,23,0,3,2],[42740,628,1,3,0,1,2],[42741,13,5,3,0,1,2],[42747,95,28,3,0,3,2],[42749,173,1,3,0,1,2],[42752,352,1,1,35,1,1],[42753,352,1,1,35,1,1],[42754,352,1,1,35,1,1],[42755,352,1,1,35,1,1],[42758,288,23,5,0,3,1],[42759,662,23,1,32,1,1],[42760,89,21,1,5,1,1],[42761,662,23,1,32,1,1],[42766,412,28,2,0,2,1],[42767,95,28,1,5,3,1],[42769,101,15,2,5,1,1],[42771,352,1,1,35,1,1],[42772,352,1,1,35,1,1],[42773,352,1,1,35,1,1],[42774,352,1,1,35,1,1],[42775,3,3,2,0,2,1],[42777,127,34,2,0,8,1],[42778,370,45,24,0,2,1],[42779,352,1,1,35,1,1],[42780,352,1,1,35,1,1],[42781,352,1,1,35,1,1],[42782,352,1,1,35,1,1],[42783,156,2,2,0,1,1],[42785,156,2,2,0,1,1],[42786,156,2,2,0,1,1],[42792,488,38,4,0,2,1],[42793,118,1,1,6,1,1],[42795,393,1,9,0,1,1],[42801,90,40,3,0,1,2],[42803,303,1,3,0,12,1],[42804,303,1,3,0,12,1],[42805,303,1,3,0,12,1],[42806,303,1,3,0,12,1],[42807,303,1,3,0,12,1],[42808,303,1,3,0,12,1],[42809,303,1,3,0,12,1],[42810,303,1,3,0,12,1],[42811,303,1,3,0,12,1],[42812,303,1,3,0,12,1],[42814,267,57,3,0,1,2],[42815,95,28,3,0,3,2],[42818,221,1,3,0,1,2],[42825,324,17,1,5,3,1],[42826,416,10,26,2,3,1],[42828,416,10,1,2,3,1],[42829,64,34,9,0,2,1],[42831,212,34,18,2,2,1],[42835,639,15,1,5,1,1],[42837,352,1,1,35,1,1],[42838,352,1,1,35,1,1],[42839,352,1,1,35,1,1],[42840,352,1,1,35,1,1],[42841,352,1,1,35,1,1],[42842,352,1,1,35,1,1],[42844,393,1,2,0,1,1],[42845,352,1,1,35,1,1],[42846,131,1,1,0,1,1],[42847,131,1,1,0,1,1],[42848,118,1,1,6,1,1],[42849,118,1,1,6,1,1],[42900,429,45,1,5,4,1],[42901,627,102,1,5,3,1],[42907,61,17,3,0,5,2],[42915,141,1,3,0,3,2],[42917,146,15,23,0,3,2],[42920,214,44,1,5,3,1],[42921,435,1,9,0,4,1],[42922,155,1,2,0,4,1],[42934,25,11,3,0,1,2],[42939,61,17,11,0,5,4],[42941,8,1,1,3,4,3],[42942,47,23,3,0,1,2],[42945,65,23,1,3,4,1],[42946,65,23,1,3,4,1],[42947,65,23,1,3,4,1],[42949,376,1,3,48,4,2],[42950,221,1,3,0,1,2],[42951,85,15,3,0,1,2],[42954,633,103,1,5,3,1],[42955,303,1,3,0,12,1],[42956,303,1,3,0,12,1],[42957,303,1,3,0,12,1],[42958,303,1,3,0,12,1],[42959,303,1,3,0,12,1],[42960,303,1,3,0,12,1],[42961,303,1,3,0,12,1],[42962,303,1,3,0,12,1],[42963,303,1,3,0,12,1],[42964,303,1,3,0,12,1],[42965,146,15,23,0,3,2],[42967,505,26,3,0,1,2],[42969,380,8,1,4,3,1],[42982,330,1,12,5,4,1],[42984,338,18,3,0,1,2],[42987,164,1,1,5,1,1],[42988,164,1,1,5,1,1],[42989,164,1,1,5,1,1],[42990,164,1,1,5,1,1],[42991,164,1,1,5,1,1],[42992,164,1,1,5,1,1],[42997,164,1,1,5,1,1],[43001,65,23,11,0,7,4],[43002,65,23,11,0,7,4],[43005,387,75,1,5,3,1],[43010,210,23,1,6,3,1],[43011,264,23,1,2,1,1],[43013,414,1,1,6,3,1],[43014,87,39,2,0,9,1],[43017,476,1,2,8,2,1],[43022,315,23,1,5,1,1],[43023,315,23,1,5,1,1],[43024,315,23,1,5,1,1],[43028,65,23,1,3,4,1],[43029,65,23,1,3,4,1],[43030,65,23,1,3,4,1],[43032,61,17,1,3,4,1],[43034,229,23,1,5,4,1],[43039,20,14,3,0,3,2],[43041,352,1,1,35,1,1],[43045,352,1,1,35,1,1],[43047,352,1,1,35,1,1],[43048,352,1,1,35,1,1],[43050,305,1,2,0,3,1],[43051,352,1,1,35,1,1],[43053,352,1,1,35,1,1],[43054,352,1,1,35,1,1],[43055,11,8,11,0,1,4],[43056,11,8,11,0,1,4],[43057,11,8,11,0,1,4],[43058,11,8,11,0,1,4],[43065,146,15,1,0,3,1],[43070,303,1,3,0,12,1],[43071,303,1,3,0,12,1],[43072,303,1,3,0,12,1],[43073,303,1,3,0,12,1],[43074,303,1,3,0,12,1],[43075,303,1,3,0,12,1],[43076,303,1,3,0,12,1],[43077,303,1,3,0,12,1],[43078,303,1,3,0,12,1],[43079,303,1,3,0,12,1],[43080,229,23,1,5,4,1],[43081,65,23,1,3,4,1],[43082,65,23,1,3,4,1],[43083,65,23,1,3,4,1],[43099,563,23,1,5,1,1],[43100,563,23,1,5,1,1],[43107,65,23,11,0,7,4],[43108,65,23,11,0,7,4],[43111,95,28,1,5,3,1],[43113,35,20,3,35,1,1],[43114,290,3,1,7,1,1],[43115,100,2,2,0,1,1],[43116,95,28,2,0,3,1],[43118,131,1,1,0,1,1],[43123,352,1,1,35,1,1],[43124,352,1,1,35,1,1],[43125,352,1,1,35,1,1],[43126,352,1,1,35,1,1],[43127,51,1,2,0,1,1],[43130,43,1,2,0,1,1],[43131,386,1,1,4,15,1],[43143,118,1,1,6,1,1],[43144,393,1,9,0,1,1],[43145,8,1,1,7,4,1],[43146,229,23,1,5,4,1],[43152,49,15,1,7,3,1],[43155,602,23,2,0,1,1],[43156,287,23,2,0,2,1],[43157,329,2,3,30,1,1],[43158,602,23,2,0,1,1],[43159,315,23,1,5,1,1],[43160,315,23,1,5,1,1],[43162,9,1,1,1,4,2],[43165,352,1,1,35,1,1],[43167,352,1,1,35,1,1],[43169,634,23,2,0,3,1],[43170,65,23,1,3,4,1],[43171,65,23,1,3,4,1],[43172,65,23,1,3,4,1],[43174,18,12,3,0,1,2],[43175,27,26,3,0,1,2],[43178,249,26,3,0,6,2],[43180,324,17,1,5,3,1],[43181,324,17,1,5,3,1],[43182,352,1,1,35,1,1],[43183,352,1,1,35,1,1],[43184,352,1,1,35,1,1],[43185,352,1,1,35,1,1],[43186,64,34,2,0,2,1],[43187,64,34,2,0,2,1],[43188,64,34,2,0,2,1],[43189,64,34,2,0,2,1],[43192,209,23,2,0,1,1],[43194,660,104,4,0,3,1],[43195,89,21,1,21,1,1],[43196,230,4,2,0,3,1],[43197,230,4,1,0,4,1],[43199,507,23,2,0,2,1],[43204,89,21,1,21,1,1],[43207,65,23,11,0,7,4],[43208,65,23,11,0,7,4],[43215,444,11,1,7,5,1],[43223,291,15,1,5,3,1],[43226,244,1,1,19,3,2],[43228,273,11,3,0,1,2],[43231,423,2,3,0,1,4],[43232,423,2,3,0,1,4],[43233,423,2,3,0,1,4],[43234,423,2,3,0,1,4],[43236,229,23,1,5,4,1],[43245,65,23,11,0,7,4],[43246,65,23,11,0,7,4],[43249,303,1,3,0,12,1],[43250,303,1,3,0,12,1],[43251,303,1,3,0,12,1],[43252,303,1,3,0,12,1],[43253,303,1,3,0,12,1],[43254,303,1,3,0,12,1],[43255,303,1,3,0,12,1],[43256,303,1,3,0,12,1],[43257,303,1,3,0,12,1],[43258,303,1,3,0,12,1],[43259,225,23,1,5,3,1],[43260,225,23,1,5,3,1],[43262,225,23,1,5,3,1],[43271,562,15,3,0,17,2],[43272,289,2,3,0,1,2],[43275,65,23,1,3,4,1],[43276,65,23,1,3,4,1],[43277,65,23,1,3,4,1],[43279,634,23,2,0,3,1],[43286,95,28,23,0,3,2],[43339,9,1,3,0,4,2],[43432,61,17,3,0,5,2],[43435,141,1,5,0,3,3],[43437,380,8,1,4,3,1],[43439,663,23,1,21,1,1],[43440,662,23,1,32,1,1],[43441,663,23,1,21,1,1],[43442,663,23,1,21,1,1],[43443,663,23,1,21,1,1],[43446,102,1,2,0,4,2],[43450,37,23,3,0,1,2],[43461,225,23,1,21,3,1],[43463,59,32,3,0,3,2],[43476,251,54,1,4,3,1],[43477,251,54,1,4,3,1],[43478,303,1,3,0,12,1],[43479,303,1,3,0,12,1],[43480,303,1,3,0,12,1],[43481,303,1,3,0,12,1],[43482,303,1,3,0,12,1],[43484,225,23,1,5,3,1],[43485,365,23,1,5,2,1],[43488,27,26,3,0,1,2],[43491,210,23,1,6,3,2],[43495,291,15,1,7,3,1],[43508,61,17,11,0,5,4],[43518,403,23,2,0,3,1],[43519,403,23,2,0,3,1],[43529,439,81,1,5,3,1],[43530,439,81,1,5,3,1],[43539,65,23,11,0,7,2],[43547,580,1,5,0,2,1],[43556,12,1,2,0,1,1],[43557,12,1,2,0,1,1],[43558,352,1,1,35,1,1],[43559,352,1,1,35,1,1],[43560,352,1,1,35,1,1],[43561,352,1,1,35,1,1],[43562,35,20,3,0,1,2],[43564,11,8,11,0,1,4],[43565,11,8,11,0,1,4],[43566,11,8,11,0,1,4],[43567,11,8,11,0,1,4],[43569,303,1,3,0,12,1],[43570,303,1,3,0,12,1],[43571,303,1,3,0,12,1],[43572,303,1,3,0,12,1],[43573,303,1,3,0,12,1],[43574,303,1,3,0,12,1],[43575,303,1,3,0,12,1],[43576,303,1,3,0,12,1],[43577,303,1,3,0,12,1],[43578,303,1,3,0,12,1],[43581,65,23,11,0,7,4],[43582,65,23,11,0,7,4],[43585,224,23,1,5,3,3],[43587,375,37,3,0,1,2],[43600,11,8,1,4,3,1],[43602,65,23,11,0,7,4],[43603,65,23,11,0,7,4],[43609,226,23,1,6,3,1],[43611,579,20,3,0,1,2],[43613,141,1,4,27,3,1],[43616,181,1,5,0,10,1],[43618,99,2,1,5,1,1],[43619,418,2,1,7,12,1],[43622,65,23,11,0,7,4],[43623,65,23,11,0,7,4],[43632,58,31,3,0,3,2],[43633,280,53,3,0,1,2],[43636,104,23,2,0,1,1],[43641,493,21,1,7,3,1],[43642,65,23,1,3,4,1],[43647,65,23,11,0,7,4],[43648,65,23,11,0,7,4],[43651,9,1,3,0,4,2],[43655,226,23,1,4,3,1],[43656,572,23,3,0,1,1],[43657,61,17,1,3,4,1],[43662,108,42,1,6,3,1],[43668,593,23,3,0,1,1],[43669,111,23,2,0,1,1],[43672,247,15,1,4,3,1],[43676,168,12,1,5,3,1],[43677,282,15,5,0,2,1],[43678,159,15,1,5,2,1],[43683,65,23,11,0,7,2],[43687,61,17,11,0,5,4],[43689,381,5,1,19,8,1],[43690,118,1,1,6,1,1],[43694,103,39,2,0,1,1],[43695,352,1,1,35,1,1],[43696,103,39,2,0,1,1],[43697,352,1,1,35,1,1],[43698,95,28,3,0,3,2],[43700,193,38,3,0,5,2],[43706,65,23,11,0,7,4],[43707,65,23,11,0,7,4],[43710,88,23,5,0,3,1],[43712,603,23,2,0,3,1],[43713,314,23,3,30,1,1],[43717,387,75,1,5,3,1],[43719,95,28,1,21,3,1],[43720,283,1,3,0,1,1],[43721,205,52,1,5,4,1],[43722,103,39,2,0,1,1],[43723,164,1,1,5,1,1],[43724,164,1,1,5,1,1],[43725,164,1,1,5,1,1],[43729,329,20,3,0,1,1],[43730,75,1,1,5,1,1],[43731,352,1,1,35,1,1],[43732,352,1,1,35,1,1],[43733,164,1,1,5,1,1],[43737,119,1,1,6,1,1],[43738,295,59,2,0,3,1],[43741,164,1,1,5,1,1],[43742,164,1,1,5,1,1],[43743,482,3,1,21,1,1],[43744,269,36,2,0,1,1],[43745,352,1,1,35,1,1],[43746,352,1,1,35,1,1],[43747,164,1,1,5,1,1],[43751,61,17,3,0,4,1],[43752,61,17,3,0,4,1],[43753,61,17,3,0,4,1],[43759,443,1,3,30,1,1],[43764,456,1,3,0,4,1],[43765,263,1,22,3,1,1],[43767,131,1,1,0,1,1],[43768,17,11,1,2,1,1],[43769,164,1,1,5,1,1],[43770,30,1,3,0,2,1],[43772,462,1,2,0,2,1],[43773,462,1,2,0,2,1],[43774,269,36,2,0,1,1],[43775,203,28,3,8,1,1],[43780,388,34,2,0,8,1],[43783,327,13,1,5,1,1],[43785,433,1,2,0,4,1],[43790,172,45,1,2,1,1],[43792,195,8,16,0,2,1],[43793,136,1,5,0,2,1],[43794,263,1,22,3,1,1],[43795,433,1,2,0,4,1],[43796,79,1,2,0,1,1],[43797,164,1,1,5,1,1],[43799,263,1,22,3,1,1],[43800,290,3,1,12,1,1],[43802,164,1,1,5,1,1],[43803,321,65,3,8,2,1],[43804,3,3,2,0,2,1],[43805,19,13,2,0,2,1],[43807,72,34,2,0,3,1],[43808,456,1,3,0,4,1],[43809,103,39,3,0,1,1],[43811,405,18,5,0,3,1],[43812,75,1,1,5,1,1],[43813,79,1,2,0,1,1],[43815,206,1,2,0,9,1],[43816,538,1,3,0,1,1],[43817,538,1,3,0,1,1],[43818,538,1,3,0,1,1],[43819,200,1,2,0,4,1],[43820,504,1,1,13,2,1],[43822,200,1,1,5,4,1],[43823,235,18,1,6,3,2],[43824,95,28,3,0,3,2],[43831,500,67,1,5,3,1],[43833,500,67,1,5,3,1],[43836,345,23,2,30,1,1],[43837,345,23,2,30,1,1],[43838,345,23,2,30,1,1],[43839,345,23,2,30,1,1],[43840,345,23,2,30,1,1],[43841,345,23,2,30,1,1],[43842,345,23,2,30,1,1],[43849,12,1,2,0,1,1],[43850,508,1,2,0,3,1],[43853,105,1,5,0,3,1],[43855,116,1,2,0,8,1],[43856,307,1,5,0,8,1],[43857,147,1,15,0,2,1],[43858,139,1,2,0,2,1],[43860,200,1,5,0,4,1],[43861,12,1,2,0,1,1],[43864,254,28,3,0,4,2],[43866,137,38,1,13,4,1],[43867,61,17,3,0,5,2],[43871,279,23,2,0,1,1],[43873,399,1,11,0,5,4],[43874,88,23,2,0,3,2],[43876,324,17,1,5,3,1],[43877,324,17,1,5,3,1],[43879,306,33,3,29,1,1],[43880,534,34,29,0,2,1],[43881,144,34,2,0,1,1],[43882,352,1,1,35,1,1],[43883,352,1,1,35,1,1],[43884,352,1,1,35,1,1],[43885,352,1,1,35,1,1],[43886,352,1,1,35,1,1],[43887,352,1,1,35,1,1],[43888,352,1,1,35,1,1],[43889,352,1,1,35,1,1],[43890,252,15,1,13,1,1],[43893,164,1,1,5,1,1],[43899,164,1,1,5,1,1],[43902,164,1,1,5,1,1],[43905,164,1,1,5,1,1],[43906,164,1,1,5,1,1],[43907,658,72,1,33,2,1],[43908,364,11,3,0,10,1],[43909,463,23,1,4,3,1],[43910,463,23,1,4,3,1],[43911,463,23,1,4,3,1],[43912,463,23,1,4,3,1],[43913,463,23,1,4,3,1],[43914,278,23,3,0,3,1],[43915,463,23,1,4,3,1],[43917,88,23,2,0,3,2],[43920,509,23,3,0,12,2],[43922,303,1,3,0,12,1],[43923,303,1,3,0,12,1],[43924,303,1,3,0,12,1],[43925,303,1,3,0,12,1],[43926,303,1,3,0,12,1],[43927,303,1,3,0,12,1],[43928,303,1,3,0,12,1],[43929,303,1,3,0,12,1],[43930,303,1,3,0,12,1],[43931,303,1,3,0,12,1],[43932,146,15,2,0,3,1],[43934,486,15,1,5,2,1],[43935,385,74,1,0,3,1],[43937,406,15,3,8,2,1],[43941,8,1,1,5,4,1],[43942,357,23,1,0,1,1],[43943,315,23,1,13,1,1],[43944,111,23,2,0,1,1],[43946,315,23,1,13,1,1],[44034,266,56,3,0,13,2],[44035,95,28,3,0,3,2],[44047,178,48,1,13,3,1],[44048,413,37,3,0,1,2],[44057,427,2,3,0,1,1],[44058,427,2,3,0,1,1],[44059,427,2,3,0,1,1],[44060,427,2,3,0,1,1],[44061,427,2,3,0,1,1],[44062,427,2,3,0,1,1],[44067,509,23,3,0,12,2],[44071,636,1,3,0,4,2],[44072,460,45,1,21,3,1],[44076,322,23,3,0,3,2],[44078,185,28,1,3,4,1],[44080,164,1,1,5,1,1],[44084,352,1,1,35,1,1],[44085,352,1,1,35,1,1],[44086,352,1,1,35,1,1],[44087,352,1,1,35,1,1],[44088,164,1,1,5,1,1],[44090,164,1,1,5,1,1],[44095,164,1,1,5,1,1],[44098,164,1,1,5,1,1],[44103,17,11,1,2,1,1],[44105,76,9,2,0,1,1],[44109,76,9,2,0,1,1],[44112,423,2,3,0,1,4],[44113,423,2,3,0,1,4],[44114,423,2,3,0,1,4],[44115,423,2,3,0,1,4],[44186,38,5,3,0,1,2],[44204,65,23,11,0,7,2],[44207,322,23,1,5,3,1],[44209,322,23,1,5,3,1],[44226,541,80,2,0,4,1],[44229,262,1,2,7,4,1],[44231,65,23,11,0,7,2],[44233,61,28,1,7,4,1],[44299,61,17,11,0,5,4],[44307,652,17,3,0,1,2],[44310,315,23,1,5,1,1],[44311,593,23,3,0,1,1],[44312,88,23,1,6,3,1],[44313,111,23,2,0,1,1],[44314,88,23,1,6,3,1],[44315,599,23,2,0,1,1],[44316,599,23,2,0,1,1],[44322,97,20,1,7,3,1],[44323,97,20,1,7,3,1],[44324,97,20,1,7,3,1],[44333,54,1,3,0,1,2],[44334,13,5,3,0,1,2],[44337,65,23,11,0,7,2],[44339,462,1,2,0,2,1],[44340,420,1,2,0,16,1],[44341,436,1,2,0,1,1],[44343,132,44,1,6,3,1],[44344,102,1,2,0,4,4],[44347,206,1,2,0,9,3],[44349,132,44,1,6,3,1],[44350,132,44,1,6,3,1],[44351,132,44,1,6,3,1],[44353,132,44,1,6,3,1],[44354,442,1,2,0,4,3],[44356,424,1,2,0,4,3],[44358,132,44,1,6,3,1],[44360,353,1,2,35,2,3],[44365,437,46,1,5,4,1],[44367,75,1,1,5,1,1],[44387,379,17,1,6,3,1],[44388,176,47,2,0,2,1],[44389,290,3,1,7,1,1],[44390,290,3,1,7,1,1],[44391,421,6,2,0,1,1],[44392,28,17,5,0,2,1],[44394,28,17,5,0,2,1],[44396,352,1,1,35,1,1],[44399,99,2,2,0,1,1],[44400,534,34,2,0,2,1],[44402,352,1,1,35,1,1],[44403,352,1,1,35,1,1],[44404,28,17,5,0,2,1],[44405,352,1,1,35,1,1],[44406,362,33,2,0,1,1],[44407,352,1,1,35,1,1],[44408,179,1,2,0,1,1],[44409,352,1,1,35,1,1],[44411,352,1,1,35,1,1],[44412,64,34,2,0,2,1],[44414,352,1,1,35,1,1],[44415,398,48,2,0,3,1],[44419,310,62,3,0,2,1],[44421,61,17,2,0,4,1],[44422,61,17,2,0,4,1],[44423,61,17,2,0,4,1],[44424,61,17,2,0,4,1],[44449,65,23,1,3,4,1],[44450,65,23,1,3,4,1],[44451,65,23,1,3,4,1],[44453,377,17,3,0,4,3],[44457,61,17,3,0,5,2],[44475,177,8,3,0,1,2],[44476,221,1,3,0,1,2],[44479,29,6,3,0,5,2],[44481,9,1,3,0,4,2],[44482,9,1,2,0,4,3],[44486,467,23,1,5,1,1],[44487,593,23,3,0,1,1],[44488,643,23,1,5,1,1],[44495,83,38,1,11,1,1],[44497,445,1,2,0,1,1],[44498,445,1,2,0,1,1],[44499,75,1,1,5,1,1],[44506,399,1,11,0,5,4],[44517,61,17,4,0,4,1],[44519,111,23,2,5,1,1],[44520,117,23,5,0,3,1],[44528,275,23,1,21,3,1],[44529,320,23,1,33,2,1],[44534,662,23,1,32,1,1],[44536,662,23,1,21,1,1],[44537,662,23,1,21,1,1],[44538,662,23,1,21,1,1],[44539,662,23,1,21,1,1],[44542,65,23,11,0,7,4],[44543,65,23,11,0,7,4],[44552,61,17,1,20,4,3],[44622,224,23,1,5,3,1],[44624,13,5,3,0,1,2],[44625,382,1,25,0,1,2],[44628,141,1,5,7,3,1],[44634,131,1,9,0,1,1],[44637,88,23,2,0,3,2],[44703,228,23,1,5,3,1],[44704,548,91,1,5,3,1],[44705,284,23,3,0,1,1],[44706,601,42,2,0,1,1],[44709,65,23,11,0,7,2],[44777,315,23,1,13,1,1],[44779,411,23,1,0,1,1],[44780,411,23,1,0,1,1],[44781,411,23,1,0,1,1],[44782,411,23,1,0,1,1],[44783,411,23,1,0,1,1],[44785,335,34,2,0,1,1],[44786,335,34,2,0,1,1],[44793,65,23,11,0,7,4],[44794,65,23,11,0,7,4],[44797,61,17,14,0,4,1],[44800,61,48,3,0,5,2],[44801,293,2,3,0,1,2],[44804,95,28,1,5,3,1],[44806,378,1,2,0,3,1],[44808,164,1,1,13,1,1],[44809,164,1,1,13,1,1],[44811,164,1,1,13,1,1],[44812,164,1,1,13,1,1],[44814,164,1,1,13,1,1],[44816,164,1,1,13,1,1],[44818,164,1,1,13,1,1],[44819,224,23,1,7,3,1],[44824,21,15,4,0,1,1],[44835,61,17,14,0,4,1],[44836,315,23,1,5,1,1],[44838,264,23,1,2,1,1],[44839,264,23,1,2,1,1],[44840,593,23,3,0,1,1],[44841,593,23,3,0,1,1],[44842,111,23,2,0,1,1],[44843,111,23,2,0,1,1],[44850,61,17,11,0,5,4],[44852,615,6,2,0,1,1],[44853,615,6,2,0,1,1],[44855,352,1,1,35,1,1],[44856,470,15,1,7,1,1],[44857,61,28,1,7,4,1],[44859,1,1,1,1,1,1],[44860,352,1,1,35,1,1],[44861,352,1,1,35,1,1],[44863,352,1,1,35,1,1],[44864,65,23,11,0,7,4],[44865,65,23,11,0,7,4],[44868,313,64,3,0,1,2],[44873,134,45,1,7,7,1],[44874,11,8,5,0,3,1],[44876,33,8,3,0,3,1],[44877,204,8,5,0,3,1],[44879,594,23,1,0,1,1],[44880,196,51,1,0,3,1],[44881,595,23,2,0,2,1],[44883,98,41,1,5,3,1],[44903,180,17,1,6,3,2],[44905,246,17,3,0,1,1],[44906,246,17,3,0,1,1],[44907,246,17,3,0,1,1],[44910,278,23,2,0,3,2],[44978,463,23,3,0,3,2],[45016,315,23,1,5,1,1],[45017,89,21,1,0,1,1],[45018,89,21,1,0,1,1],[45019,593,23,3,0,1,1],[45024,654,23,3,0,1,1],[45026,95,28,3,0,3,2],[45027,13,5,3,0,1,2],[45110,8,1,2,0,4,1],[45114,12,1,2,0,4,1],[45116,12,1,2,0,4,1],[45117,12,1,2,0,4,1],[45121,123,1,2,0,10,1],[45125,200,1,2,0,4,1],[45131,427,2,3,0,1,1],[45132,427,2,3,0,1,1],[45133,427,2,3,0,1,1],[45134,427,2,3,0,1,1],[45135,427,2,3,0,1,1],[45136,427,2,3,0,1,1],[45137,427,2,3,0,1,1],[45138,427,2,3,0,1,1],[45139,427,2,3,0,1,1],[45140,427,2,3,0,1,1],[45141,427,2,3,0,1,1],[45142,427,2,3,0,1,1],[45143,427,2,3,0,1,1],[45144,427,2,3,0,1,1],[45145,427,2,3,0,1,1],[45146,427,2,3,0,1,1],[45147,427,2,3,0,1,1],[45148,427,2,3,0,1,1],[45149,427,2,3,0,1,1],[45150,427,2,3,0,1,1],[45151,427,2,3,0,1,1],[45152,427,2,3,0,1,1],[45153,427,2,3,0,1,1],[45154,427,2,3,0,1,1],[45155,427,2,3,0,1,1],[45156,427,2,3,0,1,1],[45157,427,2,3,0,1,1],[45158,427,2,3,0,1,1],[45159,427,2,3,0,1,1],[45160,427,2,3,0,1,1],[45161,427,2,3,0,1,1],[45162,427,2,3,0,1,1],[45163,427,2,3,0,1,1],[45164,427,2,3,0,1,1],[45165,291,15,1,5,3,1],[45245,312,15,3,0,1,2],[45246,235,18,1,6,3,2],[45249,403,23,2,0,3,1],[45250,403,23,2,0,3,1],[45251,403,23,2,0,3,1],[45253,403,23,2,0,3,1],[45254,377,17,3,0,4,3],[45263,466,24,5,0,2,1],[45264,536,1,5,0,3,1],[45344,65,23,11,0,7,2],[45358,61,17,11,0,5,4],[45424,427,2,3,0,1,1],[45425,427,2,3,0,1,1],[45426,427,2,3,0,1,1],[45427,427,2,3,0,1,1],[45428,427,2,3,0,1,1],[45429,427,2,3,0,1,1],[45430,427,2,3,0,1,1],[45431,427,2,3,0,1,1],[45432,427,2,3,0,1,1],[45433,427,2,3,0,1,1],[45434,427,2,3,0,1,1],[45435,427,2,3,0,1,1],[45436,427,2,3,0,1,1],[45437,427,2,3,0,1,1],[45438,427,2,3,0,1,1],[45439,427,2,3,0,1,1],[45440,427,2,3,0,1,1],[45441,427,2,3,0,1,1],[45442,427,2,3,0,1,1],[45443,427,2,3,0,1,1],[45444,427,2,3,0,1,1],[45445,427,2,3,0,1,1],[45446,427,2,3,0,1,1],[45447,427,2,3,0,1,1],[45448,427,2,3,0,1,1],[45449,427,2,3,0,1,1],[45450,427,2,3,0,1,1],[45451,427,2,3,0,1,1],[45452,427,2,3,0,1,1],[45453,427,2,3,0,1,1],[45454,427,2,3,0,1,1],[45455,427,2,3,0,1,1],[45456,427,2,3,0,1,1],[45457,427,2,3,0,1,1],[45460,65,23,1,3,4,1],[45461,65,23,1,3,4,1],[45462,65,23,1,3,4,1],[45465,9,1,3,0,4,2],[45602,644,23,3,0,3,1],[45603,644,23,3,0,3,1],[45605,366,1,3,0,1,1],[45606,350,1,2,0,4,1],[45608,61,17,1,20,4,3],[45610,206,1,2,0,9,1],[45611,403,23,2,0,3,1],[45612,403,23,2,0,3,1],[45624,264,23,1,2,1,1],[45625,224,23,1,5,3,1],[45721,226,23,1,6,3,1],[45723,8,1,10,0,4,1],[45724,8,1,10,0,4,1],[45725,8,1,10,0,4,1],[45726,32,1,6,0,2,1],[45727,473,39,2,0,16,1],[45788,164,1,1,5,1,1],[45789,164,1,1,5,1,1],[45790,164,1,1,5,1,1],[45794,224,23,1,5,3,1],[45795,661,23,2,0,2,1],[45796,264,23,1,2,1,1],[45807,65,23,11,0,7,2],[45854,399,1,11,0,5,4],[45856,227,23,1,13,3,1],[45859,88,23,5,0,3,1],[45860,61,6,1,5,4,1],[45863,37,23,3,0,1,2],[45873,8,1,1,5,4,1],[45874,8,1,1,5,4,1],[45875,8,1,1,5,4,1],[45876,8,1,1,5,4,1],[45916,152,1,2,0,2,1],[45920,31,18,3,0,4,2],[45939,275,23,1,5,3,1],[45941,593,23,2,0,1,1],[45985,202,17,3,0,1,2],[45986,202,17,3,0,1,2],[46025,224,23,1,5,3,1],[46026,333,23,4,0,2,1],[46088,75,1,1,5,1,1],[46089,75,1,1,5,1,1],[46112,85,15,3,0,1,2],[46113,382,1,25,0,1,2],[46114,221,1,3,0,1,2],[46179,164,1,1,5,1,1],[46180,164,1,1,5,1,1],[46200,263,1,22,3,1,1],[46232,224,23,1,5,3,1],[46233,598,23,2,0,4,1],[46234,169,23,2,0,4,1],[46235,164,1,1,5,1,1],[46265,493,21,1,7,3,1],[46266,243,1,4,0,1,1],[46267,393,1,9,0,1,1],[46272,89,21,1,5,1,1],[46273,194,8,1,2,3,1],[46274,302,45,9,0,1,1],[46275,55,1,3,0,1,1],[46276,620,11,2,0,2,1],[46277,400,77,1,40,2,1],[46278,239,20,4,0,1,1],[46279,516,8,4,0,3,1],[46280,609,77,2,0,2,1],[46281,11,8,2,0,3,1],[46284,157,27,5,0,1,1],[46285,164,1,1,5,1,1],[46286,164,1,1,5,1,1],[46288,164,1,1,5,1,1],[46289,164,1,1,5,1,1],[46290,164,1,1,5,1,1],[46291,164,1,1,5,1,1],[46292,217,8,1,0,3,1],[46293,217,8,1,0,3,1],[46294,393,1,2,0,1,1],[46295,393,1,2,0,1,1],[46296,164,1,1,5,1,1],[46297,164,1,1,5,1,1],[46298,352,1,1,35,1,1],[46299,352,1,1,35,1,1],[46312,611,100,1,1,2,1],[46313,164,1,1,5,1,1],[46314,164,1,1,5,1,1],[46315,352,1,1,35,1,1],[46316,352,1,1,35,1,1],[46317,352,1,1,35,1,1],[46318,352,1,1,35,1,1],[46319,329,20,3,0,1,1],[46320,397,62,1,5,4,1],[46396,224,23,1,5,3,3],[46454,315,23,1,5,1,1],[46455,315,23,1,5,1,1],[46456,315,23,1,5,1,1],[46457,315,23,1,5,1,1],[46458,315,23,1,5,1,1],[46459,315,23,1,5,1,1],[46460,315,23,1,32,1,1],[46461,315,23,1,32,1,1],[46462,315,23,1,32,1,1],[46469,226,23,1,4,3,1],[46478,275,23,1,0,3,1],[46479,275,23,1,0,3,1],[46486,246,17,3,0,1,1],[46487,246,17,3,0,1,1],[46488,246,17,3,0,1,1],[46489,384,12,1,4,2,1],[46490,653,17,5,0,2,1],[46491,653,17,5,0,2,1],[46492,343,2,3,30,1,1],[46493,28,17,5,0,2,1],[46494,415,17,5,0,2,1],[46495,64,34,5,0,2,1],[46496,290,3,1,7,1,1],[46497,290,3,1,7,1,1],[46498,329,20,3,0,1,1],[46499,329,20,3,0,1,1],[46500,352,1,1,35,1,1],[46501,352,1,1,35,1,1],[46502,352,1,1,35,1,1],[46503,352,1,1,35,1,1],[46504,404,34,9,0,1,1],[46505,404,34,9,0,1,1],[46506,404,34,9,0,1,1],[46507,404,34,9,0,1,1],[46527,164,1,1,5,1,1],[46528,164,1,1,5,1,1],[46529,164,1,1,5,1,1],[46597,164,1,1,5,1,1],[46609,164,1,1,5,1,1],[46610,224,23,1,5,3,2],[46621,164,1,1,5,1,1],[46735,164,1,1,5,1,1],[46737,164,1,1,5,1,1],[46738,164,1,1,5,1,1],[46805,61,17,11,0,5,4],[46807,65,23,1,3,4,1],[46808,65,23,1,3,4,1],[46809,65,23,1,3,4,1],[46810,593,23,2,0,1,1],[46812,164,1,1,5,1,1],[46813,164,1,1,5,1,1],[46814,164,1,1,5,1,1],[46815,164,1,1,5,1,1],[46816,164,1,1,5,1,1],[46817,164,1,1,5,1,1],[46818,101,15,9,5,1,1],[46819,164,1,1,5,1,1],[46820,164,1,1,5,1,1],[46821,164,1,1,5,1,1],[46822,164,1,1,5,1,1],[46825,164,1,1,5,1,1],[46826,399,1,11,0,5,4],[46827,89,21,1,0,1,1],[46828,89,21,1,0,1,1],[46829,89,21,1,0,1,1],[46830,89,21,1,0,1,1],[46831,89,21,1,0,1,1],[46832,89,21,1,0,1,1],[46833,89,21,1,0,1,1],[46835,89,21,1,0,1,1],[46836,89,21,1,0,1,1],[46840,89,21,1,0,1,1],[46904,593,23,2,0,1,1],[46905,61,28,1,7,4,1],[46906,340,26,1,34,1,1],[46907,340,26,1,34,1,1],[46908,352,1,1,35,1,1],[46909,352,1,1,35,1,1],[46910,352,1,1,35,1,1],[46911,352,1,1,35,1,1],[46912,340,26,1,34,1,1],[46914,340,26,1,34,1,1],[46916,597,23,3,0,3,2],[46918,376,1,1,0,4,4],[46926,352,1,1,35,1,1],[46931,131,1,1,0,1,1],[46941,83,38,1,11,1,1],[46943,83,38,1,11,1,1],[46984,380,8,1,4,3,1],[47202,146,15,3,0,3,2],[47226,190,12,1,5,4,1],[47227,246,17,3,0,1,1],[47228,246,17,3,0,1,1],[47229,246,17,3,0,1,1],[47230,190,17,2,0,4,1],[47231,224,23,1,22,3,1],[47234,117,23,5,0,3,1],[47235,117,23,5,0,3,1],[47237,8,1,1,3,4,2],[47253,559,15,1,7,1,1],[47256,95,28,3,0,3,2],[47258,427,2,3,0,1,1],[47259,427,2,3,0,1,1],[47260,427,2,3,0,1,1],[47261,427,2,3,0,1,1],[47262,427,2,3,0,1,1],[47263,427,2,3,0,1,1],[47264,427,2,3,0,1,1],[47265,427,2,3,0,1,1],[47266,427,2,3,0,1,1],[47267,427,2,3,0,1,1],[47268,427,2,3,0,1,1],[47269,427,2,3,0,1,1],[47270,427,2,3,0,1,1],[47271,427,2,3,0,1,1],[47272,427,2,3,0,1,1],[47273,427,2,3,0,1,1],[47274,427,2,3,0,1,1],[47275,427,2,3,0,1,1],[47276,427,2,3,0,1,1],[47277,427,2,3,0,1,1],[47278,427,2,3,0,1,1],[47279,427,2,3,0,1,1],[47280,427,2,3,0,1,1],[47281,427,2,3,0,1,1],[47282,427,2,3,0,1,1],[47283,427,2,3,0,1,1],[47284,427,2,3,0,1,1],[47285,427,2,3,0,1,1],[47286,427,2,3,0,1,1],[47287,427,2,3,0,1,1],[47288,427,2,3,0,1,1],[47289,427,2,3,0,1,1],[47290,427,2,3,0,1,1],[47291,427,2,3,0,1,1],[47292,427,2,3,0,1,1],[47293,427,2,3,0,1,1],[47294,376,1,10,0,4,1],[47295,376,1,10,0,4,1],[47296,593,23,2,0,1,1],[47297,272,23,1,7,1,1],[47298,403,23,9,0,3,1],[47299,656,23,2,0,2,1],[47300,196,51,1,5,3,1],[47302,65,23,1,7,4,1],[47305,137,38,1,13,4,1],[47306,614,25,3,0,1,2],[47309,93,1,2,0,2,1],[47310,457,1,1,46,8,1],[47317,450,1,2,0,8,1],[47318,450,1,2,0,8,1],[47319,201,1,5,0,8,1],[47320,91,1,2,0,8,1],[47321,597,23,3,0,3,2],[47346,242,34,3,0,1,1],[47429,538,1,3,0,1,1],[47430,52,27,3,0,1,1],[47438,621,38,9,0,2,1],[47440,14,9,3,0,1,1],[47441,46,25,1,5,2,1],[47445,533,34,5,0,2,1],[47446,329,20,3,0,1,1],[47448,451,34,2,0,3,1],[47450,352,1,1,35,1,1],[47452,164,1,1,5,1,1],[47453,352,1,1,35,1,1],[47454,329,20,3,0,1,1],[47456,164,1,1,5,1,1],[47457,352,1,1,35,1,1],[47459,538,1,3,0,1,1],[47461,329,20,3,0,1,1],[47462,164,1,1,5,1,1],[47463,622,1,9,0,8,1],[47465,538,1,3,0,1,1],[47467,215,1,2,0,4,1],[47469,164,1,1,5,1,1],[47471,164,1,1,5,1,1],[47472,164,1,1,5,1,1],[47473,164,1,1,5,1,1],[47474,164,1,1,5,1,1],[47475,164,1,1,5,1,1],[47476,329,20,3,0,1,1],[47477,164,1,1,5,1,1],[47478,164,1,1,5,1,1],[47483,622,1,9,0,8,1],[47484,39,1,2,0,2,1],[47485,470,15,1,7,1,1],[47486,329,20,3,0,1,1],[47487,52,27,3,0,1,1],[47488,52,27,3,0,1,1],[47490,164,1,1,5,1,1],[47491,164,1,1,5,1,1],[47492,164,1,1,5,1,1],[47493,352,1,1,35,1,1],[47494,164,1,1,5,1,1],[47495,164,1,1,5,1,1],[47496,164,1,1,5,1,1],[47498,164,1,1,5,1,1],[47500,164,1,1,5,1,1],[47501,329,20,3,0,1,1],[47502,263,1,22,3,1,1],[47503,329,20,3,0,1,1],[47505,263,1,22,3,1,1],[47506,290,3,1,7,1,1],[47507,290,3,1,7,1,1],[47508,263,1,22,3,1,1],[47509,239,20,4,0,1,1],[47510,290,3,1,7,1,1],[47511,352,1,1,35,1,1],[47514,164,1,1,5,1,1],[47515,164,1,1,5,1,1],[47516,164,1,1,5,1,1],[47517,52,27,3,0,1,1],[47518,164,1,1,5,1,1],[47519,52,27,3,0,1,1],[47521,164,1,1,5,1,1],[47522,164,1,1,5,1,1],[47523,164,1,1,5,1,1],[47524,622,1,9,0,8,1],[47525,352,1,1,35,1,1],[47526,141,1,2,0,3,1],[47527,164,1,1,5,1,1],[47528,164,1,1,5,1,1],[47529,352,1,1,35,1,1],[47530,164,1,1,5,1,1],[47531,329,20,3,0,1,1],[47532,65,23,1,3,4,1],[47533,65,23,1,3,4,1],[47536,65,23,1,3,4,1],[47538,352,1,1,35,1,1],[47539,164,1,1,5,1,1],[47540,164,1,1,5,1,1],[47541,269,36,2,0,1,1],[47542,164,1,1,5,1,1],[47543,164,1,1,5,1,1],[47544,164,1,1,5,1,1],[47545,164,1,1,5,1,1],[47546,61,17,1,3,4,1],[47612,164,1,1,5,1,1],[47613,463,23,3,0,3,2],[47615,39,1,2,0,2,1],[47616,39,1,2,0,2,1],[47617,164,1,1,5,1,1],[47684,164,1,1,5,1,1],[47685,164,1,1,5,1,1],[47686,164,1,1,5,1,1],[47687,164,1,1,5,1,1],[47688,164,1,1,5,1,1],[47691,65,23,1,3,4,1],[47693,65,23,1,3,4,1],[47695,65,23,1,3,4,1],[47698,164,1,1,5,1,1],[47699,26,16,1,5,3,1],[47701,491,46,2,0,2,1],[47719,42,17,1,6,3,3],[47851,88,23,2,0,3,2],[47854,65,23,1,3,4,1],[47856,65,23,1,3,4,1],[47857,65,23,1,3,4,1],[47932,96,18,1,13,3,1],[47933,252,15,1,5,1,1],[47934,252,15,1,5,1,1],[47935,252,15,1,5,1,1],[47936,252,15,1,5,1,1],[47937,6,6,2,0,3,1],[47938,464,18,5,0,2,1],[47939,6,6,2,0,3,1],[47940,161,12,1,0,3,1],[47941,638,45,3,0,2,1],[47942,269,36,2,0,1,1],[47943,6,6,2,0,3,1],[47944,184,30,2,0,1,1],[47948,343,69,3,30,1,1],[47949,329,20,3,0,1,1],[47950,336,18,5,0,2,1],[47953,395,16,1,4,8,1],[47954,341,67,2,0,2,1],[47955,329,20,3,0,1,1],[47956,109,43,2,0,1,1],[47957,64,34,9,0,2,1],[47961,497,11,3,30,1,1],[47965,625,1,1,6,2,1],[47966,393,1,9,0,1,1],[47967,473,39,2,0,16,1],[47968,393,1,9,0,1,1],[47969,256,1,1,0,4,1],[47971,75,1,1,5,1,1],[47972,449,83,2,0,1,1],[48042,427,2,3,0,1,1],[48043,427,2,3,0,1,1],[48044,427,2,3,0,1,1],[48045,427,2,3,0,1,1],[48046,427,2,3,0,1,1],[48047,427,2,3,0,1,1],[48048,427,2,3,0,1,1],[48049,427,2,3,0,1,1],[48050,427,2,3,0,1,1],[48051,427,2,3,0,1,1],[48052,427,2,3,0,1,1],[48053,427,2,3,0,1,1],[48054,427,2,3,0,1,1],[48055,427,2,3,0,1,1],[48056,427,2,3,0,1,1],[48057,427,2,3,0,1,1],[48058,427,2,3,0,1,1],[48059,427,2,3,0,1,1],[48060,427,2,3,0,1,1],[48061,427,2,3,0,1,1],[48062,427,2,3,0,1,1],[48063,427,2,3,0,1,1],[48064,427,2,3,0,1,1],[48065,427,2,3,0,1,1],[48066,427,2,3,0,1,1],[48067,427,2,3,0,1,1],[48068,427,2,3,0,1,1],[48069,427,2,3,0,1,1],[48070,427,2,3,0,1,1],[48071,427,2,3,0,1,1],[48072,427,2,3,0,1,1],[48073,427,2,3,0,1,1],[48074,427,2,3,0,1,1],[48075,427,2,3,0,1,1],[48076,427,2,3,0,1,1],[48077,427,2,3,0,1,1],[48079,224,23,1,5,3,1],[48083,64,34,9,0,2,1],[48084,64,34,9,0,2,1],[48085,64,34,9,0,2,1],[48157,88,23,2,0,3,1],[48210,427,2,3,0,1,1],[48211,427,2,3,0,1,1],[48212,427,2,3,0,1,1],[48213,427,2,3,0,1,1],[48214,427,2,3,0,1,1],[48215,427,2,3,0,1,1],[48216,427,2,3,0,1,1],[48217,427,2,3,0,1,1],[48218,427,2,3,0,1,1],[48219,427,2,3,0,1,1],[48220,427,2,3,0,1,1],[48221,427,2,3,0,1,1],[48222,427,2,3,0,1,1],[48223,427,2,3,0,1,1],[48224,427,2,3,0,1,1],[48225,427,2,3,0,1,1],[48226,427,2,3,0,1,1],[48227,427,2,3,0,1,1],[48228,427,2,3,0,1,1],[48229,427,2,3,0,1,1],[48230,427,2,3,0,1,1],[48231,427,2,3,0,1,1],[48232,427,2,3,0,1,1],[48233,427,2,3,0,1,1],[48234,427,2,3,0,1,1],[48235,427,2,3,0,1,1],[48236,427,2,3,0,1,1],[48237,427,2,3,0,1,1],[48238,427,2,3,0,1,1],[48239,427,2,3,0,1,1],[48240,427,2,3,0,1,1],[48241,427,2,3,0,1,1],[48242,427,2,3,0,1,1],[48243,427,2,3,0,1,1],[48244,427,2,3,0,1,1],[48245,427,2,3,0,1,1],[48247,8,1,1,5,4,1],[48248,468,23,1,7,3,1],[48249,468,23,1,5,3,1],[48250,216,23,2,0,3,1],[48251,261,23,2,0,1,1],[48252,593,23,2,0,1,1],[48253,401,23,2,0,1,1],[48254,316,23,1,0,1,1],[48255,316,23,1,0,1,1],[48256,569,23,2,0,1,1],[48261,226,23,1,4,3,1],[48268,455,38,1,5,1,1],[48269,352,1,1,35,1,1],[48270,14,9,3,0,1,1],[48271,393,1,9,0,1,1],[48272,416,10,1,2,3,1],[48273,352,1,1,35,1,1],[48275,589,23,5,0,3,1],[48340,65,23,1,5,4,1],[48423,65,23,1,3,4,1],[48424,65,23,1,3,4,1],[48425,65,23,1,3,4,1],[48426,593,23,2,0,1,1],[48605,94,1,1,12,1,1],[48606,393,1,2,0,1,1],[48618,9,1,1,1,4,2],[48767,427,2,3,0,1,1],[48768,427,2,3,0,1,1],[48769,427,2,3,0,1,1],[48770,427,2,3,0,1,1],[48771,427,2,3,0,1,1],[48772,427,2,3,0,1,1],[48773,427,2,3,0,1,1],[48774,427,2,3,0,1,1],[48775,427,2,3,0,1,1],[48776,427,2,3,0,1,1],[48777,427,2,3,0,1,1],[48778,427,2,3,0,1,1],[48779,427,2,3,0,1,1],[48780,427,2,3,0,1,1],[48781,427,2,3,0,1,1],[48782,427,2,3,0,1,1],[48783,427,2,3,0,1,1],[48784,427,2,3,0,1,1],[48785,427,2,3,0,1,1],[48786,427,2,3,0,1,1],[48787,427,2,3,0,1,1],[48788,427,2,3,0,1,1],[48789,427,2,3,0,1,1],[48790,427,2,3,0,1,1],[48791,427,2,3,0,1,1],[48792,427,2,3,0,1,1],[48793,427,2,3,0,1,1],[48794,427,2,3,0,1,1],[48795,427,2,3,0,1,1],[48796,427,2,3,0,1,1],[48797,427,2,3,0,1,1],[48798,427,2,3,0,1,1],[48799,427,2,3,0,1,1],[48800,427,2,3,0,1,1],[48801,427,2,3,0,1,1],[48802,427,2,3,0,1,1],[48808,210,23,1,6,3,2],[48838,518,1,3,0,1,2],[48840,66,23,1,5,1,1],[48841,401,23,5,0,1,1],[48842,259,23,1,4,8,1],[48844,426,1,21,44,4,1],[48846,8,1,2,0,4,1],[48847,8,1,2,0,4,1],[48848,8,1,2,0,4,1],[48850,477,1,1,13,2,1],[48859,399,1,11,0,5,4],[48860,65,23,1,3,4,1],[48861,65,23,1,3,4,1],[48863,65,23,1,3,4,1],[48864,593,23,2,0,1,1],[48865,61,17,1,45,4,1],[48872,557,95,1,0,1,1],[48873,557,95,1,0,1,1],[48874,126,1,3,0,4,1],[48875,126,1,3,0,4,1],[48876,61,36,1,10,3,1],[48877,260,1,3,5,4,1],[48878,257,1,1,0,4,1],[48882,538,1,3,0,1,1],[48883,538,1,3,0,1,1],[48884,538,1,3,0,1,1],[48885,352,1,1,35,1,1],[48886,538,1,3,0,1,1],[48887,538,1,3,0,1,1],[48888,538,1,3,0,1,1],[48889,538,1,3,0,1,1],[48890,538,1,3,0,1,1],[48891,538,1,3,0,1,1],[48892,393,1,9,0,1,1],[48893,538,1,3,0,1,1],[48894,538,1,3,0,1,1],[48895,538,1,3,0,1,1],[48896,538,1,3,0,1,1],[48897,538,1,3,0,1,1],[48898,393,1,9,0,1,1],[48899,538,1,3,0,1,1],[48900,64,34,9,0,2,1],[48901,141,1,1,6,3,1],[48902,355,1,3,5,1,1],[48903,355,1,3,5,1,1],[48904,538,1,3,0,1,1],[48905,89,21,1,43,1,1],[48906,617,1,1,50,1,1],[48907,200,1,2,0,4,1],[48909,141,1,9,0,3,1],[48910,243,1,4,0,1,1],[48911,650,1,30,0,1,1],[48914,290,3,1,12,1,1],[48915,651,1,3,0,1,1],[48916,290,3,1,12,1,1],[48917,290,3,1,12,1,1],[48918,290,3,1,12,1,1],[48919,89,21,1,0,1,1],[48920,89,21,1,0,1,1],[48921,89,21,1,0,1,1],[48922,145,46,1,5,3,1],[48923,352,1,1,35,1,1],[48924,207,2,17,0,1,1],[48925,352,1,1,35,1,1],[48926,340,26,1,3,1,1],[48927,352,1,1,35,1,1],[48928,437,46,1,5,4,1],[48929,352,1,1,35,1,1],[48930,340,26,1,3,1,1],[48931,263,1,22,3,1,1],[48938,366,1,3,0,1,1],[48942,340,26,1,3,1,1],[48943,469,68,3,0,2,1],[48948,263,1,22,3,1,1],[48950,340,26,1,3,1,1],[48951,573,1,2,0,1,1],[48952,52,27,3,0,1,1],[48953,45,24,1,5,1,1],[48954,52,27,3,0,1,1],[48955,52,27,3,0,1,1],[48957,263,1,22,3,1,1],[48959,352,1,1,35,1,1],[48960,52,27,3,0,1,1],[48961,52,27,3,0,1,1],[48962,238,12,1,0,3,1],[48963,397,62,1,5,4,1],[48964,543,40,2,0,1,1],[48965,11,8,2,0,3,1],[48966,402,11,1,0,3,1],[48967,427,2,3,0,1,1],[48968,427,2,3,0,1,1],[48970,427,2,3,0,1,1],[48971,427,2,3,0,1,1],[48972,427,2,3,0,1,1],[48973,427,2,3,0,1,1],[48974,427,2,3,0,1,1],[48975,427,2,3,0,1,1],[48976,427,2,3,0,1,1],[48977,427,2,3,0,1,1],[48978,427,2,3,0,1,1],[48979,427,2,3,0,1,1],[48980,427,2,3,0,1,1],[48981,427,2,3,0,1,1],[48982,427,2,3,0,1,1],[48984,427,2,3,0,1,1],[48985,427,2,3,0,1,1],[48986,427,2,3,0,1,1],[48987,427,2,3,0,1,1],[48988,427,2,3,0,1,1],[48989,427,2,3,0,1,1],[48990,427,2,3,0,1,1],[48991,427,2,3,0,1,1],[48992,427,2,3,0,1,1],[48993,427,2,3,0,1,1],[48994,427,2,3,0,1,1],[48995,427,2,3,0,1,1],[48996,427,2,3,0,1,1],[48997,427,2,3,0,1,1],[48998,427,2,3,0,1,1],[48999,427,2,3,0,1,1],[49000,427,2,3,0,1,1],[49001,427,2,3,0,1,1],[49002,427,2,3,0,1,1],[49003,315,23,1,5,1,1],[49004,315,23,1,5,1,1],[49005,315,23,1,5,1,1],[49006,315,23,1,5,1,1],[49007,643,23,1,5,1,1],[49008,210,23,1,6,3,1],[49011,322,23,3,0,3,2],[49013,200,1,2,0,4,1],[49014,256,1,1,0,4,1],[49015,200,1,2,0,4,1],[49016,154,12,2,0,2,1],[49018,411,23,1,41,1,1],[49019,411,23,1,41,1,1],[49020,411,23,1,41,1,1],[49021,411,23,1,41,1,1],[49022,411,23,1,41,1,1],[49026,65,23,1,3,4,1],[49027,593,23,2,0,1,1],[49028,65,23,1,3,4,1],[49029,65,23,1,3,4,1],[49049,322,23,1,5,3,1],[49052,102,1,2,0,4,1],[49055,78,16,3,0,1,2],[49056,13,5,3,0,1,2],[49059,335,34,2,0,1,1],[49060,335,34,2,0,1,1],[49062,509,23,3,0,12,2],[49066,83,38,1,11,1,1],[49067,475,86,9,0,1,1],[49068,11,8,5,0,3,1],[49069,351,45,2,0,2,1],[49070,455,38,1,5,1,1],[49071,322,23,1,5,3,1],[49072,322,23,1,5,3,1],[49075,427,2,3,0,1,1],[49076,427,2,3,0,1,1],[49077,427,2,3,0,1,1],[49078,427,2,3,0,1,1],[49079,427,2,3,0,1,1],[49080,427,2,3,0,1,1],[49081,427,2,3,0,1,1],[49082,427,2,3,0,1,1],[49083,427,2,3,0,1,1],[49084,427,2,3,0,1,1],[49085,427,2,3,0,1,1],[49086,427,2,3,0,1,1],[49087,427,2,3,0,1,1],[49088,427,2,3,0,1,1],[49089,427,2,3,0,1,1],[49090,427,2,3,0,1,1],[49091,427,2,3,0,1,1],[49092,427,2,3,0,1,1],[49093,427,2,3,0,1,1],[49094,427,2,3,0,1,1],[49095,427,2,3,0,1,1],[49096,427,2,3,0,1,1],[49097,427,2,3,0,1,1],[49098,427,2,3,0,1,1],[49099,427,2,3,0,1,1],[49100,427,2,3,0,1,1],[49101,427,2,3,0,1,1],[49102,427,2,3,0,1,1],[49103,427,2,3,0,1,1],[49104,427,2,3,0,1,1],[49105,427,2,3,0,1,1],[49106,427,2,3,0,1,1],[49107,427,2,3,0,1,1],[49108,427,2,3,0,1,1],[49112,403,23,3,0,1,1],[49114,403,23,3,0,1,1],[49115,463,23,3,0,3,2],[49122,224,23,1,21,3,1],[49125,509,23,3,0,3,2],[49186,427,2,3,0,1,1],[49187,427,2,3,0,1,1],[49188,427,2,3,0,1,1],[49189,427,2,3,0,1,1],[49190,427,2,3,0,1,1],[49191,427,2,3,0,1,1],[49192,427,2,3,0,1,1],[49193,427,2,3,0,1,1],[49194,427,2,3,0,1,1],[49195,427,2,3,0,1,1],[49196,427,2,3,0,1,1],[49197,427,2,3,0,1,1],[49198,427,2,3,0,1,1],[49199,427,2,3,0,1,1],[49200,427,2,3,0,1,1],[49201,427,2,3,0,1,1],[49202,427,2,3,0,1,1],[49203,427,2,3,0,1,1],[49204,427,2,3,0,1,1],[49205,427,2,3,0,1,1],[49206,427,2,3,0,1,1],[49207,427,2,3,0,1,1],[49208,427,2,3,0,1,1],[49209,427,2,3,0,1,1],[49210,427,2,3,0,1,1],[49211,427,2,3,0,1,1],[49212,427,2,3,0,1,1],[49213,427,2,3,0,1,1],[49214,427,2,3,0,1,1],[49215,427,2,3,0,1,1],[49216,427,2,3,0,1,1],[49217,427,2,3,0,1,1],[49218,427,2,3,0,1,1],[49219,427,2,3,0,1,1],[49256,315,23,1,5,1,1],[49260,346,1,1,4,3,1],[49261,107,1,2,0,4,1],[49262,107,1,2,0,4,1],[49263,142,1,5,0,3,1],[49264,141,1,4,0,3,1],[49279,427,2,3,0,1,1],[49280,427,2,3,0,1,1],[49281,427,2,3,0,1,1],[49282,427,2,3,0,1,1],[49283,427,2,3,0,1,1],[49284,427,2,3,0,1,1],[49285,427,2,3,0,1,1],[49286,427,2,3,0,1,1],[49287,427,2,3,0,1,1],[49288,427,2,3,0,1,1],[49289,427,2,3,0,1,1],[49290,427,2,3,0,1,1],[49291,427,2,3,0,1,1],[49292,427,2,3,0,1,1],[49293,427,2,3,0,1,1],[49294,427,2,3,0,1,1],[49295,427,2,3,0,1,1],[49296,427,2,3,0,1,1],[49297,427,2,3,0,1,1],[49298,427,2,3,0,1,1],[49299,427,2,3,0,1,1],[49300,427,2,3,0,1,1],[49301,427,2,3,0,1,1],[49302,427,2,3,0,1,1],[49303,427,2,3,0,1,1],[49304,427,2,3,0,1,1],[49305,427,2,3,0,1,1],[49306,427,2,3,0,1,1],[49307,427,2,3,0,1,1],[49308,427,2,3,0,1,1],[49309,427,2,3,0,1,1],[49310,427,2,3,0,1,1],[49311,427,2,3,0,1,1],[49312,427,2,3,0,1,1],[49313,427,2,3,0,1,1],[49314,427,2,3,0,1,1],[49315,114,23,5,0,3,1],[49317,596,23,2,0,1,1],[49319,264,23,1,2,1,1],[49321,550,23,2,0,2,1],[49322,264,23,1,2,1,1],[49323,114,23,9,0,2,1],[49324,316,23,1,5,1,1],[49325,317,23,2,0,2,1],[49330,278,23,2,0,3,2],[49332,27,26,3,0,1,2],[49333,190,38,3,0,4,2],[49336,146,15,23,0,3,2],[49338,315,23,1,5,1,1],[49383,65,23,1,3,4,1],[49384,65,23,1,3,4,1],[49387,117,23,1,21,3,1],[49390,65,23,1,5,4,1],[49391,65,23,1,5,4,1],[49392,65,23,1,5,4,1],[49395,146,15,9,0,3,1],[49396,577,15,2,0,2,1],[49397,50,15,2,0,2,1],[49398,166,15,9,0,1,1],[49399,657,15,2,0,1,1],[49400,268,15,9,0,2,1],[49402,339,15,2,0,2,1],[49403,41,15,2,0,2,1],[49464,106,38,1,3,4,1],[49465,106,38,1,3,4,1],[49466,106,38,1,3,4,1],[49469,75,1,1,5,1,1],[49470,75,1,1,5,1,1],[49492,224,23,1,5,3,3],[49495,227,23,1,7,3,1],[49501,88,23,2,0,3,1],[49503,61,17,1,20,4,3],[49505,509,23,3,0,1,2],[49809,11,8,11,0,1,4],[49810,11,8,11,0,1,4],[49812,590,23,1,1,2,1],[49813,60,23,2,0,2,1],[49814,60,23,10,0,2,1],[49815,319,23,1,5,1,1],[49816,319,23,1,5,1,1],[49817,558,1,9,0,4,2],[49818,350,1,2,0,4,2],[49949,75,1,1,5,1,1],[49950,75,1,1,5,1,1],[49954,141,1,5,0,3,1],[49961,88,23,1,3,3,1],[49962,88,23,1,3,3,1],[50001,202,17,3,0,1,2],[50002,202,17,3,0,1,2],[50005,322,23,3,0,3,2],[50212,614,25,3,0,1,2],[50319,293,2,3,0,1,2],[50321,88,23,2,0,3,2],[50322,88,23,2,0,3,2],[50469,427,2,3,0,1,1],[50470,427,2,3,0,1,1],[50471,427,2,3,0,1,1],[50472,427,2,3,0,1,1],[50473,427,2,3,0,1,1],[50474,427,2,3,0,1,1],[50475,427,2,3,0,1,1],[50476,427,2,3,0,1,1],[50477,427,2,3,0,1,1],[50478,427,2,3,0,1,1],[50479,427,2,3,0,1,1],[50480,427,2,3,0,1,1],[50481,427,2,3,0,1,1],[50482,427,2,3,0,1,1],[50483,427,2,3,0,1,1],[50484,427,2,3,0,1,1],[50485,427,2,3,0,1,1],[50486,427,2,3,0,1,1],[50487,427,2,3,0,1,1],[50488,427,2,3,0,1,1],[50489,427,2,3,0,1,1],[50490,427,2,3,0,1,1],[50491,427,2,3,0,1,1],[50492,427,2,3,0,1,1],[50493,427,2,3,0,1,1],[50494,427,2,3,0,1,1],[50495,427,2,3,0,1,1],[50496,427,2,3,0,1,1],[50497,427,2,3,0,1,1],[50498,427,2,3,0,1,1],[50499,427,2,3,0,1,1],[50500,427,2,3,0,1,1],[50501,427,2,3,0,1,1],[50502,427,2,3,0,1,1],[50503,427,2,3,0,1,1],[50504,427,2,3,0,1,1],[50986,617,1,1,7,1,1],[50992,164,1,1,5,1,1],[50993,164,1,1,5,1,1],[50995,371,72,1,2,3,1],[50996,164,1,1,5,1,1],[50997,164,1,1,5,1,1],[50998,574,6,3,8,1,1],[50999,248,1,1,23,1,1],[51000,164,1,1,5,1,1],[51002,329,20,3,0,1,1],[51003,164,1,1,5,1,1],[51004,164,1,1,5,1,1],[51005,164,1,1,5,1,1],[51006,164,1,1,5,1,1],[51007,164,1,1,5,1,1],[51008,290,3,1,12,1,1],[51009,164,1,1,5,1,1],[51010,164,1,1,5,1,1],[51011,164,1,1,5,1,1],[51013,618,2,2,0,1,1],[51014,498,6,2,0,2,1],[51015,164,1,1,5,1,1],[51016,164,1,1,5,1,1],[51017,164,1,1,5,1,1],[51018,164,1,1,5,1,1],[51019,574,6,3,8,1,1],[51020,164,1,1,5,1,1],[51021,352,1,1,35,1,1],[51022,352,1,1,35,1,1],[51023,164,1,1,5,1,1],[51024,164,1,1,5,1,1],[51026,164,1,1,5,1,1],[51027,164,1,1,5,1,1],[51028,164,1,1,5,1,1],[51029,164,1,1,5,1,1],[51030,513,85,1,49,3,1],[51031,492,89,3,8,2,1],[51032,618,2,2,0,1,1],[51033,574,6,3,8,1,1],[51035,164,1,1,5,1,1],[51036,352,1,1,35,1,1],[51037,164,1,1,5,1,1],[51038,371,72,1,2,3,1],[51039,164,1,1,5,1,1],[51040,164,1,1,5,1,1],[51041,164,1,1,5,1,1],[51042,164,1,1,5,1,1],[51043,164,1,1,5,1,1],[51045,164,1,1,5,1,1],[51046,164,1,1,5,1,1],[51047,164,1,1,5,1,1],[51048,164,1,1,5,1,1],[51049,164,1,1,5,1,1],[51050,371,72,1,2,3,1],[51051,329,20,3,0,1,1],[51052,164,1,1,5,1,1],[51054,352,1,1,35,1,1],[51056,164,1,1,5,1,1],[51057,329,20,3,0,1,1],[51058,352,1,1,35,1,1],[51059,329,20,3,0,1,1],[51062,574,6,3,8,1,1],[51063,574,6,3,8,1,1],[51064,164,1,1,5,1,1],[51065,164,1,1,5,1,1],[51066,164,1,1,5,1,1],[51068,574,6,3,8,1,1],[51069,618,2,2,0,1,1],[51070,290,3,1,12,1,1],[51071,94,1,1,12,1,1],[51072,94,1,1,12,1,1],[51073,574,6,3,8,1,1],[51075,8,1,2,0,4,1],[51077,8,1,2,0,4,1],[51082,618,2,2,0,1,1],[51083,574,6,3,8,1,1],[51084,163,1,2,0,11,1],[51087,557,95,1,0,1,1],[51088,557,95,1,0,1,1],[51094,441,1,2,0,3,1],[51095,576,1,2,0,3,1],[51096,441,1,2,0,3,1],[51097,231,1,3,0,5,1],[51098,557,95,1,0,1,1],[51099,555,94,2,0,1,1],[51102,88,23,2,0,3,1],[51280,255,1,21,24,4,2],[51281,255,1,21,24,4,2],[51284,363,23,1,38,3,1],[51444,134,45,1,7,7,1],[51445,8,1,1,5,4,1],[51511,61,17,1,7,4,4],[51622,427,2,3,0,1,1],[51623,427,2,3,0,1,1],[51624,427,2,3,0,1,1],[51625,427,2,3,0,1,1],[51626,427,2,3,0,1,1],[51627,427,2,3,0,1,1],[51628,427,2,3,0,1,1],[51629,427,2,3,0,1,1],[51630,427,2,3,0,1,1],[51631,427,2,3,0,1,1],[51632,427,2,3,0,1,1],[51633,427,2,3,0,1,1],[51634,427,2,3,0,1,1],[51635,427,2,3,0,1,1],[51636,427,2,3,0,1,1],[51637,427,2,3,0,1,1],[51638,427,2,3,0,1,1],[51639,427,2,3,0,1,1],[51640,427,2,3,0,1,1],[51641,427,2,3,0,1,1],[51642,427,2,3,0,1,1],[51643,427,2,3,0,1,1],[51644,427,2,3,0,1,1],[51645,427,2,3,0,1,1],[51646,427,2,3,0,1,1],[51647,427,2,3,0,1,1],[51648,427,2,3,0,1,1],[51649,427,2,3,0,1,1],[51650,427,2,3,0,1,1],[51651,427,2,3,0,1,1],[51652,427,2,3,0,1,1],[51653,427,2,3,0,1,1],[51654,427,2,3,0,1,1],[51655,427,2,3,0,1,1],[51656,95,28,1,7,3,1],[51657,296,60,4,0,15,1],[51658,95,28,1,28,3,1],[51822,363,23,1,38,3,1],[51824,593,23,2,0,1,1],[51825,258,23,1,25,3,1],[51826,258,23,1,25,3,1],[51827,258,23,1,13,3,1],[51828,258,23,1,13,3,1],[51829,570,23,1,5,1,1],[51830,570,23,1,50,1,1],[51831,642,23,1,0,1,1],[51832,315,23,1,5,1,1],[51833,315,23,1,5,1,1],[51834,315,23,1,5,1,1],[51835,315,23,1,5,1,1],[51836,315,23,1,5,1,1],[51837,315,23,1,5,1,1],[51838,315,23,1,5,1,1],[51839,315,23,1,5,1,1],[51840,315,23,1,5,1,1],[51841,315,23,1,5,1,1],[51843,113,23,1,7,1,1],[51845,148,23,1,18,1,1],[51846,365,23,1,5,2,1],[51847,559,15,1,7,1,1],[51850,244,1,1,19,3,2],[51946,655,23,3,0,1,1],[51947,655,23,3,0,1,1],[51948,655,23,3,0,1,1],[51949,655,23,3,0,1,1],[51950,655,23,3,0,1,1],[51951,655,23,3,0,1,1],[51954,331,66,1,42,4,1],[52009,538,1,3,0,1,1],[52010,538,1,3,0,1,1],[52011,538,1,3,0,1,1],[52012,538,1,3,0,1,1],[52013,538,1,3,0,1,1],[52014,538,1,3,0,1,1],[52015,538,1,3,0,1,1],[52016,538,1,3,0,1,1],[52017,434,1,2,0,2,1],[52018,538,1,3,0,1,1],[52019,538,1,3,0,1,1],[52020,538,1,3,0,1,1],[52021,538,1,3,0,1,1],[52022,538,1,3,0,1,1],[52023,538,1,3,0,1,1],[52024,538,1,3,0,1,1],[52025,538,1,3,0,1,1],[52026,538,1,3,0,1,1],[52027,538,1,3,0,1,1],[52028,538,1,3,0,1,1],[52029,538,1,3,0,1,1],[52084,65,23,1,5,4,1],[52146,377,17,3,0,4,3],[52150,279,23,2,0,3,1],[52151,463,23,1,0,3,1],[52157,542,1,3,0,1,1],[52158,243,1,4,0,1,1],[52159,72,34,1,21,3,1],[52160,22,16,2,0,2,1],[52161,40,10,9,0,4,1],[52162,366,1,3,0,1,1],[52164,538,1,3,0,1,1],[52165,263,1,22,3,1,1],[52166,538,1,3,0,1,1],[52167,263,1,22,3,1,1],[52168,89,21,1,0,1,1],[52170,263,1,22,3,1,1],[52171,89,21,1,0,1,1],[52172,89,21,1,0,1,1],[52174,83,38,1,11,1,1],[52175,63,33,9,0,3,1],[52176,538,1,3,0,1,1],[52177,538,1,3,0,1,1],[52178,89,21,1,0,1,1],[52179,538,1,3,0,1,1],[52180,538,1,3,0,1,1],[52181,538,1,3,0,1,1],[52182,538,1,3,0,1,1],[52183,538,1,3,0,1,1],[52184,89,21,1,0,1,1],[52185,538,1,3,0,1,1],[52186,538,1,3,0,1,1],[52187,538,1,3,0,1,1],[52188,452,84,2,0,15,1],[52189,340,26,1,0,1,1],[52190,340,26,1,0,1,1],[52191,561,84,4,0,2,1],[52192,561,84,4,0,2,1],[52193,340,26,1,0,1,1],[52194,340,26,1,0,1,1],[52196,75,1,1,5,1,1],[52197,75,1,1,5,1,1],[52200,227,23,1,7,3,1],[52202,61,17,1,3,4,1],[52255,509,23,3,0,12,2],[52257,165,23,4,0,3,1],[52259,8,1,1,3,4,1],[52320,520,23,1,5,1,1],[52322,520,23,1,5,1,1],[52388,315,23,1,5,1,1],[52389,315,23,1,5,1,1],[52390,315,23,1,5,1,1],[52391,315,23,1,5,1,1],[52392,315,23,1,5,1,1],[52394,538,1,3,0,1,1],[52395,538,1,3,0,1,1],[52396,538,1,3,0,1,1],[52397,538,1,3,0,1,1],[52398,538,1,3,0,1,1],[52399,538,1,3,0,1,1],[52400,538,1,3,0,1,1],[52401,538,1,3,0,1,1],[52402,538,1,3,0,1,1],[52403,538,1,3,0,1,1],[52404,538,1,3,0,1,1],[52405,538,1,3,0,1,1],[52406,538,1,3,0,1,1],[52407,538,1,3,0,1,1],[52408,538,1,3,0,1,1],[52409,538,1,3,0,1,1],[52410,538,1,3,0,1,1],[52411,538,1,3,0,1,1],[52412,538,1,3,0,1,1],[52413,538,1,3,0,1,1],[52414,538,1,3,0,1,1],[52415,538,1,3,0,1,1],[52416,538,1,3,0,1,1],[52417,538,1,3,0,1,1],[52418,610,34,2,0,1,1],[52419,392,1,2,0,1,1],[52420,610,34,2,0,1,1],[52422,83,38,1,11,1,1],[52423,170,1,2,0,1,1],[52424,170,1,2,0,1,1],[52426,170,1,2,0,1,1],[52443,315,23,1,5,1,1],[52444,315,23,1,5,1,1],[52445,315,23,1,5,1,1],[52446,315,23,1,5,1,1],[52447,315,23,1,5,1,1],[52448,315,23,1,5,1,1],[52449,315,23,1,5,1,1],[52450,315,23,1,5,1,1],[52713,61,17,1,3,4,1],[52732,352,1,1,35,1,1],[52733,352,1,1,35,1,1],[52735,119,1,1,6,1,1],[52736,352,1,1,35,1,1],[52741,119,1,1,6,1,1],[52747,89,21,1,0,1,1],[52748,89,21,1,0,1,1],[52749,290,3,1,7,1,1],[52752,89,21,1,0,1,1],[52755,290,3,1,7,1,1],[52758,290,3,1,7,1,1],[52759,290,3,1,7,1,1],[52762,290,3,1,7,1,1],[52764,89,21,1,0,1,1],[52769,352,1,1,35,1,1],[52785,233,23,19,0,1,1],[52786,233,23,19,0,1,1],[52787,233,23,19,0,1,1],[52788,233,23,19,0,1,1],[52789,233,23,19,0,1,1],[52790,233,23,19,0,1,1],[52791,233,23,19,0,1,1],[52792,233,23,19,0,1,1],[52793,233,23,19,0,1,1],[52817,409,48,3,0,3,2],[52887,496,34,1,7,4,1],[52888,241,1,3,0,1,1],[52889,8,1,10,0,4,1],[52890,8,1,10,0,4,1],[52891,8,1,10,0,4,1],[52892,8,1,10,0,4,1],[52894,465,18,9,0,3,1],[52897,336,18,1,5,2,1],[52898,478,18,1,21,2,1],[52899,530,18,9,0,2,1],[52901,600,23,5,0,1,1],[52903,95,28,3,0,3,2],[52904,372,59,3,0,1,2],[52907,65,23,1,0,4,1],[52908,65,23,1,0,4,1],[52909,65,23,1,0,4,1],[52912,224,23,1,7,3,1],[52933,27,26,3,0,1,2],[52935,167,30,1,5,4,1],[52937,403,78,1,0,1,1],[52941,350,1,2,0,4,1],[52942,350,1,2,0,4,2],[52984,61,17,11,0,5,4],[53100,322,23,3,0,3,2],[53102,8,1,1,0,4,1],[53128,520,23,1,7,1,1],[53129,520,23,1,7,1,1],[53316,65,23,1,5,4,1],[53318,65,23,1,5,4,1],[53352,8,1,2,0,4,1],[53355,9,1,1,1,4,2],[53370,331,66,1,5,4,1],[53371,459,17,2,0,2,1],[53372,459,17,2,0,2,1],[53586,117,23,2,0,3,1],[53698,65,23,1,7,4,1],[53757,635,23,3,52,1,1],[53758,635,23,3,52,1,1],[53760,88,23,1,0,4,1],[53761,88,23,1,0,4,1],[53763,88,23,1,0,4,1],[53765,13,5,3,0,1,2],[53808,77,1,3,0,1,1],[53813,509,23,3,0,1,2],[53815,559,15,1,7,1,1],[53876,117,23,2,0,3,1],[53877,463,23,1,4,3,1],[53883,8,1,1,5,4,1],[53884,88,23,2,0,3,1],[53885,88,23,2,0,3,1],[53943,65,23,1,5,4,1],[53946,65,23,1,5,4,1],[53947,65,23,1,5,4,1],[53948,463,23,2,0,3,1],[53949,510,23,2,0,3,1],[53950,463,23,2,0,3,1],[53960,27,26,3,0,1,2],[53961,27,26,3,0,1,2],[54020,634,23,2,0,3,1],[54021,634,23,2,0,3,1],[54026,221,1,3,0,1,2],[54027,221,1,3,0,1,2],[54029,117,23,5,0,3,1],[54031,61,17,11,0,5,4],[54033,34,19,3,0,3,1],[54035,285,23,1,0,3,1],[54042,65,23,1,5,4,1],[54043,65,23,1,5,4,1],[54045,65,23,1,5,4,1],[54048,13,5,3,0,1,2],[54109,61,17,22,0,4,1],[54110,61,17,22,0,4,1],[54113,427,2,3,0,1,1],[54114,427,2,3,0,1,1],[54115,427,2,3,0,1,1],[54116,427,2,3,0,1,1],[54117,427,2,3,0,1,1],[54118,427,2,3,0,1,1],[54119,427,2,3,0,1,1],[54120,427,2,3,0,1,1],[54121,427,2,3,0,1,1],[54122,427,2,3,0,1,1],[54123,427,2,3,0,1,1],[54124,427,2,3,0,1,1],[54125,427,2,3,0,1,1],[54126,427,2,3,0,1,1],[54127,427,2,3,0,1,1],[54128,427,2,3,0,1,1],[54129,427,2,3,0,1,1],[54130,427,2,3,0,1,1],[54131,427,2,3,0,1,1],[54132,427,2,3,0,1,1],[54133,427,2,3,0,1,1],[54134,427,2,3,0,1,1],[54135,427,2,3,0,1,1],[54136,427,2,3,0,1,1],[54137,427,2,3,0,1,1],[54138,427,2,3,0,1,1],[54139,427,2,3,0,1,1],[54140,427,2,3,0,1,1],[54141,427,2,3,0,1,1],[54142,427,2,3,0,1,1],[54143,427,2,3,0,1,1],[54144,427,2,3,0,1,1],[54145,427,2,3,0,1,1],[54146,427,2,3,0,1,1],[54147,427,2,3,0,1,1],[54148,427,2,3,0,1,1],[54225,13,5,3,0,1,2],[54642,427,2,3,0,1,1],[54643,427,2,3,0,1,1],[54644,427,2,3,0,1,1],[54645,427,2,3,0,1,1],[54646,427,2,3,0,1,1],[54647,427,2,3,0,1,1],[54648,427,2,3,0,1,1],[54649,427,2,3,0,1,1],[54650,427,2,3,0,1,1],[54651,427,2,3,0,1,1],[54652,427,2,3,0,1,1],[54653,427,2,3,0,1,1],[54654,427,2,3,0,1,1],[54655,427,2,3,0,1,1],[54656,427,2,3,0,1,1],[54657,427,2,3,0,1,1],[54658,427,2,3,0,1,1],[54659,427,2,3,0,1,1],[54660,427,2,3,0,1,1],[54661,427,2,3,0,1,1],[54662,427,2,3,0,1,1],[54663,427,2,3,0,1,1],[54664,427,2,3,0,1,1],[54665,427,2,3,0,1,1],[54666,427,2,3,0,1,1],[54667,427,2,3,0,1,1],[54668,427,2,3,0,1,1],[54669,427,2,3,0,1,1],[54670,427,2,3,0,1,1],[54671,427,2,3,0,1,1],[54672,427,2,3,0,1,1],[54673,427,2,3,0,1,1],[54674,427,2,3,0,1,1],[54675,427,2,3,0,1,1],[54676,427,2,3,0,1,1],[54677,427,2,3,0,1,1],[54678,427,2,3,0,1,1],[54679,427,2,3,0,1,1],[54680,427,2,3,0,1,1],[54681,427,2,3,0,1,1],[54940,358,23,5,36,2,1],[55009,554,93,9,0,1,1],[55010,374,11,1,39,1,1],[55011,83,38,1,11,1,1],[55012,129,25,3,16,1,1],[55013,352,26,1,35,1,1],[55014,352,26,1,35,1,1],[55015,71,10,1,9,4,1],[55017,352,26,1,35,1,1],[55018,340,26,1,0,1,1],[55019,340,26,1,0,1,1],[55020,164,1,1,5,1,1],[55021,164,1,1,5,1,1],[55022,164,1,1,5,1,1],[55023,164,1,1,5,1,1],[55024,164,1,1,5,1,1],[55025,164,1,1,5,1,1],[55026,164,1,1,5,1,1],[55027,164,1,1,5,1,1],[55028,164,1,1,5,1,1],[55029,164,1,1,5,1,1],[55030,164,1,1,5,1,1],[55031,164,1,1,5,1,1],[55032,164,1,1,5,1,1],[55033,164,1,1,5,1,1],[55034,366,1,3,0,1,1],[55035,164,1,1,5,1,1],[55036,617,1,1,7,1,1],[55037,352,26,1,35,1,1],[55038,352,26,1,35,1,1],[55039,164,1,1,5,1,1],[55040,164,1,1,5,1,1],[55041,524,39,9,0,1,1],[55042,164,1,1,5,1,1],[55043,164,1,1,5,1,1],[55044,556,4,1,2,1,1],[55045,89,21,1,0,1,1],[55046,366,1,3,0,1,1],[55047,89,21,1,0,1,1],[55048,89,21,1,0,1,1],[55049,290,3,1,7,1,1],[55050,617,1,1,7,1,1],[55052,524,39,9,0,1,1],[55053,186,1,1,13,1,1],[55054,524,39,9,0,1,1],[55055,164,1,1,5,1,1],[55056,629,1,2,0,1,1],[55057,164,1,1,5,1,1],[55058,524,39,9,0,1,1],[55061,524,39,9,0,1,1],[55062,290,3,1,7,1,1],[55063,164,1,1,5,1,1],[55064,89,21,1,0,1,1],[55065,164,1,1,5,1,1],[55066,164,1,1,5,1,1],[55067,421,6,2,0,1,1],[55068,164,1,1,5,1,1],[55069,164,1,1,5,1,1],[55070,164,1,1,5,1,1],[55071,164,1,1,5,1,1],[55072,553,15,5,0,1,1],[55073,574,6,5,0,2,1],[55074,164,1,1,5,1,1],[55075,164,1,1,5,1,1],[55076,651,1,9,0,1,1],[55077,164,1,1,5,1,1],[55078,164,1,1,5,1,1],[55079,164,1,1,5,1,1],[55080,164,1,1,5,1,1],[55081,458,85,2,0,2,1],[55082,164,1,1,5,1,1],[55083,164,1,1,5,1,1],[55084,223,38,2,0,1,1],[55085,340,26,1,0,1,1],[55086,340,26,1,0,1,1],[55087,538,1,3,0,1,1],[55088,352,26,1,35,1,1],[55089,538,1,3,0,1,1],[55090,115,21,9,0,1,1],[55091,538,1,3,0,1,1],[55092,538,1,3,0,1,1],[55093,71,10,1,9,4,1],[55094,538,1,3,0,1,1],[55095,538,1,3,0,1,1],[55096,538,1,3,0,1,1],[55097,538,1,3,0,1,1],[55098,63,33,3,8,2,1],[55099,538,1,3,0,1,1],[55100,538,1,3,0,1,1],[55101,538,1,3,0,1,1],[55102,538,1,3,0,1,1],[55103,342,68,2,0,2,1],[55107,2,2,1,2,1,1],[55108,659,30,2,0,1,1],[55133,315,23,2,26,1,1],[55134,592,23,28,0,1,1],[55135,592,23,28,0,1,1],[55136,593,23,2,0,1,1],[55137,396,23,2,0,2,1],[55138,583,1,2,0,4,2],[55140,427,2,3,0,1,1],[55141,427,2,3,0,1,1],[55142,427,2,3,0,1,1],[55143,427,2,3,0,1,1],[55144,427,2,3,0,1,1],[55145,427,2,3,0,1,1],[55146,427,2,3,0,1,1],[55147,427,2,3,0,1,1],[55148,427,2,3,0,1,1],[55149,427,2,3,0,1,1],[55150,427,2,3,0,1,1],[55151,427,2,3,0,1,1],[55152,427,2,3,0,1,1],[55153,427,2,3,0,1,1],[55154,427,2,3,0,1,1],[55155,427,2,3,0,1,1],[55156,427,2,3,0,1,1],[55157,427,2,3,0,1,1],[55158,427,2,3,0,1,1],[55159,427,2,3,0,1,1],[55160,427,2,3,0,1,1],[55161,427,2,3,0,1,1],[55162,427,2,3,0,1,1],[55163,427,2,3,0,1,1],[55164,427,2,3,0,1,1],[55165,427,2,3,0,1,1],[55166,427,2,3,0,1,1],[55167,427,2,3,0,1,1],[55168,427,2,3,0,1,1],[55169,427,2,3,0,1,1],[55239,37,23,3,0,1,2],[55242,512,23,2,0,3,1],[55243,512,23,2,0,3,1],[55244,65,23,1,5,4,1],[55248,468,23,1,5,3,1],[55249,468,23,1,5,3,1],[55250,365,23,1,5,2,1],[55251,315,23,1,5,1,1],[55252,315,23,1,5,1,1],[55253,315,23,1,5,1,1],[55254,315,23,1,5,1,1],[55255,315,23,1,5,1,1],[55256,315,23,1,5,1,1],[55257,68,23,1,7,1,1],[55258,318,23,1,5,1,1],[55259,318,23,1,5,1,1],[55260,318,23,1,5,1,1],[55261,604,23,2,0,3,1],[55263,9,1,3,0,4,2],[55264,350,1,2,0,4,2],[55268,399,1,11,0,5,4],[55324,263,1,22,3,1,1],[55326,263,1,22,3,1,1],[55327,263,1,22,3,1,1],[55329,291,15,1,7,3,1],[55506,180,17,1,6,3,2],[55508,25,11,3,0,1,2],[55562,95,28,9,0,3,1],[55683,293,2,3,0,1,2],[55686,509,23,3,0,1,2],[55690,281,48,1,26,3,1],[55801,427,2,3,0,1,1],[55802,427,2,3,0,1,1],[55803,427,2,3,0,1,1],[55804,427,2,3,0,1,1],[55805,427,2,3,0,1,1],[55806,427,2,3,0,1,1],[55807,427,2,3,0,1,1],[55808,427,2,3,0,1,1],[55809,427,2,3,0,1,1],[55810,427,2,3,0,1,1],[55811,427,2,3,0,1,1],[55812,427,2,3,0,1,1],[55813,427,2,3,0,1,1],[55814,427,2,3,0,1,1],[55815,427,2,3,0,1,1],[55816,427,2,3,0,1,1],[55817,427,2,3,0,1,1],[55818,427,2,3,0,1,1],[55819,427,2,3,0,1,1],[55820,427,2,3,0,1,1],[55821,427,2,3,0,1,1],[55822,427,2,3,0,1,1],[55823,427,2,3,0,1,1],[55824,427,2,3,0,1,1],[55825,427,2,3,0,1,1],[55826,427,2,3,0,1,1],[55827,427,2,3,0,1,1],[55828,427,2,3,0,1,1],[55829,427,2,3,0,1,1],[55830,427,2,3,0,1,1],[55836,322,23,1,5,3,1],[55839,322,23,1,5,3,1],[55841,361,17,3,0,3,2],[55844,281,48,1,26,3,1],[55861,511,23,2,0,3,1],[55909,94,1,1,12,1,1],[55910,94,1,1,12,1,1],[55912,224,23,1,5,3,2],[55970,27,26,3,0,1,2],[55971,27,26,3,0,1,2],[55973,592,23,28,0,1,1],[55974,592,23,28,0,1,1],[55975,592,23,28,0,1,1],[55976,592,23,28,0,1,1],[55978,61,17,1,3,4,1],[55982,75,1,1,5,1,1],[55983,75,1,1,5,1,1],[56055,427,2,3,0,1,1],[56056,427,2,3,0,1,1],[56057,427,2,3,0,1,1],[56058,427,2,3,0,1,1],[56059,427,2,3,0,1,1],[56060,427,2,3,0,1,1],[56061,427,2,3,0,1,1],[56062,427,2,3,0,1,1],[56063,427,2,3,0,1,1],[56064,427,2,3,0,1,1],[56065,427,2,3,0,1,1],[56066,427,2,3,0,1,1],[56067,427,2,3,0,1,1],[56068,427,2,3,0,1,1],[56069,427,2,3,0,1,1],[56070,427,2,3,0,1,1],[56071,427,2,3,0,1,1],[56072,427,2,3,0,1,1],[56073,427,2,3,0,1,1],[56074,427,2,3,0,1,1],[56075,427,2,3,0,1,1],[56076,427,2,3,0,1,1],[56077,427,2,3,0,1,1],[56078,427,2,3,0,1,1],[56079,427,2,3,0,1,1],[56080,427,2,3,0,1,1],[56081,427,2,3,0,1,1],[56083,61,6,1,7,4,1],[56091,61,17,14,0,4,1],[56153,277,39,1,7,1,1],[56154,277,39,1,7,1,1],[56155,277,39,1,7,1,1],[56156,277,39,1,7,1,1],[56157,65,23,1,7,4,1],[56162,62,1,9,0,4,1],[56163,62,1,9,0,4,1],[56164,62,1,9,0,4,1],[56165,62,1,9,0,4,1],[56166,62,1,9,0,4,1],[56167,62,1,9,0,4,1],[56168,62,1,9,0,4,1],[56169,62,1,9,0,4,1],[56170,62,1,9,0,4,1],[56171,62,1,9,0,4,1],[56174,221,1,3,0,1,2],[56178,292,25,1,13,2,1],[56179,84,2,2,0,1,1],[56180,153,12,1,5,3,1],[56181,332,25,3,2,10,1],[56182,352,1,1,35,1,1],[56183,497,1,3,30,1,1],[56184,551,5,2,0,2,1],[56185,83,38,1,11,1,1],[56186,239,20,4,0,1,1],[56187,7,7,4,0,2,1],[56188,122,1,5,0,2,1],[56189,308,1,2,0,1,1],[56190,89,21,1,0,1,1],[56191,263,1,22,3,1,1],[56193,263,1,22,3,1,1],[56194,417,10,1,2,3,1],[56195,240,1,1,21,1,1],[56196,629,1,2,0,1,1],[56197,240,1,1,21,1,1],[56198,617,1,1,7,1,1],[56199,608,1,2,51,1,1],[56201,89,21,1,0,1,1],[56202,89,21,1,0,1,1],[56203,89,21,1,0,1,1],[56204,239,20,4,0,1,1],[56205,205,52,1,5,4,1],[56206,352,1,1,35,1,1],[56207,568,97,1,13,3,1],[56208,455,38,2,0,1,1],[56209,239,20,4,0,1,1],[56210,129,25,1,13,1,1],[56211,297,38,14,0,3,1],[56212,489,88,2,0,2,1],[56213,494,1,1,5,2,1],[56214,344,9,3,30,1,1],[56215,624,16,9,0,1,1],[56216,183,38,9,0,1,1],[56217,329,20,3,0,1,1],[56218,329,20,3,0,1,1],[56219,12,1,4,0,1,1],[56220,12,1,4,0,1,1],[56221,304,44,5,0,3,1],[56222,158,4,5,0,2,1],[56223,631,86,9,0,1,1]]} +{"meta":{"source":"Union of Concerned Scientists Satellite Database","version":"UCS-Satellite-Database 5-1-2023","url":"https://www.ucsusa.org/satellite_database","dataCurrentTo":"2023-05-01","license":"Free, unrestricted use; acknowledgement requested (UCS database manual, section 3)","curation":{"sourceRows":7560,"starlinkRowsDropped":3996,"invalidNoradDropped":0,"duplicateNoradDropped":0,"columns":{"operator":"Operator/Owner","operatorCountry":"Country of Operator/Owner","purpose":"Purpose"}},"recordCount":3564},"fields":["noradId","operator","operatorCountry","purpose"],"strings":{"operator":["","Hera Systems","AAC Clyde Space","Aalto University","University of Aalborg","Asia Broadcast Satellite Ltd.","Asher Space Research Institute at Technion/Israeli Aircraft Industries","Austrian Space Forum (OEWF)/Spire","National Reconnaissance Office (NRO)","US Air Force","Department of Homeland Security","European Space Agency (ESA)","Aerospace Corporation","EUTELSAT S.A.","Aurora Insight","Center for Atmospheric Sciences, Hampton University/NASA","Norwegian Coastal Admnistration","AISTech","Al Yah Satellite Communications Co. (YAHSAT)","Al-Farabi Kazakh National University","Algerian Space Agency (ASAL)","Astro Live Experiences","University of Brasilia","US Air Force Institute of Technology","Algerian Space Agency (ASAL)/UK Space Agency","Hispamar (subsidiary of Hispasat - Spain)","National Institute for Space Research (INPE)","SES S.A.","Moscow State University","Space-Communication Ltd.","AMSAT-NA","Agency for Defense Development","ANDESITE - Boston University","Centre National d'Etudes Spatiales (CNES)","AngoSat","Telesat Canada Ltd. (BCE, Inc.)","Aprize Satellite, Argentina","APT Satellite Holdings Ltd.","Arab Satellite Communications Org. (ASCO)","University of South Florida, Institute of Applied Engineering (IAE).","Norwegian Defence Research Establishment","Aoyama Gakuin University in Tokyo","Naval Physical and Oceanographic Laboratory","Planetary Resources","ArSat","Aerospacelab","Istanbul Technical University","Asia Satellite Telecommunications Co. Ltd.","1Worldspace","Japan Space Systems","Chiba Institute of Technology","Astranis","Astrocast","Telemetry Tracking and Command Network (ISTRAC) Centre","DirecTV, Inc.","PointView Tech","Centre National d'Etudes Spatiales (CNES)/Agenzia Spaziale Italiana (ASI)","Microspace Rapid Pvt Ltd.","Azercosmos","Bangladesh Telecommunication Regulatory Commission (BTRC)","Tianyi Research Institute","Ministry of Defense","Space Development Agency","Central European Institute of Technology","Technical University Berlin","Chinese Ministry of National Defense","21st Century Aerospace Technology (21AT)","Beijing Landview Mapping Information Technology Co. Ltd (BLMIT)","Changsha Tianyi space science and Technology Research Institute Co., Ltd. (Spacety)","Belintersat","Institute of Space Sensor Technology and Planetary Exploration","Norwegian Defence Research Establishment (FFI)/","German Aerospace Center (DLR)","Salish Kootenai College","National Academy of Sciences","BlackSky Global","NanoAvionics","AST SpaceMobile","Embratel Star One","SpaceQuest, Ltd.","Bank Rakyat Indonesia (BRI)","University of Toronto, Institute for Aerospace Studies","BRITE Consortium","UnseenLabs","AstroForge","Broadcasting Satellite System Corp.","Broadcasting Satellite System Corp./Sky Perfect JSAT Corporation","Defence Science and Technology Group/University of New South Wales","China Academy of Space Technology (CAST)","Satellogic S.A.","Bulsatcom","Capital Technology University","Centre National d'Etudes Spatiales (CNES)/NASA","University of Louisiana at Lafayette","Capella Space","Indian Space Research Organization (ISRO)","Korea Aerospace Research Institute (KARI)","Canadian Space Agency","China National Space Administration (CNSA)/National Institute for Space Research (Brazil)","Surrey Satellite Technology Ltd.","Earth-i/Surrey Satellite Technology Ltd.","Canon Electronics","Air Force Research Laboratory","Fleet Space Technologies","Beijing Future Navigation Technology Co. Ltd.","NASA Goddard Space Flight Center","Ministry of Armed Forces","Defense Innovation Unit/Cesium Astro","China National Space Administration (CNSA)/National Centre for Space Studies (CNES)","TELNET Holding","National Aeronautics and Space Administration (NASA) Goddard Space Flight Center","Spacety Aerospace Company","Shanghai Engineering Center for Microsatellites","Spacety Co. Ltd.","Nanjing University","Epic Aerospace","University of Florida","Chinese Academy of Sciences","GeoOptics Inc.","GeoOptics, Inc.","Ciel Satellite Group","University of California-Berkeley/Imperial College-London","Laboratory for Atmospheric and Space Physics, University of Colorado, Boulder","Utah State University","Unknown US agency","National Aeronautics and Space Administration (NASA)/Colorado State University","Department of Defense/Missile Defense Agency","FH Aachen","Armed Forces","Plan-S","US Air Force/ US Navy/NASA","Astro Digital","Taiwan's National Space Organization/National Oceanographic and Atmospheric Admistration (NOAA)","National Space Program Office (NSPO)/University Corporation for Atmospheric Research (UCAR) Boulder, CO","Italian Space Agency/Ministry of Defense","California Polytechnic State University/NASA JPL","University of Colorado","Directorate General of Armaments (DGA)","Argentinian Ministry of Science, Technology and Productive Innovation/Radio Club Bariloche","University of Illinois Urbana-Champaign","Department of Astronautics, University of Tokyo","National Aeronautics and Space Administration (NASA)","Department of Astrophysical and Planetary Science, UC Boulder/National Aeronautics and Space Administration (NASA)","University of Michigan/NASA Earth Science Technology Office","German Orbital Systems","Mexican Space Agency/NanoAvionics","Japan Aerospace Exploration Agency (JAXA)","North Idaho STEM Charter Academy","ADA Space (?)","China Amateur Satellite - CAMSAT","Deimos Imaging/DMC International Imaging (DMCII)","Technical University, Delft","Massachusetts Institute of Technology","Dubai Electricity and Water Authority (DEWA)","Khalifa University","DARPA (Defense Advanced Research Projects Agency)","Sky and Space Global, UK","SpacePharma","IT University of Copenhagen, University of Southern Denmark, Aalborg University and Aarhus University","Philippines’ Department of Science and Technology, University of the Philippines Diliman, and Japan’s Hokkaido University and Tohoku University","Deutsches Zentrum für Luft- und Raumfahrt","Mohammend Bin Rashid Space Centre (MBRSC)","DoD/NOAA","University of Southern California, Space Engineering Research Center/Lockheed Martin","Planet Labs, Inc.","China National Space Administration","Kawasaki Heavy Industries","Singapore Armed Forces","Emirates Institution for Advanced Science & Technology (EIAST)","Academy of Military Science","ESPACE","Global Eagle Entertainment","OHB Italia","Echostar Satellite Services, LLC","HughesNet leased from Echostar Technologies, LLC","Echostar Satellite Services, LLC/Intelsat","Equator Technological University","Airbus","National Authority for Remote Sensing and Space Science (NARSS)","Momentus","Roshydromet - Planeta","University of California","DGA (Arms Procurement Agency)/Centre National d'Etudes Spatiales (CNES)","Clyde Space Ltd./EUTELSAT","Astroscale","Defence Research and Development Organization","EOS Data Analytics","Earth Sciences Enterprise (NASA)","Goddard Space Flight Center/EOS Data and Operations System","National Aeronautics and Space Administration (NASA) - Earth Science Enterprise/Japan Meteorological Agency/Brazilian Space Agency","Defense Ministry","ImageSat International, NV/Ministry of Defense","EUTELSAT S.A./Es'hailSat","Es’hailSat","ESA/European Maritime Safety Agency","European University Network","Ethiopian Space Science and Technology Institute","EUTELSAT Americas","EUTELSAT S.A./Nilesat","exactEarth","Defense Advanced Research Projects Agency (DARPA)","California Polytechnic Institute","Russian Satellite Communications Company","Exseed Space","CNES (Centre national d'études spatiales)","Colombian Air Force","US Air Force Academy","InSpace","University of Texas - Austin","Link Space Aerospace Technology","China Meteorological Administration","National Aeronautics and Space Administration (NASA)/GSFC","University of Stuttgart","US Navy","National Space Organization","Los Alamos National Laboratory","Jihua Laboratory","Copernicus Land and Marine Environment services","AMSAT-UK","PanAmSat (Intelsat S.A.)","PanAmSat (Intelsat S.A.)/Sky Perfect JSAT Corp.","Intelsat S.A.","PanAmSat (Intelsat S.A.)/DirecTV, Inc.","Gama","China National Academy of Sciences (CNSAS)","China’s Ministry of Land and Resources, Ministry of Environmental Protection, and Ministry of Agriculture","State Oceanic Administration (SOA)","China's Ministry of Land and Resources, Ministry of Environmental Protection, and Ministry of Agriculture","Ministry of Natural Resources/Ministry of Housing and Urban-Rural Development/National Bureau of Statistics","People's Liberation Army (C41)","GomSpace ApS","Air Force Research Laboratory/Globalstar","NearSpace Launch/Air Force Research Laboratory","Geespace","Bigelow Aerospace","Korea Meteorological Administration/National Meteorological Satellite Center","DigitalGlobe Corporation","Institute of Space and Astronautical Science (ISAS)/NASA/ESA","Marshall Intec","GHGSat, Inc.","Orbital Sidekick","Globalstar","OHB SE (Orbitale Hochtechnologie Bremen)","PlanetiQ","NOAA (National Oceanographic and Atmospheric Administration)","Turkish Ministry of National Defense","Gonets Satcom","JAXA, Ministry of Environment, National Institute of Environmental Studies","Lunasonde","LuxGovSat","National Aeronautics and Space Administration (NASA)/JAXA","NASA/German Research Centre for Geosciences (GFZ)","Axelspace","Indian Navy","Indian Air Force","Air Force Satellite Control Network","U.S. Army’s Space and Missile Defense Command","Department of Defense/Army Space and Missile Defense Command","Sanya Institute of Remote Sensing","Xiamen University/Aerospace Dongfanghong Satellite Co. Ltd. {CASC)","Department of Defense/Space Test Program","Hunan Hangsheng Satellite Technology Co., Ltd.","US Army Space and Missile Defense Command","HawkEye 360","HEAD Aerospace","Centre National d'Etudes Spatiales (CNES)/Délégation Générale de l'Armement (DGA)","Hellas-Sat Consortium Ltd.","Hellas-Sat Consortium Ltd./INMARSAT","Tokyo Institute of Technology","Hiber Global","Japan Meteorological Agency/Meteorological Satellite Center (MSC)","Institute of Space and Aeronautical Science/NASA","Spacety","Hispasat","National Remote Sensing Center (NRSCC)","China Centre for Resources Satellite Data and Application (CRESDA)","University of Tokyo and NESTRA","PIESAT","China Aerospace Science and Technology Corporation (CASC)","China Aerospace Science and Industry Corporation (CASIC)","Horizons 2 Satellite, LLC (Intelsat, Sky Perfect JSAT Corporation)","Egyptian Space Agency","Kyushu Institute of Technology (KIT)","Harris Corporation","Shanghai Lizheng Satellite Application Technology","Ministry of Ecology and Environment/Ministry of Emergency Management","European Space Agency (ESA)/NASA","Huaian Youth Comprehensive Development Base","National Space Science Center (NSSC)","Avanti Communications, PLC","ICEYE Ltd.","Cabinet Satellite Intelligence Center (CSIC)","TÜBİTAK Space Technologies Research Institute","INMARSAT, Ltd.","INMARSAT, Ltd./European Space Agency (ESA)","Astronautic Technology Sdn. Bhd (ATSB)","University of Colorado’s Laboratory for Atmospheric and Space Physics/Indian Institute of Space Science and Technology","NRS Lab. Atmospheres, Milieux, Observations Spatiales (Verrieres)","European Space Operations Centre (ESOC)/NASA/Russia","Intelsat S.A./Sky Perfect JSAT Corp.","National Aeronautics and Space Administration (NASA)/Multinational","National Aeronautics and Space Administration (NASA)/Goddard Space Flight Center","D-Orbit","Iridium Communications, Inc.","National Center for Atmospheric Research (NCKU)","NASA Small Satellite Technology Program","iSky Technology","NASA/CalPoly","TrustPoint, Inc.","ITU Space Systems Design & Test Laboratory","Radio Amateur Society of Thailand (RAST)","National Aeronautics and Space Administration (NASA)/Centre National d'Etudes Spatiales (CNES)/NOAA/EUMETSAT","Sky Perfect JSAT Corporation","Sky Perfect JSAT Corporation/Kacific","Space OK (Shanghai Ok Space Co Ltd.)","Chang Guang Satellite Technology Co. Ltd.","Beijing ZeroG Space Technology Co., Ltd./Hong Kong Aerospace Tech Group","Shanghai Jiao Tong University/APSCO","Gang Hang Ke","Hong Kong Aerospace Technology Group","Chinese University Corporation for Polar Research","Crown Prince Foundation","China Aerospace Science and Technology Corp. (CASC)","Scientific Production Corporation (joint stock creation of Russian Space Agency)","Roscosmos State Corporation","Kazcosmos","JSC KazSat","Ghalam LLP","National University of Singapore","Kepler Communications","US Army Space and Missile Defense Command/Army Forces Strategic Command","Revolutionary Guard Corps","GUMUSH AeroSpace/University of Turkish Aeronautical Association (UTAA)","Tsinghua University","Tokyo Metropolitan College of Industrial Technology","KLEO Connect","Chosun University","KT Corporation/Korean Agency for Defense Development (ADD)","KT Corporation","National Institute of Technology at Kochi College","Kleos Space","College of Engineering, King Saud University","Kuwait University","Lacuna Space","Lacuna Space/NanoAvionics","Beijing Commsat Technology Development Co. Ltd.","National Aeronautics and Space Administration (NASA)/US Geological Survey","Laos National Authority for Science and Technology","Indonesian National Aeronautics and Space Agency (Lembaga Penerbangan dan Antariksa Nasional - LAPAN)","Aprize Satellite Argentina","US Space Force","Sapienza","Spire Global Inc.","California Polytechnic State University","Harbin Institute/China Amateur Satellite - CAMSAT","General Atomics Electromagnetic Systems","Xinwei Telecom Technology Company","Beijing ZeroG Technology Co. Ltd.","Nanjing and Hong Kong Universities","Changchun Institute of Optics","Russian Federal Space Agency","Federal Security Service (FSB)","SkyFox Labs","Shanghai Academy of Space Technology","University of Vigo/Alén Space","Wuhan University","Lynk Global Inc.","University of Michigan/Montana University","Defence Research and Development Canada (DRDC)/Canadian Space Agency","CTRS Morocco/Institut für Luft-und Raumfahrttechnik (Berlin)","Max Valier school Bolzano, Italy, Oskar von Miller school, Merano, Italy","Cape Peninsula University of Technology (CPUT)","MEASAT Satellite Systems Sdn. Bhd.","Indian Space Research Organization (ISRO)/Centre National d'Etudes Spatiales (CNES)","Open Cosmos","PT Telkomunikasi","National Reconnaissance Office (NRO)/US Air Force","Military Space Forces (VKS)","Analytical Space","Russian Federal Service For Hydrometeorology and Environmental Monitoring (ROSHYDROMET)","EUMETSAT (European Organization for the Exploitation of Meteorological Satellites)","ESA/EUMETSAT (European Organization for the Exploitation of Meterological Satellites)","Space Logistics LLC","Mexico Telecomm","Masdar Institute of Science and Technology","Vietnam National Space Center","MIT/Lincoln Laboratory-University of Massachusetts Amherst","Kingdom of Morocco","Technical University of Munich","Mozhaisky Space Engineering Academy","Mobile Satellite Ventures","DoD/US Navy","ACMEO","Tyvak Nanosatellite Systems, Inc.","Instituto Nacional de Técnia Aeroespacial (INTA)","Brazilian National Institute of Space Research (INPE/MCTI), Federal University of Santa Maria (UFSM)","Shanghai Weixing Keji","Thai Air Force","National Authority for Remote Sensing and Space Sciences","DoD/US Air Force","Center of Excellence for Space Sciences and Technologies (SPACE-SI)","Origin Space","Deimos","Unknown","Würzburg Center for Telematics","Korea Advanced Institute of Science and Technology (KAIST)","Nihon University","NigComSat","National Space Research and Development Agency (NASRDA)","Egyptian Radio and TV Union","Echostar Corporation (entire payload leased from Telesat Canada Ltd.)","Ningxia Jingui Information Technology Co. Ltd.","Noorul Islam University","PT Pasifik Satelit Nntara","National Oceanographic and Atmospheric Administration (NOAA) (part of international program)","Novosibirsk State University","Norwegian Space Center","Norwegian Space Center/Statsat","UK Government/Surrey Satellite Technologies","National Oceanographic and Atmospheric Administration (NOAA)/NASA","F Space Test Office","NSLComm","National Aeronautics and Space Administration (NASA)-Ames Research Center/Stanford University","O3b Networks Ltd.","Michigan Technological University/Air Force Research Laboratory","Swedish National Space Board","U.S. Space Force","OneWeb Satellites","Spanish Space Agency","Italian Defense Ministry","Optus Communications (Parent: Singapore Telecom)","Optus Communications (Parent: Singapore Telecom)/Australian Ministry of Defense","ORBCOMM Inc.","DARPA/Tethers Unlimited","Portland State Aerospace Society","F /ORSO (Operationally Responsive Space Office)","General Atomics - Electromagnetic Systems Group","Mexico Secretariat of National Defense (SEDENA)","Pakistan Space and Upper Atmosphere Research Commission (SUPARCO)/PakSat International","Pakistan Space and Upper Atmosphere Research Commission (SUPARCO)","National Reconnaissance Office","National Aeronautics and Space Administration (NASA)/Cornell University","US Naval Academy","Helios Wire/Echostar Global Australia","Hisdesat","Tiger Innovations","Netherlands Organization for Applied Scientific Research (NTO) consortium","Dauria Aerospace","Peruvian Space Agency","Rocket Labs","Brigham Young University","German Aerospace Center (DLR-IKN)/TESAT Spacecom","University of Chile for Aerospace Investigation)","Ministry of Defense/Centre National d'Etudes Spatiales (CNES) - cooperation with Austria, Belgium, Spain, Sweden","Ministry of Defense//Centre National d/Etudes Spatiales (CNES) - cooperation with Austria, Belgium, Spain, Sweden","Airbus Defense and Space","US Coast Guard","University of Colorado, Boulder","National Technical University","St. Petersburg Polytechnic University","Italian Space Agency","Naval Post-Graduate School","Georgia Institute of Technology","Shanghai Academy of Spaceflight Technology","Astrodynamics and Control Laboratory of Yonsei University","Korean Aerospace Research Institute (KARI)","Von Karman Institute (VKI)","Qiansheng Exploration Technology Co. Ltd.","Shandong Institutes of Industrial Technology","Orbital Space","iQPS","University of Science and Technology","SES S.A. -- total capacity leased to subsidiary of EchoStar Corp.","University of New South Wales/Department of Defence","MDA Corporation","C3S","Vanderbilt University/AMSAT","Robertsville Middle School, Oak Ridge, Tennessee","Korea Advanced Institute of Science and Technology.","Space Technologies Research Institute","Regional African Satellite Communications Organization (RASCOM)","Applied Physics Laboratory/NASA","Reaktor Space Lab","Institute of Space and Astronautical Science (ISAS)/Japan Aerospace Exploration Agency (JAXA)","Skobeltsyn Institute of Nuclear Physics","Indian Space Research Organization (ISRO)/exactEarth","Tohoku University","Tohoku University/Hokkaido University","University of Montpellier","Orbital Solutions Monaco","European Space Agency (ESA) (and 250 international scientific investigators)","National Autonomous University of Mexico","AMSAT/Nepal","National Space Activities Commission (CONAE)","Stanford Student Space Initiative","Department of National Defense","EASF/Bundeswehr","Sateliot IoT","Department of Computer Science and the Faculty of Engineering, Ariel University","Riyadh Space Research Institute","Space Research Institute, King Abdulaziz City for Science and Technology","National Reconnaissance Office (NRO)/US Navy","Strategic Space Command/Space Surveillance Network","Instituto Nacional de Pesquisas Espaciais (INPE)","University of North Carolina - Wilmington","SES S.A./EchoStar Satellite Services, LLC","Telebras/Brazilian Air Force","Teen Satellite Project","NASA Langley Research Center","China Satellite Communication Corp. (China Satcom)","Chinese Academy of Launch Vehicle Technology (CALT)","Shanghai Institute of Satellite Engineering","Chinese Academy of Space Techology (CAST)","National Space Program","Italian Defense Ministry/Telespazio","Italian Defense Ministry/French Defense Ministry","ESA/Royal Meteorological Institute, Belgium","SRI International","Sirius XM Holdings","XM Satellite Radio","China Siwei Surveying and Mapping Technology Co. Ltd.","Sky Mexico","NBN Co (operated by Optus)","DirecTV Latin America/Intelsat","Skykraft","Intelsat/Paradigm Secure Communications (wholly owned by EADS Astrium)","Ministry of Defense/Paradigm Secure Communications (wholly owned by EADS Astrium)","LightSquared","Centre National d'Etudes Spatiales (CNES)/European Space Agency (ESA)","US Southern Command","Seoul National University","National Institute of Information and Communications Technology (JPN)","Astrotech SOHLA","Technical University Dresden","University of Würzburg","National Aeronautics and Space Administration (NASA) Earth Science Office/Laboratory for Atmospheric and Space Physics, Univ. of Colorado","COSMIAC (Configurable Space Microsystems Innovations & Applications Center)","South Asian Association for Regional Cooperation","Swarm Technologies","Hughes Space and Communications Co.","Hisdesat/Ministry of Defense","Air Force Research Laboratory/Swedish Defense Materiel Administration","Omnispace","Endurosat","Astro Space Center of Moscow/Russian Academy of Sciences","Ministry of Defense/General Delegation for Ordnance (DGA)","Spot Image","SRM University","Government of Sudan","Chilean Air Force","Beihang University/APSCO","Asia-Pacific Space Cooperation Organization (APSCO)","Singapore Telecommunications Ltd. (SingTel)/Chunghwa Telecom Co. Ltd.","Sony","Scanway/German Orbital Systems","SatRevolution/Steamjet","Sternula","SatRevolution","Atlas 5","Synspective","Missile Defense Agency (MDA)","University of Chile","Sky Perfect JSAT Corporation/DSN Corp.","Beijing Space View Technology","Goddard Space Flight Center/Penn State University","Ecole Polytechnique Fédérale de Lanne (EPFL)","Naval Research Laboratory","Operational Responsive Space (ORS) Office","Kenya Space Agency","Beijing MinoSpace Technology Co., Ltd.","Minospace","German Aerospace Center (DLR)/Astrium","Alibaba","Orbit Fab","Herzliya Science Center","National Aeronautics and Space Administration (NASA)/SES Americom (SES [Société Européenne des Satellites (SES)])","National Aeronautics and Space Administration (NASA)/Ames Research Center","Teikyo University","AgilSpace","Telesat Canada Ltd. (BCE, Inc.)/APT Satellite Holdings Ltd.","Colorado State University","German Aerospace Center (DLR)/Infoterra","TerraStar Corporation","U.S. Space Force/Other Transaction Authority","Thaicom Public Company Ltd.","National Aeronautics and Space Administration (NASA)/University of California, Berkeley (in partnership with Germany, France, Austria, Canada)","Geo-Informatics and Space Technology Development Agency (GISTDA)","Telenor Satellite Broadcasting","Thuraya Satellite Communications","China Manned Space Engineering Office (CMSEO)","Tianjin University","Chinese Academy of Launch Vehicle Technology (CASIC)","Xiyong Microelectrics Park","Guodian Gaoke","GasTianta and GZH-HNJ BDS AGR Co., Ltd.","Sun Yat-sen University","Insight Position Digital Intelligence Technology Service Co., Ltd.","China Telecom","National University of Defense Technology","China Electronic Technology Group","CASIOM","Spacety Aerospace Company/ThrustMe","Spacety Aerospace Company/Changsha Gaoxinqu Tianyi Research Institute","Chinese Academy of Sciences (CAS)/China Academy of Space Technology","Institute of Software Chinese Academy of Sciences","La Sapienza University of Rome","National Aeronautics and Space Administration (NASA)/Applied Physics Laboratory, Johns Hopkins","Bolivia Aerospace Bureau","Tomorrow.io","University of Malibor/SkyLabs","MyRadar","Tallinn University of Technology","Technical University of Graz","Turkmenistan Ministry of Communications/SSI-Monaco","Turksat","Elbit Systems","UK/DMC International Imaging (DMCII)","UMBRA","Alba Orbital","Wakayama University (UNIFORM consortium)","Polytechnic University of Madrid","University of Versailles Saint-Quentin-en-Yvelines (UVSQ)/National Centre for Scientific Research (CNRS)","NASA/Carnegie Mellon University","National Aeronautics and Space Administration (NASA)/Johns Hopkins University Applied Physics Laboratory","VISIONA Tecnologia Espacial S.A.","Care Weather Technologies","Nanyang Technological University","Centre National d'Etudes Spatiales (CNES)/Israel Space Agency","ViaSat, Inc.","Momentus, Inc.","Vietnamese Posts and Telecommunications Group (VNPT)","C3S LLC","Space Technology Institute-Vietnam Academy of Science and Technology (STI-VAST)","Bolivarian Agency for Space Activities","Shanghai Micro Satellite Engineering Center","CENTI (China-Europe Networks of technology and Innovation)","Military Satellite Communications - US Air Force","WildBlue Communications","La Sapienza University of Rome/Machakos University/University of Nairobi, Kenya","Weathernews, Inc.","Maxar Technologies Inc.","Centre for Research in Satellite Technology (CREST)","MinoSpace Technology","Chengdu Guoxing Aerospace Technology Co. Ltd.","Xingyun Satellite Co.","DFH Satellite Co. Ltd.","European Space Operations Centre (ESOC)","Ministry of Defense/XTAR","DFH Satellite Co. Ltd./AMSAT-China","Chinese Amateur Satellite (CAMSAT)","Loft Orbital/Fugro","Loft Orbital","Gazprom Space Systems","Bauman Moscow State Technical University","GalaxySpace","Galaxy Space","Hubei University of Technology (HUT)","Mitsubishi Heavy Industries","French South African Institute of Technology/Cape Peninsula University of Technology","QOSMOSYS","China Earthquake Administration/Italian Institute for Nuclear Physics","Zhejiang University","Zhuhai Orbita Control Engineering Co. Ltd.","Zhuhai Orbita Aerospace Science and Technology Co."],"operatorCountry":["","USA","United Kingdom","Finland","Denmark","Multinational","Israel","Austria","ESA","Lithuania","Norway","Spain","United Arab Emirates","Kazakhstan","Algeria","Japan","Brazil","Russia","South Korea","Angola","Canada","Argentina","USA/Argentina","China","Belgium","Turkey","Luxembourg","Switzerland","India","France/Italy","Singapore","Azerbaijan","Bangladesh","Czech Republic","Germany","Belarus","Netherlands","Indonesia","France","Australia","Bulgaria","China/Brazil","China/France","Tunisia","Taiwan","Italy","Mexico","Ecuador","Egypt","USA/Canada/Japan","USA/Japan/Brazil","Ethiopia","Colombia","USA/Japan","USA/Germany","France/Italy/Belgium/Spain/Greece","Greece","Greece/United Kingdom","United Kingdom/ESA","Malaysia","USA/India/Singapore/Taiwan","ESA/Russia","Thailand","USA/France","Japan/Singapore","Jordan","Iran","Saudi Arabia","Kuwait","United Kingdom/Netherlands","Laos","Morocco/Germany","South Africa","India/France","Vietnam","Morocco","USA/Canada","Slovenia","Sinapore","Nigeria","Sweden","Pakistan","Peru","New Zealand","Chile","Ukraine","Hungary","India/Canada","Monaco","Nepal","France/Belgium/Sweden","Sudan","Singapore/Taiwan","USA/Poland","Poland/UK","Poland","USA/United Kingdom/Italy","Kenya","Iraq","Bolivia","Estonia","Turkmenistan/Monaco","France/Israel","Venezuela","China/Italy"],"purpose":["","Earth Observation","Technology Development","Communications","Earth Science","Space Science","Space Science/Technology Demonstration","Communications/Technology Development","Communications/Maritime Tracking","Technology Demonstration","Unknown","Navigation/Global Positioning","Earth Observation/Technology Development","Earth Observation/Communications","Earth/Space Observation","Educational","Earth Observation/Earth Science","Platform","Earth Observation/Space Science","Earth Observation/Navigation","Communications/Navigation","Space Observation","Surveillance","Navigation/Regional Positioning","Space Science/Technology Development","Mission Extension Technology","Earth Science/Earth Observation","Earth Observation/Communications/Space Science","Meteorological","Technology Development/Educational","Satellite Positioning"]},"records":[[7530,30,1,3],[19548,141,1,3],[20253,213,1,3],[20580,286,8,5],[20776,525,2,3],[21639,27,1,3],[22049,237,5,5],[22314,575,1,3],[22491,503,16,1],[22787,213,1,3],[22988,9,1,3],[23223,376,1,1],[23467,213,1,3],[23553,390,76,3],[23613,141,1,3],[23628,9,1,3],[23855,376,1,1],[23893,8,1,3],[24674,293,2,3],[24753,162,1,1],[24812,221,1,3],[24876,399,1,11],[24916,221,1,3],[24920,215,1,1],[24936,171,1,3],[25019,9,1,3],[25034,376,1,1],[25071,27,26,3],[25112,432,1,3],[25113,432,1,3],[25114,432,1,3],[25115,432,1,3],[25116,432,1,3],[25117,432,1,3],[25118,432,1,3],[25119,432,1,3],[25153,293,2,3],[25159,432,1,3],[25258,213,1,3],[25336,8,1,1],[25338,414,1,1],[25414,432,1,3],[25415,432,1,3],[25416,432,1,3],[25418,432,1,3],[25419,432,1,3],[25420,432,1,3],[25462,27,26,3],[25475,432,1,3],[25477,432,1,3],[25481,432,1,3],[25504,503,16,1],[25544,300,5,5],[25639,61,2,3],[25652,241,1,3],[25682,346,1,1],[25757,160,34,4],[25867,110,1,5],[25924,5,5,3],[25933,399,1,11],[25967,213,1,3],[25980,432,1,3],[25981,432,1,3],[25982,432,1,3],[25984,432,1,3],[25985,432,1,3],[25989,646,8,5],[25991,162,1,1],[25994,187,49,1],[26038,219,1,3],[26052,9,1,3],[26107,48,1,3],[26356,9,1,1],[26360,399,1,11],[26369,13,5,3],[26388,141,1,3],[26390,518,1,3],[26407,399,1,11],[26410,490,8,5],[26411,490,8,5],[26451,219,1,3],[26463,490,8,5],[26464,490,8,5],[26469,78,16,3],[26473,8,1,1],[26483,518,1,3],[26554,27,36,3],[26575,9,1,3],[26580,27,1,3],[26590,221,1,3],[26605,399,1,11],[26608,219,1,3],[26624,35,20,3],[26626,518,1,3],[26638,27,26,3],[26690,399,1,11],[26694,429,45,3],[26702,425,80,1],[26715,9,1,3],[26719,13,5,3],[26766,219,1,3],[26824,221,1,3],[26853,27,26,3],[26880,9,1,1],[26900,221,1,3],[26958,11,8,9],[26959,70,34,12],[26998,606,1,5],[27004,369,71,2],[27056,61,17,3],[27057,61,17,3],[27168,9,1,3],[27378,173,1,3],[27380,221,1,3],[27389,141,1,3],[27399,312,15,3],[27400,27,26,3],[27414,27,36,3],[27424,189,50,1],[27426,54,1,3],[27434,61,6,1],[27438,221,1,3],[27445,222,1,3],[27460,13,5,3],[27464,61,17,3],[27465,61,17,3],[27509,380,5,1],[27513,221,1,3],[27528,273,11,3],[27540,298,61,5],[27566,141,1,3],[27603,27,36,3],[27606,349,21,3],[27607,500,67,3],[27612,349,21,3],[27640,130,1,1],[27651,535,1,5],[27663,399,1,11],[27691,9,1,3],[27704,399,1,11],[27711,9,1,3],[27715,219,1,3],[27811,266,56,3],[27825,588,12,3],[27831,431,39,3],[27843,97,20,5],[27848,140,15,2],[27854,175,1,3],[27858,97,20,5],[27868,61,17,3],[27869,61,17,3],[27875,9,1,3],[27937,8,1,1],[27939,389,17,2],[27954,220,53,3],[28051,95,28,1],[28054,162,1,1],[28058,117,23,3],[28089,652,17,3],[28117,213,1,3],[28158,9,1,1],[28184,5,5,3],[28187,13,5,3],[28190,399,1,11],[28220,88,23,1],[28252,27,1,3],[28358,221,1,3],[28366,36,22,7],[28369,499,67,2],[28370,499,67,2],[28371,499,67,1],[28372,36,21,7],[28376,188,1,1],[28378,35,20,3],[28384,376,1,3],[28413,88,23,5],[28414,88,23,5],[28419,61,17,3],[28420,61,17,3],[28446,27,1,3],[28470,88,23,1],[28474,399,1,11],[28485,564,96,5],[28492,265,55,1],[28493,394,11,3],[28526,27,36,3],[28537,501,1,1],[28541,501,1,1],[28542,647,11,3],[28626,519,1,3],[28628,293,2,3],[28646,8,1,1],[28649,95,28,1],[28654,414,1,1],[28659,54,1,3],[28702,221,1,3],[28707,202,17,3],[28737,88,23,5],[28786,584,62,3],[28790,219,1,3],[28810,483,15,2],[28868,35,20,3],[28874,399,1,11],[28884,219,1,3],[28885,190,38,3],[28888,8,1,1],[28890,67,23,1],[28899,293,2,3],[28903,54,1,3],[28908,61,17,3],[28912,380,5,1],[28924,13,5,3],[28935,173,1,3],[28945,13,5,3],[28946,540,11,3],[29045,312,15,3],[29047,133,44,1],[29048,133,44,1],[29050,133,44,1],[29051,133,44,1],[29052,133,44,1],[29055,27,26,3],[29079,191,6,1],[29107,125,1,1],[29108,92,38,4],[29155,9,1,1],[29162,197,5,3],[29236,219,1,3],[29249,8,1,1],[29252,234,1,2],[29268,96,18,12],[29270,13,5,3],[29272,312,15,3],[29273,190,38,3],[29349,337,18,3],[29479,271,5,5],[29486,399,1,11],[29494,54,1,3],[29495,430,39,3],[29505,88,23,5],[29506,88,23,5],[29520,519,1,3],[29522,162,1,1],[29526,38,5,3],[29601,399,1,11],[29643,637,1,3],[29644,27,1,3],[29658,128,34,1],[29670,61,17,11],[29671,61,17,11],[29709,348,37,12],[29710,95,28,1],[30580,585,5,5],[30776,206,1,2],[30794,526,2,3],[30797,585,5,5],[30798,585,5,5],[31102,35,20,3],[31118,499,67,1],[31119,499,67,3],[31121,499,67,3],[31124,499,67,3],[31125,499,67,3],[31127,499,67,3],[31304,15,1,1],[31306,27,26,3],[31307,221,1,3],[31571,241,1,3],[31573,241,1,3],[31574,241,1,3],[31576,241,1,3],[31598,134,45,1],[31601,61,6,1],[31698,581,34,1],[31701,501,1,1],[31708,501,1,1],[31789,234,1,2],[31792,61,17,1],[31797,128,34,1],[31800,509,23,3],[31862,54,1,3],[32018,539,1,3],[32019,85,15,3],[32060,640,1,1],[32252,430,39,3],[32253,219,1,3],[32258,636,1,3],[32260,399,1,11],[32263,241,1,3],[32264,241,1,3],[32265,241,1,3],[32275,61,17,11],[32276,61,17,11],[32283,128,34,1],[32293,78,16,3],[32294,526,2,3],[32299,27,80,3],[32376,134,45,1],[32378,376,1,3],[32382,474,20,1],[32384,399,1,11],[32388,280,53,3],[32393,61,17,11],[32395,61,17,11],[32404,588,12,3],[32476,190,6,1],[32478,202,17,3],[32487,587,10,3],[32706,8,1,1],[32711,399,1,11],[32729,54,1,3],[32750,128,34,1],[32763,173,1,3],[32767,630,74,3],[32768,78,16,3],[32779,322,23,3],[32783,95,28,1],[32789,151,36,2],[32790,81,20,2],[32791,406,15,2],[32794,29,6,3],[32951,221,1,3],[32954,61,17,3],[32955,61,17,3],[32956,61,17,3],[33051,509,23,3],[33053,211,1,5],[33055,526,2,3],[33056,614,25,3],[33153,221,1,3],[33154,38,5,3],[33207,173,1,3],[33244,128,34,1],[33274,312,15,3],[33275,27,1,3],[33278,293,2,3],[33320,274,23,1],[33321,274,23,1],[33331,236,1,1],[33373,35,20,3],[33376,221,1,3],[33396,586,62,1],[33401,301,1,5],[33408,88,23,5],[33409,88,23,5],[33412,134,45,1],[33433,88,23,1],[33434,117,23,1],[33436,27,26,3],[33446,65,23,1],[33453,120,20,3],[33459,13,5,3],[33460,13,5,3],[33490,8,1,1],[33492,146,15,1],[33496,532,15,2],[33499,334,15,2],[33591,414,1,1],[33595,202,17,3],[33749,27,36,3],[33750,13,5,3],[33751,545,38,2],[33752,545,38,2],[34111,35,20,3],[34710,13,5,3],[34713,636,1,3],[34810,514,45,3],[34839,65,23,1],[34903,560,1,2],[34941,27,26,3],[35362,372,59,3],[35491,244,1,1],[35493,518,1,3],[35496,582,1,3],[35498,61,17,3],[35500,61,17,3],[35681,150,11,1],[35683,616,2,1],[35684,36,22,8],[35685,394,11,7],[35686,36,22,8],[35696,47,23,3],[35752,399,1,11],[35755,312,15,3],[35756,430,39,3],[35815,440,1,1],[35873,410,20,3],[35932,565,27,2],[35935,309,25,2],[35942,25,11,3],[35943,128,34,3],[35946,640,1,1],[35951,162,1,1],[36032,27,36,3],[36033,587,10,3],[36036,528,8,1],[36037,11,8,9],[36088,88,23,5],[36095,61,17,1],[36097,221,1,3],[36101,13,5,3],[36106,299,1,3],[36108,636,1,3],[36110,65,23,1],[36111,61,17,11],[36112,61,17,11],[36121,65,23,1],[36122,648,23,3],[36124,265,55,1],[36131,54,1,3],[36358,61,17,3],[36395,141,1,5],[36397,221,1,3],[36400,61,17,11],[36401,61,17,11],[36402,61,17,11],[36411,244,1,1],[36413,65,23,1],[36414,65,23,1],[36415,65,23,1],[36499,173,1,3],[36508,11,8,1],[36516,27,26,3],[36581,27,26,3],[36582,128,34,3],[36585,399,1,11],[36592,38,5,3],[36596,88,23,2],[36605,571,34,1],[36608,61,6,1],[36744,96,18,13],[36745,38,5,3],[36792,173,1,3],[36795,95,28,1],[36797,16,10,3],[36798,20,14,1],[36828,65,23,11],[36830,409,48,3],[36831,480,5,3],[36834,65,23,1],[36985,322,23,1],[37139,61,17,11],[37150,509,23,3],[37152,61,17,3],[37154,61,17,3],[37158,146,15,23],[37162,8,1,1],[37165,65,23,1],[37166,661,23,5],[37167,661,23,5],[37168,502,1,21],[37179,88,23,1],[37180,88,23,1],[37185,518,1,3],[37188,241,1,3],[37189,241,1,3],[37190,241,1,3],[37191,241,1,3],[37192,241,1,3],[37193,241,1,3],[37207,85,15,3],[37210,65,23,11],[37212,377,17,3],[37214,210,23,1],[37216,134,45,1],[37218,527,1,3],[37224,422,1,5],[37227,208,1,2],[37232,8,1,1],[37234,229,23,3],[37237,289,2,3],[37238,221,1,3],[37256,65,23,11],[37258,13,5,3],[37264,273,11,3],[37265,338,18,3],[37344,180,17,1],[37348,8,1,1],[37364,8,1,2],[37372,61,17,11],[37377,376,1,3],[37384,65,23,11],[37386,501,1,1],[37387,485,87,1],[37388,95,28,5],[37389,641,30,1],[37391,501,1,1],[37392,221,1,3],[37393,18,12,3],[37398,377,17,3],[37481,9,1,1],[37602,35,20,3],[37605,95,28,20],[37606,552,92,3],[37677,509,23,3],[37730,88,23,2],[37737,322,23,3],[37739,241,1,3],[37740,241,1,3],[37741,241,1,3],[37742,241,1,3],[37743,241,1,3],[37744,241,1,3],[37746,95,28,3],[37748,27,26,3],[37749,326,13,3],[37753,399,1,11],[37755,544,5,5],[37763,65,23,11],[37765,88,23,2],[37775,27,26,3],[37776,86,15,3],[37779,438,81,3],[37781,226,23,1],[37789,408,79,1],[37790,408,79,12],[37791,479,25,1],[37792,36,20,3],[37793,199,20,3],[37804,229,23,3],[37809,27,26,3],[37810,38,5,3],[37813,291,15,1],[37816,198,5,3],[37818,566,1,2],[37826,472,1,3],[37834,221,1,3],[37836,13,5,3],[37838,373,73,1],[37841,547,28,2],[37843,628,1,3],[37846,11,8,11],[37847,11,8,11],[37849,419,1,1],[37855,367,1,2],[37867,61,17,11],[37868,61,17,11],[37869,61,17,11],[37875,65,23,1],[37930,117,23,1],[37931,88,23,1],[37933,47,23,3],[37941,65,23,1],[37948,65,23,11],[37951,360,17,3],[37954,291,15,1],[38007,182,38,1],[38008,182,38,1],[38009,182,38,1],[38010,182,38,1],[38011,549,84,1],[38012,453,29,1],[38014,407,79,3],[38038,275,23,1],[38040,241,1,3],[38041,241,1,3],[38042,241,1,3],[38043,241,1,3],[38044,241,1,3],[38045,241,1,3],[38046,275,23,1],[38049,210,23,1],[38070,636,1,3],[38087,27,26,3],[38091,65,23,11],[38093,391,1,3],[38098,221,1,3],[38107,37,23,3],[38109,8,1,1],[38245,18,12,3],[38248,61,28,1],[38250,65,23,11],[38251,65,23,11],[38254,9,1,3],[38256,322,23,1],[38257,65,23,1],[38331,312,15,3],[38332,630,74,3],[38337,146,53,1],[38338,96,18,1],[38339,146,15,2],[38340,282,15,2],[38342,35,20,3],[38352,229,23,3],[38354,65,23,1],[38356,221,1,3],[38358,141,1,5],[38466,376,1,3],[38528,8,1,1],[38551,173,1,3],[38552,380,5,1],[38652,27,26,3],[38707,323,17,1],[38708,74,35,1],[38710,72,34,2],[38730,322,23,3],[38733,61,17,3],[38740,221,1,3],[38741,289,2,3],[38749,221,1,3],[38752,623,1,1],[38753,623,1,1],[38755,546,90,1],[38758,501,1,1],[38759,262,1,2],[38760,10,1,2],[38764,121,1,5],[38766,262,1,2],[38771,381,5,1],[38773,501,1,1],[38775,65,23,11],[38778,27,26,3],[38779,95,28,3],[38782,633,103,1],[38833,399,1,11],[38857,11,8,11],[38858,11,8,11],[38860,463,23,2],[38861,463,23,2],[38867,221,1,3],[38908,200,1,2],[38953,65,23,11],[38977,360,17,3],[38978,652,17,3],[38991,78,16,3],[38992,13,5,3],[38995,377,17,3],[38998,88,23,2],[38999,645,23,2],[39008,173,1,3],[39011,65,23,1],[39012,65,23,1],[39013,65,23,1],[39017,509,23,3],[39019,454,38,1],[39020,13,5,3],[39022,652,17,3],[39030,245,25,1],[39034,526,2,3],[39035,383,46,3],[39057,61,17,3],[39058,61,17,3],[39059,61,17,3],[39061,291,15,1],[39062,291,15,1],[39068,96,18,2],[39070,301,1,3],[39072,241,1,3],[39073,241,1,3],[39074,241,1,3],[39075,241,1,3],[39076,241,1,3],[39077,241,1,3],[39078,25,11,3],[39079,58,31,3],[39084,346,1,1],[39086,373,73,1],[39088,495,20,21],[39089,97,20,21],[39091,612,7,2],[39120,9,1,1],[39122,197,5,3],[39127,35,20,3],[39150,224,23,1],[39155,61,17,11],[39157,509,23,3],[39159,11,8,1],[39160,632,74,1],[39163,13,5,3],[39166,399,1,11],[39168,636,1,3],[39172,27,26,3],[39177,61,17,1],[39186,360,17,1],[39188,423,2,3],[39189,423,2,3],[39190,423,2,3],[39191,423,2,3],[39197,141,1,5],[39199,95,28,23],[39202,88,23,2],[39206,391,1,3],[39208,88,23,2],[39209,117,23,2],[39210,88,23,2],[39215,294,58,7],[39216,95,28,1],[39222,636,1,3],[39227,96,18,1],[39232,8,1,1],[39233,192,5,3],[39234,253,28,3],[39237,29,6,3],[39239,65,23,1],[39240,65,23,1],[39241,65,23,1],[39249,246,17,3],[39250,246,17,3],[39251,246,17,3],[39253,146,15,5],[39256,9,1,3],[39260,210,23,1],[39265,97,20,4],[39285,27,26,3],[39358,88,23,2],[39360,518,1,3],[39375,61,17,3],[39410,65,23,1],[39416,36,20,3],[39417,658,72,29],[39418,164,1,1],[39419,168,12,1],[39420,428,11,2],[39423,639,15,1],[39425,36,22,8],[39430,230,4,2],[39440,138,21,2],[39444,218,36,3],[39451,11,8,1],[39452,11,8,1],[39453,11,8,1],[39455,88,23,1],[39460,27,26,3],[39462,8,1,1],[39467,23,1,2],[39468,529,1,3],[39472,262,1,2],[39473,567,1,2],[39474,262,1,2],[39476,293,2,3],[39481,607,99,3],[39483,61,17,3],[39484,61,17,3],[39485,61,17,3],[39487,202,17,3],[39498,95,28,3],[39500,584,62,3],[39504,141,1,3],[39508,5,5,3],[39509,56,29,3],[39522,614,25,3],[39533,399,1,11],[39574,250,53,1],[39612,202,17,3],[39613,202,17,3],[39616,25,11,3],[39617,27,26,3],[39624,88,23,2],[39634,380,8,1],[39635,95,28,23],[39650,61,6,1],[39652,376,1,1],[39727,360,17,3],[39728,326,13,3],[39731,325,13,1],[39741,399,1,11],[39751,376,1,3],[39761,61,17,3],[39762,61,17,3],[39763,61,17,3],[39766,146,15,1],[39767,619,15,1],[39768,531,15,2],[39769,487,15,1],[39770,406,15,2],[39773,13,5,3],[40001,61,17,11],[40010,325,13,1],[40011,276,15,2],[40013,150,11,1],[40014,89,21,2],[40015,276,15,2],[40018,36,1,8],[40019,36,22,8],[40020,81,20,5],[40037,447,17,3],[40039,447,17,3],[40042,458,85,2],[40043,605,98,1],[40044,352,1,2],[40045,12,1,2],[40046,12,1,2],[40053,546,90,1],[40055,81,20,2],[40056,81,20,2],[40059,141,1,1],[40061,246,17,3],[40062,246,17,3],[40063,246,17,3],[40069,379,17,1],[40070,484,17,1],[40072,164,1,1],[40075,16,10,3],[40079,423,2,3],[40080,423,2,3],[40081,423,2,3],[40082,423,2,3],[40086,432,1,3],[40087,432,1,3],[40091,432,1,3],[40100,255,1,21],[40105,399,1,11],[40107,47,23,3],[40109,65,23,1],[40110,65,23,1],[40111,65,23,1],[40115,640,1,1],[40118,224,23,1],[40119,82,5,5],[40128,11,8,11],[40129,11,8,11],[40136,356,23,2],[40137,117,23,1],[40141,47,23,3],[40143,65,23,1],[40144,598,23,13],[40146,430,39,3],[40147,372,59,3],[40208,124,1,3],[40258,61,17,3],[40261,88,23,2],[40267,270,15,1],[40269,95,28,23],[40271,221,1,3],[40272,44,21,3],[40275,65,23,1],[40277,202,17,3],[40286,88,23,2],[40294,399,1,11],[40296,377,17,3],[40298,49,15,1],[40301,282,15,1],[40305,65,23,1],[40310,65,23,1],[40315,61,17,11],[40332,95,28,3],[40333,54,1,3],[40336,98,41,1],[40338,65,23,1],[40339,65,23,1],[40340,65,23,1],[40344,8,1,1],[40345,652,17,3],[40348,423,2,3],[40349,423,2,3],[40350,423,2,3],[40351,423,2,3],[40358,61,17,1],[40360,360,17,27],[40362,65,23,1],[40364,27,26,3],[40367,210,23,1],[40374,391,1,3],[40376,141,1,1],[40380,135,1,1],[40381,291,15,1],[40384,293,2,3],[40420,61,17,1],[40424,5,5,3],[40425,13,5,3],[40482,141,1,5],[40483,141,1,5],[40484,141,1,5],[40485,141,1,5],[40505,202,17,3],[40534,399,1,11],[40536,96,18,1],[40538,291,15,1],[40544,11,8,11],[40545,11,8,11],[40547,95,28,23],[40549,65,23,11],[40552,246,17,3],[40553,246,17,3],[40554,246,17,3],[40555,61,17,2],[40613,587,10,3],[40614,515,29,3],[40617,613,101,3],[40657,232,1,2],[40663,54,1,3],[40664,521,46,3],[40697,380,8,1],[40699,61,17,1],[40701,229,23,1],[40715,99,2,1],[40716,99,2,1],[40717,99,2,1],[40718,99,2,2],[40730,399,1,11],[40732,380,5,1],[40733,78,16,3],[40746,636,1,3],[40748,65,23,11],[40749,65,23,11],[40874,221,1,3],[40875,198,5,3],[40878,65,23,1],[40880,95,28,3],[40882,293,2,3],[40887,391,1,3],[40889,11,8,11],[40890,11,8,11],[40892,88,23,2],[40894,224,23,1],[40895,202,17,3],[40899,598,23,2],[40901,661,23,2],[40902,661,23,2],[40903,649,23,3],[40904,322,23,2],[40905,333,23,2],[40906,649,23,3],[40907,649,23,3],[40908,354,23,2],[40910,649,23,3],[40911,649,23,3],[40912,149,23,2],[40914,333,23,2],[40920,61,17,3],[40921,61,17,3],[40922,61,17,3],[40925,463,23,2],[40930,53,28,5],[40931,348,37,1],[40932,352,1,1],[40933,352,1,1],[40934,352,1,1],[40935,352,1,1],[40936,199,20,3],[40938,65,23,11],[40940,522,39,3],[40941,44,21,3],[40946,383,46,3],[40958,359,23,1],[40959,315,23,1],[40960,315,23,1],[40961,315,23,1],[40964,501,1,1],[40965,12,1,2],[40966,12,1,2],[40968,73,1,12],[40970,262,1,2],[40972,262,1,2],[40973,461,1,5],[40974,517,1,9],[40975,262,1,2],[40976,461,1,5],[40977,517,1,9],[40981,501,1,1],[40982,37,23,3],[40984,614,25,3],[40988,322,23,1],[41019,399,1,11],[41021,509,23,3],[41026,65,23,1],[41028,95,28,3],[41029,38,5,3],[41032,61,17,1],[41034,347,70,3],[41036,35,20,3],[41038,65,23,1],[41103,509,23,3],[41105,180,17,1],[41121,61,17,3],[41166,626,30,2],[41167,328,30,1],[41168,57,30,2],[41169,578,30,1],[41171,626,30,2],[41173,117,23,5],[41174,11,8,11],[41175,11,8,11],[41179,432,1,3],[41182,432,1,3],[41183,432,1,3],[41184,432,1,3],[41185,432,1,3],[41186,432,1,3],[41187,432,1,3],[41188,432,1,3],[41189,432,1,3],[41191,202,17,3],[41194,224,23,1],[41238,69,35,3],[41240,311,63,1],[41241,95,28,23],[41310,13,5,3],[41328,399,1,11],[41330,61,17,11],[41334,8,1,1],[41335,380,8,1],[41380,27,26,3],[41382,13,5,3],[41384,95,28,23],[41386,360,17,1],[41394,61,17,1],[41434,65,23,11],[41456,380,8,1],[41460,4,4,1],[41464,28,17,5],[41469,95,28,23],[41471,312,15,3],[41473,65,23,1],[41549,11,8,11],[41550,11,8,11],[41552,584,62,3],[41554,61,17,11],[41556,275,23,1],[41579,61,17,4],[41581,221,1,3],[41584,8,1,1],[41586,65,23,11],[41588,5,5,3],[41589,197,5,3],[41591,80,37,3],[41592,173,1,3],[41599,95,28,1],[41601,164,1,1],[41602,239,20,4],[41603,348,37,1],[41604,72,34,1],[41605,368,20,1],[41622,391,1,3],[41634,88,23,2],[41724,376,1,3],[41725,597,23,3],[41727,226,23,1],[41729,312,15,3],[41731,471,23,5],[41744,255,1,21],[41745,255,1,21],[41747,221,1,3],[41748,221,1,3],[41752,95,28,1],[41759,61,6,1],[41770,448,82,1],[41771,164,1,1],[41772,164,1,1],[41773,164,1,1],[41774,164,1,1],[41784,95,28,2],[41785,20,14,1],[41786,20,14,1],[41787,75,1,1],[41788,81,20,2],[41789,24,14,2],[41790,95,28,1],[41793,95,28,3],[41794,522,39,3],[41836,270,15,1],[41838,509,23,2],[41841,322,23,2],[41849,481,1,2],[41850,102,1,2],[41852,12,1,2],[41853,12,1,2],[41854,215,1,2],[41855,215,1,2],[41857,463,23,1],[41859,11,8,11],[41860,11,8,11],[41861,11,8,11],[41862,11,8,11],[41866,244,1,1],[41869,322,23,3],[41871,352,1,1],[41872,352,1,1],[41873,352,1,1],[41874,352,1,1],[41875,245,25,1],[41877,95,28,1],[41879,636,1,3],[41882,210,23,1],[41884,143,1,1],[41885,143,1,1],[41886,143,1,1],[41887,143,1,1],[41888,143,1,1],[41889,143,1,1],[41890,143,1,1],[41891,143,1,1],[41893,174,1,3],[41896,146,15,5],[41898,210,23,1],[41899,112,23,1],[41900,112,23,1],[41901,112,23,1],[41903,312,15,3],[41904,78,16,3],[41907,563,23,1],[41908,563,23,1],[41911,463,23,2],[41913,322,23,2],[41914,315,23,1],[41917,303,1,3],[41918,303,1,3],[41919,303,1,3],[41920,303,1,3],[41921,303,1,3],[41922,303,1,3],[41923,303,1,3],[41924,303,1,3],[41925,303,1,3],[41926,303,1,3],[41937,9,1,1],[41940,61,15,3],[41942,273,11,3],[41944,375,37,3],[41945,523,16,3],[41948,95,28,1],[41949,95,28,1],[41954,95,28,1],[41965,164,1,1],[41966,164,1,1],[41968,164,1,1],[41972,164,1,1],[41975,164,1,1],[41977,164,1,1],[41978,164,1,1],[41988,164,1,1],[41990,164,1,1],[41991,352,1,1],[41992,352,1,1],[41993,352,1,1],[41994,352,1,1],[41995,352,1,1],[41996,352,1,1],[41997,352,1,1],[41998,352,1,1],[42000,157,27,2],[42001,164,1,1],[42009,164,1,1],[42014,164,1,1],[42015,446,36,2],[42018,164,1,1],[42019,164,1,1],[42023,164,1,1],[42026,164,1,1],[42027,164,1,1],[42028,164,1,1],[42029,164,1,1],[42031,164,1,1],[42034,164,1,1],[42041,164,1,1],[42048,164,1,1],[42058,501,1,1],[42061,591,23,12],[42063,380,8,1],[42065,501,1,1],[42070,173,1,3],[42072,291,15,1],[42075,636,1,3],[42432,27,26,3],[42662,88,23,2],[42689,8,1,2],[42691,338,18,3],[42692,506,16,3],[42695,537,28,3],[42698,293,2,3],[42709,27,26,3],[42719,61,17,1],[42738,146,15,23],[42740,628,1,3],[42741,13,5,3],[42747,95,28,3],[42749,173,1,3],[42752,352,1,1],[42753,352,1,1],[42754,352,1,1],[42755,352,1,1],[42758,288,23,5],[42759,662,23,1],[42760,89,21,1],[42761,662,23,1],[42766,412,28,2],[42767,95,28,1],[42769,101,15,2],[42771,352,1,1],[42772,352,1,1],[42773,352,1,1],[42774,352,1,1],[42775,3,3,2],[42777,127,34,2],[42778,370,45,24],[42779,352,1,1],[42780,352,1,1],[42781,352,1,1],[42782,352,1,1],[42783,156,2,2],[42785,156,2,2],[42786,156,2,2],[42792,488,38,4],[42793,118,1,1],[42795,393,1,9],[42801,90,40,3],[42803,303,1,3],[42804,303,1,3],[42805,303,1,3],[42806,303,1,3],[42807,303,1,3],[42808,303,1,3],[42809,303,1,3],[42810,303,1,3],[42811,303,1,3],[42812,303,1,3],[42814,267,57,3],[42815,95,28,3],[42818,221,1,3],[42825,324,17,1],[42826,416,10,26],[42828,416,10,1],[42829,64,34,9],[42831,212,34,18],[42835,639,15,1],[42837,352,1,1],[42838,352,1,1],[42839,352,1,1],[42840,352,1,1],[42841,352,1,1],[42842,352,1,1],[42844,393,1,2],[42845,352,1,1],[42846,131,1,1],[42847,131,1,1],[42848,118,1,1],[42849,118,1,1],[42900,429,45,1],[42901,627,102,1],[42907,61,17,3],[42915,141,1,3],[42917,146,15,23],[42920,214,44,1],[42921,435,1,9],[42922,155,1,2],[42934,25,11,3],[42939,61,17,11],[42941,8,1,1],[42942,47,23,3],[42945,65,23,1],[42946,65,23,1],[42947,65,23,1],[42949,376,1,3],[42950,221,1,3],[42951,85,15,3],[42954,633,103,1],[42955,303,1,3],[42956,303,1,3],[42957,303,1,3],[42958,303,1,3],[42959,303,1,3],[42960,303,1,3],[42961,303,1,3],[42962,303,1,3],[42963,303,1,3],[42964,303,1,3],[42965,146,15,23],[42967,505,26,3],[42969,380,8,1],[42982,330,1,12],[42984,338,18,3],[42987,164,1,1],[42988,164,1,1],[42989,164,1,1],[42990,164,1,1],[42991,164,1,1],[42992,164,1,1],[42997,164,1,1],[43001,65,23,11],[43002,65,23,11],[43005,387,75,1],[43010,210,23,1],[43011,264,23,1],[43013,414,1,1],[43014,87,39,2],[43017,476,1,2],[43022,315,23,1],[43023,315,23,1],[43024,315,23,1],[43028,65,23,1],[43029,65,23,1],[43030,65,23,1],[43032,61,17,1],[43034,229,23,1],[43039,20,14,3],[43041,352,1,1],[43045,352,1,1],[43047,352,1,1],[43048,352,1,1],[43050,305,1,2],[43051,352,1,1],[43053,352,1,1],[43054,352,1,1],[43055,11,8,11],[43056,11,8,11],[43057,11,8,11],[43058,11,8,11],[43065,146,15,1],[43070,303,1,3],[43071,303,1,3],[43072,303,1,3],[43073,303,1,3],[43074,303,1,3],[43075,303,1,3],[43076,303,1,3],[43077,303,1,3],[43078,303,1,3],[43079,303,1,3],[43080,229,23,1],[43081,65,23,1],[43082,65,23,1],[43083,65,23,1],[43099,563,23,1],[43100,563,23,1],[43107,65,23,11],[43108,65,23,11],[43111,95,28,1],[43113,35,20,3],[43114,290,3,1],[43115,100,2,2],[43116,95,28,2],[43118,131,1,1],[43123,352,1,1],[43124,352,1,1],[43125,352,1,1],[43126,352,1,1],[43127,51,1,2],[43130,43,1,2],[43131,386,1,1],[43143,118,1,1],[43144,393,1,9],[43145,8,1,1],[43146,229,23,1],[43152,49,15,1],[43155,602,23,2],[43156,287,23,2],[43157,329,2,3],[43158,602,23,2],[43159,315,23,1],[43160,315,23,1],[43162,9,1,1],[43165,352,1,1],[43167,352,1,1],[43169,634,23,2],[43170,65,23,1],[43171,65,23,1],[43172,65,23,1],[43174,18,12,3],[43175,27,26,3],[43178,249,26,3],[43180,324,17,1],[43181,324,17,1],[43182,352,1,1],[43183,352,1,1],[43184,352,1,1],[43185,352,1,1],[43186,64,34,2],[43187,64,34,2],[43188,64,34,2],[43189,64,34,2],[43192,209,23,2],[43194,660,104,4],[43195,89,21,1],[43196,230,4,2],[43197,230,4,1],[43199,507,23,2],[43204,89,21,1],[43207,65,23,11],[43208,65,23,11],[43215,444,11,1],[43223,291,15,1],[43226,244,1,1],[43228,273,11,3],[43231,423,2,3],[43232,423,2,3],[43233,423,2,3],[43234,423,2,3],[43236,229,23,1],[43245,65,23,11],[43246,65,23,11],[43249,303,1,3],[43250,303,1,3],[43251,303,1,3],[43252,303,1,3],[43253,303,1,3],[43254,303,1,3],[43255,303,1,3],[43256,303,1,3],[43257,303,1,3],[43258,303,1,3],[43259,225,23,1],[43260,225,23,1],[43262,225,23,1],[43271,562,15,3],[43272,289,2,3],[43275,65,23,1],[43276,65,23,1],[43277,65,23,1],[43279,634,23,2],[43286,95,28,23],[43339,9,1,3],[43432,61,17,3],[43435,141,1,5],[43437,380,8,1],[43439,663,23,1],[43440,662,23,1],[43441,663,23,1],[43442,663,23,1],[43443,663,23,1],[43446,102,1,2],[43450,37,23,3],[43461,225,23,1],[43463,59,32,3],[43476,251,54,1],[43477,251,54,1],[43478,303,1,3],[43479,303,1,3],[43480,303,1,3],[43481,303,1,3],[43482,303,1,3],[43484,225,23,1],[43485,365,23,1],[43488,27,26,3],[43491,210,23,1],[43495,291,15,1],[43508,61,17,11],[43518,403,23,2],[43519,403,23,2],[43529,439,81,1],[43530,439,81,1],[43539,65,23,11],[43547,580,1,5],[43556,12,1,2],[43557,12,1,2],[43558,352,1,1],[43559,352,1,1],[43560,352,1,1],[43561,352,1,1],[43562,35,20,3],[43564,11,8,11],[43565,11,8,11],[43566,11,8,11],[43567,11,8,11],[43569,303,1,3],[43570,303,1,3],[43571,303,1,3],[43572,303,1,3],[43573,303,1,3],[43574,303,1,3],[43575,303,1,3],[43576,303,1,3],[43577,303,1,3],[43578,303,1,3],[43581,65,23,11],[43582,65,23,11],[43585,224,23,1],[43587,375,37,3],[43600,11,8,1],[43602,65,23,11],[43603,65,23,11],[43609,226,23,1],[43611,579,20,3],[43613,141,1,4],[43616,181,1,5],[43618,99,2,1],[43619,418,2,1],[43622,65,23,11],[43623,65,23,11],[43632,58,31,3],[43633,280,53,3],[43636,104,23,2],[43641,493,21,1],[43642,65,23,1],[43647,65,23,11],[43648,65,23,11],[43651,9,1,3],[43655,226,23,1],[43656,572,23,3],[43657,61,17,1],[43662,108,42,1],[43668,593,23,3],[43669,111,23,2],[43672,247,15,1],[43676,168,12,1],[43677,282,15,5],[43678,159,15,1],[43683,65,23,11],[43687,61,17,11],[43689,381,5,1],[43690,118,1,1],[43694,103,39,2],[43695,352,1,1],[43696,103,39,2],[43697,352,1,1],[43698,95,28,3],[43700,193,38,3],[43706,65,23,11],[43707,65,23,11],[43710,88,23,5],[43712,603,23,2],[43713,314,23,3],[43717,387,75,1],[43719,95,28,1],[43720,283,1,3],[43721,205,52,1],[43722,103,39,2],[43723,164,1,1],[43724,164,1,1],[43725,164,1,1],[43729,329,20,3],[43730,75,1,1],[43731,352,1,1],[43732,352,1,1],[43733,164,1,1],[43737,119,1,1],[43738,295,59,2],[43741,164,1,1],[43742,164,1,1],[43743,482,3,1],[43744,269,36,2],[43745,352,1,1],[43746,352,1,1],[43747,164,1,1],[43751,61,17,3],[43752,61,17,3],[43753,61,17,3],[43759,443,1,3],[43764,456,1,3],[43765,263,1,22],[43767,131,1,1],[43768,17,11,1],[43769,164,1,1],[43770,30,1,3],[43772,462,1,2],[43773,462,1,2],[43774,269,36,2],[43775,203,28,3],[43780,388,34,2],[43783,327,13,1],[43785,433,1,2],[43790,172,45,1],[43792,195,8,16],[43793,136,1,5],[43794,263,1,22],[43795,433,1,2],[43796,79,1,2],[43797,164,1,1],[43799,263,1,22],[43800,290,3,1],[43802,164,1,1],[43803,321,65,3],[43804,3,3,2],[43805,19,13,2],[43807,72,34,2],[43808,456,1,3],[43809,103,39,3],[43811,405,18,5],[43812,75,1,1],[43813,79,1,2],[43815,206,1,2],[43816,538,1,3],[43817,538,1,3],[43818,538,1,3],[43819,200,1,2],[43820,504,1,1],[43822,200,1,1],[43823,235,18,1],[43824,95,28,3],[43831,500,67,1],[43833,500,67,1],[43836,345,23,2],[43837,345,23,2],[43838,345,23,2],[43839,345,23,2],[43840,345,23,2],[43841,345,23,2],[43842,345,23,2],[43849,12,1,2],[43850,508,1,2],[43853,105,1,5],[43855,116,1,2],[43856,307,1,5],[43857,147,1,15],[43858,139,1,2],[43860,200,1,5],[43861,12,1,2],[43864,254,28,3],[43866,137,38,1],[43867,61,17,3],[43871,279,23,2],[43873,399,1,11],[43874,88,23,2],[43876,324,17,1],[43877,324,17,1],[43879,306,33,3],[43880,534,34,29],[43881,144,34,2],[43882,352,1,1],[43883,352,1,1],[43884,352,1,1],[43885,352,1,1],[43886,352,1,1],[43887,352,1,1],[43888,352,1,1],[43889,352,1,1],[43890,252,15,1],[43893,164,1,1],[43899,164,1,1],[43902,164,1,1],[43905,164,1,1],[43906,164,1,1],[43907,658,72,1],[43908,364,11,3],[43909,463,23,1],[43910,463,23,1],[43911,463,23,1],[43912,463,23,1],[43913,463,23,1],[43914,278,23,3],[43915,463,23,1],[43917,88,23,2],[43920,509,23,3],[43922,303,1,3],[43923,303,1,3],[43924,303,1,3],[43925,303,1,3],[43926,303,1,3],[43927,303,1,3],[43928,303,1,3],[43929,303,1,3],[43930,303,1,3],[43931,303,1,3],[43932,146,15,2],[43934,486,15,1],[43935,385,74,1],[43937,406,15,3],[43941,8,1,1],[43942,357,23,1],[43943,315,23,1],[43944,111,23,2],[43946,315,23,1],[44034,266,56,3],[44035,95,28,3],[44047,178,48,1],[44048,413,37,3],[44057,427,2,3],[44058,427,2,3],[44059,427,2,3],[44060,427,2,3],[44061,427,2,3],[44062,427,2,3],[44067,509,23,3],[44071,636,1,3],[44072,460,45,1],[44076,322,23,3],[44078,185,28,1],[44080,164,1,1],[44084,352,1,1],[44085,352,1,1],[44086,352,1,1],[44087,352,1,1],[44088,164,1,1],[44090,164,1,1],[44095,164,1,1],[44098,164,1,1],[44103,17,11,1],[44105,76,9,2],[44109,76,9,2],[44112,423,2,3],[44113,423,2,3],[44114,423,2,3],[44115,423,2,3],[44186,38,5,3],[44204,65,23,11],[44207,322,23,1],[44209,322,23,1],[44226,541,80,2],[44229,262,1,2],[44231,65,23,11],[44233,61,28,1],[44299,61,17,11],[44307,652,17,3],[44310,315,23,1],[44311,593,23,3],[44312,88,23,1],[44313,111,23,2],[44314,88,23,1],[44315,599,23,2],[44316,599,23,2],[44322,97,20,1],[44323,97,20,1],[44324,97,20,1],[44333,54,1,3],[44334,13,5,3],[44337,65,23,11],[44339,462,1,2],[44340,420,1,2],[44341,436,1,2],[44343,132,44,1],[44344,102,1,2],[44347,206,1,2],[44349,132,44,1],[44350,132,44,1],[44351,132,44,1],[44353,132,44,1],[44354,442,1,2],[44356,424,1,2],[44358,132,44,1],[44360,353,1,2],[44365,437,46,1],[44367,75,1,1],[44387,379,17,1],[44388,176,47,2],[44389,290,3,1],[44390,290,3,1],[44391,421,6,2],[44392,28,17,5],[44394,28,17,5],[44396,352,1,1],[44399,99,2,2],[44400,534,34,2],[44402,352,1,1],[44403,352,1,1],[44404,28,17,5],[44405,352,1,1],[44406,362,33,2],[44407,352,1,1],[44408,179,1,2],[44409,352,1,1],[44411,352,1,1],[44412,64,34,2],[44414,352,1,1],[44415,398,48,2],[44419,310,62,3],[44421,61,17,2],[44422,61,17,2],[44423,61,17,2],[44424,61,17,2],[44449,65,23,1],[44450,65,23,1],[44451,65,23,1],[44453,377,17,3],[44457,61,17,3],[44475,177,8,3],[44476,221,1,3],[44479,29,6,3],[44481,9,1,3],[44482,9,1,2],[44486,467,23,1],[44487,593,23,3],[44488,643,23,1],[44495,83,38,1],[44497,445,1,2],[44498,445,1,2],[44499,75,1,1],[44506,399,1,11],[44517,61,17,4],[44519,111,23,2],[44520,117,23,5],[44528,275,23,1],[44529,320,23,1],[44534,662,23,1],[44536,662,23,1],[44537,662,23,1],[44538,662,23,1],[44539,662,23,1],[44542,65,23,11],[44543,65,23,11],[44552,61,17,1],[44622,224,23,1],[44624,13,5,3],[44625,382,1,25],[44628,141,1,5],[44634,131,1,9],[44637,88,23,2],[44703,228,23,1],[44704,548,91,1],[44705,284,23,3],[44706,601,42,2],[44709,65,23,11],[44777,315,23,1],[44779,411,23,1],[44780,411,23,1],[44781,411,23,1],[44782,411,23,1],[44783,411,23,1],[44785,335,34,2],[44786,335,34,2],[44793,65,23,11],[44794,65,23,11],[44797,61,17,14],[44800,61,48,3],[44801,293,2,3],[44804,95,28,1],[44806,378,1,2],[44808,164,1,1],[44809,164,1,1],[44811,164,1,1],[44812,164,1,1],[44814,164,1,1],[44816,164,1,1],[44818,164,1,1],[44819,224,23,1],[44824,21,15,4],[44835,61,17,14],[44836,315,23,1],[44838,264,23,1],[44839,264,23,1],[44840,593,23,3],[44841,593,23,3],[44842,111,23,2],[44843,111,23,2],[44850,61,17,11],[44852,615,6,2],[44853,615,6,2],[44855,352,1,1],[44856,470,15,1],[44857,61,28,1],[44859,1,1,1],[44860,352,1,1],[44861,352,1,1],[44863,352,1,1],[44864,65,23,11],[44865,65,23,11],[44868,313,64,3],[44873,134,45,1],[44874,11,8,5],[44876,33,8,3],[44877,204,8,5],[44879,594,23,1],[44880,196,51,1],[44881,595,23,2],[44883,98,41,1],[44903,180,17,1],[44905,246,17,3],[44906,246,17,3],[44907,246,17,3],[44910,278,23,2],[44978,463,23,3],[45016,315,23,1],[45017,89,21,1],[45018,89,21,1],[45019,593,23,3],[45024,654,23,3],[45026,95,28,3],[45027,13,5,3],[45110,8,1,2],[45114,12,1,2],[45116,12,1,2],[45117,12,1,2],[45121,123,1,2],[45125,200,1,2],[45131,427,2,3],[45132,427,2,3],[45133,427,2,3],[45134,427,2,3],[45135,427,2,3],[45136,427,2,3],[45137,427,2,3],[45138,427,2,3],[45139,427,2,3],[45140,427,2,3],[45141,427,2,3],[45142,427,2,3],[45143,427,2,3],[45144,427,2,3],[45145,427,2,3],[45146,427,2,3],[45147,427,2,3],[45148,427,2,3],[45149,427,2,3],[45150,427,2,3],[45151,427,2,3],[45152,427,2,3],[45153,427,2,3],[45154,427,2,3],[45155,427,2,3],[45156,427,2,3],[45157,427,2,3],[45158,427,2,3],[45159,427,2,3],[45160,427,2,3],[45161,427,2,3],[45162,427,2,3],[45163,427,2,3],[45164,427,2,3],[45165,291,15,1],[45245,312,15,3],[45246,235,18,1],[45249,403,23,2],[45250,403,23,2],[45251,403,23,2],[45253,403,23,2],[45254,377,17,3],[45263,466,24,5],[45264,536,1,5],[45344,65,23,11],[45358,61,17,11],[45424,427,2,3],[45425,427,2,3],[45426,427,2,3],[45427,427,2,3],[45428,427,2,3],[45429,427,2,3],[45430,427,2,3],[45431,427,2,3],[45432,427,2,3],[45433,427,2,3],[45434,427,2,3],[45435,427,2,3],[45436,427,2,3],[45437,427,2,3],[45438,427,2,3],[45439,427,2,3],[45440,427,2,3],[45441,427,2,3],[45442,427,2,3],[45443,427,2,3],[45444,427,2,3],[45445,427,2,3],[45446,427,2,3],[45447,427,2,3],[45448,427,2,3],[45449,427,2,3],[45450,427,2,3],[45451,427,2,3],[45452,427,2,3],[45453,427,2,3],[45454,427,2,3],[45455,427,2,3],[45456,427,2,3],[45457,427,2,3],[45460,65,23,1],[45461,65,23,1],[45462,65,23,1],[45465,9,1,3],[45602,644,23,3],[45603,644,23,3],[45605,366,1,3],[45606,350,1,2],[45608,61,17,1],[45610,206,1,2],[45611,403,23,2],[45612,403,23,2],[45624,264,23,1],[45625,224,23,1],[45721,226,23,1],[45723,8,1,10],[45724,8,1,10],[45725,8,1,10],[45726,32,1,6],[45727,473,39,2],[45788,164,1,1],[45789,164,1,1],[45790,164,1,1],[45794,224,23,1],[45795,661,23,2],[45796,264,23,1],[45807,65,23,11],[45854,399,1,11],[45856,227,23,1],[45859,88,23,5],[45860,61,6,1],[45863,37,23,3],[45873,8,1,1],[45874,8,1,1],[45875,8,1,1],[45876,8,1,1],[45916,152,1,2],[45920,31,18,3],[45939,275,23,1],[45941,593,23,2],[45985,202,17,3],[45986,202,17,3],[46025,224,23,1],[46026,333,23,4],[46088,75,1,1],[46089,75,1,1],[46112,85,15,3],[46113,382,1,25],[46114,221,1,3],[46179,164,1,1],[46180,164,1,1],[46200,263,1,22],[46232,224,23,1],[46233,598,23,2],[46234,169,23,2],[46235,164,1,1],[46265,493,21,1],[46266,243,1,4],[46267,393,1,9],[46272,89,21,1],[46273,194,8,1],[46274,302,45,9],[46275,55,1,3],[46276,620,11,2],[46277,400,77,1],[46278,239,20,4],[46279,516,8,4],[46280,609,77,2],[46281,11,8,2],[46284,157,27,5],[46285,164,1,1],[46286,164,1,1],[46288,164,1,1],[46289,164,1,1],[46290,164,1,1],[46291,164,1,1],[46292,217,8,1],[46293,217,8,1],[46294,393,1,2],[46295,393,1,2],[46296,164,1,1],[46297,164,1,1],[46298,352,1,1],[46299,352,1,1],[46312,611,100,1],[46313,164,1,1],[46314,164,1,1],[46315,352,1,1],[46316,352,1,1],[46317,352,1,1],[46318,352,1,1],[46319,329,20,3],[46320,397,62,1],[46396,224,23,1],[46454,315,23,1],[46455,315,23,1],[46456,315,23,1],[46457,315,23,1],[46458,315,23,1],[46459,315,23,1],[46460,315,23,1],[46461,315,23,1],[46462,315,23,1],[46469,226,23,1],[46478,275,23,1],[46479,275,23,1],[46486,246,17,3],[46487,246,17,3],[46488,246,17,3],[46489,384,12,1],[46490,653,17,5],[46491,653,17,5],[46492,343,2,3],[46493,28,17,5],[46494,415,17,5],[46495,64,34,5],[46496,290,3,1],[46497,290,3,1],[46498,329,20,3],[46499,329,20,3],[46500,352,1,1],[46501,352,1,1],[46502,352,1,1],[46503,352,1,1],[46504,404,34,9],[46505,404,34,9],[46506,404,34,9],[46507,404,34,9],[46527,164,1,1],[46528,164,1,1],[46529,164,1,1],[46597,164,1,1],[46609,164,1,1],[46610,224,23,1],[46621,164,1,1],[46735,164,1,1],[46737,164,1,1],[46738,164,1,1],[46805,61,17,11],[46807,65,23,1],[46808,65,23,1],[46809,65,23,1],[46810,593,23,2],[46812,164,1,1],[46813,164,1,1],[46814,164,1,1],[46815,164,1,1],[46816,164,1,1],[46817,164,1,1],[46818,101,15,9],[46819,164,1,1],[46820,164,1,1],[46821,164,1,1],[46822,164,1,1],[46825,164,1,1],[46826,399,1,11],[46827,89,21,1],[46828,89,21,1],[46829,89,21,1],[46830,89,21,1],[46831,89,21,1],[46832,89,21,1],[46833,89,21,1],[46835,89,21,1],[46836,89,21,1],[46840,89,21,1],[46904,593,23,2],[46905,61,28,1],[46906,340,26,1],[46907,340,26,1],[46908,352,1,1],[46909,352,1,1],[46910,352,1,1],[46911,352,1,1],[46912,340,26,1],[46914,340,26,1],[46916,597,23,3],[46918,376,1,1],[46926,352,1,1],[46931,131,1,1],[46941,83,38,1],[46943,83,38,1],[46984,380,8,1],[47202,146,15,3],[47226,190,12,1],[47227,246,17,3],[47228,246,17,3],[47229,246,17,3],[47230,190,17,2],[47231,224,23,1],[47234,117,23,5],[47235,117,23,5],[47237,8,1,1],[47253,559,15,1],[47256,95,28,3],[47258,427,2,3],[47259,427,2,3],[47260,427,2,3],[47261,427,2,3],[47262,427,2,3],[47263,427,2,3],[47264,427,2,3],[47265,427,2,3],[47266,427,2,3],[47267,427,2,3],[47268,427,2,3],[47269,427,2,3],[47270,427,2,3],[47271,427,2,3],[47272,427,2,3],[47273,427,2,3],[47274,427,2,3],[47275,427,2,3],[47276,427,2,3],[47277,427,2,3],[47278,427,2,3],[47279,427,2,3],[47280,427,2,3],[47281,427,2,3],[47282,427,2,3],[47283,427,2,3],[47284,427,2,3],[47285,427,2,3],[47286,427,2,3],[47287,427,2,3],[47288,427,2,3],[47289,427,2,3],[47290,427,2,3],[47291,427,2,3],[47292,427,2,3],[47293,427,2,3],[47294,376,1,10],[47295,376,1,10],[47296,593,23,2],[47297,272,23,1],[47298,403,23,9],[47299,656,23,2],[47300,196,51,1],[47302,65,23,1],[47305,137,38,1],[47306,614,25,3],[47309,93,1,2],[47310,457,1,1],[47317,450,1,2],[47318,450,1,2],[47319,201,1,5],[47320,91,1,2],[47321,597,23,3],[47346,242,34,3],[47429,538,1,3],[47430,52,27,3],[47438,621,38,9],[47440,14,9,3],[47441,46,25,1],[47445,533,34,5],[47446,329,20,3],[47448,451,34,2],[47450,352,1,1],[47452,164,1,1],[47453,352,1,1],[47454,329,20,3],[47456,164,1,1],[47457,352,1,1],[47459,538,1,3],[47461,329,20,3],[47462,164,1,1],[47463,622,1,9],[47465,538,1,3],[47467,215,1,2],[47469,164,1,1],[47471,164,1,1],[47472,164,1,1],[47473,164,1,1],[47474,164,1,1],[47475,164,1,1],[47476,329,20,3],[47477,164,1,1],[47478,164,1,1],[47483,622,1,9],[47484,39,1,2],[47485,470,15,1],[47486,329,20,3],[47487,52,27,3],[47488,52,27,3],[47490,164,1,1],[47491,164,1,1],[47492,164,1,1],[47493,352,1,1],[47494,164,1,1],[47495,164,1,1],[47496,164,1,1],[47498,164,1,1],[47500,164,1,1],[47501,329,20,3],[47502,263,1,22],[47503,329,20,3],[47505,263,1,22],[47506,290,3,1],[47507,290,3,1],[47508,263,1,22],[47509,239,20,4],[47510,290,3,1],[47511,352,1,1],[47514,164,1,1],[47515,164,1,1],[47516,164,1,1],[47517,52,27,3],[47518,164,1,1],[47519,52,27,3],[47521,164,1,1],[47522,164,1,1],[47523,164,1,1],[47524,622,1,9],[47525,352,1,1],[47526,141,1,2],[47527,164,1,1],[47528,164,1,1],[47529,352,1,1],[47530,164,1,1],[47531,329,20,3],[47532,65,23,1],[47533,65,23,1],[47536,65,23,1],[47538,352,1,1],[47539,164,1,1],[47540,164,1,1],[47541,269,36,2],[47542,164,1,1],[47543,164,1,1],[47544,164,1,1],[47545,164,1,1],[47546,61,17,1],[47612,164,1,1],[47613,463,23,3],[47615,39,1,2],[47616,39,1,2],[47617,164,1,1],[47684,164,1,1],[47685,164,1,1],[47686,164,1,1],[47687,164,1,1],[47688,164,1,1],[47691,65,23,1],[47693,65,23,1],[47695,65,23,1],[47698,164,1,1],[47699,26,16,1],[47701,491,46,2],[47719,42,17,1],[47851,88,23,2],[47854,65,23,1],[47856,65,23,1],[47857,65,23,1],[47932,96,18,1],[47933,252,15,1],[47934,252,15,1],[47935,252,15,1],[47936,252,15,1],[47937,6,6,2],[47938,464,18,5],[47939,6,6,2],[47940,161,12,1],[47941,638,45,3],[47942,269,36,2],[47943,6,6,2],[47944,184,30,2],[47948,343,69,3],[47949,329,20,3],[47950,336,18,5],[47953,395,16,1],[47954,341,67,2],[47955,329,20,3],[47956,109,43,2],[47957,64,34,9],[47961,497,11,3],[47965,625,1,1],[47966,393,1,9],[47967,473,39,2],[47968,393,1,9],[47969,256,1,1],[47971,75,1,1],[47972,449,83,2],[48042,427,2,3],[48043,427,2,3],[48044,427,2,3],[48045,427,2,3],[48046,427,2,3],[48047,427,2,3],[48048,427,2,3],[48049,427,2,3],[48050,427,2,3],[48051,427,2,3],[48052,427,2,3],[48053,427,2,3],[48054,427,2,3],[48055,427,2,3],[48056,427,2,3],[48057,427,2,3],[48058,427,2,3],[48059,427,2,3],[48060,427,2,3],[48061,427,2,3],[48062,427,2,3],[48063,427,2,3],[48064,427,2,3],[48065,427,2,3],[48066,427,2,3],[48067,427,2,3],[48068,427,2,3],[48069,427,2,3],[48070,427,2,3],[48071,427,2,3],[48072,427,2,3],[48073,427,2,3],[48074,427,2,3],[48075,427,2,3],[48076,427,2,3],[48077,427,2,3],[48079,224,23,1],[48083,64,34,9],[48084,64,34,9],[48085,64,34,9],[48157,88,23,2],[48210,427,2,3],[48211,427,2,3],[48212,427,2,3],[48213,427,2,3],[48214,427,2,3],[48215,427,2,3],[48216,427,2,3],[48217,427,2,3],[48218,427,2,3],[48219,427,2,3],[48220,427,2,3],[48221,427,2,3],[48222,427,2,3],[48223,427,2,3],[48224,427,2,3],[48225,427,2,3],[48226,427,2,3],[48227,427,2,3],[48228,427,2,3],[48229,427,2,3],[48230,427,2,3],[48231,427,2,3],[48232,427,2,3],[48233,427,2,3],[48234,427,2,3],[48235,427,2,3],[48236,427,2,3],[48237,427,2,3],[48238,427,2,3],[48239,427,2,3],[48240,427,2,3],[48241,427,2,3],[48242,427,2,3],[48243,427,2,3],[48244,427,2,3],[48245,427,2,3],[48247,8,1,1],[48248,468,23,1],[48249,468,23,1],[48250,216,23,2],[48251,261,23,2],[48252,593,23,2],[48253,401,23,2],[48254,316,23,1],[48255,316,23,1],[48256,569,23,2],[48261,226,23,1],[48268,455,38,1],[48269,352,1,1],[48270,14,9,3],[48271,393,1,9],[48272,416,10,1],[48273,352,1,1],[48275,589,23,5],[48340,65,23,1],[48423,65,23,1],[48424,65,23,1],[48425,65,23,1],[48426,593,23,2],[48605,94,1,1],[48606,393,1,2],[48618,9,1,1],[48767,427,2,3],[48768,427,2,3],[48769,427,2,3],[48770,427,2,3],[48771,427,2,3],[48772,427,2,3],[48773,427,2,3],[48774,427,2,3],[48775,427,2,3],[48776,427,2,3],[48777,427,2,3],[48778,427,2,3],[48779,427,2,3],[48780,427,2,3],[48781,427,2,3],[48782,427,2,3],[48783,427,2,3],[48784,427,2,3],[48785,427,2,3],[48786,427,2,3],[48787,427,2,3],[48788,427,2,3],[48789,427,2,3],[48790,427,2,3],[48791,427,2,3],[48792,427,2,3],[48793,427,2,3],[48794,427,2,3],[48795,427,2,3],[48796,427,2,3],[48797,427,2,3],[48798,427,2,3],[48799,427,2,3],[48800,427,2,3],[48801,427,2,3],[48802,427,2,3],[48808,210,23,1],[48838,518,1,3],[48840,66,23,1],[48841,401,23,5],[48842,259,23,1],[48844,426,1,21],[48846,8,1,2],[48847,8,1,2],[48848,8,1,2],[48850,477,1,1],[48859,399,1,11],[48860,65,23,1],[48861,65,23,1],[48863,65,23,1],[48864,593,23,2],[48865,61,17,1],[48872,557,95,1],[48873,557,95,1],[48874,126,1,3],[48875,126,1,3],[48876,61,36,1],[48877,260,1,3],[48878,257,1,1],[48882,538,1,3],[48883,538,1,3],[48884,538,1,3],[48885,352,1,1],[48886,538,1,3],[48887,538,1,3],[48888,538,1,3],[48889,538,1,3],[48890,538,1,3],[48891,538,1,3],[48892,393,1,9],[48893,538,1,3],[48894,538,1,3],[48895,538,1,3],[48896,538,1,3],[48897,538,1,3],[48898,393,1,9],[48899,538,1,3],[48900,64,34,9],[48901,141,1,1],[48902,355,1,3],[48903,355,1,3],[48904,538,1,3],[48905,89,21,1],[48906,617,1,1],[48907,200,1,2],[48909,141,1,9],[48910,243,1,4],[48911,650,1,30],[48914,290,3,1],[48915,651,1,3],[48916,290,3,1],[48917,290,3,1],[48918,290,3,1],[48919,89,21,1],[48920,89,21,1],[48921,89,21,1],[48922,145,46,1],[48923,352,1,1],[48924,207,2,17],[48925,352,1,1],[48926,340,26,1],[48927,352,1,1],[48928,437,46,1],[48929,352,1,1],[48930,340,26,1],[48931,263,1,22],[48938,366,1,3],[48942,340,26,1],[48943,469,68,3],[48948,263,1,22],[48950,340,26,1],[48951,573,1,2],[48952,52,27,3],[48953,45,24,1],[48954,52,27,3],[48955,52,27,3],[48957,263,1,22],[48959,352,1,1],[48960,52,27,3],[48961,52,27,3],[48962,238,12,1],[48963,397,62,1],[48964,543,40,2],[48965,11,8,2],[48966,402,11,1],[48967,427,2,3],[48968,427,2,3],[48970,427,2,3],[48971,427,2,3],[48972,427,2,3],[48973,427,2,3],[48974,427,2,3],[48975,427,2,3],[48976,427,2,3],[48977,427,2,3],[48978,427,2,3],[48979,427,2,3],[48980,427,2,3],[48981,427,2,3],[48982,427,2,3],[48984,427,2,3],[48985,427,2,3],[48986,427,2,3],[48987,427,2,3],[48988,427,2,3],[48989,427,2,3],[48990,427,2,3],[48991,427,2,3],[48992,427,2,3],[48993,427,2,3],[48994,427,2,3],[48995,427,2,3],[48996,427,2,3],[48997,427,2,3],[48998,427,2,3],[48999,427,2,3],[49000,427,2,3],[49001,427,2,3],[49002,427,2,3],[49003,315,23,1],[49004,315,23,1],[49005,315,23,1],[49006,315,23,1],[49007,643,23,1],[49008,210,23,1],[49011,322,23,3],[49013,200,1,2],[49014,256,1,1],[49015,200,1,2],[49016,154,12,2],[49018,411,23,1],[49019,411,23,1],[49020,411,23,1],[49021,411,23,1],[49022,411,23,1],[49026,65,23,1],[49027,593,23,2],[49028,65,23,1],[49029,65,23,1],[49049,322,23,1],[49052,102,1,2],[49055,78,16,3],[49056,13,5,3],[49059,335,34,2],[49060,335,34,2],[49062,509,23,3],[49066,83,38,1],[49067,475,86,9],[49068,11,8,5],[49069,351,45,2],[49070,455,38,1],[49071,322,23,1],[49072,322,23,1],[49075,427,2,3],[49076,427,2,3],[49077,427,2,3],[49078,427,2,3],[49079,427,2,3],[49080,427,2,3],[49081,427,2,3],[49082,427,2,3],[49083,427,2,3],[49084,427,2,3],[49085,427,2,3],[49086,427,2,3],[49087,427,2,3],[49088,427,2,3],[49089,427,2,3],[49090,427,2,3],[49091,427,2,3],[49092,427,2,3],[49093,427,2,3],[49094,427,2,3],[49095,427,2,3],[49096,427,2,3],[49097,427,2,3],[49098,427,2,3],[49099,427,2,3],[49100,427,2,3],[49101,427,2,3],[49102,427,2,3],[49103,427,2,3],[49104,427,2,3],[49105,427,2,3],[49106,427,2,3],[49107,427,2,3],[49108,427,2,3],[49112,403,23,3],[49114,403,23,3],[49115,463,23,3],[49122,224,23,1],[49125,509,23,3],[49186,427,2,3],[49187,427,2,3],[49188,427,2,3],[49189,427,2,3],[49190,427,2,3],[49191,427,2,3],[49192,427,2,3],[49193,427,2,3],[49194,427,2,3],[49195,427,2,3],[49196,427,2,3],[49197,427,2,3],[49198,427,2,3],[49199,427,2,3],[49200,427,2,3],[49201,427,2,3],[49202,427,2,3],[49203,427,2,3],[49204,427,2,3],[49205,427,2,3],[49206,427,2,3],[49207,427,2,3],[49208,427,2,3],[49209,427,2,3],[49210,427,2,3],[49211,427,2,3],[49212,427,2,3],[49213,427,2,3],[49214,427,2,3],[49215,427,2,3],[49216,427,2,3],[49217,427,2,3],[49218,427,2,3],[49219,427,2,3],[49256,315,23,1],[49260,346,1,1],[49261,107,1,2],[49262,107,1,2],[49263,142,1,5],[49264,141,1,4],[49279,427,2,3],[49280,427,2,3],[49281,427,2,3],[49282,427,2,3],[49283,427,2,3],[49284,427,2,3],[49285,427,2,3],[49286,427,2,3],[49287,427,2,3],[49288,427,2,3],[49289,427,2,3],[49290,427,2,3],[49291,427,2,3],[49292,427,2,3],[49293,427,2,3],[49294,427,2,3],[49295,427,2,3],[49296,427,2,3],[49297,427,2,3],[49298,427,2,3],[49299,427,2,3],[49300,427,2,3],[49301,427,2,3],[49302,427,2,3],[49303,427,2,3],[49304,427,2,3],[49305,427,2,3],[49306,427,2,3],[49307,427,2,3],[49308,427,2,3],[49309,427,2,3],[49310,427,2,3],[49311,427,2,3],[49312,427,2,3],[49313,427,2,3],[49314,427,2,3],[49315,114,23,5],[49317,596,23,2],[49319,264,23,1],[49321,550,23,2],[49322,264,23,1],[49323,114,23,9],[49324,316,23,1],[49325,317,23,2],[49330,278,23,2],[49332,27,26,3],[49333,190,38,3],[49336,146,15,23],[49338,315,23,1],[49383,65,23,1],[49384,65,23,1],[49387,117,23,1],[49390,65,23,1],[49391,65,23,1],[49392,65,23,1],[49395,146,15,9],[49396,577,15,2],[49397,50,15,2],[49398,166,15,9],[49399,657,15,2],[49400,268,15,9],[49402,339,15,2],[49403,41,15,2],[49464,106,38,1],[49465,106,38,1],[49466,106,38,1],[49469,75,1,1],[49470,75,1,1],[49492,224,23,1],[49495,227,23,1],[49501,88,23,2],[49503,61,17,1],[49505,509,23,3],[49809,11,8,11],[49810,11,8,11],[49812,590,23,1],[49813,60,23,2],[49814,60,23,10],[49815,319,23,1],[49816,319,23,1],[49817,558,1,9],[49818,350,1,2],[49949,75,1,1],[49950,75,1,1],[49954,141,1,5],[49961,88,23,1],[49962,88,23,1],[50001,202,17,3],[50002,202,17,3],[50005,322,23,3],[50212,614,25,3],[50319,293,2,3],[50321,88,23,2],[50322,88,23,2],[50469,427,2,3],[50470,427,2,3],[50471,427,2,3],[50472,427,2,3],[50473,427,2,3],[50474,427,2,3],[50475,427,2,3],[50476,427,2,3],[50477,427,2,3],[50478,427,2,3],[50479,427,2,3],[50480,427,2,3],[50481,427,2,3],[50482,427,2,3],[50483,427,2,3],[50484,427,2,3],[50485,427,2,3],[50486,427,2,3],[50487,427,2,3],[50488,427,2,3],[50489,427,2,3],[50490,427,2,3],[50491,427,2,3],[50492,427,2,3],[50493,427,2,3],[50494,427,2,3],[50495,427,2,3],[50496,427,2,3],[50497,427,2,3],[50498,427,2,3],[50499,427,2,3],[50500,427,2,3],[50501,427,2,3],[50502,427,2,3],[50503,427,2,3],[50504,427,2,3],[50986,617,1,1],[50992,164,1,1],[50993,164,1,1],[50995,371,72,1],[50996,164,1,1],[50997,164,1,1],[50998,574,6,3],[50999,248,1,1],[51000,164,1,1],[51002,329,20,3],[51003,164,1,1],[51004,164,1,1],[51005,164,1,1],[51006,164,1,1],[51007,164,1,1],[51008,290,3,1],[51009,164,1,1],[51010,164,1,1],[51011,164,1,1],[51013,618,2,2],[51014,498,6,2],[51015,164,1,1],[51016,164,1,1],[51017,164,1,1],[51018,164,1,1],[51019,574,6,3],[51020,164,1,1],[51021,352,1,1],[51022,352,1,1],[51023,164,1,1],[51024,164,1,1],[51026,164,1,1],[51027,164,1,1],[51028,164,1,1],[51029,164,1,1],[51030,513,85,1],[51031,492,89,3],[51032,618,2,2],[51033,574,6,3],[51035,164,1,1],[51036,352,1,1],[51037,164,1,1],[51038,371,72,1],[51039,164,1,1],[51040,164,1,1],[51041,164,1,1],[51042,164,1,1],[51043,164,1,1],[51045,164,1,1],[51046,164,1,1],[51047,164,1,1],[51048,164,1,1],[51049,164,1,1],[51050,371,72,1],[51051,329,20,3],[51052,164,1,1],[51054,352,1,1],[51056,164,1,1],[51057,329,20,3],[51058,352,1,1],[51059,329,20,3],[51062,574,6,3],[51063,574,6,3],[51064,164,1,1],[51065,164,1,1],[51066,164,1,1],[51068,574,6,3],[51069,618,2,2],[51070,290,3,1],[51071,94,1,1],[51072,94,1,1],[51073,574,6,3],[51075,8,1,2],[51077,8,1,2],[51082,618,2,2],[51083,574,6,3],[51084,163,1,2],[51087,557,95,1],[51088,557,95,1],[51094,441,1,2],[51095,576,1,2],[51096,441,1,2],[51097,231,1,3],[51098,557,95,1],[51099,555,94,2],[51102,88,23,2],[51280,255,1,21],[51281,255,1,21],[51284,363,23,1],[51444,134,45,1],[51445,8,1,1],[51511,61,17,1],[51622,427,2,3],[51623,427,2,3],[51624,427,2,3],[51625,427,2,3],[51626,427,2,3],[51627,427,2,3],[51628,427,2,3],[51629,427,2,3],[51630,427,2,3],[51631,427,2,3],[51632,427,2,3],[51633,427,2,3],[51634,427,2,3],[51635,427,2,3],[51636,427,2,3],[51637,427,2,3],[51638,427,2,3],[51639,427,2,3],[51640,427,2,3],[51641,427,2,3],[51642,427,2,3],[51643,427,2,3],[51644,427,2,3],[51645,427,2,3],[51646,427,2,3],[51647,427,2,3],[51648,427,2,3],[51649,427,2,3],[51650,427,2,3],[51651,427,2,3],[51652,427,2,3],[51653,427,2,3],[51654,427,2,3],[51655,427,2,3],[51656,95,28,1],[51657,296,60,4],[51658,95,28,1],[51822,363,23,1],[51824,593,23,2],[51825,258,23,1],[51826,258,23,1],[51827,258,23,1],[51828,258,23,1],[51829,570,23,1],[51830,570,23,1],[51831,642,23,1],[51832,315,23,1],[51833,315,23,1],[51834,315,23,1],[51835,315,23,1],[51836,315,23,1],[51837,315,23,1],[51838,315,23,1],[51839,315,23,1],[51840,315,23,1],[51841,315,23,1],[51843,113,23,1],[51845,148,23,1],[51846,365,23,1],[51847,559,15,1],[51850,244,1,1],[51946,655,23,3],[51947,655,23,3],[51948,655,23,3],[51949,655,23,3],[51950,655,23,3],[51951,655,23,3],[51954,331,66,1],[52009,538,1,3],[52010,538,1,3],[52011,538,1,3],[52012,538,1,3],[52013,538,1,3],[52014,538,1,3],[52015,538,1,3],[52016,538,1,3],[52017,434,1,2],[52018,538,1,3],[52019,538,1,3],[52020,538,1,3],[52021,538,1,3],[52022,538,1,3],[52023,538,1,3],[52024,538,1,3],[52025,538,1,3],[52026,538,1,3],[52027,538,1,3],[52028,538,1,3],[52029,538,1,3],[52084,65,23,1],[52146,377,17,3],[52150,279,23,2],[52151,463,23,1],[52157,542,1,3],[52158,243,1,4],[52159,72,34,1],[52160,22,16,2],[52161,40,10,9],[52162,366,1,3],[52164,538,1,3],[52165,263,1,22],[52166,538,1,3],[52167,263,1,22],[52168,89,21,1],[52170,263,1,22],[52171,89,21,1],[52172,89,21,1],[52174,83,38,1],[52175,63,33,9],[52176,538,1,3],[52177,538,1,3],[52178,89,21,1],[52179,538,1,3],[52180,538,1,3],[52181,538,1,3],[52182,538,1,3],[52183,538,1,3],[52184,89,21,1],[52185,538,1,3],[52186,538,1,3],[52187,538,1,3],[52188,452,84,2],[52189,340,26,1],[52190,340,26,1],[52191,561,84,4],[52192,561,84,4],[52193,340,26,1],[52194,340,26,1],[52196,75,1,1],[52197,75,1,1],[52200,227,23,1],[52202,61,17,1],[52255,509,23,3],[52257,165,23,4],[52259,8,1,1],[52320,520,23,1],[52322,520,23,1],[52388,315,23,1],[52389,315,23,1],[52390,315,23,1],[52391,315,23,1],[52392,315,23,1],[52394,538,1,3],[52395,538,1,3],[52396,538,1,3],[52397,538,1,3],[52398,538,1,3],[52399,538,1,3],[52400,538,1,3],[52401,538,1,3],[52402,538,1,3],[52403,538,1,3],[52404,538,1,3],[52405,538,1,3],[52406,538,1,3],[52407,538,1,3],[52408,538,1,3],[52409,538,1,3],[52410,538,1,3],[52411,538,1,3],[52412,538,1,3],[52413,538,1,3],[52414,538,1,3],[52415,538,1,3],[52416,538,1,3],[52417,538,1,3],[52418,610,34,2],[52419,392,1,2],[52420,610,34,2],[52422,83,38,1],[52423,170,1,2],[52424,170,1,2],[52426,170,1,2],[52443,315,23,1],[52444,315,23,1],[52445,315,23,1],[52446,315,23,1],[52447,315,23,1],[52448,315,23,1],[52449,315,23,1],[52450,315,23,1],[52713,61,17,1],[52732,352,1,1],[52733,352,1,1],[52735,119,1,1],[52736,352,1,1],[52741,119,1,1],[52747,89,21,1],[52748,89,21,1],[52749,290,3,1],[52752,89,21,1],[52755,290,3,1],[52758,290,3,1],[52759,290,3,1],[52762,290,3,1],[52764,89,21,1],[52769,352,1,1],[52785,233,23,19],[52786,233,23,19],[52787,233,23,19],[52788,233,23,19],[52789,233,23,19],[52790,233,23,19],[52791,233,23,19],[52792,233,23,19],[52793,233,23,19],[52817,409,48,3],[52887,496,34,1],[52888,241,1,3],[52889,8,1,10],[52890,8,1,10],[52891,8,1,10],[52892,8,1,10],[52894,465,18,9],[52897,336,18,1],[52898,478,18,1],[52899,530,18,9],[52901,600,23,5],[52903,95,28,3],[52904,372,59,3],[52907,65,23,1],[52908,65,23,1],[52909,65,23,1],[52912,224,23,1],[52933,27,26,3],[52935,167,30,1],[52937,403,78,1],[52941,350,1,2],[52942,350,1,2],[52984,61,17,11],[53100,322,23,3],[53102,8,1,1],[53128,520,23,1],[53129,520,23,1],[53316,65,23,1],[53318,65,23,1],[53352,8,1,2],[53355,9,1,1],[53370,331,66,1],[53371,459,17,2],[53372,459,17,2],[53586,117,23,2],[53698,65,23,1],[53757,635,23,3],[53758,635,23,3],[53760,88,23,1],[53761,88,23,1],[53763,88,23,1],[53765,13,5,3],[53808,77,1,3],[53813,509,23,3],[53815,559,15,1],[53876,117,23,2],[53877,463,23,1],[53883,8,1,1],[53884,88,23,2],[53885,88,23,2],[53943,65,23,1],[53946,65,23,1],[53947,65,23,1],[53948,463,23,2],[53949,510,23,2],[53950,463,23,2],[53960,27,26,3],[53961,27,26,3],[54020,634,23,2],[54021,634,23,2],[54026,221,1,3],[54027,221,1,3],[54029,117,23,5],[54031,61,17,11],[54033,34,19,3],[54035,285,23,1],[54042,65,23,1],[54043,65,23,1],[54045,65,23,1],[54048,13,5,3],[54109,61,17,22],[54110,61,17,22],[54113,427,2,3],[54114,427,2,3],[54115,427,2,3],[54116,427,2,3],[54117,427,2,3],[54118,427,2,3],[54119,427,2,3],[54120,427,2,3],[54121,427,2,3],[54122,427,2,3],[54123,427,2,3],[54124,427,2,3],[54125,427,2,3],[54126,427,2,3],[54127,427,2,3],[54128,427,2,3],[54129,427,2,3],[54130,427,2,3],[54131,427,2,3],[54132,427,2,3],[54133,427,2,3],[54134,427,2,3],[54135,427,2,3],[54136,427,2,3],[54137,427,2,3],[54138,427,2,3],[54139,427,2,3],[54140,427,2,3],[54141,427,2,3],[54142,427,2,3],[54143,427,2,3],[54144,427,2,3],[54145,427,2,3],[54146,427,2,3],[54147,427,2,3],[54148,427,2,3],[54225,13,5,3],[54642,427,2,3],[54643,427,2,3],[54644,427,2,3],[54645,427,2,3],[54646,427,2,3],[54647,427,2,3],[54648,427,2,3],[54649,427,2,3],[54650,427,2,3],[54651,427,2,3],[54652,427,2,3],[54653,427,2,3],[54654,427,2,3],[54655,427,2,3],[54656,427,2,3],[54657,427,2,3],[54658,427,2,3],[54659,427,2,3],[54660,427,2,3],[54661,427,2,3],[54662,427,2,3],[54663,427,2,3],[54664,427,2,3],[54665,427,2,3],[54666,427,2,3],[54667,427,2,3],[54668,427,2,3],[54669,427,2,3],[54670,427,2,3],[54671,427,2,3],[54672,427,2,3],[54673,427,2,3],[54674,427,2,3],[54675,427,2,3],[54676,427,2,3],[54677,427,2,3],[54678,427,2,3],[54679,427,2,3],[54680,427,2,3],[54681,427,2,3],[54940,358,23,5],[55009,554,93,9],[55010,374,11,1],[55011,83,38,1],[55012,129,25,3],[55013,352,26,1],[55014,352,26,1],[55015,71,10,1],[55017,352,26,1],[55018,340,26,1],[55019,340,26,1],[55020,164,1,1],[55021,164,1,1],[55022,164,1,1],[55023,164,1,1],[55024,164,1,1],[55025,164,1,1],[55026,164,1,1],[55027,164,1,1],[55028,164,1,1],[55029,164,1,1],[55030,164,1,1],[55031,164,1,1],[55032,164,1,1],[55033,164,1,1],[55034,366,1,3],[55035,164,1,1],[55036,617,1,1],[55037,352,26,1],[55038,352,26,1],[55039,164,1,1],[55040,164,1,1],[55041,524,39,9],[55042,164,1,1],[55043,164,1,1],[55044,556,4,1],[55045,89,21,1],[55046,366,1,3],[55047,89,21,1],[55048,89,21,1],[55049,290,3,1],[55050,617,1,1],[55052,524,39,9],[55053,186,1,1],[55054,524,39,9],[55055,164,1,1],[55056,629,1,2],[55057,164,1,1],[55058,524,39,9],[55061,524,39,9],[55062,290,3,1],[55063,164,1,1],[55064,89,21,1],[55065,164,1,1],[55066,164,1,1],[55067,421,6,2],[55068,164,1,1],[55069,164,1,1],[55070,164,1,1],[55071,164,1,1],[55072,553,15,5],[55073,574,6,5],[55074,164,1,1],[55075,164,1,1],[55076,651,1,9],[55077,164,1,1],[55078,164,1,1],[55079,164,1,1],[55080,164,1,1],[55081,458,85,2],[55082,164,1,1],[55083,164,1,1],[55084,223,38,2],[55085,340,26,1],[55086,340,26,1],[55087,538,1,3],[55088,352,26,1],[55089,538,1,3],[55090,115,21,9],[55091,538,1,3],[55092,538,1,3],[55093,71,10,1],[55094,538,1,3],[55095,538,1,3],[55096,538,1,3],[55097,538,1,3],[55098,63,33,3],[55099,538,1,3],[55100,538,1,3],[55101,538,1,3],[55102,538,1,3],[55103,342,68,2],[55107,2,2,1],[55108,659,30,2],[55133,315,23,2],[55134,592,23,28],[55135,592,23,28],[55136,593,23,2],[55137,396,23,2],[55138,583,1,2],[55140,427,2,3],[55141,427,2,3],[55142,427,2,3],[55143,427,2,3],[55144,427,2,3],[55145,427,2,3],[55146,427,2,3],[55147,427,2,3],[55148,427,2,3],[55149,427,2,3],[55150,427,2,3],[55151,427,2,3],[55152,427,2,3],[55153,427,2,3],[55154,427,2,3],[55155,427,2,3],[55156,427,2,3],[55157,427,2,3],[55158,427,2,3],[55159,427,2,3],[55160,427,2,3],[55161,427,2,3],[55162,427,2,3],[55163,427,2,3],[55164,427,2,3],[55165,427,2,3],[55166,427,2,3],[55167,427,2,3],[55168,427,2,3],[55169,427,2,3],[55239,37,23,3],[55242,512,23,2],[55243,512,23,2],[55244,65,23,1],[55248,468,23,1],[55249,468,23,1],[55250,365,23,1],[55251,315,23,1],[55252,315,23,1],[55253,315,23,1],[55254,315,23,1],[55255,315,23,1],[55256,315,23,1],[55257,68,23,1],[55258,318,23,1],[55259,318,23,1],[55260,318,23,1],[55261,604,23,2],[55263,9,1,3],[55264,350,1,2],[55268,399,1,11],[55324,263,1,22],[55326,263,1,22],[55327,263,1,22],[55329,291,15,1],[55506,180,17,1],[55508,25,11,3],[55562,95,28,9],[55683,293,2,3],[55686,509,23,3],[55690,281,48,1],[55801,427,2,3],[55802,427,2,3],[55803,427,2,3],[55804,427,2,3],[55805,427,2,3],[55806,427,2,3],[55807,427,2,3],[55808,427,2,3],[55809,427,2,3],[55810,427,2,3],[55811,427,2,3],[55812,427,2,3],[55813,427,2,3],[55814,427,2,3],[55815,427,2,3],[55816,427,2,3],[55817,427,2,3],[55818,427,2,3],[55819,427,2,3],[55820,427,2,3],[55821,427,2,3],[55822,427,2,3],[55823,427,2,3],[55824,427,2,3],[55825,427,2,3],[55826,427,2,3],[55827,427,2,3],[55828,427,2,3],[55829,427,2,3],[55830,427,2,3],[55836,322,23,1],[55839,322,23,1],[55841,361,17,3],[55844,281,48,1],[55861,511,23,2],[55909,94,1,1],[55910,94,1,1],[55912,224,23,1],[55970,27,26,3],[55971,27,26,3],[55973,592,23,28],[55974,592,23,28],[55975,592,23,28],[55976,592,23,28],[55978,61,17,1],[55982,75,1,1],[55983,75,1,1],[56055,427,2,3],[56056,427,2,3],[56057,427,2,3],[56058,427,2,3],[56059,427,2,3],[56060,427,2,3],[56061,427,2,3],[56062,427,2,3],[56063,427,2,3],[56064,427,2,3],[56065,427,2,3],[56066,427,2,3],[56067,427,2,3],[56068,427,2,3],[56069,427,2,3],[56070,427,2,3],[56071,427,2,3],[56072,427,2,3],[56073,427,2,3],[56074,427,2,3],[56075,427,2,3],[56076,427,2,3],[56077,427,2,3],[56078,427,2,3],[56079,427,2,3],[56080,427,2,3],[56081,427,2,3],[56083,61,6,1],[56091,61,17,14],[56153,277,39,1],[56154,277,39,1],[56155,277,39,1],[56156,277,39,1],[56157,65,23,1],[56162,62,1,9],[56163,62,1,9],[56164,62,1,9],[56165,62,1,9],[56166,62,1,9],[56167,62,1,9],[56168,62,1,9],[56169,62,1,9],[56170,62,1,9],[56171,62,1,9],[56174,221,1,3],[56178,292,25,1],[56179,84,2,2],[56180,153,12,1],[56181,332,25,3],[56182,352,1,1],[56183,497,1,3],[56184,551,5,2],[56185,83,38,1],[56186,239,20,4],[56187,7,7,4],[56188,122,1,5],[56189,308,1,2],[56190,89,21,1],[56191,263,1,22],[56193,263,1,22],[56194,417,10,1],[56195,240,1,1],[56196,629,1,2],[56197,240,1,1],[56198,617,1,1],[56199,608,1,2],[56201,89,21,1],[56202,89,21,1],[56203,89,21,1],[56204,239,20,4],[56205,205,52,1],[56206,352,1,1],[56207,568,97,1],[56208,455,38,2],[56209,239,20,4],[56210,129,25,1],[56211,297,38,14],[56212,489,88,2],[56213,494,1,1],[56214,344,9,3],[56215,624,16,9],[56216,183,38,9],[56217,329,20,3],[56218,329,20,3],[56219,12,1,4],[56220,12,1,4],[56221,304,44,5],[56222,158,4,5],[56223,631,86,9]]} diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs index 38ff4d24c..f1c66f032 100644 --- a/src/data/rocketLaunches.test.mjs +++ b/src/data/rocketLaunches.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { gstime } from 'satellite.js'; +import { worldClock } from '../time/worldClock.js'; import { createModel } from '../layers/launches/model.js'; import rocketLaunchesLayer, { _setRocketMissionOverlayHostForTest, @@ -1240,7 +1241,11 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths rocketLaunchesLayer.init(viewer); initialized = true; await rocketLaunchesLayer.enable(); + assert.equal(worldClock.coverage(), null, 'nothing loaded, no time to show'); await rocketLaunchesLayer.update(); + const at = Date.parse(launchTime); + assert.deepEqual(worldClock.coverage(), { start: at, end: at, marks: [at] }); + assert.equal(worldClock.isSupported(), true, 'loaded launches bring up the timeline'); const entities = dataSources[0].entities.values; assert.ok(entities.length >= 7, 'runtime guard requires the populated mission build path'); @@ -1379,6 +1384,7 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths assert.equal(deselectedPublication[2][0].protected, false); await rocketLaunchesLayer.disable(); + assert.equal(worldClock.coverage(), null, 'off, launches show no time'); assert.deepEqual(hostCalls.slice(-4), [ ['clear', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID], ['visible', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, false], @@ -1500,3 +1506,11 @@ test('missing payload details stay unknown without inventing mass or classificat const [launch] = normalizeRocketLaunches([{ id: 'missing', pad: { latitude: 1, longitude: 2 }, net: '2026-07-20T10:00:00Z' }], NOW); assert.deepEqual(launch.payloads, []); }); + +test('away from live the feed keeps its own window of launches', (t) => { + t.after(() => worldClock.goLive()); + worldClock.seek(Date.now() - 20 * 86_400_000); + const net = new Date(Date.now() - 86_400_000).toISOString(); + const launches = normalizeRocketLaunches([{ id: 'recent', pad: { latitude: 1, longitude: 2 }, net }]); + assert.deepEqual(launches.map((launch) => launch.id), ['recent']); +}); diff --git a/src/data/ucsSatellites.js b/src/data/ucsSatellites.js index 5a87a2bd4..d376570db 100644 --- a/src/data/ucsSatellites.js +++ b/src/data/ucsSatellites.js @@ -22,9 +22,7 @@ const PACK_URL = new URL( /** * One UCS row: what UCS says about a satellite's operator and purpose. * @typedef {{noradId: number, operator: string|null, - * operatorCountry: string|null, purpose: string|null, - * detailedPurpose: string|null, users: string|null, - * orbitClass: string|null}} UcsSatellite + * operatorCountry: string|null, purpose: string|null}} UcsSatellite */ /** diff --git a/src/data/ucsSatellites.test.mjs b/src/data/ucsSatellites.test.mjs index 86df68e81..09c9903c4 100644 --- a/src/data/ucsSatellites.test.mjs +++ b/src/data/ucsSatellites.test.mjs @@ -27,9 +27,6 @@ test('the bundled UCS pack names its release, licence and curation', () => { 'operator', 'operatorCountry', 'purpose', - 'detailedPurpose', - 'users', - 'orbitClass', ]); // Small enough to load with the layer. assert.ok(readFileSync(PACK_URL).length < 200_000); @@ -49,7 +46,6 @@ test('the pack loads into records keyed by NORAD number', async () => { /starlink/i, 'Starlink rows are dropped', ); - if (record.orbitClass) assert.match(record.orbitClass, /^(LEO|MEO|GEO|ELLIPTICAL)$/); } }); diff --git a/src/hud.js b/src/hud.js index 7e61fe91b..37e9dfcb4 100644 --- a/src/hud.js +++ b/src/hud.js @@ -23,6 +23,11 @@ import * as Cesium from 'cesium'; import { greatCircleKm } from './geoDistance.js'; import { forward as toMGRS } from 'mgrs'; import { CITY_POIS } from './cityPois.js'; +import { worldTimeFor } from './time/worldClock.js'; + +// The HUD stamps its readouts with the world time; its sun is the sky's. +const hudTime = worldTimeFor('hud'); +const skyTime = worldTimeFor('sky'); import { composeLocalityTag } from './hudLocality.js'; import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js'; import { @@ -447,11 +452,11 @@ export class IntelHUD { } /** - * Format the current wall-clock time as a UTC Zulu string. + * Format the HUD's world time as a UTC Zulu string. * @returns {string} Timestamp in `YYYY-MM-DD HH:MM:SSZ` format. */ _formatUTC() { - const now = new Date(); + const now = new Date(hudTime()); const y = now.getUTCFullYear(); const mo = String(now.getUTCMonth() + 1).padStart(2, '0'); const d = String(now.getUTCDate()).padStart(2, '0'); @@ -554,7 +559,7 @@ export class IntelHUD { readouts.alt = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`; // Collection timestamp - const now = new Date(); + const now = new Date(hudTime()); const h = String(now.getUTCHours()).padStart(2, '0'); const m = String(now.getUTCMinutes()).padStart(2, '0'); const s = String(now.getUTCSeconds()).padStart(2, '0'); @@ -661,7 +666,7 @@ export class IntelHUD { * @returns {number} Estimated sun elevation in degrees (negative = below horizon). */ _estimateSunElevation(lat, lon) { - const now = new Date(); + const now = new Date(skyTime()); // Approximate local solar time by shifting UTC hours by longitude offset const hours = now.getUTCHours() + now.getUTCMinutes() / 60 + lon / 15; const solarNoon = 12; diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 2411f6e88..97f6b4eb7 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3588,5 +3588,14 @@ "COURSE HOLD · AIS": "تثبيت المسار · AIS", "Zoom in (+)": "تكبير (+)", "Zoom out (−)": "تصغير (−)", - "ORBITAL VIEW · OFF-NADIR": "العرض المداري · انحراف عن النظير" + "ORBITAL VIEW · OFF-NADIR": "العرض المداري · انحراف عن النظير", + "World time": "وقت العالم", + "Back to live": "العودة إلى البث المباشر", + "Live": "مباشر", + "1 h/s": "ساعة/ث", + "1 d/s": "يوم/ث", + "1 wk/s": "أسبوع/ث", + "Play speed": "سرعة التشغيل", + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل", + "1 min/s": "دقيقة/ث" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 812ae8e65..94baca906 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3284,5 +3284,14 @@ "COURSE HOLD · AIS": "KURS FIX · AIS", "Zoom in (+)": "Hineinzoomen (+)", "Zoom out (−)": "Herauszoomen (−)", - "ORBITAL VIEW · OFF-NADIR": "ORBITALANSICHT · NADIRABWEICHUNG" + "ORBITAL VIEW · OFF-NADIR": "ORBITALANSICHT · NADIRABWEICHUNG", + "World time": "Weltzeit", + "Back to live": "Zurück zu live", + "Live": "Live", + "1 h/s": "1 Std./s", + "1 d/s": "1 Tag/s", + "1 wk/s": "1 Wo./s", + "Play speed": "Wiedergabegeschwindigkeit", + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scrollen oder mit zwei Fingern zoomen, ziehen zum Verschieben · Doppelklick zeigt alles", + "1 min/s": "1 Min./s" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d79768cf0..329ef3f4f 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3360,5 +3360,14 @@ "COURSE HOLD · AIS": "BLOQUEO DE RUMBO · AIS", "Zoom in (+)": "Acercar (+)", "Zoom out (−)": "Alejar (−)", - "ORBITAL VIEW · OFF-NADIR": "VISTA ORBITAL · FUERA DEL NADIR" + "ORBITAL VIEW · OFF-NADIR": "VISTA ORBITAL · FUERA DEL NADIR", + "World time": "Hora mundial", + "Back to live": "Volver al directo", + "Live": "En directo", + "1 h/s": "1 h/s", + "1 d/s": "1 d/s", + "1 wk/s": "1 sem/s", + "Play speed": "Velocidad de reproducción", + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Desplaza o pellizca para ampliar, arrastra para moverte · doble clic para verlo todo", + "1 min/s": "1 min/s" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 287b279dc..5d76194aa 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3360,5 +3360,14 @@ "COURSE HOLD · AIS": "VERROU ROUTE · AIS", "Zoom in (+)": "Zoomer (+)", "Zoom out (−)": "Dézoomer (−)", - "ORBITAL VIEW · OFF-NADIR": "VUE ORBITALE · HORS NADIR" + "ORBITAL VIEW · OFF-NADIR": "VUE ORBITALE · HORS NADIR", + "World time": "Heure du monde", + "Back to live": "Revenir au direct", + "Live": "Direct", + "1 h/s": "1 h/s", + "1 d/s": "1 j/s", + "1 wk/s": "1 sem/s", + "Play speed": "Vitesse de lecture", + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Faites défiler ou pincez pour zoomer, glissez pour vous déplacer · double-clic pour tout voir", + "1 min/s": "1 min/s" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 3b2c6e062..04578c035 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3360,5 +3360,14 @@ "COURSE HOLD · AIS": "BLOCCO ROTTA · AIS", "Zoom in (+)": "Avvicina (+)", "Zoom out (−)": "Allontana (−)", - "ORBITAL VIEW · OFF-NADIR": "VISTA ORBITALE · FUORI NADIR" + "ORBITAL VIEW · OFF-NADIR": "VISTA ORBITALE · FUORI NADIR", + "World time": "Ora del mondo", + "Back to live": "Torna alla diretta", + "Live": "Diretta", + "1 h/s": "1 h/s", + "1 d/s": "1 g/s", + "1 wk/s": "1 sett/s", + "Play speed": "Velocità di riproduzione", + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scorri o pizzica per lo zoom, trascina per spostarti · doppio clic per vedere tutto", + "1 min/s": "1 min/s" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 8caa29939..4e79c1a8e 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3208,5 +3208,14 @@ "COURSE HOLD · AIS": "対地針路保持 · AIS", "Zoom in (+)": "ズームイン (+)", "Zoom out (−)": "ズームアウト (−)", - "ORBITAL VIEW · OFF-NADIR": "軌道ビュー · オフナディア" + "ORBITAL VIEW · OFF-NADIR": "軌道ビュー · オフナディア", + "World time": "世界時刻", + "Back to live": "ライブに戻る", + "Live": "ライブ", + "1 h/s": "1時間/秒", + "1 d/s": "1日/秒", + "1 wk/s": "1週間/秒", + "Play speed": "再生速度", + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "スクロールまたはピンチでズーム、ドラッグで移動 · ダブルクリックで全体表示", + "1 min/s": "1分/秒" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 18fd9ef3f..a8a0c1cbd 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3360,5 +3360,14 @@ "COURSE HOLD · AIS": "TRAVA DE RUMO · AIS", "Zoom in (+)": "Aproximar (+)", "Zoom out (−)": "Afastar (−)", - "ORBITAL VIEW · OFF-NADIR": "VISTA ORBITAL · FORA DO NADIR" + "ORBITAL VIEW · OFF-NADIR": "VISTA ORBITAL · FORA DO NADIR", + "World time": "Hora do mundo", + "Back to live": "Voltar ao vivo", + "Live": "Ao vivo", + "1 h/s": "1 h/s", + "1 d/s": "1 d/s", + "1 wk/s": "1 sem/s", + "Play speed": "Velocidade de reprodução", + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Role ou faça pinça para ampliar, arraste para se mover · clique duplo para ver tudo", + "1 min/s": "1 min/s" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 01670b32e..6f02fb0bd 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3436,5 +3436,14 @@ "COURSE HOLD · AIS": "УДЕРЖАНИЕ ПУТЕВОГО УГЛА · AIS", "Zoom in (+)": "Приблизить (+)", "Zoom out (−)": "Отдалить (−)", - "ORBITAL VIEW · OFF-NADIR": "ОРБИТАЛЬНЫЙ ВИД · ОТКЛОНЕНИЕ ОТ НАДИРА" + "ORBITAL VIEW · OFF-NADIR": "ОРБИТАЛЬНЫЙ ВИД · ОТКЛОНЕНИЕ ОТ НАДИРА", + "World time": "Мировое время", + "Back to live": "Вернуться к прямому эфиру", + "Live": "Сейчас", + "1 h/s": "1 ч/с", + "1 d/s": "1 дн/с", + "1 wk/s": "1 нед/с", + "Play speed": "Скорость воспроизведения", + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё", + "1 min/s": "1 мин/с" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 17b33744b..b2f8e8d5e 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3436,5 +3436,14 @@ "COURSE HOLD · AIS": "УТРИМАННЯ ШЛЯХОВОГО КУТА · AIS", "Zoom in (+)": "Наблизити (+)", "Zoom out (−)": "Віддалити (−)", - "ORBITAL VIEW · OFF-NADIR": "ОРБІТАЛЬНИЙ ВИГЛЯД · ВІДХИЛЕННЯ ВІД НАДИРА" + "ORBITAL VIEW · OFF-NADIR": "ОРБІТАЛЬНИЙ ВИГЛЯД · ВІДХИЛЕННЯ ВІД НАДИРА", + "World time": "Світовий час", + "Back to live": "Повернутися до прямого ефіру", + "Live": "Зараз", + "1 h/s": "1 год/с", + "1 d/s": "1 дн/с", + "1 wk/s": "1 тиж/с", + "Play speed": "Швидкість відтворення", + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу, перетягніть для переміщення · подвійне клацання — показати все", + "1 min/s": "1 хв/с" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index d991830aa..90d2afb62 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3208,5 +3208,14 @@ "COURSE HOLD · AIS": "对地航向保持 · AIS", "Zoom in (+)": "放大 (+)", "Zoom out (−)": "缩小 (−)", - "ORBITAL VIEW · OFF-NADIR": "轨道视图 · 偏离天底" + "ORBITAL VIEW · OFF-NADIR": "轨道视图 · 偏离天底", + "World time": "世界时间", + "Back to live": "返回实时", + "Live": "实时", + "1 h/s": "1 小时/秒", + "1 d/s": "1 天/秒", + "1 wk/s": "1 周/秒", + "Play speed": "播放速度", + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "滚动或双指缩放,拖动以移动 · 双击查看全部", + "1 min/s": "1 分钟/秒" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index d25024945..a37bb7a9d 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3208,5 +3208,14 @@ "COURSE HOLD · AIS": "對地航向保持 · AIS", "Zoom in (+)": "放大 (+)", "Zoom out (−)": "縮小 (−)", - "ORBITAL VIEW · OFF-NADIR": "軌道視圖 · 偏離天底" + "ORBITAL VIEW · OFF-NADIR": "軌道視圖 · 偏離天底", + "World time": "世界時間", + "Back to live": "返回即時", + "Live": "即時", + "1 h/s": "1 小時/秒", + "1 d/s": "1 天/秒", + "1 wk/s": "1 週/秒", + "Play speed": "播放速度", + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部", + "1 min/s": "1 分鐘/秒" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index baf05cfe0..e64b2307b 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -105,6 +105,7 @@ "Landsat 8/9", "Latin", "Layout", + "Live", "London", "Los Angeles", "MIL", @@ -232,6 +233,7 @@ "▲ {frp} MW" ], "es": [ + "1 min/s", "1090 MHz", "978 MHz UAT", "AIS: --", @@ -382,6 +384,7 @@ "iNaturalist" ], "fr": [ + "1 min/s", "1090 MHz", "978 MHz UAT", "ALTITUDE", @@ -585,6 +588,7 @@ "▲ {frp} MW" ], "it": [ + "1 min/s", "1090 MHz", "978 MHz UAT", "AFRICA", @@ -837,6 +841,7 @@ "▲ {frp} MW" ], "pt": [ + "1 min/s", "1090 MHz", "978 MHz UAT", "AIS: --", diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js index b66775f1e..a14c8c4ac 100644 --- a/src/layers/flights/motion.js +++ b/src/layers/flights/motion.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { julianAt } from '../../time/julian.js'; import { COURSE_HOLD_SPEED_MPS, lerpAngleDeg, @@ -156,7 +157,7 @@ export function createMotion({ const renderTime = sampledRenderTime ?? Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(flightState._scratchRenderTime), + julianAt(flightState.now(), flightState._scratchRenderTime), -RENDER_DELAY_SEC, flightState._scratchRenderTime, ); @@ -332,7 +333,7 @@ export function createMotion({ const history = flightState._positionHistory.get(flightState._trackedIcao); if (!history || history.length === 0) return true; const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), + julianAt(flightState.now()), -RENDER_DELAY_SEC, flightState._scratchWarmupTime, ); diff --git a/src/layers/flights/motion.test.mjs b/src/layers/flights/motion.test.mjs index dcb809d86..dd7a0ab03 100644 --- a/src/layers/flights/motion.test.mjs +++ b/src/layers/flights/motion.test.mjs @@ -12,6 +12,7 @@ function fixture() { _scratchArc: {}, _scratchOffset: new Cesium.Cartesian3(), _scratchRenderTime: new Cesium.JulianDate(), + now: Date.now, }; const motion = createMotion({ flightState, @@ -138,7 +139,7 @@ test('fleet render time is shared without resampling or mutating the supplied in assert.ok(Cesium.JulianDate.equals(renderTime, savedTime)); }); -test('tracked callers still sample a fresh delayed time without a fleet epoch', (t) => { +test('tracked callers still sample a fresh delayed time without a fleet epoch', () => { const { flightState, motion } = fixture(); const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z'); const history = [ @@ -156,10 +157,11 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch', flightState.records.data.set('tracked', { velocity: 100, true_track: 90 }); flightState._positionHistory.set('tracked', history); let elapsed = 60; - const spy = t.mock.method(Cesium.JulianDate, 'now', (result) => { - assert.equal(result, flightState._scratchRenderTime); - return Cesium.JulianDate.addSeconds(start, elapsed, result); - }); + let reads = 0; + flightState.now = () => { + reads += 1; + return Cesium.JulianDate.toDate(start).getTime() + elapsed * 1000; + }; const out = new Cesium.Cartesian3(); close( motion._deadReckon('tracked', out), @@ -180,5 +182,5 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch', new Cesium.Cartesian3(), ), ); - assert.equal(spy.mock.callCount(), 2); + assert.equal(reads, 2); }); diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index 6709f9647..333935bc1 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { julianAt } from '../../time/julian.js'; import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; import { selectModelEligible } from '../../data/modelEligibility.js'; import { civilAircraftModelSpec } from './modelSpec.js'; @@ -871,7 +872,7 @@ export function createRendering({ flightState._lastFleetTickMs = nowMs; // Every contact in this pass shares the same delayed display instant. const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(flightState._scratchFleetRenderTime), + julianAt(flightState.now(), flightState._scratchFleetRenderTime), -RENDER_DELAY_SEC, flightState._scratchFleetRenderTime, ); diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index 25d1c1aa3..c259c44f1 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -1,6 +1,7 @@ import { FlightRecords } from './records.js'; import { createFlightFeed } from './ingestion.js'; import * as Cesium from 'cesium'; +import { worldTimeFor } from '../../time/worldClock.js'; import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js'; export function createFlightState({ source, services }) { @@ -12,6 +13,8 @@ export function createFlightState({ source, services }) { ...services.groundFloor, }); flightState._cullPositions = new Map(); + /** The time contacts are drawn behind by the render delay, in epoch ms. */ + flightState.now = worldTimeFor('flights'); /** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs * vertex-baked to real-world METERS in the airplane.glb axis convention, so diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index 59270892e..f81249e88 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { julianAt } from '../../time/julian.js'; import { formatNumber, t } from '../../i18n/index.js'; import { unitLabel } from '../../ui/unitLabels.js'; import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; @@ -383,7 +384,7 @@ export function createTracking({ // would draw the trail in front of the plane. They join the trail via _appendTrailFix // as they age past the delay. const seedRenderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), + julianAt(flightState.now()), -RENDER_DELAY_SEC, flightState._scratchWarmupTime, ); diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index ba0e9ff5d..7a9087998 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -69,6 +69,8 @@ export function createGnssIntegrityLayer({ source, viewAnchor = defaultGnssViewAnchor, now = () => Date.now(), + // The end of the window shown; retries and receipt times stay on `now`. + worldNow = now, monotonicNow = () => performance.now(), windowMs: requestedWindowMs, } = {}) { @@ -163,7 +165,7 @@ export function createGnssIntegrityLayer({ /** Age the store without new evidence, so expired cells leave the globe. */ function expire() { - pruneGnssObservations(_observations, now(), { windowMs }); + pruneGnssObservations(_observations, worldNow(), { windowMs }); _cells = binGnssCells(_observations); render(); } diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js index 9adc88922..93f667951 100644 --- a/src/layers/launches/controls.js +++ b/src/layers/launches/controls.js @@ -29,7 +29,7 @@ export function createControls({ state: layerState, services, parts, source }) { */ getAnalystRecords(maxCount = Infinity) { if (!layerState._enabled) return []; - const now = Date.now(); + const now = layerState.now(); const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null); const result = []; for (const launch of layerState._launches || []) { diff --git a/src/layers/launches/ingestion.js b/src/layers/launches/ingestion.js index fbd3778e1..3bf6b9014 100644 --- a/src/layers/launches/ingestion.js +++ b/src/layers/launches/ingestion.js @@ -224,6 +224,7 @@ export function createIngestion({ } layerState._focusAfterActiveLookup = false; parts.panel.renderMissionPanel(); + layerState.coverageChanged(); layerState._entryCameraView = null; } catch (error) { if (layerState._enabled && token === layerState._lifecycleToken) { diff --git a/src/layers/launches/launchPad.js b/src/layers/launches/launchPad.js index f0a87ce6a..9fd438762 100644 --- a/src/layers/launches/launchPad.js +++ b/src/layers/launches/launchPad.js @@ -105,7 +105,7 @@ export function createLaunchPad({ } const nowMs = Date.now(); if (nowMs - layerState._lastMissionRingRotationMs >= 1000) { - layerState._missionRingDate.setTime(nowMs); + layerState._missionRingDate.setTime(layerState.orbitNow()); parts.orbitRendering.updateMissionOrbitPrimitiveFrames( layerState._missionRingDate, ); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 252ca7ab1..2bff72090 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { isPointerFree } from '../../data/inputOwnership.js'; +import { worldClock } from '../../time/worldClock.js'; const HOVER_PICK_INTERVAL_MS = 120; @@ -156,6 +157,21 @@ export function createLifecycle({ }) { const { holdContinuousRender, releaseContinuousRender } = services.render; let hover = null; + // While on, the launches loaded span the time from the first to the last, + // each one a mark. Registered once; off, the layer has no time to show, and + // each step tells the clock once it has settled. + worldClock.register({ + id: 'rocket-launches', + coverage: () => { + if (!layerState._enabled) return null; + const marks = layerState._launches + .map((launch) => Date.parse(launch.launchTime)) + .filter(Number.isFinite) + .sort((a, b) => a - b); + if (!marks.length) return null; + return { start: marks[0], end: marks.at(-1), marks }; + }, + }); const methods = { init(viewer) { @@ -231,6 +247,7 @@ export function createLifecycle({ } throw error; } + layerState.coverageChanged(); }, async _enableBody() { @@ -297,6 +314,7 @@ export function createLifecycle({ parts.overlays.clearMissionOverlaySources(); await parts.ingestion.restoreSatelliteDependency(); parts.panel.renderMissionPanel(); + layerState.coverageChanged(); }, async destroy(viewer) { diff --git a/src/layers/launches/orbitRendering.js b/src/layers/launches/orbitRendering.js index d907904eb..f02494fff 100644 --- a/src/layers/launches/orbitRendering.js +++ b/src/layers/launches/orbitRendering.js @@ -67,6 +67,9 @@ export function createOrbitRendering({ return Boolean( layerState._enabled && layerState._dataSource?.show && + !layerState.launchHidden( + layerState._missionOverlayRecords.get(launchId)?.launch, + ) && (!layerState._selectedLaunchId || layerState._selectedLaunchId === launchId), ); @@ -124,7 +127,7 @@ export function createOrbitRendering({ collection.show = missionOrbitPrimitiveVisible(launch.id); orbitFrameModelMatrix( satelliteTrack.gmstAtBake, - new Date(), + new Date(layerState.orbitNow()), collection.modelMatrix, ); layerState._viewer.scene.primitives.add(collection); diff --git a/src/layers/launches/overlays.js b/src/layers/launches/overlays.js index 4b246e702..50c9f91e2 100644 --- a/src/layers/launches/overlays.js +++ b/src/layers/launches/overlays.js @@ -411,8 +411,11 @@ export function createOverlays({ state: layerState, services, parts, source }) { false, ); layerState._selectedMissionOverlayTimeText = null; + const records = [...layerState._missionOverlayRecords.values()].filter( + (record) => !layerState.launchHidden(record.launch), + ); const entries = selectRocketMissionMarkerOverlayCohort( - Array.from(layerState._missionOverlayRecords.values(), (record) => { + records.map((record) => { const entry = createRocketMissionMarkerOverlayEntry( record.launch, record.anchorPosition, diff --git a/src/layers/launches/rendering.js b/src/layers/launches/rendering.js index 6379e78e1..2ea0b86d5 100644 --- a/src/layers/launches/rendering.js +++ b/src/layers/launches/rendering.js @@ -22,6 +22,25 @@ export function createRendering({ if (current !== next) graphic.show = next; } + // Away from live, a launch shows from its lift-off on, so scrubbing the + // world time forward brings the launches up in the order they flew. The + // selected mission stays, whenever it flies. + function syncPendingLaunches() { + const pending = new Set(); + for (const { launch } of layerState._missionOverlayRecords.values()) { + if (layerState.launchHidden(launch)) pending.add(launch.id); + } + const previous = layerState._pendingLaunchIds; + if ( + pending.size === previous.size && + [...pending].every((id) => previous.has(id)) + ) + return; + layerState._pendingLaunchIds = pending; + parts.overlays.syncMissionOverlayEntries(); + parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); + } + function updateMissionFrame() { if ( !layerState._enabled || @@ -31,6 +50,7 @@ export function createRendering({ parts.overlays.hideReplayVehicleOverlay(); return; } + syncPendingLaunches(); parts.panel.updateMissionTelemetry(); const time = Cesium.JulianDate.now(layerState._declutterTime); layerState._declutterOccluder.cameraPosition = @@ -39,21 +59,28 @@ export function createRendering({ parts.overlays.refreshSelectedMissionOverlayText(); for (const entity of layerState._dataSource.entities.values) { if (!entity.show) continue; - if (!entity.position) continue; const id = parts.selection.entityLaunchId(entity); if (!id) continue; + const pending = layerState._pendingLaunchIds.has(id); + if (entity.polyline) + setGraphicVisibility(entity.polyline, !pending, time); + if (!entity.position) continue; const position = entity.position.getValue(time); - let horizonVisible = Boolean( - position && layerState._declutterOccluder.isPointVisible(position), - ); + let horizonVisible = + !pending && + Boolean( + position && layerState._declutterOccluder.isPointVisible(position), + ); const launchAnchor = entity.id.startsWith('rocket-launch:'); if (launchAnchor) { - horizonVisible = parts.overlays.missionAnchorVisible( - layerState._viewer.scene.camera.positionWC, - position, - id, - layerState._selectedLaunchId, - ); + horizonVisible = + !pending && + parts.overlays.missionAnchorVisible( + layerState._viewer.scene.camera.positionWC, + position, + id, + layerState._selectedLaunchId, + ); // Keep entity.show reserved for mission-selection isolation. Horizon // state is applied to the individual graphics below so a rear-side // anchor remains eligible for reevaluation as the camera moves. @@ -394,7 +421,7 @@ export function createRendering({ orbitCurrent.latitude, orbitCurrent.altitude, ); - const liveTime = new Date(); + const liveTime = new Date(layerState.orbitNow()); const liveTelemetry = { speedMps: null }; layerState._satelliteTelemetry.set(launch.id, liveTelemetry); let liveFrameNumber = -1; @@ -405,7 +432,7 @@ export function createRendering({ layerState._viewer?.scene?.frameState?.frameNumber ?? -1; if (frameNumber !== -1 && frameNumber === liveFrameNumber) return; liveFrameNumber = frameNumber; - const nowMs = Date.now(); + const nowMs = layerState.orbitNow(); liveTime.setTime(nowMs); if (satelliteTrack) { const propagated = diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index fd2ae7176..3349af81d 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { worldClock, worldTimeFor } from '../../time/worldClock.js'; export function createState({ services }) { const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = @@ -156,6 +157,38 @@ export function createState({ services }) { state._missionRingDate = new Date(0); + /** The time the launch window ends at, in epoch ms. */ + + state.now = worldTimeFor('rocket-launches'); + + /** A launched mission's satellite flies on the satellites layer's time. */ + + state.orbitNow = worldTimeFor('satellites'); + + /** The launches loaded changed, and with them the time they cover. */ + + state.coverageChanged = () => worldClock.coverageChanged(); + + /** + * Whether a launch has yet to lift off at the world time. While live none + * has, so the layer shows every launch in its window as it always did. + */ + + state.launchPending = (launch) => { + if (worldClock.isLive()) return false; + const launchMs = Date.parse(launch?.launchTime); + return Number.isFinite(launchMs) && launchMs > state.now(); + }; + + /** Whether a launch is hidden for lifting off after the world time. */ + + state.launchHidden = (launch) => + launch?.id !== state._selectedLaunchId && state.launchPending(launch); + + /** The launches hidden at the last frame, by id, to spot a change. */ + + state._pendingLaunchIds = new Set(); + state._missionOrbitPatternRegistered = false; state._pathDistanceCache = new WeakMap(); diff --git a/src/layers/launches/worldTime.test.mjs b/src/layers/launches/worldTime.test.mjs new file mode 100644 index 000000000..d351c92b7 --- /dev/null +++ b/src/layers/launches/worldTime.test.mjs @@ -0,0 +1,102 @@ +// Away from live, a launch shows from its lift-off on, so scrubbing the world +// time brings the launches up in the order they flew. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { worldClock } from '../../time/worldClock.js'; +import { createState } from './state.js'; +import { createRendering } from './rendering.js'; +import { createSelection } from './selection.js'; + +const noop = () => {}; +const LAUNCH_MS = Date.UTC(2026, 9, 20, 12); + +function scene(t) { + t.after(() => worldClock.goLive()); + const state = createState({ + services: { + overlays: { + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + clearOverlaySource: noop, + }, + }, + }); + const launch = { + id: 'ceres-1', + launchTime: new Date(LAUNCH_MS).toISOString(), + }; + state._enabled = true; + state._dataSource = new Cesium.CustomDataSource('launches'); + state._viewer = { + scene: { camera: { positionWC: new Cesium.Cartesian3(2e7, 0, 0) } }, + }; + state._missionOverlayRecords.set(launch.id, { launch }); + const position = Cesium.Cartesian3.fromDegrees(0, 0); + const anchor = state._dataSource.entities.add({ + id: `rocket-launch:${launch.id}`, + position, + point: { pixelSize: 5 }, + }); + const path = state._dataSource.entities.add({ + id: `rocket-transfer:${launch.id}`, + polyline: { positions: [position, Cesium.Cartesian3.fromDegrees(1, 1)] }, + }); + const syncs = { labels: 0, orbits: 0 }; + const parts = { + panel: { updateMissionTelemetry: noop }, + overlays: { + hideReplayVehicleOverlay: noop, + updateReplayVehicleOverlay: noop, + refreshSelectedMissionOverlayText: noop, + missionAnchorVisible: () => true, + syncMissionOverlayEntries: () => syncs.labels++, + }, + orbitRendering: { + syncMissionOrbitPrimitiveVisibility: () => syncs.orbits++, + }, + }; + const services = { + geometry: { getKeyholeGeometry: noop }, + satellites: {}, + }; + parts.selection = createSelection({ state, services, parts }); + const rendering = createRendering({ state, services, parts }); + const shown = () => { + rendering.updateMissionFrame(); + const time = Cesium.JulianDate.now(); + return { + marker: anchor.point.show.getValue(time), + path: path.polyline.show?.getValue(time) ?? true, + }; + }; + return { state, launch, syncs, shown }; +} + +test('live shows every launch in the window, lifted off or not', (t) => { + const { state, launch, shown } = scene(t); + assert.equal(state.launchPending(launch), false); + assert.deepEqual(shown(), { marker: true, path: true }); +}); + +test('away from live a launch shows from its lift-off on', (t) => { + const { state, launch, syncs, shown } = scene(t); + worldClock.seek(LAUNCH_MS - 60_000); + assert.equal(state.launchPending(launch), true); + assert.deepEqual(shown(), { marker: false, path: false }); + assert.deepEqual(syncs, { labels: 1, orbits: 1 }, 'its label and orbit go'); + shown(); + assert.deepEqual(syncs, { labels: 1, orbits: 1 }, 'once, not each frame'); + + worldClock.seek(LAUNCH_MS); + assert.equal(state.launchPending(launch), false); + assert.deepEqual(shown(), { marker: true, path: true }); + assert.deepEqual(syncs, { labels: 2, orbits: 2 }); +}); + +test('the selected mission stays whenever it flies', (t) => { + const { state, launch, shown } = scene(t); + state._selectedLaunchId = launch.id; + worldClock.seek(LAUNCH_MS - 60_000); + assert.deepEqual(shown(), { marker: true, path: true }); +}); diff --git a/src/layers/military/motion.js b/src/layers/military/motion.js index 27489400b..a13508e54 100644 --- a/src/layers/military/motion.js +++ b/src/layers/military/motion.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { julianAt } from '../../time/julian.js'; import { lerpAngleDeg, speedRamp, @@ -67,7 +68,7 @@ export function createMotion({ // Render one poll interval behind real time so we interpolate between // two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), + julianAt(flightState.now()), -RENDER_DELAY_SEC, flightState._scratchRenderTime, ); @@ -213,7 +214,7 @@ export function createMotion({ const history = flightState._positionHistory.get(flightState._trackedIcao); if (!history || history.length === 0) return true; const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), + julianAt(flightState.now()), -RENDER_DELAY_SEC, flightState._scratchWarmupTime, ); diff --git a/src/layers/military/state.js b/src/layers/military/state.js index f1a2d691f..f27595d2b 100644 --- a/src/layers/military/state.js +++ b/src/layers/military/state.js @@ -1,6 +1,7 @@ import { MilitaryFlightRecords } from './records.js'; import { createMilitaryFeed } from './ingestion.js'; import * as Cesium from 'cesium'; +import { worldTimeFor } from '../../time/worldClock.js'; export function createFlightState({ source, services }) { const { createGroundSnap } = services.groundSnap; @@ -11,6 +12,8 @@ export function createFlightState({ source, services }) { ...services.groundFloor, }); flightState._cullPositions = new Map(); + /** The time contacts are drawn behind by the render delay, in epoch ms. */ + flightState.now = worldTimeFor('military'); /** Per-class model spec for THIS layer (2026-08-16, owner playtest ask: * military contacts should read as their WEIGHT CLASS, always in this layer's diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 5ad60adf9..ed40ff4ac 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { julianAt } from '../../time/julian.js'; import { t } from '../../i18n/index.js'; import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; @@ -500,7 +501,7 @@ export function createTracking({ // newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them // would draw the trail in front of the plane. They join via _appendTrailFix as they age. const seedRenderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), + julianAt(flightState.now()), -RENDER_DELAY_SEC, flightState._scratchWarmupTime, ); diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js index 7e452d0ee..b1faf8f70 100644 --- a/src/layers/satellites/catalog.js +++ b/src/layers/satellites/catalog.js @@ -32,7 +32,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { 1, Math.ceil(layerState._denseIds.length / DENSE_REFRESH_FRAMES), ); - const now = new Date(); + const now = new Date(layerState.now()); for (let i = 0; i < perFrame; i++) { if (layerState._denseCursor >= layerState._denseIds.length) layerState._denseCursor = 0; @@ -97,7 +97,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { const entries = parts.orbits.parseTLE(text); const style = POINT_STYLES.dense; - const now = new Date(); + const now = new Date(layerState.now()); let added = 0; for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) { @@ -172,6 +172,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { layerState._count = layerState._points.size; layerState._catalogRevision++; + layerState.coverageChanged(); layerState._denseStatus = 'ready'; console.log( `[Data:Satellites] Dense catalog: +${added} ${DENSE_GROUP_PATH} (points only)`, @@ -247,6 +248,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { layerState._denseCursor = 0; layerState._count = layerState._points.size; layerState._catalogRevision++; + layerState.coverageChanged(); } return { _abortActiveUpdates, diff --git a/src/layers/satellites/classes.test.mjs b/src/layers/satellites/classes.test.mjs index 6243181f9..66f983a6f 100644 --- a/src/layers/satellites/classes.test.mjs +++ b/src/layers/satellites/classes.test.mjs @@ -326,6 +326,7 @@ test('a failed Starlink load hides the core catalog Starlink points again', asyn _detectionObjects: new Map(), _trackedNorad: null, _activeUpdateControllers: new Set(), + coverageChanged() {}, }; const catalog = createCatalog({ state, @@ -384,6 +385,7 @@ test('enabling the layer leaves a hidden Stations class without its ISS ring', a [90001, other], ]), _points: new Map(), + coverageChanged() {}, }; const { methods } = createLifecycle({ state, diff --git a/src/layers/satellites/cockpitSubject.js b/src/layers/satellites/cockpitSubject.js index 703eeb0e6..aadd81aac 100644 --- a/src/layers/satellites/cockpitSubject.js +++ b/src/layers/satellites/cockpitSubject.js @@ -202,16 +202,24 @@ export function createCockpitSubject({ state: layerState, parts }) { // The tracked primitive stays hidden: its entity draws the dot. if (noradId !== tracked) _setPointShown(noradId, true); } + // Re-applied every sync: a catalog rebuild replaces the point + // primitives, and the new ones arrive shown. for (const noradId of companions) { - if (noradId === tracked || hiddenPoints.has(noradId)) continue; + if (noradId === tracked) continue; hiddenPoints.add(noradId); _setPointShown(noradId, false); } + if (held) { + const trackedPoint = layerState._points.get(tracked); + if (trackedPoint) trackedPoint.show = false; + } const hiddenPath = layerState._cockpitHiddenPath; if (hiddenPath !== null && (!held || hiddenPath !== tracked)) { const path = layerState._orbitPaths.get(hiddenPath); - if (path) + // The ISS's ring also follows its class (and its tracking). + if (path && hiddenPath === ISS_NORAD) parts.controls._syncIssOrbit(); + else if (path) path.primitive.show = parts.controls.satelliteVisualsVisible( layerState._enabled, layerState._params.showOrbits, diff --git a/src/layers/satellites/cockpitSubject.test.mjs b/src/layers/satellites/cockpitSubject.test.mjs index b9b0332ae..26cef12a4 100644 --- a/src/layers/satellites/cockpitSubject.test.mjs +++ b/src/layers/satellites/cockpitSubject.test.mjs @@ -154,7 +154,7 @@ async function fixture(t) { await parts.lifecycle.methods.init(viewer); await parts.ingestion.methods.update(viewer); parts.lifecycle.methods.enable(viewer); - // GPS is TELECOM, hidden by default; these tests need its point on screen. + // GPS is TELECOM; these tests need its point on screen whatever the default. parts.controls.methods.setParams({ classes: { telecom: true } }); Object.assign(console, { log, warn }); t.after(() => { @@ -377,6 +377,38 @@ test('untracking a held subject gives its marks back', async (t) => { assert.equal(f.state._points.get(ISS_NORAD).show, true); }); +test('a catalog refresh while held keeps the subject and its companions hidden', async (t) => { + const f = await fixture(t); + f.track(ISS_NORAD); + f.nextFrame(); + f.methods.setCockpitSubject(ISS_NORAD); + const log = console.log; + console.log = () => {}; + try { + await f.parts.ingestion.methods.update(f.viewer); + } finally { + console.log = log; + } + f.nextFrame(); + assert.equal(f.state._points.get(COMPANION).show, false, 'companion'); + assert.equal(f.state._points.get(ISS_NORAD).show, false, 'tracked point'); + f.methods.setCockpitSubject(null); + assert.equal(f.state._points.get(COMPANION).show, true, 'given back'); +}); + +test('untracking a held ISS leaves its ring hidden while Stations is off', async (t) => { + const f = await fixture(t); + f.track(ISS_NORAD); + f.nextFrame(); + f.methods.setCockpitSubject(ISS_NORAD); + f.methods.setParams({ classes: 'itpd' }); + f.nextFrame(); + f.methods.stopTracking(); + assert.equal(f.ring(ISS_NORAD).show, false); + f.methods.setParams({ classes: 'sitpd' }); + assert.equal(f.ring(ISS_NORAD).show, true); +}); + test('tracking without a Cockpit subject keeps its marks', async (t) => { const f = await fixture(t); f.track(GPS); @@ -555,8 +587,18 @@ test('the ground track bearing comes from the SGP4 velocity, less the Earth turn test('the subject says whether its class may be ridden', () => { const fix = { latitude: 0, longitude: 0, altitude: 400_000 }; const subject = (satelliteClass) => - satelliteCockpitInfo(1, { name: 'X', group: 'visual', satelliteClass }, fix); + satelliteCockpitInfo( + 1, + { name: 'X', group: 'visual', satelliteClass }, + fix, + ); assert.equal(subject('debris').enterable, false); - for (const satelliteClass of ['stations', 'imaging', 'telecom', 'starlink', 'probes']) + for (const satelliteClass of [ + 'stations', + 'imaging', + 'telecom', + 'starlink', + 'probes', + ]) assert.equal(subject(satelliteClass).enterable, true, satelliteClass); }); diff --git a/src/layers/satellites/controls.js b/src/layers/satellites/controls.js index 230c6a68a..dee92342a 100644 --- a/src/layers/satellites/controls.js +++ b/src/layers/satellites/controls.js @@ -314,7 +314,10 @@ export function createControls({ state: layerState, services, parts, source }) { if (noradId === null) return null; const sat = layerState._catalog.get(noradId); - const pos = parts.orbits.propagatePosition(sat.satrec, new Date()); + const pos = parts.orbits.propagatePosition( + sat.satrec, + new Date(layerState.now()), + ); if (!pos) return null; return { diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index 0b9b6bc1a..96df3ea35 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -294,7 +294,7 @@ export function createIngestion({ layerState._denseLoadToken++; // cancel any in-flight dense load against the old catalog parts.labels._syncIssOverlay(); - const now = new Date(); + const now = new Date(layerState.now()); // Process all TLE entries in CATALOG_GROUPS order const allEntries = []; @@ -425,6 +425,7 @@ export function createIngestion({ // the rebuild must not block on that. void parts.tracking._reconcileTrackedSubjectContext(); parts.tracking._applyPendingTrackingRestore(); + layerState.coverageChanged(); } catch (e) { if (updateSignal.aborted || e?.name === 'AbortError') { throw new DOMException('Satellite update aborted', 'AbortError'); diff --git a/src/layers/satellites/ingestion.test.mjs b/src/layers/satellites/ingestion.test.mjs index 85b3ff020..12d97ee7b 100644 --- a/src/layers/satellites/ingestion.test.mjs +++ b/src/layers/satellites/ingestion.test.mjs @@ -40,6 +40,8 @@ function ingestionFor(catalogText, { source: sourceExtras = {} } = {}) { _params: { catalog: 'core', showOrbits: false }, _satcat: new Map(), _ucs: null, + now: Date.now, + coverageChanged() {}, }; const parts = { orbits: { diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js index 294043aa5..ae3088109 100644 --- a/src/layers/satellites/lifecycle.js +++ b/src/layers/satellites/lifecycle.js @@ -1,5 +1,10 @@ import * as Cesium from 'cesium'; -import { ISS_NORAD, ISS_OVERLAY_SOURCE_ID } from './policy.js'; +import { + ISS_NORAD, + ISS_OVERLAY_SOURCE_ID, + SATELLITE_COVERAGE_MS, +} from './policy.js'; +import { worldClock } from '../../time/worldClock.js'; export function createLifecycle({ state: layerState, @@ -8,6 +13,20 @@ export function createLifecycle({ source, }) { const { clearFocusTarget } = services.focus; + // While on with a catalog, satellites can be shown at any time in reach of + // their element sets. Registered once; off, the layer has no time to show, + // and each step tells the clock once it has settled. + worldClock.register({ + id: 'satellites', + coverage: () => { + if (!layerState._enabled || !layerState._catalog.size) return null; + const now = worldClock.wallNow(); + return { + start: now - SATELLITE_COVERAGE_MS, + end: now + SATELLITE_COVERAGE_MS, + }; + }, + }); const { holdContinuousRender, releaseContinuousRender } = services.render; const { registerPickOwner, unregisterPickOwner } = services.picking; @@ -50,6 +69,7 @@ export function createLifecycle({ layerState._denseStatus = 'idle'; layerState._denseError = null; layerState._catalogRevision++; + layerState.coverageChanged(); // Point primitives for satellite dots layerState._pointCollection = new Cesium.PointPrimitiveCollection(); @@ -99,6 +119,7 @@ export function createLifecycle({ ); } parts.tracking._applyPendingTrackingRestore(); + layerState.coverageChanged(); }, disable(viewer) { @@ -126,6 +147,7 @@ export function createLifecycle({ layerState._preRenderListener(); layerState._preRenderListener = null; } + layerState.coverageChanged(); }, destroy(viewer) { @@ -183,6 +205,7 @@ export function createLifecycle({ failedGroups: [], }; layerState._viewer = null; + layerState.coverageChanged(); }, }; diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js index fe4de6789..ccf5c166e 100644 --- a/src/layers/satellites/orbits.js +++ b/src/layers/satellites/orbits.js @@ -197,7 +197,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { lonDeg, minElevDeg = 10, requireVisible = false, - fromMs = Date.now(), + fromMs = layerState.now(), }) { return getNextSatellitePass(ISS_NORAD, { latDeg, @@ -227,7 +227,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { lonDeg, minElevDeg = 10, requireVisible = false, - fromMs = Date.now(), + fromMs = layerState.now(), }, ) { const id = Number(noradId); @@ -331,7 +331,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { } function orbitTrackFromRecord(name, satrec) { - const referenceDate = new Date(); + const referenceDate = new Date(layerState.now()); const current = propagatePosition(satrec, referenceDate); if (!current) return null; return { diff --git a/src/layers/satellites/policy.js b/src/layers/satellites/policy.js index 48152e818..44b31d814 100644 --- a/src/layers/satellites/policy.js +++ b/src/layers/satellites/policy.js @@ -32,6 +32,15 @@ export const ORBIT_PATH_STEPS = 180; // points per orbital path export const POSITION_UPDATE_MS = 1000; +/** How far either side of now SGP4 on current element sets stays meaningful. */ +export const SATELLITE_COVERAGE_MS = 14 * 24 * 3_600_000; + +/** + * How far the world time may move from when an orbit ring was drawn before it + * is drawn again: the Earth's turn is followed exactly, but the orbit's plane + * drifts a few degrees a day. + */ +export const ORBIT_REBAKE_MS = 30 * 60_000; // re-propagate every 1s (SGP4 is smooth at this rate) export const RING_ROTATION_MS = 1000; diff --git a/src/layers/satellites/records.js b/src/layers/satellites/records.js index 84ca0e3ff..7371eb246 100644 --- a/src/layers/satellites/records.js +++ b/src/layers/satellites/records.js @@ -82,7 +82,7 @@ export function createRecords({ state, parts }) { const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000; - const now = new Date(); + const now = new Date(state.now()); const result = []; const emit = (noradId, sat) => { if (result.length >= limit) return false; diff --git a/src/layers/satellites/rendering.js b/src/layers/satellites/rendering.js index 4c612a74a..177884b48 100644 --- a/src/layers/satellites/rendering.js +++ b/src/layers/satellites/rendering.js @@ -1,6 +1,11 @@ import * as Cesium from 'cesium'; import { gstime } from 'satellite.js'; -import { ISS_NORAD, POSITION_UPDATE_MS, RING_ROTATION_MS } from './policy.js'; +import { + ISS_NORAD, + ORBIT_REBAKE_MS, + POSITION_UPDATE_MS, + RING_ROTATION_MS, +} from './policy.js'; export function createRendering({ state: layerState, @@ -42,7 +47,7 @@ export function createRendering({ const sat = layerState._catalog.get(noradId); if (!sat || !layerState._viewer) return; - const bakeDate = new Date(); + const bakeDate = new Date(layerState.now()); const basePositions = parts.orbits.computeOrbitPath(sat.satrec, bakeDate); if (basePositions.length < 2) return; @@ -77,6 +82,8 @@ export function createRendering({ layerState._orbitPaths.set(noradId, { primitive, + color: pathColor, + bakeMs: bakeDate.getTime(), // Same Date object as computeOrbitPath's internal fixedGmst → identical // GMST value (gstime is pure), so delta starts at exactly 0 and the // initial identity modelMatrix is correct until the first ring tick. @@ -120,8 +127,15 @@ export function createRendering({ function _updateOrbitPathRotations(nowDate) { if (layerState._orbitPaths.size === 0) return; - for (const path of layerState._orbitPaths.values()) { + for (const [noradId, path] of [...layerState._orbitPaths]) { if (!path.primitive) continue; + // The rotation keeps the ring under the dot for minutes; the plane + // itself precesses, so a world time moved far from the bake re-bakes. + if (Math.abs(nowDate.getTime() - path.bakeMs) > ORBIT_REBAKE_MS) { + _hideOrbitPath(noradId); + _showOrbitPath(noradId, path.color); + continue; + } parts.orbits.orbitFrameModelMatrix( path.gmstAtBake, nowDate, @@ -151,7 +165,7 @@ export function createRendering({ */ function _propagateAll() { - const now = new Date(); + const now = new Date(layerState.now()); let updated = 0; for (const [noradId, sat] of layerState._catalog) { @@ -223,7 +237,7 @@ export function createRendering({ layerState._params.showOrbits && now - layerState._lastRingRotation >= RING_ROTATION_MS ) { - _updateOrbitPathRotations(new Date(now)); + _updateOrbitPathRotations(new Date(layerState.now())); layerState._lastRingRotation = now; } } diff --git a/src/layers/satellites/state.js b/src/layers/satellites/state.js index ad603f392..73f5c79c9 100644 --- a/src/layers/satellites/state.js +++ b/src/layers/satellites/state.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { DEFAULT_SATELLITE_CLASSES } from './classes.js'; +import { worldClock, worldTimeFor } from '../../time/worldClock.js'; export function createState({ services }) { const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = @@ -187,6 +188,14 @@ export function createState({ services }) { state._cockpitHiddenDot = false; + /** The time satellites are propagated to, in epoch ms. */ + + state.now = worldTimeFor('satellites'); + + /** The catalog changed, so the time satellites can be shown at may have. */ + + state.coverageChanged = () => worldClock.coverageChanged(); + // Scratch variables state._scratchCartesian = new Cesium.Cartesian3(); diff --git a/src/layers/satellites/tracking.js b/src/layers/satellites/tracking.js index ccdd8b100..0431e0c7d 100644 --- a/src/layers/satellites/tracking.js +++ b/src/layers/satellites/tracking.js @@ -159,7 +159,7 @@ export function createTracking({ state: layerState, services, parts, source }) { sat.satrec, layerState._trackedFrameNowForTest ? new Date(layerState._trackedFrameNowForTest()) - : new Date(), + : new Date(layerState.now()), ); if (!pos) return layerState._trackedFrameGeo; // propagation hiccup — keep last good sample layerState._trackedFrameGeo = pos; @@ -440,7 +440,10 @@ export function createTracking({ state: layerState, services, parts, source }) { // Comfortable tracking landing: ~726 km back for LEO (user-validated // "slightly zoomed out" framing — no stutter, label reads cleanly), scaled // up for MEO/GEO so the camera doesn't land on top of a high-orbit dot. - const initialPos = parts.orbits.propagatePosition(sat.satrec, new Date()); + const initialPos = parts.orbits.propagatePosition( + sat.satrec, + new Date(layerState.now()), + ); const viewScale = initialPos && initialPos.altitude > HIGH_ORBIT_ALTITUDE_M ? TRACK_VIEW_FROM_HIGH_SCALE diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 9c22b95a5..f3b421991 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -8,6 +8,7 @@ import { createSelection } from './selection.js'; import { createViewerHost } from './viewerHost.js'; import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js'; import { normalizeFilter, resolveFilter, sameFilter } from './filter.js'; +import { worldTimeFor } from '../../time/worldClock.js'; import { decodeParams, encodeParams } from './params.js'; import { composeUIState, @@ -41,6 +42,9 @@ const GROUND_RETRY_MS = 1000; * a time. * @param {{providers: Array, services?: object}} options */ +// "Since N days" counts back from the world time. +const streetLevelTime = worldTimeFor(STREET_LEVEL_LAYER_ID); + export function createStreetLevelLayer({ providers: definitions, services = {}, @@ -93,7 +97,7 @@ export function createStreetLevelLayer({ return Object.freeze({ services: state.services, /** Resolved against now, so "since N days" keeps up with the clock. */ - getFilter: () => resolveFilter(state.filter), + getFilter: () => resolveFilter(state.filter, streetLevelTime()), /** The layer and this provider's switch are both on. */ isActive: () => state.enabled && entry.on, notify, @@ -365,7 +369,7 @@ export function createStreetLevelLayer({ const filter = normalizeFilter(next, state.filter); if (sameFilter(filter, state.filter)) return; state.filter = filter; - const resolved = resolveFilter(filter); + const resolved = resolveFilter(filter, streetLevelTime()); for (const entry of state.providers.values()) entry.instance.setFilter(resolved); notify(); diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index ea547f85d..d1c14dd64 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -3927,6 +3927,7 @@ test('parked transit reprojects held and reported courses at the bounded camera _rotationRevision: -1, _cameraRevision: 0, _rotationDirty: false, + now: () => Date.now(), }; const noop = () => {}; const rendering = createRendering({ diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index 2309978a5..f3868444a 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -108,7 +108,7 @@ export function createRendering({ state, services, parts }) { } function sampleIdle(entry) { if (!state._moving.has(entry)) { - updatePlayback(entry, Date.now(), performance.now()); + updatePlayback(entry, state.now(), performance.now()); placeSample(entry); } } @@ -172,7 +172,7 @@ export function createRendering({ state, services, parts }) { () => { entry.wakeTimer = null; if (!state._enabled || !state._vehicles.has(entry.key)) return; - updatePlayback(entry, Date.now(), performance.now()); + updatePlayback(entry, state.now(), performance.now()); placeSample(entry); schedulePlayback(entry); syncRenderHold(); @@ -388,7 +388,7 @@ export function createRendering({ state, services, parts }) { ) return; if (!state._enabled) return; - const now = Date.now(); + const now = state.now(); if (state._vehicles.size === 0) { state._moving.clear(); syncRenderHold(); @@ -424,7 +424,7 @@ export function createRendering({ state, services, parts }) { } parts.trails.update(); - refreshRotations(now); + refreshRotations(Date.now()); const selected = state._vehicles.get(state._selectedKey); if (selected && state._visible.has(selected)) { selected.marker.rotation = Number.isFinite(selected.courseDeg) @@ -489,7 +489,7 @@ export function createRendering({ state, services, parts }) { const noSample = !entry.sample || !Number.isFinite(entry.sample.lat); if (entry.track?.count && (!state._visible.has(entry) || noSample)) { entry.sample ||= {}; - updatePlayback(entry, now, performance.now()); + updatePlayback(entry, state.now(), performance.now()); } sphere.center = state._visible.has(entry) || !entry.sample @@ -557,7 +557,7 @@ export function createRendering({ state, services, parts }) { } function resumePlayback() { if (!state._enabled) return; - const now = Date.now(), + const now = state.now(), mono = performance.now(); const entries = new Set(state._visible); const selected = state._vehicles.get(state._selectedKey); diff --git a/src/layers/transit/state.js b/src/layers/transit/state.js index 75b13d84b..5bb152015 100644 --- a/src/layers/transit/state.js +++ b/src/layers/transit/state.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { createHistoryBudget } from '../../data/contactPlayback.js'; +import { worldTimeFor } from '../../time/worldClock.js'; /** * One Transit layer's mutable scene state. Every field lives on the instance, @@ -14,6 +15,8 @@ export function createState({ services }) { const state = {}; state._historyBudget = createHistoryBudget(); + /** The time vehicles play back behind by their display delay, in epoch ms. */ + state.now = worldTimeFor('transit'); state.DEFAULT_OVERLAY_HOST = Object.freeze({ setEntries: setOverlayEntries, diff --git a/src/layers/weather/clock.js b/src/layers/weather/clock.js index a758dddd2..7384935b0 100644 --- a/src/layers/weather/clock.js +++ b/src/layers/weather/clock.js @@ -1,22 +1,75 @@ +import { createWorldClock } from '../../time/worldClock.js'; + export const RADAR_MAX_GAP_MS = 30 * 60_000; export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000; export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000; export const LIGHTNING_MAX_GAP_MS = 30 * 60_000; +/** The longest play waits on a frame before it goes on without it. */ +export const PLAY_WAIT_MS = 8_000; +// Playing on its own, the world does not wait on the frame it is at: that +// frame loads as play goes on. Play holds only at the next frame while the +// one before it still loads, then goes on to it once it shows, so every frame +// shows, play stops only for a load that is slow, and a frame loaded ahead +// costs play nothing. -/** Transient observed time. Products must honor the signal before installing a frame. */ +let clocks = 0; + +/** + * Transient observed time. Products must honor the signal before installing a + * frame. Its time is the world clock's: latest while the world is live, + * history at the world's time otherwise. Replay steps the observed frames and + * moves the world clock as it goes, as the world clock's driver. + */ export function createWeatherClock({ now = Date.now, setTimeout = globalThis.setTimeout, clearTimeout = globalThis.clearTimeout, + world = createWorldClock({ now }), } = {}) { const registry = new Map(); const listeners = new Set(); let mode = 'latest'; let target = null; let playing = false; + // The world time last applied; while this clock moves the world, the + // transition its move started. + let seen = 'latest'; + let moving = false; + let moved = null; + const releases = new Map(); + // Attached at the end, once the API exists. + let driver = null; let generation = 0; let timer = null; let pending = null; + // This clock's name on the world, for its holds and its gate. + const holdId = `weather:${++clocks}`; + // While the world plays, the hold on it at the next frame as the one before + // it loads, and the timer that lets play go on should that frame never + // come; and whether the move to the next frame waits on it. + let waiting = null; + let waitTimer = null; + let deferred = false; + const stopWaiting = () => { + const release = waiting; + waiting = null; + if (waitTimer !== null) clearTimeout(waitTimer); + waitTimer = null; + release?.(); + }; + const startWaiting = () => { + if (waiting || destroyed || !registry.size) return; + waiting = world.wait(holdId); + waitTimer = setTimeout(() => { + waitTimer = null; + stopWaiting(); + // The frame that never came gives way to the one play is at. + if (deferred) void transition(); + }, PLAY_WAIT_MS); + }; + // Playing on its own, the world waits for frames as they load. + const worldPlaysOn = (state = world.getState()) => + state.playing && !state.live && !state.stepping; let destroyed = false; const timesFor = (product) => [...new Set(product.getTimes())] @@ -49,6 +102,9 @@ export function createWeatherClock({ mode, target, playing, + // Frames come on one after another: this clock replays them, or the world + // plays on through them, so the next is worth loading ahead. + advancing: playing || worldPlaysOn(), timeline: getTimeline(), products: [...registry.values()].map(({ product }) => ({ id: product.id, @@ -65,11 +121,32 @@ export function createWeatherClock({ timer = null; }; const canPlay = () => getTimeline().length >= 2; + const stopPlaying = () => { + if (!playing) return; + playing = false; + driver?.driven(false); + }; + // Move the world clock; the change comes back through followWorld. Returns + // the transition the move started, if it started one. + const move = (fn) => { + const outer = [moving, moved]; + moving = true; + moved = null; + try { + fn(); + return moved; + } finally { + [moving, moved] = outer; + } + }; + const selections = () => + [...registry.values()].map(({ product }) => selectedFor(product)).join(); + let applied = null; function schedule() { cancelTimer(); if (!playing || destroyed || pending) return; if (!canPlay()) { - playing = false; + stopPlaying(); notify(); return; } @@ -92,7 +169,11 @@ export function createWeatherClock({ if (destroyed) return Promise.resolve(false); cancelTimer(); const owner = ++generation; - if (!canPlay()) playing = false; + if (!canPlay()) stopPlaying(); + // A scrub drops what loads and moves on; play goes on as this loads. + applied = selections(); + deferred = false; + stopWaiting(); for (const entry of registry.values()) entry.controller?.abort(); // Start all products together; a rejection must not skip the settle barrier. const tasks = [...registry.values()].map((entry) => { @@ -106,13 +187,17 @@ export function createWeatherClock({ }); const result = Promise.allSettled(tasks).then(() => { if (destroyed || owner !== generation) return false; + stopWaiting(); pending = null; - if (!canPlay()) playing = false; + if (!canPlay()) stopPlaying(); notify(); schedule(); + // Play held at the next frame for this one goes on to it. + if (deferred) void transition(); return true; }); pending = result; + if (moving) moved ??= result; notify(); return result; } @@ -123,11 +208,41 @@ export function createWeatherClock({ throw new Error(`Duplicate weather product: ${product.id}`); const entry = { product, controller: null }; registry.set(product.id, entry); + // The product moves with the world clock, and its frames are time the + // world clock's timeline can show while it is on, suspended or not, so + // the way back to live stays on screen. Both calls notify the world's + // listeners, which may destroy this clock: the release is in place + // first, and anything acquired after a destroy is let go at once. + let unhold = null; + let uncover = null; + const release = () => { + uncover?.(); + unhold?.(); + uncover = unhold = null; + }; + releases.set(product.id, release); + unhold = world.hold(product.id); + if (!destroyed) + uncover = world.register({ + id: product.id, + coverage: () => { + const marks = timesFor(product).map((time) => Date.parse(time)); + return marks.length + ? { start: marks[0], end: marks.at(-1), marks } + : null; + }, + }); + if (destroyed) { + release(); + return () => {}; + } void api.refresh(); return () => { if (registry.get(product.id) !== entry) return; entry.controller?.abort(); registry.delete(product.id); + releases.get(product.id)?.(); + releases.delete(product.id); void api.refresh(); }; }, @@ -140,9 +255,11 @@ export function createWeatherClock({ const normalized = new Date(time).toISOString(); if (mode === 'history' && target === normalized) return pending ?? Promise.resolve(true); - mode = 'history'; - target = normalized; - return transition(); + return ( + move(() => world.seek(Date.parse(normalized))) ?? + pending ?? + Promise.resolve(true) + ); }, step(direction) { if (destroyed || ![-1, 1].includes(direction)) @@ -159,10 +276,9 @@ export function createWeatherClock({ }, latest() { if (destroyed) return Promise.resolve(false); - mode = 'latest'; - target = null; - playing = false; - return transition(); + stopPlaying(); + if (mode === 'latest') return transition(); + return move(() => world.goLive()) ?? pending ?? Promise.resolve(true); }, play() { if (destroyed || playing || !canPlay()) @@ -170,18 +286,28 @@ export function createWeatherClock({ playing = true; if (mode === 'latest') { const timeline = getTimeline(); - return api.setTarget( - timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0], + const start = + timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0]; + // Playing and at the first frame in one move of the world. + return ( + move(() => driver.driven(true, Date.parse(start))) ?? + pending ?? + Promise.resolve(true) ); } + driver.driven(true); notify(); schedule(); return pending ?? Promise.resolve(true); }, pause() { cancelTimer(); - if (!playing) return; - playing = false; + if (!playing) { + // The world may be playing on its own; a pause or step here holds it. + if (world.isPlaying()) world.pause(); + return; + } + stopPlaying(); notify(); }, togglePlay() { @@ -189,8 +315,12 @@ export function createWeatherClock({ }, refresh() { if (destroyed) return Promise.resolve(false); + // New frames, or products added or dropped, and with them the next. + world.coverageChanged(); + gated = undefined; + gateNext(world.getState()); if (!canPlay()) { - playing = false; + stopPlaying(); cancelTimer(); } if (mode === 'history') return transition(); @@ -204,15 +334,86 @@ export function createWeatherClock({ }, destroy() { if (destroyed) return; + stopPlaying(); destroyed = true; ++generation; - playing = false; + stopWaiting(); + world.gate(holdId, null); cancelTimer(); + unfollowWorld(); + // The last weather clock leaves the world live, as a new one expects. + if (driver.detach() === 0) world.goLive(); + for (const release of releases.values()) release(); + releases.clear(); for (const entry of registry.values()) entry.controller?.abort(); registry.clear(); listeners.clear(); pending = null; }, }; + // Follow the world clock. A move of this clock's own always applies; one + // from elsewhere (a host's seek, continuous play) applies only when it + // changes the mode or a product's frame. + function followWorld(state) { + if (destroyed) return; + gateNext(state); + // Play starting or stopping changes whether the next frame loads ahead. + const playsOn = worldPlaysOn(state); + const playChanged = playsOn !== playedOn; + playedOn = playsOn; + const nextMode = state.live ? 'latest' : 'history'; + const nextTarget = state.live + ? null + : new Date(Math.floor(state.timeMs)).toISOString(); + const key = nextTarget ?? 'latest'; + if (key === seen) { + if (playChanged) notify(); + return; + } + seen = key; + const modeChanged = nextMode !== mode; + mode = nextMode; + target = nextTarget; + if (!moving && !modeChanged && selections() === applied) { + // Back at the frame that loads, play need not hold for it. + if (deferred) { + deferred = false; + stopWaiting(); + } + // Played through, the time moves each frame with nothing new to show: + // listeners hear of the frames as they change. + if (!playsOn || playChanged) notify(); + return; + } + // Playing on into the next frame while the one before it loads, the + // world holds here until that one shows. + if (!moving && !modeChanged && pending && playsOn) { + deferred = true; + startWaiting(); + notify(); + return; + } + void transition(); + } + // Playing on its own, the world stops at each next frame to load it rather + // than run past frames between its ticks. + let gated = null; + let playedOn = false; + function gateNext(state) { + const next = worldPlaysOn(state) + ? (getTimeline() + .map((time) => Date.parse(time)) + .find((time) => time > Math.floor(state.timeMs)) ?? null) + : null; + if (next === gated) return; + gated = next; + world.gate(holdId, next); + } + const unfollowWorld = world.subscribe(followWorld); + driver = world.attachDriver({ + play: () => (canPlay() ? void api.play() : false), + pause: () => api.pause(), + }); + followWorld(world.getState()); return api; } diff --git a/src/layers/weather/clock.test.mjs b/src/layers/weather/clock.test.mjs index 24bd124b8..dd2535ffc 100644 --- a/src/layers/weather/clock.test.mjs +++ b/src/layers/weather/clock.test.mjs @@ -6,7 +6,9 @@ import { REGIONAL_INFRARED_MAX_GAP_MS, GLOBAL_INFRARED_MAX_GAP_MS, LIGHTNING_MAX_GAP_MS, + PLAY_WAIT_MS, } from './clock.js'; +import { createWorldClock } from '../../time/worldClock.js'; const time = (minutes) => new Date(Date.UTC(2026, 8, 21, 12, minutes)).toISOString(); @@ -37,7 +39,15 @@ function harness(t) { let at = Date.parse(time(20)); const timers = new Map(); let id = 0; + let mono = 0; + const world = createWorldClock({ + now: () => at, + monotonicNow: () => mono, + setInterval: () => 0, + clearInterval() {}, + }); const clock = createWeatherClock({ + world, now: () => at, setTimeout(fn, delay) { timers.set(++id, { fn, due: at + delay }); @@ -50,9 +60,11 @@ function harness(t) { t.after(() => clock.destroy()); return { clock, + world, timers, tick(ms) { at += ms; + mono += ms; for (const [id, timer] of [...timers]) { if (timer.due > at) continue; timers.delete(id); @@ -275,3 +287,423 @@ test('registration joins history; unregister and destroy abort work and release assert.deepEqual(clock.getTimeline(), []); assert.equal(await clock.setTarget(time(5)), false); }); + +test('the weather time is the world time, both ways', async (t) => { + const { clock, world } = harness(t); + const radar = product('radar', [0, 10, 20]); + clock.register(radar); + assert.equal(world.follows('radar'), true); + await clock.setTarget(time(10)); + assert.equal(world.isLive(), false); + assert.equal(world.now(), Date.parse(time(10))); + world.seek(Date.parse(time(3))); + await flush(); + assert.equal(clock.getState().target, time(3)); + assert.equal(radar.shown, time(0)); + world.goLive(); + await flush(); + assert.equal(clock.getState().mode, 'latest'); + assert.equal(radar.shown, time(20)); +}); + +test('world play and pause drive the weather replay', async (t) => { + const { clock, world, tick } = harness(t); + const radar = product('radar', [0, 10, 20]); + clock.register(radar); + world.seek(Date.parse(time(0))); + await flush(); + world.play(); + assert.equal(clock.getState().playing, true); + assert.equal(world.isPlaying(), true); + tick(2000); + await flush(); + assert.equal(radar.shown, time(10)); + assert.equal(world.now(), Date.parse(time(10))); + world.pause(); + assert.equal(clock.getState().playing, false); + clock.play(); + assert.equal(world.isPlaying(), true); + clock.pause(); + assert.equal(world.isPlaying(), false); + clock.play(); + world.goLive(); + await flush(); + assert.equal(clock.getState().playing, false); + assert.equal(clock.getState().mode, 'latest'); +}); + +test('a moving world re-applies frames only when a product changes frame', async (t) => { + const { clock, world } = harness(t); + const radar = product('radar', [0, 10, 20]); + clock.register(radar); + world.seek(Date.parse(time(0))); + await flush(); + const applied = radar.calls.length; + world.seek(Date.parse(time(0)) + 60_000); + await flush(); + assert.equal(radar.calls.length, applied); + assert.equal( + clock.getState().target, + new Date(Date.parse(time(1))).toISOString(), + ); + world.seek(Date.parse(time(11))); + await flush(); + assert.equal(radar.calls.length, applied + 1); + assert.equal(radar.shown, time(10)); +}); + +test('a world without frames to step plays on its own', async (t) => { + const { clock, world, tick } = harness(t); + world.seek(Date.parse(time(0))); + world.play(); + assert.equal(clock.getState().playing, false); + tick(1000); + assert.equal(world.now(), Date.parse(time(0)) + 1000); +}); + +function worldAt(at) { + return createWorldClock({ + now: () => at, + monotonicNow: () => 0, + setInterval: () => 0, + clearInterval() {}, + }); +} + +test('weather clocks sharing a world hand play over, and the last leaves it live', async () => { + const world = worldAt(Date.parse(time(20))); + const a = createWeatherClock({ world, now: () => Date.parse(time(20)) }); + const b = createWeatherClock({ world, now: () => Date.parse(time(20)) }); + const radarA = product('radar', [0, 10, 20]); + const radarB = product('radar', [0, 10, 20]); + a.register(radarA); + b.register(radarB); + await a.play(); + assert.equal(world.isPlaying(), true); + world.pause(); + assert.equal(a.getState().playing, false); + assert.equal(world.isPlaying(), false); + await a.play(); + world.goLive(); + assert.equal(a.getState().playing, false); + b.destroy(); + assert.equal(world.follows('radar'), true); + world.seek(Date.parse(time(0))); + world.play(); + assert.equal(a.getState().playing, true); + a.destroy(); + assert.equal(world.follows('radar'), false); + assert.equal(world.isLive(), true); + assert.equal(world.isPlaying(), false); +}); + +test('pausing from inside the first playing notice stops the world too', async (t) => { + const { clock, world } = harness(t); + clock.register(product('radar', [0, 10, 20])); + const off = clock.subscribe((state) => { + if (state.playing) { + off(); + clock.pause(); + } + }); + await clock.play(); + assert.equal(clock.getState().playing, false); + assert.equal(world.isPlaying(), false); +}); + +test('a step holds a world that was playing on its own', async (t) => { + const { clock, world } = harness(t); + world.seek(Date.parse(time(0))); + world.play(); + clock.register(product('radar', [0, 10, 20])); + await clock.step(1); + assert.equal(world.isPlaying(), false); + assert.equal(world.now(), Date.parse(time(10))); +}); + +test('a target superseded from a listener resolves false', async (t) => { + const { clock } = harness(t); + const radar = product('radar', [0, 10, 20]); + clock.register(radar); + await flush(); + let redirected = false; + clock.subscribe((state) => { + if (state.target === time(0) && !redirected) { + redirected = true; + void clock.setTarget(time(10)); + } + }); + assert.equal(await clock.setTarget(time(0)), false); + assert.equal(radar.shown, time(10)); +}); + +test('each product’s frames are world clock coverage, and support the timeline while shown', (t) => { + const { clock, world } = harness(t); + assert.equal(world.coverage(), null); + assert.equal(world.getState().supported, false); + const radar = product('radar', [0, 10]); + const unregister = clock.register(radar); + clock.register(product('lightning', [5])); + assert.deepEqual(world.coverage(), { + start: Date.parse(time(0)), + end: Date.parse(time(10)), + marks: [0, 5, 10].map((m) => Date.parse(time(m))), + }); + assert.equal(world.getState().supported, true); + unregister(); + assert.deepEqual(world.coverage().marks, [Date.parse(time(5))]); + clock.destroy(); + assert.equal(world.coverage(), null); + assert.equal(world.getState().supported, false); +}); + +test('a suspended product keeps its coverage, so the way back to live stays', (t) => { + const { clock, world } = harness(t); + const radar = product('radar', [0, 10]); + clock.register(radar); + radar.suspended = true; + void clock.refresh(); + assert.equal(world.getState().supported, true); + assert.deepEqual( + world.coverage().marks, + [0, 10].map((m) => Date.parse(time(m))), + ); +}); + +test('new frames reach the world clock through refresh', (t) => { + const { clock, world } = harness(t); + const radar = product('radar', []); + clock.register(radar); + const supported = []; + world.subscribe((state) => supported.push(state.supported)); + assert.equal(world.getState().supported, false); + radar.times = [time(0)]; + void clock.refresh(); + assert.equal(supported.at(-1), true); +}); + +test('a clock destroyed while it registers a product lets go of it', (t) => { + const { clock, world } = harness(t); + const off = world.subscribe(() => { + off(); + clock.destroy(); + }); + const unregister = clock.register(product('radar', [0, 10])); + unregister(); + assert.equal(world.follows('radar'), false); + assert.equal(world.coverage(), null); + assert.equal(world.getState().supported, false); +}); + +// A radar whose frames load only when the test says. +function slowRadar(minutes) { + const radar = product('radar', minutes); + radar.loads = []; + radar.apply = (selected, { signal }) => + new Promise((resolve) => { + radar.loads.push({ + selected, + signal, + done() { + radar.shown = selected; + resolve(true); + }, + }); + }); + return radar; +} + +test('play stops at the next frame, not past it, until it shows', async (t) => { + const { clock, world, tick } = harness(t); + const radar = slowRadar([0, 10, 20]); + clock.register(radar); + world.seek(Date.parse(time(1))); + await flush(); + radar.loads.at(-1).done(); + await flush(); + world.play({ rate: 3_600, continuous: true }); + world.coverageChanged(); + tick(1_000); + assert.equal( + world.now(), + Date.parse(time(10)), + 'an hour on, but held at the frame', + ); +}); + +test('playing, a frame loads as play goes on, and play holds at the next until it shows', async (t) => { + const { clock, world, tick } = harness(t); + const radar = slowRadar([0, 10, 20, 30]); + clock.register(radar); + world.seek(Date.parse(time(9))); + await flush(); + radar.loads.at(-1).done(); + await flush(); + world.play({ rate: 60, continuous: true }); + // Into the next frame (the page's frames tell of it): it loads as play goes on. + tick(2_000); + world.coverageChanged(); + await flush(); + assert.equal(radar.loads.at(-1).selected, time(10)); + assert.equal( + world.getState().buffering, + false, + 'no hold for the frame play is at', + ); + // At the frame after while that one still loads, play holds there. + tick(10_000); + world.coverageChanged(); + await flush(); + assert.equal(world.now(), Date.parse(time(20))); + assert.equal(world.getState().buffering, true); + assert.equal( + radar.loads.at(-1).selected, + time(10), + 'the frame before still loads', + ); + tick(5_000); + assert.equal(world.now(), Date.parse(time(20)), 'held while it loads'); + radar.loads.at(-1).done(); + await flush(); + assert.equal( + radar.loads.at(-1).selected, + time(20), + 'and on to the next once it shows', + ); + assert.equal(world.getState().buffering, false); + tick(1_000); + assert.equal(world.now(), Date.parse(time(20)) + 60_000); + + // A scrub aborts what loads and loads where the hand is, with no wait. + world.pause(); + world.seek(Date.parse(time(30))); + await flush(); + const scrubbed = radar.loads.at(-1); + world.seek(Date.parse(time(0))); + await flush(); + assert.equal(scrubbed.signal.aborted, true); + assert.equal(radar.loads.at(-1).selected, time(0)); + assert.equal(world.getState().buffering, false); +}); + +test('a frame that never comes holds play only so long', async (t) => { + const { clock, world, tick } = harness(t); + const radar = slowRadar([0, 10, 20]); + clock.register(radar); + world.seek(Date.parse(time(9))); + await flush(); + radar.loads.at(-1).done(); + await flush(); + world.play({ rate: 60, continuous: true }); + tick(2_000); + world.coverageChanged(); + await flush(); + tick(10_000); + world.coverageChanged(); + await flush(); + assert.equal(world.getState().buffering, true); + const stuck = radar.loads.at(-1); + tick(PLAY_WAIT_MS); + await flush(); + assert.equal(world.getState().buffering, false, 'play goes on without it'); + assert.equal(stuck.signal.aborted, true); + assert.equal(radar.loads.at(-1).selected, time(20), 'loading where play is'); +}); + +test('held at the next frame, a scrub back to the one loading lets play go', async (t) => { + const { clock, world, tick } = harness(t); + const radar = slowRadar([0, 10, 20]); + clock.register(radar); + world.seek(Date.parse(time(9))); + await flush(); + radar.loads.at(-1).done(); + await flush(); + world.play({ rate: 60, continuous: true }); + tick(2_000); + world.coverageChanged(); + await flush(); + tick(10_000); + world.coverageChanged(); + await flush(); + assert.equal(world.getState().buffering, true); + world.pause(); + world.seek(Date.parse(time(11))); + await flush(); + world.play({ rate: 60, continuous: true }); + assert.equal( + world.getState().buffering, + false, + 'no hold for the frame it is at', + ); + const at = world.now(); + tick(1_000); + assert.equal(world.now(), at + 60_000); +}); + +test('a frame in flight as play starts loads as it goes on; a refresh does not hold', async (t) => { + const { clock, world } = harness(t); + const radar = slowRadar([0, 10, 20]); + clock.register(radar); + world.seek(Date.parse(time(5))); + await flush(); + world.play({ continuous: true }); + assert.equal(world.getState().buffering, false); + radar.loads.at(-1).done(); + await flush(); + void clock.refresh(); + await flush(); + assert.equal(world.getState().buffering, false, 'the same frames, no hold'); + radar.loads.at(-1).done(); +}); + +test('weather clocks sharing a world gate it each on their own', async (t) => { + let mono = 0; + const world = createWorldClock({ + now: () => Date.parse(time(30)), + monotonicNow: () => mono, + setInterval: () => 0, + clearInterval() {}, + }); + const a = createWeatherClock({ world, now: () => Date.parse(time(30)) }); + const b = createWeatherClock({ world, now: () => Date.parse(time(30)) }); + t.after(() => { + a.destroy(); + b.destroy(); + }); + a.register(product('radar', [0, 10])); + b.register(product('clouds', [0, 15])); + world.seek(Date.parse(time(1))); + await flush(); + world.play({ rate: 3_600, continuous: true }); + world.coverageChanged(); + b.destroy(); + world.coverageChanged(); + mono += 1_000; + assert.equal( + world.now(), + Date.parse(time(10)), + 'the other clock’s gate stays', + ); +}); + +test('played through, listeners hear of frames as they change, and of play stopping', async (t) => { + const { clock, world, tick } = harness(t); + const radar = product('radar', [0, 10, 20]); + clock.register(radar); + world.seek(Date.parse(time(1))); + await flush(); + world.play({ rate: 60, continuous: true }); + await flush(); + const heard = []; + clock.subscribe((state) => heard.push(state)); + // Seconds of world time within one frame: nothing new to show. + for (let i = 0; i < 5; i++) { + tick(100); + world.coverageChanged(); + } + await flush(); + assert.equal(heard.length, 0); + world.pause(); + await flush(); + assert.equal(heard.at(-1)?.advancing, false, 'heard play stop'); +}); diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index e318e3087..fb1447827 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -52,7 +52,8 @@ const utc = (value) => : t('Unavailable'); /** A per-application observation layer, using the existing layer lifecycle and - * row controls. History is transient: shared links always open latest imagery. */ + * row controls. Its time is the world clock's, so a link made away from live + * opens its imagery at that time; one made live opens the latest. */ export function createWeatherLayer({ feed, clock, @@ -108,7 +109,8 @@ export function createWeatherLayer({ const notify = () => listener?.(); const shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time); const unsubscribeClock = clock?.subscribe(() => { - if (!clock.getState().playing || suspended()) rendering?.cancelPrefetch?.(); + if (!clock.getState().advancing || suspended()) + rendering?.cancelPrefetch?.(); notify(); }); const observationDelayed = () => @@ -210,7 +212,7 @@ export function createWeatherLayer({ if ( !enabled || suspended() || - !(clock ? clock.getState().playing : playing) + !(clock ? clock.getState().advancing : playing) ) return; const times = manifest?.times ?? []; diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index af18adcab..e908bce31 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -31,6 +31,7 @@ import { } from '../data/detectionPolicy.js'; import { clampScopeFeatherPct } from '../scopeMaskPolicy.js'; import { VIEW_STYLES } from '../view/index.js'; +import { isWorldTime } from '../time/worldClock.js'; import { RETIRED_STYLES } from '../visualStylePolicy.js'; import { GLOBAL_POST_DEFAULTS, @@ -379,6 +380,7 @@ export const SHARE_VIEW_FIELDS = freezeAll([ { number: 25, key: 'scopeFeatherPct', type: 'uint', default: 100 }, { number: 26, key: 'scopeTerminusPct', type: 'uint', default: null }, { number: 27, key: 'styleParams', type: 'bytes', default: BY_STYLE }, + { number: 28, key: 'worldTime', type: 'epoch', default: null }, ]); const FIELD_BY_KEY = new Map(SHARE_VIEW_FIELDS.map((f) => [f.key, f])); const fieldNumber = (key) => FIELD_BY_KEY.get(key).number; @@ -738,6 +740,9 @@ export function normalizeShareViewState(input = {}) { : null; state.panelState = normalizePanelState(source.panelState); state.sharedAtMs = epochMsOr(source.sharedAtMs); + state.worldTime = isWorldTime(source.worldTime) + ? Math.floor(source.worldTime / 1000) * 1000 + : null; let view = normalizeView(source.view); if (state.layerState) view = liftTrackedEntity(state.layerState, view); if (view.kind === 'thing') { @@ -954,8 +959,9 @@ export function encodeShareView(input) { writer.sintField(field.number, signedAngle(state[field.key])); break; case 'sharedAtMs': - if (state.sharedAtMs !== null) - writer.sintField(field.number, epochSeconds(state.sharedAtMs)); + case 'worldTime': + if (state[field.key] !== null) + writer.sintField(field.number, epochSeconds(state[field.key])); break; case 'flags': { let bits = 0; @@ -1353,6 +1359,12 @@ export function decodeShareView(code, { nowMs = Date.now() } = {}) { raw.sharedAtMs = createdAtS >= 1 && Number.isSafeInteger(ms) && ms <= nowMs ? ms : null; } + // The world time the link was made at, when it was away from live. It + // may lie ahead of the link's creation (a forecast) or of now. + if (get('worldTime')) { + const ms = (SHARE_VIEW_EPOCH_S + unz(get('worldTime').varint)) * 1000; + raw.worldTime = isWorldTime(ms) ? ms : null; + } if (get('layerState')) raw.layerState = decodeLayers( diff --git a/src/share/viewCodec.test.mjs b/src/share/viewCodec.test.mjs index 6cc6fd9b1..f9fd81d38 100644 --- a/src/share/viewCodec.test.mjs +++ b/src/share/viewCodec.test.mjs @@ -361,6 +361,7 @@ test('every registered field round-trips at once', () => { })), }, sharedAtMs: NOW_MS, + worldTime: NOW_MS - 6 * 3_600_000, }; // A thing view derives its camera (roll 0), so the camera fields are // checked on the same state as a place view. @@ -501,6 +502,7 @@ test('the field registry is well formed and permanent', () => { [25, 'scopeFeatherPct', 100], [26, 'scopeTerminusPct', null], [27, 'styleParams', BY_STYLE], + [28, 'worldTime', null], ], ); assert.deepEqual( @@ -1338,3 +1340,22 @@ test('a layer option filed under a mirror layer is ignored', () => { { scope: 'layer-option', code: `${military}.${layerTokenNumber('m')}` }, ]); }); + +test('the world time is written only away from live, and may lie ahead', () => { + const at = (worldTime) => + decodeShareView(encodeShareView({ sharedAtMs: NOW_MS, worldTime }), { + nowMs: NOW_MS, + }).state.worldTime; + assert.equal(at(null), null); + assert.equal(at(NOW_MS - 3_600_000), NOW_MS - 3_600_000); + // A forecast moment ahead of the link's creation is kept. + assert.equal(at(NOW_MS + 86_400_000), NOW_MS + 86_400_000); + // Before the 2026 epoch too: the field is zigzag. + assert.equal(at(Date.UTC(2024, 0, 1)), Date.UTC(2024, 0, 1)); + // Past what a Date can hold: dropped, so the link opens live. + assert.equal(at(8.8e15), null); + assert.ok( + encodeShareView({ sharedAtMs: NOW_MS, worldTime: NOW_MS - 1000 }).length > + encodeShareView({ sharedAtMs: NOW_MS }).length, + ); +}); diff --git a/src/sharelink.js b/src/sharelink.js index 4a0291a84..fcef04ea5 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -5,6 +5,7 @@ import { normalizeAllocationStrategy, } from './data/detectionPolicy.js'; import { clampScopeTerminusPct } from './scopeMask.js'; +import { worldClock as pageWorldClock } from './time/worldClock.js'; import { clampScopeFeatherPct } from './scopeMaskPolicy.js'; import { decodeLayerStateParams, @@ -68,9 +69,11 @@ export class ShareLinkManager { isNavigationCurrent, cancelOwnedNavigation, initialShareCode = null, + worldClock = pageWorldClock, } = {}, ) { this.viewer = viewer; + this._worldClock = worldClock; // A host may hand over the share code to open instead of the address. this._initialShareCode = typeof initialShareCode === 'string' ? initialShareCode : null; @@ -163,8 +166,9 @@ export class ShareLinkManager { /** * The restore state for a share code, in the long form's parsed shape plus - * `shareView` (what the view follows, with its offset and last-known fix), - * or null when the code is refused. + * `shareView` (what the view follows, with its offset and last-known fix) + * and `worldTime` (null when the link was made live), or null when the + * code is refused. */ _parseShareCode(code) { const result = decodeShareView(code); @@ -174,6 +178,7 @@ export class ShareLinkManager { } const state = toLegacyShareState(result.state); state.shareView = result.state.view; + state.worldTime = result.state.worldTime; return state; } @@ -592,6 +597,10 @@ export class ShareLinkManager { // that state in the share format. const state = fromLegacyShareState(this._parseShareHash(params.toString())); state.sharedAtMs = Math.floor(nowMs / 1000) * 1000; + // Away from live, the link opens at the same world time. + state.worldTime = this._worldClock.isLive() + ? null + : Math.floor(this._worldClock.now() / 1000) * 1000; let subject = null; try { subject = this._followSubjectProvider?.({ nowMs }) ?? null; diff --git a/src/sharelink.navigation.test.mjs b/src/sharelink.navigation.test.mjs index e4e70b629..e486ba58d 100644 --- a/src/sharelink.navigation.test.mjs +++ b/src/sharelink.navigation.test.mjs @@ -423,3 +423,28 @@ test('the layer coordinator replaces in-flight share layers with the navigated s ); coordinator.destroy(); }); + +test('a link made away from live carries its world time to the page that opens it', (t) => { + installTab(t, SHARE_A); + const worldTime = Date.UTC(2026, 9, 30, 6, 15, 42); + const sharer = new ShareLinkManager(fakeViewer(), { + worldClock: { isLive: () => false, now: () => worldTime + 400 }, + }); + const link = sharer.buildLink({ nowMs: Date.UTC(2026, 10, 1) }); + sharer.destroy(); + const code = new URL(link).pathname.split('/').at(-1); + const opener = new ShareLinkManager(fakeViewer(), { initialShareCode: code }); + assert.equal(opener.parseInitialHash().worldTime, worldTime); + opener.destroy(); + + const live = new ShareLinkManager(fakeViewer(), { + worldClock: { isLive: () => true, now: () => worldTime }, + }); + const liveCode = new URL(live.buildLink()).pathname.split('/').at(-1); + live.destroy(); + const liveOpener = new ShareLinkManager(fakeViewer(), { + initialShareCode: liveCode, + }); + assert.equal(liveOpener.parseInitialHash().worldTime, null); + liveOpener.destroy(); +}); diff --git a/src/time/julian.js b/src/time/julian.js new file mode 100644 index 000000000..b63a53621 --- /dev/null +++ b/src/time/julian.js @@ -0,0 +1,13 @@ +import * as Cesium from 'cesium'; + +const scratch = new Date(); + +/** + * Epoch ms as a Cesium JulianDate, like `JulianDate.now(result)` for any time. + * @param {number} ms + * @param {Cesium.JulianDate} [result] + */ +export function julianAt(ms, result) { + scratch.setTime(ms); + return Cesium.JulianDate.fromDate(scratch, result); +} diff --git a/src/time/viewerClock.js b/src/time/viewerClock.js new file mode 100644 index 000000000..e4c456d42 --- /dev/null +++ b/src/time/viewerClock.js @@ -0,0 +1,46 @@ +import * as Cesium from 'cesium'; + +/** + * Draw Cesium's sky at the world time: the sun, moon, stars, atmosphere and + * the lighting on the tiles all read `frameState.time`, which Cesium takes + * from its own clock. That clock never animates in GEV, so before this the + * sky stayed at the moment the page loaded. + * + * Each frame's time is set in `preUpdate`, before the scene stamps the frame + * with it, so the sky is drawn at the same moment the layers read that frame. + * The clock's `currentTime` follows too, for anything that samples it. + * @param {Cesium.Viewer} viewer + * @param {() => number} readTime Epoch ms the sky is drawn at. + * @param {{subscribe?: Function, requestRender?: () => void}} [options] + * A world clock to hear moves from, so a resting scene repaints when the + * world time jumps. + * @returns {() => void} Stop following. + */ +export function followWorldTime( + viewer, + readTime, + { subscribe, requestRender = () => viewer.scene.requestRender() } = {}, +) { + const date = new Date(); + const removePreUpdate = viewer.scene.preUpdate.addEventListener( + (_scene, time) => { + date.setTime(readTime()); + const clock = viewer.clock; + Cesium.JulianDate.fromDate(date, clock.currentTime); + // The frame's own time: Cesium stamps the frame with this object next. + if (time) Cesium.JulianDate.clone(clock.currentTime, time); + }, + ); + let last = readTime(); + const unsubscribe = + subscribe?.(() => { + const next = readTime(); + if (next === last) return; + last = next; + requestRender(); + }) ?? (() => {}); + return () => { + removePreUpdate(); + unsubscribe(); + }; +} diff --git a/src/time/worldClock.js b/src/time/worldClock.js new file mode 100644 index 000000000..edfe4c283 --- /dev/null +++ b/src/time/worldClock.js @@ -0,0 +1,419 @@ +/** + * The world's one time, in epoch ms. Every layer that draws something at a + * moment reads it here instead of calling `Date.now()` itself, so layers can + * never disagree about when the world is. + * + * Live is a mode, not a timestamp: while live the clock is the wall clock and + * every layer behaves as it would reading `Date.now()`. Away from live the + * clock holds a cursor, fixed or playing. Only the layers that follow the clock + * move with the cursor; the rest keep reading the live edge through + * `timeFor(id)`. On the page's clock the layers whose data has a time of its + * own (the sky, satellites, launches and weather) follow it; live feeds such + * as flights and vessels have no time to move to and stay live. + * + * Playback is continuous (`cursor + elapsed × rate`) unless a driver takes + * it: weather replay steps its observed frames itself and moves the clock as + * it goes, so play and pause hand over to it. Several drivers may be attached; + * play goes to the newest that can play, pause to the one playing. + */ + +/** The span a JS Date can hold, either side of 1970. */ +const MAX_DATE_MS = 8.64e15; + +/** Whether `ms` is a time the world clock can hold. */ +export const isWorldTime = (ms) => + Number.isFinite(ms) && Math.abs(ms) <= MAX_DATE_MS; + +/** + * How often subscribers hear of a continuously playing cursor with no + * animation frames to go by; with them, they hear of it every frame. + */ +export const WORLD_CLOCK_TICK_MS = 250; + +/** + * @param {{now?: () => number, monotonicNow?: () => number, + * setInterval?: typeof globalThis.setInterval, + * clearInterval?: typeof globalThis.clearInterval, + * requestFrame?: typeof globalThis.requestAnimationFrame | null, + * cancelFrame?: typeof globalThis.cancelAnimationFrame | null, + * follow?: Iterable}} [options] `follow` lists the layers that + * move with the cursor from the start. Playing, the clock tells its + * listeners of the time each animation frame, or each tick without frames. + */ +export function createWorldClock({ + // Read at each call, so a stand-in for Date.now (a test's, a harness's) + // reaches every layer as it did when they called Date.now themselves. + now = () => Date.now(), + monotonicNow = () => globalThis.performance.now(), + setInterval = globalThis.setInterval, + clearInterval = globalThis.clearInterval, + requestFrame = globalThis.requestAnimationFrame?.bind(globalThis) ?? null, + cancelFrame = globalThis.cancelAnimationFrame?.bind(globalThis) ?? null, + follow = [], +} = {}) { + const listeners = new Set(); + // Layers a host follows, and layers a part of the app holds following. + const following = new Set(follow); + const holds = new Map(); + const readers = new Map(); + const sources = new Set(); + const drivers = []; + let live = true; + // The cursor while fixed; while playing continuously, the cursor at `anchorMono`. + let cursorMs = null; + let anchorMono = 0; + let rate = 1; + let playing = false; + // The driver handle stepping the clock, or null. + let active = null; + let ticker = null; + let notifying = 0; + // Layers loading what the cursor needs; continuous play holds while any is. + const waits = new Map(); + // Times continuous play may not pass yet, by layer: each one's next frame. + const gates = new Map(); + + const continuous = () => playing && !active; + const buffering = () => waits.size > 0; + const isFollowed = (id) => following.has(id) || (holds.get(id) ?? 0) > 0; + const followedIds = () => [ + ...new Set([...following, ...[...holds.keys()].filter(isFollowed)]), + ]; + // A host's coverage that throws counts as none, so it cannot break the + // listeners every state reaches. + const spanOf = (source) => { + let span; + try { + span = source.coverage(); + } catch (error) { + console.error(`World time coverage failed: ${source.id}`, error); + return null; + } + return span && span.end >= span.start ? span : null; + }; + // Whether a layer that moves with the clock has time to show: only then is + // there anything for a timeline to play. + const isSupported = () => { + for (const { source } of sources) + if (isFollowed(source.id) && spanOf(source)) return true; + return false; + }; + const time = () => { + if (live) return now(); + if (!continuous() || buffering()) return cursorMs; + let at = cursorMs + (monotonicNow() - anchorMono) * rate; + for (const gate of gates.values()) + if (gate >= cursorMs) at = Math.min(at, gate); + return at; + }; + const getState = () => + Object.freeze({ + live, + timeMs: time(), + playing, + rate, + // A driver steps the clock, at its own pace rather than the rate. + stepping: Boolean(active), + // Playing, but held while a layer loads what the cursor needs. + buffering: continuous() && buffering(), + following: followedIds(), + supported: isSupported(), + }); + // Each listener hears the state as it is when called. A change made from + // inside a listener notifies everyone afresh, and the outer round stops, so + // nobody is left holding a state that has since changed. + // A round's state is read once: any change made during it starts a new one. + const notify = () => { + const round = ++notifying; + let state = null; + for (const listener of [...listeners]) { + if (round !== notifying) return; + if (!listeners.has(listener)) continue; + try { + listener((state ??= getState())); + } catch (error) { + console.error('World clock listener failed', error); + } + } + }; + // Playing, the time moves on smoothly: each frame, so the timeline and the + // globe move with it and a layer's next frame is met as it comes, or each + // tick where there are no frames. + const frames = Boolean(requestFrame && cancelFrame); + const frame = () => { + ticker = requestFrame(frame); + notify(); + }; + const syncTicker = () => { + if (continuous() && listeners.size) { + if (ticker === null) + ticker = frames + ? requestFrame(frame) + : setInterval(notify, WORLD_CLOCK_TICK_MS); + } else if (ticker !== null) { + if (frames) cancelFrame(ticker); + else clearInterval(ticker); + ticker = null; + } + }; + const changed = () => { + syncTicker(); + notify(); + }; + // Freeze a continuously playing cursor where it is. + const settle = () => { + if (continuous()) cursorMs = time(); + anchorMono = monotonicNow(); + }; + + const api = { + /** The live edge: the wall clock, whatever the cursor does. */ + wallNow: () => now(), + /** The world time: the wall clock while live, else the cursor. */ + now: time, + isLive: () => live, + isPlaying: () => playing, + getRate: () => rate, + + /** Hold the world at `ms`; a playing clock plays on from there. */ + seek(ms) { + if (!isWorldTime(ms)) throw new TypeError('World time must be epoch ms'); + if (!live && cursorMs === ms && !continuous()) return; + live = false; + cursorMs = ms; + anchorMono = monotonicNow(); + changed(); + }, + /** Back to the wall clock; playback stops and its rate goes back to 1. */ + goLive() { + if (live && !playing && rate === 1) return; + const stepping = active; + live = true; + cursorMs = null; + playing = false; + active = null; + rate = 1; + // Its driven(false) finds nothing left to stop. + stepping?.driver.pause(); + changed(); + }, + /** + * Play from the cursor (from now, when live). A driver steps it unless + * `continuous`, which runs the cursor at the rate past every driver. + */ + play({ rate: nextRate, continuous: runOn = false } = {}) { + if (nextRate !== undefined) api.setRate(nextRate); + if (playing) return; + for (let index = drivers.length - 1; !runOn && index >= 0; index -= 1) { + if (drivers[index].driver.play() !== false) return; + } + if (live) { + live = false; + cursorMs = now(); + } + playing = true; + anchorMono = monotonicNow(); + changed(); + }, + pause() { + if (!playing) return; + if (active) { + active.driver.pause(); + return; + } + settle(); + playing = false; + changed(); + }, + togglePlay() { + return playing ? api.pause() : api.play(); + }, + /** + * Hold continuous play where it is while layer `id` loads what the cursor + * needs, as a video waits on its buffer; play goes on once every hold is + * released. A seek still moves the cursor. Returns the release. Neither + * tells listeners (play's own ticks do), so a layer can take and release a + * hold mid-change without anyone moving the clock under it. + */ + wait(id) { + settle(); + waits.set(id, (waits.get(id) ?? 0) + 1); + let held = true; + return () => { + if (!held) return; + held = false; + const count = waits.get(id) - 1; + if (count > 0) { + waits.set(id, count); + return; + } + waits.delete(id); + // Play goes on from where it held, not from where it would have been. + if (!buffering()) anchorMono = monotonicNow(); + }; + }, + /** + * Keep continuous play from passing `ms` (null lifts it): the time layer + * `id` shows next, which it has yet to load. Play stops there, to wait on + * the load as it starts, rather than rush past frames between ticks. + */ + gate(id, ms) { + settle(); + if (ms === null || ms === undefined) gates.delete(id); + else if (isWorldTime(ms)) gates.set(id, ms); + }, + /** Simulated ms per real ms for continuous playback. */ + setRate(nextRate) { + if (!Number.isFinite(nextRate) || nextRate <= 0) + throw new RangeError('World clock rate must be positive'); + if (nextRate === rate) return; + settle(); + rate = nextRate; + changed(); + }, + + /** + * Offer a stepping player. The driver moves the clock with `seek()` and + * reports through its handle's `driven(playing, atMs?)`; its `play()` + * returns false when it cannot play, and the clock tries an older driver + * or plays continuously instead. + * @param {{play: () => (boolean|void), pause: () => void}} driver + * @returns {{driven: (playing: boolean, atMs?: number) => void, + * detach: () => number}} `detach()` returns how many drivers remain. + */ + attachDriver(driver) { + const handle = { + driver, + /** This driver started stepping (at `atMs`, if given) or stopped. */ + driven(isPlaying, atMs) { + if (!drivers.includes(handle)) return; + if (!isPlaying) { + if (active !== handle) return; + active = null; + playing = false; + changed(); + return; + } + // A continuous run hands over where it is; another driver stops. + settle(); + const previous = active; + active = handle; + playing = true; + live = false; + if (Number.isFinite(atMs)) cursorMs = atMs; + else cursorMs ??= now(); + anchorMono = monotonicNow(); + if (previous && previous !== handle) previous.driver.pause(); + changed(); + }, + detach() { + const index = drivers.indexOf(handle); + if (index < 0) return drivers.length; + handle.driven(false); + drivers.splice(index, 1); + return drivers.length; + }, + }; + drivers.push(handle); + return handle; + }, + + /** Let layer `id` move with the cursor instead of reading the live edge. */ + follow(id, on = true) { + if (on === following.has(id)) return; + if (on) following.add(id); + else following.delete(id); + changed(); + }, + /** + * Keep layer `id` following until the returned release is called, however + * many parts of the app hold it. + */ + hold(id) { + const was = isFollowed(id); + holds.set(id, (holds.get(id) ?? 0) + 1); + if (!was) changed(); + let held = true; + return () => { + if (!held) return; + held = false; + const count = holds.get(id) - 1; + if (count > 0) holds.set(id, count); + else holds.delete(id); + if (!isFollowed(id)) changed(); + }; + }, + follows: isFollowed, + /** + * The time layer `id` draws at: the cursor when it follows the clock, + * otherwise the live edge. + * @returns {() => number} + */ + timeFor(id) { + let reader = readers.get(id); + if (!reader) { + reader = () => (isFollowed(id) ? time() : now()); + readers.set(id, reader); + } + return reader; + }, + + /** + * A layer's span of time it can show, for a host's timeline; null while + * it has none (off, or nothing loaded). Returns the unregister. + * @param {{id: string, coverage: () => ({start: number, end: number, marks?: number[]} | null)}} source + */ + register(source) { + // Each registration is its own, even under a shared id. + const entry = { source }; + sources.add(entry); + changed(); + return () => { + if (sources.delete(entry)) changed(); + }; + }, + /** A source's coverage changed (frames loaded, the layer paused). */ + coverageChanged: () => changed(), + /** Whether a followed layer has time to show; see getState().supported. */ + isSupported, + /** The union of the registered sources' coverage, or null with none. */ + coverage() { + let start = Infinity; + let end = -Infinity; + const marks = new Set(); + for (const { source } of sources) { + const span = spanOf(source); + if (!span) continue; + start = Math.min(start, span.start); + end = Math.max(end, span.end); + for (const mark of span.marks ?? []) marks.add(mark); + } + if (start > end) return null; + return { start, end, marks: [...marks].sort((a, b) => a - b) }; + }, + + getState, + subscribe(listener) { + listeners.add(listener); + syncTicker(); + return () => { + listeners.delete(listener); + syncTicker(); + }; + }, + }; + return api; +} + +/** + * The page's world clock. Layers, the sky and a host all read this one. The + * sky and the HUD's clock show the world time; satellites (and the launched + * missions flying on their time) and launches have a time of their own and + * move with it; weather holds each product it shows. + */ +export const worldClock = createWorldClock({ + follow: ['sky', 'hud', 'satellites', 'rocket-launches'], +}); + +/** The time layer `id` draws at on the page's clock. */ +export const worldTimeFor = (id) => worldClock.timeFor(id); diff --git a/src/time/worldClock.test.mjs b/src/time/worldClock.test.mjs new file mode 100644 index 000000000..f81fa85a9 --- /dev/null +++ b/src/time/worldClock.test.mjs @@ -0,0 +1,389 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createWorldClock, WORLD_CLOCK_TICK_MS } from './worldClock.js'; + +function fakeTime() { + const t = { + wall: Date.UTC(2026, 10, 1, 12), + mono: 1_000, + timers: new Map(), + nextId: 1, + advance(ms) { + t.wall += ms; + t.mono += ms; + }, + options: { + now: () => t.wall, + monotonicNow: () => t.mono, + setInterval: (fn, ms) => { + const id = t.nextId++; + t.timers.set(id, { fn, ms }); + return id; + }, + clearInterval: (id) => t.timers.delete(id), + }, + }; + return t; +} + +test('live is the wall clock for every layer', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + assert.equal(clock.isLive(), true); + assert.equal(clock.now(), t.wall); + t.advance(5_000); + assert.equal(clock.now(), t.wall); + assert.equal(clock.timeFor('flights')(), t.wall); + clock.follow('flights'); + assert.equal(clock.timeFor('flights')(), t.wall); +}); + +test('away from live only following layers move with the cursor', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const satellites = clock.timeFor('satellites'); + const flights = clock.timeFor('flights'); + clock.follow('satellites'); + const past = t.wall - 3_600_000; + clock.seek(past); + assert.equal(clock.isLive(), false); + assert.equal(clock.now(), past); + assert.equal(satellites(), past); + assert.equal(flights(), t.wall); + assert.equal(clock.timeFor('satellites'), satellites); + clock.goLive(); + assert.equal(satellites(), t.wall); +}); + +test('layers named at creation follow from the start', () => { + const t = fakeTime(); + const clock = createWorldClock({ ...t.options, follow: ['satellites'] }); + clock.seek(t.wall - 1_000); + assert.equal(clock.timeFor('satellites')(), t.wall - 1_000); + assert.equal(clock.timeFor('flights')(), t.wall); + clock.follow('satellites', false); + assert.equal(clock.follows('satellites'), false); +}); + +test('the page clock moves the layers with a time of their own', async () => { + const { worldClock } = await import('./worldClock.js'); + for (const id of ['sky', 'hud', 'satellites', 'rocket-launches']) + assert.equal(worldClock.follows(id), true, id); + for (const id of ['flights', 'military', 'vessels', 'transit']) + assert.equal(worldClock.follows(id), false, id); +}); + +test('continuous play advances the cursor at the rate and pause holds it', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const start = t.wall - 60_000; + clock.seek(start); + clock.play({ rate: 60 }); + t.advance(1_000); + assert.equal(clock.now(), start + 60_000); + clock.setRate(120); + t.advance(1_000); + assert.equal(clock.now(), start + 180_000); + clock.pause(); + t.advance(1_000); + assert.equal(clock.now(), start + 180_000); + assert.equal(clock.isPlaying(), false); +}); + +test('play from live starts at now, and goLive returns to the wall clock', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const from = t.wall; + clock.play({ rate: 2 }); + assert.equal(clock.isLive(), false); + t.advance(1_000); + assert.equal(clock.now(), from + 2_000); + clock.goLive(); + assert.equal(clock.isPlaying(), false); + assert.equal(clock.now(), t.wall); +}); + +test('subscribers hear changes, and ticks only while playing continuously', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const states = []; + const off = clock.subscribe((state) => states.push(state)); + clock.seek(t.wall - 1_000); + assert.equal(states.length, 1); + assert.equal(states[0].live, false); + assert.equal(t.timers.size, 0); + clock.play(); + assert.equal(t.timers.size, 1); + assert.equal([...t.timers.values()][0].ms, WORLD_CLOCK_TICK_MS); + clock.pause(); + assert.equal(t.timers.size, 0); + clock.play(); + off(); + assert.equal(t.timers.size, 0); +}); + +test('a driver takes play and pause, and moves the clock with seek', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const calls = []; + const handle = clock.attachDriver({ + play() { + calls.push('play'); + handle.driven(true, t.wall - 10_000); + }, + pause() { + calls.push('pause'); + handle.driven(false); + }, + }); + clock.play(); + assert.deepEqual(calls, ['play']); + assert.equal(clock.isPlaying(), true); + assert.equal(clock.now(), t.wall - 10_000); + const at = clock.now(); + t.advance(5_000); + // Stepped, not continuous: the cursor waits for the driver. + assert.equal(clock.now(), at); + clock.pause(); + assert.deepEqual(calls, ['play', 'pause']); + assert.equal(clock.isPlaying(), false); + clock.play(); + clock.goLive(); + assert.deepEqual(calls, ['play', 'pause', 'play', 'pause']); + assert.equal(clock.isLive(), true); + assert.equal(clock.isPlaying(), false); + assert.equal(handle.detach(), 0); + clock.play(); + assert.equal(calls.length, 4); + t.advance(1_000); + assert.equal(clock.now(), t.wall); +}); + +test('play goes to the newest driver that can play, pause to the one playing', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const calls = []; + const driver = (name, canPlay) => { + const handle = clock.attachDriver({ + play() { + if (!canPlay()) return false; + calls.push(`${name} play`); + handle.driven(true); + }, + pause() { + calls.push(`${name} pause`); + handle.driven(false); + }, + }); + return handle; + }; + let bCanPlay = false; + const a = driver('a', () => true); + const b = driver('b', () => bCanPlay); + clock.seek(t.wall - 1_000); + clock.play(); + assert.deepEqual(calls, ['a play']); + // b starting takes over from a. + bCanPlay = true; + b.driven(true); + assert.deepEqual(calls, ['a play', 'a pause']); + clock.pause(); + assert.deepEqual(calls, ['a play', 'a pause', 'b pause']); + assert.equal(clock.isPlaying(), false); + // A detached driver stops, and the other one plays next. + clock.play(); + assert.equal(b.detach(), 1); + assert.equal(clock.isPlaying(), false); + clock.play(); + assert.equal(calls.at(-1), 'a play'); + assert.equal(a.detach(), 0); + assert.equal(clock.isPlaying(), false); +}); + +test('a listener that changes the clock leaves nobody with a stale state', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const seen = []; + clock.subscribe((state) => { + if (state.timeMs === 0) clock.seek(1_000); + }); + clock.subscribe((state) => seen.push(state.timeMs)); + clock.seek(0); + assert.deepEqual(seen, [1_000]); +}); + +test('holds keep a layer following until every holder releases it', () => { + const clock = createWorldClock(fakeTime().options); + const first = clock.hold('radar'); + const second = clock.hold('radar'); + first(); + first(); + assert.equal(clock.follows('radar'), true); + second(); + assert.equal(clock.follows('radar'), false); + clock.follow('radar'); + const third = clock.hold('radar'); + third(); + assert.equal(clock.follows('radar'), true); + assert.deepEqual(clock.getState().following, ['radar']); +}); + +test('a driver that cannot play leaves continuous playback', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + clock.attachDriver({ play: () => false, pause() {} }); + clock.seek(t.wall - 10_000); + clock.play(); + assert.equal(clock.isPlaying(), true); + t.advance(1_000); + // Wall and cursor moved the same second at rate 1. + assert.equal(clock.now(), t.wall - 10_000); +}); + +test('coverage is the union of the registered sources', () => { + const clock = createWorldClock(fakeTime().options); + assert.equal(clock.coverage(), null); + const off = clock.register({ + id: 'radar', + coverage: () => ({ start: 100, end: 200, marks: [100, 150, 200] }), + }); + clock.register({ id: 'satellites', coverage: () => ({ start: 50, end: 180 }) }); + clock.register({ id: 'empty', coverage: () => null }); + assert.deepEqual(clock.coverage(), { + start: 50, + end: 200, + marks: [100, 150, 200], + }); + off(); + assert.deepEqual(clock.coverage(), { start: 50, end: 180, marks: [] }); +}); + +test('seek refuses anything but epoch ms and rate must be positive', () => { + const clock = createWorldClock(fakeTime().options); + assert.throws(() => clock.seek(Number.NaN), TypeError); + assert.throws(() => clock.setRate(0), RangeError); +}); + +test('seek refuses a time no Date can hold', () => { + const clock = createWorldClock(fakeTime().options); + assert.throws(() => clock.seek(8.8e15), TypeError); + clock.seek(-8.64e15); + assert.equal(clock.now(), -8.64e15); +}); + +test('only a followed layer with time to show supports the timeline', () => { + const clock = createWorldClock(fakeTime().options); + let span = null; + const states = []; + clock.subscribe((state) => states.push(state.supported)); + const off = clock.register({ id: 'satellites', coverage: () => span }); + span = { start: 0, end: 10 }; + clock.coverageChanged(); + assert.equal(clock.isSupported(), false, 'covered but not followed'); + clock.follow('satellites'); + assert.equal(clock.isSupported(), true); + assert.equal(states.at(-1), true); + span = null; + clock.coverageChanged(); + assert.equal(states.at(-1), false); + span = { start: 0, end: 10 }; + off(); + assert.equal(clock.isSupported(), false); + assert.equal(states.at(-1), false); +}); + +test('a coverage that throws counts as none', (t) => { + t.mock.method(console, 'error', () => {}); + const clock = createWorldClock(fakeTime().options); + clock.follow('broken'); + clock.register({ + id: 'broken', + coverage() { + throw new Error('no'); + }, + }); + assert.equal(clock.getState().supported, false); + assert.equal(clock.coverage(), null); +}); + +test('continuous play runs past every driver, and live resets the rate', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const calls = []; + clock.attachDriver({ play: () => calls.push('play'), pause() {} }); + clock.seek(t.wall - 60_000); + clock.play({ rate: 30, continuous: true }); + assert.deepEqual(calls, []); + t.advance(1_000); + assert.equal(clock.now(), t.wall - 61_000 + 30_000); + clock.goLive(); + assert.equal(clock.getRate(), 1); +}); + +test('continuous play holds while a layer waits, and goes on from there', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const start = t.wall - 600_000; + clock.seek(start); + clock.play({ rate: 60, continuous: true }); + t.advance(1_000); + assert.equal(clock.now(), start + 60_000); + const release = clock.wait('radar'); + const other = clock.wait('clouds'); + assert.equal(clock.getState().buffering, true); + t.advance(5_000); + assert.equal(clock.now(), start + 60_000, 'held while loading'); + release(); + release(); + t.advance(1_000); + assert.equal(clock.now(), start + 60_000, 'held until every layer is ready'); + other(); + assert.equal(clock.getState().buffering, false); + t.advance(1_000); + assert.equal(clock.now(), start + 120_000, 'on from where it held'); +}); + +test('a seek still moves a waiting clock', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + clock.seek(t.wall - 600_000); + clock.play({ continuous: true }); + const release = clock.wait('radar'); + clock.seek(t.wall - 60_000); + assert.equal(clock.now(), t.wall - 60_000); + release(); +}); + +test('playing, listeners hear of the time every animation frame', () => { + const t = fakeTime(); + const frames = new Map(); + let nextFrame = 1; + const clock = createWorldClock({ + ...t.options, + requestFrame: (fn) => { + frames.set(nextFrame, fn); + return nextFrame++; + }, + cancelFrame: (id) => frames.delete(id), + }); + const heard = []; + clock.subscribe((state) => heard.push(state.timeMs)); + const start = t.wall - 60_000; + clock.seek(start); + clock.play({ rate: 60 }); + assert.equal(t.timers.size, 0, 'no ticks with frames to go by'); + assert.equal(frames.size, 1); + const runFrame = () => { + const [[id, fn]] = frames; + frames.delete(id); + fn(); + }; + heard.length = 0; + for (let i = 0; i < 3; i++) { + t.advance(16); + runFrame(); + } + assert.deepEqual(heard, [start + 960, start + 1_920, start + 2_880]); + clock.pause(); + assert.equal(frames.size, 0); +}); diff --git a/src/ui/cockpitCoordinator.js b/src/ui/cockpitCoordinator.js index 686e5f16a..60a3259f9 100644 --- a/src/ui/cockpitCoordinator.js +++ b/src/ui/cockpitCoordinator.js @@ -189,6 +189,12 @@ export class CockpitCoordinator { // vessel selection before the entry can run, leaving neither. if (this.cockpitView?.active && this.cockpitView.subjectKind === 'vessel') return 'Cockpit is on a vessel bridge — exit the bridge first, then track an aircraft to enter Cockpit'; + // Likewise in orbit: the aircraft's tracking takes the satellite's. + if ( + this.cockpitView?.active && + this.cockpitView.subjectKind === 'satellite' + ) + return 'Cockpit is in an orbital view — exit it first, then track an aircraft to enter Cockpit'; if (!this._aircraftLayerEnabled) return 'No aircraft layer is on — turn on Flights or Military Flights and track an aircraft to enter Cockpit'; return null; diff --git a/src/ui/cockpitEntryGate.test.mjs b/src/ui/cockpitEntryGate.test.mjs index ec144128b..fd5dfb52b 100644 --- a/src/ui/cockpitEntryGate.test.mjs +++ b/src/ui/cockpitEntryGate.test.mjs @@ -246,6 +246,26 @@ test('voice entry on a vessel bridge refuses before it touches tracking', (t) => assert.equal(cockpit.subjectKind, 'vessel'); }); +test('voice entry in an orbital view refuses before it touches tracking', (t) => { + const { coordinator, cockpit } = fixture(t, { enabled: ['flights'] }); + let navigated = false; + coordinator.services.militaryAwarenessLayer.navigateNext = () => { + navigated = true; + return true; + }; + cockpit.active = true; + cockpit.subjectKind = 'satellite'; + t.after(() => { + cockpit.active = false; + cockpit.subjectKind = null; + }); + const voice = coordinator.controlCockpit('enter', { targetLayer: 'flights' }); + assert.equal(voice.ok, false); + assert.match(voice.error, /orbital view/); + assert.equal(navigated, false, 'no aircraft was tracked'); + assert.equal(cockpit.subjectKind, 'satellite'); +}); + test('with an aircraft layer on but nothing tracked, the reason is the missing aircraft', (t) => { const { coordinator } = fixture(t, { enabled: ['military'] }); assert.equal( diff --git a/src/ui/cockpitInput.js b/src/ui/cockpitInput.js index b8df16357..c5987e97a 100644 --- a/src/ui/cockpitInput.js +++ b/src/ui/cockpitInput.js @@ -89,7 +89,7 @@ export function onScenePointerDown(nowMs = performance.now()) { /** * Enter first person when the followed track itself is double-clicked: the - * same entry, and the same gates, as C and the entry buttons. The click may + * same entry, and the same gates, as the entry buttons. The click may * land where the track is drawn now or where it was as the gesture began. A * double-click anywhere else, or one a tool owns (drawing ends a shape with * one), is left alone. diff --git a/src/ui/cockpitOrbitalView.js b/src/ui/cockpitOrbitalView.js index 4131c5f68..a536f8cb8 100644 --- a/src/ui/cockpitOrbitalView.js +++ b/src/ui/cockpitOrbitalView.js @@ -19,6 +19,7 @@ import { nextOrbitalZoom, offNadirAngleDeg, offNadirOrientation, + offNadirCentreMeetsEarth, offNadirSwathScale, orbitalBaseFovRad, orbitalMaxZoom, @@ -31,6 +32,7 @@ import { zoomTransitionState, } from '../cockpitOrbital.js'; import { + clampOrbitalLook, easeLook, lookIsOffset, orbitalLookReadoutParts, @@ -568,9 +570,25 @@ export function publishOrbitalView(info, transition = IDLE_TRANSITION) { const maxZoom = orbitalMaxZoomFor.call(this, info); const swathKm = sensorSwathKm(info); const trackUp = Number.isFinite(info.groundTrackDeg); - const swathScale = offset - ? offNadirSwathScale(lookTilt(orbitalLookReadoutParts(state.lookTarget))) - : 1; + const tilt = offset + ? lookTilt(orbitalLookReadoutParts(state.lookTarget)) + : null; + const swathScale = offset ? offNadirSwathScale(tilt) : 1; + // Tilted past the limb, the view's centre looks into space: no swath. The + // camera eases toward the target, so both must meet the Earth; the HUD + // republishes on its cadence, so the swath appears once the view arrives. + // Exact angles here, not the whole degrees the readout rounds to. + const target = clampOrbitalLook(state.lookTarget); + const seesEarth = + !offset || + (offNadirCentreMeetsEarth(info.altitudeM, { + northDeg: target.pitchDeg, + eastDeg: target.yawDeg, + }) && + offNadirCentreMeetsEarth(info.altitudeM, { + northDeg: state.lookView?.pitchDeg ?? 0, + eastDeg: state.lookView?.yawDeg ?? 0, + })); this.publishOrbital({ name: info.name, classLabel: info.classLabel || '', @@ -603,7 +621,7 @@ export function publishOrbitalView(info, transition = IDLE_TRANSITION) { // the nadir footprint (`offNadirSwathScale`); 1 and null at nadir. swathScale, swathKm: - offset && Number.isFinite(footprintKm) + offset && seesEarth && Number.isFinite(footprintKm) ? footprintKm * swathScale : null, }, diff --git a/src/ui/cockpitOrbitalView.test.mjs b/src/ui/cockpitOrbitalView.test.mjs index f1f5859d9..2b236c659 100644 --- a/src/ui/cockpitOrbitalView.test.mjs +++ b/src/ui/cockpitOrbitalView.test.mjs @@ -35,7 +35,7 @@ const ISS = Object.freeze({ id: '25544', noradId: 25544, name: 'ISS (ZARYA)', - classKey: 'station', + satelliteClass: 'stations', classLabel: 'STATION · ISS', latitude: 30, longitude: -97, @@ -826,10 +826,6 @@ test('a map already leased elsewhere does not keep orbit out', async (t) => { assert.equal(store.orbital, null); }); -/** - * A double-click `offsetPx` from where the followed satellite is drawn; the - * scene projects the satellite to (400, 300) on a canvas at (10, 20). - */ /** * A double-click `offsetPx` from where the followed satellite is drawn, at * (400, 300) on the canvas, through Cesium's double-click handler. diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index 75746b6e3..4fecad9e7 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -434,12 +434,12 @@ test('Cyber lower-left telemetry tracks the credit anchor with clearance for its cyberStyles, /#intel-hud\[data-variant='cyber'\] \.hud-bottom-left/, ); - const offset = telemetry.match(/bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/); + const offset = telemetry.match(/bottom:\s*calc\(2vh \+ ([\d.]+)rem \+ var\(--world-timeline-lift, 0px\)\);/); assert.ok(offset, 'telemetry must retain the attribution viewport anchor'); const credits = read('./styles/command-dock-trays.css'); const creditOffsets = [ ...credits.matchAll( - /#cesium-credits\s*\{[^}]*bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/g, + /#cesium-credits\s*\{[^}]*bottom:\s*calc\(2vh \+ ([\d.]+)rem \+ var\(--world-timeline-lift, 0px\)\);/g, ), ]; assert.ok(creditOffsets.length > 0); @@ -738,7 +738,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', () ); assert.match( cyberStyles, - /#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;[\s\S]*?bottom: calc\(2vh \+ 13rem\);/, + /#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;[\s\S]*?bottom: calc\(2vh \+ 13rem \+ var\(--world-timeline-lift, 0px\)\);/, ); assert.match( cyberStyles, @@ -793,7 +793,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', () cyberStyles, /#right-context-rail:has\(> \[data-panel-id\]:not\(\.collapsed, \[hidden\]\)\) \{\s*border-inline-end-color:/, ); - assert.match(cyberStyles, /bottom: calc\(2vh \+ 13rem\);/); + assert.match(cyberStyles, /bottom: calc\(2vh \+ 13rem \+ var\(--world-timeline-lift, 0px\)\);/); assert.doesNotMatch(cyberStyles, /NAV 01 \/\/ COMMAND BUS \/\/ VIS 03/); assert.match(cyberStyles, /data-layer-id='satellites'/); assert.match(cyberStyles, /#search-toggle::before/); diff --git a/src/ui/mobileShell.test.mjs b/src/ui/mobileShell.test.mjs index a391a6ba7..87286240f 100644 --- a/src/ui/mobileShell.test.mjs +++ b/src/ui/mobileShell.test.mjs @@ -586,7 +586,7 @@ test('the phone shell keeps the required attribution visible', () => { }; const lift = (block) => Number( - /bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px\)/.exec(block)?.[1], + /bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px \+ var\(--world-timeline-lift, 0px\)\)/.exec(block)?.[1], ); const z = (block) => Number(/z-index: (\d+);/.exec(block)?.[1]); const idleCredit = rule( diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js index dddedf575..ee0d5541b 100644 --- a/src/ui/panelLayoutController.js +++ b/src/ui/panelLayoutController.js @@ -32,6 +32,7 @@ const LEFT_STACK_OBSTACLE_SELECTOR = [ '#gev-voice-control', '#pp-toggles', '#param-slider-panel', + '#world-timeline', ].join(', '); /** * Fixed UI regions that can occupy the right control lane. Runtime rectangle @@ -59,6 +60,7 @@ const RIGHT_STACK_OBSTACLE_SELECTOR = [ '#cesium-credits .cesium-credit-textContainer', '#command-dock', '#gev-voice-control', + '#world-timeline', ].join(', '); export class PanelLayoutController { constructor({ diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 12f09c18b..9fc4c6f45 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -4,6 +4,16 @@ import { canPresentDeferredStatusNotice } from '../loadingFeedback.js'; import { t, msg, formatDate } from '../i18n/index.js'; import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js'; import { UiLifetime } from './uiLifetime.js'; +import { isWorldTime, worldClock } from '../time/worldClock.js'; + +// A link made away from live opens at its world time, paused. One without a +// world time leaves the clock as it is: a page opens live, and Back or Forward +// through long-form hashes (which never carry one) does not stop a replay. +function applySharedWorldTime(state) { + if (!isWorldTime(state?.worldTime)) return; + worldClock.pause(); + worldClock.seek(state.worldTime); +} /** How long a shared vessel may take to appear in the vessel feed. */ export const SHARED_VESSEL_WAIT_MS = 120_000; @@ -146,6 +156,7 @@ export class ShareRestoration { : Promise.resolve({ status: 'not-requested', share: null, layers: [] }); if (savedState) { this._hasShareState = true; + applySharedWorldTime(savedState); // Reserve camera authority now; the delayed mesh-friendly flight may // run only if no newer user, voice, or tracking navigation has won. this._initialShareNavigationGeneration = @@ -301,6 +312,7 @@ export class ShareRestoration { layers: [], }); this._initialShareState = state; + applySharedWorldTime(state); this._initialShareSelectionSuperseded = false; this._hasShareState = true; this._navigatedShareRestoreActive = true; diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js new file mode 100644 index 000000000..80f51ac71 --- /dev/null +++ b/src/ui/stores/worldTime.js @@ -0,0 +1,166 @@ +import { defineStore } from 'pinia'; +import { onScopeDispose, shallowRef } from 'vue'; +import { worldClock } from '../../time/worldClock.js'; + +/** How often a shown timeline moves its readout and its end on with now. */ +const LIVE_TICK_MS = 1000; +/** A scrub this close to now, as a share of the track, is a scrub to live. */ +const LIVE_EDGE = 0.005; +/** The least that counts as at now, past the readout's own second. */ +const LIVE_EDGE_MS = 5000; +/** The speeds play offers, in world ms per real ms. */ +export const WORLD_TIME_SPEEDS = Object.freeze({ + real: 1, + minute: 60, + hour: 3_600, + day: 86_400, + week: 604_800, +}); + +/** + * The world clock as the timeline shows it: `{live, playing, stepping, + * buffering, timeMs, supported, span}`, where `supported` says a layer that moves with the clock + * has time to show and `span` is the stretch the timeline runs over: the + * time those layers cover, reaching to now and to the world time, with their + * marks. It follows the page's world clock, which tells it of every move, and + * of the time a few times a second while it plays on its own. While a + * timeline shows, it reads the clock each second itself, so live and the + * track's end keep up with now. + */ +export const useWorldTimeStore = defineStore('gev-world-time', () => { + // The time the layers cover, reaching to now. + const reach = () => { + const coverage = worldClock.coverage(); + if (!coverage) return null; + const now = worldClock.wallNow(); + return { + start: Math.min(coverage.start, now), + end: Math.max(coverage.end, now), + marks: coverage.marks, + }; + }; + const spanOf = (state) => { + if (!state.supported) return null; + const covered = reach(); + if (!covered) return null; + return Object.freeze({ + start: Math.min(covered.start, state.timeMs), + end: Math.max(covered.end, state.timeMs), + marks: covered.marks, + }); + }; + const nearNow = (ms, length) => + Math.abs(ms - worldClock.wallNow()) <= + Math.max(LIVE_EDGE_MS, length * LIVE_EDGE); + // Whether the track ends at now (and moves on with it), not past it. + const endsAtNow = (covered) => + covered.end - worldClock.wallNow() <= LIVE_EDGE_MS; + // Play stops at the end of the track: at now it goes live, past now (a + // satellite's future) it holds there. + let stopping = false; + const stopAtEnd = (state) => { + if (stopping || !state.playing || state.live || !state.supported) + return false; + const covered = reach(); + if (!covered || state.timeMs < covered.end) return false; + stopping = true; + try { + if (endsAtNow(covered)) worldClock.goLive(); + else { + // Back to the end first, so nothing hears the overshoot as paused. + worldClock.seek(covered.end); + worldClock.pause(); + worldClock.seek(covered.end); + } + } finally { + stopping = false; + } + return true; + }; + const read = (state = worldClock.getState()) => + Object.freeze({ + live: state.live, + playing: state.playing, + stepping: state.stepping, + buffering: state.buffering, + timeMs: state.timeMs, + supported: state.supported, + span: spanOf(state), + }); + const time = shallowRef(read()); + const speed = shallowRef('real'); + const rate = () => WORLD_TIME_SPEEDS[speed.value]; + let ticker = null; + const syncTicker = () => { + // Playing, the clock tells of the time itself. + const ticking = Boolean(time.value.span) && !time.value.playing; + if (ticking && ticker === null) + ticker = setInterval(() => { + time.value = read(); + }, LIVE_TICK_MS); + else if (!ticking && ticker !== null) { + clearInterval(ticker); + ticker = null; + } + }; + const unsubscribe = worldClock.subscribe((state) => { + if (stopAtEnd(state)) return; + time.value = read(state); + syncTicker(); + }); + syncTicker(); + onScopeDispose(() => { + unsubscribe(); + if (ticker !== null) clearInterval(ticker); + ticker = null; + }); + + return { + time, + /** + * Play runs the track at the speed chosen, from the start when it is at + * its end (at live, say), as a video plays again from the top. It runs + * the clock itself, past weather's own replay: weather follows the time + * frame by frame like every other layer the track covers. + */ + togglePlay() { + const { playing, live, timeMs, span } = time.value; + if (playing) return worldClock.pause(); + const covered = reach(); + if (span && covered && (live || timeMs >= covered.end)) + worldClock.seek(span.start); + worldClock.play({ rate: rate(), continuous: true }); + }, + speed, + /** + * Choose how fast play runs; playing, it changes at once. Weather's own + * replay steps at its own pace, so a speed chosen while it plays takes + * playback over from where it is. + */ + setSpeed(next) { + if (!Object.hasOwn(WORLD_TIME_SPEEDS, next)) return; + speed.value = next; + const { playing, stepping } = worldClock.getState(); + if (!playing) return; + if (!stepping) return worldClock.setRate(rate()); + worldClock.pause(); + worldClock.play({ rate: rate(), continuous: true }); + }, + goLive: () => worldClock.goLive(), + /** + * Scrub to `ms`: playback stops where the hand puts it; at now, live. + * How near now counts as now goes by `length`, the stretch the track + * shows, all of the span unless it is zoomed. + */ + scrub(ms, length) { + const span = time.value.span; + length ??= span ? span.end - span.start : 0; + if (nearNow(ms, length)) { + worldClock.goLive(); + return; + } + worldClock.pause(); + worldClock.seek(ms); + }, + }; +}); diff --git a/src/ui/styles/cockpit.css b/src/ui/styles/cockpit.css index f6e466e43..ca76a555e 100644 --- a/src/ui/styles/cockpit.css +++ b/src/ui/styles/cockpit.css @@ -904,7 +904,7 @@ html:not([lang='en']) .cockpit-roll-token.is-separator { position: fixed; z-index: 146; right: 36px; - bottom: calc(2vh + 4.5rem); + bottom: calc(2vh + 4.5rem + var(--world-timeline-lift, 0px)); display: grid; gap: 6px; } @@ -2083,7 +2083,7 @@ body.cockpit-mode #intel-hud .hud-right-edge { @media (max-width: 760px) { #view-switcher { right: 16px; - bottom: 16px; + bottom: calc(16px + var(--world-timeline-lift, 0px)); } body.cockpit-mode #view-switcher { right: auto; diff --git a/src/ui/styles/command-dock-compact.css b/src/ui/styles/command-dock-compact.css index ced41df8f..0c40799de 100644 --- a/src/ui/styles/command-dock-compact.css +++ b/src/ui/styles/command-dock-compact.css @@ -377,15 +377,15 @@ /* Compact dock clearance keeps the reference-like low profile. */ body:not(.ui-clean-view):not(.recording-mode) #cesium-credits { - bottom: calc(2vh + 4.7rem); + bottom: calc(2vh + 4.7rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='minimal'] .hud-bottom-left { - bottom: calc(2vh + 5.4rem); + bottom: calc(2vh + 5.4rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='minimal'] .hud-bottom-bar { - bottom: calc(2vh + 8.9rem); + bottom: calc(2vh + 8.9rem + var(--world-timeline-lift, 0px)); } @media (max-width: 900px) { diff --git a/src/ui/styles/command-dock-trays.css b/src/ui/styles/command-dock-trays.css index fb41acb8e..08580ecb2 100644 --- a/src/ui/styles/command-dock-trays.css +++ b/src/ui/styles/command-dock-trays.css @@ -455,7 +455,7 @@ body:not(.ui-clean-view):not(.recording-mode):has( ) #cesium-credits { right: auto; - bottom: calc(2vh + 5rem); + bottom: calc(2vh + 5rem + var(--world-timeline-lift, 0px)); left: 1.5rem; width: auto !important; max-width: calc(100vw - 3rem); @@ -464,21 +464,21 @@ body:not(.ui-clean-view):not(.recording-mode):has( #intel-hud[data-variant='tactical'] .hud-bottom-left, #intel-hud[data-variant='tactical'] .hud-bottom-right { - bottom: calc(2vh + 6.5rem); + bottom: calc(2vh + 6.5rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='minimal'] .hud-bottom-left { - bottom: calc(2vh + 5.4rem); + bottom: calc(2vh + 5.4rem + var(--world-timeline-lift, 0px)); } :root[data-scope-mask='off'] #intel-hud[data-variant='minimal'] .hud-bottom-left { - bottom: calc(2vh + 6.5rem); + bottom: calc(2vh + 6.5rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='minimal'] .hud-bottom-bar { - bottom: calc(2vh + 9rem); + bottom: calc(2vh + 9rem + var(--world-timeline-lift, 0px)); } @media (max-width: 900px) { @@ -487,7 +487,7 @@ body:not(.ui-clean-view):not(.recording-mode):has( #intel-hud[data-variant='minimal'].active ) #cesium-credits { - bottom: calc(2vh + 5rem); + bottom: calc(2vh + 5rem + var(--world-timeline-lift, 0px)); left: 0.75rem; max-width: calc(100vw - 1.5rem); } diff --git a/src/ui/styles/command-dock.css b/src/ui/styles/command-dock.css index 3c6c8e531..808b5f480 100644 --- a/src/ui/styles/command-dock.css +++ b/src/ui/styles/command-dock.css @@ -2,7 +2,8 @@ #command-dock { position: fixed; right: auto; - bottom: 2vh; + /* Above the world timeline while it shows (world-timeline.css). */ + bottom: calc(2vh + var(--world-timeline-lift, 0px)); left: 50%; z-index: 145; display: grid; @@ -216,7 +217,7 @@ /* Keep required map attribution clear of the full-width command surface. */ body:not(.ui-clean-view):not(.recording-mode) #cesium-credits { - bottom: calc(2vh + 7rem); + bottom: calc(2vh + 7rem + var(--world-timeline-lift, 0px)); } body:not(.ui-clean-view):not(.recording-mode):has( @@ -244,11 +245,11 @@ body.recording-mode #cesium-credits { } #intel-hud[data-variant='minimal'] .hud-bottom-left { - bottom: calc(2vh + 7.5rem); + bottom: calc(2vh + 7.5rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='minimal'] .hud-bottom-bar { - bottom: calc(2vh + 11.5rem); + bottom: calc(2vh + 11.5rem + var(--world-timeline-lift, 0px)); } @media (max-width: 980px) { @@ -275,21 +276,21 @@ body.recording-mode #cesium-credits { } body:not(.ui-clean-view):not(.recording-mode) #cesium-credits { - bottom: calc(2vh + 9.5rem); + bottom: calc(2vh + 9.5rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='minimal'] .hud-bottom-left { - bottom: calc(2vh + 12rem); + bottom: calc(2vh + 12rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='minimal'] .hud-bottom-bar { - bottom: calc(2vh + 16rem); + bottom: calc(2vh + 16rem + var(--world-timeline-lift, 0px)); } } @media (max-width: 720px) { #command-dock { - bottom: 8px; + bottom: calc(8px + var(--world-timeline-lift, 0px)); } #command-dock .location-search-wrap { diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css index 4aaf5b1c2..9c80ddf4a 100644 --- a/src/ui/styles/cyber.css +++ b/src/ui/styles/cyber.css @@ -1590,7 +1590,7 @@ } #intel-hud[data-variant='cyber'] .hud-bottom-bar { - bottom: calc(2vh + 9.8rem); + bottom: calc(2vh + 9.8rem + var(--world-timeline-lift, 0px)); width: min(48vw, 680px); } @@ -1601,13 +1601,13 @@ #intel-hud[data-variant='cyber'] .hud-bottom-left, #intel-hud[data-variant='cyber'] .hud-bottom-right { - bottom: calc(2vh + 6.5rem); + bottom: calc(2vh + 6.5rem + var(--world-timeline-lift, 0px)); } /* Attribution's logo row extends above its text baseline. Reserve the whole row plus a gap above the dock-relative 2vh + 5rem credit position. */ #intel-hud[data-variant='cyber'] .hud-bottom-left { - bottom: calc(2vh + 7.75rem); + bottom: calc(2vh + 7.75rem + var(--world-timeline-lift, 0px)); } #intel-hud[data-variant='cyber'] .hud-corner { @@ -1652,7 +1652,7 @@ below its 650px threshold. Keep the left rail inside the same telemetry and attribution-safe floor while its panel bodies take over scrolling. */ :root[data-ui-theme='cyber'] #left-panel-stack { - bottom: calc(2vh + 13rem); + bottom: calc(2vh + 13rem + var(--world-timeline-lift, 0px)); } } @@ -1675,7 +1675,7 @@ /* The stack holds still, so its rail needs no offset (leftPanelRail.js). */ --panel-stack-top-pinned: 1; top: var(--cyber-left-rail-top) !important; - bottom: calc(2vh + 13rem); + bottom: calc(2vh + 13rem + var(--world-timeline-lift, 0px)); inset-inline-start: calc(54px + var(--panel-rail-clearance)); width: 300px; padding-inline-start: 10px; diff --git a/src/ui/styles/mobile-shell.css b/src/ui/styles/mobile-shell.css index 707b0f43f..b607391de 100644 --- a/src/ui/styles/mobile-shell.css +++ b/src/ui/styles/mobile-shell.css @@ -200,7 +200,7 @@ body.mobile-shell #command-dock { /* The dock's tray rules size voice by this basis; a column makes it a height. */ --dock-voice-width: auto; top: auto; - bottom: calc(var(--mobile-tab-space) + 10px); + bottom: calc(var(--mobile-tab-space) + 10px + var(--world-timeline-lift, 0px)); display: flex; flex-direction: column; align-items: center; @@ -380,7 +380,7 @@ body.mobile-shell #command-dock #gev-voice-control { body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) #cesium-credits { right: auto; - bottom: calc(var(--mobile-tab-space) + 106px) !important; + bottom: calc(var(--mobile-tab-space) + 106px + var(--world-timeline-lift, 0px)) !important; left: 10px; /* With the base rule's nowrap the credits run off the right edge, and a max-width alone wrapped them into a narrow column: the line needs wrapping @@ -407,7 +407,7 @@ body.mobile-shell[data-mobile-sheet]:not(.recording-mode):not( /* The view switcher clears the tab bar and the voice bar, opposite the credit. */ body.mobile-shell:not(.cockpit-mode) #view-switcher { right: 12px; - bottom: calc(var(--mobile-tab-space) + 106px); + bottom: calc(var(--mobile-tab-space) + 106px + var(--world-timeline-lift, 0px)); } body.mobile-shell #corner-actions { diff --git a/src/ui/styles/responsive.css b/src/ui/styles/responsive.css index 27a8ded3c..d3d986f44 100644 --- a/src/ui/styles/responsive.css +++ b/src/ui/styles/responsive.css @@ -177,7 +177,7 @@ drawn straight over the notice. Track the credit's own anchor instead and the 12px of clear air holds regardless of height; the rail already scrolls (overflow-y below), so it loses reach, not content. */ - bottom: calc(2vh + 7.5rem); + bottom: calc(2vh + 7.5rem + var(--world-timeline-lift, 0px)); left: 16px; width: auto; max-height: none; diff --git a/src/ui/styles/status.css b/src/ui/styles/status.css index bba1ef788..404b9cd2c 100644 --- a/src/ui/styles/status.css +++ b/src/ui/styles/status.css @@ -390,7 +390,7 @@ html:not([lang='en']) #global-loading-detail { /* ── Toast Notification ──────────────────────── */ #toast { position: fixed; - bottom: 100px; + bottom: calc(100px + var(--world-timeline-lift, 0px)); left: 50%; transform: translateX(-50%) translateY(20px); z-index: 200; diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css new file mode 100644 index 000000000..f644f8543 --- /dev/null +++ b/src/ui/styles/world-timeline.css @@ -0,0 +1,325 @@ +/* ── World timeline ─────────────────────────── */ +/* Shown while a layer with time to show is on, centred along the bottom of + the screen; the command dock and what sits above it rise by its height. */ +:root { + --world-timeline-lift: 0px; +} + +/* The bar and the time's bubble over its handle. */ +body:has(#world-timeline) { + --world-timeline-lift: 66px; +} + +/* Clean view, recording and the cockpit put the dock away; the bar goes too. */ +body.ui-clean-view, +body.recording-mode, +body.cockpit-mode { + --world-timeline-lift: 0px !important; +} + +body.ui-clean-view #world-timeline, +body.recording-mode #world-timeline, +body.cockpit-mode #world-timeline { + display: none; +} + +/* Time runs left to right in every language, so the handle, the fill, the + ticks and the bubble agree under a right-to-left page too. */ +#world-timeline { + direction: ltr; + position: fixed; + bottom: 2vh; + left: 50%; + z-index: 146; + display: flex; + align-items: center; + gap: 10px; + width: min(820px, calc(100vw - 24px)); + height: 42px; + box-sizing: border-box; + padding: 4px 6px; + border: 1px solid var(--glass-border); + border-radius: 10px; + background: var(--glass-bg); + backdrop-filter: blur(24px) saturate(1.4); + -webkit-backdrop-filter: blur(24px) saturate(1.4); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5); + transform: translateX(-50%); +} + +#world-timeline .world-timeline-play, +#world-timeline .world-timeline-live { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 32px; + padding: 0 8px; + border: 1px solid rgba(123, 189, 211, 0.28); + border-radius: 8px; + background: rgba(0, 212, 255, 0.08); + color: var(--text-primary); + font: inherit; + cursor: pointer; +} + +#world-timeline .world-timeline-play:hover, +#world-timeline .world-timeline-live:hover { + background: rgba(0, 212, 255, 0.18); +} + +#world-timeline .world-timeline-live.is-live { + border-color: rgba(0, 212, 255, 0.6); + background: rgba(0, 212, 255, 0.2); + color: var(--accent); + cursor: default; +} + +#world-timeline .world-timeline-live { + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +#world-timeline .world-timeline-speed { + flex: none; +} + +/* The time rides over the handle. It slides across itself as the handle + crosses the track (its start at the handle at the track's start, its end at + the end), so however wide its date it stays within the track. */ +#world-timeline .world-timeline-time { + --inset: 0px; + position: absolute; + bottom: calc(100% + 6px); + left: calc(var(--inset) + (100% - var(--inset)) * var(--at)); + padding: 2px 8px; + border: 1px solid rgba(0, 212, 255, 0.4); + border-radius: 6px; + background: var(--glass-bg); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + transform: translateX(calc(var(--at) * -100%)); + pointer-events: none; +} + +/* Playing, held while a layer loads the time's data: a spinner by the time. */ +#world-timeline .world-timeline-time.is-waiting { + display: inline-flex; + align-items: center; + gap: 6px; +} + +#world-timeline .world-timeline-spinner { + width: 10px; + height: 10px; + border: 2px solid rgba(255, 255, 255, 0.2); + border-top-color: var(--accent); + border-radius: 50%; + animation: world-timeline-spin 0.9s linear infinite; +} + +@keyframes world-timeline-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + #world-timeline .world-timeline-spinner { + animation: none; + border-color: var(--accent); + } +} + +/* The track: ticks for each launch or frame, the elapsed fill, and a native + range input on top for dragging and the keyboard. */ +#world-timeline .world-timeline-track { + position: relative; + /* Two fingers pinch the track, not the page. */ + touch-action: none; + flex: 1 1 auto; + cursor: pointer; + min-width: 80px; + height: 24px; +} + +/* Zoomed, the track's ends fade, as it goes on past them. */ +#world-timeline .world-timeline-track.is-zoomed::before { + background: linear-gradient( + 90deg, + transparent, + rgba(123, 189, 211, 0.22) 12px, + rgba(123, 189, 211, 0.22) calc(100% - 12px), + transparent + ); +} + +#world-timeline .world-timeline-track::before { + position: absolute; + top: 50%; + right: 0; + left: 0; + height: 4px; + border-radius: 2px; + background: rgba(123, 189, 211, 0.22); + content: ''; + transform: translateY(-50%); +} + +#world-timeline .world-timeline-fill { + position: absolute; + top: 50%; + left: 0; + height: 4px; + border-radius: 2px; + background: var(--accent); + opacity: 0.7; + transform: translateY(-50%); + pointer-events: none; +} + +#world-timeline .world-timeline-tick { + position: absolute; + top: 50%; + width: 2px; + height: 12px; + margin-left: -1px; + border-radius: 1px; + background: rgba(0, 212, 255, 0.75); + transform: translateY(-50%); + pointer-events: none; +} + +/* Zoomed, the track drags along. */ +#world-timeline .world-timeline-track.is-zoomed { + cursor: grab; +} + +#world-timeline .world-timeline-track.is-zoomed:active { + cursor: grabbing; +} + +/* Only the handle takes the pointer as a slider; the track around it takes + taps, drags and pinches itself. */ +#world-timeline .world-timeline-scrub { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + background: transparent; + appearance: none; + pointer-events: none; +} + +#world-timeline .world-timeline-scrub::-webkit-slider-thumb { + cursor: ew-resize; + pointer-events: auto; +} + +#world-timeline .world-timeline-scrub::-moz-range-thumb { + cursor: ew-resize; + pointer-events: auto; +} + +/* The time dragged off the track: the handle waits at its edge, unseen. */ +#world-timeline .world-timeline-scrub.is-off-view::-webkit-slider-thumb { + opacity: 0; + pointer-events: none; +} + +#world-timeline .world-timeline-scrub.is-off-view::-moz-range-thumb { + opacity: 0; + pointer-events: none; +} + +#world-timeline .world-timeline-scrub::-webkit-slider-runnable-track { + height: 100%; + background: transparent; +} + +#world-timeline .world-timeline-scrub::-moz-range-track { + background: transparent; +} + +#world-timeline .world-timeline-scrub::-webkit-slider-thumb { + width: 14px; + height: 14px; + margin-top: 5px; + border: 2px solid var(--accent); + border-radius: 50%; + background: #031018; + box-shadow: 0 0 8px rgba(0, 212, 255, 0.6); + appearance: none; +} + +#world-timeline .world-timeline-scrub::-moz-range-thumb { + width: 12px; + height: 12px; + border: 2px solid var(--accent); + border-radius: 50%; + background: #031018; +} + +/* A finger needs a bigger handle. */ +@media (pointer: coarse) { + #world-timeline .world-timeline-scrub::-webkit-slider-thumb { + width: 22px; + height: 22px; + margin-top: 1px; + } + + #world-timeline .world-timeline-scrub::-moz-range-thumb { + width: 20px; + height: 20px; + } +} + +#world-timeline .world-timeline-scrub:focus-visible { + outline: 1px solid var(--accent); + outline-offset: 2px; +} + +@media (max-width: 720px) { + #world-timeline { + bottom: 8px; + } +} + +/* On phones the bar sits just above the tab bar, under the voice dock, with + the track and the speed on a row of their own beneath play and live. */ +body.mobile-shell:has(#world-timeline) { + --world-timeline-lift: 80px; +} + +body.mobile-shell #world-timeline { + bottom: calc(var(--mobile-tab-space) + 6px); + flex-wrap: wrap; + row-gap: 4px; + height: auto; +} + +/* The bubble rises into the first row, clear of play on its left. */ +body.mobile-shell #world-timeline .world-timeline-time { + --inset: 48px; +} + +/* The speed sits on the track's row, a full-size target beside it. */ +body.mobile-shell #world-timeline .world-timeline-track { + order: 1; + flex: 1 1 calc(100% - 96px); +} + +body.mobile-shell #world-timeline .world-timeline-live { + margin-inline-start: auto; +} + +body.mobile-shell #world-timeline .world-timeline-speed { + order: 2; + height: 32px; +} + diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index f77dad3d2..32900f42a 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -18,6 +18,7 @@ import ScreenAnnotations from './panels/ScreenAnnotations.vue'; import AwarenessDirection from './panels/AwarenessDirection.vue'; import UiButton from '../kit/UiButton.vue'; import GlobeActions from './panels/GlobeActions.vue'; +import WorldTimeline from './panels/WorldTimeline.vue'; // The ACTIVE STYLE corner shows the style VisualSettings publishes. const { style } = storeToRefs(useDisplayStore()); @@ -117,4 +118,5 @@ const vToastText = { + diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs new file mode 100644 index 000000000..073d6f95e --- /dev/null +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -0,0 +1,548 @@ +// The world timeline shows the world time while a layer with time to show is +// on, with play and pause on the left and the way back to live on the right. +import '../../../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp, nextTick } from 'vue'; +import { createPinia } from 'pinia'; +import { worldClock } from '../../../time/worldClock.js'; +import WorldTimeline from './WorldTimeline.vue'; +import { useWorldTimeStore } from '../../stores/worldTime.js'; + +function mount(t) { + document.body.replaceChildren(); + const pinia = createPinia(); + const app = createApp(WorldTimeline).use(pinia); + app.mount(document.body.appendChild(document.createElement('div'))); + t.after(() => { + app.unmount(); + // The store outlives the view; dispose it with its clock subscription. + useWorldTimeStore(pinia).$dispose(); + worldClock.goLive(); + }); + return () => document.getElementById('world-timeline'); +} + +// A layer that moves with the clock and has an hour of frames. +function supportingLayer(t, at) { + const release = worldClock.hold('test-radar'); + const unregister = worldClock.register({ + id: 'test-radar', + coverage: () => ({ start: at - 3_600_000, end: at }), + }); + t.after(() => { + unregister(); + release(); + }); + return () => { + unregister(); + release(); + }; +} + +test('the timeline shows live, and plays, pauses and goes back', async (t) => { + const bar = mount(t); + const at = Date.UTC(2026, 9, 30, 6, 15, 42); + supportingLayer(t, at); + await nextTick(); + assert.ok(bar(), 'shown while live'); + const live = bar().querySelector('.world-timeline-live'); + assert.equal(live.disabled, true, 'already live'); + assert.equal(live.getAttribute('aria-pressed'), 'true'); + + worldClock.seek(at); + await nextTick(); + assert.ok(bar()); + const time = bar().querySelector('time'); + assert.equal(time.getAttribute('datetime'), new Date(at).toISOString()); + assert.match(time.textContent, /UTC$/); + const play = bar().querySelector('.world-timeline-play'); + assert.equal(play.getAttribute('aria-label'), 'Play'); + + play.click(); + await nextTick(); + assert.equal(worldClock.isPlaying(), true); + assert.equal(play.getAttribute('aria-label'), 'Pause'); + play.click(); + await nextTick(); + assert.equal(worldClock.isPlaying(), false); + + assert.equal(bar().querySelector('.world-timeline-live').disabled, false); + bar().querySelector('.world-timeline-live').click(); + await nextTick(); + assert.equal(worldClock.isLive(), true); + assert.ok(bar(), 'still shown, live'); + assert.equal(bar().querySelector('.world-timeline-live').disabled, true); +}); + +test('the timeline hides while no layer with time to show is on', async (t) => { + const bar = mount(t); + const at = Date.UTC(2026, 9, 30, 6, 15, 42); + await nextTick(); + assert.equal(bar(), null, 'live, nothing to play'); + worldClock.seek(at); + await nextTick(); + assert.equal(bar(), null, 'away from live, but nothing to play'); + const remove = supportingLayer(t, at); + await nextTick(); + assert.ok(bar()); + remove(); + await nextTick(); + assert.equal(bar(), null, 'the layer went off'); +}); + +test('the track runs over the time the layers show, to now, and scrubs', async (t) => { + const bar = mount(t); + const now = Date.now(); + const at = now - 2 * 86_400_000; + const release = worldClock.hold('test-launches'); + const unregister = worldClock.register({ + id: 'test-launches', + coverage: () => ({ start: at - 86_400_000, end: at, marks: [at - 86_400_000, at] }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + const input = bar().querySelector('input[type=range]'); + assert.equal(Number(input.min), at - 86_400_000); + assert.ok(Number(input.max) >= now, 'reaches to now'); + assert.equal(bar().querySelectorAll('.world-timeline-tick').length, 2); + + worldClock.play(); + input.value = String(at); + input.dispatchEvent(new Event('input')); + await nextTick(); + assert.equal(worldClock.isPlaying(), false, 'a scrub stops playback'); + assert.equal(worldClock.now(), at); + assert.equal(bar().querySelector('time').getAttribute('datetime'), new Date(at).toISOString()); + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' })); + await nextTick(); + assert.equal(worldClock.now(), at - 86_400_000, 'the arrow goes to the previous mark'); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })); + await nextTick(); + assert.equal(worldClock.now(), at, 'and back to the next'); + + input.value = String(Date.now() + 2_000); + input.dispatchEvent(new Event('input')); + await nextTick(); + assert.equal(worldClock.isLive(), true, 'a scrub to now goes live'); + + // A layer that reaches ahead of now, as satellites do. + const holdAhead = worldClock.hold('test-ahead'); + const unregisterAhead = worldClock.register({ + id: 'test-ahead', + coverage: () => ({ start: now, end: now + 3 * 86_400_000 }), + }); + t.after(() => { + unregisterAhead(); + holdAhead(); + }); + await nextTick(); + const ahead = Date.now() + 86_400_000; + input.value = String(ahead); + input.dispatchEvent(new Event('input')); + await nextTick(); + assert.equal(worldClock.isLive(), false, 'a scrub past now stays there'); + assert.equal(worldClock.now(), ahead); +}); + +test('play runs in real time, from the start of the track when at live', async (t) => { + const bar = mount(t); + const now = Date.now(); + const start = now - 10 * 86_400_000; + const release = worldClock.hold('test-launches'); + const unregister = worldClock.register({ + id: 'test-launches', + coverage: () => ({ start, end: now - 86_400_000 }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + // Weather's replay stepping its own frames does not take the track's play. + const stepped = []; + const driver = worldClock.attachDriver({ + play: () => stepped.push('play'), + pause: () => stepped.push('pause'), + }); + t.after(() => driver.detach()); + bar().querySelector('.world-timeline-play').click(); + worldClock.pause(); + await nextTick(); + assert.deepEqual(stepped, []); + const rate = worldClock.getRate(); + assert.ok(worldClock.now() - start < rate * 1_000, 'from the start of the track'); + assert.equal(rate, 1, 'real time to start with'); + worldClock.play({ continuous: true }); + + // Playing on to now goes live. + worldClock.seek(Date.now() + 1_000); + await nextTick(); + assert.equal(worldClock.isLive(), true); + assert.equal(worldClock.isPlaying(), false); + assert.equal(worldClock.getRate(), 1, 'live plays at the rate of the world'); +}); + +test('play holds at the end of a track that reaches past now', async (t) => { + const bar = mount(t); + const now = Date.now(); + // An hour ahead is within the track's share of now, but still ahead of it. + const end = now + 3_600_000; + const release = worldClock.hold('test-ahead'); + const unregister = worldClock.register({ + id: 'test-ahead', + coverage: () => ({ start: now - 10 * 86_400_000, end }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + // Paused just past now, play plays on rather than from the top. + worldClock.seek(Date.now() + 1_000); + await nextTick(); + const from = worldClock.now(); + bar().querySelector('.world-timeline-play').click(); + worldClock.pause(); + assert.ok(worldClock.now() >= from, 'plays on from where it was'); + worldClock.play(); + worldClock.seek(end + 60_000); + await nextTick(); + assert.equal(worldClock.isPlaying(), false); + assert.equal(worldClock.isLive(), false); + assert.equal(worldClock.now(), end); +}); + +test('the speed chosen sets how fast play runs, at once while playing', async (t) => { + const bar = mount(t); + const now = Date.now(); + const release = worldClock.hold('test-launches'); + const unregister = worldClock.register({ + id: 'test-launches', + coverage: () => ({ start: now - 10 * 86_400_000, end: now - 86_400_000 }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + const select = bar().querySelector('select.world-timeline-speed'); + assert.equal(select.value, 'real'); + assert.deepEqual( + [...select.options].map((option) => option.value), + ['real', 'minute', 'hour', 'day', 'week'], + ); + select.value = 'week'; + select.dispatchEvent(new Event('change')); + await nextTick(); + bar().querySelector('.world-timeline-play').click(); + assert.equal(worldClock.getRate(), 604_800, 'a week a second'); + select.value = 'hour'; + select.dispatchEvent(new Event('change')); + await nextTick(); + assert.equal(worldClock.isPlaying(), true); + assert.equal(worldClock.getRate(), 3_600, 'changed while playing'); + select.value = 'minute'; + select.dispatchEvent(new Event('change')); + await nextTick(); + assert.equal(worldClock.getRate(), 60, 'a minute a second'); + select.value = 'real'; + select.dispatchEvent(new Event('change')); + await nextTick(); + assert.equal(worldClock.getRate(), 1, 'the world as it goes'); +}); + +test('a speed chosen while weather replay steps takes playback over', async (t) => { + const bar = mount(t); + const at = Date.now() - 3_600_000; + supportingLayer(t, at); + const calls = []; + const driver = worldClock.attachDriver({ + play() { + calls.push('play'); + driver.driven(true, at - 600_000); + }, + pause() { + calls.push('pause'); + driver.driven(false); + }, + }); + t.after(() => driver.detach()); + worldClock.play(); + await nextTick(); + assert.equal(worldClock.getState().stepping, true); + const select = bar().querySelector('select.world-timeline-speed'); + select.value = 'hour'; + select.dispatchEvent(new Event('change')); + await nextTick(); + assert.deepEqual(calls, ['play', 'pause']); + assert.equal(worldClock.getState().stepping, false); + assert.equal(worldClock.isPlaying(), true); + assert.equal(worldClock.getRate(), 3_600); + assert.ok(worldClock.now() - (at - 600_000) < 3_600 * 1_000, 'on from where it was'); + worldClock.pause(); +}); + +test('paused just before the end, play carries on rather than restarting', async (t) => { + const bar = mount(t); + const now = Date.now(); + const end = now + 86_400_000; + const release = worldClock.hold('test-ahead'); + const unregister = worldClock.register({ + id: 'test-ahead', + coverage: () => ({ start: now - 86_400_000, end }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + worldClock.seek(end - 4_000); + await nextTick(); + bar().querySelector('.world-timeline-play').click(); + worldClock.pause(); + assert.ok(worldClock.now() >= end - 4_000, 'not back to the start'); +}); + +test('the track zooms around the handle, follows it, and shows it all again', async (t) => { + const bar = mount(t); + const now = Date.now(); + const start = now - 26 * 86_400_000; + const release = worldClock.hold('test-launches'); + const unregister = worldClock.register({ + id: 'test-launches', + // A month of launches and three hours of lightning, sharing one track. + coverage: () => ({ + start, + end: now, + marks: [start, now - 3 * 3_600_000, now - 2 * 3_600_000, now - 3_600_000], + }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + const track = bar().querySelector('.world-timeline-track'); + const input = bar().querySelector('input[type=range]'); + const length = () => Number(input.max) - Number(input.min); + const full = length(); + assert.equal(bar().querySelectorAll('.world-timeline-tick').length, 4); + + // Live, the handle is at the end: zooming in keeps the view on now. + for (let i = 0; i < 10; i++) { + track.dispatchEvent(new WheelEvent('wheel', { deltaY: -200, cancelable: true })); + await nextTick(); + } + assert.ok(length() < full / 100, 'zoomed in'); + assert.ok(Number(input.max) >= now, 'still ending at now'); + assert.equal(track.classList.contains('is-zoomed'), true); + assert.equal(bar().querySelectorAll('.world-timeline-tick').length, 3, 'only the ticks in view'); + + // A seek off the view takes the view along. + worldClock.seek(start + 86_400_000); + await nextTick(); + assert.ok(Number(input.min) <= start + 86_400_000 && Number(input.max) >= start + 86_400_000); + assert.ok(length() < full / 100, 'at the same zoom'); + + // Minus widens, zero and a double-click show it all. + input.dispatchEvent(new KeyboardEvent('keydown', { key: '-' })); + await nextTick(); + input.dispatchEvent(new KeyboardEvent('keydown', { key: '0' })); + await nextTick(); + assert.ok(Math.abs(length() - full) < 1_000, 'all of it'); + input.dispatchEvent(new KeyboardEvent('keydown', { key: '+' })); + await nextTick(); + assert.ok(length() < full); + track.dispatchEvent(new MouseEvent('dblclick')); + await nextTick(); + assert.equal(track.classList.contains('is-zoomed'), false); +}); + +test('zoomed, a scrub near now stays off live, and the view keeps a still handle', async (t) => { + const bar = mount(t); + const now = Date.now(); + const start = now - 26 * 86_400_000; + let end = now; + const release = worldClock.hold('test-launches'); + const unregister = worldClock.register({ + id: 'test-launches', + coverage: () => ({ start, end, marks: [start, now - 3_600_000] }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + const track = bar().querySelector('.world-timeline-track'); + const input = bar().querySelector('input[type=range]'); + // Ten notches of a line-mode wheel, as Firefox sends them. + for (let i = 0; i < 10; i++) { + track.dispatchEvent(new WheelEvent('wheel', { deltaY: -12, deltaMode: 1, cancelable: true })); + await nextTick(); + } + assert.ok(Number(input.max) - Number(input.min) < 6 * 3_600_000, 'lines zoom like pixels'); + + // Twenty minutes back is near now on a month, not on a few hours. + input.value = String(now - 20 * 60_000); + input.dispatchEvent(new Event('input')); + await nextTick(); + assert.equal(worldClock.isLive(), false); + assert.ok(Math.abs(worldClock.now() - (now - 20 * 60_000)) < 1_000); + + // The span running on past the paused handle takes the view back to it. + end = now + 86_400_000; + worldClock.coverageChanged(); + await nextTick(); + const at = worldClock.now(); + assert.ok(Number(input.min) <= at && Number(input.max) >= at, 'handle in view'); + + // With a modifier, plus is the browser's zoom. + const browserZoom = new KeyboardEvent('keydown', { key: '+', ctrlKey: true, cancelable: true }); + input.dispatchEvent(browserZoom); + assert.equal(browserZoom.defaultPrevented, false); + + // A double-click puts back the time its first press moved. + track.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 1 })); + track.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1 })); + input.value = String(at - 3_600_000); + input.dispatchEvent(new Event('input')); + track.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + await nextTick(); + assert.equal(track.classList.contains('is-zoomed'), false); + assert.ok(Math.abs(worldClock.now() - at) < 1_000, 'time as it was'); +}); + +test('play held for a layer to load shows a spinner once the hold lasts', async (t) => { + const bar = mount(t); + const at = Date.UTC(2026, 9, 30, 6, 15, 42); + supportingLayer(t, at); + worldClock.seek(at - 1_800_000); + worldClock.play({ continuous: true }); + await nextTick(); + const bubble = () => bar().querySelector('.world-timeline-time'); + const release = worldClock.wait('test-radar'); + t.after(release); + // A clock tick (a quarter second) tells of the hold; the spinner waits on. + await new Promise((resolve) => setTimeout(resolve, 250)); + await nextTick(); + assert.equal(bubble().querySelector('.world-timeline-spinner'), null, 'not for a quick frame'); + // The clock tells of the hold on its next tick, and the bar shows it after. + const until = async (shown) => { + for (let waited = 0; waited < 3_000; waited += 50) { + if (Boolean(bubble().querySelector('.world-timeline-spinner')) === shown) return true; + await new Promise((resolve) => setTimeout(resolve, 50)); + await nextTick(); + } + return false; + }; + assert.ok(await until(true), 'spinner once the hold lasts'); + assert.match(bubble().textContent, /UTC$/, 'the time stays'); + release(); + assert.ok(await until(false), 'gone once play goes on'); +}); + +test('the track drags along, a tap goes to its time, and the handle waits off it', async (t) => { + const bar = mount(t); + const now = Date.now(); + const start = now - 26 * 86_400_000; + const release = worldClock.hold('test-launches'); + const unregister = worldClock.register({ + id: 'test-launches', + coverage: () => ({ start, end: now, marks: [start] }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + const track = bar().querySelector('.world-timeline-track'); + const input = bar().querySelector('input[type=range]'); + // A 1000 px track from x = 0. + Object.defineProperty(track, 'clientWidth', { value: 1_000 }); + track.getBoundingClientRect = () => ({ left: 0, width: 1_000, top: 0, height: 24 }); + const press = (type, clientX, pointerId = 1) => + track.dispatchEvent(new PointerEvent(type, { bubbles: true, clientX, pointerId })); + const view = () => ({ min: Number(input.min), max: Number(input.max) }); + + // All of it shown, a tap goes to the time under it. + press('pointerdown', 500); + press('pointerup', 500); + await nextTick(); + const middle = start + (now - start) / 2; + assert.ok(Math.abs(worldClock.now() - middle) < 60_000, 'went to the middle'); + + // Zoomed to a day around it, dragging right by half the track goes back half a day. + for (let i = 0; i < 6; i++) { + input.dispatchEvent(new KeyboardEvent('keydown', { key: '+' })); + await nextTick(); + } + const before = view(); + const day = before.max - before.min; + press('pointerdown', 600, 2); + press('pointermove', 700, 2); + press('pointermove', 1_100, 2); + press('pointerup', 1_100, 2); + await nextTick(); + assert.ok(Math.abs(view().max - (before.max - day / 2)) < 1_000, 'dragged half a view'); + assert.ok(Math.abs(worldClock.now() - middle) < 60_000, 'a drag leaves the time'); + + // Dragged off the handle, it waits unseen at the edge; a sideways swipe drags too. + track.dispatchEvent(new WheelEvent('wheel', { deltaX: -2_000, deltaY: 0, cancelable: true })); + await nextTick(); + assert.ok(view().max < worldClock.now(), 'the handle is past the view'); + assert.equal(input.classList.contains('is-off-view'), true); + // Moving the time takes the view back to it. + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageUp' })); + await nextTick(); + assert.equal(input.classList.contains('is-off-view'), false); +}); + +test('a pinch measures from where the fingers are, and a lifted finger ends it', async (t) => { + const bar = mount(t); + const now = Date.now(); + const start = now - 26 * 86_400_000; + const release = worldClock.hold('test-launches'); + const unregister = worldClock.register({ + id: 'test-launches', + coverage: () => ({ start, end: now, marks: [start] }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + const track = bar().querySelector('.world-timeline-track'); + const input = bar().querySelector('input[type=range]'); + Object.defineProperty(track, 'clientWidth', { value: 1_000 }); + track.getBoundingClientRect = () => ({ left: 0, width: 1_000, top: 0, height: 24 }); + const on = (target, type, clientX, pointerId) => + target.dispatchEvent(new PointerEvent(type, { bubbles: true, clientX, pointerId })); + const length = () => Number(input.max) - Number(input.min); + for (let i = 0; i < 4; i++) { + input.dispatchEvent(new KeyboardEvent('keydown', { key: '+' })); + await nextTick(); + } + const before = length(); + // A finger on the handle moves before the second comes down. + on(input, 'pointerdown', 300, 1); + on(input, 'pointermove', 500, 1); + on(track, 'pointerdown', 700, 2); + on(input, 'pointermove', 500, 1); + await nextTick(); + assert.ok(Math.abs(length() - before) < 1_000, 'no zoom from a stale finger'); + on(track, 'pointermove', 900, 2); + await nextTick(); + assert.ok(length() < before, 'spread apart, it zooms in'); + // One finger up: the other moving does nothing, and throws nothing. + const zoomedTo = length(); + on(track, 'pointerup', 900, 2); + on(input, 'pointermove', 100, 1); + await nextTick(); + assert.ok(Math.abs(length() - zoomedTo) < 1_000); + on(input, 'pointercancel', 100, 1); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue new file mode 100644 index 000000000..866380638 --- /dev/null +++ b/src/ui/views/panels/WorldTimeline.vue @@ -0,0 +1,355 @@ + + + diff --git a/src/voice/analystAccessors.test.mjs b/src/voice/analystAccessors.test.mjs index bd8ac611a..4d9abe453 100644 --- a/src/voice/analystAccessors.test.mjs +++ b/src/voice/analystAccessors.test.mjs @@ -74,9 +74,11 @@ test('bikeshare stations join coordinates from station information', () => { assert.deepEqual(methods.getAnalystRecords(), []); }); -test('launches carry time and hours until launch', () => { - const at = Date.now() + 5 * 3_600_000; +test('launches carry time and hours until launch at the world time', () => { + const now = Date.UTC(2026, 10, 1); + const at = now + 5 * 3_600_000; const state = { + now: () => now, _enabled: true, _launches: [ { diff --git a/style.css b/style.css index 30b7190fb..0537c30e1 100644 --- a/style.css +++ b/style.css @@ -33,6 +33,7 @@ @import './src/ui/styles/provider-settings.css'; @import './src/ui/styles/locale-picker.css'; @import './src/ui/styles/weather.css'; +@import './src/ui/styles/world-timeline.css'; @import './src/ui/styles/panel-surfaces.css'; @import './src/ui/styles/segmented-control.css'; @import './src/ui/styles/kit.css';