curio.
no. 07 · maps

Classic map

Soft grey street map with a pulsing pin and a small label. follows light/dark.

Install

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

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

Usage

import { MapClassic } from '@/components/curio/maps/map-classic'

export function Where() {
  return <MapClassic 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.

Source

This is exactly what the command copies into your project.

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

// soft grey street map, pulsing pin, a little label. zooms in slightly on hover.
export function MapClassic({ lat, lon, label, href, size = 260, zoom = 12 }: 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 rounded-2xl border"
      style={{ width: size, height: size, borderColor: 'var(--curio-border)' }}
    >
      <div className="absolute inset-0 transition-transform duration-700 ease-out group-hover:scale-110">
        <ThemedTiles lat={lat} lon={lon} zoom={zoom} size={size} light="light" dark="dark" />
      </div>
      <MapPin />
      {label && (
        <span
          className="absolute bottom-2 right-2 z-10 rounded-md border px-1.5 py-1 text-[12px]"
          style={{ background: 'var(--curio-surface)', borderColor: 'var(--curio-border)', color: 'var(--curio-fg)' }}
        >
          {label}
        </span>
      )}
      <Credit text={MAP_CREDIT} />
    </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'}`
}