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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user