---
title: "Update annotations with live on-demand data | Geosplats sdk"
description: "Load popup content on demand and update labels with simulated live telemetry."
---

> For AI agents: a documentation index is available at [/llms.txt](https://docs.maptiler.com/llms.txt). Markdown versions of all pages are available by appending .md to any URL.

# Update annotations with live on-demand data

This example demonstrates how to load popup content asynchronously on demand and stream live telemetry updates directly to 3D Gaussian splat annotation labels, colors, and open popups.

### NPM module setup

```bash
npm install --save @maptiler/geosplats
```

#### main.js

```js
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: [-80.27822, 25.74077],
  zoom: 17.5,
  pitch: 50,
  bearing: -15,
});

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

let parkingAnnotation;
let poolAnnotation;
let isStreaming = true;
let timerId = null;

let parkingOccupancy = 68;
let poolCapacity = 42;

function getColorForPercent(pct) {
  if (pct >= 90) return "red";
  if (pct >= 60) return "#e69439";
  return "#2bb673";
}

function generateParkingPopup(pct) {
  const free = Math.max(0, Math.round(240 * (1 - pct / 100)));
  return `<h3>🚘 Parking Occupancy</h3>
    <p><strong>${pct}% occupied</strong> (${free} bays available)</p>
    <div style="background:#eee;border-radius:4px;height:8px;overflow:hidden;margin-top:6px;">
      <div style="background:${getColorForPercent(pct)};width:${pct}%;height:100%;"></div>
    </div>`;
}

function generatePoolPopup(pct) {
  return `<h3>🏖️ Sun Pool Capacity</h3>
    <p><strong>${pct}% capacity</strong></p>
    <p style="font-size:12px;color:#666;">Water Temp: 28°C · Sunbeds free: ${Math.round(50 * (1 - pct / 100))}</p>`;
}

map.on("splatmodel:load", () => {
  // 1. Parking annotation with live occupancy
  parkingAnnotation = splatModel.addAnnotation({
    pos: { x: 0.1, z: 0.2 },
    icon: "🚘",
    label: `Parking: ${parkingOccupancy}%`,
    color: getColorForPercent(parkingOccupancy),
    data: { id: "parking", loaded: false },
  });

  // 2. Pool annotation with on-demand data fetch
  poolAnnotation = splatModel.addAnnotation({
    pos: { x: -0.4, z: -0.2 },
    icon: "🏖️",
    label: `Pool: ${poolCapacity}%`,
    color: getColorForPercent(poolCapacity),
    data: { id: "pool", loaded: false },
  });

  startStream();
});

// On-demand popup content loading on click
map.on("splatmodel:annotation:click", ({ annotation }) => {
  const data = annotation.getData();
  if (!data) return;

  if (!data.loaded) {
    // Show loading placeholder immediately before popup opens
    annotation.setPopup(
      '<div style="padding:12px;text-align:center;"><em>Fetching live telemetry…</em></div>',
    );

    // Simulate async backend fetch
    setTimeout(() => {
      data.loaded = true;
      if (data.id === "parking") {
        annotation.setPopup(generateParkingPopup(parkingOccupancy));
      } else if (data.id === "pool") {
        annotation.setPopup(generatePoolPopup(poolCapacity));
      }
    }, 500);
  }
});

function updateTelemetry() {
  if (!isStreaming) return;

  // Random jitter
  parkingOccupancy = Math.min(
    100,
    Math.max(10, parkingOccupancy + Math.floor(Math.random() * 9) - 4),
  );
  poolCapacity = Math.min(100, Math.max(5, poolCapacity + Math.floor(Math.random() * 11) - 5));

  if (parkingAnnotation) {
    parkingAnnotation.setLabel(`Parking: ${parkingOccupancy}%`);
    parkingAnnotation.setColor(getColorForPercent(parkingOccupancy));
    if (parkingAnnotation.isActive()) {
      parkingAnnotation.setPopup(generateParkingPopup(parkingOccupancy));
    }
  }

  if (poolAnnotation) {
    poolAnnotation.setLabel(`Pool: ${poolCapacity}%`);
    poolAnnotation.setColor(getColorForPercent(poolCapacity));
    if (poolAnnotation.isActive()) {
      poolAnnotation.setPopup(generatePoolPopup(poolCapacity));
    }
  }
}

function startStream() {
  if (timerId) clearInterval(timerId);
  timerId = setInterval(updateTelemetry, 3000);
}

document.getElementById("toggleStreamBtn")?.addEventListener("click", () => {
  isStreaming = !isStreaming;
  const btn = document.getElementById("toggleStreamBtn");
  const status = document.getElementById("streamStatus");
  const dot = document.getElementById("pulseDot");

  if (!btn || !status || !dot) return;

  if (isStreaming) {
    btn.textContent = "Pause Stream";
    status.textContent = "Receiving updates every 3s";
    dot.style.background = "#2bb673";
  } else {
    btn.textContent = "Resume Stream";
    status.textContent = "Stream paused";
    dot.style.background = "#999";
  }
});
```

#### index.html

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="robots" content="noindex" />
    <title>Update annotations with live on-demand data</title>
    <link rel="stylesheet" href="./style.css" />
  </head>
  <body>
    <div class="telemetry-panel">
      <div class="title"><span class="status-badge" id="pulseDot"></span>Live Sensor Stream</div>
      <div style="color: #666; font-size: 12px" id="streamStatus">Receiving updates every 3s</div>
      <button id="toggleStreamBtn">Pause Stream</button>
    </div>
    <div id="map"></div>
    <script type="module" src="./main.js"></script>
  </body>
</html>
```

#### style.css

```css
body {
  font-family: Inter, sans-serif;
  margin: 0;
  overflow: hidden;
}

#map {
  width: 100%;
  height: 100vh;
}

.telemetry-panel {
  position: absolute;
  top: 16px;
  left: 16px;
  background: rgba(255, 255, 255, 0.95);
  backdrop-filter: blur(4px);
  padding: 12px 18px;
  border-radius: 10px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
  z-index: 10;
  font-size: 13px;
}

.telemetry-panel .title {
  font-weight: 700;
  color: #333;
  margin-bottom: 4px;
}

.status-badge {
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #2bb673;
  margin-right: 6px;
}

.telemetry-panel button {
  background: #3174ff;
  color: #fff;
  border: none;
  padding: 5px 12px;
  border-radius: 6px;
  cursor: pointer;
  width: 100%;
  font-size: 12px;
  font-weight: 600;
  margin-top: 6px;
}
```

### Basic JavaScript setup

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Update annotations with live on-demand data</title>
    <link
      rel="stylesheet"
      href="https://cdn.maptiler.com/maptiler-geosplats/v1.2.1/maptiler-geosplats.css"
    />
    <style>
      body {
        font-family: Inter, sans-serif;
        margin: 0;
        overflow: hidden;
      }

      #map {
        width: 100%;
        height: 100vh;
      }

      .telemetry-panel {
        position: absolute;
        top: 16px;
        left: 16px;
        background: rgba(255, 255, 255, 0.95);
        backdrop-filter: blur(4px);
        padding: 12px 18px;
        border-radius: 10px;
        box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
        z-index: 10;
        font-size: 13px;
      }

      .telemetry-panel .title {
        font-weight: 700;
        color: #333;
        margin-bottom: 4px;
      }

      .status-badge {
        display: inline-block;
        width: 8px;
        height: 8px;
        border-radius: 50%;
        background: #2bb673;
        margin-right: 6px;
      }

      .telemetry-panel button {
        background: #3174ff;
        color: #fff;
        border: none;
        padding: 5px 12px;
        border-radius: 6px;
        cursor: pointer;
        width: 100%;
        font-size: 12px;
        font-weight: 600;
        margin-top: 6px;
      }
    </style>
  </head>

  <body>
    <div class="telemetry-panel">
      <div class="title"><span class="status-badge" id="pulseDot"></span>Live Sensor Stream</div>
      <div style="color: #666; font-size: 12px" id="streamStatus">Receiving updates every 3s</div>
      <button id="toggleStreamBtn">Pause Stream</button>
    </div>
    <div id="map"></div>
    <script type="module">
      import {
        SplatModel,
        Map,
        config,
      } from "https://cdn.maptiler.com/maptiler-geosplats/v1.2.1/maptiler-geosplats.mjs";

      config.apiKey = "YOUR_MAPTILER_API_KEY_HERE";

      const map = new Map({
        apiKey: config.apiKey,
        container: "map",
        center: [-80.27822, 25.74077],
        zoom: 17.5,
        pitch: 50,
        bearing: -15,
      });

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

      let parkingAnnotation;
      let poolAnnotation;
      let isStreaming = true;
      let timerId = null;

      let parkingOccupancy = 68;
      let poolCapacity = 42;

      function getColorForPercent(pct) {
        if (pct >= 90) return "red";
        if (pct >= 60) return "#e69439";
        return "#2bb673";
      }

      function generateParkingPopup(pct) {
        const free = Math.max(0, Math.round(240 * (1 - pct / 100)));
        return `<h3>🚘 Parking Occupancy</h3>
        <p><strong>${pct}% occupied</strong> (${free} bays available)</p>
        <div style="background:#eee;border-radius:4px;height:8px;overflow:hidden;margin-top:6px;">
          <div style="background:${getColorForPercent(pct)};width:${pct}%;height:100%;"></div>
        </div>`;
      }

      function generatePoolPopup(pct) {
        return `<h3>🏖️ Sun Pool Capacity</h3>
        <p><strong>${pct}% capacity</strong></p>
        <p style="font-size:12px;color:#666;">Water Temp: 28°C · Sunbeds free: ${Math.round(50 * (1 - pct / 100))}</p>`;
      }

      map.on("splatmodel:load", () => {
        // 1. Parking annotation with live occupancy
        parkingAnnotation = splatModel.addAnnotation({
          pos: { x: 0.1, z: 0.2 },
          icon: "🚘",
          label: `Parking: ${parkingOccupancy}%`,
          color: getColorForPercent(parkingOccupancy),
          data: { id: "parking", loaded: false },
        });

        // 2. Pool annotation with on-demand data fetch
        poolAnnotation = splatModel.addAnnotation({
          pos: { x: -0.4, z: -0.2 },
          icon: "🏖️",
          label: `Pool: ${poolCapacity}%`,
          color: getColorForPercent(poolCapacity),
          data: { id: "pool", loaded: false },
        });

        startStream();
      });

      // On-demand popup content loading on click
      map.on("splatmodel:annotation:click", ({ annotation }) => {
        const data = annotation.getData();
        if (!data) return;

        if (!data.loaded) {
          // Show loading placeholder immediately before popup opens
          annotation.setPopup(
            '<div style="padding:12px;text-align:center;"><em>Fetching live telemetry…</em></div>',
          );

          // Simulate async backend fetch
          setTimeout(() => {
            data.loaded = true;
            if (data.id === "parking") {
              annotation.setPopup(generateParkingPopup(parkingOccupancy));
            } else if (data.id === "pool") {
              annotation.setPopup(generatePoolPopup(poolCapacity));
            }
          }, 500);
        }
      });

      function updateTelemetry() {
        if (!isStreaming) return;

        // Random jitter
        parkingOccupancy = Math.min(
          100,
          Math.max(10, parkingOccupancy + Math.floor(Math.random() * 9) - 4),
        );
        poolCapacity = Math.min(
          100,
          Math.max(5, poolCapacity + Math.floor(Math.random() * 11) - 5),
        );

        if (parkingAnnotation) {
          parkingAnnotation.setLabel(`Parking: ${parkingOccupancy}%`);
          parkingAnnotation.setColor(getColorForPercent(parkingOccupancy));
          if (parkingAnnotation.isActive()) {
            parkingAnnotation.setPopup(generateParkingPopup(parkingOccupancy));
          }
        }

        if (poolAnnotation) {
          poolAnnotation.setLabel(`Pool: ${poolCapacity}%`);
          poolAnnotation.setColor(getColorForPercent(poolCapacity));
          if (poolAnnotation.isActive()) {
            poolAnnotation.setPopup(generatePoolPopup(poolCapacity));
          }
        }
      }

      function startStream() {
        if (timerId) clearInterval(timerId);
        timerId = setInterval(updateTelemetry, 3000);
      }

      document.getElementById("toggleStreamBtn").addEventListener("click", () => {
        isStreaming = !isStreaming;
        const btn = document.getElementById("toggleStreamBtn");
        const status = document.getElementById("streamStatus");
        const dot = document.getElementById("pulseDot");

        if (isStreaming) {
          btn.textContent = "Pause Stream";
          status.textContent = "Receiving updates every 3s";
          dot.style.background = "#2bb673";
        } else {
          btn.textContent = "Resume Stream";
          status.textContent = "Stream paused";
          dot.style.background = "#999";
        }
      });
    </script>
  </body>
</html>
```

## Loading popup content on demand

Because the `"annotation:click"` event fires **before** the popup opens, you can assign an HTML popup inside your click listener. This enables showing an immediate loading placeholder while fetching real-time data from an API:

```js
splatModel.on("annotation:click", async ({ annotation }) => {
  const data = annotation.getData();
  if (data.loaded) return;

  // Placeholder opens immediately on the current click
  annotation.setPopup("<p>Loading live telemetry…</p>");

  const response = await fetch(`/api/sensors/${data.id}`);
  const details = await response.json();

  data.loaded = true;
  // Updates the card in place
  annotation.setPopup(`<h3>${details.title}</h3><p>${details.reading}</p>`);
});
```

## Streaming live telemetry updates

You can update annotation properties as frequently as needed (such as from WebSocket events or periodic timers). Calling `setLabel()` and `setColor()` recalculates the pin dimensions with smooth transitions:

```js
function onTelemetry(occupancyRate) {
  annotation.setLabel(`Parking: ${occupancyRate}%`);

  const color = occupancyRate >= 90 ? "red" : occupancyRate >= 60 ? "#e69439" : "#2bb673";
  annotation.setColor(color);
}
```

The SDK avoids unnecessary re-renders if the provided string or color is identical to the current value.

## Updating active popups in place

If an annotation’s popup is currently open when new data arrives, calling `annotation.setPopup(newHtml)` updates the card in place without closing it:

```js
if (annotation.isActive()) {
  annotation.setPopup(`<h3>🚘 Parking</h3><p>${freeSpaces} bays available.</p>`);
}
```

## Learn more

Check out the [MapTiler GeoSplats SDK reference](https://docs.maptiler.com/geosplats-sdk/api) to explore the `SplatAnnotation` runtime methods.

## Related examples

### [Add annotations](https://docs.maptiler.com/geosplats-sdk/examples/add-annotations/)

Examples

Add interactive labelled pins and popups to a splat model.

### [Rich HTML popups](https://docs.maptiler.com/geosplats-sdk/examples/rich-popup-annotations/)

Examples

Display custom HTML cards in annotation popups with images, tables, and custom widths.

### [Filter annotations by category](https://docs.maptiler.com/geosplats-sdk/examples/filter-annotations/)

Examples

Filter annotations by category using visibility toggle or color dimming.

### [Sync annotations with sidebar](https://docs.maptiler.com/geosplats-sdk/examples/sync-annotations-sidebar/)

Examples

Connect 3D model annotations bidirectionally with an external sidebar list.
