Skip to content
KONIGI Patterns

Dashboards / Charts and data displays / Geo map with markers

‹ 8 of 22 ›

Geo map with markers

People need to see how values and conditions vary by location.

Updated September 10, 2026

Problem

People need to see whether differences in a metric follow a geographic pattern.

Solution

Use markers for individual locations, filled regions for area-based comparisons, or a density layer for many nearby points. Choose the form that matches the question.

Keep the base map quiet so the data stands out. Cluster overlapping markers with a clear indication of their count. Label the measure and color scale.

For region comparisons, choose an appropriate rate or normalized measure when raw totals would mainly reflect population size. Explain aggregation and keep exact values available. Consider how map projection and location precision affect the reading.

Use when

Physical location helps explain the data and the audience needs to compare places.

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

Large regions can dominate visually without having large values. Maps spend space on areas without data and may depend on external map services. Precise locations can also reveal information that should be grouped or withheld.

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 arranges items without geographic positions. Heatmap shows density without requiring a map. Sequential and diverging scales defines the color range.

Drill-down opens location details. Data table is an alternative for exact comparisons.

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

Screenshots are grouped by product and include the capture date. Dashed boxes highlight this pattern. Notes appear below each screenshot and when you hover over a box.

Grafana

Examples of panel grids, shared selectors, stat tiles, and chart configuration.

Examples / Geomap September 10, 2026 Grafana Play (signed out; no version string exposed) medium · dark · desktop-web
The same flight data appears on several map backgrounds. Small green markers are difficult to distinguish on the dark maps and compete with terrain colors on the satellite view. The density layer makes broad clusters easier to see, though its settings affect how widely those clusters spread. Legends labeled Layer 1 do not explain the data being shown.
Show 2 more examples Hide the rest

Plausible Analytics

A metric row controls the main chart, followed by focused traffic breakdowns.

Live demo / plausible.io September 10, 2026 Plausible live demo, plausible.io's own stats (signed out) medium · light · desktop-web
The metric row controls the main chart: the selected tile is outlined and its series appears below. Supporting sections then break the results down by source, page, geography, and other categories. Ranked lists show counts and bars, but no visible share of the total. The map uses very pale colors for most countries, limiting how much variation is easy to see.

Tableau Public

Published workbooks show how individual authors arrange charts and interactions in Tableau.

Shopify Customer Journey September 10, 2026 Tableau Public embed view; workbook published by Lovelytics medium · light · desktop-web
This Lovelytics workbook uses clickable metrics and bars to filter or reveal detail. Instructions explain these interactions because the marks do not clearly look interactive. Nine summary values compete for attention in a grid. The sales chart also has a mismatch between its monthly title and the displayed dates, while its truncated vertical scale emphasizes variation. These choices make labels and interaction cues especially important.
  • Cross-filter An instruction explains that metric tiles filter the dashboard.
  • Header KPI strip Nine values share a grid without a clear starting priority.
  • Time series The title and displayed period differ, and the vertical axis starts above zero.
  • Hover detail City details are described as available on hover.
  • Geo map with markers The map shows raw sales totals, which also reflect differences between populations.
  • Ranked list One much larger value leaves the remaining bars difficult to compare.