Skip to main content
Showcases
Muze Showcase · 2026-09-28

Store Map · Co-located Stores

US store locations and annual sales, with co-located stores drawn as concentric bubbles.

Loading viz…

What you’re looking at

Each bubble is a store, placed by latitude and longitude; size is annual sales and colour is the brand. Sixteen of the sixty-two locations host two to four stores at identical coordinates. Their bubbles overlap there, smallest on top, and hovering the location lists every store at it. Click legend items to filter brands.

The map frames whatever US stores it is given over US Census Bureau state boundaries loaded at runtime from the us-atlas package on jsDelivr. Coordinates are approximate shopping centres; the stores, brands and sales figures are invented.

How it was built

Muze has no geographic mark, so this is a scatter plot in disguise: d3-geo projects the coordinates and draws one SVG basemap, while Muze supplies the marks, scales, hover, tooltip and filtering. There is no mapping library or tile server behind it, so there is no pan or zoom, no street or terrain detail, and the basemap is not chosen from the coordinates: you supply one boundary file, and the store coordinates determine the frame.

1. Read the Answer. Studio provides viz and getDataFromSearchQuery(), as shown in the Studio Quick Start. The FIELD block maps your Answer column names; the adapter validates IDs, coordinates and sales before anything is drawn.

2. Order and project. Rows are sorted by sales, largest first, because Muze paints marks in data order and that puts the smaller bubbles on top; groupColocated() then gives each store the list of stores at its exact coordinates for the tooltip. d3.geoMercator().fitExtent() fits the stores’ bounding box into the plot and turns each location into plot pixels, which become the x and y measures.

3. Bind fields to marks. In the point layer, the projected coordinates set position, .detail(["Store ID"]) keeps one mark per store, .size("Sales") and .color("Brand") encode the rest. Both axes are hidden, with the plot’s pixel size as their domain. The brand legend is plain HTML under the plot, with swatches in the order Muze assigns colours; a click restores all rows with resetData(), then dispatches the filter behaviour for the active brands without changing the colour or size scales. Because concentric bubbles resolve to one hover target, the tooltip formatter lists every store at a shared location.

4. Draw the basemap. The boundaries are decoded with topojson.feature() while loading; once Muze fires animationEnd, d3.geoPath() draws them as one <path> in an <svg> pinned onto the plot area. Its stream runs through the same projection and then the axes’ getScaleValue(), so basemap and bubbles share one scale. A resize observer rebuilds the chart when the box changes size.

SETTINGS.modules lists d3-geo, topojson-client and the longitude/latitude TopoJSON as ES modules from jsDelivr, loaded with import() because Muze Studio blocks fetch() but allows module imports; the browser must be able to reach cdn.jsdelivr.net. For another region, point boundaries at a file such as world-atlas and boundariesObject at the object to draw.

Take it with you

Load the CSV into a Worksheet and keep one row per store in the Answer. Paste these artifacts into Muze Studio and set FIELD to match your column names.

JavaScript

Store bubbles, brand legend and the d3-drawn us-atlas basemap.

Preview
const { muze, getDataFromSearchQuery } = viz;
const data = getDataFromSearchQuery();

const FIELD = {
  id: "Store ID",
  store: "Store",
  brand: "Brand",
  lat: "Latitude",
  lon: "Longitude",
  sales: "Sales",
};

const SETTINGS = {
  bubbleRadius: [5, 15],
  // Fraction of the shorter plot side kept clear around the stores.
  framePadding: 0.08,
  palette: ["#1f7a8c", "#e76f51", "#3a5bd9", "#b5179e", "#e9a03b", "#588157"],
  // Studio blocks fetch(), so import the TopoJSON as a module.
  modules: {
    d3: "https://cdn.jsdelivr.net/npm/d3-geo@3/+esm",
    topojson: "https://cdn.jsdelivr.net/npm/topojson-client@3/+esm",
    boundaries: "https://cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json/+esm",
  },
  boundariesObject: "states",
};

let d3;

function readStores(muze, data) {
  const columns = Object.fromEntries(
    Object.entries(FIELD).map(([key, field]) => {
      const values = data
        .getField(field)
        ?.data()
        ?.map((value) => (muze.DataStore.isInvalid(value) ? null : value));
      if (!values)
        throw new Error(`Store Map requires the Answer column "${field}".`);
      return [key, values];
    }),
  );
  if (!columns.id.length)
    throw new Error("Store Map needs at least one store.");

  const ids = new Set();
  return columns.id.map((rawId, i) => {
    const id = String(rawId ?? "").trim();
    const fail = (field, hint) => {
      throw new Error(
        `Store Map row ${i + 1}: invalid ${FIELD[field]}. ${hint}`,
      );
    };
    if (!id || ids.has(id))
      fail("id", "Store IDs must be unique and non-empty.");
    ids.add(id);
    const text = (key) => String(columns[key][i] ?? "");
    const number = (key, hint) => {
      const value = columns[key][i];
      if (
        (typeof value !== "number" && typeof value !== "string") ||
        String(value).trim() === "" ||
        !Number.isFinite(Number(value))
      )
        fail(key, hint);
      return Number(value);
    };
    const store = {
      id,
      store: text("store") || id,
      brand: text("brand") || "Store",
      lat: number("lat", "Latitude must be a number from -85 to 85."),
      lon: number("lon", "Longitude must be a number from -180 to 180."),
      sales: number("sales", "Sales must be a non-negative number."),
    };
    if (Math.abs(store.lat) > 85)
      fail("lat", "Latitude must be from -85 to 85.");
    if (Math.abs(store.lon) > 180)
      fail("lon", "Longitude must be from -180 to 180.");
    if (store.sales < 0) fail("sales", "Sales must be non-negative.");
    return store;
  });
}

function groupColocated(stores) {
  const groups = new Map();
  for (const store of stores) {
    const key = `${store.lat},${store.lon}`;
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(store);
    store.colocated = groups.get(key);
  }
}

// d3 expects clockwise exterior rings.
function frameBox(stores) {
  const [[w0, s0], [e0, n0]] = d3.geoBounds({
    type: "MultiPoint",
    coordinates: stores.map((p) => [p.lon, p.lat]),
  });
  // Avoid a zero-span box.
  const pad = Math.max(e0 - w0, n0 - s0) < 2 ? 1 : 0;
  const [w, s, e, n] = [w0 - pad, s0 - pad, e0 + pad, n0 + pad];
  return {
    type: "Polygon",
    coordinates: [
      [
        [w, s],
        [w, n],
        [e, n],
        [e, s],
        [w, s],
      ],
    ],
  };
}

async function buildViz(muze, data, mountId, options = {}) {
  // Sales-descending so smaller bubbles paint last.
  const stores = readStores(muze, data).sort((a, b) => b.sales - a.sales);
  groupColocated(stores);
  const { modules } = SETTINGS;
  let topo, topojson;
  [d3, topojson, { default: topo }] = await Promise.all(
    [modules.d3, modules.topojson, modules.boundaries].map(
      (url) => import(url),
    ),
  );
  const land = topojson.feature(topo, topo.objects[SETTINGS.boundariesObject]);
  if (options.signal?.aborted) return null;
  const mount = document.getElementById(mountId);
  if (!mount) throw new Error(`Store Map mount #${mountId} was not found.`);

  // Muze assigns colours in first-appearance order.
  const brands = [...new Set(stores.map((s) => s.brand))];
  const legend = { brands, active: new Set(brands) };
  mount.innerHTML = `
  <section id="map" aria-label="Map of stores">
    <svg id="basemap" aria-hidden="true"></svg>
    <div id="plot"></div>
    <div id="legend" role="group" aria-label="${escapeHtml(FIELD.brand)}">
      <span>${escapeHtml(FIELD.brand)}</span>
      ${brands
        .map(
          (brand, i) =>
            `<button type="button" aria-pressed="true" data-index="${i}">
              <i style="background:${SETTINGS.palette[i % SETTINGS.palette.length]}"></i>${escapeHtml(brand)}
            </button>`,
        )
        .join("")}
    </div>
  </section>`;
  const $ = (id) => mount.querySelector(`#${id}`);
  const mapEl = $("map");
  const plotEl = $("plot");
  // Muze's touch handlers block page scrolling; a tap still hovers.
  mapEl.addEventListener("touchstart", (e) => e.stopPropagation(), true);

  let current = null;
  let timer;
  let disposed = false;
  let width = 0;
  let height = 0;
  $("legend").addEventListener("click", (e) => {
    const button = e.target.closest("button");
    if (!button) return;
    const brand = brands[button.dataset.index];
    const { active } = legend;
    active.has(brand) ? active.delete(brand) : active.add(brand);
    button.setAttribute("aria-pressed", active.has(brand));
    current?.filter();
  });
  const render = () => {
    if (disposed || !mapEl.isConnected) return dispose();
    if (!plotEl.clientWidth || !plotEl.clientHeight) return;
    if (plotEl.clientWidth === width && plotEl.clientHeight === height) return;
    width = plotEl.clientWidth;
    height = plotEl.clientHeight;
    current?.dispose();
    current = renderMap(muze, stores, land, mount, $, legend);
  };
  const observer = new ResizeObserver(() => {
    clearTimeout(timer);
    timer = setTimeout(render, 150);
  });
  function dispose() {
    if (disposed) return;
    disposed = true;
    clearTimeout(timer);
    observer.disconnect();
    current?.dispose();
    current = null;
  }
  observer.observe(plotEl);
  render();
  return { dispose };
}

const formatSales = (value) => {
  const [div, unit] =
    value >= 1e9
      ? [1e9, "B"]
      : value >= 1e6
        ? [1e6, "M"]
        : value >= 1e3
          ? [1e3, "K"]
          : [1, ""];
  return `$${div === 1 ? Math.round(value).toLocaleString() : (value / div).toFixed(1)}${unit}`;
};

function escapeHtml(value) {
  return String(value)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

function renderMap(muze, stores, land, mount, $, { brands, active }) {
  const { DataModel, ActionModel, Operators } = muze;
  const plotEl = $("plot");
  const basemap = $("basemap");
  const byId = new Map(stores.map((s) => [s.id, s]));
  const W = plotEl.clientWidth;
  const H = plotEl.clientHeight;
  const bubbleScale = Math.min(1, Math.max(0.55, W / 1000));
  const bubbleRange = SETTINGS.bubbleRadius.map((r) => r * bubbleScale);
  const pad = Math.min(W, H) * SETTINGS.framePadding;
  const projection = d3.geoMercator().fitExtent(
    [
      [pad, pad],
      [W - pad, H - pad],
    ],
    frameBox(stores),
  );
  // Plot pixels as data; y flipped because Muze's y axis grows upward.
  const model = new DataModel(
    DataModel.loadDataSync(
      stores.map((s) => {
        const [x, y] = projection([s.lon, s.lat]);
        return {
          [FIELD.id]: s.id,
          [FIELD.brand]: s.brand,
          x,
          y: H - y,
          [FIELD.sales]: s.sales,
        };
      }),
      [
        { name: FIELD.id, type: "dimension" },
        { name: FIELD.brand, type: "dimension" },
        { name: "x", type: "measure" },
        { name: "y", type: "measure" },
        { name: FIELD.sales, type: "measure" },
      ],
    ),
  );

  const canvas = (typeof muze === "function" ? muze() : muze)
    .canvas()
    .data(model)
    .columns(["x"])
    .rows(["y"])
    .detail([FIELD.id])
    .color(FIELD.brand)
    .size(FIELD.sales)
    .layers([
      {
        mark: "point",
        encoding: {
          opacity: { value: null },
          "fill-opacity": { value: 0.82 },
          stroke: { value: "#ffffff" },
          "stroke-width": { value: 1.2 },
        },
      },
    ])
    .width(W)
    .height(H)
    .config({
      autoGroupBy: { disabled: true },
      columns: { headers: { show: false } },
      gridLines: { x: { show: false }, y: { show: false } },
      border: { width: 0, showValueBorders: { left: false, bottom: false } },
      // Muze reserves scrollbar space even when nothing scrolls; 4 is its minimum.
      scrollBar: { thickness: 4, buttons: { show: false } },
      legend: {
        show: false,
        color: { range: SETTINGS.palette },
        size: { range: bubbleRange },
      },
      axes: {
        x: {
          show: false,
          showAxisLine: false,
          domain: [0, W],
          nice: false,
        },
        y: {
          show: false,
          showAxisLine: false,
          domain: [0, H],
          nice: false,
        },
      },
      interaction: {
        // Filter marks without rescaling.
        filter: { sideEffects: { filter: {} } },
        tooltip: {
          // Coincident centres resolve to one hover target.
          formatter: ({ dataModel }) => {
            const s = byId.get(dataModel?.getField(FIELD.id)?.data()[0]);
            if (!s) return [];
            const members = s.colocated;
            if (members.length === 1) {
              return Operators.html`<div class="store-map-tip">
                  <b>${escapeHtml(s.store)}</b>
                  <span>${formatSales(s.sales)} sales</span>
                </div>`;
            }
            const total = members.reduce((sum, m) => sum + m.sales, 0);
            return Operators.html`<div class="store-map-tip">
                <b>${members.length} stores at this location</b>
                <span>${formatSales(total)} sales</span>
                <ul>${members
                  .map(
                    (m) =>
                      `<li>${escapeHtml(m.store)}<em>${formatSales(m.sales)}</em></li>`,
                  )
                  .join("")}</ul>
              </div>`;
          },
        },
      },
    })
    .mount(`#${mount.id} #plot`);

  ActionModel.for(canvas).dissociateBehaviour(
    ["brush", "drag"],
    ["brush", "touchdrag"],
    ["select", "click"],
    ["select", "longtouch"],
  );
  // Reset first: Muze filters only the current rows.
  const filter = () =>
    canvas
      .firebolt()
      .resetData()
      .dispatchBehaviour("filter", {
        criteria: {
          dimensions: [[FIELD.brand], ...Array.from(active, (b) => [b])],
        },
      });

  let disposed = false;
  canvas.once("animationEnd", () => {
    if (disposed) return;
    if (active.size < brands.length) filter();
    const plotSvg = plotEl.querySelector(".muze-layer-point")?.ownerSVGElement;
    if (!plotSvg) return;
    const svgRect = plotSvg.getBoundingClientRect();
    const plotRect = plotEl.getBoundingClientRect();
    basemap.setAttribute("width", W);
    basemap.setAttribute("height", H);

    // Run projected points through Muze's scales so basemap and bubbles share them.
    const xAxis = canvas.xAxes().flat(2)[0];
    const yAxis = canvas.yAxes().flat(2)[0];
    const toAxes = d3.geoTransform({
      point(x, y) {
        this.stream.point(xAxis.getScaleValue(x), yAxis.getScaleValue(H - y));
      },
    });
    const path = d3.geoPath({
      stream: (s) => projection.stream(toAxes.stream(s)),
    });
    const dx = svgRect.left - plotRect.left;
    const dy = svgRect.top - plotRect.top;
    basemap.innerHTML = `<path class="land" transform="translate(${dx},${dy})" d="${path(land)}"/>`;
  });

  return {
    filter,
    dispose() {
      if (disposed) return;
      disposed = true;
      canvas.dispose();
      model.dispose();
      basemap.replaceChildren();
    },
  };
}

buildViz(muze, data, "chart");

CSS

Layout, legend and tooltip styles.

Preview
/* Muze tooltips live outside the chart. */
#chart,
.muze-tooltip-box:has(.store-map-tip) {
  --border: #d9dfe5;
  --ink: #1f2933;
  --text: #52606d;
  font-family:
    Inter,
    "Avenir Next",
    -apple-system,
    "Segoe UI",
    Roboto,
    "Helvetica Neue",
    Arial,
    sans-serif;
  font-variant-numeric: tabular-nums;
  color: var(--text);

  * {
    box-sizing: border-box;
  }
  table {
    display: table;
    margin: 0;
    overflow: visible;
  }
  td,
  th {
    padding: 0;
    border: 0;
  }
  tr {
    background: transparent;
    border: 0;
  }

  #map {
    position: relative;
    display: flex;
    flex-direction: column;
    width: min(1100px, 100%);
    aspect-ratio: 11 / 7;
    max-height: 700px;
    margin: 0 auto;
    overflow: hidden;
  }
  /* Use DOM paint order; z-index can cover Muze's tooltip. */
  #basemap {
    position: absolute;
    top: 0;
    left: 0;
    pointer-events: none;
  }
  #basemap .land {
    fill: #e5e5e5;
    stroke: #c3c9cf;
    stroke-width: 0.8;
    stroke-linejoin: round;
  }
  #plot {
    position: relative;
    flex: 1;
    min-height: 0;
  }
  /* Override Muze's inline touch-action. */
  #plot g {
    touch-action: auto !important;
  }

  #legend {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 2px 14px;
    padding: 8px 22px 10px;
    font-size: 13.5px;
    line-height: 1.4;
  }
  #legend span {
    font-weight: 600;
  }
  #legend button {
    padding: 0;
    border: 0;
    background: none;
    font: inherit;
    color: var(--ink);
    cursor: pointer;
  }
  #legend button[aria-pressed="false"] {
    color: var(--text);
    opacity: 0.45;
  }
  #legend i {
    display: inline-block;
    width: 10px;
    height: 10px;
    margin-right: 6px;
    border-radius: 50%;
    vertical-align: -1px;
  }

  &.muze-tooltip-box {
    background: rgba(255, 255, 255, 0.98) !important;
    border: 1px solid #d5dde3 !important;
    border-radius: 6px !important;
    box-shadow: 0 6px 24px rgba(31, 41, 51, 0.16) !important;
    padding: 8px 10px !important;
  }
  .store-map-tip {
    display: flex;
    flex-direction: column;
    gap: 2px;
    font-size: 11.5px;
    line-height: 1.45;
    color: var(--ink);
  }
  .store-map-tip b {
    font-weight: 600;
  }
  .store-map-tip span {
    color: var(--text);
  }
  .store-map-tip ul {
    list-style: none;
    margin: 4px 0 2px;
    padding: 4px 0 0;
    border-top: 1px solid var(--border);
  }
  .store-map-tip li {
    display: flex;
    justify-content: space-between;
    gap: 16px;
    color: var(--text);
  }
  .store-map-tip li em {
    font-style: normal;
    color: var(--ink);
  }

  @media (max-width: 720px) {
    #legend {
      padding: 6px 14px 8px;
    }
  }
}

HTML

The mount element.

Preview
<div id="chart"></div>

Dataset (CSV)

91 stores at 62 locations with brand, coordinates and annual sales in dollars.