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");
});
});