no. 09 · maps
Satellite map
Real satellite imagery with crosshairs through the location.
Install
npx shadcn@latest add https://curio-components.vercel.app/r/map-satellite.jsonFirst curio component? Also add @import "./curio.css"; to your global CSS. See installation.
Usage
import { MapSatellite } from '@/components/curio/maps/map-satellite'
export function Where() {
return <MapSatellite lat={38.7223} lon={-9.1393} label="Lisbon, PT" />
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| lat / lon | number | - | The centre of the map. |
| label | string | - | Shown on the map, e.g. "Lisbon, PT". |
| href | string | - | Where clicking the map goes. |
| size | number | 260 | Square size in px. |
| zoom | number | 12 to 13 | Map zoom level. |
Source
This is exactly what the command copies into your project.
components/curio/maps/map-satellite.tsx
import { MapTiles, SATELLITE_CREDIT } from '../lib/map-tiles'
import { coords, Credit, type MapProps } from './map-pin'
// real satellite imagery with crosshairs; brightens on hover
export function MapSatellite({ lat, lon, label, href, size = 260, zoom = 13 }: MapProps) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={label ? `map of ${label}` : 'map'}
className="group relative block shrink-0 overflow-hidden"
style={{ width: size, height: size }}
>
<div className="absolute inset-0 transition-[filter] duration-500 [filter:saturate(0.6)_brightness(0.75)] group-hover:[filter:saturate(1)_brightness(0.95)]">
<MapTiles lat={lat} lon={lon} zoom={zoom} size={size} style="satellite" />
</div>
<span className="absolute inset-x-0 top-1/2 z-10 h-px bg-white/40" />
<span className="absolute inset-y-0 left-1/2 z-10 w-px bg-white/40" />
<span className="absolute left-1/2 top-1/2 z-10 h-[30px] w-[30px] -translate-x-1/2 -translate-y-1/2 border border-white" />
<span className="absolute bottom-2.5 right-2.5 z-10 text-right font-mono text-[11px] uppercase leading-tight text-white">
{label}
<br />
<span className="text-white/70">{coords(lat, lon)}</span>
</span>
<Credit text={SATELLITE_CREDIT} light />
</a>
)
}components/curio/lib/map-tiles.tsx
// square map centred on lat/lon, built from esri's free tiles (no api key).
// attribution is required: render MAP_CREDIT somewhere on the map.
export type TileStyle = 'street' | 'light' | 'dark' | 'light_nolabels' | 'dark_nolabels' | 'satellite'
const TILE = 256
const LAYERS: Record<TileStyle, string[]> = {
street: ['World_Street_Map'],
light: ['Canvas/World_Light_Gray_Base', 'Canvas/World_Light_Gray_Reference'],
dark: ['Canvas/World_Dark_Gray_Base', 'Canvas/World_Dark_Gray_Reference'],
light_nolabels: ['Canvas/World_Light_Gray_Base'],
dark_nolabels: ['Canvas/World_Dark_Gray_Base'],
satellite: ['World_Imagery'],
}
export const MAP_CREDIT = '© Esri, HERE, Garmin, OSM'
export const SATELLITE_CREDIT = '© Esri, Maxar, Earthstar'
// web mercator pixel position at a zoom level
export function project(lat: number, lon: number, zoom: number) {
const n = 2 ** zoom
const r = (lat * Math.PI) / 180
return {
x: ((lon + 180) / 360) * n * TILE,
y: ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * n * TILE,
}
}
export function MapTiles({
lat,
lon,
zoom,
size,
style,
className = '',
}: {
lat: number
lon: number
zoom: number
size: number
style: TileStyle
className?: string
}) {
const n = 2 ** zoom
const { x, y } = project(lat, lon, zoom)
const left = x - size / 2
const top = y - size / 2
const x0 = Math.floor(left / TILE)
const y0 = Math.floor(top / TILE)
const x1 = Math.floor((left + size) / TILE)
const y1 = Math.floor((top + size) / TILE)
const tiles = []
for (const layer of LAYERS[style]) {
for (let tx = x0; tx <= x1; tx++) {
for (let ty = y0; ty <= y1; ty++) {
const wrapped = ((tx % n) + n) % n
tiles.push(
// eslint-disable-next-line @next/next/no-img-element
<img
key={`${layer}-${tx}-${ty}`}
src={`https://server.arcgisonline.com/ArcGIS/rest/services/${layer}/MapServer/tile/${zoom}/${ty}/${wrapped}`}
alt=""
width={TILE}
height={TILE}
draggable={false}
className="absolute max-w-none select-none"
style={{ left: Math.round(tx * TILE - left), top: Math.round(ty * TILE - top), width: TILE, height: TILE }}
/>,
)
}
}
}
return (
<div className={`absolute inset-0 overflow-hidden ${className}`} aria-hidden="true">
{tiles}
</div>
)
}
/** light tiles normally, dark tiles inside .dark / [data-theme="dark"] */
export function ThemedTiles(props: { lat: number; lon: number; zoom: number; size: number; light: TileStyle; dark: TileStyle }) {
const { light, dark, ...rest } = props
return (
<>
<MapTiles {...rest} style={light} className="curio-tiles-light" />
<MapTiles {...rest} style={dark} className="curio-tiles-dark" />
</>
)
}components/curio/maps/map-pin.tsx
// pulsing location pin shared by the map cards
export function MapPin({ color = 'var(--curio-accent)' }: { color?: string }) {
return (
<span className="absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2">
<span className="curio-pulse absolute -inset-[10px] rounded-full opacity-40" style={{ background: color }} />
<span className="relative block h-[18px] w-[18px] rounded-full border-[3px] border-white shadow-[0_2px_8px_rgba(0,0,0,0.35)]" style={{ background: color }} />
</span>
)
}
export function Credit({ text, light = false }: { text: string; light?: boolean }) {
return (
<span className="absolute bottom-1 left-1.5 z-10 text-[9px] opacity-60" style={{ color: light ? 'rgba(255,255,255,0.85)' : 'var(--curio-muted)' }}>
{text}
</span>
)
}
export type MapProps = {
lat: number
lon: number
/** e.g. "pune, in" */
label?: string
/** where clicking the map goes */
href?: string
/** square size in px */
size?: number
zoom?: number
}
export function coords(lat: number, lon: number) {
return `${Math.abs(lat).toFixed(2)}°${lat >= 0 ? 'N' : 'S'} ${Math.abs(lon).toFixed(2)}°${lon >= 0 ? 'E' : 'W'}`
}