Merge main into fix/routes-panel-honesty
Both sides added a test beside the other in the dock's spec: the panel's own departure guard here, the give-up wording on main. Both kept, and the offer test carries the `least` the route answer now has. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
@@ -49,9 +49,11 @@ jobs:
|
||||
key: gaia-dr3-${{ hashFiles('tools/etl/sources/gaia.ts') }}
|
||||
|
||||
# Every other source is fetched live on this fresh runner. A failed fetch fails the run by
|
||||
# design — no refresh is better than a partial one. That includes Gaia on a cold cache:
|
||||
# the ETL skips it when unreachable, and the merge gate in build.ts then refuses a
|
||||
# catalogue it contributed nothing to.
|
||||
# design — no refresh is better than a partial one. That includes Gaia on a cold cache, by
|
||||
# two different paths: its Hipparcos cross-match is required, so an unreachable archive
|
||||
# fails the run from fetchStars itself, while its main query is skipped when unreachable and
|
||||
# the merge gate in build.ts then refuses a catalogue it contributed nothing to. An archive
|
||||
# that answers short rather than not at all is caught in fetchGaiaStars.
|
||||
- name: Rebuild the datasets
|
||||
run: npm run etl
|
||||
|
||||
|
||||
@@ -13,8 +13,10 @@ import { NavigationStore } from '../../shared/state/navigation.store';
|
||||
import { LinkBudget } from '../../shared/astro/jump-links';
|
||||
import { HudDisplay } from '../hud/hud-dock.component';
|
||||
import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
|
||||
import { galacticNormal } from './grid-plane';
|
||||
import { JumpLinkRenderer } from './jump-link-renderer';
|
||||
import { StarFieldRenderer } from './star-field-renderer';
|
||||
import { LabeledPoint, StarLabelOverlay } from './star-label-overlay';
|
||||
|
||||
// jsdom does not implement ResizeObserver; the component only uses it to react to real
|
||||
// layout changes, which never happen in this headless test.
|
||||
@@ -418,12 +420,155 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the local grid of distance rings', () => {
|
||||
type GridScene = {
|
||||
controls: { target: THREE.Vector3; update(): void };
|
||||
display: { update(change: (display: HudDisplay) => HudDisplay): void };
|
||||
localGridRadii: readonly number[];
|
||||
};
|
||||
|
||||
it('sizes the rings by how far the frame reaches from the Sun, under either projection', async () => {
|
||||
const component = fixture.componentInstance as unknown as GridScene;
|
||||
const camera = engine.getCamera();
|
||||
// Centred on a point 200 pc out along the galactic plane — where the rings are — seen from
|
||||
// 20 pc above it. The rings have to reach it, and one of them has to cross the frame.
|
||||
const normal = galacticNormal();
|
||||
const centre = new THREE.Vector3(1, 0, 0).projectOnPlane(normal).normalize().multiplyScalar(200);
|
||||
component.controls.target.copy(centre);
|
||||
camera.position.copy(centre).addScaledVector(normal, 20);
|
||||
component.controls.update();
|
||||
await advanceFrames(engine, 0.3);
|
||||
const underPerspective = [...component.localGridRadii];
|
||||
|
||||
component.display.update((display) => ({ ...display, plan: true }));
|
||||
TestBed.tick();
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
expect(underPerspective.at(-1)).toBeGreaterThanOrEqual(200);
|
||||
// The frame is a band about 19 pc either side of 200 pc: rings out to 220 at a step sized to
|
||||
// all 220 are 180 and 200, both of them off screen.
|
||||
const halfHeight = engine.visibleHalfHeight(20);
|
||||
expect(underPerspective.some((radius) => Math.abs(radius - 200) < halfHeight)).toBe(true);
|
||||
// The plan view's wheel moves the frame rather than the camera, so "how far out the camera
|
||||
// is" means something else there; what the rings have to cover does not.
|
||||
expect([...component.localGridRadii]).toEqual(underPerspective);
|
||||
});
|
||||
|
||||
it('measures the span in the plane the rings lie in, not through it', async () => {
|
||||
const component = fixture.componentInstance as unknown as GridScene;
|
||||
const camera = engine.getCamera();
|
||||
// The same 200 pc out along the plane, but lifted 150 pc above it: 250 pc from the Sun as the
|
||||
// crow flies, and still 200 pc out among the rings, which is the distance they are drawn at.
|
||||
const normal = galacticNormal();
|
||||
const centre = new THREE.Vector3(1, 0, 0).projectOnPlane(normal).normalize().multiplyScalar(200).addScaledVector(normal, 150);
|
||||
component.controls.target.copy(centre);
|
||||
camera.position.copy(centre).addScaledVector(normal, 20);
|
||||
component.controls.update();
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
const halfHeight = engine.visibleHalfHeight(20);
|
||||
expect([...component.localGridRadii].some((radius) => Math.abs(radius - 200) < halfHeight)).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the rings alone while the grid is not drawn', async () => {
|
||||
const component = fixture.componentInstance as unknown as GridScene;
|
||||
const camera = engine.getCamera();
|
||||
await advanceFrames(engine, 0.3);
|
||||
component.display.update((display) => ({ ...display, grid: false }));
|
||||
TestBed.tick();
|
||||
await advanceFrames(engine, 0.3);
|
||||
const hidden = [...component.localGridRadii];
|
||||
|
||||
// A zoom this size crosses two round steps, and each crossing rebuilds every ring's vertices.
|
||||
camera.position.setLength(camera.position.length() / 8);
|
||||
component.controls.update();
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
expect([...component.localGridRadii]).toEqual(hidden);
|
||||
});
|
||||
|
||||
it('drops a ring label that a star name has taken, or that is off screen, and keeps the ladder otherwise', () => {
|
||||
const component = fixture.componentInstance as unknown as {
|
||||
ringLabelsInTheClear(candidates: readonly LabeledPoint[], camera: THREE.Camera, stars: readonly LabeledPoint[]): LabeledPoint[];
|
||||
};
|
||||
const camera = engine.getCamera();
|
||||
camera.updateMatrixWorld(true);
|
||||
const at = (x: number, y: number) => new THREE.Vector3(x, y, 0.5).unproject(camera);
|
||||
// Rungs at a twentieth of the screen: well inside the separation two names would keep, and
|
||||
// well outside the clearance a ring label keeps from a name, so neither test is a coin toss.
|
||||
const near = at(0.1, 0.1);
|
||||
const nextRungUp = at(0.1, 0.18);
|
||||
const offScreen = at(1.6, 0.1);
|
||||
const ladder: LabeledPoint[] = [
|
||||
{ id: 'ring-50', name: '50 pc', x: near.x, y: near.y, z: near.z },
|
||||
{ id: 'ring-100', name: '100 pc', x: nextRungUp.x, y: nextRungUp.y, z: nextRungUp.z },
|
||||
{ id: 'ring-150', name: '150 pc', x: offScreen.x, y: offScreen.y, z: offScreen.z }
|
||||
];
|
||||
|
||||
// A ladder of rings stays whole, though its rungs are closer than two star names would be.
|
||||
expect(component.ringLabelsInTheClear(ladder, camera, []).map((label) => label.id)).toEqual(['ring-50', 'ring-100']);
|
||||
// A star's name is worth more than a distance.
|
||||
const star: LabeledPoint = { id: 7, name: 'Sirius', x: near.x, y: near.y, z: near.z };
|
||||
expect(component.ringLabelsInTheClear(ladder, camera, [star]).map((label) => label.id)).toEqual(['ring-100']);
|
||||
});
|
||||
|
||||
it('stays out of the text of a name, not just off its point', () => {
|
||||
const component = fixture.componentInstance as unknown as {
|
||||
ringLabelsInTheClear(candidates: readonly LabeledPoint[], camera: THREE.Camera, stars: readonly LabeledPoint[]): LabeledPoint[];
|
||||
viewportAspect(): number;
|
||||
};
|
||||
const camera = engine.getCamera();
|
||||
camera.updateMatrixWorld(true);
|
||||
const aspect = component.viewportAspect();
|
||||
const at = (x: number, y: number) => new THREE.Vector3(x / aspect, y, 0.5).unproject(camera);
|
||||
// A hand's breadth apart on screen — past any clearance around the point — and on the same
|
||||
// line, with the name's text running right through where the ring label starts.
|
||||
const ring = at(0.125, -0.123);
|
||||
const rung: LabeledPoint = { id: 'ring-50', name: '50 pc', x: ring.x, y: ring.y, z: ring.z };
|
||||
const beside = at(0.06, -0.12);
|
||||
const rightHand: LabeledPoint = { id: 7, name: 'Alpha Centauri', side: 'right', x: beside.x, y: beside.y, z: beside.z };
|
||||
|
||||
expect(component.ringLabelsInTheClear([rung], camera, [rightHand])).toEqual([]);
|
||||
// The same name hanging the other way leaves that space empty, and the rung with it.
|
||||
expect(component.ringLabelsInTheClear([rung], camera, [{ ...rightHand, side: 'left' }])).toEqual([rung]);
|
||||
|
||||
// And a rung to the left of a name keeps its place: "50 pc" is a third of a star name's
|
||||
// width, so it ends well before the name starts, whatever the anchors' spacing suggests.
|
||||
const centred = at(0, 0);
|
||||
const spanning: LabeledPoint = { id: 8, name: 'Alnitak', side: 'right', x: centred.x, y: centred.y, z: centred.z };
|
||||
const toTheLeft = at(-0.25, 0.02);
|
||||
const clearRung: LabeledPoint = { id: 'ring-100', name: '100 pc', x: toTheLeft.x, y: toTheLeft.y, z: toTheLeft.z };
|
||||
|
||||
expect(component.ringLabelsInTheClear([clearRung], camera, [spanning])).toEqual([clearRung]);
|
||||
});
|
||||
|
||||
it('places the ring labels with the star names rather than over them', async () => {
|
||||
const component = fixture.componentInstance as unknown as GridScene;
|
||||
const update = vi.spyOn(StarLabelOverlay.prototype, 'update');
|
||||
const cleared = vi.spyOn(GalaxySystemSceneComponent.prototype as unknown as { ringLabelsInTheClear: (...args: unknown[]) => LabeledPoint[] }, 'ringLabelsInTheClear');
|
||||
const camera = engine.getCamera();
|
||||
camera.position.set(0, 4, 10);
|
||||
component.controls.target.set(0, 0, 0);
|
||||
component.controls.update();
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
const labels = (update.mock.calls.at(-1)?.[0] ?? []) as LabeledPoint[];
|
||||
const rings = labels.filter((label) => String(label.id).startsWith('ring-'));
|
||||
expect(rings.length).toBeGreaterThan(0);
|
||||
// Handed over as the clearing pass left them, not as the grid produced them.
|
||||
expect(cleared).toHaveBeenCalled();
|
||||
expect(rings).toEqual(cleared.mock.results.at(-1)?.value);
|
||||
update.mockRestore();
|
||||
cleared.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the stars of a plotted route drawn, and the selected star', async () => {
|
||||
const component = fixture.componentInstance as unknown as { routeResult: { set(value: unknown): void } };
|
||||
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }, { id: PROXIMA.id, name: 'Proxima Centauri' }], totalPc: 1.3, neededRangePc: null });
|
||||
component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }, { id: PROXIMA.id, name: 'Proxima Centauri' }], totalPc: 1.3, neededRangePc: null, gaveUp: false, least: true });
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
// As catalogue indices: the Sun is the first entry of STARS, Proxima the third.
|
||||
@@ -473,7 +618,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
expect(links.mock.calls[1][1]).not.toBe(links.mock.calls[0][1]);
|
||||
|
||||
// A route re-chooses the drawn stars around its pins, and here they come out the same: no new graph.
|
||||
component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }], totalPc: 0, neededRangePc: null });
|
||||
component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }], totalPc: 0, neededRangePc: null, gaveUp: false, least: true });
|
||||
await advanceFrames(engine, 0.3);
|
||||
await settle();
|
||||
expect(links).toHaveBeenCalledTimes(2);
|
||||
@@ -595,12 +740,12 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
});
|
||||
|
||||
it('shows the answer to the latest route asked for, whatever order the answers arrive in', async () => {
|
||||
type Answer = { route: { stars: number[]; totalPc: number; longestHopPc: number } | null; neededRangePc: number | null };
|
||||
type Answer = { route: { stars: number[]; totalPc: number; longestHopPc: number } | null; neededRangePc: number | null; gaveUp: boolean; least: boolean };
|
||||
const answers: Array<(answer: Answer) => void> = [];
|
||||
const component = fixture.componentInstance as unknown as {
|
||||
routing: { route(): Promise<Answer>; links(): Promise<Float32Array>; dispose(): void };
|
||||
routePending(): boolean;
|
||||
routeResult(): { stars: { id: number }[] } | null;
|
||||
routeResult(): { stars: { id: number }[]; gaveUp: boolean } | null;
|
||||
onRouteRequested(request: { fromId: number; toId: number; rangePc: number }): void;
|
||||
};
|
||||
component.routing = {
|
||||
@@ -613,13 +758,19 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
component.onRouteRequested({ fromId: SUN.id, toId: PROXIMA.id, rangePc: 2 });
|
||||
expect(component.routePending()).toBe(true);
|
||||
|
||||
answers[1]({ route: { stars: [SUN.id, PROXIMA.id], totalPc: 1.3, longestHopPc: 1.3 }, neededRangePc: null });
|
||||
answers[1]({ route: { stars: [SUN.id, PROXIMA.id], totalPc: 1.3, longestHopPc: 1.3 }, neededRangePc: null, gaveUp: false, least: true });
|
||||
await flushAsync();
|
||||
answers[0]({ route: { stars: [SUN.id, ALPHA_CENTAURI.id], totalPc: 1.34, longestHopPc: 1.34 }, neededRangePc: null });
|
||||
answers[0]({ route: { stars: [SUN.id, ALPHA_CENTAURI.id], totalPc: 1.34, longestHopPc: 1.34 }, neededRangePc: null, gaveUp: false, least: true });
|
||||
await flushAsync();
|
||||
|
||||
expect(component.routeResult()?.stars.map((star) => star.id)).toEqual([SUN.id, PROXIMA.id]);
|
||||
expect(component.routePending()).toBe(false);
|
||||
|
||||
// "It gave up" travels to the panel, which says something else for it than for "there is none".
|
||||
component.onRouteRequested({ fromId: SUN.id, toId: ALPHA_CENTAURI.id, rangePc: 0.5 });
|
||||
answers[2]({ route: null, neededRangePc: null, gaveUp: true, least: false });
|
||||
await flushAsync();
|
||||
expect(component.routeResult()).toMatchObject({ stars: [], gaveUp: true });
|
||||
});
|
||||
|
||||
it('releases the routes panel when a route cannot be worked out, so it can be tried again', async () => {
|
||||
|
||||
@@ -70,8 +70,21 @@ const LABEL_MAX_COUNT = 15;
|
||||
const LABEL_MIN_SEPARATION_NDC = 0.12;
|
||||
/** Beyond this the text of a right-hand label would run off the view: hang it on the left. */
|
||||
const LABEL_EDGE_NDC = 0.7;
|
||||
/**
|
||||
* How far a ring label has to sit from a star's name, in NDC — half what two star names keep
|
||||
* between them, as a clearance around the anchor and as the height of the row its text occupies.
|
||||
* A ring label is one short line, and the rungs of its ladder are a twentieth of the screen apart,
|
||||
* so the full separation would have one name clear three rungs.
|
||||
*/
|
||||
const RING_LABEL_CLEARANCE_NDC = LABEL_MIN_SEPARATION_NDC / 2;
|
||||
/** How far right of its point a label's text reaches, in aspect-scaled NDC (~135px at 1440). */
|
||||
const LABEL_REACH_NDC = 0.3;
|
||||
/**
|
||||
* The same for a ring label, which is shorter: "1.5 kpc" with "Survey edge" under it is the widest
|
||||
* of them, about 100px at 1440. Measuring those as a star name's width rejected rungs a hand's
|
||||
* breadth clear of it.
|
||||
*/
|
||||
const RING_LABEL_REACH_NDC = 0.23;
|
||||
/**
|
||||
* How long the range control has to be still before the graph is rebuilt at its value, since a drag
|
||||
* emits per pixel; and how often at most a view on the move gets a graph for its new drawn stars.
|
||||
@@ -194,10 +207,11 @@ const GALACTIC_FAR_PC = 250000;
|
||||
*/
|
||||
const SURVEY_EDGE_PC = 250;
|
||||
/**
|
||||
* The local grid's rings are distances from the Sun, at a round step that follows the camera:
|
||||
* five of them out to about the camera's own distance, so 50 to 250 pc from the opening view and
|
||||
* 2 to 10 pc from beside the Sun. A fixed set could only serve one end of the zoom: 50 pc rings
|
||||
* say nothing from inside a 2 pc hop, and nothing marked the stars now drawn past 250 pc.
|
||||
* How many rings the local grid aims for: the step is rounded down from a fifth of how far the
|
||||
* frame reaches from the Sun, which makes five to fourteen of them. So 50 to 350 pc from the
|
||||
* opening view, and 2 to 10 pc from beside the Sun. A fixed set could only serve one end of the
|
||||
* zoom: 50 pc rings say nothing from inside a 2 pc hop, and nothing marked the stars now drawn
|
||||
* past 250 pc.
|
||||
*/
|
||||
const LOCAL_GRID_RING_COUNT = 5;
|
||||
const LOCAL_GRID_SPOKES = 12;
|
||||
@@ -614,7 +628,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
centre: new THREE.Vector3(centre.x, centre.y, centre.z),
|
||||
emphasisRadii: [SUN_GALACTOCENTRIC_RADIUS_PC]
|
||||
});
|
||||
this.setLocalGridRadii(distanceRings(GALAXY_OVERVIEW_POSITION.length(), LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC));
|
||||
this.setLocalGridRadii(distanceRings(0, GALAXY_OVERVIEW_POSITION.length(), LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC));
|
||||
// A fixed set rather than whatever is currently labelled: a tether that appears and vanishes
|
||||
// as the camera drifts reads as a glitch.
|
||||
this.tethers = new TetherField(TETHERED_STAR_COUNT);
|
||||
@@ -912,11 +926,46 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ring labels worth drawing: the ones on screen, and clear of the star names already placed.
|
||||
*
|
||||
* Not held apart from each other, as the star names are: they are a ladder up one ray, a few
|
||||
* hundredths of the screen apart, and reading them in order is the point. What they must not do
|
||||
* is sit on a star's name, which is worth more than a distance — or be handed to the overlay
|
||||
* from beside the camera, which CSS2DRenderer places past the edge of the container rather than
|
||||
* hiding, since all it tests is depth.
|
||||
*
|
||||
* A name is a line of text hanging to one side of its point, about 135 px of it, not the point:
|
||||
* two anchors a tenth of the screen apart still print one inside the other. So the test is
|
||||
* against the span the name occupies, with the anchors' own clearance kept for the pair whose
|
||||
* text runs the other way.
|
||||
*/
|
||||
private ringLabelsInTheClear(candidates: readonly LabeledPoint[], camera: SceneCamera, stars: readonly LabeledPoint[]): LabeledPoint[] {
|
||||
const projected = new THREE.Vector3();
|
||||
const onScreen = (label: LabeledPoint): THREE.Vector2 | null => {
|
||||
projected.set(label.x, label.y, label.z).project(camera);
|
||||
const outside = projected.z < -1 || projected.z > 1 || Math.abs(projected.x) > 1 || Math.abs(projected.y) > 1;
|
||||
return outside ? null : new THREE.Vector2(projected.x * this.viewportAspect(), projected.y);
|
||||
};
|
||||
const taken = stars
|
||||
.map((star) => ({ at: onScreen(star), side: star.side }))
|
||||
.filter((name): name is { at: THREE.Vector2; side: LabelSide | undefined } => name.at !== null)
|
||||
.map(({ at, side }) => ({ at, from: side === 'left' ? at.x - LABEL_REACH_NDC : at.x, to: side === 'left' ? at.x : at.x + LABEL_REACH_NDC }));
|
||||
return candidates.filter((label) => {
|
||||
const point = onScreen(label);
|
||||
// Ring labels hang right, as `applyPresentation` leaves anything with no side of its own.
|
||||
return (
|
||||
point !== null &&
|
||||
!taken.some(
|
||||
(name) =>
|
||||
name.at.distanceTo(point) < RING_LABEL_CLEARANCE_NDC ||
|
||||
(Math.abs(name.at.y - point.y) < RING_LABEL_CLEARANCE_NDC && name.from < point.x + RING_LABEL_REACH_NDC && point.x < name.to)
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private updateLabels(camera: SceneCamera): void {
|
||||
const radii = distanceRings(this.effectiveDistance(camera), LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC);
|
||||
if (radii.join() !== this.localGridRadii.join()) {
|
||||
this.setLocalGridRadii(radii);
|
||||
}
|
||||
const selectedId = this.navigationStore.selectedStarId();
|
||||
// Measured from what the camera is looking at, not from where it is. Those differ by the
|
||||
// orbit distance, so a camera-relative rule names the stars closest to the near edge of the
|
||||
@@ -928,6 +977,31 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
// Individual star names mean nothing once the whole Galaxy is in frame — at that range the
|
||||
// entire catalogue is inside one pixel — so the labels hand over to the structural ones.
|
||||
const isGalactic = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD;
|
||||
// The rings are distances from the Sun, so what they have to cover is how far from the Sun the
|
||||
// frame reaches: where the view is centred, plus how far out the camera is orbiting it. Under
|
||||
// the plan view the orbit distance is the frame's own extent, since that is what the wheel
|
||||
// moves there. Read as "how far the camera is from the Sun" instead, panning away from the Sun
|
||||
// and flipping to the plan view left every ring off the frame.
|
||||
// Rebuilt only while the grid is drawn: each new set disposes the old rings and builds every
|
||||
// vertex of the new ones, and the set changes on any zoom that crosses a round step.
|
||||
if (!isGalactic && this.display().grid) {
|
||||
const orbitPc = this.engine.currentProjection === 'perspective' ? camera.position.distanceTo(target) : this.effectiveDistance(camera);
|
||||
// Half the frame's diagonal, at the depth it is centred on: how near the Sun the frame
|
||||
// reaches, as well as how far. A step sized to the far edge alone is no use to a frame that
|
||||
// does not contain the Sun — 20 pc rings for a view of a 19 pc band at 190 pc drew none of
|
||||
// them on screen, and the ladder of labels went with them.
|
||||
const frameRadiusPc = this.engine.visibleHalfHeight(orbitPc) * Math.hypot(1, this.viewportAspect());
|
||||
// Measured in the plane the rings lie in, not through it: a ring of radius r passes within
|
||||
// `|r - p|` of the view's centre, where p is how far out the centre is *along the plane*. For
|
||||
// a target above it the two differ by its height, which would put the band around a radius no
|
||||
// ring has — and `ringLabels` compares its own in-plane bearing against the innermost.
|
||||
const normal = galacticNormal();
|
||||
const inPlanePc = target.clone().addScaledVector(normal, -target.dot(normal)).length();
|
||||
const radii = distanceRings(Math.max(0, inPlanePc - frameRadiusPc), inPlanePc + orbitPc, LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC);
|
||||
if (radii.join() !== this.localGridRadii.join()) {
|
||||
this.setLocalGridRadii(radii);
|
||||
}
|
||||
}
|
||||
// Brightest first, not nearest first. Proximity was the right ranking when the catalogue was
|
||||
// a 50 pc bubble and everything in it was equally worth naming; across 250 pc it labels a
|
||||
// clump of whatever happens to be closest to the middle of the screen and never names the
|
||||
@@ -944,7 +1018,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
};
|
||||
const starLabels: LabeledPoint[] = isGalactic ? [] : this.spreadLabels(candidates(this.stars, this.starsByBrightness), camera, selectedId);
|
||||
const backdropLabels = isGalactic ? this.galacticLabels : this.deepSkyLabels;
|
||||
const ringLabels = isGalactic || !this.display().grid ? [] : this.ringLabels(camera);
|
||||
const ringLabels = isGalactic || !this.display().grid ? [] : this.ringLabelsInTheClear(this.ringLabels(camera), camera, starLabels);
|
||||
this.labelOverlay?.update([...starLabels, ...ringLabels, ...backdropLabels]);
|
||||
}
|
||||
|
||||
@@ -1528,7 +1602,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
const request = ++this.routeRequest;
|
||||
this.routePending.set(true);
|
||||
void this.routing.route(fromId, toId, rangePc, ROUTE_RANGE_CEILING_PC).then(
|
||||
({ route, neededRangePc }) => {
|
||||
({ route, neededRangePc, gaveUp, least }) => {
|
||||
if (request !== this.routeRequest) {
|
||||
return;
|
||||
}
|
||||
@@ -1536,7 +1610,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.routeResult.set({
|
||||
stars: route ? route.stars.map((id) => ({ id, name: this.starsById.get(id)?.name ?? `Star ${id}` })) : [],
|
||||
totalPc: route?.totalPc ?? 0,
|
||||
neededRangePc
|
||||
neededRangePc,
|
||||
gaveUp,
|
||||
least
|
||||
});
|
||||
this.jumpLinks?.setRoute(route?.stars ?? [], (id) => this.starsById.get(id));
|
||||
},
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('RoutingClient without a worker', () => {
|
||||
it('answers a route from the index it was given', async () => {
|
||||
const client = new RoutingClient(STARS, POSITIONS, index);
|
||||
|
||||
await expect(client.route(100, 104, 1.5, 8)).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5), neededRangePc: null });
|
||||
await expect(client.route(100, 104, 1.5, 8)).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5).route, neededRangePc: null, gaveUp: false, least: true });
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
@@ -143,8 +143,8 @@ describe('RoutingClient with a worker', () => {
|
||||
await expect(first).resolves.toHaveLength(6);
|
||||
|
||||
const routeRequest = worker.requests[1];
|
||||
worker.answer({ kind: 'route', requestId: routeRequest.requestId, route: null, neededRangePc: 4 });
|
||||
await expect(route).resolves.toEqual({ route: null, neededRangePc: 4 });
|
||||
worker.answer({ kind: 'route', requestId: routeRequest.requestId, route: null, neededRangePc: 4, gaveUp: false, least: true });
|
||||
await expect(route).resolves.toEqual({ route: null, neededRangePc: 4, gaveUp: false, least: true });
|
||||
await flush();
|
||||
|
||||
expect(worker.requests.map((request) => (request.kind === 'links' ? request.rangePc : request.kind))).toEqual([5, 'route', 8]);
|
||||
@@ -161,14 +161,14 @@ describe('RoutingClient with a worker', () => {
|
||||
const widerRange = client.route(100, 104, 2.5, 8);
|
||||
|
||||
expect(worker.requests).toHaveLength(1);
|
||||
worker.answer({ kind: 'route', requestId: worker.requests[0].requestId, route: null, neededRangePc: 4 });
|
||||
worker.answer({ kind: 'route', requestId: worker.requests[0].requestId, route: null, neededRangePc: 4, gaveUp: false, least: true });
|
||||
|
||||
expect(await again).toEqual(await once);
|
||||
await flush();
|
||||
// The same two stars at another range is another question.
|
||||
expect(worker.requests.map((request) => request.rangePc)).toEqual([1.5, 2.5]);
|
||||
worker.answer({ kind: 'route', requestId: worker.requests[1].requestId, route: null, neededRangePc: null });
|
||||
await expect(widerRange).resolves.toEqual({ route: null, neededRangePc: null });
|
||||
worker.answer({ kind: 'route', requestId: worker.requests[1].requestId, route: null, neededRangePc: null, gaveUp: false, least: true });
|
||||
await expect(widerRange).resolves.toEqual({ route: null, neededRangePc: null, gaveUp: false, least: true });
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
@@ -218,6 +218,16 @@ describe('RoutingClient with a worker', () => {
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
it('hands on that the search gave up, along with the answer it did give', async () => {
|
||||
const { client, worker } = clientWithFake();
|
||||
const answer = client.route(100, 105, 1.5, 8);
|
||||
|
||||
worker.answer({ kind: 'route', requestId: worker.requests[0].requestId, route: null, neededRangePc: null, gaveUp: true, least: false });
|
||||
|
||||
await expect(answer).resolves.toEqual({ route: null, neededRangePc: null, gaveUp: true, least: false });
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
it('rejects a request the worker failed on, and goes on to the next', async () => {
|
||||
const { client, worker } = clientWithFake();
|
||||
const failing = client.route(100, 104, 1.5, 8).catch((error: unknown) => error);
|
||||
@@ -240,7 +250,7 @@ describe('RoutingClient with a worker', () => {
|
||||
|
||||
worker.fail();
|
||||
|
||||
await expect(route).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5), neededRangePc: null });
|
||||
await expect(route).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5).route, neededRangePc: null, gaveUp: false, least: true });
|
||||
expect(Array.from(await graph)).toEqual([0, 0, 0, 1, 0, 0]);
|
||||
await expect(client.route(100, 105, 1.5, 8)).resolves.toMatchObject({ route: null });
|
||||
expect(worker.terminated).toBe(true);
|
||||
|
||||
@@ -6,6 +6,10 @@ import { StarRecord } from '../../shared/models/star.model';
|
||||
export interface RouteAnswer {
|
||||
readonly route: Route | null;
|
||||
readonly neededRangePc: number | null;
|
||||
/** True when the search at the range asked for gave up rather than ruling a route out. */
|
||||
readonly gaveUp: boolean;
|
||||
/** True when the search for a range that would work looked everywhere up to the ceiling. */
|
||||
readonly least: boolean;
|
||||
}
|
||||
|
||||
/** A request dropped before it was sent, because a newer one of the same kind replaced it. */
|
||||
@@ -104,7 +108,9 @@ export class RoutingClient {
|
||||
|
||||
route(fromId: number, toId: number, rangePc: number, ceilingPc: number): Promise<RouteAnswer> {
|
||||
return this.ask({ kind: 'route', requestId: this.nextRequestId++, fromId, toId, rangePc, ceilingPc }).then((response) =>
|
||||
response.kind === 'route' ? { route: response.route, neededRangePc: response.neededRangePc } : { route: null, neededRangePc: null }
|
||||
response.kind === 'route'
|
||||
? { route: response.route, neededRangePc: response.neededRangePc, gaveUp: response.gaveUp, least: response.least }
|
||||
: { route: null, neededRangePc: null, gaveUp: false, least: false }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -289,7 +289,7 @@ describe('HudDockComponent', () => {
|
||||
|
||||
// The offer beside a refusal is the same request by another route, so it is held to the same
|
||||
// test: moving the range with nothing to plot leaves the panel contradicting itself.
|
||||
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: 1.8, gaveUp: false });
|
||||
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: 1.8, gaveUp: false, least: true });
|
||||
fixture.detectChanges();
|
||||
const offer = () => host().querySelector<HTMLButtonElement>('[data-testid="route-summary"] button')!;
|
||||
expect(offer().disabled).toBe(false);
|
||||
@@ -312,6 +312,33 @@ describe('HudDockComponent', () => {
|
||||
expect(host().querySelector('#dock-panel-display')?.classList.contains('hud-acquire')).toBe(true);
|
||||
});
|
||||
|
||||
it('says the search gave up rather than that there is no route, when that is what happened', () => {
|
||||
fixture.componentRef.setInput('routing', true);
|
||||
fixture.componentRef.setInput('defaultTab', 'routes');
|
||||
const summary = () => host().querySelector('[data-testid="route-summary"]')?.textContent?.replace(/\s+/g, ' ').trim() ?? '';
|
||||
|
||||
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: null, gaveUp: true, least: false });
|
||||
fixture.detectChanges();
|
||||
expect(summary()).toBe('Too many stars to search at this range.');
|
||||
|
||||
// Having looked everywhere the range reaches is a different answer, and one that can be stated.
|
||||
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: null, gaveUp: false, least: true });
|
||||
fixture.detectChanges();
|
||||
expect(summary()).toContain('No chain of jumps up to');
|
||||
|
||||
// A range a chain was found at is worth offering — but the search that gave up at the range
|
||||
// asked for still gave up, and saying "no route" beside the offer is saying it did not.
|
||||
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: 6.4, gaveUp: true, least: false });
|
||||
fixture.detectChanges();
|
||||
expect(summary()).toBe('Too many stars to search at this range. 6.40 pc would reach.');
|
||||
|
||||
// The other way round: the range asked for was searched to exhaustion and the wider search was
|
||||
// the one that gave up. There is no route at this range, and nothing further can be claimed.
|
||||
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: null, gaveUp: false, least: false });
|
||||
fixture.detectChanges();
|
||||
expect(summary()).toBe('No route at this range.');
|
||||
});
|
||||
|
||||
it('keeps what the Routes panel was set to across a trip to another tab', () => {
|
||||
setReadout();
|
||||
fixture.componentRef.setInput('routing', true);
|
||||
|
||||
@@ -20,8 +20,12 @@ export interface RouteResult {
|
||||
/** The chain, departure first. Empty when there is no route at the range asked for. */
|
||||
readonly stars: readonly { id: number; name: string }[];
|
||||
readonly totalPc: number;
|
||||
/** The shortest range that would open a route, where none was found at the one asked for. */
|
||||
/** A range that would open a route, where none was found at the one asked for. */
|
||||
readonly neededRangePc: number | null;
|
||||
/** True when the search gave up rather than showing there is no route at this range. */
|
||||
readonly gaveUp: boolean;
|
||||
/** True when the search for a range that would work looked everywhere up to the widest offered. */
|
||||
readonly least: boolean;
|
||||
}
|
||||
|
||||
export interface RouteRequest {
|
||||
@@ -113,7 +117,11 @@ type Field = 'from' | 'to';
|
||||
</p>
|
||||
} @else {
|
||||
<p data-testid="route-summary" class="text-sm text-muted">
|
||||
No route at this range.
|
||||
@if (plotted.gaveUp) {
|
||||
Too many stars to search at this range.
|
||||
} @else {
|
||||
No route at this range.
|
||||
}
|
||||
@if (plotted.neededRangePc !== null) {
|
||||
<button
|
||||
type="button"
|
||||
@@ -123,7 +131,7 @@ type Field = 'from' | 'to';
|
||||
>
|
||||
{{ format(plotted.neededRangePc) }} would reach.
|
||||
</button>
|
||||
} @else {
|
||||
} @else if (plotted.least) {
|
||||
No chain of jumps up to {{ format(maxRangePc) }} reaches it.
|
||||
}
|
||||
</p>
|
||||
|
||||
@@ -14,7 +14,7 @@ function index(points: StarPoint[]): StarNeighbourhood {
|
||||
|
||||
describe('routeBetween', () => {
|
||||
it('walks the chain a hop at a time when that is all the range allows', () => {
|
||||
const route = routeBetween(chain(5), 0, 4, 1.5);
|
||||
const { route } = routeBetween(chain(5), 0, 4, 1.5);
|
||||
|
||||
expect(route?.stars).toEqual([0, 1, 2, 3, 4]);
|
||||
expect(route?.totalPc).toBeCloseTo(4);
|
||||
@@ -24,7 +24,7 @@ describe('routeBetween', () => {
|
||||
it('goes straight there when the range reaches, however many stars lie between', () => {
|
||||
// The direct crossing is never longer than a chain through anything — Euclid says so — so a
|
||||
// range that covers it makes it the answer, and the stars in between are just scenery.
|
||||
const route = routeBetween(chain(5), 0, 4, 5);
|
||||
const { route } = routeBetween(chain(5), 0, 4, 5);
|
||||
|
||||
expect(route?.stars).toEqual([0, 4]);
|
||||
expect(route?.totalPc).toBeCloseTo(4);
|
||||
@@ -33,7 +33,7 @@ describe('routeBetween', () => {
|
||||
it('picks the shorter of two ways round when neither is a straight line', () => {
|
||||
// 0 to 3 is 10 pc, out of a 6 pc range. Two ways round, both inside it: through 1, barely
|
||||
// off the line, or through 2, well off it. Shorter is what "the way there" means.
|
||||
const route = routeBetween(
|
||||
const { route } = routeBetween(
|
||||
index([
|
||||
{ id: 0, x: 0, y: 0, z: 0 },
|
||||
{ id: 1, x: 5, y: 0.5, z: 0 },
|
||||
@@ -56,19 +56,19 @@ describe('routeBetween', () => {
|
||||
{ id: 2, x: 20, y: 0, z: 0 }
|
||||
]);
|
||||
|
||||
expect(routeBetween(split, 0, 2, 5)).toBeNull();
|
||||
expect(routeBetween(split, 0, 2, 5)).toEqual({ route: null, gaveUp: false });
|
||||
});
|
||||
|
||||
it('answers nothing for a star that is not there, or for going nowhere', () => {
|
||||
const line = chain(3);
|
||||
|
||||
expect(routeBetween(line, 0, 0, 2)).toBeNull();
|
||||
expect(routeBetween(line, 0, 99, 2)).toBeNull();
|
||||
expect(routeBetween(line, 0, 2, 0)).toBeNull();
|
||||
expect(routeBetween(line, 0, 0, 2).route).toBeNull();
|
||||
expect(routeBetween(line, 0, 99, 2).route).toBeNull();
|
||||
expect(routeBetween(line, 0, 2, 0).route).toBeNull();
|
||||
});
|
||||
|
||||
it('reports the longest hop, which is what the range has to cover', () => {
|
||||
const route = routeBetween(
|
||||
const { route } = routeBetween(
|
||||
index([
|
||||
{ id: 0, x: 0, y: 0, z: 0 },
|
||||
{ id: 1, x: 1, y: 0, z: 0 },
|
||||
@@ -82,11 +82,32 @@ describe('routeBetween', () => {
|
||||
expect(route?.longestHopPc).toBeCloseTo(4);
|
||||
});
|
||||
|
||||
it('says it gave up rather than that there is no chain, once it has spent its budget', { timeout: 30_000 }, () => {
|
||||
// Nothing reaches the island, but the crowd around the departure is larger than the budget, so
|
||||
// the search stops without having looked everywhere the range reaches. Read as "no chain", that
|
||||
// is a confident wrong answer — and the range search downstream would build on it.
|
||||
// Cells sized for the range asked of them, as the real catalogue's are: a search that settles
|
||||
// 40 000 stars scans every cell it touches 40 000 times.
|
||||
const search = routeBetween(knotAndChain(1.5), 0, ISLAND, 1.5);
|
||||
|
||||
expect(search.route).toBeNull();
|
||||
expect(search.gaveUp).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a dead end proved with the last star of the budget as a dead end, not a give-up', () => {
|
||||
// Exactly the budget's worth of stars reach each other, and the destination is not among them.
|
||||
// The search does look everywhere the range reaches, so "no chain" is what it found — but the
|
||||
// set is full at the end of it, and a budget read off the settled count says it gave up.
|
||||
const search = routeBetween(budgetExactly(), 0, BUDGET_ISLAND, 1.5);
|
||||
|
||||
expect(search).toEqual({ route: null, gaveUp: false });
|
||||
});
|
||||
|
||||
it('heads for the destination rather than exhausting a dense knot around the departure', () => {
|
||||
// The Gaia catalogue in miniature: a crowd around the departure, larger than the search's
|
||||
// budget, with the only way on a thin chain leading out of it. A search widening evenly from
|
||||
// the departure spends the budget on the crowd and never reaches the chain's far end.
|
||||
const route = routeBetween(knotAndChain(), 0, CHAIN_END, 1.5);
|
||||
const { route } = routeBetween(knotAndChain(), 0, CHAIN_END, 1.5);
|
||||
|
||||
expect(route).not.toBeNull();
|
||||
expect(route!.stars[route!.stars.length - 1]).toBe(CHAIN_END);
|
||||
@@ -95,24 +116,73 @@ describe('routeBetween', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* 21 000 stars scattered through the 30 pc cube around the origin, about ten times the density
|
||||
* around the real Sun and more than a search's budget, with a chain a parsec a hop running along
|
||||
* x from the origin out through the crowd and on to 75 pc.
|
||||
* 45 000 stars scattered through the 30 pc cube around the origin, twenty times the density around
|
||||
* the real Sun and more than a search's budget, with a chain a parsec a hop running along x from
|
||||
* the origin out through the crowd and on to 75 pc — and one star at 500 pc that nothing reaches.
|
||||
*/
|
||||
const CHAIN_END = 75;
|
||||
function knotAndChain(): StarNeighbourhood {
|
||||
const ISLAND = 999;
|
||||
function knotAndChain(cellSizePc?: number): StarNeighbourhood {
|
||||
let seed = 7;
|
||||
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 30 - 15;
|
||||
const knot: StarPoint[] = Array.from({ length: 21000 }, (_, i) => ({ id: 1000 + i, x: random(), y: random(), z: random() }));
|
||||
const knot: StarPoint[] = Array.from({ length: 45000 }, (_, i) => ({ id: 1000 + i, x: random(), y: random(), z: random() }));
|
||||
const chainOut: StarPoint[] = Array.from({ length: CHAIN_END }, (_, i) => ({ id: i + 1, x: i + 1, y: 0, z: 0 }));
|
||||
return index([{ id: 0, x: 0, y: 0, z: 0 }, ...knot, ...chainOut]);
|
||||
return new StarNeighbourhood([{ id: 0, x: 0, y: 0, z: 0 }, ...knot, ...chainOut, { id: ISLAND, x: 500, y: 0, z: 0 }], cellSizePc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exactly a search's budget of stars that reach one another — 40 000 a parsec apart along x, which
|
||||
* a 1.5 pc range walks end to end — and one 500 pc off that line, which nothing reaches. The dead
|
||||
* end is real and the search proves it, with the last star it is allowed.
|
||||
*
|
||||
* A line rather than a crowd because the count has to be exact: a random cloud dense enough to
|
||||
* connect leaves clumps the departure never reaches, and 39 662 of 40 000 settled is a budget that
|
||||
* was never spent.
|
||||
*/
|
||||
const BUDGET_ISLAND = 99_999;
|
||||
function budgetExactly(): StarNeighbourhood {
|
||||
const line: StarPoint[] = Array.from({ length: 40_000 }, (_, i) => ({ id: i, x: i, y: 0, z: 0 }));
|
||||
return new StarNeighbourhood([...line, { id: BUDGET_ISLAND, x: 0, y: 500, z: 0 }], 1.5);
|
||||
}
|
||||
|
||||
/**
|
||||
* 45 000 stars in a 10 pc cube — dense enough to stay one connected piece at half a parsec, where
|
||||
* walking it costs more than a search's budget — with a chain a parsec a hop leaving its edge for
|
||||
* 30 pc. Its cells are sized for the ranges asked of it, as the real catalogue's are for its own.
|
||||
*/
|
||||
const CROWD_CHAIN_END = 25;
|
||||
const CROWD_ISLAND = 999999;
|
||||
function crowdedKnot(): StarNeighbourhood {
|
||||
let seed = 11;
|
||||
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 10 - 5;
|
||||
const knot: StarPoint[] = Array.from({ length: 45000 }, (_, i) => ({ id: 1000 + i, x: random(), y: random(), z: random() }));
|
||||
const chainOut: StarPoint[] = Array.from({ length: CROWD_CHAIN_END }, (_, i) => ({ id: i + 1, x: 5 + i + 1, y: 0, z: 0 }));
|
||||
// One star nothing reaches, for the questions that have no answer.
|
||||
return new StarNeighbourhood([{ id: 0, x: 0, y: 0, z: 0 }, ...knot, ...chainOut, { id: CROWD_ISLAND, x: 500, y: 0, z: 0 }], 0.25);
|
||||
}
|
||||
|
||||
describe('minimumRangeBetween', () => {
|
||||
it('works out the range past a dense knot around the departure', () => {
|
||||
// Past the crowd the chain's hops of a parsec are the only way on, so a parsec is the
|
||||
// answer, to the half-step the panel rounds up to.
|
||||
expect(minimumRangeBetween(knotAndChain(), 0, CHAIN_END, 8)).toBeCloseTo(1, 1);
|
||||
expect(minimumRangeBetween(knotAndChain(), 0, CHAIN_END, 8).rangePc).toBeCloseTo(1, 1);
|
||||
});
|
||||
|
||||
it('stops bisecting where a search gave up, and hands back a range that does work', { timeout: 30_000 }, () => {
|
||||
// Below the chain's own hop of a parsec, the crowd is still one connected piece and larger than
|
||||
// the budget, so those probes give up. Reading a give-up as "no chain at this range" is what
|
||||
// used to report ranges up to 29% wider than needed, and went on paying for probes whose
|
||||
// answers it could not use; the answer now is the narrowest range a chain was found at.
|
||||
const knot = crowdedKnot();
|
||||
|
||||
const needed = minimumRangeBetween(knot, 0, CROWD_CHAIN_END, 1.2);
|
||||
|
||||
expect(needed.least).toBe(false);
|
||||
expect(needed.rangePc).not.toBeNull();
|
||||
expect(routeBetween(knot, 0, CROWD_CHAIN_END, needed.rangePc!).route).not.toBeNull();
|
||||
// Narrower than the ceiling's own route, too: stopping before the bisection has found a range
|
||||
// of its own hands back the ceiling, which is the control's maximum — the question, not an answer.
|
||||
expect(needed.rangePc!).toBeLessThan(routeBetween(knot, 0, CROWD_CHAIN_END, 1.2).route!.longestHopPc);
|
||||
});
|
||||
|
||||
it('names the shortest range that opens a way through', () => {
|
||||
@@ -123,9 +193,9 @@ describe('minimumRangeBetween', () => {
|
||||
{ id: 2, x: 5, y: 0, z: 0 }
|
||||
]);
|
||||
|
||||
expect(minimumRangeBetween(stepped, 0, 2, 50)).toBeCloseTo(4);
|
||||
expect(routeBetween(stepped, 0, 2, 4)).not.toBeNull();
|
||||
expect(routeBetween(stepped, 0, 2, 3.99)).toBeNull();
|
||||
expect(minimumRangeBetween(stepped, 0, 2, 50)).toEqual({ rangePc: expect.closeTo(4) as number, least: true });
|
||||
expect(routeBetween(stepped, 0, 2, 4).route).not.toBeNull();
|
||||
expect(routeBetween(stepped, 0, 2, 3.99).route).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers a longer way whose worst hop is shorter, since that is what the range pays for', () => {
|
||||
@@ -139,8 +209,16 @@ describe('minimumRangeBetween', () => {
|
||||
|
||||
const needed = minimumRangeBetween(both, 0, 3, 50);
|
||||
|
||||
expect(needed).toBeLessThan(10);
|
||||
expect(routeBetween(both, 0, 3, needed!)).not.toBeNull();
|
||||
expect(needed.rangePc).toBeLessThan(10);
|
||||
expect(routeBetween(both, 0, 3, needed.rangePc!).route).not.toBeNull();
|
||||
});
|
||||
|
||||
it('claims nothing about a ceiling its own search gave up on', () => {
|
||||
// Nothing reaches the island at any range here, but the crowd spends the budget first, so the
|
||||
// widest search proves nothing — and neither does the null it hands back.
|
||||
const needed = minimumRangeBetween(crowdedKnot(), 0, CROWD_ISLAND, 0.5);
|
||||
|
||||
expect(needed).toEqual({ rangePc: null, least: false });
|
||||
});
|
||||
|
||||
it('finds nothing when even the ceiling does not reach', () => {
|
||||
@@ -149,7 +227,7 @@ describe('minimumRangeBetween', () => {
|
||||
{ id: 1, x: 100, y: 0, z: 0 }
|
||||
]);
|
||||
|
||||
expect(minimumRangeBetween(split, 0, 1, 50)).toBeNull();
|
||||
expect(minimumRangeBetween(split, 0, 1, 50)).toEqual({ rangePc: null, least: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -296,7 +374,7 @@ describe('jumpLinkSegments', () => {
|
||||
|
||||
const drawn = new Set(linksDrawn(jumpLinkSegments(cloud, range), points));
|
||||
|
||||
const route = routeBetween(cloud, 0, 119, range);
|
||||
const { route } = routeBetween(cloud, 0, 119, range);
|
||||
// Asserted, not guarded: a skipped body would let the two disagree unnoticed.
|
||||
expect(route).not.toBeNull();
|
||||
expect(route!.stars.length).toBeGreaterThan(2);
|
||||
|
||||
@@ -16,6 +16,21 @@
|
||||
|
||||
import { StarNeighbourhood } from './star-neighbourhood';
|
||||
|
||||
/** What a search found, and whether it looked everywhere the range reaches before answering. */
|
||||
export interface RouteSearch {
|
||||
readonly route: Route | null;
|
||||
/** True when the search spent its budget: "no route" then means "gave up", not "there is none". */
|
||||
readonly gaveUp: boolean;
|
||||
}
|
||||
|
||||
/** A range that opens a route, and whether anything shorter was actually ruled out. */
|
||||
export interface RangeSearch {
|
||||
/** A range a chain was found at, or `null` where none was found up to the ceiling. */
|
||||
readonly rangePc: number | null;
|
||||
/** True when every shorter range was searched to exhaustion, so this is the least that works. */
|
||||
readonly least: boolean;
|
||||
}
|
||||
|
||||
/** A chain of stars from one to another, each hop within the range that was asked for. */
|
||||
export interface Route {
|
||||
/** Star ids, departure first and destination last. One hop is two ids. */
|
||||
@@ -30,11 +45,17 @@ export interface Route {
|
||||
}
|
||||
|
||||
/**
|
||||
* A cap on how much of the catalogue one search may walk. A search that hits it has already
|
||||
* visited more stars than any real chain passes through: the longest measured, Sol to HD 2626 at
|
||||
* 236 pc in jumps of 8 pc, settles about 7 000.
|
||||
* A cap on how much of the catalogue one search may walk, so a hopeless question cannot run for
|
||||
* ever. It is a budget, not a verdict: a search that spends it has proved nothing, and says so
|
||||
* through {@link RouteSearch.gaveUp}.
|
||||
*
|
||||
* Sized against the catalogue actually shipped rather than against the longest route. At 20 000 a
|
||||
* search from the Sun to HD 120147 (136 pc) in jumps of 5 pc gave up, though the chain it wanted,
|
||||
* 50 jumps, is there to be found; a star at 170 pc needed 58. Both are found at this budget. The
|
||||
* cost is paid by questions with no answer, which walk the whole of it: from the Sun to the
|
||||
* farthest star at 8 pc, 0.7 s at 20 000 against 2.1 s here, in the worker.
|
||||
*/
|
||||
const MAX_VISITED = 20000;
|
||||
const MAX_VISITED = 40000;
|
||||
|
||||
/**
|
||||
* How close to the true minimum `minimumRangeBetween` works a range out: half the Routes panel's
|
||||
@@ -43,6 +64,23 @@ const MAX_VISITED = 20000;
|
||||
*/
|
||||
const RANGE_RESOLUTION_PC = 0.05;
|
||||
|
||||
/**
|
||||
* How many of `minimumRangeBetween`'s probes may give up, once it has a range of its own, before it
|
||||
* answers with what it has — and how many before it has one.
|
||||
*
|
||||
* A probe that finds a route is quick — it heads straight for the destination — while one that
|
||||
* gives up walks the whole search budget, about two seconds on the real catalogue. Those are also
|
||||
* the probes that buy the least: they cannot rule anything out. Two of them is the difference
|
||||
* between an answer of 7.96 pc in half a second and 5.76 pc in seventeen, for a star at 236 pc; it
|
||||
* lands on 5.97 pc in five.
|
||||
*
|
||||
* Until a probe succeeds there is nothing to answer with but the ceiling route's own longest hop,
|
||||
* which is the control's maximum, so the bound is looser there — but a bound, since the search is
|
||||
* one the panel waits on: five probes, ten seconds, rather than the resolution's own eight.
|
||||
*/
|
||||
const MAX_RANGE_GIVE_UPS = 2;
|
||||
const MAX_UNEARNED_GIVE_UPS = 5;
|
||||
|
||||
/** A binary min-heap of star ids by priority. Duplicates are allowed; stale ones are skipped on the way out. */
|
||||
class Frontier {
|
||||
private readonly ids: number[] = [];
|
||||
@@ -113,8 +151,8 @@ function rebuild(cameFrom: Map<number, number>, fromId: number, toId: number): n
|
||||
}
|
||||
|
||||
/**
|
||||
* The shortest chain from one star to another in which no single hop exceeds `rangePc`, or
|
||||
* `null` where the catalogue holds no such chain.
|
||||
* The shortest chain from one star to another in which no single hop exceeds `rangePc`, or no
|
||||
* chain where the catalogue holds none within the search's budget.
|
||||
*
|
||||
* Shortest by total distance travelled rather than by number of hops: two chains of the same
|
||||
* length are not equally good, and the one that covers less ground is the one a reader means by
|
||||
@@ -126,12 +164,15 @@ function rebuild(cameFrom: Map<number, number>, fromId: number, toId: number): n
|
||||
* than widening evenly in every direction. Widening evenly is what the Gaia catalogue broke. From
|
||||
* the Sun it spent its whole budget on the 20 000 stars nearest, all inside about 40 pc, and so
|
||||
* found no route to anything farther at any range; Mirfak, 155 pc out, is 27 jumps at 8 pc.
|
||||
*
|
||||
* "No route" and "no chain" are not the same answer: a search that spends {@link MAX_VISITED}
|
||||
* reports that it gave up, so nothing downstream reads it as proof that no chain exists.
|
||||
*/
|
||||
export function routeBetween(index: StarNeighbourhood, fromId: number, toId: number, rangePc: number): Route | null {
|
||||
export function routeBetween(index: StarNeighbourhood, fromId: number, toId: number, rangePc: number): RouteSearch {
|
||||
const origin = index.point(fromId);
|
||||
const destination = index.point(toId);
|
||||
if (fromId === toId || rangePc <= 0 || !origin || !destination) {
|
||||
return null;
|
||||
return { route: null, gaveUp: false };
|
||||
}
|
||||
const straightLineOn = (x: number, y: number, z: number) => Math.hypot(destination.x - x, destination.y - y, destination.z - z);
|
||||
|
||||
@@ -145,24 +186,31 @@ export function routeBetween(index: StarNeighbourhood, fromId: number, toId: num
|
||||
const frontier = new Frontier();
|
||||
frontier.push(fromId, straightLineOn(origin.x, origin.y, origin.z));
|
||||
|
||||
while (frontier.size > 0 && settled.size < MAX_VISITED) {
|
||||
let gaveUp = false;
|
||||
while (frontier.size > 0) {
|
||||
const starId = frontier.pop();
|
||||
if (settled.has(starId)) {
|
||||
continue;
|
||||
}
|
||||
// Counted against the budget only once the frontier has been drained of stale duplicates, so
|
||||
// the flag below records why the search stopped rather than how full the set happened to be.
|
||||
if (settled.size >= MAX_VISITED) {
|
||||
gaveUp = true;
|
||||
break;
|
||||
}
|
||||
settled.add(starId);
|
||||
const costHere = travelled.get(starId)!;
|
||||
|
||||
if (starId === toId) {
|
||||
const stars = rebuild(cameFrom, fromId, toId);
|
||||
if (stars.length === 0) {
|
||||
return null;
|
||||
return { route: null, gaveUp: false };
|
||||
}
|
||||
let longestHopPc = 0;
|
||||
for (let i = 1; i < stars.length; i++) {
|
||||
longestHopPc = Math.max(longestHopPc, hopTo.get(stars[i])!);
|
||||
}
|
||||
return { stars, totalPc: costHere, longestHopPc };
|
||||
return { route: { stars, totalPc: costHere, longestHopPc }, gaveUp: false };
|
||||
}
|
||||
|
||||
index.forEachWithin(starId, rangePc, (neighbour, distancePc) => {
|
||||
@@ -179,38 +227,60 @@ export function routeBetween(index: StarNeighbourhood, fromId: number, toId: num
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
// An empty frontier means the range reaches nothing further; a spent budget means only that the
|
||||
// search stopped looking.
|
||||
return { route: null, gaveUp };
|
||||
}
|
||||
|
||||
/**
|
||||
* The shortest range at which any chain at all exists between two stars, to within
|
||||
* `RANGE_RESOLUTION_PC`, or `null` if none does within `ceilingPc`.
|
||||
* A range at which a chain exists between two stars — the shortest, to within
|
||||
* `RANGE_RESOLUTION_PC`, where every shorter range could be ruled out — or `null` where no chain
|
||||
* was found up to `ceilingPc`.
|
||||
*
|
||||
* This is what turns "no route" from a dead end into an answer: the range control can be told
|
||||
* what it would have to be raised to. The exact figure is the minimax path, the chain whose
|
||||
* longest hop is as short as possible. It used to be searched for directly, widening from the
|
||||
* departure in order of the worst hop needed, which from the Sun meant exhausting the whole dense
|
||||
* core before anything farther could be reached: it gave up with nothing after up to a minute.
|
||||
* Whether a chain exists can only become truer as the range grows, so the range is bisected
|
||||
* instead, each step one directed `routeBetween`.
|
||||
* This is what turns "no route" from a dead end into an answer: the range control can be told what
|
||||
* it would have to be raised to. The figure aimed at is the minimax path, the chain whose longest
|
||||
* hop is as short as possible. It used to be searched for directly, widening from the departure in
|
||||
* order of the worst hop needed, which from the Sun meant exhausting the whole dense core before
|
||||
* anything farther could be reached: it gave up with nothing after up to a minute. Whether a chain
|
||||
* exists can only become truer as the range grows, so the range is bisected instead, each step one
|
||||
* directed `routeBetween`.
|
||||
*
|
||||
* Each step has to answer "is there a chain at this range", and a search that gives up answers
|
||||
* nothing. It is still worth carrying on from — the ranges above it are the ones left to try — but
|
||||
* the result is no longer the least range, only a range that works, and `least` says which. The
|
||||
* number of steps that may give up is bounded for the same reason: each one walks the whole budget,
|
||||
* and 11 s of them for a star at 236 pc bought two decimal places nobody reads. Bounded more
|
||||
* loosely before the bisection has found a range of its own, since until then the only range it
|
||||
* could offer is the ceiling's, which is the control's maximum, for crossings that work well below
|
||||
* it. See {@link MAX_RANGE_GIVE_UPS}.
|
||||
*/
|
||||
export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, toId: number, ceilingPc: number): number | null {
|
||||
export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, toId: number, ceilingPc: number): RangeSearch {
|
||||
const widest = routeBetween(index, fromId, toId, ceilingPc);
|
||||
if (!widest) {
|
||||
return null;
|
||||
if (!widest.route) {
|
||||
return { rangePc: null, least: !widest.gaveUp };
|
||||
}
|
||||
const ceilingHopPc = widest.route.longestHopPc;
|
||||
let unreachable = 0;
|
||||
let reachable = widest.longestHopPc;
|
||||
while (reachable - unreachable > RANGE_RESOLUTION_PC) {
|
||||
let reachable = ceilingHopPc;
|
||||
let giveUps = 0;
|
||||
// While `reachable` is still the ceiling route's own longest hop the bisection has nothing of its
|
||||
// own to answer with, and that figure sends the control to its maximum for a crossing that works
|
||||
// well below — 8.00 pc for a star that routes at 6. So it is allowed more probes there, not
|
||||
// unlimited ones: the panel is waiting on this.
|
||||
while (reachable - unreachable > RANGE_RESOLUTION_PC && giveUps < (reachable === ceilingHopPc ? MAX_UNEARNED_GIVE_UPS : MAX_RANGE_GIVE_UPS)) {
|
||||
const range = (unreachable + reachable) / 2;
|
||||
const route = routeBetween(index, fromId, toId, range);
|
||||
const { route, gaveUp } = routeBetween(index, fromId, toId, range);
|
||||
if (route) {
|
||||
reachable = route.longestHopPc;
|
||||
} else {
|
||||
// A search that gave up is worth going on from — the ranges above it are the ones left to
|
||||
// try — but it is not evidence that nothing routes here, so the answer stops being the least.
|
||||
unreachable = range;
|
||||
giveUps += gaveUp ? 1 : 0;
|
||||
}
|
||||
}
|
||||
return reachable;
|
||||
// Without a give-up the loop can only have ended by closing on the resolution, so that is the least.
|
||||
return { rangePc: reachable, least: giveUps === 0 };
|
||||
}
|
||||
|
||||
/** How much of a graph to keep: the links nearest a point, up to a total length. */
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { jumpLinkSegments, minimumRangeBetween, routeBetween } from './jump-links';
|
||||
import { answerRouting, indexCatalogue } from './routing';
|
||||
import { StarNeighbourhood } from './star-neighbourhood';
|
||||
import { StarNeighbourhood, StarPoint } from './star-neighbourhood';
|
||||
|
||||
/** Stars a parsec apart along x, then a gap of 5 pc to one more. */
|
||||
const POINTS = [...Array.from({ length: 5 }, (_, i) => ({ id: 10 + i, x: i, y: 0, z: 0 })), { id: 99, x: 9, y: 0, z: 0 }];
|
||||
@@ -25,6 +25,16 @@ describe('indexCatalogue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** The same index, counting the neighbour queries a search makes through it. */
|
||||
class CountingNeighbourhood extends StarNeighbourhood {
|
||||
queries = 0;
|
||||
|
||||
override forEachWithin(id: number, radiusPc: number, visit: (neighbour: StarPoint, distancePc: number) => void): void {
|
||||
this.queries++;
|
||||
super.forEachWithin(id, radiusPc, visit);
|
||||
}
|
||||
}
|
||||
|
||||
describe('answerRouting', () => {
|
||||
const index = indexCatalogue(catalogue());
|
||||
const direct = new StarNeighbourhood(POINTS);
|
||||
@@ -32,16 +42,43 @@ describe('answerRouting', () => {
|
||||
it('answers a route the range allows, with nothing to raise it to', () => {
|
||||
const answer = answerRouting(index, { kind: 'route', requestId: 7, fromId: 10, toId: 14, rangePc: 1.5, ceilingPc: 8 });
|
||||
|
||||
expect(answer).toEqual({ kind: 'route', requestId: 7, route: routeBetween(direct, 10, 14, 1.5), neededRangePc: null });
|
||||
expect(answer).toEqual({ kind: 'route', requestId: 7, route: routeBetween(direct, 10, 14, 1.5).route, neededRangePc: null, gaveUp: false, least: true });
|
||||
});
|
||||
|
||||
it('answers a route the range does not allow with the range that would', () => {
|
||||
const answer = answerRouting(index, { kind: 'route', requestId: 8, fromId: 10, toId: 99, rangePc: 1.5, ceilingPc: 8 });
|
||||
|
||||
expect(answer).toEqual({ kind: 'route', requestId: 8, route: null, neededRangePc: minimumRangeBetween(direct, 10, 99, 8) });
|
||||
expect(answer).toEqual({ kind: 'route', requestId: 8, route: null, neededRangePc: minimumRangeBetween(direct, 10, 99, 8).rangePc, gaveUp: false, least: true });
|
||||
expect(answer.kind === 'route' && answer.neededRangePc).toBeCloseTo(5, 1);
|
||||
});
|
||||
|
||||
it('passes on that the search gave up, rather than reporting no route', () => {
|
||||
// A crowd larger than a search's budget around the departure, and a destination nothing reaches:
|
||||
// the answer is "it gave up", and the scene has to be able to tell that from "there is none".
|
||||
let seed = 5;
|
||||
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 10 - 5;
|
||||
const crowd: StarPoint[] = Array.from({ length: 45000 }, (_, i) => ({ id: 1000 + i, x: random(), y: random(), z: random() }));
|
||||
const knot = new StarNeighbourhood([{ id: 0, x: 0, y: 0, z: 0 }, ...crowd, { id: 99, x: 500, y: 0, z: 0 }], 0.5);
|
||||
|
||||
const answer = answerRouting(knot, { kind: 'route', requestId: 12, fromId: 0, toId: 99, rangePc: 0.5, ceilingPc: 0.5 });
|
||||
|
||||
expect(answer).toMatchObject({ route: null, neededRangePc: null, gaveUp: true });
|
||||
});
|
||||
|
||||
it('asks the ceiling its question once, rather than searching it again to answer it', () => {
|
||||
// At the panel's widest range the refused route and the range search are the same question, run
|
||||
// with the same arguments over the same index: the second pays the whole budget for the answer
|
||||
// the first already gave.
|
||||
const counting = new CountingNeighbourhood(POINTS);
|
||||
const oneSearch = new CountingNeighbourhood(POINTS);
|
||||
routeBetween(oneSearch, 10, 99, 3);
|
||||
|
||||
const answer = answerRouting(counting, { kind: 'route', requestId: 11, fromId: 10, toId: 99, rangePc: 3, ceilingPc: 3 });
|
||||
|
||||
expect(answer).toMatchObject({ route: null, neededRangePc: null });
|
||||
expect(counting.queries).toBe(oneSearch.queries);
|
||||
});
|
||||
|
||||
it('offers nothing to raise to when even the ceiling does not reach', () => {
|
||||
const answer = answerRouting(index, { kind: 'route', requestId: 9, fromId: 10, toId: 99, rangePc: 1.5, ceilingPc: 3 });
|
||||
|
||||
|
||||
@@ -26,7 +26,21 @@ export type RoutingRequest =
|
||||
| { readonly kind: 'links'; readonly requestId: number; readonly rangePc: number; readonly drawn: Uint32Array; readonly budget?: LinkBudget };
|
||||
|
||||
export type RoutingResponse =
|
||||
| { readonly kind: 'route'; readonly requestId: number; readonly route: Route | null; readonly neededRangePc: number | null }
|
||||
/**
|
||||
* Two searches, and two things they can fail to prove, kept apart because they are printed as
|
||||
* different sentences. `gaveUp` is about the range that was asked for: true when that search
|
||||
* spent its budget rather than looking everywhere the range reaches. `least` is about the search
|
||||
* for a range that would work: true when it looked everywhere up to the ceiling, so `null` there
|
||||
* means no chain exists rather than none was found.
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'route';
|
||||
readonly requestId: number;
|
||||
readonly route: Route | null;
|
||||
readonly neededRangePc: number | null;
|
||||
readonly gaveUp: boolean;
|
||||
readonly least: boolean;
|
||||
}
|
||||
| { readonly kind: 'links'; readonly requestId: number; readonly segments: Float32Array }
|
||||
/** The question threw in the worker. Sent back so the request settles instead of waiting for good. */
|
||||
| { readonly kind: 'failed'; readonly requestId: number; readonly message: string };
|
||||
@@ -37,8 +51,9 @@ export function indexCatalogue({ ids, positions }: RoutingCatalogue): StarNeighb
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers one request. A route that cannot be made comes back with the range that would make
|
||||
* one, searched no wider than `ceilingPc`, so a refusal is always also an offer.
|
||||
* Answers one request. A route that cannot be made comes back with the range that would make one,
|
||||
* searched no wider than `ceilingPc`, so a refusal is usually also an offer — unless the searches
|
||||
* gave up, which is reported rather than passed off as "there is no route".
|
||||
*/
|
||||
export function answerRouting(index: StarNeighbourhood, request: RoutingRequest): RoutingResponse {
|
||||
if (request.kind === 'links') {
|
||||
@@ -47,11 +62,13 @@ export function answerRouting(index: StarNeighbourhood, request: RoutingRequest)
|
||||
const drawn = new StarNeighbourhood(Array.from(request.drawn, (at) => index.pointAt(at)), request.rangePc);
|
||||
return { kind: 'links', requestId: request.requestId, segments: jumpLinkSegments(drawn, request.rangePc, request.budget) };
|
||||
}
|
||||
const route = routeBetween(index, request.fromId, request.toId, request.rangePc);
|
||||
return {
|
||||
kind: 'route',
|
||||
requestId: request.requestId,
|
||||
route,
|
||||
neededRangePc: route ? null : minimumRangeBetween(index, request.fromId, request.toId, request.ceilingPc)
|
||||
};
|
||||
const { route, gaveUp } = routeBetween(index, request.fromId, request.toId, request.rangePc);
|
||||
// At the ceiling the question has just been asked: the range search would repeat it, identically
|
||||
// and at the same cost, before bisecting below it.
|
||||
if (route || request.rangePc >= request.ceilingPc) {
|
||||
// Asked at the ceiling, the one search answers both questions.
|
||||
return { kind: 'route', requestId: request.requestId, route, neededRangePc: null, gaveUp: !route && gaveUp, least: !gaveUp };
|
||||
}
|
||||
const needed = minimumRangeBetween(index, request.fromId, request.toId, request.ceilingPc);
|
||||
return { kind: 'route', requestId: request.requestId, route: null, neededRangePc: needed.rangePc, gaveUp, least: needed.least };
|
||||
}
|
||||
|
||||
@@ -28,30 +28,45 @@ describe('distanceRings', () => {
|
||||
// The opening view sits about 307 pc from the Sun: the rings the map always had, with the
|
||||
// survey edge at the fifth, and on out past the camera for the stars now drawn beyond it.
|
||||
it('reaches past the camera from the opening view', () => {
|
||||
expect(distanceRings(307, 5, 250)).toEqual([50, 100, 150, 200, 250, 300, 350]);
|
||||
expect(distanceRings(0, 307, 5, 250)).toEqual([50, 100, 150, 200, 250, 300, 350]);
|
||||
});
|
||||
|
||||
it('closes in with the camera', () => {
|
||||
expect(distanceRings(20, 5, 250)).toEqual([2, 4, 6, 8, 10, 12, 14, 16, 18, 20]);
|
||||
expect(distanceRings(1, 5, 250)).toEqual([0.2, 0.4, 0.6, 0.8, 1]);
|
||||
expect(distanceRings(0, 20, 5, 250)).toEqual([2, 4, 6, 8, 10, 12, 14, 16, 18, 20]);
|
||||
expect(distanceRings(0, 1, 5, 250)).toEqual([0.2, 0.4, 0.6, 0.8, 1]);
|
||||
});
|
||||
|
||||
// Near Mirfak the camera is 155 pc out; rounding the step down to 20 pc must not leave the
|
||||
// rings stopping at 100.
|
||||
it('covers the whole distance whatever the rounding', () => {
|
||||
expect(distanceRings(155, 5, 250)).toEqual([20, 40, 60, 80, 100, 120, 140, 160]);
|
||||
expect(distanceRings(0, 155, 5, 250)).toEqual([20, 40, 60, 80, 100, 120, 140, 160]);
|
||||
});
|
||||
|
||||
// A star 190 pc out seen from 20 pc away: the frame is a band about 19 pc either side of it and
|
||||
// the Sun is nowhere in it. Sized to the 210 pc it reaches, the step would be 20 pc and the
|
||||
// nearest rings — 180 and 200 — would both miss the frame.
|
||||
it('spaces the rings for a frame that does not hold the Sun', () => {
|
||||
const radii = distanceRings(171, 210, 5, 250);
|
||||
|
||||
expect(radii).toEqual([170, 175, 180, 185, 190, 195, 200, 205, 210]);
|
||||
expect(radii.some((radius) => Math.abs(radius - 190) < 19)).toBe(true);
|
||||
});
|
||||
|
||||
it('marks the callout among rings the step does not land on', () => {
|
||||
expect(distanceRings(1000, 5, 250)).toEqual([200, 250, 400, 600, 800, 1000]);
|
||||
expect(distanceRings(0, 1000, 5, 250)).toEqual([200, 250, 400, 600, 800, 1000]);
|
||||
});
|
||||
|
||||
it('leaves the callout out when it is past the last ring', () => {
|
||||
expect(distanceRings(100, 5, 250)).toEqual([20, 40, 60, 80, 100]);
|
||||
it('leaves the callout out when it is past the last ring, or behind the first', () => {
|
||||
expect(distanceRings(0, 100, 5, 250)).toEqual([20, 40, 60, 80, 100]);
|
||||
// Short of the survey edge by less than one step is not the rule — the last ring is: 245 pc
|
||||
// overshoots to 260 and gets it, 235 pc stops at 240 and does not, on the same 20 pc step.
|
||||
expect(distanceRings(0, 245, 5, 250)).toContain(250);
|
||||
expect(distanceRings(0, 235, 5, 250)).not.toContain(250);
|
||||
expect(distanceRings(400, 440, 5, 250)).toEqual([400, 405, 410, 415, 420, 425, 430, 435, 440]);
|
||||
});
|
||||
|
||||
it('draws no rings for a camera with no distance', () => {
|
||||
expect(distanceRings(0, 5, 250)).toEqual([]);
|
||||
expect(distanceRings(0, 0, 5, 250)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,19 +16,33 @@ export function roundLengthAtMost(value: number): number | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rings at a round step of about `reach / count`, out to `reach` or just past it, plus `callout`
|
||||
* wherever it falls among them: the grid's own radii are round, and the one radius that means
|
||||
* something in its own right is marked whether the step lands on it or not. Rounding the step
|
||||
* down makes for `count` to `2.5 × count` rings, never fewer than it takes to cover `reach`.
|
||||
* Rings across the span from `nearest` to `reach`, at a round step of about a `count`th of it,
|
||||
* plus `callout` where it falls between the first ring and the last: the grid's own radii are
|
||||
* round, and the one radius that means something in its own right is marked whether the step lands
|
||||
* on it or not. A frame that stops short of it gets it only when the last ring — the first multiple
|
||||
* of `step` at or past `reach` — is past it: reach 245 with a 20 pc step gets it, reach 235 does
|
||||
* not, since its last ring is 240.
|
||||
*
|
||||
* Two numbers rather than one because these rings are centred on a fixed point — the Sun — and a
|
||||
* frame need not be. Looking at something 200 pc out from 20 pc away, what is on screen is a band
|
||||
* 200 pc wide at its narrowest and nowhere near the Sun; a step sized to the whole 220 puts every
|
||||
* ring off the frame. The span is what the frame covers, so the step is what it can resolve.
|
||||
*
|
||||
* Rounding the step down, over a span that need not start at the Sun, makes for `count` to
|
||||
* `ceil(2.5 × count) + 2` rings — `ceil(reach / step) - floor(nearest / step) + 1` — and the
|
||||
* callout can add one: 5 to 16 for a count of 5.
|
||||
*/
|
||||
export function distanceRings(reach: number, count: number, callout: number): number[] {
|
||||
const step = roundLengthAtMost(reach / count);
|
||||
export function distanceRings(nearest: number, reach: number, count: number, callout: number): number[] {
|
||||
const step = roundLengthAtMost((reach - nearest) / count);
|
||||
if (step === null) {
|
||||
return [];
|
||||
}
|
||||
// The ring just inside the near edge of the span, so the band is crossed rather than started at.
|
||||
const first = Math.max(1, Math.floor(nearest / step));
|
||||
const last = Math.ceil(reach / step);
|
||||
// `toPrecision` clears the binary noise of stepping by a tenth: 0.1 × 3 is 0.30000000000000004.
|
||||
const radii = Array.from({ length: Math.ceil(reach / step) }, (_, index) => Number((step * (index + 1)).toPrecision(12)));
|
||||
if (callout > step && callout < radii[radii.length - 1] && !radii.includes(callout)) {
|
||||
const radii = Array.from({ length: last - first + 1 }, (_, index) => Number((step * (first + index)).toPrecision(12)));
|
||||
if (callout > radii[0] && callout < radii[radii.length - 1] && !radii.includes(callout)) {
|
||||
radii.push(callout);
|
||||
radii.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
+12
-2
@@ -66,8 +66,18 @@ function validateStars(stars: StarRecord[]): void {
|
||||
* The other failure leaves no close pair at all, because proper motion had already carried the
|
||||
* two entries tens of arcseconds apart — the 2026-08-24 refresh, where HYG sat at epoch 2000.0
|
||||
* and Gaia at J2016.0. What it does leave is HYG rows that found no counterpart: 36 056 of them
|
||||
* against the 10 876 Gaia genuinely lacks (bright stars it saturates on, red dwarfs past its
|
||||
* magnitude cut).
|
||||
* against the 10 886 today, and no counterpart was possible for most of those. Two thirds of them,
|
||||
* 6 835, are the stars Gaia measures but the main query never downloads, because Gaia's parallax
|
||||
* puts them past `ETL_GAIA_DISTANCE_PC` while Hipparcos put them inside `ETL_STAR_DISTANCE_PC`;
|
||||
* they are every star in the published catalogue beyond 250 pc. The rest are what Gaia genuinely
|
||||
* lacks: bright stars it saturates on, red dwarfs past its magnitude cut. So the headroom left to
|
||||
* the ceiling tracks the gap between those two cutoffs as much as Gaia's completeness.
|
||||
*
|
||||
* This bounds a merge that went wrong, and — loosely — a Gaia download that came back short: a
|
||||
* truncated answer leaves the HYG rows whose counterpart it dropped without one, so survivors go
|
||||
* *up*, not down. Measured against the published catalogue: 10 886 today, 11 004 at nine tenths of
|
||||
* the rows, 12 711 at half, 16 258 at a third. So this ceiling only catches a truncation past about
|
||||
* two thirds, and `fetchGaiaStars` catches the shallower ones with its own row floor.
|
||||
*/
|
||||
const MAX_UNMERGED_TWINS = 100;
|
||||
const MAX_HYG_SURVIVORS = 15_000;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { writeFileSync } from 'node:fs';
|
||||
import { mergeStarCatalogues, placementDistancePc } from '../../src/app/shared/astro/star-merge';
|
||||
import { encodeStarCatalog } from '../../src/app/shared/models/star-catalog';
|
||||
import { StarRecord, SUN_STAR_ID } from '../../src/app/shared/models/star.model';
|
||||
import { fetchGaiaDistancesByHip } from './sources/gaia';
|
||||
import { fetchGaiaDistancesByHip, GaiaAnswerError } from './sources/gaia';
|
||||
import { positionalSources } from './sources/registry';
|
||||
import { PARALLAX_PRECISION_MAS } from './sources/star-sources';
|
||||
import { parseCsvObjects, parseOptionalNumber } from './lib/csv';
|
||||
@@ -142,7 +142,9 @@ export async function fetchStars(): Promise<StarRecord[]> {
|
||||
*
|
||||
* A source that cannot be reached is reported and skipped here rather than thrown, so a run still
|
||||
* gets as far as validation and says what it has. Whether that may be published is decided
|
||||
* there: `validateMerge` in build.ts refuses a catalogue Gaia contributed nothing to.
|
||||
* there: `validateMerge` in build.ts refuses a catalogue Gaia contributed nothing to. A source
|
||||
* that answered with something unusable ({@link GaiaAnswerError}) is a different matter, and stops
|
||||
* the run where it happened rather than being reported later as an outage.
|
||||
*/
|
||||
async function mergeWithOtherSources(hygStars: StarRecord[]): Promise<StarRecord[]> {
|
||||
const others = positionalSources().filter((source) => source.id !== 'hyg');
|
||||
@@ -160,6 +162,11 @@ async function mergeWithOtherSources(hygStars: StarRecord[]): Promise<StarRecord
|
||||
stars: await source.fetch!()
|
||||
});
|
||||
} catch (error) {
|
||||
// An answer that cannot be worked with is not an outage: skipping it would write a
|
||||
// half-catalogue over the published assets before the merge gate got to say so.
|
||||
if (error instanceof GaiaAnswerError) {
|
||||
throw error;
|
||||
}
|
||||
console.log(` skipping ${source.name}: ${error instanceof Error ? error.message : error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,15 @@ const RETRY_DELAYS_MS = [30_000, 120_000];
|
||||
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const response = await fetch(url).catch((error: unknown) => (error instanceof Error ? error : new Error(String(error))));
|
||||
let response = await fetch(url).catch((error: unknown) => (error instanceof Error ? error : new Error(String(error))));
|
||||
if (!(response instanceof Error) && response.ok) {
|
||||
return response.text();
|
||||
// Read inside the loop, because the body is where these downloads fail: the Gaia CSV is
|
||||
// 57 MB, and a connection reset part-way through rejects here, long after the 200.
|
||||
const body = await response.text().catch((error: unknown) => (error instanceof Error ? error : new Error(String(error))));
|
||||
if (typeof body === 'string') {
|
||||
return body;
|
||||
}
|
||||
response = body;
|
||||
}
|
||||
const reason = response instanceof Error ? response.message : `${response.status} ${response.statusText}`;
|
||||
// A 4xx is the request's own fault, and waiting will not change the answer.
|
||||
|
||||
@@ -38,9 +38,12 @@ const CATALOGUE_EPOCH = 2000.0;
|
||||
* past anything this map draws, so the limit here is a payload decision: the catalogue is baked
|
||||
* into a static asset that a browser downloads before the first frame.
|
||||
*/
|
||||
const DISTANCE_CUTOFF_PC = Number(process.env['ETL_GAIA_DISTANCE_PC'] ?? 250);
|
||||
const MAGNITUDE_LIMIT = Number(process.env['ETL_GAIA_MAGNITUDE_LIMIT'] ?? 12);
|
||||
const ROW_LIMIT = Number(process.env['ETL_GAIA_ROW_LIMIT'] ?? 500000);
|
||||
const DEFAULT_DISTANCE_CUTOFF_PC = 250;
|
||||
const DEFAULT_MAGNITUDE_LIMIT = 12;
|
||||
const DEFAULT_ROW_LIMIT = 500_000;
|
||||
const DISTANCE_CUTOFF_PC = Number(process.env['ETL_GAIA_DISTANCE_PC'] ?? DEFAULT_DISTANCE_CUTOFF_PC);
|
||||
const MAGNITUDE_LIMIT = Number(process.env['ETL_GAIA_MAGNITUDE_LIMIT'] ?? DEFAULT_MAGNITUDE_LIMIT);
|
||||
const ROW_LIMIT = Number(process.env['ETL_GAIA_ROW_LIMIT'] ?? DEFAULT_ROW_LIMIT);
|
||||
|
||||
/**
|
||||
* Relative parallax error above which a star is dropped: a parallax measured to worse than 20%
|
||||
@@ -67,6 +70,35 @@ function buildQuery(): string {
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* How many rows the query above holds when nothing is overridden: 412 765, and DR3 is a finished
|
||||
* data release, so that number only moves when the query does. It lives here, under the query, so
|
||||
* that an edit to any of its filters is made with the count it invalidates in view.
|
||||
*
|
||||
* Checked because a short answer looks exactly like a complete one. The TAP service truncates on
|
||||
* its own timeout and still serves a well-formed CSV with a 200, and the rows are ordered by
|
||||
* magnitude, so what comes back is the bright half — the half HYG overlaps. The merge gate in
|
||||
* `build.ts` would then see Gaia stars present and a survivor count barely moved, and pass a
|
||||
* catalogue missing two hundred thousand stars, which the weekly job would publish and the runner
|
||||
* would cache for the weeks after it. Same failure, and same guard, as
|
||||
* {@link MIN_USABLE_HIP_DISTANCES} below.
|
||||
*
|
||||
* Only checked when nothing is overridden: the environment overrides exist to fetch a smaller
|
||||
* slice on purpose.
|
||||
*/
|
||||
const DEFAULT_QUERY_ROWS = 412_765;
|
||||
const MIN_ROW_SHARE = 0.95;
|
||||
|
||||
/**
|
||||
* An answer the archive gave that cannot be worked with, as against an archive that gave none.
|
||||
*
|
||||
* `fetchStars` skips a source it cannot reach and leaves the merge gate to judge the result. That
|
||||
* is right for an outage and wrong for a truncated CSV, which would be skipped, cached, and land
|
||||
* as "the archive was unreachable" long after the assets had been overwritten — so these throws
|
||||
* are marked, and rethrown there.
|
||||
*/
|
||||
export class GaiaAnswerError extends Error {}
|
||||
|
||||
/**
|
||||
* Gaia publishes no spectral classifications, but `bp_rp` is a colour index on the same footing
|
||||
* as HYG's `ci` — so the app's existing colour and spectral-class handling works unchanged, and
|
||||
@@ -90,8 +122,22 @@ export async function fetchGaiaStars(): Promise<StarRecord[]> {
|
||||
// Keyed by the whole request, so a response cached for other columns, another order, or
|
||||
// another endpoint can never be mistaken for this one — the cache records only that some
|
||||
// response arrived, not what it answered.
|
||||
const csv = await fetchTextCached(url, `gaia-dr3-${createHash('sha1').update(url).digest('hex').slice(0, 8)}.csv`);
|
||||
const cacheKey = `gaia-dr3-${createHash('sha1').update(url).digest('hex').slice(0, 8)}.csv`;
|
||||
const csv = await fetchTextCached(url, cacheKey);
|
||||
const rows = parseCsvObjects(csv);
|
||||
const jobsQuery = DISTANCE_CUTOFF_PC === DEFAULT_DISTANCE_CUTOFF_PC && MAGNITUDE_LIMIT === DEFAULT_MAGNITUDE_LIMIT && ROW_LIMIT === DEFAULT_ROW_LIMIT;
|
||||
if (jobsQuery && rows.length < DEFAULT_QUERY_ROWS * MIN_ROW_SHARE) {
|
||||
throw new GaiaAnswerError(
|
||||
`Gaia returned ${rows.length} rows, not the ~${DEFAULT_QUERY_ROWS} this query holds — the answer was cut short, it was an error page ` +
|
||||
`served with a 200, or the query was edited without updating DEFAULT_QUERY_ROWS; delete tools/etl/.cache/${cacheKey} once the archive answers properly`
|
||||
);
|
||||
}
|
||||
// Not gated on `jobsQuery` like the floor above it: the only ways to reach this cap are the
|
||||
// overrides that *widen* the query, and they are exactly when it is worth saying. What it must
|
||||
// not fire on is a deliberately smaller slice, where filling the limit is the whole point.
|
||||
if (ROW_LIMIT >= DEFAULT_ROW_LIMIT && rows.length >= ROW_LIMIT) {
|
||||
throw new GaiaAnswerError(`Gaia returned the query's own ${ROW_LIMIT}-row limit, so it is the limit deciding what the map holds; raise ETL_GAIA_ROW_LIMIT.`);
|
||||
}
|
||||
const stars: StarRecord[] = [];
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
|
||||
Reference in New Issue
Block a user