diff --git a/src/app/globals.css b/src/app/globals.css index b134686..3e9184e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -44,6 +44,7 @@ body { color: hsl(var(--foreground)); } +/* Custom attribution component replaces built-in controls */ .maplibregl-ctrl-attrib { display: none !important; } diff --git a/src/components/flight-tracker.tsx b/src/components/flight-tracker.tsx index 774f4d7..5999da6 100644 --- a/src/components/flight-tracker.tsx +++ b/src/components/flight-tracker.tsx @@ -20,6 +20,7 @@ import { ControlPanel } from "@/components/ui/control-panel"; import { AltitudeLegend } from "@/components/ui/altitude-legend"; import { CameraControls } from "@/components/ui/camera-controls"; import { StatusBar } from "@/components/ui/status-bar"; +import { MapAttribution } from "@/components/ui/map-attribution"; import { SettingsProvider, useSettings } from "@/hooks/use-settings"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useFlights } from "@/hooks/use-flights"; @@ -413,6 +414,7 @@ function FlightTrackerInner() {
+
diff --git a/src/components/ui/map-attribution.tsx b/src/components/ui/map-attribution.tsx new file mode 100644 index 0000000..ea90ec4 --- /dev/null +++ b/src/components/ui/map-attribution.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useState, useCallback, useRef, useEffect } from "react"; +import { motion, AnimatePresence } from "motion/react"; +import { Info } from "lucide-react"; +import { getAttributions, type AttributionEntry } from "@/lib/map-styles"; + +type MapAttributionProps = { + styleId: string; +}; + +const SM_BREAKPOINT = 640; + +function getInitialExpanded(): boolean { + if (typeof window === "undefined") return true; + return window.innerWidth >= SM_BREAKPOINT; +} + +export function MapAttribution({ styleId }: MapAttributionProps) { + const [expanded, setExpanded] = useState(getInitialExpanded); + const attributions = getAttributions(styleId); + const containerRef = useRef(null); + + const toggle = useCallback(() => setExpanded((prev) => !prev), []); + + // Close on outside click for small screens + useEffect(() => { + if (!expanded) return; + function handlePointerDown(e: PointerEvent) { + if ( + window.innerWidth >= SM_BREAKPOINT || + !containerRef.current || + containerRef.current.contains(e.target as Node) + ) + return; + setExpanded(false); + } + document.addEventListener("pointerdown", handlePointerDown); + return () => document.removeEventListener("pointerdown", handlePointerDown); + }, [expanded]); + + return ( +
+ + {expanded ? ( + + ) : ( + + )} + +
+ ); +} + +function CollapsedAttribution({ onExpand }: { onExpand: () => void }) { + return ( + + + + ); +} + +function ExpandedAttribution({ + attributions, + onCollapse, +}: { + attributions: AttributionEntry[]; + onCollapse: () => void; +}) { + return ( + + + + + © + + {attributions.map((attr, i) => ( + + + {attr.label} + + {i < attributions.length - 1 && ( + + · + + )} + + ))} + + · + + + OpenSky Network + + + + ); +} diff --git a/src/lib/map-styles.ts b/src/lib/map-styles.ts index 86af833..7a52e29 100644 --- a/src/lib/map-styles.ts +++ b/src/lib/map-styles.ts @@ -19,7 +19,8 @@ const SATELLITE_STYLE: Record = { ], tileSize: 256, maxzoom: 18, - attribution: "© Esri", + attribution: + "© Esri, Maxar, Earthstar Geographics", }, }, layers: [{ id: "satellite", type: "raster", source: "esri-satellite" }], @@ -33,7 +34,8 @@ const TERRAIN_STYLE: Record = { tiles: ["https://tile.opentopomap.org/{z}/{x}/{y}.png"], tileSize: 256, maxzoom: 17, - attribution: "© OpenTopoMap", + attribution: + "© OpenTopoMap (CC-BY-SA) · © OpenStreetMap contributors", }, }, layers: [{ id: "terrain", type: "raster", source: "opentopomap" }], @@ -49,7 +51,8 @@ const ESRI_TOPO_STYLE: Record = { ], tileSize: 256, maxzoom: 19, - attribution: "© Esri", + attribution: + "© Esri · © OpenStreetMap contributors", }, }, layers: [{ id: "esri-topo", type: "raster", source: "esri-topo" }], @@ -65,7 +68,8 @@ const SHADED_RELIEF_STYLE: Record = { ], tileSize: 256, maxzoom: 18, - attribution: "© Esri", + attribution: + "© Esri, Maxar, Earthstar Geographics", }, "terrain-dem": { type: "raster-dem", @@ -75,6 +79,8 @@ const SHADED_RELIEF_STYLE: Record = { tileSize: 256, maxzoom: 15, encoding: "terrarium", + attribution: + "Mapzen/Tilezen · AWS Open Data", }, }, terrain: { @@ -166,3 +172,64 @@ export const MAP_STYLES: MapStyle[] = [ ]; export const DEFAULT_STYLE = MAP_STYLES[0]; + +export type AttributionEntry = { + label: string; + url: string; +}; + +/** Returns the proper attribution entries for a given map style. */ +export function getAttributions(styleId: string): AttributionEntry[] { + const base: AttributionEntry[] = []; + + switch (styleId) { + case "dark": + case "dark-labels": + case "voyager": + case "positron": + base.push( + { + label: "OpenStreetMap", + url: "https://www.openstreetmap.org/copyright", + }, + { label: "CARTO", url: "https://carto.com/attributions" }, + ); + break; + case "satellite": + base.push({ label: "Esri", url: "https://www.esri.com/" }); + break; + case "terrain": + base.push( + { + label: "OpenStreetMap", + url: "https://www.openstreetmap.org/copyright", + }, + { label: "OpenTopoMap", url: "https://opentopomap.org/" }, + ); + break; + case "topo": + base.push( + { + label: "OpenStreetMap", + url: "https://www.openstreetmap.org/copyright", + }, + { label: "Esri", url: "https://www.esri.com/" }, + ); + break; + case "relief": + base.push( + { label: "Esri", url: "https://www.esri.com/" }, + { label: "Mapzen", url: "https://github.com/tilezen/joerd" }, + ); + break; + default: + base.push({ + label: "OpenStreetMap", + url: "https://www.openstreetmap.org/copyright", + }); + } + + base.push({ label: "MapLibre", url: "https://maplibre.org/" }); + + return base; +}