Join us online - 19th of November
Save your spot
For AI agents: a documentation index is available at /llms.txt. Markdown versions of all pages are available by appending .md to any URL.

Synchronize annotations with a sidebar list

This example demonstrates how to build a bidirectional interface connecting 3D model annotations with an external HTML sidebar list using the Roman Colosseum model.

NPM module setup


npm install --save @maptiler/geosplats
import { SplatModel, Map, config } from "@maptiler/geosplats";
import "@maptiler/geosplats/dist/maptiler-geosplats.css";

config.apiKey = "YOUR_MAPTILER_API_KEY_HERE";

const map = new Map({
  apiKey: config.apiKey,
  container: "map",
  center: [12.4925, 41.89018],
  zoom: 17.2,
  pitch: 55,
  bearing: -25,
});

let splatModel;
map.on("load", () => {
  splatModel = new SplatModel({
    model: "YOUR_MAPTILER_SPLAT_MODEL_ID_HERE",
    revealParams: { enabled: true },
  });
  map.addSplatModel(splatModel);
});

const colosseumPois = [
  {
    id: "arena",
    pos: { x: 0.0, z: 0.0 },
    icon: "🏛️",
    label: "Arena & Hypogeum",
    popup:
      "<h3>Arena & Hypogeum</h3><p>The central combat stage and subterranean maze of chambers, animal cages, and elevator lifts.</p>",
  },
  {
    id: "north-facade",
    pos: { x: 0.0, z: -0.25 },
    icon: "🧱",
    label: "Northern Outer Arc",
    popup:
      "<h3>Northern Outer Arc</h3><p>The best-preserved segment of the four-tiered travertine outer facade, reaching 48 meters high.</p>",
  },
  {
    id: "imperial-gate",
    pos: { x: -0.28, z: 0.02 },
    icon: "👑",
    label: "Imperial Entrance",
    popup:
      "<h3>Imperial Entrance</h3><p>Direct ceremonial portal connecting the arena podium to the imperial palace on the Palatine Hill.</p>",
  },
  {
    id: "gladiator-gate",
    pos: { x: 0.28, z: -0.02 },
    icon: "⚔️",
    label: "Gladiators' Gate",
    popup:
      "<h3>Gladiators' Gate</h3><p>The eastern gate (Porta Libitinensis) leading to the gladiator training barracks at the Ludus Magnus.</p>",
  },
  {
    id: "south-cavea",
    pos: { x: 0.0, z: 0.22 },
    icon: "🏟️",
    label: "Southern Cavea",
    popup:
      "<h3>Southern Cavea</h3><p>The tiered spectator seating once holding over 50,000 citizens organized by social class.</p>",
  },
];

const HIGHLIGHT_COLOR = "#ffcc00";

// 1. Collapsible sidebar logic
const sidebarEl = document.getElementById("sidebar");
const sidebarToggle = document.getElementById("sidebarToggle");
if (sidebarToggle && sidebarEl) {
  sidebarToggle.addEventListener("click", () => {
    sidebarEl.classList.toggle("collapsed");
    sidebarToggle.setAttribute("aria-expanded", !sidebarEl.classList.contains("collapsed"));
  });
}

// 2. Render the sidebar list items immediately on page load
const listEl = document.getElementById("poiList");
if (listEl) {
  colosseumPois.forEach((item) => {
    const li = document.createElement("li");
    li.className = "poi-item";
    li.innerHTML = `<span>${item.icon}</span> <span>${item.label}</span>`;

    // Hover on list item -> highlight 3D annotation
    li.addEventListener("mouseenter", () => {
      if (item.annotation) item.annotation.setColor(HIGHLIGHT_COLOR);
    });
    li.addEventListener("mouseleave", () => {
      if (item.annotation) item.annotation.setColor(undefined);
    });

    // Click on list item -> open annotation popup
    li.addEventListener("click", () => {
      if (item.annotation) item.annotation.openPopup();
    });

    listEl.appendChild(li);
    item.liElement = li;
  });
}

// 3. When the 3D model finishes loading, attach 3D annotations
map.on("splatmodel:load", () => {
  colosseumPois.forEach((item) => {
    const annotation = splatModel.addAnnotation({
      pos: item.pos,
      icon: item.icon,
      label: item.label,
      popup: item.popup,
      data: { id: item.id },
    });

    item.annotation = annotation;
  });
});

// 4. 3D Pin opened -> highlight sidebar item, expand if collapsed, and scroll
map.on("splatmodel:annotation:active", ({ annotation }) => {
  if (sidebarEl && sidebarEl.classList.contains("collapsed")) {
    sidebarEl.classList.remove("collapsed");
    sidebarToggle?.setAttribute("aria-expanded", "true");
  }

  colosseumPois.forEach((item) => {
    if (item.liElement) item.liElement.classList.remove("active");
    if (item.annotation === annotation && item.liElement) {
      item.liElement.classList.add("active");
      item.liElement.scrollIntoView({ behavior: "smooth", block: "nearest" });
    }
  });
});

// 5. 3D Pin closed -> remove active styling
map.on("splatmodel:annotation:inactive", () => {
  colosseumPois.forEach((item) => {
    if (item.liElement) item.liElement.classList.remove("active");
  });
});

Storing annotation references

Populate the sidebar DOM elements upon page initialization and link each entry to its SplatAnnotation instance once the 3D model finishes loading:


const listEl = document.getElementById("poiList");

colosseumPois.forEach((item) => {
  const li = document.createElement("li");
  li.innerHTML = `<span>${item.icon}</span> <span>${item.label}</span>`;
  listEl.appendChild(li);
  item.liElement = li;
});

map.on("splatmodel:load", () => {
  colosseumPois.forEach((item) => {
    item.annotation = splatModel.addAnnotation({
      pos: item.pos,
      icon: item.icon,
      label: item.label,
      popup: item.popup,
      data: { id: item.id },
    });
  });
});

Highlighting 3D pins from sidebar hovers

Attach mouse event listeners to the HTML list elements to highlight the corresponding 3D pin dynamically:


const HIGHLIGHT_COLOR = "#ffcc00";

li.addEventListener("mouseenter", () => {
  if (item.annotation) item.annotation.setColor(HIGHLIGHT_COLOR);
});
li.addEventListener("mouseleave", () => {
  if (item.annotation) item.annotation.setColor(undefined);
});

li.addEventListener("click", () => {
  if (item.annotation) item.annotation.openPopup();
});

Calling annotation.setColor(undefined) reverts the pin back to its default color.

Updating external UI on active popup changes

Listen for the "splatmodel:annotation:active" and "splatmodel:annotation:inactive" events on the map to detect when a popup opens or closes (whether from clicking the 3D pin directly or programmatically via the sidebar):


map.on("splatmodel:annotation:active", ({ annotation }) => {
  colosseumPois.forEach((item) => {
    if (item.liElement) item.liElement.classList.remove("active");
    if (item.annotation === annotation && item.liElement) {
      item.liElement.classList.add("active");
      item.liElement.scrollIntoView({ behavior: "smooth", block: "nearest" });
    }
  });
});

map.on("splatmodel:annotation:inactive", () => {
  colosseumPois.forEach((item) => {
    if (item.liElement) item.liElement.classList.remove("active");
  });
});

Learn more

Check out the MapTiler GeoSplats SDK reference to explore map and model event handlers.

How to add annotations to a splat model

Add annotations

Examples

Add interactive labelled pins and popups to a splat model.

Customize annotation pins and colors

Pin styles and categories

Examples

Style annotations with colors, icons, numbers, and custom popup placement.

Create a guided tour with annotations

Guided tour with annotations

Examples

Guide users through model annotations with next and previous buttons.

Filter annotations by category at runtime

Filter annotations by category

Examples

Filter annotations by category using visibility toggle or color dimming.

Was this helpful?