diff --git a/CHANGELOG.md b/CHANGELOG.md index 2364f90..dc109cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 326efe2..be09ebb 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@eclipse-timer/mobile", - "version": "1.1.9", + "version": "1.1.10", "private": true, "main": "index.js", "scripts": { diff --git a/apps/mobile/src/screens/EclipsePreviewScreen.tsx b/apps/mobile/src/screens/EclipsePreviewScreen.tsx index 1160ac9..b23a866 100644 --- a/apps/mobile/src/screens/EclipsePreviewScreen.tsx +++ b/apps/mobile/src/screens/EclipsePreviewScreen.tsx @@ -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; @@ -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; @@ -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"; @@ -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), @@ -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, }, ]} /> diff --git a/apps/mobile/src/utils/previewGeometry.ts b/apps/mobile/src/utils/previewGeometry.ts new file mode 100644 index 0000000..fd9e42c --- /dev/null +++ b/apps/mobile/src/utils/previewGeometry.ts @@ -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 }, + { 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, + }; +} diff --git a/apps/mobile/tests/preview-geometry.test.ts b/apps/mobile/tests/preview-geometry.test.ts new file mode 100644 index 0000000..14888f0 --- /dev/null +++ b/apps/mobile/tests/preview-geometry.test.ts @@ -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); + }); +});