curio.
no. 11 · maps

Zoom map

Shows the whole region, then dives into the city on hover.

Install

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

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

Usage

import { MapZoom } from '@/components/curio/maps/map-zoom'

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

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.
regionZoomnumber4Zoom before hovering.
regionLabelstring"region"Label before hovering.

Source

This is exactly what the command copies into your project.

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

// starts zoomed out on the region, dives into the city on hover
export function MapZoom({
  lat,
  lon,
  label,
  href,
  size = 260,
  zoom = 12,
  regionZoom = 4,
  regionLabel = 'Region',
}: MapProps & { regionZoom?: number; regionLabel?: string }) {
  const ease = 'ease-[cubic-bezier(.65,0,.35,1)]'
  return (
    <a
      href={href}
      target="_blank"
      rel="noopener noreferrer"
      aria-label={label ? `map of ${label}` : 'map'}
      className="group relative block shrink-0 overflow-hidden border"
      style={{ width: size, height: size, borderColor: 'var(--curio-border)' }}
    >
      <div className={`absolute inset-0 transition-all duration-700 ${ease} group-hover:scale-[3] group-hover:opacity-0`}>
        <ThemedTiles lat={lat} lon={lon} zoom={regionZoom} size={size} light="light" dark="dark" />
      </div>
      <div className={`absolute inset-0 scale-[0.35] opacity-0 transition-all duration-700 ${ease} group-hover:scale-100 group-hover:opacity-100`}>
        <ThemedTiles lat={lat} lon={lon} zoom={zoom} size={size} light="light" dark="dark" />
      </div>
      <MapPin />
      <span className="absolute bottom-2 right-2 z-10 px-1.5 py-1 text-xs" style={{ background: 'var(--curio-accent)', color: 'var(--curio-on-accent)' }}>
        <span className="group-hover:hidden">{regionLabel}</span>
        <span className="hidden group-hover:inline">{label}</span>
      </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'}`
}