Skip to content
KONIGI

Dashboards / Visual representation / Geo map with markers

8 of 22

Geo map with markers

The items have locations and their state is best read spatially.

Updated September 10, 2026

Problem

Latency is up in some regions and fine in others. A table sorted by region gives the answer eventually. A map gives it immediately, because the pattern is usually geographic—a submarine cable, a weather event, a data centre.

Solution

Put the values on a map. Markers at points, colour on regions, or density where the volume is high enough that individual points stop resolving.

The three encodings answer different questions and are not interchangeable.

Markers suit discrete located things: stores, sensors, data centres, incidents. Size or colour carries the measure. They fail by overlapping, so at density they need clustering, and clustering introduces its own reading problem because a cluster of five and a cluster of five hundred must look different.

Choropleth fills administrative regions with colour, and carries a well-known distortion: the eye reads area, so a large sparsely-populated region dominates a small dense one carrying ten times the volume. Normalising by population or by area is not optional, and a choropleth of raw counts is close to always a map of where people live.

Density or heat suits high-volume point data where individual points are meaningless. It is honest about aggregation but hides outliers, and its radius parameter changes the apparent story more than most people realise.

Grafana’s geomap exposes these as layer types over a configurable base map, which is the right model: the base map is context and should recede, while the data layer carries the meaning. Base maps that are dark, detailed and full of labels compete directly with the data drawn on them.

The projection question is real but usually settled: web maps use Web Mercator, which inflates area with latitude, so on a choropleth of raw values Greenland and Russia look enormously important. Worth knowing when the map covers high latitudes.

Use when

Location genuinely explains the variation, and the audience thinks geographically. Delivery, retail, field equipment, edge infrastructure, anything with a physical footprint.

Don’t use when

Location is a label rather than a cause. Plotting per-country revenue on a map when the interesting comparison is between two countries makes an easy comparison hard, and a bar chart would have ranked them in one glance.

Trade-offs

Maps are the most persuasive chart type in this collection and frequently the least informative, which makes them a favourite of stakeholder decks. They spend enormous space on geography that carries no data—oceans, empty land—and that space comes from the panels that would have shown the trend. Base map tiles are usually an external dependency, so the panel has a network failure mode nothing else on the page has. And precise location can be personally identifying, so a map of user events is a privacy decision as much as a design one.

Checklist

  • Which encoding is this, and does it match the question?
  • If choropleth, is the value normalised by population or area rather than raw?
  • Does region area distort importance, especially at high latitudes?
  • At full density, do markers overlap, and does clustering distinguish sizes?
  • Does the base map recede, or does it compete with the data?
  • Where do the map tiles come from, and what shows if they fail?
  • Does the colour ramp work for colourblind viewers and in the page’s theme?
  • Can the viewer get from a marker or region to the underlying records?
  • Is the default viewport right for the audience, or centred on someone else’s country?
  • Could precise points identify individuals, and should they be aggregated?

Compare

Grafana’s geomap builds the panel as stackable layers—markers, heatmap, GeoJSON—over a swappable base map, which makes it flexible and makes the base map a decision most dashboard authors never consciously take. Datadog ties maps to tag scope so a region view narrows the rest of the page, which is the pattern working as navigation rather than as illustration. Kibana carries the strongest geospatial tooling of the observability tools, with real spatial queries rather than only display, reflecting Elasticsearch’s geo-query heritage. Mapping-first tools like Mapbox and Felt are worth naming because they treat the base map as a design surface rather than a backdrop, which is the discipline general dashboard tools skip.

Host map is the same spatial scanning idea with an invented rather than geographic layout. Heatmap shares the density encoding without the geography. Sequential and diverging scales governs the choropleth ramp and is where most map errors actually live. Drill-down is what a marker click owes. Data table is the unglamorous alternative that often answers faster.

Geo map with markers anatomy The same located data under three encodings: markers with a cluster carrying a count, a choropleth where a large empty region dominates a small dense one, and a density layer that is honest about aggregating and hides the outliers. Three encodings, three questions 42 Markers Choropleth Density 1 2 3 1 DISCRETE LOCATED THINGS Stores, sensors, data centres, incidents. They fail by overlapping, so at density they cluster — and a cluster of five and a cluster of five hundred have to look different. 2 THE EYE READS AREA So a large sparse region dominates a small dense one carrying ten times the volume. Normalising isn't optional: a choropleth of raw counts is a map of where people live. 3 HONEST ABOUT AGGREGATING Right when individual points are meaningless. It hides outliers, and its radius changes the apparent story more than most people realise. The base map is context and should recede. Dark, detailed and labelled competes.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

A map is the right chart only when the geography is the question. Population usually explains the pattern, so a choropleth of raw counts is a map of where people live redrawn with your metric's name on it.

Tokens
--foreground--card--muted--border--chart-1--scale-seq-3
42 locations42ManchesterBirminghamBristolEdinburgh

GeoMap.tsxProjects points into a viewport, merges any within a pixel radius into a cluster that carries its count and grows with it, and keeps the base map to one muted outline.

/**
 * Markers for discrete located things. They fail by overlapping, so past a
 * pixel radius they cluster, and a cluster carries its count and scales with
 * it: five and five hundred have to look different. The base map is one
 * outline in a muted stroke, because it is context and should recede.
 *
 * Plain equirectangular projection. Good enough for a country; wrong for a
 * globe, where you want a real projection library.
 */
export type Point = { id: string; lat: number; lng: number; label: string };
export type Bounds = { lat: [number, number]; lng: [number, number] };
export type Cluster = { x: number; y: number; points: Point[] };

export function cluster(points: { x: number; y: number; point: Point }[], radius: number): Cluster[] {
  const out: Cluster[] = [];
  for (const p of points) {
    const near = out.find((c) => Math.hypot(c.x - p.x, c.y - p.y) <= radius);
    if (!near) { out.push({ x: p.x, y: p.y, points: [p.point] }); continue; }
    const n = near.points.length;
    near.x = (near.x * n + p.x) / (n + 1);
    near.y = (near.y * n + p.y) / (n + 1);
    near.points.push(p.point);
  }
  return out;
}

export function GeoMap({ points, bounds, outline, width = 320, height = 240, clusterRadius = 24, onSelect }: {
  points: Point[];
  /** The viewport, as a latitude range and a longitude range. */
  bounds: Bounds;
  /** Coastline or region border as lat/lng vertices, drawn under the data. */
  outline?: [number, number][];
  width?: number;
  height?: number;
  /** Pixel distance under which markers merge. */
  clusterRadius?: number;
  /** A marker gives one point; a cluster gives all of them. */
  onSelect?: (points: Point[]) => void;
}) {
  const [lat0, lat1] = bounds.lat, [lng0, lng1] = bounds.lng;
  const squeeze = Math.cos(((lat0 + lat1) / 2) * Math.PI / 180);
  const k = Math.min(width / ((lng1 - lng0) * squeeze), height / (lat1 - lat0));
  const ox = (width - (lng1 - lng0) * squeeze * k) / 2, oy = (height - (lat1 - lat0) * k) / 2;
  const project = (lat: number, lng: number) => ({ x: ox + (lng - lng0) * squeeze * k, y: oy + (lat1 - lat) * k });

  const clusters = cluster(points.map((p) => ({ ...project(p.lat, p.lng), point: p })), clusterRadius);
  const r = (n: number) => (n === 1 ? 4 : 4 + 1.6 * Math.sqrt(n));

  return (
    <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} role="img" aria-label={`${points.length} locations on a map`} className="block max-w-full">
      {outline && (
        <path
          d={outline.map(([lat, lng], i) => { const p = project(lat, lng); return `${i ? "L" : "M"}${p.x.toFixed(1)} ${p.y.toFixed(1)}`; }).join(" ") + " Z"}
          className="fill-muted/40 stroke-border"
          strokeWidth={1}
        />
      )}
      {clusters.map((c) => {
        const n = c.points.length;
        return (
          <g key={c.points[0].id} onClick={onSelect && (() => onSelect(c.points))} className={onSelect && "cursor-pointer"}>
            <circle cx={c.x.toFixed(1)} cy={c.y.toFixed(1)} r={r(n).toFixed(1)} className={n === 1 ? "fill-chart-1" : "fill-scale-seq-3"}>
              <title>{n === 1 ? c.points[0].label : `${n} locations`}</title>
            </circle>
            {n > 1 && (
              <text x={c.x.toFixed(1)} y={(c.y + 3).toFixed(1)} textAnchor="middle" className="fill-foreground text-[9px] font-medium tabular-nums">{n}</text>
            )}
          </g>
        );
      })}
    </svg>
  );
}

stores.tsThe located things and a coarse coastline to place them on.

import type { Point } from "./GeoMap";

/** Forty-six stores: forty-two inside Greater London and four elsewhere. */
export const STORES: Point[] = [
  ["oxford-st", 51.515, -0.141, "Oxford Street"], ["covent-garden", 51.512, -0.123, "Covent Garden"],
  ["stratford", 51.543, -0.008, "Stratford"], ["white-city", 51.507, -0.221, "White City"],
  ["canary-wharf", 51.505, -0.019, "Canary Wharf"], ["kings-cross", 51.531, -0.124, "King's Cross"],
  ["camden", 51.539, -0.143, "Camden"], ["angel", 51.532, -0.106, "Angel"],
  ["hackney", 51.545, -0.055, "Hackney"], ["brixton", 51.462, -0.115, "Brixton"],
  ["clapham", 51.462, -0.138, "Clapham"], ["wimbledon", 51.421, -0.206, "Wimbledon"],
  ["richmond", 51.461, -0.304, "Richmond"], ["kingston", 51.41, -0.306, "Kingston"],
  ["croydon", 51.372, -0.099, "Croydon"], ["bromley", 51.406, 0.014, "Bromley"],
  ["greenwich", 51.478, 0.0, "Greenwich"], ["lewisham", 51.465, -0.013, "Lewisham"],
  ["peckham", 51.474, -0.069, "Peckham"], ["ealing", 51.513, -0.305, "Ealing"],
  ["hammersmith", 51.492, -0.223, "Hammersmith"], ["kensington", 51.501, -0.192, "Kensington"],
  ["chelsea", 51.487, -0.169, "Chelsea"], ["victoria", 51.496, -0.144, "Victoria"],
  ["waterloo", 51.503, -0.113, "Waterloo"], ["london-bridge", 51.505, -0.086, "London Bridge"],
  ["liverpool-st", 51.518, -0.081, "Liverpool Street"], ["shoreditch", 51.526, -0.078, "Shoreditch"],
  ["bethnal-green", 51.527, -0.055, "Bethnal Green"], ["walthamstow", 51.583, -0.02, "Walthamstow"],
  ["wood-green", 51.597, -0.11, "Wood Green"], ["finchley", 51.599, -0.187, "Finchley"],
  ["hampstead", 51.556, -0.178, "Hampstead"], ["harrow", 51.579, -0.335, "Harrow"],
  ["uxbridge", 51.546, -0.478, "Uxbridge"], ["hounslow", 51.468, -0.361, "Hounslow"],
  ["sutton", 51.361, -0.194, "Sutton"], ["ilford", 51.559, 0.07, "Ilford"],
  ["romford", 51.575, 0.183, "Romford"], ["barking", 51.54, 0.081, "Barking"],
  ["woolwich", 51.491, 0.069, "Woolwich"], ["enfield", 51.652, -0.081, "Enfield"],
  ["manchester", 53.48, -2.24, "Manchester"], ["birmingham", 52.48, -1.9, "Birmingham"],
  ["bristol", 51.45, -2.59, "Bristol"], ["edinburgh", 55.95, -3.19, "Edinburgh"],
].map(([id, lat, lng, label]) => ({ id, lat, lng, label } as Point));

/** A coarse Great Britain coastline, enough to place the markers and no more. */
export const GREAT_BRITAIN: [number, number][] = [
  [50.07, -5.7], [50.37, -4.14], [50.52, -2.45], [50.82, -0.14], [51.13, 1.32], [51.38, 1.44],
  [51.96, 1.35], [52.48, 1.75], [52.93, 1.3], [52.9, 0.2], [53.15, 0.34], [53.58, 0.11],
  [54.12, -0.08], [54.49, -0.61], [54.69, -1.21], [55.0, -1.4], [55.77, -2.0], [56.0, -2.5],
  [56.05, -3.3], [56.4, -2.8], [57.15, -2.1], [57.7, -2.0], [57.5, -4.2], [58.44, -3.1],
  [58.62, -5.0], [57.9, -5.2], [57.3, -6.3], [56.8, -5.1], [55.3, -5.8], [55.46, -4.63],
  [54.9, -3.6], [54.5, -3.6], [54.1, -3.2], [53.8, -3.05], [53.4, -3.0], [53.4, -4.6],
  [52.8, -4.7], [52.4, -4.1], [51.9, -5.3], [51.6, -4.0], [51.45, -3.2], [51.35, -3.0],
  [51.2, -3.5], [51.0, -4.5], [50.55, -4.95],
];

demo.tsxHow it is called: forty-six stores over Great Britain, with the forty-two in London collapsing into one cluster.

import { GeoMap } from "./GeoMap";
import { GREAT_BRITAIN, STORES } from "./stores";

/**
 * Forty-six stores on a map of Great Britain. At this zoom the London stores
 * sit within a few pixels of each other, so they merge into one cluster that
 * carries the count; the four elsewhere stay as single markers.
 */
export default function Demo() {
  return (
    <div className="w-fit rounded-lg border bg-card p-2">
      <GeoMap
        points={STORES}
        bounds={{ lat: [49.9, 58.8], lng: [-6.5, 2.0] }}
        outline={GREAT_BRITAIN}
        width={320}
        height={240}
        onSelect={(points) => console.log(points.map((p) => p.label))}
      />
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

Captures whose hotspots reference this pattern, grouped by product and dated. The dashed boxes are this pattern; hover any box for the note.

Grafana

The reference implementation for panel grids, template variables, and stat panels; most other tools are defined by how they differ from it.

Examples / Geomap September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
Four maps of the same flight data, and the base map does more damage than any of the data settings. On the three dark panels the markers are small green dots on dark grey, which at this size is close to the contrast floor; on the satellite panel they are small green dots on terrain that is already green, brown and blue, and they effectively disappear. The base layer is supposed to be context and recede, and imagery is the loudest option available. The density layer along the bottom is the honest one: it stops pretending individual points resolve, and the clusters on both coasts read immediately. Its radius is doing a great deal of unlabelled work, though, and the blobs merge into clouds at a setting nobody can see. Three of the four panels also carry a legend that says "Layer 1", which is a legend occupying a corner and naming nothing.
Show 2 more examples Hide the rest

Plausible Analytics

One column, top to bottom, where the metric row doubles as the chart's control. The clearest working argument that a dashboard can have exactly one interaction.

Plausible Analytics — Live demo / plausible.io
Single-column narrative. One column, top to bottom, no panel arrangement and nothing to configure before reading starts. Overview then detail. The decomposition: sources, pages, geography, browsers, goals. Same subject, narrowed, no navigation. Ranked list. Sorted with an in-row bar, no share of total and no other row. Direct 271k versus Google 25.1k, out of what? Geo map with markers. A choropleth pale enough that every country but one reads as the same white. Raw counts, unnormalised. Header KPI strip. Six tiles sharing one anatomy, each with a delta. The boxed one is selected, and the chart below plots it. Ratio and rate. Bounce rate 43%, with the denominator two tiles away and the window only in the header. Tabs as genres. Tabs inside the panel—channels, sources, campaigns—so one card answers three questions in one slot.
Live demo / plausible.io September 10, 2026 Plausible live demo, plausible.io's own stats (signed out) medium · light · desktop-web
My single-column-narrative entry names Plausible as the reference implementation and says the trick is that the metric row doubles as the chart's control. Here it is doing exactly that: six tiles across the top, the first one boxed because it's selected, and the chart underneath plotting that metric and no other. Click a different tile and the chart follows. The page therefore has one interaction, and it is the same gesture as paying attention. Everything below reads as a single column in argument order—headline, then the shape behind it, then what it decomposes into, then goals. Two things it doesn't do. The ranked lists carry a count and a bar and no share of total, so Direct at 271k against Google at 25.1k tells you the ordering and not whether the top row is most of the traffic. And the choropleth is so pale that outside the United States almost every country is the same near-white, which is the encoding spending a whole panel to say "mostly America".
  • Single-column narrative One column, top to bottom, no panel arrangement and nothing to configure before reading starts.
  • Header KPI strip Six tiles sharing one anatomy, each with a delta. The boxed one is selected, and the chart below plots it.
  • Ratio and rate Bounce rate 43%, with the denominator two tiles away and the window only in the header.
  • Overview then detail The decomposition: sources, pages, geography, browsers, goals. Same subject, narrowed, no navigation.
  • Ranked list Sorted with an in-row bar, no share of total and no other row. Direct 271k versus Google 25.1k, out of what?
  • Geo map with markers A choropleth pale enough that every country but one reads as the same white. Raw counts, unnormalised.
  • Tabs as genres Tabs inside the panel—channels, sources, campaigns—so one card answers three questions in one slot.

Tableau Public

Thousands of dashboards made by people who are not designers, published without a review step. The best available sample of what the pattern language looks like in the wild.

Shopify Customer Journey September 10, 2026 Tableau Public embed view; workbook published by Lovelytics medium · light · desktop-web
Tableau Public is the product; the design decisions here are the author's. This workbook was published by Lovelytics, so read it as what a competent analyst builds in Tableau rather than as how Tableau thinks dashboards should look. What is instructive is that it carries three separate lines of small-caps instruction—"click on metric to filter dashboard", "hover on a province to view breakdown by top 10 cities", "click on bar to view the second product purchased". Every interaction on the page needed a label, because none of them announces itself. That is the honest cost of cross-filtering: it is powerful and it is invisible until someone tells you it is there. Two other things. The tile block is nine values in a three-by-three grid, which is past the point where a strip has a reading order—the eye has to be told where to start and isn't. And the chart titled "Total sales per month" is plotting seven days, on a y-axis that begins at 500K, so a roughly twenty-five percent spread draws as a mountain range.
  • Cross-filter The tiles are the filter. It needed a line of instruction above it, because nothing about a number says it is clickable.
  • Header KPI strip Nine values in a grid rather than four to six in a row, so there is no privileged place for the eye to start.
  • Time series Titled per month, plotting seven days, on an axis starting at 500K. A 25% spread rendered as a cliff.
  • Hover detail The breakdown by city exists only on hover, so it is unavailable on touch and invisible in this screenshot.
  • Geo map with markers A choropleth of raw sales, unnormalised, so California and Texas lead partly by being large and populous.
  • Ranked list 490 against 30 for second place, so every bar below the first is a sliver and the ordering is all you get.