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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ 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.10] — 2026-02-22

### Fixed
- Improved eclipse preview moon-path geometry so contact phases align with expected tangency behavior: C1 starts at outer tangency, C2 reaches inner tangency, MAX is centered, and C3 remains at inner tangency before sun reappears.
- Added regression tests for preview geometry calculations to keep C1/C2/MAX/C3 positioning behavior verifiable.

## [1.1.9] — 2026-02-21

### Added
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.9",
"version": "1.1.10",
"private": true,
"main": "index.js",
"scripts": {
Expand Down
90 changes: 47 additions & 43 deletions apps/mobile/src/screens/EclipsePreviewScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import BurgerButton from "../components/BurgerButton";
import type { ContactKey } from "../utils/contacts";
import { colorForContactKey } from "../utils/contactTheme";
import { fmtLocalHuman, fmtUtcHuman } from "../utils/date";
import {
calculatePreviewMoonGeometry,
PREVIEW_STAGE_SIZE,
PREVIEW_SUN_RADIUS,
} from "../utils/previewGeometry";

type PreviewContactKey = ContactKey;

Expand Down Expand Up @@ -52,8 +57,8 @@ type EclipsePreviewScreenProps = {
const DEFAULT_WINDOW_MS = 2 * 60 * 60 * 1000;
const MIN_WINDOW_MS = 5 * 60 * 1000;
const PLAYBACK_SPEED = 480;
const SIM_STAGE_SIZE = 300;
const SUN_RADIUS = 72;
const SIM_STAGE_SIZE = PREVIEW_STAGE_SIZE;
const SUN_RADIUS = PREVIEW_SUN_RADIUS;
const MARKER_LABEL_HALF_WIDTH_PX = 18;
const MARKER_LABEL_MIN_GAP_PX = 40;
const MARKER_LABEL_ROW_LIMIT = 1;
Expand Down Expand Up @@ -104,31 +109,6 @@ function buildTimelineEvents(payload: PreviewPayload): TimelineEvent[] {
.sort((a, b) => a.t - b.t);
}

function determineMoonRadius(kindAtLocation: EclipseKindAtLocation) {
if (kindAtLocation === "annular") return 58;
if (kindAtLocation === "total") return 76;
if (kindAtLocation === "partial") return 68;
return 66;
}

function determineApproachOffset(
kindAtLocation: EclipseKindAtLocation,
magnitude: number | undefined,
moonRadius: number,
) {
if (kindAtLocation === "none") {
return SUN_RADIUS + moonRadius + 14;
}

if (kindAtLocation === "partial") {
const safeMag =
typeof magnitude === "number" && Number.isFinite(magnitude) ? clamp01(magnitude) : 0.6;
return (1 - safeMag) * (SUN_RADIUS + moonRadius - 6);
}

return 0;
}

function phaseLabelForTime(nowMs: number, events: TimelineEvent[]) {
if (!events.length) return "No contact times available for this location";

Expand Down Expand Up @@ -297,18 +277,42 @@ export default function EclipsePreviewScreen({
return positionedMarkers;
}, [progressTrackWidth, timelineBounds.startMs, timelineDurationMs, timelineEvents]);

const moonRadius = useMemo(
() => determineMoonRadius(payload.kindAtLocation),
[payload.kindAtLocation],
);
const moonClosestOffset = useMemo(
() => determineApproachOffset(payload.kindAtLocation, payload.magnitude, moonRadius),
[moonRadius, payload.kindAtLocation, payload.magnitude],
);
const contactProgress = useMemo(() => {
const toProgress = (iso?: string) => {
const t = parseUtcMs(iso);
if (typeof t !== "number") return undefined;
return clamp01((t - timelineBounds.startMs) / timelineDurationMs);
};

const moonTravelHalfSpan = SUN_RADIUS + moonRadius + 26;
const moonCenterX = SIM_STAGE_SIZE / 2 - moonTravelHalfSpan + progress * moonTravelHalfSpan * 2;
const moonCenterY = SIM_STAGE_SIZE / 2 + moonClosestOffset;
return {
c1: toProgress(payload.c1Utc),
c2: toProgress(payload.c2Utc),
max: toProgress(payload.maxUtc),
c3: toProgress(payload.c3Utc),
c4: toProgress(payload.c4Utc),
};
}, [
payload.c1Utc,
payload.c2Utc,
payload.c3Utc,
payload.c4Utc,
payload.maxUtc,
timelineBounds.startMs,
timelineDurationMs,
]);

const moonGeometry = useMemo(
() =>
calculatePreviewMoonGeometry({
progress,
kindAtLocation: payload.kindAtLocation,
magnitude: payload.magnitude,
contacts: contactProgress,
stageSize: SIM_STAGE_SIZE,
sunRadius: SUN_RADIUS,
}),
[contactProgress, payload.kindAtLocation, payload.magnitude, progress],
);

const phaseLabel = useMemo(
() => phaseLabelForTime(currentMs, timelineEvents),
Expand Down Expand Up @@ -417,11 +421,11 @@ export default function EclipsePreviewScreen({
style={[
styles.moonDisk,
{
width: moonRadius * 2,
height: moonRadius * 2,
borderRadius: moonRadius,
left: moonCenterX - moonRadius,
top: moonCenterY - moonRadius,
width: moonGeometry.moonRadius * 2,
height: moonGeometry.moonRadius * 2,
borderRadius: moonGeometry.moonRadius,
left: moonGeometry.moonCenterX - moonGeometry.moonRadius,
top: moonGeometry.moonCenterY - moonGeometry.moonRadius,
},
]}
/>
Expand Down
136 changes: 136 additions & 0 deletions apps/mobile/src/utils/previewGeometry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import type { EclipseKindAtLocation } from "@eclipse-timer/shared";

export const PREVIEW_SUN_RADIUS = 72;
export const PREVIEW_STAGE_SIZE = 300;

export type PreviewMotionContacts = {
c1?: number;
c2?: number;
max?: number;
c3?: number;
c4?: number;
};

export type PreviewMoonGeometry = {
moonRadius: number;
moonClosestOffset: number;
moonCenterX: number;
moonCenterY: number;
moonOffsetX: number;
moonTravelHalfSpan: number;
};

function clamp01(v: number) {
return Math.max(0, Math.min(1, v));
}

export function determineMoonRadius(kindAtLocation: EclipseKindAtLocation) {
if (kindAtLocation === "annular") return 58;
if (kindAtLocation === "total") return 76;
if (kindAtLocation === "partial") return 68;
return 66;
}

export function determineApproachOffset(
kindAtLocation: EclipseKindAtLocation,
magnitude: number | undefined,
moonRadius: number,
sunRadius = PREVIEW_SUN_RADIUS,
) {
if (kindAtLocation === "none") {
return sunRadius + moonRadius + 14;
}

if (kindAtLocation === "partial") {
const safeMag =
typeof magnitude === "number" && Number.isFinite(magnitude) ? clamp01(magnitude) : 0.6;
return (1 - safeMag) * (sunRadius + moonRadius - 6);
}

return 0;
}

function buildMotionAnchors(
contacts: PreviewMotionContacts,
sunRadius: number,
moonRadius: number,
): Array<{ progress: number; offsetX: number }> {
const externalTouchOffset = sunRadius + moonRadius;
const internalTouchOffset = Math.abs(sunRadius - moonRadius);

const anchors: Array<{ progress: number; offsetX: number }> = [
{ progress: 0, offsetX: -externalTouchOffset },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor earliest available contact at progress 0

EclipsePreviewScreen computes timeline start as c1 ?? firstMs, so payloads without c1Utc can legitimately have c2/max at progress 0. This hardcoded start anchor forces progress 0 to outer tangency, and interpolateOffsetX returns that value before considering the real first contact, so the moon is drawn in the wrong phase exactly at the first timestamp (for example, showing C2 as outer tangency instead of inner tangency).

Useful? React with 👍 / 👎.

{ progress: 1, offsetX: externalTouchOffset },
];

const maybePush = (progress: number | undefined, offsetX: number) => {
if (typeof progress !== "number" || !Number.isFinite(progress)) return;
anchors.push({ progress: clamp01(progress), offsetX });
};

maybePush(contacts.c1, -externalTouchOffset);
maybePush(contacts.c2, -internalTouchOffset);
maybePush(contacts.max, 0);
maybePush(contacts.c3, internalTouchOffset);
maybePush(contacts.c4, externalTouchOffset);

return anchors.sort((a, b) => a.progress - b.progress);
}

function interpolateOffsetX(
progress: number,
anchors: Array<{ progress: number; offsetX: number }>,
) {
const clampedProgress = clamp01(progress);
const first = anchors[0];
const last = anchors[anchors.length - 1];
if (!first || !last) return 0;
if (clampedProgress <= first.progress) return first.offsetX;
if (clampedProgress >= last.progress) return last.offsetX;

for (let idx = 1; idx < anchors.length; idx += 1) {
const prev = anchors[idx - 1];
const next = anchors[idx];
if (!prev || !next) continue;
if (clampedProgress > next.progress) continue;
const span = next.progress - prev.progress;
if (span <= 0) return next.offsetX;
const segmentProgress = (clampedProgress - prev.progress) / span;
return prev.offsetX + (next.offsetX - prev.offsetX) * segmentProgress;
}

return last.offsetX;
}

export function calculatePreviewMoonGeometry(params: {
progress: number;
kindAtLocation: EclipseKindAtLocation;
magnitude?: number;
contacts?: PreviewMotionContacts;
stageSize?: number;
sunRadius?: number;
}): PreviewMoonGeometry {
const stageSize = params.stageSize ?? PREVIEW_STAGE_SIZE;
const sunRadius = params.sunRadius ?? PREVIEW_SUN_RADIUS;
const moonRadius = determineMoonRadius(params.kindAtLocation);
const moonClosestOffset = determineApproachOffset(
params.kindAtLocation,
params.magnitude,
moonRadius,
sunRadius,
);

const anchors = buildMotionAnchors(params.contacts ?? {}, sunRadius, moonRadius);
const moonOffsetX = interpolateOffsetX(params.progress, anchors);
const moonCenterX = stageSize / 2 + moonOffsetX;
const moonCenterY = stageSize / 2 + moonClosestOffset;

return {
moonRadius,
moonClosestOffset,
moonCenterX,
moonCenterY,
moonOffsetX,
moonTravelHalfSpan: sunRadius + moonRadius,
};
}
52 changes: 52 additions & 0 deletions apps/mobile/tests/preview-geometry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";

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

describe("preview moon geometry", () => {
it("places C1 at exact outer tangency", () => {
const geometry = calculatePreviewMoonGeometry({
progress: 0,
kindAtLocation: "total",
contacts: { c1: 0, c2: 0.25, max: 0.5, c3: 0.75, c4: 1 },
});

const centerDistance = Math.abs(geometry.moonCenterX - 150);
expect(centerDistance).toBeCloseTo(PREVIEW_SUN_RADIUS + geometry.moonRadius, 6);
});

it("places C2 at exact inner tangency and max at center", () => {
const c2Geometry = calculatePreviewMoonGeometry({
progress: 0.25,
kindAtLocation: "total",
contacts: { c1: 0, c2: 0.25, max: 0.5, c3: 0.75, c4: 1 },
});
const maxGeometry = calculatePreviewMoonGeometry({
progress: 0.5,
kindAtLocation: "total",
contacts: { c1: 0, c2: 0.25, max: 0.5, c3: 0.75, c4: 1 },
});

const c2Distance = Math.abs(c2Geometry.moonCenterX - 150);
expect(c2Distance).toBeCloseTo(Math.abs(PREVIEW_SUN_RADIUS - c2Geometry.moonRadius), 6);
expect(maxGeometry.moonCenterX).toBeCloseTo(150, 6);
});

it("keeps C3 as inner tangency and only exposes sun after C3", () => {
const c3Geometry = calculatePreviewMoonGeometry({
progress: 0.75,
kindAtLocation: "total",
contacts: { c1: 0, c2: 0.25, max: 0.5, c3: 0.75, c4: 1 },
});
const postC3Geometry = calculatePreviewMoonGeometry({
progress: 0.8,
kindAtLocation: "total",
contacts: { c1: 0, c2: 0.25, max: 0.5, c3: 0.75, c4: 1 },
});

const c3Distance = Math.abs(c3Geometry.moonCenterX - 150);
const postC3Distance = Math.abs(postC3Geometry.moonCenterX - 150);

expect(c3Distance).toBeCloseTo(Math.abs(PREVIEW_SUN_RADIUS - c3Geometry.moonRadius), 6);
expect(postC3Distance).toBeGreaterThan(c3Distance);
});
});