FairGarden Design

Map

A map figure with the pan-and-zoom interactive map of v1: a caps title, an in-flow zoom toolbar, numbered index markers, a caption, and an index that lists every place.

import { Map } from '@fairgarden-private/design/components/Map'

The map is a progressive enhancement over a static map, which stays the baseline and the print form. The base is either an SVG drawing in role variables, which follows the mode with no script and prints as black line, or a vector map: OpenStreetMap data in a Protomaps PMTiles archive, drawn by MapLibre GL JS in the same role variables. Only the canvas comes from MapLibre; the view, the markers, the labels and the controls are the component's own. The toolbar sits above the map, never over it, and holds icon-only zoom in, zoom out and reset view Buttons at the tag tier, each named and paired with a Tooltip. Zoom steps are discrete, and the current step is announced. The map pans by dragging or with the arrow keys when it is focused, and zooms with plus and minus or a two-finger pinch. The wheel always scrolls the page, and one finger on a touch screen scrolls the page too. Markers, labels, strokes and pattern pitches keep their screen size at every zoom step. Changes are instant.

A drawn map

A drawn garden map

drawing.render receives a kit with the §8.10 encodings, each unique to this map: fill.water (hatch 45°), fill.green (dot screen in the odd slot ink), fill.planned (hatch 135°, always labeled), and line classes for boundaries, dotted subdivisions, ranked roads, buildings, patterned-area edges and the dashed route in --role-accent. Text goes in labels, so it stays at screen size on a halo plate. Markers are 24 px numbered circles, squares or diamonds, told apart by shape and index group, never by color. Selecting an index entry pans its marker into view and opens its details; selecting a marker opens its entry. The index is the complete non-visual route to every place, so markers are not tab stops.

Parkside Community Garden

Fig. 2 The loop trail links every garden; the orchard is still planned.

Buildings

Views

Gardens

Drag or use the arrow keys to pan; press plus or minus to zoom. The index lists every place.

fg.example/parkside

MapDrawn.tsx
'use client'

import * as React from 'react'
import { Map, type MapMarker } from '@fairgarden-private/design/components/Map'

const markers: MapMarker[] = [
  {
    id: 'visitor-center',
    name: 'Visitor Center',
    x: 150,
    y: 395,
    group: 'Buildings',
    details: 'Maps, restrooms and water. Open daily 8:00 to 18:00.',
  },
  {
    id: 'tool-shed',
    name: 'Tool Shed',
    x: 610,
    y: 395,
    group: 'Buildings',
    shape: 'square',
    details: 'Volunteers borrow tools here; sign them back in by dusk.',
  },
  {
    id: 'lake-overlook',
    name: 'Lake Overlook',
    x: 300,
    y: 150,
    group: 'Views',
    shape: 'diamond',
    details: 'A bench above the lake, reached by the loop trail.',
  },
  {
    id: 'meadow',
    name: 'Pollinator Meadow',
    x: 560,
    y: 150,
    group: 'Gardens',
    details: 'Native asters, milkweed and bee balm, mown once each March.',
  },
  {
    id: 'plots',
    name: 'Community Plots',
    x: 400,
    y: 330,
    group: 'Gardens',
    details: '48 raised beds, allotted each spring by lottery.',
  },
]

export function MapDrawn() {
  return (
    <Map
      title="Parkside Community Garden"
      figureLabel="Fig. 2"
      caption="The loop trail links every garden; the orchard is still planned."
      url="fg.example/parkside"
      markers={markers}
      labels={[
        { id: 'lake', text: 'Lake', x: 150, y: 150 },
        { id: 'orchard', text: 'Planned orchard', x: 690, y: 290 },
      ]}
      drawing={{
        width: 800,
        height: 500,
        maxZoom: 3,
        render: (kit) => (
          <>
            <rect x={20} y={20} width={760} height={460} className={kit.line.boundary} />
            <path
              d="M40 60 C120 30 260 50 290 110 C320 180 250 250 150 250 C70 250 40 190 40 60 Z"
              fill={kit.fill.water}
              className={kit.line.area}
            />
            <rect x={460} y={60} width={280} height={170} fill={kit.fill.green} className={kit.line.area} />
            <rect x={620} y={250} width={140} height={90} fill={kit.fill.planned} className={kit.line.area} />
            <rect x={320} y={280} width={220} height={100} className={kit.line.building} />
            <path d="M375 280 V380 M430 280 V380 M485 280 V380 M320 330 H540" className={kit.line.subdivision} />
            <rect x={110} y={370} width={80} height={50} className={kit.line.building} />
            <rect x={580} y={370} width={60} height={50} className={kit.line.building} />
            <path d="M20 440 H780" className={kit.line.road} />
            <path d="M150 420 V440 M610 420 V440 M400 380 V440" className={kit.line.street} />
            <path d="M300 250 C340 270 360 270 400 280" className={kit.line.path} />
            <path
              d="M150 420 C150 300 220 280 300 160 C360 90 480 250 560 240 C640 230 620 330 610 370"
              className={kit.line.route}
            />
          </>
        ),
      }}
    />
  )
}

A location map on PMTiles

A location map on PMTiles

Pass pmtiles={{ url, center, zoom }} (plus minZoom, maxZoom, ratio, lang, glyphs, workerUrl and attribution as needed). The archive must use the Protomaps basemap schema v4 or later and be served with HTTP range requests and CORS. kind="location" draws the --ds-radius-20 frame with a --border-size-2 --primary12 edge and always carries a mixed-case text address. routes draws line-dashed routes in --role-accent (alternates in --primary12) over the roads and under the labels.

Setup. maplibre-gl, pmtiles and @protomaps/basemaps are optional peer dependencies: install them in apps that use Map. They load with a dynamic import when a PMTiles map mounts, so pages without one never fetch them. MapLibre's stylesheet is not needed, because the component positions the canvas itself and uses no MapLibre control, marker or popup. Under a bundler MapLibre needs its worker URL: copy maplibre-gl-worker.mjs and maplibre-gl-shared.mjs from maplibre-gl/dist into one public directory at build time (a predev and prebuild script, as MapLibre's installation guide shows for Next.js) and pass workerUrl: '/maplibre/maplibre-gl-worker.mjs'. This site serves the pair from a route handler beside this page instead.

Style. The MapLibre style reuses the @protomaps/basemaps layers and takes every color from the role variables, read with getComputedStyle on the map at runtime (the non-CSS consumer of §1.11.9). Land is --role-tint; the frame and road faces are --role-ground; road edges are --role-hairline (minor roads, links, paths) and --role-rule (major roads, highways), as are boundaries and rail; buildings are a ground face with a hairline edge; water is the scope's --secondary5; green space is the §8.10 dot screen in --secondary11; labels are --primary12 on a --border-size-2 --role-halo knockout. Every paint is opaque, with no opacity, blur or shadow. With oneInk, water takes the 45° hatch and the dots take --primary12. The style rebuilds when the mode or the scope changes (useColorScheme, and any data-theme, data-ground, data-tone or data-scheme change). Labels draw in the page's UI font, found in the document's @font-face rules and handed to MapLibre as font-faces; characters it lacks, and every label when the font files are unreadable, fall back to Protomaps' hosted Noto Sans glyphs (glyphs points them at your own copy).

Attribution. The credit is "© OpenStreetMap", its name linked to openstreetmap.org/copyright, below the frame and never on the map; print spells out the URL. It is the only credit these tiles need. OpenStreetMap's ODbL requires it, and the OSMF guidelines accept "© OpenStreetMap". Protomaps asks for a credit but does not require one. Natural Earth is public domain. The landcover layer (ESA WorldCover, CC BY 4.0) and the POI icons (Mapzen, MIT) would each need their own credit, so the style does not draw them. MapLibre (BSD-3) puts nothing on the map. Pass attribution when your archive carries other sources.

Demo tiles. The demo reads Protomaps' public OpenStreetMap build on Source Cooperative (data.source.coop/protomaps/openstreetmap/v4.pmtiles, no key), fetching only the byte ranges it needs. Set NEXT_PUBLIC_MAP_PMTILES_URL to use another archive. Production maps host their own extract (pmtiles extract), since Protomaps discourages hotlinking its builds.

Getting there

© OpenStreetMap(openstreetmap.org/copyright)

Parkside Community Garden, 100 Garden Lane, Brooklyn, NY

Leave the station by the Lincoln Road exit; the garden is a four-minute walk east.

Drag or use the arrow keys to pan; press plus or minus to zoom. The index lists every place.

fg.example/parkside

MapLocation.tsx
'use client'

import * as React from 'react'
import { Map, type MapPmtiles } from '@fairgarden-private/design/components/Map'

// Demo tiles: Protomaps' public OpenStreetMap build (basemap schema v4) on
// Source Cooperative, read with range requests; it needs no key. Set
// NEXT_PUBLIC_MAP_PMTILES_URL to use your own archive: production maps host
// their own extract (`pmtiles extract`).
const url =
  process.env.NEXT_PUBLIC_MAP_PMTILES_URL ||
  'https://data.source.coop/protomaps/openstreetmap/v4.pmtiles'

const pmtiles: MapPmtiles = {
  url,
  center: [-73.9592, 40.6611],
  zoom: 16,
  minZoom: 13,
  maxZoom: 18,
  // MapLibre's worker pair, served beside this page (../../maplibre/[file]/route.ts).
  workerUrl: '/components/map/maplibre/maplibre-gl-worker.mjs',
}

export function MapLocation() {
  return (
    <Map
      kind="location"
      title="Getting there"
      address="Parkside Community Garden, 100 Garden Lane, Brooklyn, NY"
      caption="Leave the station by the Lincoln Road exit; the garden is a four-minute walk east."
      url="fg.example/parkside"
      pmtiles={pmtiles}
      routes={[
        {
          // Lincoln Road, from Flatbush Avenue east.
          id: 'walk',
          points: [
            [-73.9604, 40.66097],
            [-73.96013, 40.66098],
            [-73.958, 40.66111],
          ],
        },
      ]}
      markers={[
        {
          id: 'garden',
          name: 'Parkside Community Garden',
          lon: -73.958,
          lat: 40.66124,
          details: 'Open daily from dawn to dusk.',
        },
        {
          id: 'station',
          name: 'Subway station',
          lon: -73.9604,
          lat: 40.66112,
          shape: 'square',
          details: 'Trains every 6 to 10 minutes.',
        },
      ]}
    />
  )
}

Layout and print

Below 1024 px of its container the index follows the map as an Accordion; from 1024 px they sit side by side at 2:1. In print the toolbar is hidden, the map shows its reset extent within --ds-print-figure-max, the full index prints expanded, and url prints in type-url with the qr code beside it, never instead of it. A drawing prints through the remap. A vector map never prints its canvas, because WebGL canvases print unreliably and beforeprint leaves no time to load tiles. Once the live map settles, a hidden second MapLibre instance renders the reset extent in the print palette (black line on white, water hatched, green space dotted) at 2×, and print swaps that image in, with the markers over it and the credit and its URL below. printFallback, a static figure of your own, replaces the image, and then none is rendered.

API Reference

A map figure (§8.10) with the pan-and-zoom interactive map of v1 [D169]: a caps title, an in-flow Toolbar (zoom in, zoom out, reset view), the framed map with numbered index markers, the caption, and the index, an Accordion that is the complete non-visual route to every place. Selecting an index entry pans its marker into view and opens its details; selecting a marker opens its entry. Print shows the static map at the reset extent (or printFallback), the full index, and the URL beside its QR code.

PropTypeDescription
address
React.ReactNode | undefined

The text address, mixed case; required with kind="location".

caption
React.ReactNode | undefined

The caption below the map: what to notice.

defaultSelected
string | null | undefined

The initially selected marker’s id.

drawing
MapDrawing | undefined

A drawn base map. Pass drawing or pmtiles.

figureLabel
React.ReactNode | undefined

The figure label, e.g. “Fig. 4″.

headingLevel
HeadingLevel | undefined

Heading level of the index entries. Default 3.

kind
'technical' | 'location' | null | undefined

technical (default): --ds-radius-none, a --border-size-1 --role-rule frame. location: --ds-radius-20, a --border-size-2 --primary12 frame, always with a mixed-case address.

labels
MapLabel[] | undefined

Live text labels (areas, water) at screen size.

markers
MapMarker[] | undefined

The places, numbered in this order (01, 02 …) and listed in the index.

messages
Partial<MapMessages> | undefined

Interface strings.

onSelectedChange
((id: string | null) => void) | undefined

Called when the selection changes, from a marker or the index.

oneInk
boolean | undefined

One ink: area patterns in --primary12 (a vector map also hatches its water). Pass it on any ground but a light base or a white plate (§8.10). Default false.

pmtiles
MapPmtiles | undefined

A vector base map: a Protomaps PMTiles archive drawn by MapLibre GL JS in the role variables. Pass drawing or pmtiles. Needs the optional peers maplibre-gl, pmtiles and @protomaps/basemaps, and MapLibre’s worker URL under bundlers (pmtiles.workerUrl).

primary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| null
| undefined

Primary Radix scale: lines, markers, labels. Never defaulted [D133].

printFallback
React.ReactNode | undefined

The static figure printed instead of the live base, e.g. a black-line export at the reset extent. Without it, a drawing prints at its reset extent, and a vector map prints a snapshot of its reset extent in the print palette, rendered once the map settles.

qr
React.ReactNode | undefined

A QR code for url as inline SVG, printed at --ds-print-qr beside the URL, never instead of it.

routes
MapRoute[] | undefined

Routes over a vector base map (a drawing draws its own with kit.line.route).

secondary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| 'amber'
| 'blue'
| 'cyan'
| 'grass'
| 'green'
| 'jade'
| 'lime'
| 'mint'
| 'orange'
| 'sky'
| 'teal'
| 'yellow'
| null
| undefined

Secondary Radix scale: odd-slot area patterns. Never defaulted [D133].

selected
string | null | undefined

The selected marker’s id (controlled).

title
string | undefined

The caps title above the map, with “(DETAIL)” for crops; also the frame’s accessible name.

url
string | undefined

The map’s URL, printed in type-url beside the QR code [D168].

Additional types

map
type map = map
MapDrawing

A drawn base map: an SVG drawing in its own units, in role variables.

type MapDrawing = {
  /** The drawing's width in its own units; with `height`, the frame's aspect ratio. */
  width: number;
  /** The drawing's height in its own units. */
  height: number;
  /**
   * Draws the map's SVG content in drawing units, using the kit's fills and
   * line classes and role-variable strokes. Put text in `labels`, not here,
   * so it keeps its size when zoomed.
   */
  render: (kit: MapDrawingKit) => React.ReactNode;
  /** The reset zoom step. Default 0, the whole drawing. */
  zoom?: number;
  /** The deepest zoom step; each step doubles the scale. Default 3. */
  maxZoom?: number;
  /** The reset center in drawing units. Default: the drawing's center. */
  center?: [number, number];
}
MapDrawingKit

The §8.10 encodings a drawing uses: pattern fills and line classes, unique to this map.

type MapDrawingKit = {
  /**
   * Area fills as `url(#…)` strings for a shape's `fill`: `water` (hatch
   * 45° in --primary12), `green` (dot screen 1.25 px at 6 px in the odd slot
   * ink), `planned` (hatch 135°, always with a label). Add `line.area` for
   * the shape's --border-size-1 --primary12 edge.
   */
  fill: { water: string; green: string; planned: string };
  /**
   * Classes for strokes and outlines: `boundary` (line-solid 1 px),
   * `subdivision` (line-dotted-fine), `road` / `street` / `path` (solid 2 /
   * 1.5 / 1 px), `building` (ground face, 1 px outline), `area` (the edge of
   * a patterned area), `route` (line-dashed in --role-accent), `altRoute`
   * (line-dashed in --primary12). Strokes never scale with zoom.
   */
  line: {
    boundary: string;
    subdivision: string;
    road: string;
    street: string;
    path: string;
    building: string;
    area: string;
    route: string;
    altRoute: string;
  };
}
MapLabel

A live text label on the map, kept at screen size on a halo plate.

type MapLabel = {
  /** Stable id. */
  id: string;
  /** The label text, e.g. "Lake". */
  text: string;
  /** Drawing maps: the label's center in drawing units. */
  x?: number;
  /** Drawing maps: the label's center in drawing units. */
  y?: number;
  /** Vector (PMTiles) maps: longitude in degrees. */
  lon?: number;
  /** Vector (PMTiles) maps: latitude in degrees. */
  lat?: number;
}
MapMarker

A place on the map: a numbered index marker and its index entry.

type MapMarker = {
  /** Stable id, used for selection. */
  id: string;
  /** The place's name: the index entry title and the marker's accessible name. */
  name: string;
  /** Drawing maps: the position in drawing units. */
  x?: number;
  /** Drawing maps: the position in drawing units. */
  y?: number;
  /** Vector (PMTiles) maps: longitude in degrees. */
  lon?: number;
  /** Vector (PMTiles) maps: latitude in degrees. */
  lat?: number;
  /** The index group head (a category), e.g. "Gardens". */
  group?: string;
  /** The marker's category shape. Default `circle`. */
  shape?: MapMarkerShape;
  /** Details shown when the entry is selected, in the index; printed expanded. */
  details?: React.ReactNode;
}
MapMarkerShape

Index marker shapes (§8.10): categories are told apart by shape and index group, never by color.

type MapMarkerShape = 'circle' | 'square' | 'diamond'
MapMessages

Interface strings, for localization.

type MapMessages = {
  /** Default "Zoom in". */
  zoomIn: string;
  /** Default "Zoom out". */
  zoomOut: string;
  /** Default "Reset map view". */
  reset: string;
  /** The toolbar's accessible name. Default "Map controls". */
  toolbar: string;
  /** How to operate the map, read with the frame. */
  instructions: string;
  /** The announced zoom step. Default "Zoom 2 of 4". */
  zoomStatus: (level: number, levels: number) => string;
  /** The index's accessible name. Default "Index". */
  index: string;
}
MapPmtiles

A vector base map: a Protomaps PMTiles archive (basemap schema v4 or later), drawn by MapLibre GL JS with a style built from the map’s role variables at runtime [D158].

type MapPmtiles = {
  /**
   * The archive's URL. It is read with HTTP range requests, so the host must
   * allow them and CORS. Host your own extract in production (`pmtiles
   * extract`); the public planet builds are for demos.
   */
  url: string;
  /** The reset center, `[longitude, latitude]`. */
  center: [number, number];
  /** The reset zoom level. */
  zoom: number;
  /** The shallowest zoom level. Default: `zoom` − 3. */
  minZoom?: number;
  /** The deepest zoom level. Default: `zoom` + 4, at most 19. */
  maxZoom?: number;
  /** The frame's aspect ratio (width ÷ height). Default 3 / 2. */
  ratio?: number;
  /** The label language, a Protomaps `lang` code. Default `"en"`. */
  lang?: string;
  /**
   * Glyphs URL template (`{fontstack}`, `{range}`) for the characters the
   * page's UI font files do not cover. Default: Protomaps' hosted Noto Sans
   * glyphs, OFL, which need no credit.
   */
  glyphs?: string;
  /**
   * URL of MapLibre's worker, `maplibre-gl-worker.mjs`, served in the same
   * directory as `maplibre-gl-shared.mjs`. Bundlers (Next.js, Vite, webpack)
   * need it: copy both files from `maplibre-gl/dist` to a public path, as
   * MapLibre's installation guide shows. Set once per page; the first map
   * wins. Default: MapLibre's own detection, which only works unbundled.
   */
  workerUrl?: string;
  /**
   * The data credit below the frame. Default: "© OpenStreetMap", linked to
   * openstreetmap.org/copyright, which print spells out. Protomaps asks for
   * no credit; add one only when your tiles carry other sources.
   */
  attribution?: React.ReactNode;
}
MapProps

Props for Map: div props plus the base map, the markers, the figure text and the print extras.

type MapProps = {
  /**
   * `technical` (default): --ds-radius-none, a --border-size-1 --role-rule
   * frame. `location`: --ds-radius-20, a --border-size-2 --primary12 frame,
   * always with a mixed-case `address`.
   */
  kind?: 'technical' | 'location' | null;
  /** The caps title above the map, with "(DETAIL)" for crops; also the frame's accessible name. */
  title: string;
  /** The caption below the map: what to notice. */
  caption?: React.ReactNode;
  /** The figure label, e.g. "Fig. 4". */
  figureLabel?: React.ReactNode;
  /** The text address, mixed case; required with `kind="location"`. */
  address?: React.ReactNode;
  /** A drawn base map. Pass `drawing` or `pmtiles`. */
  drawing?: MapDrawing;
  /**
   * A vector base map: a Protomaps PMTiles archive drawn by MapLibre GL JS
   * in the role variables. Pass `drawing` or `pmtiles`. Needs the optional
   * peers `maplibre-gl`, `pmtiles` and `@protomaps/basemaps`, and MapLibre's
   * worker URL under bundlers (`pmtiles.workerUrl`).
   */
  pmtiles?: MapPmtiles;
  /** Routes over a vector base map (a drawing draws its own with `kit.line.route`). */
  routes?: MapRoute[];
  /** The places, numbered in this order (01, 02 …) and listed in the index. */
  markers?: MapMarker[];
  /** Live text labels (areas, water) at screen size. */
  labels?: MapLabel[];
  /** The selected marker's id (controlled). */
  selected?: string | null;
  /** The initially selected marker's id. */
  defaultSelected?: string | null;
  /** Called when the selection changes, from a marker or the index. */
  onSelectedChange?: (id: string | null) => void;
  /** Heading level of the index entries. Default 3. */
  headingLevel?: HeadingLevel;
  /**
   * The static figure printed instead of the live base, e.g. a black-line
   * export at the reset extent. Without it, a drawing prints at its reset
   * extent, and a vector map prints a snapshot of its reset extent in the
   * print palette, rendered once the map settles.
   */
  printFallback?: React.ReactNode;
  /** The map's URL, printed in `type-url` beside the QR code [D168]. */
  url?: string;
  /** A QR code for `url` as inline SVG, printed at --ds-print-qr beside the URL, never instead of it. */
  qr?: React.ReactNode;
  /**
   * One ink: area patterns in --primary12 (a vector map also hatches its
   * water). Pass it on any ground but a light base or a `white` plate
   * (§8.10). Default `false`.
   */
  oneInk?: boolean;
  /** Primary Radix scale: lines, markers, labels. Never defaulted [D133]. */
  primary?:
    | 'ruby'
    | 'olive'
    | 'sage'
    | 'slate'
    | 'sand'
    | 'gray'
    | 'mauve'
    | 'brown'
    | 'bronze'
    | 'gold'
    | 'red'
    | 'crimson'
    | 'tomato'
    | 'pink'
    | 'plum'
    | 'indigo'
    | 'iris'
    | 'violet'
    | 'purple'
    | null;
  /** Secondary Radix scale: odd-slot area patterns. Never defaulted [D133]. */
  secondary?:
    | 'ruby'
    | 'olive'
    | 'sage'
    | 'slate'
    | 'sand'
    | 'gray'
    | 'mauve'
    | 'brown'
    | 'bronze'
    | 'gold'
    | 'red'
    | 'crimson'
    | 'tomato'
    | 'pink'
    | 'plum'
    | 'indigo'
    | 'iris'
    | 'violet'
    | 'purple'
    | 'amber'
    | 'blue'
    | 'cyan'
    | 'grass'
    | 'green'
    | 'jade'
    | 'lime'
    | 'mint'
    | 'orange'
    | 'sky'
    | 'teal'
    | 'yellow'
    | null;
  /** Interface strings. */
  messages?: Partial<MapMessages>;
}
MapRoute

A route over a vector base map (§8.10): line-dashed, screen-size at every zoom [I7].

type MapRoute = {
  /** Stable id. */
  id: string;
  /** The route's line, as `[longitude, latitude]` pairs in order. */
  points: [number, number][];
  /**
   * The accessible or alternate route: `line-dashed` in --primary12 instead
   * of --role-accent. Default `false`.
   */
  alternate?: boolean;
}

Specification: DESIGN-SYSTEM.md §8.10 (maps), §6.10 (the zoom icons) and §1.11.9 (mode detection for the canvas); decisions D158, D168, D169 and D191.