Pin Map

Pro

Points of interest on a projected map, over an optional base layer of shapes. Markers that would overlap merge into one pin that states its own count, so the map never shows fewer things than it has. Shares the choropleth's projections and fitting. Pure SVG.

</>sv-pin-map chartmappinsmarkersgeocluster
import { SvPinMapComponent } from '@styloviz-pro/charts';

Demo & usage

Basic usage

Pro

Usage example — open the gallery for the full interactive demo.

Pro

An interactive demo for this component lives in the gallery.

Open live demo

More examples

Planar coordinates (a floor plan, a fictional city)

Pro

Price labels and a selected pin

Pro

Dots instead of labels on a dense map

Pro

Theme the pins (SVG fill cannot read a CSS token)

Pro

When to use

  • WHAT and WHERE — the listings, stores, incidents or sites themselves.
  • A companion map beside a result list, with hover and selection synced both ways.
  • For one number per administrative region, use the Choropleth Map: colour per shape answers "how much", not "which one".

Async states

  • loading
  • empty
  • error
  • retry

Item identity & owned state

Restorable state
  • highlightIds

Bind these to put a saved view back — the open sections, the hidden columns, the selected rows — and read their Change events to store what the user does.

Inputs

NameTypeDefaultDescription
loadingbooleanfalseTrue while the markers or the base layer are being fetched.
errorSvDataError | string | nullnullLoad failure. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`.
emptyMessagestring'Nothing to show on the map'Copy for the no-data state.
filteredbooleanfalseTrue when the emptiness is caused by a filter rather than an empty set.
markersreadonly SvMapMarker[][]The points.
featuresreadonly SvMapFeature[][]Optional shapes drawn behind the pins for context — districts, a coastline, a floor outline. Purely decorative: not focusable, not clickable.
projectionSvMapProjection'equal-earth'How lon/lat becomes a plane.
widthnumber640Map width in pixels.
heightnumber360Map height in pixels.
clusterRadiusnumber34Minimum distance in pixels between two drawn pins. Markers closer than this merge into one counted pin. `0` disables merging.
showLabelsbooleantrueDraw each marker's `label` inside the pin. Turn off for a dense map where dots read better than fifty price tags.
selectedIdstring''The marker drawn as selected. `''` for none.
highlightIdsreadonly string[][]Markers to draw ringed — a hover in a companion list, say. Cheap to pass one id; the map does not care how many.
baseFillstring'color-mix(in oklab, var(--color-gray-4…Fill for the base shapes.
baseStrokestring'color-mix(in oklab, var(--color-gray-5…Outline for the base shapes.
showBaseLabelsbooleanfalseDraw the base shapes' names at their centre.
titlestring''Title rendered above the map.
ariaLabelstring''Accessible name override. Falls back to a generated summary.
customClassstring''Extra CSS classes applied to the root wrapper.
palettePartial<Record<SvPinTone, string>>{}Override the colour for any tone. SVG `fill` CANNOT READ A TAILWIND THEME TOKEN, which is why this input exists rather than a set of `fill-*` classes. The pins are painted with literal hexes (see `fillFor`) because a class name assembled at runtime never reaches the stylesheet — so a template that has remapped `--color-brand-*` to its own palette gets indigo pins on an olive page with no way to fix it from CSS. Pass the hexes here instead: ```ts readonly pinPalette = { accent: '#718126', default: '#3c441f' }; ``` Only the tones you name are replaced; the rest keep the defaults, which are the pairs `check:contrast` already accepts for white text on a solid fill. A colour you supply is yours to check.

Outputs

EventPayload typeDescription
retryvoidThe host should re-run the failed load.
markerHoverSvPinMapMarkerEventA single marker was hovered or keyboard-focused. Not emitted for clusters.
markerSelectSvPinMapMarkerEventA single marker was activated. Not emitted for clusters.
clusterSelectSvPinMapClusterEventA merged pin was activated. Separate from `markerSelect` on purpose. A host that opened `markers[0]` on every click would open one listing and quietly discard the other four — the exact bug clustering exists to prevent. Two outputs make "which one did you mean?" the host's explicit decision.

Wire it to your API

This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — drive it from whatever you already use.

Template

Pro

Bound to the names this component actually exposes.

Host class

Pro

Drive the three async inputs from your own fetch.

  • This component does not emit a query, so it renders what you give it. Fetch on your own trigger and bind `markers`, `loading` and `error`.
  • Set `filtered` when a search or filter is active. It is the difference between "you have no orders" and "no orders match this filter" — telling a customer with 4,000 orders the first one reads as a bug.
  • Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.

Full walkthrough: Connecting to a backend.

Customization & theming

  • Four projections, including identity for pre-projected or invented coordinates.
  • Labelled price bubbles or plain dots, with a per-tone colour palette.
  • Optional base shapes with haloed labels, drawn behind the pins and out of the tab order.
  • Cluster radius, or 0 to disable merging entirely.

Best practices

  • Do: Leave clustering on. Two markers a few pixels apart hide each other, and an overlapped pin makes the map show one thing where there are five.
  • Do: Handle clusterSelect separately from markerSelect — opening markers[0] on a merged pin discards the rest.
  • Do: Feed the map the same set the list shows; a map with more pins than results is a second, contradictory answer.
  • Don't: Do not pass lat/lng as [lat, lon]. It is [longitude, latitude], the GeoJSON order.
  • Tip: clusterRadius is in SCREEN pixels, not degrees or metres: the thing being avoided is a visual collision.

Accessibility

  • The SVG is a `role="group"` whose generated summary states the point count and says outright when pins were merged because they overlap.
  • A pin's position IS the datum, and position is the one thing that does not survive into text — so `sv-data-summary-table` publishes one row per MARKER, not per pin, with its label, detail, status and which merged pin it sits inside. A merged pin draws one thing where there are five; the table is where the other four are.
  • One roving tab stop for the whole map: Tab enters and leaves, arrow keys and Home/End move between pins in reading order, Enter/Space activates. Each pin carries an aria-label; a merged pin names its count and every member it stands for.
  • The selected pin exposes `aria-pressed`.

Feedback on Pin Map

Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.

Loads giscus.app and GitHub — nothing is fetched until you click.