Open the map out to the whole Milky Way
The map stopped at the catalogued 50 pc around the Sun — 0.33% of the Galaxy's width — and looked like a point cloud with a search box. Adds the galactic scale above it and the heads-up display the reference map is built from. The Galaxy is not a third coordinate space. It is the same parsec space four orders of magnitude further out, so the model and the star field crossfade against camera distance instead of switching, and the Sun stays where it really is: 8.18 kpc out, on the Orion Spur, between the Sagittarius and Perseus arms. The depth range scales with that distance — one fixed near/far pair cannot both fly into a star and hold the Galaxy. The structure in shared/astro/galaxy.ts is measured: the directions of the centre and the north galactic pole, which fix the disc's 63 degree tilt against the celestial equator; the Sun's galactocentric distance; and a radius, azimuth and pitch angle per arm. The particles scattered around it are not, and cannot be — dust hides the disc, so no catalogue holds the Galaxy's stars. The view says so, and the model fades out before the camera reaches the 50 pc where the real stars are. The rest is the look: polar grids lying in the galactic plane with drop lines from the Sun's neighbours, a scale ladder, a readout panel, range, reticle and frame brackets. Two things had to give way for it. The deep-sky shell is the sky as seen from here, so it dissolves rather than letting the camera fly through a wall of nebulae, and so does the skybox, which is a photograph taken from inside the thing now being viewed from outside. Labels are picked by screen separation rather than distance alone: the Sun's fifteen nearest neighbours are all inside four parsecs and printed as one unreadable clump. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CartesianCoordinates } from './coordinates';
|
||||
import {
|
||||
armRadiusPc,
|
||||
BAR_HALF_LENGTH_PC,
|
||||
DISC_RADIUS_PC,
|
||||
equatorialToGalactic,
|
||||
GALACTIC_BASIS_EQUATORIAL,
|
||||
GALACTIC_LANDMARKS,
|
||||
galacticCentrePositionPc,
|
||||
galacticToEquatorial,
|
||||
galactocentricToHeliocentricGalactic,
|
||||
landmarkPositionPc,
|
||||
MILKY_WAY_ARMS,
|
||||
ORION_SPUR,
|
||||
SUN_GALACTOCENTRIC_RADIUS_PC,
|
||||
SUN_HEIGHT_ABOVE_MIDPLANE_PC
|
||||
} from './galaxy';
|
||||
|
||||
const RAD_TO_DEG = 180 / Math.PI;
|
||||
|
||||
function dot(a: CartesianCoordinates, b: CartesianCoordinates): number {
|
||||
return a.x * b.x + a.y * b.y + a.z * b.z;
|
||||
}
|
||||
|
||||
function length(v: CartesianCoordinates): number {
|
||||
return Math.hypot(v.x, v.y, v.z);
|
||||
}
|
||||
|
||||
/** Galactic longitude of a heliocentric galactic vector, in degrees, 0-360. */
|
||||
function galacticLongitudeDeg(v: CartesianCoordinates): number {
|
||||
return (Math.atan2(v.y, v.x) * RAD_TO_DEG + 360) % 360;
|
||||
}
|
||||
|
||||
describe('GALACTIC_BASIS_EQUATORIAL', () => {
|
||||
it('is an orthonormal basis', () => {
|
||||
const { x, y, z } = GALACTIC_BASIS_EQUATORIAL;
|
||||
for (const axis of [x, y, z]) {
|
||||
expect(length(axis)).toBeCloseTo(1, 12);
|
||||
}
|
||||
expect(dot(x, y)).toBeCloseTo(0, 12);
|
||||
expect(dot(y, z)).toBeCloseTo(0, 12);
|
||||
expect(dot(z, x)).toBeCloseTo(0, 12);
|
||||
});
|
||||
|
||||
it('is right-handed, so the model is rotated rather than mirrored', () => {
|
||||
const { x, y, z } = GALACTIC_BASIS_EQUATORIAL;
|
||||
const cross = { x: x.y * y.z - x.z * y.y, y: x.z * y.x - x.x * y.z, z: x.x * y.y - x.y * y.x };
|
||||
expect(cross.x).toBeCloseTo(z.x, 12);
|
||||
expect(cross.y).toBeCloseTo(z.y, 12);
|
||||
expect(cross.z).toBeCloseTo(z.z, 12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('galacticToEquatorial', () => {
|
||||
it('is inverted exactly by equatorialToGalactic', () => {
|
||||
for (const point of [
|
||||
{ x: 1, y: 0, z: 0 },
|
||||
{ x: 0, y: 0, z: 1 },
|
||||
{ x: -8178, y: 4200, z: -75 }
|
||||
]) {
|
||||
const round = equatorialToGalactic(galacticToEquatorial(point));
|
||||
expect(round.x).toBeCloseTo(point.x, 8);
|
||||
expect(round.y).toBeCloseTo(point.y, 8);
|
||||
expect(round.z).toBeCloseTo(point.z, 8);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves length, being a rotation', () => {
|
||||
const rotated = galacticToEquatorial({ x: 300, y: -450, z: 120 });
|
||||
expect(length(rotated)).toBeCloseTo(length({ x: 300, y: -450, z: 120 }), 9);
|
||||
});
|
||||
|
||||
it('sends the galactic pole to the catalogued equatorial direction of the pole', () => {
|
||||
// Dec of the north galactic pole is +27.12825 degrees, so its equatorial z is sin of that.
|
||||
const pole = galacticToEquatorial({ x: 0, y: 0, z: 1 });
|
||||
expect(Math.asin(pole.z) * RAD_TO_DEG).toBeCloseTo(27.12825, 6);
|
||||
});
|
||||
|
||||
it('puts the galactic plane at about 60 degrees to the celestial equator', () => {
|
||||
// The complement of the pole's declination: the two planes are as far apart as their poles.
|
||||
const pole = galacticToEquatorial({ x: 0, y: 0, z: 1 });
|
||||
expect(90 - Math.asin(pole.z) * RAD_TO_DEG).toBeCloseTo(62.87, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('galactocentricToHeliocentricGalactic', () => {
|
||||
it('places the Sun at the origin, at its own radius and zero azimuth', () => {
|
||||
const sun = galactocentricToHeliocentricGalactic(SUN_GALACTOCENTRIC_RADIUS_PC, 0, SUN_HEIGHT_ABOVE_MIDPLANE_PC);
|
||||
expect(sun.x).toBeCloseTo(0, 9);
|
||||
expect(sun.y).toBeCloseTo(0, 9);
|
||||
expect(sun.z).toBeCloseTo(0, 9);
|
||||
});
|
||||
|
||||
it('puts the midplane below the Sun, not through it', () => {
|
||||
const belowSun = galactocentricToHeliocentricGalactic(SUN_GALACTOCENTRIC_RADIUS_PC, 0, 0);
|
||||
expect(belowSun.z).toBeCloseTo(-SUN_HEIGHT_ABOVE_MIDPLANE_PC, 9);
|
||||
});
|
||||
|
||||
it('places the galactic centre toward longitude zero at the Sun-centre distance', () => {
|
||||
const centre = galactocentricToHeliocentricGalactic(0, 0, 0);
|
||||
expect(galacticLongitudeDeg(centre)).toBeCloseTo(0, 6);
|
||||
expect(Math.hypot(centre.x, centre.y)).toBeCloseTo(SUN_GALACTOCENTRIC_RADIUS_PC, 6);
|
||||
});
|
||||
|
||||
it('sends increasing azimuth toward longitude 90, the direction of rotation', () => {
|
||||
const ahead = galactocentricToHeliocentricGalactic(SUN_GALACTOCENTRIC_RADIUS_PC, 30, 0);
|
||||
expect(ahead.y).toBeGreaterThan(0);
|
||||
expect(galacticLongitudeDeg(ahead)).toBeGreaterThan(0);
|
||||
expect(galacticLongitudeDeg(ahead)).toBeLessThan(180);
|
||||
});
|
||||
});
|
||||
|
||||
describe('galacticCentrePositionPc', () => {
|
||||
it('is the Sun-centre distance away, in the equatorial frame', () => {
|
||||
expect(length(galacticCentrePositionPc())).toBeCloseTo(Math.hypot(SUN_GALACTOCENTRIC_RADIUS_PC, SUN_HEIGHT_ABOVE_MIDPLANE_PC), 6);
|
||||
});
|
||||
|
||||
it('lands within a tenth of a degree of the catalogued direction of Sagittarius A*', () => {
|
||||
// The two are not identical by construction: galactic latitude zero is defined by the disc
|
||||
// the Sun orbits in, and Sgr A* sits a few hundredths of a degree off it.
|
||||
const centre = galacticCentrePositionPc();
|
||||
const catalogued = galacticToEquatorial({ x: 1, y: 0, z: 0 });
|
||||
const cosAngle = dot(centre, catalogued) / length(centre);
|
||||
expect(Math.acos(cosAngle) * RAD_TO_DEG).toBeLessThan(0.2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('armRadiusPc', () => {
|
||||
it('returns the reference radius at the reference azimuth', () => {
|
||||
for (const arm of MILKY_WAY_ARMS) {
|
||||
expect(armRadiusPc(arm, arm.referenceAzimuthDeg)).toBeCloseTo(arm.referenceRadiusPc, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('winds inward as azimuth increases, so the arms trail', () => {
|
||||
for (const arm of MILKY_WAY_ARMS) {
|
||||
expect(armRadiusPc(arm, arm.referenceAzimuthDeg + 40)).toBeLessThan(arm.referenceRadiusPc);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every arm inside the modelled disc over its traced range', () => {
|
||||
for (const arm of [...MILKY_WAY_ARMS, ORION_SPUR]) {
|
||||
expect(armRadiusPc(arm, arm.fromAzimuthDeg)).toBeLessThanOrEqual(DISC_RADIUS_PC);
|
||||
expect(armRadiusPc(arm, arm.toAzimuthDeg)).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every arm clear of the bar, so the spiral starts where the bar ends', () => {
|
||||
for (const arm of MILKY_WAY_ARMS) {
|
||||
for (let beta = arm.fromAzimuthDeg; beta <= arm.toAzimuthDeg; beta += 10) {
|
||||
expect(armRadiusPc(arm, beta)).toBeGreaterThan(0);
|
||||
}
|
||||
expect(arm.referenceRadiusPc).toBeGreaterThan(BAR_HALF_LENGTH_PC * 0.9);
|
||||
}
|
||||
});
|
||||
|
||||
it('brackets the Sun between the Sagittarius and Perseus arms at the Sun azimuth', () => {
|
||||
// The one arrangement the local star field depends on: the solar neighbourhood sits in the
|
||||
// gap between them, on the minor spur, not inside a major arm.
|
||||
const sagittarius = MILKY_WAY_ARMS.find((arm) => arm.name.startsWith('Sagittarius'))!;
|
||||
const perseus = MILKY_WAY_ARMS.find((arm) => arm.name === 'Perseus')!;
|
||||
|
||||
expect(armRadiusPc(sagittarius, 0)).toBeLessThan(SUN_GALACTOCENTRIC_RADIUS_PC);
|
||||
expect(armRadiusPc(perseus, 0)).toBeGreaterThan(SUN_GALACTOCENTRIC_RADIUS_PC);
|
||||
});
|
||||
|
||||
it('runs the Orion Spur past the Sun, close to the Sun radius', () => {
|
||||
expect(Math.abs(armRadiusPc(ORION_SPUR, 0) - SUN_GALACTOCENTRIC_RADIUS_PC)).toBeLessThan(600);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GALACTIC_LANDMARKS', () => {
|
||||
it('has a unique id per landmark', () => {
|
||||
const ids = GALACTIC_LANDMARKS.map((landmark) => landmark.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('names one landmark per modelled arm, plus the centre, the Sun and the spur', () => {
|
||||
expect(GALACTIC_LANDMARKS).toHaveLength(MILKY_WAY_ARMS.length + 3);
|
||||
});
|
||||
|
||||
it('puts Sol back at the origin', () => {
|
||||
const sol = GALACTIC_LANDMARKS.find((landmark) => landmark.id === 'sol')!;
|
||||
const position = landmarkPositionPc(sol);
|
||||
// The Sun is the origin of the scene's coordinates, give or take its height above the plane,
|
||||
// which the landmark deliberately drops so the label sits on the map rather than off it.
|
||||
expect(Math.hypot(position.x, position.y, position.z)).toBeCloseTo(SUN_HEIGHT_ABOVE_MIDPLANE_PC, 6);
|
||||
});
|
||||
|
||||
it('keeps every landmark inside the modelled disc', () => {
|
||||
for (const landmark of GALACTIC_LANDMARKS) {
|
||||
expect(length(landmarkPositionPc(landmark))).toBeLessThan(DISC_RADIUS_PC * 2);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { CartesianCoordinates, raDegDecDistanceToXyz } from './coordinates';
|
||||
|
||||
const DEG_TO_RAD = Math.PI / 180;
|
||||
|
||||
/**
|
||||
* Structural model of the Milky Way, and the rotation that carries it into the equatorial
|
||||
* frame the rest of the scene works in.
|
||||
*
|
||||
* **This is a model, not a catalogue.** Every other dataset in this app is measured: HYG gives
|
||||
* real parallaxes, Horizons real ephemerides, the Exoplanet Archive real orbits. The Galaxy is
|
||||
* different — we sit inside it, and dust blocks the view across the disc, so no catalogue holds
|
||||
* the positions of its stars. What *is* measured is its skeleton: the distance to the centre,
|
||||
* the tilt of the disc against the sky, and the radius/pitch/azimuth of each spiral arm from
|
||||
* maser parallaxes. Those measurements are the constants below; the individual particles the
|
||||
* renderer scatters around them are illustrative, and labelled as such in the UI.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Direction of the galactic centre (Sgr A*) and the north galactic pole, in equatorial J2000.
|
||||
* These two directions are what tie the model to the sky: everything else is built in galactic
|
||||
* coordinates and rotated through them.
|
||||
*/
|
||||
export const GALACTIC_CENTRE_RA_DEG = 266.4051;
|
||||
export const GALACTIC_CENTRE_DEC_DEG = -28.936175;
|
||||
export const NORTH_GALACTIC_POLE_RA_DEG = 192.85948;
|
||||
export const NORTH_GALACTIC_POLE_DEC_DEG = 27.12825;
|
||||
|
||||
/**
|
||||
* Sun-to-galactic-centre distance, in parsecs (GRAVITY Collaboration 2019, from the orbit of
|
||||
* S2 around Sgr A*), and the Sun's height above the disc midplane.
|
||||
*/
|
||||
export const SUN_GALACTOCENTRIC_RADIUS_PC = 8178;
|
||||
export const SUN_HEIGHT_ABOVE_MIDPLANE_PC = 20.8;
|
||||
|
||||
/** Rough visible extent of the stellar disc, and its exponential scale length/height. */
|
||||
export const DISC_RADIUS_PC = 16000;
|
||||
export const DISC_SCALE_LENGTH_PC = 2600;
|
||||
export const DISC_SCALE_HEIGHT_PC = 300;
|
||||
|
||||
/** Boxy/peanut bulge and bar: half-length, half-width, half-thickness, and orientation. */
|
||||
export const BAR_HALF_LENGTH_PC = 4200;
|
||||
export const BAR_HALF_WIDTH_PC = 1300;
|
||||
export const BAR_HALF_THICKNESS_PC = 900;
|
||||
/** Angle between the bar's long axis and the Sun-centre line, near end at positive longitude. */
|
||||
export const BAR_POSITION_ANGLE_DEG = 25;
|
||||
|
||||
/**
|
||||
* A spiral arm as a logarithmic spiral: `R(beta) = referenceRadiusPc * exp(-(beta -
|
||||
* referenceAzimuthDeg) * tan(pitchAngleDeg))`.
|
||||
*
|
||||
* `beta` is the galactocentric azimuth measured from the Sun's direction, increasing in the
|
||||
* direction of galactic rotation — the convention used by the maser-parallax surveys these
|
||||
* figures approximate (Reid et al. 2019). Values are rounded; they place each arm on the right
|
||||
* side of the Sun at the right pitch, which is what the view needs, and are not a substitute
|
||||
* for the published fits.
|
||||
*/
|
||||
export interface SpiralArm {
|
||||
readonly name: string;
|
||||
readonly referenceRadiusPc: number;
|
||||
readonly referenceAzimuthDeg: number;
|
||||
readonly pitchAngleDeg: number;
|
||||
/** Azimuth range to trace the arm over, in the same `beta` convention. */
|
||||
readonly fromAzimuthDeg: number;
|
||||
readonly toAzimuthDeg: number;
|
||||
/**
|
||||
* Where along the arm to anchor its name. Staggered between arms on purpose: anchoring them
|
||||
* all at one azimuth stacks five labels on the same radial line, which is unreadable.
|
||||
*/
|
||||
readonly labelAzimuthDeg: number;
|
||||
/** Half-width of the star-forming ridge, in parsecs — how far particles scatter off the spine. */
|
||||
readonly widthPc: number;
|
||||
/** Relative particle density, so the two grand-design arms read as the dominant pair. */
|
||||
readonly weight: number;
|
||||
}
|
||||
|
||||
export const MILKY_WAY_ARMS: readonly SpiralArm[] = [
|
||||
{ name: 'Norma', referenceRadiusPc: 4460, referenceAzimuthDeg: 18, pitchAngleDeg: 1, fromAzimuthDeg: -20, toAzimuthDeg: 200, labelAzimuthDeg: 120, widthPc: 500, weight: 0.7 },
|
||||
{ name: 'Scutum–Centaurus', referenceRadiusPc: 4910, referenceAzimuthDeg: 23, pitchAngleDeg: 12.1, fromAzimuthDeg: -30, toAzimuthDeg: 290, labelAzimuthDeg: 70, widthPc: 700, weight: 1 },
|
||||
{ name: 'Sagittarius–Carina', referenceRadiusPc: 6040, referenceAzimuthDeg: 24, pitchAngleDeg: 17.1, fromAzimuthDeg: -40, toAzimuthDeg: 230, labelAzimuthDeg: -20, widthPc: 650, weight: 0.95 },
|
||||
{ name: 'Perseus', referenceRadiusPc: 8870, referenceAzimuthDeg: 40, pitchAngleDeg: 10.3, fromAzimuthDeg: -60, toAzimuthDeg: 220, labelAzimuthDeg: 90, widthPc: 700, weight: 1 },
|
||||
{ name: 'Outer', referenceRadiusPc: 12240, referenceAzimuthDeg: 18, pitchAngleDeg: 3, fromAzimuthDeg: -60, toAzimuthDeg: 200, labelAzimuthDeg: 30, widthPc: 800, weight: 0.55 }
|
||||
];
|
||||
|
||||
/**
|
||||
* The Orion Spur — the minor arm the Sun sits in, which is why the local star field is not
|
||||
* empty. Short, so it is described by its own azimuth span rather than the full sweep above.
|
||||
*/
|
||||
export const ORION_SPUR: SpiralArm = {
|
||||
name: 'Orion Spur',
|
||||
referenceRadiusPc: 8260,
|
||||
referenceAzimuthDeg: 8.9,
|
||||
pitchAngleDeg: 11.4,
|
||||
fromAzimuthDeg: -25,
|
||||
toAzimuthDeg: 45,
|
||||
labelAzimuthDeg: 20,
|
||||
widthPc: 400,
|
||||
weight: 0.45
|
||||
};
|
||||
|
||||
/**
|
||||
* Galactocentric radius of a point on an arm at azimuth `betaDeg`, in parsecs.
|
||||
* Diverges for large negative azimuths by construction — a logarithmic spiral has no
|
||||
* outer end — so callers trace it only over the arm's own azimuth range.
|
||||
*/
|
||||
export function armRadiusPc(arm: SpiralArm, betaDeg: number): number {
|
||||
return arm.referenceRadiusPc * Math.exp(-(betaDeg - arm.referenceAzimuthDeg) * DEG_TO_RAD * Math.tan(arm.pitchAngleDeg * DEG_TO_RAD));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts galactocentric cylindrical coordinates into heliocentric galactic Cartesian
|
||||
* coordinates in parsecs: +X toward the galactic centre, +Y toward galactic longitude 90
|
||||
* (the direction of the Sun's rotation about the centre), +Z toward the north galactic pole.
|
||||
*
|
||||
* `heightPc` is measured from the disc midplane, not from the Sun — so a point with
|
||||
* `heightPc = 0` comes out at `z = -SUN_HEIGHT_ABOVE_MIDPLANE_PC`, since the Sun sits a little
|
||||
* above the plane it orbits in.
|
||||
*/
|
||||
export function galactocentricToHeliocentricGalactic(radiusPc: number, azimuthDeg: number, heightPc: number): CartesianCoordinates {
|
||||
const beta = azimuthDeg * DEG_TO_RAD;
|
||||
return {
|
||||
x: SUN_GALACTOCENTRIC_RADIUS_PC - radiusPc * Math.cos(beta),
|
||||
y: radiusPc * Math.sin(beta),
|
||||
z: heightPc - SUN_HEIGHT_ABOVE_MIDPLANE_PC
|
||||
};
|
||||
}
|
||||
|
||||
function normalize(v: CartesianCoordinates): CartesianCoordinates {
|
||||
const length = Math.hypot(v.x, v.y, v.z);
|
||||
return { x: v.x / length, y: v.y / length, z: v.z / length };
|
||||
}
|
||||
|
||||
function cross(a: CartesianCoordinates, b: CartesianCoordinates): CartesianCoordinates {
|
||||
return { x: a.y * b.z - a.z * b.y, y: a.z * b.x - a.x * b.z, z: a.x * b.y - a.y * b.x };
|
||||
}
|
||||
|
||||
/**
|
||||
* The galactic frame's basis vectors, expressed in the equatorial frame.
|
||||
*
|
||||
* Built from the two catalogue directions above, which are given to finite precision and so are
|
||||
* not exactly perpendicular. The pole is taken as authoritative for "up" and the centre
|
||||
* direction is re-orthogonalised against it, which keeps the result an exact rotation — a basis
|
||||
* that is a fraction of an arcsecond off square would shear the whole model.
|
||||
*/
|
||||
const GALACTIC_POLE_EQUATORIAL = raDegDecDistanceToXyz(NORTH_GALACTIC_POLE_RA_DEG, NORTH_GALACTIC_POLE_DEC_DEG, 1);
|
||||
const GALACTIC_CENTRE_EQUATORIAL = raDegDecDistanceToXyz(GALACTIC_CENTRE_RA_DEG, GALACTIC_CENTRE_DEC_DEG, 1);
|
||||
const GALACTIC_Z = normalize(GALACTIC_POLE_EQUATORIAL);
|
||||
const GALACTIC_Y = normalize(cross(GALACTIC_Z, GALACTIC_CENTRE_EQUATORIAL));
|
||||
const GALACTIC_X = cross(GALACTIC_Y, GALACTIC_Z);
|
||||
|
||||
export const GALACTIC_BASIS_EQUATORIAL = {
|
||||
x: GALACTIC_X,
|
||||
y: GALACTIC_Y,
|
||||
z: GALACTIC_Z
|
||||
} as const;
|
||||
|
||||
/** Rotates a heliocentric galactic vector into the scene's equatorial frame. */
|
||||
export function galacticToEquatorial(v: CartesianCoordinates): CartesianCoordinates {
|
||||
return {
|
||||
x: GALACTIC_X.x * v.x + GALACTIC_Y.x * v.y + GALACTIC_Z.x * v.z,
|
||||
y: GALACTIC_X.y * v.x + GALACTIC_Y.y * v.y + GALACTIC_Z.y * v.z,
|
||||
z: GALACTIC_X.z * v.x + GALACTIC_Y.z * v.y + GALACTIC_Z.z * v.z
|
||||
};
|
||||
}
|
||||
|
||||
/** Rotates an equatorial vector into heliocentric galactic coordinates (the inverse rotation). */
|
||||
export function equatorialToGalactic(v: CartesianCoordinates): CartesianCoordinates {
|
||||
return {
|
||||
x: GALACTIC_X.x * v.x + GALACTIC_X.y * v.y + GALACTIC_X.z * v.z,
|
||||
y: GALACTIC_Y.x * v.x + GALACTIC_Y.y * v.y + GALACTIC_Y.z * v.z,
|
||||
z: GALACTIC_Z.x * v.x + GALACTIC_Z.y * v.y + GALACTIC_Z.z * v.z
|
||||
};
|
||||
}
|
||||
|
||||
/** The galactic centre's position in the scene's equatorial frame, in parsecs from the Sun. */
|
||||
export function galacticCentrePositionPc(): CartesianCoordinates {
|
||||
return galacticToEquatorial(galactocentricToHeliocentricGalactic(0, 0, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Named landmarks worth pinning in the galactic view. Positions are derived from the same
|
||||
* structural constants as the model, so a label always sits on the feature it names.
|
||||
*/
|
||||
export interface GalacticLandmark {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
/** Galactocentric radius/azimuth, matching {@link galactocentricToHeliocentricGalactic}. */
|
||||
readonly radiusPc: number;
|
||||
readonly azimuthDeg: number;
|
||||
}
|
||||
|
||||
export const GALACTIC_LANDMARKS: readonly GalacticLandmark[] = [
|
||||
{ id: 'sgr-a', name: 'Sagittarius A*', radiusPc: 0, azimuthDeg: 0 },
|
||||
{ id: 'sol', name: 'Sol', radiusPc: SUN_GALACTOCENTRIC_RADIUS_PC, azimuthDeg: 0 },
|
||||
...MILKY_WAY_ARMS.map((arm) => ({
|
||||
id: `arm-${arm.name.toLowerCase().replace(/[^a-z]+/g, '-')}`,
|
||||
name: `${arm.name} Arm`,
|
||||
radiusPc: armRadiusPc(arm, arm.labelAzimuthDeg),
|
||||
azimuthDeg: arm.labelAzimuthDeg
|
||||
})),
|
||||
{ id: 'arm-orion-spur', name: 'Orion Spur', radiusPc: armRadiusPc(ORION_SPUR, ORION_SPUR.labelAzimuthDeg), azimuthDeg: ORION_SPUR.labelAzimuthDeg }
|
||||
];
|
||||
|
||||
/** A landmark's position in the scene's equatorial frame, in parsecs from the Sun. */
|
||||
export function landmarkPositionPc(landmark: GalacticLandmark): CartesianCoordinates {
|
||||
return galacticToEquatorial(galactocentricToHeliocentricGalactic(landmark.radiusPc, landmark.azimuthDeg, 0));
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type ViewLevel = 'galaxy' | 'system';
|
||||
/**
|
||||
* The map's zoom levels, outermost first.
|
||||
*
|
||||
* `galactic` is the whole Milky Way; `galaxy` is the catalogued solar neighbourhood inside it
|
||||
* (the level the app opens on); `system` is one star's planets. The first two share a coordinate
|
||||
* space and are told apart by how far the camera has pulled back, so the scene reports which one
|
||||
* it is in rather than being commanded into it.
|
||||
*/
|
||||
export type ViewLevel = 'galactic' | 'galaxy' | 'system';
|
||||
|
||||
/**
|
||||
* App-wide navigation state: which zoom level is active and what's currently selected.
|
||||
|
||||
Reference in New Issue
Block a user