no. 10 · maps
Blueprint map
Streets redrawn as a blue technical plan on a grid.
Install
npx shadcn@latest add https://curio-components.vercel.app/r/map-blueprint.jsonFirst curio component? Also add @import "./curio.css"; to your global CSS. See installation.
Usage
import { MapBlueprint } from '@/components/curio/maps/map-blueprint'
export function Where() {
return <MapBlueprint 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-blueprint.tsx
import { MAP_CREDIT, MapTiles } from '../lib/map-tiles'
import { coords, Credit, type MapProps } from './map-pin'
// the street map redrawn as a blue technical plan on a grid
export function MapBlueprint({ 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="relative block shrink-0 overflow-hidden"
style={{ width: size, height: size, background: '#0f2a6b' }}
>
<div className="absolute inset-0 opacity-90 mix-blend-screen [filter:invert(1)_grayscale(1)_brightness(0.9)]">
<MapTiles lat={lat} lon={lon} zoom={zoom} size={size} style="street" />
</div>
<div className="absolute inset-0" style={{ background: 'rgba(29,43,214,0.55)', mixBlendMode: 'multiply' }} />
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(rgba(255,255,255,0.12) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.12) 1px, transparent 1px)',
backgroundSize: '26px 26px',
}}
/>
<span className="absolute left-1/2 top-1/2 z-10 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rotate-45 border-2 border-white bg-[#ff5a2e]" />
<span className="absolute left-2.5 top-2 z-10 font-mono text-[10px] uppercase tracking-[0.08em] text-white/80">
Sheet 01{label ? ` · ${label}` : ''}
</span>
<span className="absolute bottom-2 right-2.5 z-10 font-mono text-[10px] text-white/80">{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'}`
}