curio.
no. 12 · maps

Radar map

Dark map with a sweeping scanner and range rings.

Install

npx shadcn@latest add https://curio-components.vercel.app/r/map-radar.json

First curio component? Also add @import "./curio.css"; to your global CSS. See installation.

Usage

import { MapRadar } from '@/components/curio/maps/map-radar'

export function Where() {
  return <MapRadar lat={38.7223} lon={-9.1393} label="Lisbon, PT" />
}

Props

PropTypeDefaultDescription
lat / lonnumber-The centre of the map.
labelstring-Shown on the map, e.g. "Lisbon, PT".
hrefstring-Where clicking the map goes.
sizenumber260Square size in px.
zoomnumber12 to 13Map zoom level.
colorstring"#5dff9e"Scanner colour.

Source

This is exactly what the command copies into your project.

components/curio/maps/map-radar.tsx
import { MAP_CREDIT, MapTiles } from '../lib/map-tiles'
import { coords, Credit, MapPin, type MapProps } from './map-pin'

// dark map with a sweeping scanner and range rings
export function MapRadar({ lat, lon, label, href, size = 260, zoom = 12, color = '#5dff9e' }: MapProps & { color?: string }) {
  return (
    <a
      href={href}
      target="_blank"
      rel="noopener noreferrer"
      aria-label={label ? `map of ${label}` : 'map'}
      className="relative block shrink-0 overflow-hidden"
      style={{ width: size, height: size, background: '#0b0f0c' }}
    >
      <div className="absolute inset-0 opacity-80">
        <MapTiles lat={lat} lon={lon} zoom={zoom} size={size} style="dark_nolabels" />
      </div>
      {[0.35, 0.7, 1.05].map((r) => (
        <span
          key={r}
          className="absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full border"
          style={{ width: size * r, height: size * r, borderColor: `${color}33` }}
        />
      ))}
      <span
        className="curio-spin absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full"
        style={{ width: size * 1.6, height: size * 1.6, animationDuration: '4s', background: `conic-gradient(from 0deg, ${color}59, transparent 22%)` }}
      />
      <MapPin color={color} />
      <span className="absolute left-2.5 top-2 z-10 font-mono text-[10px] uppercase tracking-[0.08em]" style={{ color }}>
        ● Tracking
      </span>
      <span className="absolute bottom-2 right-2.5 z-10 text-right font-mono text-[10px] uppercase" style={{ color }}>
        {label}
        <br />
        {coords(lat, lon)}
      </span>
      <Credit text={MAP_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'}`
}