diff --git a/client/src/components/Studio/TravelElements.tsx b/client/src/components/Studio/TravelElements.tsx index 181184590..3f4bf58cd 100644 --- a/client/src/components/Studio/TravelElements.tsx +++ b/client/src/components/Studio/TravelElements.tsx @@ -8,6 +8,7 @@ import { type LucideIcon, } from 'lucide-react' import { fontStack } from './bookFonts' +import { legKm } from './legKm' import { brightness } from './folioColour' import { COUNTRY_SHAPES, countryParts, countryWorldPath, projectMercator, unprojectMercator } from './countryShapes' import { projectOntoTiles, tileView, usableStaticUrl } from './mapTiles' @@ -358,16 +359,6 @@ function MapView({ el, frameStyle, big = false }: { */ const bowed = el.routeArc === 'bow' && trail.length === 0 && points.length > 1 - /** Kilometres between two coordinates, for the ramp. */ - const km = (a: { lat: number; lng: number }, b: { lat: number; lng: number }) => { - const r = (d: number) => (d * Math.PI) / 180 - const dLat = r(b.lat - a.lat) - const dLng = r(b.lng - a.lng) - const h = Math.sin(dLat / 2) ** 2 - + Math.cos(r(a.lat)) * Math.cos(r(b.lat)) * Math.sin(dLng / 2) ** 2 - return 12742 * Math.asin(Math.min(1, Math.sqrt(h))) - } - const meanLat = el.points.length ? el.points.reduce((sum, p) => sum + p.lat, 0) / el.points.length : 0 @@ -389,7 +380,7 @@ function MapView({ el, frameStyle, big = false }: { const d = Math.hypot(dx, dy) if (d < 1e-6) return { k: 0, nx: 0, ny: 0 } - const u = Math.min(1, Math.max(0, Math.log(km(a, b) / 150) / Math.log(10))) + const u = Math.min(1, Math.max(0, Math.log(legKm(a, b) / 150) / Math.log(10))) const ramp = u * u * (3 - 2 * u) // How much of the leg runs east-west, which is where a bow is truthful. const eastWest = 0.35 + 0.65 * Math.abs(dx / d) diff --git a/client/src/components/Studio/legKm.test.ts b/client/src/components/Studio/legKm.test.ts new file mode 100644 index 000000000..8eab05ec3 --- /dev/null +++ b/client/src/components/Studio/legKm.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { legKm } from './legKm'; + +describe('legKm', () => { + it('is zero between a point and itself', () => { + expect(legKm({ lat: 48.1, lng: 11.6 }, { lat: 48.1, lng: 11.6 })).toBe(0); + }); + + it('measures a degree of latitude as about 111 km', () => { + expect(legKm({ lat: 0, lng: 0 }, { lat: 1, lng: 0 })).toBeCloseTo(111.19, 1); + }); + + it('is the same both ways', () => { + const a = { lat: 35.68, lng: 139.69 }; + const b = { lat: 34.05, lng: -118.24 }; + expect(legKm(a, b)).toBeCloseTo(legKm(b, a), 9); + expect(legKm(a, b)).toBeGreaterThan(8700); + expect(legKm(a, b)).toBeLessThan(8900); + }); + + it('stays defined for antipodal points', () => { + expect(legKm({ lat: 0, lng: 0 }, { lat: 0, lng: 180 })).toBeCloseTo((12742 * Math.PI) / 2, 3); + }); +}); diff --git a/client/src/components/Studio/legKm.ts b/client/src/components/Studio/legKm.ts new file mode 100644 index 000000000..4ecfa4cab --- /dev/null +++ b/client/src/components/Studio/legKm.ts @@ -0,0 +1,14 @@ +/** + * Kilometres between two coordinates, along the great circle. + * + * Shared by the map element, which ramps how far a leg bows by it, and by the + * road fetch, which refuses to route a leg past its ceiling. The clamp keeps + * `asin` defined when rounding pushes two near antipodal points a hair past 1. + */ +export function legKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number { + const r = (d: number) => (d * Math.PI) / 180; + const dLat = r(b.lat - a.lat); + const dLng = r(b.lng - a.lng); + const h = Math.sin(dLat / 2) ** 2 + Math.cos(r(a.lat)) * Math.cos(r(b.lat)) * Math.sin(dLng / 2) ** 2; + return 12742 * Math.asin(Math.min(1, Math.sqrt(h))); +} diff --git a/client/src/components/Studio/roadRoute.ts b/client/src/components/Studio/roadRoute.ts index 59de3fa2b..1826da00f 100644 --- a/client/src/components/Studio/roadRoute.ts +++ b/client/src/components/Studio/roadRoute.ts @@ -1,4 +1,5 @@ import { calculateRouteWithLegs } from '../Map/RouteCalculator' +import { legKm } from './legKm' /** * The roads between a book's stops. @@ -63,15 +64,6 @@ export interface RoadStop { lng: number } -function km(a: RoadStop, b: RoadStop): number { - const r = (d: number) => (d * Math.PI) / 180 - const dLat = r(b.lat - a.lat) - const dLng = r(b.lng - a.lng) - const h = Math.sin(dLat / 2) ** 2 - + Math.cos(r(a.lat)) * Math.cos(r(b.lat)) * Math.sin(dLng / 2) ** 2 - return 12742 * Math.asin(Math.min(1, Math.sqrt(h))) -} - /** * Thin a leg to what a printed line can show. * @@ -107,7 +99,7 @@ export async function fetchRoads( const a = stops[i] const b = stops[i + 1] - if (km(a, b) > ROAD_CEILING_KM) { + if (legKm(a, b) > ROAD_CEILING_KM) { opts.onProgress?.(i + 1, legs) continue }