fix: add OpenStreetMap and data source attribution across all map styles (#8)

Resolves #7 — missing OpenStreetMap attribution.

- Add custom MapAttribution component (expanded by default on desktop,
  collapsed on mobile with outside-click-to-close)
- Add proper attribution strings to all raster tile sources (OSM, CARTO,
  Esri, OpenTopoMap, Mapzen)
- Add getAttributions() helper that returns correct credits per style
- Include OpenSky Network as flight data source attribution
- Include MapLibre as rendering engine attribution
- Replace hidden built-in MapLibre attribution with themed custom UI
This commit is contained in:
kew
2026-02-17 22:22:15 +05:30
committed by GitHub
parent bf99d4843f
commit e262bd730d
4 changed files with 228 additions and 4 deletions

View File

@ -44,6 +44,7 @@ body {
color: hsl(var(--foreground)); color: hsl(var(--foreground));
} }
/* Custom attribution component replaces built-in controls */
.maplibregl-ctrl-attrib { .maplibregl-ctrl-attrib {
display: none !important; display: none !important;
} }

View File

@ -20,6 +20,7 @@ import { ControlPanel } from "@/components/ui/control-panel";
import { AltitudeLegend } from "@/components/ui/altitude-legend"; import { AltitudeLegend } from "@/components/ui/altitude-legend";
import { CameraControls } from "@/components/ui/camera-controls"; import { CameraControls } from "@/components/ui/camera-controls";
import { StatusBar } from "@/components/ui/status-bar"; import { StatusBar } from "@/components/ui/status-bar";
import { MapAttribution } from "@/components/ui/map-attribution";
import { SettingsProvider, useSettings } from "@/hooks/use-settings"; import { SettingsProvider, useSettings } from "@/hooks/use-settings";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useFlights } from "@/hooks/use-flights"; import { useFlights } from "@/hooks/use-flights";
@ -413,6 +414,7 @@ function FlightTrackerInner() {
<div className="pointer-events-auto absolute bottom-[env(safe-area-inset-bottom,0px)] right-3 mb-3 flex flex-col items-end gap-2 sm:bottom-4 sm:right-4 sm:mb-0"> <div className="pointer-events-auto absolute bottom-[env(safe-area-inset-bottom,0px)] right-3 mb-3 flex flex-col items-end gap-2 sm:bottom-4 sm:right-4 sm:mb-0">
<CameraControls /> <CameraControls />
<AltitudeLegend /> <AltitudeLegend />
<MapAttribution styleId={mapStyle.id} />
</div> </div>
</div> </div>

View File

@ -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<HTMLDivElement>(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 (
<div ref={containerRef} className="flex items-end justify-end">
<AnimatePresence mode="wait">
{expanded ? (
<ExpandedAttribution
key="expanded"
attributions={attributions}
onCollapse={toggle}
/>
) : (
<CollapsedAttribution key="collapsed" onExpand={toggle} />
)}
</AnimatePresence>
</div>
);
}
function CollapsedAttribution({ onExpand }: { onExpand: () => void }) {
return (
<motion.button
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.15 }}
onClick={onExpand}
className="flex h-5 w-5 items-center justify-center rounded-full backdrop-blur-xl transition-colors"
style={{
backgroundColor: "rgb(var(--ui-bg) / 0.35)",
border: "1px solid rgb(var(--ui-fg) / 0.06)",
color: "rgb(var(--ui-fg) / 0.3)",
}}
aria-label="Show map attribution"
title="Map data attribution"
>
<Info className="h-2.5 w-2.5" />
</motion.button>
);
}
function ExpandedAttribution({
attributions,
onCollapse,
}: {
attributions: AttributionEntry[];
onCollapse: () => void;
}) {
return (
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 4 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
className="flex items-center gap-1.5 rounded-lg px-2 py-1 backdrop-blur-xl"
style={{
backgroundColor: "rgb(var(--ui-bg) / 0.45)",
border: "1px solid rgb(var(--ui-fg) / 0.06)",
}}
>
<button
onClick={onCollapse}
className="shrink-0 transition-colors"
style={{ color: "rgb(var(--ui-fg) / 0.3)" }}
aria-label="Collapse attribution"
>
<Info className="h-2.5 w-2.5" />
</button>
<span
className="flex flex-wrap items-center gap-x-1 text-[9px] leading-tight tracking-wide"
style={{ color: "rgb(var(--ui-fg) / 0.35)" }}
>
<span
className="font-medium"
style={{ color: "rgb(var(--ui-fg) / 0.25)" }}
>
©
</span>
{attributions.map((attr, i) => (
<span key={attr.label} className="inline-flex items-center">
<a
href={attr.url}
target="_blank"
rel="noopener noreferrer"
className="transition-colors hover:underline"
style={{ color: "rgb(var(--ui-fg) / 0.4)" }}
>
{attr.label}
</a>
{i < attributions.length - 1 && (
<span
className="ml-1"
style={{ color: "rgb(var(--ui-fg) / 0.15)" }}
>
·
</span>
)}
</span>
))}
<span className="ml-0.5" style={{ color: "rgb(var(--ui-fg) / 0.15)" }}>
·
</span>
<a
href="https://opensky-network.org/"
target="_blank"
rel="noopener noreferrer"
className="transition-colors hover:underline"
style={{ color: "rgb(var(--ui-fg) / 0.4)" }}
>
OpenSky Network
</a>
</span>
</motion.div>
);
}

View File

@ -19,7 +19,8 @@ const SATELLITE_STYLE: Record<string, unknown> = {
], ],
tileSize: 256, tileSize: 256,
maxzoom: 18, maxzoom: 18,
attribution: "&copy; Esri", attribution:
"&copy; <a href='https://www.esri.com/'>Esri</a>, Maxar, Earthstar Geographics",
}, },
}, },
layers: [{ id: "satellite", type: "raster", source: "esri-satellite" }], layers: [{ id: "satellite", type: "raster", source: "esri-satellite" }],
@ -33,7 +34,8 @@ const TERRAIN_STYLE: Record<string, unknown> = {
tiles: ["https://tile.opentopomap.org/{z}/{x}/{y}.png"], tiles: ["https://tile.opentopomap.org/{z}/{x}/{y}.png"],
tileSize: 256, tileSize: 256,
maxzoom: 17, maxzoom: 17,
attribution: "&copy; OpenTopoMap", attribution:
"&copy; <a href='https://opentopomap.org/'>OpenTopoMap</a> (<a href='https://creativecommons.org/licenses/by-sa/3.0/'>CC-BY-SA</a>) · &copy; <a href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors",
}, },
}, },
layers: [{ id: "terrain", type: "raster", source: "opentopomap" }], layers: [{ id: "terrain", type: "raster", source: "opentopomap" }],
@ -49,7 +51,8 @@ const ESRI_TOPO_STYLE: Record<string, unknown> = {
], ],
tileSize: 256, tileSize: 256,
maxzoom: 19, maxzoom: 19,
attribution: "&copy; Esri", attribution:
"&copy; <a href='https://www.esri.com/'>Esri</a> · &copy; <a href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors",
}, },
}, },
layers: [{ id: "esri-topo", type: "raster", source: "esri-topo" }], layers: [{ id: "esri-topo", type: "raster", source: "esri-topo" }],
@ -65,7 +68,8 @@ const SHADED_RELIEF_STYLE: Record<string, unknown> = {
], ],
tileSize: 256, tileSize: 256,
maxzoom: 18, maxzoom: 18,
attribution: "&copy; Esri", attribution:
"&copy; <a href='https://www.esri.com/'>Esri</a>, Maxar, Earthstar Geographics",
}, },
"terrain-dem": { "terrain-dem": {
type: "raster-dem", type: "raster-dem",
@ -75,6 +79,8 @@ const SHADED_RELIEF_STYLE: Record<string, unknown> = {
tileSize: 256, tileSize: 256,
maxzoom: 15, maxzoom: 15,
encoding: "terrarium", encoding: "terrarium",
attribution:
"<a href='https://github.com/tilezen/joerd'>Mapzen/Tilezen</a> · AWS Open Data",
}, },
}, },
terrain: { terrain: {
@ -166,3 +172,64 @@ export const MAP_STYLES: MapStyle[] = [
]; ];
export const DEFAULT_STYLE = MAP_STYLES[0]; 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;
}