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;
+}