Skip to content

Commit 71217c1

Browse files
authored
Merge pull request #87 from jbeshir/agent/astrolabe-optional-sun-assist
Correct the historical front and rete orientation
2 parents 4f24f28 + 1e46c3e commit 71217c1

11 files changed

Lines changed: 132 additions & 40 deletions

File tree

‎widgets/astrolabe-simulator/journey.json‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"label": "initial",
99
"steps": [
1010
{
11-
"eval": "(() => { const eclipticTicks = document.querySelectorAll('.astro-ecliptic-tick'); const limbTicks = document.querySelectorAll('.astro-limb-tick'); if (eclipticTicks.length !== 720 || !document.querySelector('[data-ecliptic-longitude=\"0.5\"]')) throw new Error(`front ecliptic is not graduated by half degrees (${eclipticTicks.length} ticks)`); if (document.querySelector('.astro-zodiac-label')) throw new Error('zodiac abbreviations remain on the front'); if (limbTicks.length !== 360 || !document.querySelector('[data-limb-degree=\"1\"]')) throw new Error(`front limb is not graduated by degrees (${limbTicks.length} ticks)`); const texture = document.querySelector('.astro-rete-edge-texture'); const filter = document.querySelector('#rete-edge-texture'); const textureStyle = texture && getComputedStyle(texture); if (!document.querySelector('.astro-rete-outer-rim') || !texture || !filter?.querySelector('feTurbulence') || textureStyle?.filter === 'none' || parseFloat(textureStyle?.strokeWidth || '0') < 18 || parseFloat(textureStyle?.opacity || '0') < 0.5) throw new Error('visible procedural rotating rete texture is missing'); })()"
11+
"eval": "(() => { const eclipticTicks = document.querySelectorAll('.astro-ecliptic-tick'); const limbTicks = document.querySelectorAll('.astro-limb-tick'); if (eclipticTicks.length !== 720 || !document.querySelector('[data-ecliptic-longitude=\"0.5\"]')) throw new Error(`front ecliptic is not graduated by half degrees (${eclipticTicks.length} ticks)`); if (document.querySelector('.astro-zodiac-label')) throw new Error('zodiac abbreviations remain on the front'); if (limbTicks.length !== 360 || !document.querySelector('[data-limb-degree=\"1\"]')) throw new Error(`front limb is not graduated by degrees (${limbTicks.length} ticks)`); const texture = document.querySelector('.astro-rete-edge-texture'); const filter = document.querySelector('#rete-edge-texture'); const textureStyle = texture && getComputedStyle(texture); if (!document.querySelector('.astro-rete-outer-rim') || !texture || !filter?.querySelector('feTurbulence') || textureStyle?.filter === 'none' || parseFloat(textureStyle?.strokeWidth || '0') < 18 || parseFloat(textureStyle?.opacity || '0') < 0.5) throw new Error('visible procedural rotating rete texture is missing'); const assists = document.querySelector('[data-testid=layer-artificialAssists]'); if (!assists || assists.checked || document.querySelector('.astro-sun')) throw new Error('artificial Sun assist is not off by default'); })()"
1212
}
1313
],
1414
"expect": { "state": "ready" }
@@ -25,7 +25,17 @@
2525
"eval": "new Promise((resolve) => { window.astrolabe.setRete(47); requestAnimationFrame(() => requestAnimationFrame(resolve)); })"
2626
},
2727
{
28-
"eval": "(() => { const rete = document.querySelector('[aria-label=\"Rete rotation\"]'); const texture = document.querySelector('.astro-rete-edge-texture'); if (rete?.getAttribute('transform') !== 'rotate(47)' || !texture) throw new Error('rete texture did not rotate with the star-map layer'); })()"
28+
"eval": "(() => { const rete = document.querySelector('[aria-label=\"Rete rotation\"]'); const texture = document.querySelector('.astro-rete-edge-texture'); if (rete?.getAttribute('aria-valuenow') !== '47' || !rete?.getAttribute('transform')?.startsWith('matrix(') || !texture) throw new Error('rete texture did not rotate with the star-map layer'); })()"
29+
}
30+
],
31+
"expect": { "state": "ready" }
32+
},
33+
{
34+
"label": "enable-artificial-assists",
35+
"steps": [
36+
{ "click": "[data-testid=layer-artificialAssists]" },
37+
{
38+
"eval": "(() => { if (!document.querySelector('.astro-sun')) throw new Error('calculated Sun assist did not appear'); if (new URLSearchParams(location.search).get('show') !== 'artificialAssists') throw new Error('enabled artificial assist was not preserved in the URL'); })()"
2939
}
3040
],
3141
"expect": { "state": "ready" }
@@ -107,7 +117,7 @@
107117
},
108118
{ "eval": "new Promise((resolve) => setTimeout(resolve, 900))" },
109119
{
110-
"eval": "(() => { const heading = document.querySelector('#lesson-step-heading'); if (heading?.textContent !== 'Set the sky') throw new Error('sky-setting step missing before star reading'); if (document.body.textContent.includes('Locate Sirius')) throw new Error('star reading appeared before sky setting completed'); if (heading.hasAttribute('tabindex')) throw new Error('non-interactive lesson heading is focusable'); if (document.activeElement === heading) throw new Error('lesson heading received focus'); })()"
120+
"eval": "(() => { const heading = document.querySelector('#lesson-step-heading'); if (heading?.textContent !== 'Set the sky') throw new Error('sky-setting step missing before star reading'); if (document.body.textContent.includes('Locate Sirius')) throw new Error('star reading appeared before sky setting completed'); if (document.body.textContent.includes('Sirius pointer')) throw new Error('lesson still calls Sirius a pointer'); if (heading.hasAttribute('tabindex')) throw new Error('non-interactive lesson heading is focusable'); if (document.activeElement === heading) throw new Error('lesson heading received focus'); const sirius = document.querySelector('[data-tutorial-target=\"front.star.sirius\"] .astro-star-dot'); const svg = sirius?.ownerSVGElement; if (!sirius || !svg) throw new Error('Sirius render target missing'); const star = new DOMPoint(Number(sirius.getAttribute('cx')), Number(sirius.getAttribute('cy'))).matrixTransform(sirius.getCTM()); const center = new DOMPoint(0, 0).matrixTransform(svg.getCTM()); if (star.x <= center.x) throw new Error(`Sirius rendered east of south instead of at azimuth about 191° (${star.x} <= ${center.x})`); })()"
111121
}
112122
],
113123
"expect": { "state": "ready" }

‎widgets/astrolabe-simulator/src/components/Controls.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ type LayerControl = { key: keyof Visibility; label: string; description?: string
77
const FRONT_LAYERS: LayerControl[] = [
88
{ key: 'almucantars', label: 'Altitude circles' }, { key: 'azimuths', label: 'Azimuths' },
99
{ key: 'unequalHours', label: 'Unequal hours' }, { key: 'ecliptic', label: 'Ecliptic' },
10+
{ key: 'artificialAssists', label: 'Artificial assists', description: 'Shows calculated markers that are not part of a historical astrolabe.' },
1011
{ key: 'stars', label: 'Stars' }, { key: 'rule', label: 'Rule' },
1112
];
1213
const BACK_LAYERS: LayerControl[] = [

‎widgets/astrolabe-simulator/src/components/Rete.tsx‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { JSX } from 'preact';
22
import { useRef, useState } from 'preact/hooks';
33
import { solarLongitude } from '../astro';
44
import { STARS } from '../data/stars';
5-
import { capricornRadius, eclipticCircle, eclipticPoint, project } from '../geometry';
5+
import { capricornRadius, eclipticCircle, eclipticPoint, project, reteOrientationMatrix } from '../geometry';
66
import { setRete, useStore, type Visibility } from '../store';
77
import { angleFromPointer, keyRotate, rotationDelta } from '../interaction';
88
import { ASTROLABE_R } from './Plate';
@@ -21,8 +21,12 @@ const ECLIPTIC_LABELS = eclipticLabels().map((longitude) => ({
2121
}));
2222
const RETE_RIM = capricornRadius(ASTROLABE_R);
2323

24+
function matrixTransform(rotation: number): string {
25+
return `matrix(${reteOrientationMatrix(rotation).join(' ')} 0 0)`;
26+
}
27+
2428
function uprightTransform(x: number, y: number, rotation: number): string {
25-
return `translate(${x} ${y}) rotate(${-rotation}) scale(1,-1)`;
29+
return `translate(${x} ${y}) ${matrixTransform(rotation)} scale(1,-1)`;
2630
}
2731

2832
export function Rete({ reteRotation, visibility }: ReteProps): JSX.Element {
@@ -44,7 +48,7 @@ export function Rete({ reteRotation, visibility }: ReteProps): JSX.Element {
4448
<g
4549
data-tutorial-target="front.rete"
4650
className={`astro-rotary${dragging ? ' is-dragging' : ''}`}
47-
transform={`rotate(${reteRotation})`}
51+
transform={matrixTransform(reteRotation)}
4852
tabIndex={0}
4953
role="slider"
5054
aria-label="Rete rotation"
@@ -64,7 +68,7 @@ export function Rete({ reteRotation, visibility }: ReteProps): JSX.Element {
6468
const start = drag.current;
6569
const svg = event.currentTarget.ownerSVGElement;
6670
if (!start || start.pointerId !== event.pointerId || !svg) return;
67-
setRete(start.rotation + rotationDelta(start.pointerAngle, angleFromPointer(svg, event.clientX, event.clientY)));
71+
setRete(start.rotation - rotationDelta(start.pointerAngle, angleFromPointer(svg, event.clientX, event.clientY)));
6872
}}
6973
onPointerUp={endDrag}
7074
onPointerCancel={endDrag}
@@ -101,7 +105,7 @@ export function Rete({ reteRotation, visibility }: ReteProps): JSX.Element {
101105
<circle className="astro-rotary-hit" r={rim} />
102106
<circle className="astro-rete-outer-rim" r={RETE_RIM} aria-label="Outer frame of the rete" />
103107
<circle className="astro-rete-edge-texture" r={RETE_RIM - 10} aria-hidden="true" />
104-
{visibility.ecliptic && <g clip-path="url(#plate-clip)" aria-label="Ecliptic longitude scale, graduated every half degree">
108+
{visibility.ecliptic && <g clip-path="url(#plate-clip)" aria-label="Ecliptic longitude scale, graduated every half degree" data-tutorial-target="front.ecliptic">
105109
<circle className="astro-rete-ring" cx={ecliptic.cx} cy={ecliptic.cy} r={ecliptic.r} />
106110
<circle className="astro-rete-thin" cx={ecliptic.cx} cy={ecliptic.cy} r={ecliptic.r - 14} />
107111
{ECLIPTIC_TICKS.map((tick) =>
@@ -112,10 +116,10 @@ export function Rete({ reteRotation, visibility }: ReteProps): JSX.Element {
112116
<text className="astro-ecliptic-degree-label" text-anchor="middle" dominant-baseline="middle">{longitude}°</text>
113117
</g>;
114118
})}
115-
<g data-tutorial-target="front.sun">
116-
<circle className="astro-sun" cx={sun.x} cy={sun.y} r={9} />
117-
<circle cx={sun.x} cy={sun.y} r={3} fill="var(--astro-mater-fill)" />
118-
</g>
119+
</g>}
120+
{visibility.artificialAssists && <g clip-path="url(#plate-clip)" aria-label="Artificial calculated assists">
121+
<circle className="astro-sun" cx={sun.x} cy={sun.y} r={9} aria-label="Calculated Sun position" />
122+
<circle cx={sun.x} cy={sun.y} r={3} fill="var(--astro-mater-fill)" />
119123
</g>}
120124

121125
{visibility.stars && <g clip-path="url(#plate-clip)">

‎widgets/astrolabe-simulator/src/geometry.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,26 @@ export function project(angleDeg: number, decDeg: number, R: number): ProjectedP
180180
};
181181
}
182182

183+
/**
184+
* Orient a catalogue point on the rete for local sidereal angle θ.
185+
*
186+
* The engraved catalogue angle is right ascension α, while the fixed plate
187+
* reads local hour angle H = θ − α. This is a reflection followed by a
188+
* rotation, not an ordinary rotation of the SVG's RA coordinates.
189+
*/
190+
export function orientRetePoint(point: Point, siderealDeg: number): Point {
191+
const sidereal = deg2rad(siderealDeg);
192+
return {
193+
x: -Math.cos(sidereal) * point.x + Math.sin(sidereal) * point.y,
194+
y: Math.sin(sidereal) * point.x + Math.cos(sidereal) * point.y,
195+
};
196+
}
197+
198+
export function reteOrientationMatrix(siderealDeg: number): [number, number, number, number] {
199+
const sidereal = deg2rad(siderealDeg);
200+
return [-Math.cos(sidereal), Math.sin(sidereal), Math.sin(sidereal), Math.cos(sidereal)];
201+
}
202+
183203
/**
184204
* Project a local horizontal-coordinate point onto the plate. Azimuth is
185205
* measured conventionally clockwise from north. This is useful for locating

‎widgets/astrolabe-simulator/src/knownObservation.test.ts‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import { describe, expect, it } from 'vitest';
22
import {
33
equatorialToHorizontal,
44
greenwichSiderealTime,
5+
localSiderealTime,
56
raDegFromHMS,
67
solarLongitude,
78
} from './astro';
8-
import { project, projectHorizontal, rOfDec } from './geometry';
9+
import { orientRetePoint, project, projectHorizontal, rOfDec } from './geometry';
910
import { STARS } from './data/stars';
1011

1112
/**
@@ -77,3 +78,52 @@ describe('externally verified Aldebaran observation', () => {
7778
expect(platePoint.y).toBeCloseTo(retePoint.y, 9);
7879
});
7980
});
81+
82+
/**
83+
* Sirius reference:
84+
* SIMBAD ICRS/J2000: 06 45 08.91728, −16 42 58.0171
85+
* https://simbad.u-strasbg.fr/simbad/sim-basic?Ident=sirius
86+
*
87+
* Astropy 8.0.1 ICRS → AltAz at 2026-07-14T12:00:00Z for
88+
* 51.5° N, 0.12° W, pressure=0 gives altitude 21.1228204159° and
89+
* azimuth 190.9588941942° clockwise from north:
90+
* https://docs.astropy.org/en/stable/coordinates/index.html
91+
*
92+
* This astrolabe intentionally holds J2000 catalogue coordinates fixed, so
93+
* its simpler result differs by about 0.30° in azimuth at this date.
94+
*/
95+
describe('externally verified Sirius lesson observation', () => {
96+
const date = new Date('2026-07-14T12:00:00Z');
97+
const latitude = 51.5;
98+
const longitude = -0.12;
99+
const sirius = {
100+
ra: raDegFromHMS(6, 45, 8.91728),
101+
dec: -(16 + 42 / 60 + 58.0171 / 3600),
102+
};
103+
const astropy = { altitude: 21.1228204159, azimuth: 190.9588941942 };
104+
105+
it('keeps the rete catalogue consistent with SIMBAD', () => {
106+
const catalogue = STARS.find((star) => star.name === 'Sirius');
107+
expect(catalogue).toBeDefined();
108+
expect(catalogue!.raDeg).toBeCloseTo(sirius.ra, 3);
109+
expect(catalogue!.decDeg).toBeCloseTo(sirius.dec, 3);
110+
});
111+
112+
it('agrees with the trusted apparent AltAz calculation within fixed-J2000 model accuracy', () => {
113+
const sidereal = localSiderealTime(date, longitude);
114+
const observation = equatorialToHorizontal(sirius.ra, sirius.dec, latitude, sidereal);
115+
expect(observation.altitude).toBeCloseTo(astropy.altitude, 1);
116+
expect(Math.abs(observation.azimuth - astropy.azimuth)).toBeLessThan(0.35);
117+
expect(observation.azimuth).toBeGreaterThan(180);
118+
});
119+
120+
it('renders Sirius at the same altitude and azimuth grid intersection', () => {
121+
const sidereal = localSiderealTime(date, longitude);
122+
const observation = equatorialToHorizontal(sirius.ra, sirius.dec, latitude, sidereal);
123+
const renderedStar = orientRetePoint(project(sirius.ra, sirius.dec, RADIUS), sidereal);
124+
const platePoint = projectHorizontal(latitude, observation.altitude, observation.azimuth, RADIUS);
125+
126+
expect(renderedStar.x).toBeCloseTo(platePoint.x, 9);
127+
expect(renderedStar.y).toBeCloseTo(platePoint.y, 9);
128+
});
129+
});

‎widgets/astrolabe-simulator/src/store.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export interface Visibility {
2424
azimuths: boolean;
2525
unequalHours: boolean;
2626
ecliptic: boolean;
27+
artificialAssists: boolean;
2728
stars: boolean;
2829
rule: boolean;
2930
tropics: boolean;
@@ -77,6 +78,7 @@ function defaultState(): AstrolabeState {
7778
azimuths: true,
7879
unequalHours: true,
7980
ecliptic: true,
81+
artificialAssists: false,
8082
stars: true,
8183
rule: true,
8284
tropics: true,

‎widgets/astrolabe-simulator/src/tutorial/catalog.test.ts‎

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest';
2-
import { eclipticPoint, project } from '../geometry';
2+
import { eclipticPoint, orientRetePoint, project } from '../geometry';
33
import { alidadeLineCross, backLongitudePoint } from '../backGeometry';
44
import { FUTURE_TOPICS, LESSONS, SIRIUS_FIXTURE, SUN_FIXTURE, validateCatalog } from './catalog';
55

@@ -20,29 +20,23 @@ describe('tutorial catalog', () => {
2020
});
2121
it('derives a Sirius fixture whose rule, rete, and altitude agree', () => {
2222
const star = project(SIRIUS_FIXTURE.star.raDeg, SIRIUS_FIXTURE.star.decDeg, 380);
23-
const rete = SIRIUS_FIXTURE.reteRotation * Math.PI / 180;
24-
const transformed = {
25-
x: star.x * Math.cos(rete) - star.y * Math.sin(rete),
26-
y: star.x * Math.sin(rete) + star.y * Math.cos(rete),
27-
};
23+
const transformed = orientRetePoint(star, SIRIUS_FIXTURE.reteRotation);
2824
const rule = SIRIUS_FIXTURE.ruleRotation * Math.PI / 180;
2925
expect(transformed.x * Math.cos(rule) + transformed.y * Math.sin(rule)).toBeCloseTo(0, 8);
3026
expect(SIRIUS_FIXTURE.altitude).toBeCloseTo(21.1142782184, 8);
3127
expect(LESSONS[1].steps.at(-1)?.result).toContain(`approximately ${SIRIUS_FIXTURE.altitude.toFixed(1)}°`);
3228
});
3329
it('sets the sky by aligning the dated Sun point with the time rule', () => {
3430
const sun = eclipticPoint(SUN_FIXTURE.eclipticLongitude, 380);
35-
const rete = SUN_FIXTURE.reteRotation * Math.PI / 180;
36-
const transformed = {
37-
x: sun.x * Math.cos(rete) - sun.y * Math.sin(rete),
38-
y: sun.x * Math.sin(rete) + sun.y * Math.cos(rete),
39-
};
31+
const transformed = orientRetePoint(sun, SUN_FIXTURE.reteRotation);
4032
const rule = SUN_FIXTURE.ruleRotation * Math.PI / 180;
4133
expect(transformed.x * Math.cos(rule) + transformed.y * Math.sin(rule)).toBeCloseTo(0, 8);
4234
const lessonCopy = LESSONS[1].steps.map((item) => `${item.title} ${item.body}`).join(' ');
4335
expect(lessonCopy).not.toMatch(/prepared/i);
36+
expect(lessonCopy).not.toMatch(/Sun marker|Sirius pointer|pointer shows/i);
4437
expect(lessonCopy).toMatch(/calendar.+ecliptic longitude.+rule.+rotate the rete/is);
4538
expect(lessonCopy).toMatch(/inner edge.+across the zodiac-sign band.+outer side/is);
39+
expect(LESSONS[1].steps.every((step) => !step.snapshot.visibility.artificialAssists)).toBe(true);
4640
});
4741
it('aligns the lesson alidade with the dated back-scale radial', () => {
4842
const datePoint = backLongitudePoint(456, SUN_FIXTURE.eclipticLongitude);

0 commit comments

Comments
 (0)