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");
});
});
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="robots" content="noindex" />
<title>Synchronize annotations with a sidebar list</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div class="sidebar" id="sidebar">
<div
class="sidebar-header"
id="sidebarToggle"
role="button"
aria-expanded="true"
title="Toggle landmarks panel"
>
<div class="header-title">
<span>🏛️</span>
<h3>Colosseum Landmarks</h3>
</div>
<button class="toggle-btn" id="toggleBtn" aria-label="Toggle panel">▲</button>
</div>
<div class="sidebar-content" id="sidebarContent">
<div class="subtitle" id="sidebarSubtitle">Select a landmark to explore:</div>
<ul id="poiList" class="poi-list"></ul>
</div>
</div>
<div id="map"></div>
<script type="module" src="./main.js"></script>
</body>
</html>
body {
font-family: Inter, sans-serif;
margin: 0;
overflow: hidden;
}
#map {
width: 100%;
height: 100vh;
}
.sidebar {
position: absolute;
top: 12px;
left: 12px;
width: 240px;
max-width: calc(100% - 24px);
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
z-index: 10;
overflow: hidden;
transition: box-shadow 0.25s ease;
user-select: none;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
cursor: pointer;
background: transparent;
transition: background 0.15s ease;
}
.sidebar-header:hover {
background: rgba(0, 0, 0, 0.04);
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
}
.sidebar h3 {
font-size: 13.5px;
margin: 0;
font-weight: 700;
color: #222;
white-space: nowrap;
}
.toggle-btn {
background: transparent;
border: none;
font-size: 10px;
color: #666;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
cursor: pointer;
transition: transform 0.25s ease;
}
.sidebar.collapsed .toggle-btn {
transform: rotate(180deg);
}
.sidebar-content {
padding: 0 12px 12px 12px;
max-height: 260px;
overflow-y: auto;
transition:
max-height 0.25s ease,
opacity 0.2s ease,
padding 0.25s ease;
}
.sidebar.collapsed .sidebar-content {
max-height: 0;
opacity: 0;
padding-top: 0;
padding-bottom: 0;
pointer-events: none;
}
.sidebar .subtitle {
font-size: 11px;
color: #666;
margin-bottom: 8px;
}
.poi-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.poi-item {
padding: 6px 10px;
border-radius: 6px;
background: #f8f9fa;
border: 1px solid #e9ecef;
cursor: pointer;
font-size: 12px;
font-weight: 500;
color: #333;
display: flex;
align-items: center;
gap: 8px;
transition:
background 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
}
.poi-item:hover {
background: #eef2ff;
border-color: #c7d2fe;
}
.poi-item.active {
background: #3174ff;
color: #fff;
border-color: #3174ff;
}
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.
Related examples
Pin styles and categories
ExamplesStyle annotations with colors, icons, numbers, and custom popup placement.
Guided tour with annotations
ExamplesGuide users through model annotations with next and previous buttons.
Filter annotations by category
ExamplesFilter annotations by category using visibility toggle or color dimming.