Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions widgets/astrolabe-simulator/LESSON_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ 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.
1. **Understand and configure the astrolabe.** Identify the fixed front plate,
moving rete and rule, compare latitude plates, then identify the fixed back
scales and independently moving alidade.
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
Expand Down
2 changes: 1 addition & 1 deletion widgets/astrolabe-simulator/journey.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
},
{ "eval": "new Promise((resolve) => setTimeout(resolve, 800))" },
{
"eval": "(() => { if (document.querySelector('#lesson-step-heading')?.textContent !== 'Find the date on the calendar') throw new Error('calendar step did not precede reading'); const tick = document.querySelector('[data-testid=calendar-july-14]'); const pointer = document.querySelector('.astro-alidade-pointer'); if (!tick || !pointer) throw new Error('date or alidade geometry missing'); const transform = (element, x, y) => { const point = new DOMPoint(x, y).matrixTransform(element.getCTM()); return { x: point.x, y: point.y }; }; const a = transform(pointer, Number(pointer.getAttribute('x1')), Number(pointer.getAttribute('y1'))); const b = transform(pointer, Number(pointer.getAttribute('x2')), Number(pointer.getAttribute('y2'))); const t = transform(tick, (Number(tick.getAttribute('x1')) + Number(tick.getAttribute('x2'))) / 2, (Number(tick.getAttribute('y1')) + Number(tick.getAttribute('y2'))) / 2); const distance = Math.abs((b.x - a.x) * (a.y - t.y) - (a.x - t.x) * (b.y - a.y)) / Math.hypot(b.x - a.x, b.y - a.y); if (distance > 0.5) throw new Error(`alidade misses July 14 tick by ${distance}px`); const buttons = [...document.querySelectorAll('.lesson-actions button')]; const next = buttons.find((button) => button.textContent === 'Next'); const check = buttons.find((button) => button.textContent === 'Check'); if (!check || !next?.disabled) throw new Error('Find the date did not require Check before Next'); check.click(); })()"
"eval": "(() => { if (document.querySelector('#lesson-step-heading')?.textContent !== 'Find the date on the calendar') throw new Error('calendar step did not precede reading'); const tick = document.querySelector('[data-testid=calendar-july-14]'); const pointer = document.querySelector('.astro-alidade-pointer'); if (!tick || !pointer) throw new Error('date or alidade geometry missing'); const transform = (element, x, y) => { const point = new DOMPoint(x, y).matrixTransform(element.getCTM()); return { x: point.x, y: point.y }; }; const a = transform(pointer, Number(pointer.getAttribute('x1')), Number(pointer.getAttribute('y1'))); const b = transform(pointer, Number(pointer.getAttribute('x2')), Number(pointer.getAttribute('y2'))); const t = transform(tick, (Number(tick.getAttribute('x1')) + Number(tick.getAttribute('x2'))) / 2, (Number(tick.getAttribute('y1')) + Number(tick.getAttribute('y2'))) / 2); const distance = Math.abs((b.x - a.x) * (a.y - t.y) - (a.x - t.x) * (b.y - a.y)) / Math.hypot(b.x - a.x, b.y - a.y); if (distance > 0.5) throw new Error(`alidade misses July 14 tick by ${distance}px`); const buttons = [...document.querySelectorAll('.lesson-actions button')]; const next = buttons.find((button) => button.textContent === 'Next'); const check = buttons.find((button) => button.textContent === 'Check'); const style = next && getComputedStyle(next); if (!check || !next?.disabled || next.getAttribute('aria-disabled') !== 'true' || style?.cursor !== 'not-allowed' || Number(style?.opacity) >= 1) throw new Error('Find the date did not visibly disable Next before Check'); check.click(); })()"
},
{ "eval": "new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))" },
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,9 @@ export function AstrolabeGuide(): JSX.Element {
setStatus(passed ? `Checkpoint passed. ${step.result}` : 'Not yet. Recreate the visible alignment described in this step and check again.');
}}>Check</button>}
{stepIndex < lesson.steps.length - 1
? <button disabled={Boolean(step.check) && !checkpointPassed} onClick={() => runStep(lesson, stepIndex + 1, true)}>Next</button>
? <button disabled={Boolean(step.check) && !checkpointPassed}
aria-disabled={Boolean(step.check) && !checkpointPassed}
onClick={() => runStep(lesson, stepIndex + 1, true)}>Next</button>
: <button onClick={() => { setComplete(true); setStatus(`Lesson complete. ${step.result}`); }}>Finish</button>}
<button onClick={() => exit(false)}>Exit</button>
</div>}
Expand Down
5 changes: 5 additions & 0 deletions widgets/astrolabe-simulator/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,11 @@ button, input, select {
.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; }
.lesson-actions button:last-child { margin-left: auto; }
.lesson-actions button:disabled {
opacity: 0.48;
cursor: not-allowed;
border-style: dashed;
}
.interruption { padding: 10px; border: 2px solid var(--widget-warning); }
.interruption p { flex: 1 1 100%; margin: 0; }
.sr-status { min-height: 1.5em; color: var(--fg-secondary); }
Expand Down
10 changes: 9 additions & 1 deletion widgets/astrolabe-simulator/src/tutorial/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('tutorial catalog', () => {
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]);
expect(LESSONS.slice(0, 3).map((lesson) => lesson.steps.length)).toEqual([12, 7, 8]);
for (const lesson of LESSONS.slice(0, 3)) expect(lesson.steps.some((step) => step.check)).toBe(true);
});
it('demonstrates latitude mismatch and returns to the exact plate', () => {
Expand All @@ -38,6 +38,14 @@ describe('tutorial catalog', () => {
expect(foundations.steps.find((step) => step.id === 'compare-mismatch')?.target).toBe('setup.plate-mismatch');
expect(foundations.steps.find((step) => step.id === 'restore-plate')?.snapshot.plateLatitude).toBe(51.5);
});
it('introduces both faces without claiming an unperformed sky setting', () => {
const foundations = LESSONS[0];
expect(foundations.title).not.toMatch(/front$/i);
expect(foundations.steps[0].result).toMatch(/no date or time has been set/i);
expect(foundations.steps.some((step) => step.snapshot.face === 'back')).toBe(true);
expect(foundations.steps.find((step) => step.id === 'move-alidade')?.demonstration?.field).toBe('alidadeRotation');
expect(foundations.steps.at(-1)?.result).not.toMatch(/noon|July 14/i);
});
it('passes typed runtime validation', () => expect(validateCatalog()).toEqual([]));
it.each(LESSONS)('$id has canonical results on every step', (lesson) => {
for (const step of lesson.steps) {
Expand Down
11 changes: 7 additions & 4 deletions widgets/astrolabe-simulator/src/tutorial/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,18 +156,21 @@ const step = (id: string, title: string, body: string, target: LessonStep['targe
export const LESSONS = [
{
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.',
title: 'Understand and configure the astrolabe',
summary: 'Identify the fixed and moving parts on both faces, then choose the latitude plate used for a reading.',
steps: [
step('meet-instrument', 'Meet the instrument', 'The mater holds the working parts. On the front, a latitude plate supplies local coordinates, the rete carries the star map, and the rule provides a straight reading edge.', 'instrument', base('front'), 'The front is set for London at noon on July 14, 2026.'),
step('meet-instrument', 'Meet the instrument', 'The mater holds the working parts. On the front, a latitude plate supplies local coordinates, the rete carries the star map, and the rule provides a straight reading edge.', 'instrument', base('front'), 'The front shows London’s 51.5° plate with the rete and rule at reference positions; no date or time has been set.'),
step('fixed-plate', 'Find the fixed plate', 'The plate carries the horizon, altitude circles, and azimuths for 51.5° north.', 'front.plate', base('front'), 'The plate remains fixed while the sky turns.'),
step('moving-rete', 'Turn the moving sky', 'Rotate the rete to 45°. The star map turns over the fixed latitude plate, placing the sky in a new orientation.', 'front.rete', base('front', { reteRotation: 45 }), 'The rete is at 45° while the plate remains fixed.', { demonstration: { field: 'reteRotation', from: 0, to: 45, durationMs: 700 }, check: { kind: 'angleNear', field: 'reteRotation', value: 45, tolerance: 2 } }),
step('reading-rule', 'Use the reading rule', 'Rotate the rule to 90°. It provides a reading edge across the plate and rete without moving either engraving.', 'front.rule', base('front', { reteRotation: 45, ruleRotation: 90 }), 'The rule is at 90°, independently of the rete.', { demonstration: { field: 'ruleRotation', from: 0, to: 90, durationMs: 700 }, check: { kind: 'angleNear', field: 'ruleRotation', value: 90, tolerance: 2 } }),
step('return-rete', 'Return the rete', 'Use the rete control, keyboard arrows, or drag to return it to 0°.', 'front.rete', base('front', { reteRotation: 45, ruleRotation: 90 }), 'The rete is back at 0°.', { check: { kind: 'angleNear', field: 'reteRotation', value: 0, tolerance: 2 } }),
step('choose-plate', 'Choose the latitude plate', 'Select Exact 51.5° for London. A plate’s horizon and coordinate curves are constructed for one latitude, so this choice controls how the rete is read.', 'setup.plate', plateSnapshot(51.5), 'The exact 51.5° plate matches London.'),
step('compare-mismatch', 'See what a nearby plate changes', 'Now compare the 50° plate. Its star map is unchanged, but its local horizon, altitude, and azimuth curves are 1.5° away from London’s latitude.', 'setup.plate-mismatch', plateSnapshot(50), 'The mismatch warning quantifies the 1.5° latitude difference.'),
step('restore-plate', 'Restore the exact plate', 'Return to Exact 51.5° before making observations for London.', 'setup.plate', plateSnapshot(51.5), 'The plate and observation latitude match again.'),
step('foundations-result', 'Foundations complete', 'You can distinguish the fixed local-coordinate plate from the independently moving rete and rule, and choose a plate appropriate to the observer’s latitude.', 'instrument', base('front'), 'Result: the instrument is configured with London’s exact latitude plate.'),
step('turn-to-back', 'Turn to the back', 'The back carries fixed scales for dates, ecliptic longitude, altitude, proportional measurement, equation of time, and temporal hours.', 'back.calendar', base('back'), 'The fixed calendar and ecliptic-longitude rings are visible on the back.'),
step('read-back-scales', 'Distinguish the fixed back scales', 'The outer degree scale, zodiac band, and calendar ring share radial lines. The shadow square, equation-of-time loop, and double horary quadrant occupy the central field.', 'back.ecliptic-longitude', base('back'), 'These engravings remain fixed in the mater while a reading is made.'),
step('move-alidade', 'Move the alidade', 'Rotate the alidade to 35°. Its straight inner edge crosses the fixed engravings so observations and values can be transferred between scales.', 'back.alidade', base('back'), 'The alidade moves independently over the fixed back scales.', { demonstration: { field: 'alidadeRotation', from: 0, to: 35, durationMs: 700 }, check: { kind: 'angleNear', field: 'alidadeRotation', value: 35, tolerance: 1 } }),
step('foundations-result', 'Foundations complete', 'You can distinguish the front plate, rete, and rule; choose a latitude plate; identify the fixed back engravings; and move the back alidade independently.', 'back.calendar', base('back'), 'Result: both faces and their independently moving parts have been identified; no observational date or time setting is implied.'),
],
},
{
Expand Down
Loading