Skip to content
Merged
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@eclipse-timer/mobile",
"version": "1.1.11",
"version": "1.1.12",
"private": true,
"main": "index.js",
"scripts": {
Expand Down
44 changes: 27 additions & 17 deletions apps/mobile/src/screens/EclipsePreviewScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand All @@ -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(
Expand Down Expand Up @@ -431,6 +434,7 @@ export default function EclipsePreviewScreen({
? ` | Mag ${payload.magnitude.toFixed(3)}`
: ""}
</Text>
<Text style={styles.directionText}>Moon path: {travelDirectionLabel}</Text>
</View>

<View style={styles.simContainer}>
Expand Down Expand Up @@ -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",
Expand Down
57 changes: 47 additions & 10 deletions apps/mobile/src/utils/previewGeometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export type PreviewMoonGeometry = {
moonTravelHalfSpan: number;
};

export type PreviewTravelVector = {
x: number;
y: number;
};

export type PreviewDirectionBearings = {
c1BearingDeg?: number;
c2BearingDeg?: number;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down
45 changes: 26 additions & 19 deletions apps/mobile/tests/preview-geometry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest";

import {
calculatePreviewMoonGeometry,
determinePreviewTravelDirection,
describePreviewTravelDirection,
determinePreviewTravelVector,
PREVIEW_SUN_RADIUS,
} from "../src/utils/previewGeometry";

Expand Down Expand Up @@ -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");
});
});