From 01d3a1c40acdce6432ab5eb3f3c6cb8b8e90d815 Mon Sep 17 00:00:00 2001 From: John Beshir Date: Fri, 31 Jul 2026 02:26:12 +0100 Subject: [PATCH] Complete the astrolabe lesson curriculum --- widgets/astrolabe-simulator/LESSON_PLAN.md | 67 +++------ widgets/astrolabe-simulator/journey.json | 12 ++ .../src/components/AstrolabeGuide.tsx | 23 +-- .../src/components/Back.tsx | 4 +- .../src/components/Plate.tsx | 2 +- widgets/astrolabe-simulator/src/styles.css | 17 +-- .../src/tutorial/catalog.test.ts | 97 ++++++++++--- .../src/tutorial/catalog.ts | 132 ++++++++++++++++-- .../astrolabe-simulator/src/tutorial/types.ts | 13 +- 9 files changed, 245 insertions(+), 122 deletions(-) diff --git a/widgets/astrolabe-simulator/LESSON_PLAN.md b/widgets/astrolabe-simulator/LESSON_PLAN.md index 3b635aa..4260af6 100644 --- a/widgets/astrolabe-simulator/LESSON_PLAN.md +++ b/widgets/astrolabe-simulator/LESSON_PLAN.md @@ -50,51 +50,28 @@ value on the alidade, and teaches how to use the reading from that point on. ## Course sequence -### 1. Understand and configure the astrolabe front - -**Prerequisites:** none. - -This substantial lesson identifies the fixed plate, moving rete, and reading -rule; demonstrates their independent motion; and shows why the observer's -latitude determines the plate. It includes choosing London's exact plate, -observing the warning from a nearby plate, and restoring the exact match. - -### 2. Set the astrolabe for a date and time - -**Prerequisite:** lesson 1. - -This substantial lesson carries July 14 from the back calendar to the -ecliptic-longitude scale, finds the same longitude on the front, sets the rule -to local apparent solar time, and turns the rete until the date point lies -beneath the rule. The result is a complete sky setting rather than an -unexplained rotation angle. - -### 3. Read a star and follow its daily path - -**Prerequisites:** lessons 1 and 2. - -This substantial lesson locates Sirius on the rete, reads its altitude and -azimuth from the fixed plate, and then keeps the date point beneath the moving -rule while following Sirius to the eastern horizon, culmination, and western -horizon. - -### 4. Solar operations - -Keep these as separate future lessons: - -- determine sunrise, noon, and sunset; -- read unequal hours on the front. - -### 5. Back operations - -Keep these as separate lessons: - -- measure altitude with the alidade; -- use the equation-of-time loop; -- measure height with the shadow square; -- read a temporal hour on the double horary quadrant (implemented). - -There is no section 6 in the current course plan. +The guide presents one ordered list rather than dividing operations by face. +Many real operations move between the front and back, so a face-based taxonomy +would be misleading. + +1. **Understand and configure the astrolabe front.** Identify the fixed plate, + moving rete, and rule; compare a nearby plate with London's exact plate. +2. **Set the astrolabe for a date and time.** Carry July 14 from the back + calendar to ecliptic longitude, set the rule, and orient the rete. +3. **Read a star and follow its daily path.** Read Sirius and follow it through + rising, culmination, and setting. +4. **Find sunrise, noon, and sunset.** Carry the Sun's engraved date point + across the eastern horizon, meridian, and western horizon. +5. **Read a daylight temporal hour.** Use the point opposite the Sun and the + lower unequal-hour curves. +6. **Transfer a measured altitude to the alidade.** Explain the external + physical sighting and set its supplied angle on the simulated back. +7. **Convert apparent solar time to mean solar time.** Read and apply the + equation-of-time loop with its engraved sign convention. +8. **Measure a height with the shadow square.** Use an external angle and known + distance to read a proportional height. +9. **Read a temporal hour on the double horary quadrant.** Transfer the + curve-VI distance between noon and observed-altitude settings. ## Assessment and test requirements diff --git a/widgets/astrolabe-simulator/journey.json b/widgets/astrolabe-simulator/journey.json index b5c0a1d..fdc7a67 100644 --- a/widgets/astrolabe-simulator/journey.json +++ b/widgets/astrolabe-simulator/journey.json @@ -9,6 +9,9 @@ "steps": [ { "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'); })()" + }, + { + "eval": "(() => { const cards = [...document.querySelectorAll('.lesson-card')]; const starts = [...document.querySelectorAll('[data-testid^=\"start-\"]')]; if (cards.length !== 9 || starts.length !== 9) throw new Error(`complete curriculum not rendered (${cards.length} cards, ${starts.length} starts)`); if (document.querySelector('.future-card, .lesson-category, .lesson-card button:disabled')) throw new Error('lesson categories or placeholders remain'); const titles = cards.map((card) => card.querySelector('h3')?.textContent); if (!titles[3]?.includes('sunrise, noon, and sunset') || !titles[6]?.includes('apparent solar time') || !titles[7]?.includes('shadow square')) throw new Error(`curriculum order is wrong: ${titles.join(' | ')}`); })()" } ], "expect": { "state": "ready" } @@ -121,6 +124,15 @@ } ], "expect": { "state": "ready" } + }, + { + "label": "all-completed-lessons-launch", + "steps": [ + { + "eval": "(async () => { const cases = [['solar.events.v1', 'Choose the date and place', 'front'], ['front.unequal-hours.v1', 'Understand temporal hours', 'front'], ['back.measure-altitude.v1', 'Understand the physical observation', 'back'], ['back.equation-time.v1', 'Distinguish the two kinds of time', 'back'], ['back.shadow-square.v1', 'Choose the known measurements', 'back'], ['back.unequal-hours.v1', 'Turn to the back', 'back']]; const settle = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); for (const [id, heading, face] of cases) { const start = document.querySelector(`[data-testid=\"start-${CSS.escape(id)}\"]`); if (!start) throw new Error(`missing start button for ${id}`); start.click(); await settle(); if (document.querySelector('#lesson-step-heading')?.textContent !== heading) throw new Error(`${id} did not open at its first step`); if (window.astrolabe.getState().face !== face) throw new Error(`${id} opened on the wrong face`); const all = [...document.querySelectorAll('button')].find((button) => button.textContent === 'All lessons'); if (!all) throw new Error(`cannot return from ${id}`); all.click(); await settle(); } })()" + } + ], + "expect": { "state": "ready" } } ] } diff --git a/widgets/astrolabe-simulator/src/components/AstrolabeGuide.tsx b/widgets/astrolabe-simulator/src/components/AstrolabeGuide.tsx index 138f96f..d4a6ae3 100644 --- a/widgets/astrolabe-simulator/src/components/AstrolabeGuide.tsx +++ b/widgets/astrolabe-simulator/src/components/AstrolabeGuide.tsx @@ -5,7 +5,7 @@ import { type AstrolabeState, } from '../store'; import { animateAngle } from '../tutorial/animation'; -import { LESSONS, FUTURE_TOPICS } from '../tutorial/catalog'; +import { LESSONS } from '../tutorial/catalog'; import { evaluateCheckpoint } from '../tutorial/checkpoints'; import { composeTutorialSearch, parseTutorialSearch } from '../tutorial/url'; import type { Lesson, Snapshot } from '../tutorial/types'; @@ -136,26 +136,17 @@ export function AstrolabeGuide(): JSX.Element { {tab === 'learn' ?

Astrolabe Guide

Choose a guided lesson to learn the instrument step by step.

- {[...new Set(LESSONS.map((item) => item.category))].map((category) =>
-

{category}

- {LESSONS.filter((item) => item.category === category).map((item) =>
-

{item.title}

{item.summary}

-

{item.steps.length} steps · {item.id.startsWith('back') ? 'Back' : 'Front'}

+
+ {LESSONS.map((item, index) =>
+

{index + 1}. {item.title}

{item.summary}

+

{item.steps.length} steps

)} -
)} -

Future operations

- {[...new Set(FUTURE_TOPICS.map((item) => item.category))].map((category) =>
-

{category}

- {FUTURE_TOPICS.filter((item) => item.category === category).map((item) =>
-

{item.title}

{item.prerequisite}

- -
)} -
)} +
: } :
-

{lesson.category} · {lesson.title}

+

{lesson.title}

{!complete && step && <>

Step {stepIndex + 1} of {lesson.steps.length}

{step.title}

diff --git a/widgets/astrolabe-simulator/src/components/Back.tsx b/widgets/astrolabe-simulator/src/components/Back.tsx index e33b12d..4c617ba 100644 --- a/widgets/astrolabe-simulator/src/components/Back.tsx +++ b/widgets/astrolabe-simulator/src/components/Back.tsx @@ -119,7 +119,7 @@ export function Back(): JSX.Element { - {visibility.shadowSquare && + {visibility.shadowSquare && {shadow.verticals.map((x, part) => )} {shadow.horizontals.map((y, part) => )} @@ -163,7 +163,7 @@ export function Back(): JSX.Element { ))} } - {visibility.equationOfTime && + {visibility.equationOfTime && EOT diff --git a/widgets/astrolabe-simulator/src/components/Plate.tsx b/widgets/astrolabe-simulator/src/components/Plate.tsx index ef51d53..8d2bf63 100644 --- a/widgets/astrolabe-simulator/src/components/Plate.tsx +++ b/widgets/astrolabe-simulator/src/components/Plate.tsx @@ -84,7 +84,7 @@ export function Plate({ latitude, visibility }: PlateProps): JSX.Element { {visibility.azimuths && {azimuthDegrees.map((degrees) => )} } - {visibility.unequalHours && + {visibility.unequalHours && {UNEQUAL_HOUR_LINES.map((hour) => , )} diff --git a/widgets/astrolabe-simulator/src/styles.css b/widgets/astrolabe-simulator/src/styles.css index c66a432..5fd4476 100644 --- a/widgets/astrolabe-simulator/src/styles.css +++ b/widgets/astrolabe-simulator/src/styles.css @@ -249,7 +249,7 @@ button, input, select { .astrolabe-guide button:focus-visible { outline: 3px solid var(--input-focus); outline-offset: 2px; } .astrolabe-guide h2 { margin-top: 10px; } .astrolabe-guide h3 { margin: 18px 0 8px; font-size: 17px; } -.astrolabe-guide h4 { margin: 0; font-size: 16px; } +.lesson-card h3 { margin: 0; font-size: 16px; } .guide-intro, .lesson-card p { color: var(--fg-secondary); } .lesson-card { margin-bottom: 10px; @@ -260,20 +260,6 @@ button, input, select { } .lesson-card p { margin: 5px 0 10px; } .lesson-meta, .lesson-progress, .lesson-breadcrumb, .target-note { color: var(--muted); font-size: 14px; } -.future-card { - color: var(--fg); - background: var(--card-bg); - border-color: var(--card-border); -} -/* Theme audits sample disabled controls directly: keep every surface fully - opaque and explicit. Never dim this card or one of its ancestors. */ -.future-card button:disabled { - opacity: 1; - color: var(--fg-secondary); - background-color: var(--card-bg); - border-color: var(--card-border); - cursor: not-allowed; -} .lesson-player h2:focus { outline: 3px solid var(--input-focus); outline-offset: 3px; } .lesson-result { padding: 10px; border-left: 4px solid var(--accent); background: var(--card-bg); } .lesson-actions, .interruption, .completion { display: flex; flex-wrap: wrap; gap: 8px; } @@ -412,7 +398,6 @@ button, input, select { } @media (forced-colors: active) { - .future-card, .future-card button:disabled { color: GrayText; background: Canvas; border-color: GrayText; opacity: 1; } .astrolabe-guide button:focus-visible { outline-color: Highlight; } } diff --git a/widgets/astrolabe-simulator/src/tutorial/catalog.test.ts b/widgets/astrolabe-simulator/src/tutorial/catalog.test.ts index 194e361..e15cb91 100644 --- a/widgets/astrolabe-simulator/src/tutorial/catalog.test.ts +++ b/widgets/astrolabe-simulator/src/tutorial/catalog.test.ts @@ -1,13 +1,30 @@ import { describe, expect, it } from 'vitest'; import { eclipticPoint, orientRetePoint, project } from '../geometry'; import { alidadeLineCross, backLongitudePoint } from '../backGeometry'; -import { FUTURE_TOPICS, LESSONS, SIRIUS_FIXTURE, SIRIUS_PATH_FIXTURE, SUN_FIXTURE, validateCatalog } from './catalog'; +import { equationOfTime } from '../astro'; +import { shadowSquareLayout } from '../shadowSquare'; +import { unequalHourPoint } from '../unequalHours'; +import { + ALTITUDE_FIXTURE, EQUATION_TIME_FIXTURE, FRONT_UNEQUAL_HOUR_FIXTURE, LESSONS, + SHADOW_SQUARE_FIXTURE, SIRIUS_FIXTURE, SIRIUS_PATH_FIXTURE, SOLAR_EVENT_FIXTURE, + SUN_FIXTURE, validateCatalog, +} from './catalog'; describe('tutorial catalog', () => { - it('has exactly the four enabled permanent IDs', () => { + it('has the complete ordered curriculum and no placeholders', () => { expect(LESSONS.map((lesson) => lesson.id)).toEqual([ - 'front.foundations.v1', 'front.set-sky.v1', 'front.read-star.v1', 'back.unequal-hours.v1', + 'front.foundations.v1', + 'front.set-sky.v1', + 'front.read-star.v1', + 'solar.events.v1', + 'front.unequal-hours.v1', + 'back.measure-altitude.v1', + 'back.equation-time.v1', + 'back.shadow-square.v1', + 'back.unequal-hours.v1', ]); + expect(LESSONS.every((lesson) => !('category' in lesson))).toBe(true); + expect(JSON.stringify(LESSONS)).not.toMatch(/planned lesson|coming later|future operations/i); }); it('keeps each foundational lesson substantial', () => { expect(LESSONS.slice(0, 3).map((lesson) => lesson.steps.length)).toEqual([9, 7, 8]); @@ -50,7 +67,7 @@ describe('tutorial catalog', () => { expect(LESSONS[1].steps.every((step) => !step.snapshot.visibility.artificialAssists)).toBe(true); }); it('does not use the confusing prepared terminology anywhere in the curriculum', () => { - expect(JSON.stringify({ lessons: LESSONS, future: FUTURE_TOPICS })).not.toMatch(/prepared/i); + expect(JSON.stringify(LESSONS)).not.toMatch(/prepared/i); }); it('follows Sirius from the eastern horizon through culmination to the western horizon', () => { expect(SIRIUS_PATH_FIXTURE.rising.altitude).toBeCloseTo(0, 8); @@ -66,6 +83,62 @@ describe('tutorial catalog', () => { const rule = event.ruleRotation * Math.PI / 180; expect(transformed.x * Math.cos(rule) + transformed.y * Math.sin(rule)).toBeCloseTo(0, 8); }); + it('places the Sun on the horizon at rise and set and on the meridian at noon', () => { + expect(SOLAR_EVENT_FIXTURE.sunrise.altitude).toBeCloseTo(0, 8); + expect(SOLAR_EVENT_FIXTURE.sunset.altitude).toBeCloseTo(0, 8); + expect(SOLAR_EVENT_FIXTURE.sunrise.azimuth).toBeLessThan(180); + expect(SOLAR_EVENT_FIXTURE.sunset.azimuth).toBeGreaterThan(180); + expect(SOLAR_EVENT_FIXTURE.noon.azimuth).toBeCloseTo(180, 8); + expect(SOLAR_EVENT_FIXTURE.noon.altitude).toBeGreaterThan(50); + }); + it.each(Object.entries(SOLAR_EVENT_FIXTURE))('keeps the Sun date point under the event rule at %s', (_name, event) => { + const sun = eclipticPoint(SUN_FIXTURE.eclipticLongitude, 380); + const transformed = orientRetePoint(sun, event.reteRotation); + const rule = event.ruleRotation * Math.PI / 180; + expect(transformed.x * Math.cos(rule) + transformed.y * Math.sin(rule)).toBeCloseTo(0, 8); + }); + it('constructs temporal hour IX three quarters through daylight', () => { + const daylightArc = SOLAR_EVENT_FIXTURE.sunset.reteRotation - SOLAR_EVENT_FIXTURE.sunrise.reteRotation; + const normalizedArc = daylightArc < 0 ? daylightArc + 360 : daylightArc; + const fromSunrise = FRONT_UNEQUAL_HOUR_FIXTURE.reteRotation - SOLAR_EVENT_FIXTURE.sunrise.reteRotation; + const normalizedFromSunrise = fromSunrise < 0 ? fromSunrise + 360 : fromSunrise; + expect(FRONT_UNEQUAL_HOUR_FIXTURE.hour).toBe(9); + expect(normalizedFromSunrise / normalizedArc).toBeCloseTo(9 / 12, 10); + const antipode = orientRetePoint( + eclipticPoint(SUN_FIXTURE.eclipticLongitude + 180, 380), + FRONT_UNEQUAL_HOUR_FIXTURE.reteRotation, + ); + const hourPoint = unequalHourPoint( + 51.5, + FRONT_UNEQUAL_HOUR_FIXTURE.hour, + FRONT_UNEQUAL_HOUR_FIXTURE.antipodalDeclination, + 380, + ); + expect(hourPoint).not.toBeNull(); + expect(antipode.x).toBeCloseTo(hourPoint!.x, 8); + expect(antipode.y).toBeCloseTo(hourPoint!.y, 8); + }); + it('treats the simulated altitude as an external observation', () => { + const lesson = LESSONS.find((item) => item.id === 'back.measure-altitude.v1'); + expect(lesson?.steps.find((step) => step.id === 'set-alidade')?.check).toMatchObject({ + field: 'alidadeRotation', value: ALTITUDE_FIXTURE.angle, + }); + expect(lesson?.steps.map((step) => step.body).join(' ')).toMatch(/cannot reproduce that sight line/i); + expect(ALTITUDE_FIXTURE.angle + (90 - ALTITUDE_FIXTURE.angle)).toBe(90); + }); + it('uses the rendered equation-of-time convention and dated radial', () => { + expect(EQUATION_TIME_FIXTURE.minutes).toBeCloseTo(equationOfTime(new Date('2026-07-14T12:00:00.000Z')), 10); + const datePoint = backLongitudePoint(456, SUN_FIXTURE.eclipticLongitude); + expect(alidadeLineCross(datePoint, EQUATION_TIME_FIXTURE.alidadeRotation)).toBeCloseTo(0, 10); + expect(EQUATION_TIME_FIXTURE.minutes).toBeLessThan(0); + }); + it('makes the 45-degree shadow-square journey land exactly on its 1:1 corner', () => { + const layout = shadowSquareLayout(); + expect(SHADOW_SQUARE_FIXTURE.intersection).toEqual({ + x: layout.left, y: layout.bottom, edge: 'bottom', + }); + expect(SHADOW_SQUARE_FIXTURE.height / SHADOW_SQUARE_FIXTURE.distance).toBe(1); + }); it('aligns the lesson alidade with the dated back-scale radial', () => { const datePoint = backLongitudePoint(456, SUN_FIXTURE.eclipticLongitude); const longitudePoint = backLongitudePoint(590, SUN_FIXTURE.eclipticLongitude); @@ -80,25 +153,13 @@ describe('tutorial catalog', () => { }); }); it('commits the unequal-hour fixture and approximation', () => { - const copy = LESSONS[3].steps.map((step) => `${step.body} ${step.result}`).join(' '); + const lesson = LESSONS.find((item) => item.id === 'back.unequal-hours.v1'); + const copy = lesson?.steps.map((step) => `${step.body} ${step.result}`).join(' ') ?? ''; expect(copy).toContain('70°'); expect(copy).toContain('27.5°'); expect(copy).toMatch(/approximate/i); expect(copy).toContain('hour X'); }); - it('categorizes all future work and names prerequisites', () => { - expect(new Set(FUTURE_TOPICS.map((topic) => topic.category))).toEqual(new Set([ - 'Solar operations', 'Back operations', - ])); - expect(FUTURE_TOPICS.map((topic) => topic.title)).toEqual([ - 'Determine sunrise, noon, and sunset', - 'Read unequal hours on the front', - 'Measure altitude with the alidade', - 'Use the equation-of-time loop', - 'Measure height with the shadow square', - ]); - for (const topic of FUTURE_TOPICS) expect(topic.prerequisite).toMatch(/^Planned[: ]/); - }); it('gives every enabled lesson a real scripted instrument demonstration', () => { for (const lesson of LESSONS) { expect(lesson.steps.some((item) => item.demonstration)).toBe(true); diff --git a/widgets/astrolabe-simulator/src/tutorial/catalog.ts b/widgets/astrolabe-simulator/src/tutorial/catalog.ts index 06ec9db..7df1f16 100644 --- a/widgets/astrolabe-simulator/src/tutorial/catalog.ts +++ b/widgets/astrolabe-simulator/src/tutorial/catalog.ts @@ -1,9 +1,10 @@ import type { AstrolabeState } from '../store'; -import { equatorialToHorizontal, localSiderealTime, normalizeDeg, solarLongitude } from '../astro'; -import { eclipticPoint, orientRetePoint } from '../geometry'; +import { equationOfTime, equatorialToHorizontal, localSiderealTime, normalizeDeg, solarLongitude } from '../astro'; +import { eclipticPoint, OBLIQUITY_DEG, orientRetePoint } from '../geometry'; import { alidadeRotationForLongitude } from '../backGeometry'; +import { shadowSquareIntersection } from '../shadowSquare'; import { STARS } from '../data/stars'; -import type { FutureTopic, Lesson, LessonStep, Snapshot } from './types'; +import type { Lesson, LessonStep, Snapshot } from './types'; const epochIso = '2026-07-14T12:00:00.000Z'; const lessonLocation = { label: 'London', lat: 51.5, lng: -0.12, manual: false } as const; @@ -53,6 +54,51 @@ export const SIRIUS_PATH_FIXTURE = { culmination: pathEvent(sirius.raDeg), setting: pathEvent(sirius.raDeg + horizonHourAngle), } as const; +const sunLongitudeRad = sunLongitude * Math.PI / 180; +const obliquityRad = OBLIQUITY_DEG * Math.PI / 180; +const sunRightAscension = normalizeDeg(Math.atan2( + Math.sin(sunLongitudeRad) * Math.cos(obliquityRad), + Math.cos(sunLongitudeRad), +) * 180 / Math.PI); +const sunDeclination = Math.asin(Math.sin(obliquityRad) * Math.sin(sunLongitudeRad)) * 180 / Math.PI; +const sunHorizonHourAngle = Math.acos( + -Math.tan(latitudeRad) * Math.tan(sunDeclination * Math.PI / 180), +) * 180 / Math.PI; +const solarEvent = (hourAngle: number) => { + const reteRotation = normalizeDeg(sunRightAscension + hourAngle); + const observation = equatorialToHorizontal( + sunRightAscension, sunDeclination, lessonLocation.lat, reteRotation, + ); + return { + reteRotation, + ruleRotation: ruleRotationForSidereal(reteRotation), + altitude: observation.altitude, + azimuth: observation.azimuth, + }; +}; +export const SOLAR_EVENT_FIXTURE = { + sunrise: solarEvent(-sunHorizonHourAngle), + noon: solarEvent(0), + sunset: solarEvent(sunHorizonHourAngle), +} as const; +export const FRONT_UNEQUAL_HOUR_FIXTURE = { + hour: 9, + antipodalDeclination: -sunDeclination, + ...solarEvent(sunHorizonHourAngle / 2), +} as const; +export const ALTITUDE_FIXTURE = { angle: 35 } as const; +export const EQUATION_TIME_FIXTURE = { + minutes: equationOfTime(new Date(epochIso)), + alidadeRotation: SUN_FIXTURE.alidadeRotation, +} as const; +const shadowIntersection = shadowSquareIntersection(45); +if (!shadowIntersection) throw new Error('The 45-degree shadow-square fixture must intersect the square'); +export const SHADOW_SQUARE_FIXTURE = { + angle: 45, + distance: 12, + height: 12, + intersection: shadowIntersection, +} as const; const visibility: AstrolabeState['visibility'] = { almucantars: true, azimuths: true, unequalHours: true, ecliptic: true, artificialAssists: false, stars: true, rule: true, tropics: true, calendar: true, zodiacScale: true, shadowSquare: true, @@ -75,7 +121,7 @@ const step = (id: string, title: string, body: string, target: LessonStep['targe export const LESSONS = [ { - id: 'front.foundations.v1', version: 1, category: 'Foundations', + id: 'front.foundations.v1', version: 1, title: 'Understand and configure the astrolabe front', summary: 'Identify the fixed and moving parts, then choose the latitude plate used for a reading.', steps: [ @@ -91,7 +137,7 @@ export const LESSONS = [ ], }, { - id: 'front.set-sky.v1', version: 1, category: 'Front operations', + id: 'front.set-sky.v1', version: 1, title: 'Set the astrolabe for a date and time', summary: 'Convert a calendar date to ecliptic longitude, set the time, and orient the rete.', steps: [ @@ -105,7 +151,7 @@ export const LESSONS = [ ], }, { - id: 'front.read-star.v1', version: 1, category: 'Front operations', + id: 'front.read-star.v1', version: 1, title: 'Read a star and follow its daily path', summary: 'Read Sirius from a known date-and-time setting, then follow it from rising to setting.', steps: [ @@ -120,7 +166,71 @@ export const LESSONS = [ ], }, { - id: 'back.unequal-hours.v1', version: 1, category: 'Back operations', + id: 'solar.events.v1', version: 1, + title: 'Find sunrise, noon, and sunset', + summary: 'Use the Sun’s date point, horizon, meridian, and rule to read the day’s three principal solar events.', + steps: [ + step('choose-date', 'Choose the date and place', 'Use London on July 14. From the earlier date-setting lesson, July 14 corresponds to the marked ecliptic longitude on the rete.', 'instrument', base('front'), `The Sun’s date point is ${SUN_FIXTURE.eclipticLongitude.toFixed(1)}° ecliptic longitude on London’s 51.5° plate.`), + step('identify-sun-point', 'Identify the Sun’s date point', 'Use the ecliptic-longitude scale to identify July 14’s point. This engraved point represents the Sun for every event in this lesson; no added Sun marker is needed.', 'front.ecliptic', base('front'), 'The same ecliptic point will be carried across the local horizon and meridian.'), + step('find-sunrise', 'Move the Sun to the eastern horizon', 'Turn the rete until the date point meets the eastern side of the horizon. Then place the rule through that point and read the corresponding time on the limb.', 'front.ecliptic', base('front', { reteRotation: SOLAR_EVENT_FIXTURE.noon.reteRotation, ruleRotation: SOLAR_EVENT_FIXTURE.sunrise.ruleRotation }), `At sunrise the point is on the horizon at azimuth ${SOLAR_EVENT_FIXTURE.sunrise.azimuth.toFixed(1)}°.`, { demonstration: { field: 'reteRotation', from: SOLAR_EVENT_FIXTURE.noon.reteRotation, to: SOLAR_EVENT_FIXTURE.sunrise.reteRotation, durationMs: 900 }, check: { kind: 'angleNear', field: 'reteRotation', value: SOLAR_EVENT_FIXTURE.sunrise.reteRotation, tolerance: 2 } }), + step('find-noon', 'Move the Sun to the meridian', 'Turn the rete until the date point crosses the southern meridian. Move the rule to remain over the point; the limb now reads local apparent noon.', 'front.ecliptic', base('front', { reteRotation: SOLAR_EVENT_FIXTURE.sunrise.reteRotation, ruleRotation: SOLAR_EVENT_FIXTURE.noon.ruleRotation }), `At noon the Sun culminates due south at altitude ${SOLAR_EVENT_FIXTURE.noon.altitude.toFixed(1)}°.`, { demonstration: { field: 'reteRotation', from: SOLAR_EVENT_FIXTURE.sunrise.reteRotation, to: SOLAR_EVENT_FIXTURE.noon.reteRotation, durationMs: 900 }, check: { kind: 'angleNear', field: 'reteRotation', value: SOLAR_EVENT_FIXTURE.noon.reteRotation, tolerance: 2 } }), + step('find-sunset', 'Move the Sun to the western horizon', 'Continue turning the rete until the date point meets the western side of the horizon, again moving the rule through the point to read the time.', 'front.ecliptic', base('front', { reteRotation: SOLAR_EVENT_FIXTURE.noon.reteRotation, ruleRotation: SOLAR_EVENT_FIXTURE.sunset.ruleRotation }), `At sunset the point is on the horizon at azimuth ${SOLAR_EVENT_FIXTURE.sunset.azimuth.toFixed(1)}°.`, { demonstration: { field: 'reteRotation', from: SOLAR_EVENT_FIXTURE.noon.reteRotation, to: SOLAR_EVENT_FIXTURE.sunset.reteRotation, durationMs: 900 }, check: { kind: 'angleNear', field: 'reteRotation', value: SOLAR_EVENT_FIXTURE.sunset.reteRotation, tolerance: 2 } }), + step('interpret-day', 'Interpret the three readings', 'The two horizon crossings give sunrise and sunset; the meridian crossing gives local apparent noon and the Sun’s greatest altitude. The intervals on the limb give morning length, afternoon length, and total daylight.', 'instrument', base('front', { reteRotation: SOLAR_EVENT_FIXTURE.sunset.reteRotation, ruleRotation: SOLAR_EVENT_FIXTURE.sunset.ruleRotation }), 'Result: the Sun has been followed from sunrise through noon to sunset without an artificial marker.'), + ], + }, + { + id: 'front.unequal-hours.v1', version: 1, + title: 'Read a daylight temporal hour', + summary: 'Use the Sun’s antipodal point and the lower plate curves to divide daylight into twelve seasonal hours.', + steps: [ + step('recall-temporal-hours', 'Understand temporal hours', 'Temporal hours divide the daylight interval from sunrise to sunset into twelve equal parts. Their clock duration therefore changes with the season.', 'instrument', base('front'), 'The sixth temporal hour is local apparent noon; the ninth is three quarters of the way from sunrise to sunset.'), + step('set-date-point', 'Set the Sun’s date point', `Use July 14’s ${SUN_FIXTURE.eclipticLongitude.toFixed(1)}° ecliptic-longitude point, as in the preceding solar-events lesson.`, 'front.ecliptic', base('front'), 'The Sun’s seasonal declination is fixed by its date point.'), + step('use-opposite-point', 'Use the point opposite the Sun', 'During daylight the Sun is above the horizon while the unequal-hour curves are engraved below it. Follow the ecliptic exactly 180° from the Sun’s point and use that antipodal point for the reading.', 'front.ecliptic', base('front'), 'The antipodal point mirrors the daytime solar path onto the lower unequal-hour scale.'), + step('find-hour-nine', 'Move to temporal hour IX', 'Turn the rete until the antipodal point meets curve IX. Keep the date point beneath the rule so the rule can read the corresponding local apparent solar time.', 'front.unequal-hours', base('front', { reteRotation: SOLAR_EVENT_FIXTURE.sunrise.reteRotation, ruleRotation: FRONT_UNEQUAL_HOUR_FIXTURE.ruleRotation }), 'The antipodal date point lies on curve IX, three quarters of the way through daylight.', { demonstration: { field: 'reteRotation', from: SOLAR_EVENT_FIXTURE.sunrise.reteRotation, to: FRONT_UNEQUAL_HOUR_FIXTURE.reteRotation, durationMs: 900 }, check: { kind: 'angleNear', field: 'reteRotation', value: FRONT_UNEQUAL_HOUR_FIXTURE.reteRotation, tolerance: 2 } }), + step('read-rule', 'Read the corresponding time', 'Read the rule where it crosses the 24-hour limb. That equal-hour clock reading changes with the date even though the temporal-hour label remains IX.', 'front.rule', base('front', { reteRotation: FRONT_UNEQUAL_HOUR_FIXTURE.reteRotation, ruleRotation: FRONT_UNEQUAL_HOUR_FIXTURE.ruleRotation }), 'The rule converts temporal hour IX on July 14 into local apparent solar time.'), + step('unequal-front-result', 'Interpret the temporal hour', 'Curve IX means that nine of the day’s twelve temporal hours have elapsed and three remain before sunset.', 'instrument', base('front', { reteRotation: FRONT_UNEQUAL_HOUR_FIXTURE.reteRotation, ruleRotation: FRONT_UNEQUAL_HOUR_FIXTURE.ruleRotation }), 'Result: July 14’s ninth daylight temporal hour is read from the front plate.'), + ], + }, + { + id: 'back.measure-altitude.v1', version: 1, + title: 'Transfer a measured altitude to the alidade', + summary: 'Understand physical altitude sighting, then reproduce an external 35° observation on the simulated back.', + steps: [ + step('physical-sighting', 'Understand the physical observation', 'On a physical astrolabe, suspend the instrument vertically and sight the object along the alidade. For the Sun, align by the shadows instead of looking at it. This flat simulation cannot reproduce that sight line.', 'instrument', base('back'), 'The lesson begins with an altitude measured outside the simulation.'), + step('given-altitude', 'Take the supplied observation', `Use an externally measured altitude of ${ALTITUDE_FIXTURE.angle}°. Altitude is the angle above the local horizon: 0° at the horizon and 90° at the zenith.`, 'back.altitude-scale', base('back'), `The supplied observation is ${ALTITUDE_FIXTURE.angle}° above the horizon.`), + step('set-alidade', 'Set the alidade to the observation', `Rotate the alidade until its straight reading edge meets ${ALTITUDE_FIXTURE.angle}° on the red outer altitude scale.`, 'back.alidade', base('back', { alidadeRotation: ALTITUDE_FIXTURE.angle }), `The simulated alidade records the external ${ALTITUDE_FIXTURE.angle}° observation.`, { demonstration: { field: 'alidadeRotation', from: 0, to: ALTITUDE_FIXTURE.angle, durationMs: 700 }, check: { kind: 'angleNear', field: 'alidadeRotation', value: ALTITUDE_FIXTURE.angle, tolerance: 1 } }), + step('check-complement', 'Distinguish altitude from zenith distance', `The complementary black scale reads ${90 - ALTITUDE_FIXTURE.angle}°. That is the object’s zenith distance, not a second altitude.`, 'back.altitude-scale', base('back', { alidadeRotation: ALTITUDE_FIXTURE.angle }), `Altitude ${ALTITUDE_FIXTURE.angle}° and zenith distance ${90 - ALTITUDE_FIXTURE.angle}° add to 90°.`), + step('altitude-result', 'Carry the reading forward', 'Keep the alidade at this angle when a later construction asks for the observed altitude. The simulation starts at the engraved reading, after the real-world sighting.', 'instrument', base('back', { alidadeRotation: ALTITUDE_FIXTURE.angle }), `Result: an external ${ALTITUDE_FIXTURE.angle}° altitude has been transferred accurately to the back.`), + ], + }, + { + id: 'back.equation-time.v1', version: 1, + title: 'Convert apparent solar time to mean solar time', + summary: 'Use a calendar date and the equation-of-time loop to correct a sundial-style reading.', + steps: [ + step('understand-times', 'Distinguish the two kinds of time', 'The Sun does not cross the meridian at perfectly uniform clock intervals. Equation of time is apparent solar time minus mean solar time.', 'instrument', base('back'), 'A negative value means apparent solar time is behind mean solar time.'), + step('find-eot-date', 'Set July 14 on the calendar', 'Place the alidade’s inner edge through July 14, using the same calendar operation taught earlier.', 'back.calendar', base('back', { alidadeRotation: EQUATION_TIME_FIXTURE.alidadeRotation }), 'The alidade follows July 14’s radial line.', { demonstration: { field: 'alidadeRotation', from: 0, to: EQUATION_TIME_FIXTURE.alidadeRotation, durationMs: 700 }, check: { kind: 'angleNear', field: 'alidadeRotation', value: EQUATION_TIME_FIXTURE.alidadeRotation, tolerance: 1 } }), + step('meet-eot-loop', 'Find the equation-of-time intersection', 'Continue along the alidade’s inner edge until it intersects the potato-shaped equation-of-time loop. Read the signed minute offset represented by that radial displacement.', 'back.equation-time', base('back', { alidadeRotation: EQUATION_TIME_FIXTURE.alidadeRotation }), `For July 14 the engraved convention gives about ${EQUATION_TIME_FIXTURE.minutes.toFixed(1)} minutes.`), + step('apply-sign', 'Apply the engraved sign', `Because apparent minus mean is ${EQUATION_TIME_FIXTURE.minutes.toFixed(1)} minutes, apparent solar time is about ${Math.abs(EQUATION_TIME_FIXTURE.minutes).toFixed(1)} minutes behind mean solar time. Subtracting this negative correction therefore adds the same amount to apparent time.`, 'back.equation-time', base('back', { alidadeRotation: EQUATION_TIME_FIXTURE.alidadeRotation }), `An apparent reading of 12:00 corresponds to about 12:${Math.round(Math.abs(EQUATION_TIME_FIXTURE.minutes)).toString().padStart(2, '0')} mean solar time.`), + step('limit-correction', 'Keep civil-time corrections separate', 'Longitude within a time zone and daylight-saving time are later civil conventions. The equation-of-time loop corrects only the non-uniform apparent Sun to uniform local mean solar time.', 'instrument', base('back', { alidadeRotation: EQUATION_TIME_FIXTURE.alidadeRotation }), 'The astronomical correction has been separated from time-zone corrections.'), + step('eot-result', 'Complete the conversion', 'Read the date, take the signed offset from the loop, and subtract apparent-minus-mean from the apparent reading.', 'instrument', base('back', { alidadeRotation: EQUATION_TIME_FIXTURE.alidadeRotation }), `Result: July 14 apparent solar time is converted using a ${EQUATION_TIME_FIXTURE.minutes.toFixed(1)}-minute correction.`), + ], + }, + { + id: 'back.shadow-square.v1', version: 1, + title: 'Measure a height with the shadow square', + summary: 'Turn a supplied sighting angle and measured distance into a proportional height.', + steps: [ + step('choose-measurement', 'Choose the known measurements', `Stand ${SHADOW_SQUARE_FIXTURE.distance} m from an object on level ground. A physical alidade sighting to its top gives ${SHADOW_SQUARE_FIXTURE.angle}°; this simulation begins with that supplied angle.`, 'instrument', base('back'), `Known horizontal distance: ${SHADOW_SQUARE_FIXTURE.distance} m; supplied elevation angle: ${SHADOW_SQUARE_FIXTURE.angle}°.`), + step('set-shadow-angle', 'Set the sighting angle', `Rotate the alidade to ${SHADOW_SQUARE_FIXTURE.angle}° on the altitude scale.`, 'back.alidade', base('back', { alidadeRotation: SHADOW_SQUARE_FIXTURE.angle }), `The alidade records the ${SHADOW_SQUARE_FIXTURE.angle}° sighting.`, { demonstration: { field: 'alidadeRotation', from: 0, to: SHADOW_SQUARE_FIXTURE.angle, durationMs: 700 }, check: { kind: 'angleNear', field: 'alidadeRotation', value: SHADOW_SQUARE_FIXTURE.angle, tolerance: 1 } }), + step('read-corner', 'Read the shadow-square ratio', 'Follow the alidade’s inner edge to the shadow square. At 45° it passes exactly through the corner where both twelve-part scales reach 12.', 'back.shadow-square', base('back', { alidadeRotation: SHADOW_SQUARE_FIXTURE.angle }), 'The corner gives a 12:12 ratio, which simplifies to 1:1.'), + step('calculate-height', 'Apply the proportion', `Multiply the ${SHADOW_SQUARE_FIXTURE.distance} m horizontal distance by the 12:12 ratio.`, 'back.shadow-square', base('back', { alidadeRotation: SHADOW_SQUARE_FIXTURE.angle }), `The height above eye level is ${SHADOW_SQUARE_FIXTURE.height} m.`), + step('account-eye-level', 'Account for the observer', 'For a real object, add the height of the astrolabe’s pivot above the ground unless the measured baseline begins at the same level as the object’s base.', 'instrument', base('back', { alidadeRotation: SHADOW_SQUARE_FIXTURE.angle }), 'The proportional result is height above the horizontal sight line.'), + step('shadow-result', 'Complete the height reading', 'The shadow square replaces trigonometric calculation with engraved similar-triangle ratios.', 'instrument', base('back', { alidadeRotation: SHADOW_SQUARE_FIXTURE.angle }), `Result: a ${SHADOW_SQUARE_FIXTURE.angle}° sighting at ${SHADOW_SQUARE_FIXTURE.distance} m gives ${SHADOW_SQUARE_FIXTURE.height} m above eye level.`), + ], + }, + { + id: 'back.unequal-hours.v1', version: 1, title: 'Read a temporal hour on the double horary quadrant', summary: 'Reproduce the documented traditional July 14 unequal-hour construction.', steps: [ @@ -137,14 +247,6 @@ export const LESSONS = [ }, ] as const satisfies readonly Lesson[]; -export const FUTURE_TOPICS: readonly FutureTopic[] = [ - { category: 'Solar operations', title: 'Determine sunrise, noon, and sunset', prerequisite: 'Planned: choose a date and follow the Sun from the eastern horizon through culmination to the western horizon.' }, - { category: 'Solar operations', title: 'Read unequal hours on the front', prerequisite: 'Planned: use the Sun’s ecliptic-longitude point with the unequal-hour curves below the horizon.' }, - { category: 'Back operations', title: 'Measure altitude with the alidade', prerequisite: 'Planned: sight an object along the alidade and read its altitude from the limb.' }, - { category: 'Back operations', title: 'Use the equation-of-time loop', prerequisite: 'Planned: choose a date and read the difference between apparent and mean solar time.' }, - { category: 'Back operations', title: 'Measure height with the shadow square', prerequisite: 'Planned: enter a known distance and turn the observed shadow ratio into a height.' }, -]; - export function validateCatalog(lessons: readonly Lesson[] = LESSONS): string[] { const errors: string[] = []; const ids = new Set(); diff --git a/widgets/astrolabe-simulator/src/tutorial/types.ts b/widgets/astrolabe-simulator/src/tutorial/types.ts index 011213b..0ab10e7 100644 --- a/widgets/astrolabe-simulator/src/tutorial/types.ts +++ b/widgets/astrolabe-simulator/src/tutorial/types.ts @@ -2,13 +2,14 @@ import type { AstrolabeState } from '../store'; export type LessonId = | 'front.foundations.v1' | 'front.set-sky.v1' | 'front.read-star.v1' - | 'back.unequal-hours.v1'; + | 'solar.events.v1' | 'front.unequal-hours.v1' | 'back.measure-altitude.v1' + | 'back.equation-time.v1' | 'back.shadow-square.v1' | 'back.unequal-hours.v1'; export type TargetId = | 'instrument' | 'setup.plate' | 'setup.plate-mismatch' | 'front.plate' | 'front.altitude-grid' | 'front.rete' - | 'front.star.sirius' | 'front.ecliptic' | 'front.rule' | 'back.altitude-scale' + | 'front.star.sirius' | 'front.ecliptic' | 'front.unequal-hours' | 'front.rule' | 'back.altitude-scale' | 'back.calendar' | 'back.ecliptic-longitude' - | 'back.horary.vi' | 'back.alidade'; + | 'back.horary.vi' | 'back.equation-time' | 'back.shadow-square' | 'back.alidade'; export type Snapshot = Pick; @@ -28,13 +29,7 @@ export interface LessonStep { export interface Lesson { id: LessonId; version: 1; - category: 'Foundations' | 'Front operations' | 'Back operations'; title: string; summary: string; steps: readonly LessonStep[]; } -export interface FutureTopic { - category: string; - title: string; - prerequisite: string; -}