diff --git a/app/globals.css b/app/globals.css index 0cc0085..d4fc3b6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,5 +1,8 @@ @import "tailwindcss"; -@import "mapbox-gl/dist/mapbox-gl.css"; +/* Mapbox's stylesheet and our map-specific overrides (controls, popups, photo + markers) live in components/map-overrides.css, imported by MapView.tsx. They + ship in the lazy map chunk rather than this render-blocking critical CSS, and + only target DOM that exists once the map has mounted. */ @theme inline { --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; @@ -28,135 +31,9 @@ body { letter-spacing: -0.01em; } -.mapboxgl-popup-content, -.lofoten-popup .mapboxgl-popup-content { - border-radius: 14px !important; - background: rgba(255, 253, 246, 0.98) !important; - color: #1c1917 !important; - border: 1px solid rgba(214, 204, 188, 0.9); - box-shadow: 0 24px 60px rgba(46, 61, 54, 0.22) !important; - padding: 0 !important; - overflow: hidden; -} -.mapboxgl-popup-tip, -.lofoten-popup .mapboxgl-popup-tip { border-top-color: rgba(255, 253, 246, 0.98) !important; } -.mapboxgl-popup-close-button, -.lofoten-popup .mapboxgl-popup-close-button { - border-radius: 999px; - color: #57534e !important; - font-size: 20px; - line-height: 1; - margin: 8px; - padding: 5px 8px; -} -.mapboxgl-popup-close-button:hover, -.mapboxgl-popup-close-button:focus, -.lofoten-popup .mapboxgl-popup-close-button:hover, -.lofoten-popup .mapboxgl-popup-close-button:focus { - background: rgba(28, 25, 23, 0.06) !important; - color: #1c1917 !important; - outline: 2px solid rgba(15, 118, 110, 0.35); - outline-offset: 2px; -} -.mapboxgl-ctrl-group { - border-radius: 10px !important; - overflow: hidden; - background: rgba(255, 253, 246, 0.94) !important; - box-shadow: 0 12px 30px rgba(46, 61, 54, 0.18) !important; -} -.mapboxgl-ctrl button { color: #1c1917 !important; } - -.lofoten-photo-marker { - width: 48px; - height: 48px; - cursor: pointer; - overflow: visible; - border: 3px solid #fffdf6; - border-radius: 12px; - background: #e7a13d; - padding: 0; - box-shadow: - 0 2px 5px rgba(28, 25, 23, 0.22), - 0 10px 22px rgba(46, 61, 54, 0.22); - transition: transform 160ms ease, box-shadow 160ms ease; -} - -.lofoten-photo-marker::before, -.lofoten-photo-marker::after { - content: ""; - position: absolute; - z-index: -1; - inset: 2px; - border: 2px solid #fffdf6; - border-radius: 11px; - background: #d6ccb8; - box-shadow: 0 4px 10px rgba(28, 25, 23, 0.16); -} - -.lofoten-photo-marker:not(.lofoten-photo-marker-cluster)::before, -.lofoten-photo-marker:not(.lofoten-photo-marker-cluster)::after { - display: none; -} - -.lofoten-photo-marker-cluster { - width: 56px; - height: 56px; -} - -.lofoten-photo-marker-cluster::before { - transform: translate(5px, -4px) rotate(7deg); -} - -.lofoten-photo-marker-cluster::after { - transform: translate(-5px, 4px) rotate(-5deg); -} - -.lofoten-photo-marker img { - display: block; - width: 100%; - height: 100%; - border-radius: 9px; - object-fit: cover; - background: #dbe7df; -} - -/* Pulsing ring around the photo currently open in the editor. The outer div is - positioned by Mapbox (inline transform), so the animation lives on a child. */ -.lofoten-photo-highlight { - width: 76px; - height: 76px; - cursor: grab; -} - -.lofoten-photo-highlight:active { - cursor: grabbing; -} - -.lofoten-photo-highlight-ring { - position: absolute; - inset: 0; - border: 3px solid #e7a13d; - border-radius: 18px; - box-shadow: - 0 0 0 7px rgba(231, 161, 61, 0.28), - 0 0 34px rgba(231, 161, 61, 0.55); - animation: lofoten-highlight-pulse 1.5s ease-in-out infinite; -} - -@keyframes lofoten-highlight-pulse { - 0%, - 100% { - transform: scale(1); - opacity: 1; - } - 50% { - transform: scale(1.14); - opacity: 0.65; - } -} - /* Slow drift on the active Journey hero slide — ambient motion that hints the - trip can be played. Disabled for reduced-motion users. */ + trip can be played. Disabled for reduced-motion users. Lives here (not in the + map overrides) because the hero card renders independently of the map. */ .journey-hero-kenburns { animation: journey-hero-kenburns 7s ease-out forwards; } @@ -169,193 +46,3 @@ body { @media (prefers-reduced-motion: reduce) { .journey-hero-kenburns { animation: none; } } - -/* Location-check preview: the flagged photo's time-neighbors and the - suggested corrected spot. */ -.lofoten-outlier-neighbor { - width: 14px; - height: 14px; - border-radius: 9999px; - border: 2.5px solid #fffdf6; - background: #0f766e; - box-shadow: 0 2px 8px rgba(28, 25, 23, 0.35); - pointer-events: none; -} - -.lofoten-outlier-center { - width: 36px; - height: 36px; - border-radius: 9999px; - border: 3px dashed #0f766e; - background: rgba(15, 118, 110, 0.14); - pointer-events: none; -} - -.lofoten-photo-marker-fallback { - display: flex; - width: 100%; - height: 100%; - align-items: center; - justify-content: center; - border-radius: 9px; - color: #fffdf6; - font-size: 0.62rem; - font-weight: 800; - text-transform: uppercase; -} - -.lofoten-photo-marker-count { - position: absolute; - top: -10px; - right: -10px; - min-width: 25px; - height: 25px; - padding: 0 7px; - display: flex; - align-items: center; - justify-content: center; - border: 2px solid #fffdf6; - border-radius: 999px; - background: #0f766e; - color: white; - font-size: 0.7rem; - font-weight: 800; - line-height: 1; - box-shadow: 0 4px 10px rgba(28, 25, 23, 0.24); -} - -.lofoten-photo-marker:hover, -.lofoten-photo-marker:focus-visible { - z-index: 2; - box-shadow: - 0 3px 7px rgba(28, 25, 23, 0.24), - 0 14px 28px rgba(46, 61, 54, 0.28); -} - -.lofoten-photo-marker:focus-visible { - outline: 3px solid rgba(15, 118, 110, 0.55); - outline-offset: 4px; -} - -.map-unavailable .mapboxgl-ctrl, -.map-unavailable .mapboxgl-ctrl-bottom-left, -.map-unavailable .mapboxgl-ctrl-bottom-right, -.map-unavailable-active .mapboxgl-control-container, -.map-unavailable-active .mapboxgl-ctrl, -.map-unavailable-active .mapboxgl-ctrl-bottom-left, -.map-unavailable-active .mapboxgl-ctrl-bottom-right { - display: none !important; -} - -@media (max-width: 767px) { - .mapboxgl-ctrl-bottom-left, - .mapboxgl-ctrl-bottom-right { - bottom: calc(11.75rem + env(safe-area-inset-bottom)) !important; - } - - .mapboxgl-ctrl-bottom-left .mapboxgl-ctrl { - margin-left: 0.75rem !important; - } - - .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl { - margin-right: 0.75rem !important; - } -} - -.lofoten-popup-card { - width: 16rem; - background: rgba(255, 253, 246, 0.98); - color: #1c1917; -} - -.lofoten-popup-image { - display: block; - height: 9rem; - width: 100%; - object-fit: cover; -} - -.lofoten-popup-video-fallback { - align-items: center; - background: #f5f5f4; - color: #78716c; - display: flex; - font-size: 0.85rem; - font-weight: 800; - justify-content: center; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.lofoten-popup-body { - padding: 0.85rem; -} - -.lofoten-popup-title { - color: #1c1917; - font-size: 0.92rem; - font-weight: 800; - line-height: 1.35; - padding-right: 1.5rem; -} - -.lofoten-popup-meta { - color: #78716c; - font-size: 0.78rem; - line-height: 1.4; - margin-top: 0.35rem; -} - -.lofoten-popup-by { font-weight: 600; color: #57534e; } - -.lofoten-popup-actions { - display: flex; - gap: 0.4rem; - margin-top: 0.7rem; - padding-top: 0.6rem; - border-top: 1px solid rgba(214, 204, 188, 0.7); -} -.lofoten-popup-action { - flex: 1; - cursor: pointer; - border-radius: 8px; - border: 1px solid rgba(214, 204, 188, 0.9); - background: #fffdf6; - padding: 0.4rem 0.5rem; - font-size: 0.78rem; - font-weight: 800; - color: #1c1917; - transition: background 0.15s ease; -} -.lofoten-popup-action:hover { background: rgba(28, 25, 23, 0.05); } -.lofoten-popup-action:focus-visible { - outline: 2px solid rgba(15, 118, 110, 0.4); - outline-offset: 1px; -} -.lofoten-popup-action-danger { - border-color: #f6c9cf; - background: #fef2f3; - color: #b4232f; -} -.lofoten-popup-action-danger:hover { background: #fde4e6; } -.lofoten-popup-action-journey { - border-color: rgba(231, 161, 61, 0.6); - background: #fdf1dc; - color: #8a5414; -} -.lofoten-popup-action-journey:hover { background: #fbe6c2; } - -.lofoten-popup-tag { - display: inline-block; - margin-bottom: 0.4rem; - border-radius: 999px; - padding: 0.12rem 0.5rem; - font-size: 0.62rem; - font-weight: 800; - letter-spacing: 0.08em; - text-transform: uppercase; -} -.lofoten-popup-tag-photo { background: #fbe7c6; color: #8a5212; } -.lofoten-popup-tag-note { background: #f6ead0; color: #7c4a14; } -.lofoten-popup-tag-place { background: #d7ecdf; color: #0f5f55; } -.lofoten-popup-tag-route { background: #d7ecdf; color: #0f5f55; } diff --git a/app/layout.tsx b/app/layout.tsx index fe3a369..3236704 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -14,9 +14,34 @@ export const viewport: Viewport = { themeColor: "#e7efe8", }; +// Origin of the Supabase project (storage + realtime). Derived from the public +// env var so a preconnect can warm the TLS handshake before the first photo or +// data request fires. +const supabaseOrigin = (() => { + try { + return process.env.NEXT_PUBLIC_SUPABASE_URL ? new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).origin : null; + } catch { + return null; + } +})(); + export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) { return ( +
+ {/* Warm the connections the map and gallery need on first paint: Mapbox + style/tiles/telemetry and Supabase storage. preconnect opens the + TCP+TLS early; dns-prefetch is the cheaper fallback for older browsers. */} + + + + {supabaseOrigin ? ( + <> + + + > + ) : null} + {children} ); diff --git a/components/JourneyHeroCard.tsx b/components/JourneyHeroCard.tsx index 9fbc200..ca6d19a 100644 --- a/components/JourneyHeroCard.tsx +++ b/components/JourneyHeroCard.tsx @@ -130,6 +130,7 @@ export function JourneyHeroCard({ photos, momentCount, dayCount, onPlay, disable src={previewUrl(photo) ?? undefined} alt="" aria-hidden + decoding="async" className={cn( "pointer-events-none absolute inset-0 h-full w-full object-cover transition-opacity duration-[1200ms] ease-in-out motion-reduce:transition-none", index === active ? "opacity-100" : "opacity-0", diff --git a/components/MapView.tsx b/components/MapView.tsx index ca349ab..2ea6322 100644 --- a/components/MapView.tsx +++ b/components/MapView.tsx @@ -1,6 +1,12 @@ "use client"; import mapboxgl from "mapbox-gl"; +// Loaded here rather than in globals.css so the vendored stylesheet and our map +// overrides ship with this lazily-imported (ssr:false) map chunk, keeping them +// off the render-blocking critical path. The overrides import must follow the +// vendor sheet so its cascade wins. +import "mapbox-gl/dist/mapbox-gl.css"; +import "./map-overrides.css"; import { MapPin } from "lucide-react"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { LOFOTEN_CENTER } from "@/lib/geo"; diff --git a/components/map-overrides.css b/components/map-overrides.css new file mode 100644 index 0000000..9b6cb61 --- /dev/null +++ b/components/map-overrides.css @@ -0,0 +1,328 @@ +/* Map-specific style overrides for Mapbox controls, popups and the custom photo + markers. Imported by components/MapView.tsx (after mapbox-gl's own stylesheet) + rather than from globals.css, so these rules ship in the lazy map chunk and + stay off the render-blocking critical path. Every selector here targets DOM + that only exists once the map has mounted. */ + +.mapboxgl-popup-content, +.lofoten-popup .mapboxgl-popup-content { + border-radius: 14px !important; + background: rgba(255, 253, 246, 0.98) !important; + color: #1c1917 !important; + border: 1px solid rgba(214, 204, 188, 0.9); + box-shadow: 0 24px 60px rgba(46, 61, 54, 0.22) !important; + padding: 0 !important; + overflow: hidden; +} +.mapboxgl-popup-tip, +.lofoten-popup .mapboxgl-popup-tip { border-top-color: rgba(255, 253, 246, 0.98) !important; } +.mapboxgl-popup-close-button, +.lofoten-popup .mapboxgl-popup-close-button { + border-radius: 999px; + color: #57534e !important; + font-size: 20px; + line-height: 1; + margin: 8px; + padding: 5px 8px; +} +.mapboxgl-popup-close-button:hover, +.mapboxgl-popup-close-button:focus, +.lofoten-popup .mapboxgl-popup-close-button:hover, +.lofoten-popup .mapboxgl-popup-close-button:focus { + background: rgba(28, 25, 23, 0.06) !important; + color: #1c1917 !important; + outline: 2px solid rgba(15, 118, 110, 0.35); + outline-offset: 2px; +} +.mapboxgl-ctrl-group { + border-radius: 10px !important; + overflow: hidden; + background: rgba(255, 253, 246, 0.94) !important; + box-shadow: 0 12px 30px rgba(46, 61, 54, 0.18) !important; +} +.mapboxgl-ctrl button { color: #1c1917 !important; } + +.lofoten-photo-marker { + width: 48px; + height: 48px; + cursor: pointer; + overflow: visible; + border: 3px solid #fffdf6; + border-radius: 12px; + background: #e7a13d; + padding: 0; + box-shadow: + 0 2px 5px rgba(28, 25, 23, 0.22), + 0 10px 22px rgba(46, 61, 54, 0.22); + transition: transform 160ms ease, box-shadow 160ms ease; +} + +.lofoten-photo-marker::before, +.lofoten-photo-marker::after { + content: ""; + position: absolute; + z-index: -1; + inset: 2px; + border: 2px solid #fffdf6; + border-radius: 11px; + background: #d6ccb8; + box-shadow: 0 4px 10px rgba(28, 25, 23, 0.16); +} + +.lofoten-photo-marker:not(.lofoten-photo-marker-cluster)::before, +.lofoten-photo-marker:not(.lofoten-photo-marker-cluster)::after { + display: none; +} + +.lofoten-photo-marker-cluster { + width: 56px; + height: 56px; +} + +.lofoten-photo-marker-cluster::before { + transform: translate(5px, -4px) rotate(7deg); +} + +.lofoten-photo-marker-cluster::after { + transform: translate(-5px, 4px) rotate(-5deg); +} + +.lofoten-photo-marker img { + display: block; + width: 100%; + height: 100%; + border-radius: 9px; + object-fit: cover; + background: #dbe7df; +} + +/* Pulsing ring around the photo currently open in the editor. The outer div is + positioned by Mapbox (inline transform), so the animation lives on a child. */ +.lofoten-photo-highlight { + width: 76px; + height: 76px; + cursor: grab; +} + +.lofoten-photo-highlight:active { + cursor: grabbing; +} + +.lofoten-photo-highlight-ring { + position: absolute; + inset: 0; + border: 3px solid #e7a13d; + border-radius: 18px; + box-shadow: + 0 0 0 7px rgba(231, 161, 61, 0.28), + 0 0 34px rgba(231, 161, 61, 0.55); + animation: lofoten-highlight-pulse 1.5s ease-in-out infinite; +} + +@keyframes lofoten-highlight-pulse { + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.14); + opacity: 0.65; + } +} + +/* The ring stays visible (it marks the photo being edited) but holds still for + users who prefer reduced motion. */ +@media (prefers-reduced-motion: reduce) { + .lofoten-photo-highlight-ring { animation: none; } +} + +/* Location-check preview: the flagged photo's time-neighbors and the + suggested corrected spot. */ +.lofoten-outlier-neighbor { + width: 14px; + height: 14px; + border-radius: 9999px; + border: 2.5px solid #fffdf6; + background: #0f766e; + box-shadow: 0 2px 8px rgba(28, 25, 23, 0.35); + pointer-events: none; +} + +.lofoten-outlier-center { + width: 36px; + height: 36px; + border-radius: 9999px; + border: 3px dashed #0f766e; + background: rgba(15, 118, 110, 0.14); + pointer-events: none; +} + +.lofoten-photo-marker-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + border-radius: 9px; + color: #fffdf6; + font-size: 0.62rem; + font-weight: 800; + text-transform: uppercase; +} + +.lofoten-photo-marker-count { + position: absolute; + top: -10px; + right: -10px; + min-width: 25px; + height: 25px; + padding: 0 7px; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid #fffdf6; + border-radius: 999px; + background: #0f766e; + color: white; + font-size: 0.7rem; + font-weight: 800; + line-height: 1; + box-shadow: 0 4px 10px rgba(28, 25, 23, 0.24); +} + +.lofoten-photo-marker:hover, +.lofoten-photo-marker:focus-visible { + z-index: 2; + box-shadow: + 0 3px 7px rgba(28, 25, 23, 0.24), + 0 14px 28px rgba(46, 61, 54, 0.28); +} + +.lofoten-photo-marker:focus-visible { + outline: 3px solid rgba(15, 118, 110, 0.55); + outline-offset: 4px; +} + +.map-unavailable .mapboxgl-ctrl, +.map-unavailable .mapboxgl-ctrl-bottom-left, +.map-unavailable .mapboxgl-ctrl-bottom-right, +.map-unavailable-active .mapboxgl-control-container, +.map-unavailable-active .mapboxgl-ctrl, +.map-unavailable-active .mapboxgl-ctrl-bottom-left, +.map-unavailable-active .mapboxgl-ctrl-bottom-right { + display: none !important; +} + +@media (max-width: 767px) { + .mapboxgl-ctrl-bottom-left, + .mapboxgl-ctrl-bottom-right { + bottom: calc(11.75rem + env(safe-area-inset-bottom)) !important; + } + + .mapboxgl-ctrl-bottom-left .mapboxgl-ctrl { + margin-left: 0.75rem !important; + } + + .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl { + margin-right: 0.75rem !important; + } +} + +.lofoten-popup-card { + width: 16rem; + background: rgba(255, 253, 246, 0.98); + color: #1c1917; +} + +.lofoten-popup-image { + display: block; + height: 9rem; + width: 100%; + object-fit: cover; +} + +.lofoten-popup-video-fallback { + align-items: center; + background: #f5f5f4; + color: #78716c; + display: flex; + font-size: 0.85rem; + font-weight: 800; + justify-content: center; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.lofoten-popup-body { + padding: 0.85rem; +} + +.lofoten-popup-title { + color: #1c1917; + font-size: 0.92rem; + font-weight: 800; + line-height: 1.35; + padding-right: 1.5rem; +} + +.lofoten-popup-meta { + color: #78716c; + font-size: 0.78rem; + line-height: 1.4; + margin-top: 0.35rem; +} + +.lofoten-popup-by { font-weight: 600; color: #57534e; } + +.lofoten-popup-actions { + display: flex; + gap: 0.4rem; + margin-top: 0.7rem; + padding-top: 0.6rem; + border-top: 1px solid rgba(214, 204, 188, 0.7); +} +.lofoten-popup-action { + flex: 1; + cursor: pointer; + border-radius: 8px; + border: 1px solid rgba(214, 204, 188, 0.9); + background: #fffdf6; + padding: 0.4rem 0.5rem; + font-size: 0.78rem; + font-weight: 800; + color: #1c1917; + transition: background 0.15s ease; +} +.lofoten-popup-action:hover { background: rgba(28, 25, 23, 0.05); } +.lofoten-popup-action:focus-visible { + outline: 2px solid rgba(15, 118, 110, 0.4); + outline-offset: 1px; +} +.lofoten-popup-action-danger { + border-color: #f6c9cf; + background: #fef2f3; + color: #b4232f; +} +.lofoten-popup-action-danger:hover { background: #fde4e6; } +.lofoten-popup-action-journey { + border-color: rgba(231, 161, 61, 0.6); + background: #fdf1dc; + color: #8a5414; +} +.lofoten-popup-action-journey:hover { background: #fbe6c2; } + +.lofoten-popup-tag { + display: inline-block; + margin-bottom: 0.4rem; + border-radius: 999px; + padding: 0.12rem 0.5rem; + font-size: 0.62rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.lofoten-popup-tag-photo { background: #fbe7c6; color: #8a5212; } +.lofoten-popup-tag-note { background: #f6ead0; color: #7c4a14; } +.lofoten-popup-tag-place { background: #d7ecdf; color: #0f5f55; } +.lofoten-popup-tag-route { background: #d7ecdf; color: #0f5f55; } diff --git a/lib/geo.ts b/lib/geo.ts index 3912424..87d22c0 100644 --- a/lib/geo.ts +++ b/lib/geo.ts @@ -1,11 +1,34 @@ import type { Feature, FeatureCollection, LineString, Point } from "geojson"; -import length from "@turf/length"; import type { LngLat, Note, Photo, Place, RouteSegment } from "@/types/trip"; export const LOFOTEN_CENTER: [number, number] = [13.0897, 67.9325]; export type CoordinateBounds = { sw: [number, number]; ne: [number, number]; center: [number, number]; diagonalMeters: number }; +// Great-circle (haversine) distance, inlined so route/bounds math doesn't pull +// @turf/length (and its @turf/distance/helpers/meta deps) into the page graph — +// geo.ts is the only Turf consumer reachable from the initial bundle, so this +// drops Turf from first load entirely. Uses Turf's mean Earth radius so the +// displayed distances stay identical to the previous implementation. +const EARTH_RADIUS_METERS = 6_371_008.8; + +function segmentDistanceMeters([fromLng, fromLat]: [number, number], [toLng, toLat]: [number, number]): number { + const fromPhi = (fromLat * Math.PI) / 180; + const toPhi = (toLat * Math.PI) / 180; + const deltaPhi = ((toLat - fromLat) * Math.PI) / 180; + const deltaLambda = ((toLng - fromLng) * Math.PI) / 180; + const a = Math.sin(deltaPhi / 2) ** 2 + Math.cos(fromPhi) * Math.cos(toPhi) * Math.sin(deltaLambda / 2) ** 2; + return 2 * EARTH_RADIUS_METERS * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +function geometryDistanceMeters(geometry: LineString): number { + let meters = 0; + for (let i = 1; i < geometry.coordinates.length; i += 1) { + meters += segmentDistanceMeters(geometry.coordinates[i - 1] as [number, number], geometry.coordinates[i] as [number, number]); + } + return meters; +} + /** * Axis-aligned bounds for a set of [lng, lat] coordinates, in the shape * map.fitBounds accepts ([sw, ne]). Replaces mapboxgl.LngLatBounds at call @@ -24,15 +47,21 @@ export function coordinateBounds(coords: [number, number][]): CoordinateBounds | if (lat < minLat) minLat = lat; if (lat > maxLat) maxLat = lat; } - const diagonal: LineString = { type: "LineString", coordinates: [[minLng, minLat], [maxLng, maxLat]] }; return { sw: [minLng, minLat], ne: [maxLng, maxLat], center: [(minLng + maxLng) / 2, (minLat + maxLat) / 2], - diagonalMeters: length({ type: "Feature", geometry: diagonal, properties: {} }, { units: "kilometers" }) * 1000, + diagonalMeters: segmentDistanceMeters([minLng, minLat], [maxLng, maxLat]), }; } +// Great-circle distance between two points in kilometers. Mirrors @turf/distance +// (same Earth radius) so callers like photo-outlier detection can reach it +// without importing the Turf-heavy journey-leg module into the page graph. +export function distanceKm(a: LngLat, b: LngLat): number { + return segmentDistanceMeters([a.lng, a.lat], [b.lng, b.lat]) / 1000; +} + export function routeGeometry(points: LngLat[]): LineString { return { type: "LineString", coordinates: points.map((point) => [point.lng, point.lat]) }; } @@ -44,7 +73,7 @@ export function routeDistanceMeters(points: LngLat[]) { export function lineDistanceMeters(geometry: LineString) { if (geometry.coordinates.length < 2) return 0; - return Math.round(length({ type: "Feature", geometry, properties: {} }, { units: "kilometers" }) * 1000); + return Math.round(geometryDistanceMeters(geometry)); } export type DayItems = { @@ -77,9 +106,9 @@ export function routeFeatureCollection(routes: RouteSegment[]): FeatureCollectio type: "FeatureCollection", features: routes.map((route) => { const geometry = (route.geometry_geojson.type === "Feature" ? route.geometry_geojson.geometry : route.geometry_geojson) as LineString; - const distanceKm = route.distance_meters + const routeDistanceKm = route.distance_meters ? route.distance_meters / 1000 - : length({ type: "Feature", geometry, properties: {} }, { units: "kilometers" }); + : geometryDistanceMeters(geometry) / 1000; const feature: Feature