diff --git a/CHANGELOG.md b/CHANGELOG.md index dea04b9..5965785 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.12] — 2026-02-22 + +### Changed +- Fixed eclipse preview moon trajectory to use contact-bearing motion vectors (including vertical drift), so locations like the 2024 eclipse path animate from lower-left toward upper-right when appropriate. +- Preview now shows a text summary of the computed moon-path direction relative to the sun for the selected GPS point. + +### Tests +- Added regression tests for diagonal travel vector behavior and user-facing direction labeling in preview geometry utilities. + ## [1.1.11] — 2026-02-22 ### Fixed diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 66443ba..e094b47 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@eclipse-timer/mobile", - "version": "1.1.11", + "version": "1.1.12", "private": true, "main": "index.js", "scripts": { diff --git a/apps/mobile/src/screens/EclipsePreviewScreen.tsx b/apps/mobile/src/screens/EclipsePreviewScreen.tsx index 10ad490..d5dc774 100644 --- a/apps/mobile/src/screens/EclipsePreviewScreen.tsx +++ b/apps/mobile/src/screens/EclipsePreviewScreen.tsx @@ -16,7 +16,8 @@ import { colorForContactKey } from "../utils/contactTheme"; import { fmtLocalHuman, fmtUtcHuman } from "../utils/date"; import { calculatePreviewMoonGeometry, - determinePreviewTravelDirection, + describePreviewTravelDirection, + determinePreviewTravelVector, PREVIEW_STAGE_SIZE, PREVIEW_SUN_RADIUS, } from "../utils/previewGeometry"; @@ -306,6 +307,17 @@ export default function EclipsePreviewScreen({ timelineDurationMs, ]); + const travelVector = useMemo( + () => + determinePreviewTravelVector({ + c1BearingDeg: payload.c1BearingDeg, + c2BearingDeg: payload.c2BearingDeg, + c3BearingDeg: payload.c3BearingDeg, + c4BearingDeg: payload.c4BearingDeg, + }), + [payload.c1BearingDeg, payload.c2BearingDeg, payload.c3BearingDeg, payload.c4BearingDeg], + ); + const moonGeometry = useMemo( () => calculatePreviewMoonGeometry({ @@ -315,23 +327,14 @@ export default function EclipsePreviewScreen({ contacts: contactProgress, stageSize: SIM_STAGE_SIZE, sunRadius: SUN_RADIUS, - travelDirection: determinePreviewTravelDirection({ - c1BearingDeg: payload.c1BearingDeg, - c2BearingDeg: payload.c2BearingDeg, - c3BearingDeg: payload.c3BearingDeg, - c4BearingDeg: payload.c4BearingDeg, - }), + travelVector, }), - [ - contactProgress, - payload.c1BearingDeg, - payload.c2BearingDeg, - payload.c3BearingDeg, - payload.c4BearingDeg, - payload.kindAtLocation, - payload.magnitude, - progress, - ], + [contactProgress, payload.kindAtLocation, payload.magnitude, progress, travelVector], + ); + + const travelDirectionLabel = useMemo( + () => describePreviewTravelDirection(travelVector), + [travelVector], ); const phaseLabel = useMemo( @@ -431,6 +434,7 @@ export default function EclipsePreviewScreen({ ? ` | Mag ${payload.magnitude.toFixed(3)}` : ""} + Moon path: {travelDirectionLabel} @@ -649,6 +653,12 @@ const styles = StyleSheet.create({ fontWeight: "700", textTransform: "uppercase", }, + directionText: { + marginTop: 2, + color: "#9ea4c8", + fontSize: 11, + fontWeight: "600", + }, simContainer: { flex: 1, justifyContent: "center", diff --git a/apps/mobile/src/utils/previewGeometry.ts b/apps/mobile/src/utils/previewGeometry.ts index 66f0670..7758368 100644 --- a/apps/mobile/src/utils/previewGeometry.ts +++ b/apps/mobile/src/utils/previewGeometry.ts @@ -20,6 +20,11 @@ export type PreviewMoonGeometry = { moonTravelHalfSpan: number; }; +export type PreviewTravelVector = { + x: number; + y: number; +}; + export type PreviewDirectionBearings = { c1BearingDeg?: number; c2BearingDeg?: number; @@ -53,14 +58,44 @@ function resolveDirectionalBearingPair(bearings: PreviewDirectionBearings) { return null; } -export function determinePreviewTravelDirection( +function bearingDegToUnitCirclePoint(bearingDeg: number): PreviewTravelVector { + const angleRad = (bearingDeg * Math.PI) / 180; + return { + x: Math.sin(angleRad), + y: -Math.cos(angleRad), + }; +} + +export function determinePreviewTravelVector( bearings: PreviewDirectionBearings | undefined, -): 1 | -1 { - if (!bearings) return 1; +): PreviewTravelVector { + if (!bearings) return { x: 1, y: 0 }; const pair = resolveDirectionalBearingPair(bearings); - if (!pair) return 1; - const delta = normalizeSignedDeltaDeg(pair.start, pair.end); - return delta >= 0 ? 1 : -1; + if (!pair) return { x: 1, y: 0 }; + + const start = bearingDegToUnitCirclePoint(pair.start); + const end = bearingDegToUnitCirclePoint(pair.end); + const deltaX = end.x - start.x; + const deltaY = end.y - start.y; + const magnitude = Math.hypot(deltaX, deltaY); + + if (!Number.isFinite(magnitude) || magnitude < 1e-6) { + const fallbackDirection = normalizeSignedDeltaDeg(pair.start, pair.end) >= 0 ? 1 : -1; + return { x: fallbackDirection, y: 0 }; + } + + return { + x: deltaX / magnitude, + y: deltaY / magnitude, + }; +} + +export function describePreviewTravelDirection(vector: PreviewTravelVector): string { + const horizontal = vector.x >= 0 ? "left to right" : "right to left"; + const vertical = vector.y <= -0.2 ? "bottom to top" : vector.y >= 0.2 ? "top to bottom" : "level"; + + if (vertical === "level") return horizontal; + return `${vertical}, ${horizontal}`; } export function determineMoonRadius(kindAtLocation: EclipseKindAtLocation) { @@ -148,7 +183,7 @@ export function calculatePreviewMoonGeometry(params: { contacts?: PreviewMotionContacts; stageSize?: number; sunRadius?: number; - travelDirection?: 1 | -1; + travelVector?: PreviewTravelVector; }): PreviewMoonGeometry { const stageSize = params.stageSize ?? PREVIEW_STAGE_SIZE; const sunRadius = params.sunRadius ?? PREVIEW_SUN_RADIUS; @@ -161,10 +196,12 @@ export function calculatePreviewMoonGeometry(params: { ); const anchors = buildMotionAnchors(params.contacts ?? {}, sunRadius, moonRadius); - const travelDirection = params.travelDirection ?? 1; - const moonOffsetX = interpolateOffsetX(params.progress, anchors) * travelDirection; + const axisOffset = interpolateOffsetX(params.progress, anchors); + const travelVector = params.travelVector ?? { x: 1, y: 0 }; + const moonOffsetX = axisOffset * travelVector.x - moonClosestOffset * travelVector.y; + const moonOffsetY = axisOffset * travelVector.y + moonClosestOffset * travelVector.x; const moonCenterX = stageSize / 2 + moonOffsetX; - const moonCenterY = stageSize / 2 + moonClosestOffset; + const moonCenterY = stageSize / 2 + moonOffsetY; return { moonRadius, diff --git a/apps/mobile/tests/preview-geometry.test.ts b/apps/mobile/tests/preview-geometry.test.ts index deddd02..e96bf97 100644 --- a/apps/mobile/tests/preview-geometry.test.ts +++ b/apps/mobile/tests/preview-geometry.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest"; import { calculatePreviewMoonGeometry, - determinePreviewTravelDirection, + describePreviewTravelDirection, + determinePreviewTravelVector, PREVIEW_SUN_RADIUS, } from "../src/utils/previewGeometry"; @@ -54,38 +55,44 @@ describe("preview moon geometry", () => { expect(postC3Distance).toBeGreaterThan(c3Distance); }); - it("uses contact bearings to keep moon travel direction accurate", () => { - const leftToRightDirection = determinePreviewTravelDirection({ - c1BearingDeg: 100, - c4BearingDeg: 140, - }); - const rightToLeftDirection = determinePreviewTravelDirection({ - c1BearingDeg: 140, - c4BearingDeg: 100, + it("uses contact bearings to produce a diagonal moon travel vector", () => { + const travelVector = determinePreviewTravelVector({ + c1BearingDeg: 246, + c4BearingDeg: 66, }); + expect(travelVector.x).toBeGreaterThan(0); + expect(travelVector.y).toBeLessThan(0); + const baseParams = { progress: 0.25, kindAtLocation: "total" as const, contacts: { c1: 0, c2: 0.25, max: 0.5, c3: 0.75, c4: 1 }, }; - const leftToRight = calculatePreviewMoonGeometry({ + const earlyGeometry = calculatePreviewMoonGeometry({ ...baseParams, - travelDirection: leftToRightDirection, + travelVector, }); - const rightToLeft = calculatePreviewMoonGeometry({ + const lateGeometry = calculatePreviewMoonGeometry({ ...baseParams, - travelDirection: rightToLeftDirection, + progress: 0.75, + travelVector, }); - expect(leftToRight.moonOffsetX).toBeLessThan(0); - expect(rightToLeft.moonOffsetX).toBeGreaterThan(0); - expect(Math.abs(leftToRight.moonOffsetX)).toBeCloseTo(Math.abs(rightToLeft.moonOffsetX), 6); + expect(lateGeometry.moonCenterX).toBeGreaterThan(earlyGeometry.moonCenterX); + expect(lateGeometry.moonCenterY).toBeLessThan(earlyGeometry.moonCenterY); + }); + + it("falls back to default travel vector when bearings are missing", () => { + expect(determinePreviewTravelVector(undefined)).toEqual({ x: 1, y: 0 }); + expect(determinePreviewTravelVector({ c2BearingDeg: 120 })).toEqual({ x: 1, y: 0 }); }); - it("falls back to default travel direction when bearings are missing", () => { - expect(determinePreviewTravelDirection(undefined)).toBe(1); - expect(determinePreviewTravelDirection({ c2BearingDeg: 120 })).toBe(1); + it("describes moon travel direction in user-facing terms", () => { + expect(describePreviewTravelDirection({ x: 0.8, y: -0.4 })).toBe( + "bottom to top, left to right", + ); + expect(describePreviewTravelDirection({ x: -0.7, y: 0.1 })).toBe("right to left"); }); });