🛰️Digital Twin Workshop

Building a Digital Twin Dashboard

Streamlit + MapboxGL + Landsat LST — from zero folder to 3D hexagons 🚀

Today you'll learn to
  • Create a clean, versioned dashboard project
  • Style a Streamlit app like a real product
  • Embed a 3D Mapbox map inside Streamlit
  • Hexify a Landsat LST raster with tobler
  • Wire frontend ↔ backend with FastAPI
Tech stack
🐍 Python ⚙️ Streamlit 🗺️ Mapbox GL JS 🌡️ Landsat 9 LST 🧱 h3 / tobler 🚀 FastAPI
Big picture:
UI (Streamlit) → Map (MapboxGL) → Geo stack (GeoPandas, rasterio, tobler) →
Back to UI as indicators and 3D hexagons.
🗂️Initialization

Booting the project folder

Step 1
Create your project folder: My_folder/Dashboard
Step 2
Optionally initialize a Git repository and connect to GitHub.
Step 3
Add the core files: Dashboard.py and Map.html.
💻Project skeleton (terminal)

mkdir -p My_folder/Dashboard
cd My_folder/Dashboard
touch Dashboard.py Map.html
          

Version control & remote

Why bother with Git? 🤔
  • Experiment safely with map styles & code
  • Roll back that "small change" that broke everything
  • Push your tutorial to GitHub for students
🌐Minimal Git flow

git init
git add .
git commit -m "Initialize dashboard tutorial"
# (After creating repo on GitHub)
git remote add origin https://github.com/username/Dashboard
git push -u origin master
            
🧪Environment

Spinning up a clean Python env

Package manager
We'll use uv for a clean venv + fast installs:
  • Isolated from system Python
  • Reproducible between machines
  • Perfect for teaching setups
Key libraries
streamlit pandas plotly numpy geopandas rasterio shapely json
Terminal spells
📦Install uv

pip install uv       # Windows / generic
brew install uv      # macOS
              

Creating & filling the venv

🧬Create & activate venv

# Inside My_folder/Dashboard
uv venv

# Activate (typical patterns)
# Windows
.\.venv\Scripts\activate

# macOS / Linux
source .venv/bin/activate
            
📦Install all dashboard deps

uv pip install \
  streamlit pandas plotly numpy \
  geopandas json rasterio shapely
            

Pro tip: add a requirements.txt later so others can clone the repo and run uv pip install -r requirements.txt.

🎨Streamlit setup

Theming the dashboard

Theme config file
In your app folder:
  • Create a folder .streamlit
  • Add config.toml inside
  • Define a dark, dashboard-y theme
🧾.streamlit/config.toml

[theme]
primaryColor = "#3b80cfff"
backgroundColor = "#2a2a2a"
secondaryBackgroundColor = "#121212"
textColor = "#ffffff"
linkColor = "#3b80cfff"
borderColor = "#7c7c7c"
showWidgetBorder = true
font = "sans serif"
            
🎨Make it your own: colors and fonts are meant to be tweaked!

Secrets: hiding your Mapbox key

Why secrets.toml? 🔐
  • Don't hard-code tokens in Python or HTML
  • Easy to read via st.secrets
  • Plays nicely with Streamlit Cloud
🔑.streamlit/secrets.toml

MAPBOX_ACCESS_KEY = "pk.YOURKEYHERE"
            

Later in Python: MAPBOX_API_KEY = st.secrets["MAPBOX_ACCESS_KEY"]

🗺️Do you already have a Mapbox access token?

Basic Streamlit app skeleton

Sectioning your code
Use comments as landmarks in Dashboard.py:

# ==== Import packages ====
# ==== Page layout ====
# ==== Read data ===
# ==== Functions ====
# ==== Sidebar ====
# ==== Main panel ====
# ==== Map ====
# ==== Footer ====
                
⚙️Imports & layout

import streamlit as st
import pandas as pd
import numpy as np
import streamlit.components.v1 as components
import plotly.express as px
import plotly.graph_objects as go
import json
import geopandas as gpd

st.set_page_config(
    page_title="Dashboard",
    page_icon="📊",
    layout="wide",
    initial_sidebar_state="expanded"
)

st.title("DT Dashboard Demo")
            
🗺️Map container

Why a custom Mapbox container?

Beyond Streamlit's default maps
Streamlit's map functions are great for quick plots, but:
  • We want full MapboxGL power (3D, layers, draw tools)
  • We want to control camera, styles & layers precisely
  • We can embed HTML as a component with components.html
Architecture sketch:
Dashboard.py (Streamlit)
loads Map.html
injects Mapbox token + data
MapboxGL JS renders 3D city + hexes

Map.html — HTML skeleton

📄Base HTML

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport"
          content="width=device-width, initial-scale=1.0" />
    <title>Map view</title>

    <link href="https://api.mapbox.com/mapbox-gl-js/v3.15.0/mapbox-gl.css"
          rel="stylesheet" />
    <script src="https://api.mapbox.com/mapbox-gl-js/v3.15.0/mapbox-gl.js">
    </script>

    <style>
      body, html {
        margin: 0;
        padding: 0;
        height: 100%;
      }
      #map {
        position: absolute;
        inset: 0;
      }
    </style>
  </head>
  <body>

  </body>
</html>
            
Key ingredients
  • Include Mapbox GL CSS + JS in <head>
  • Add a full-screen <div id="map"> in <body>
  • We'll inject the access token and layers next

Injecting the Mapbox token + basic map

🔑Token placeholder in HTML

<body>
  <script id="mapbox-key" type="text/plain">
    __MAPBOX_KEY__
  </script>

  <div id="map"></div>

  <script>
    mapboxgl.accessToken =
      document.getElementById("mapbox-key").textContent.trim();

    const map = new mapboxgl.Map({
      container: "map",
      style: "mapbox://styles/mapbox/streets-v12",
      center: [6.52, 52.40],
      pitch: 45,
      bearing: -20,
      zoom: 12
    });
  </script>
</body>
            
First visual check ✅
  • Run Streamlit and embed Map.html
  • If the token is injected correctly, you'll see a 3D-ish map over Enschede
  • We'll add layers next (points → 3D buildings → hexagons)
🏙️3D city

Turning on 3D buildings

Use the composite source from Mapbox Streets:

1. Find label layer ID
2. Insert 3D buildings just under labels
3. Use zoom-based interpolation for smooth growth
🏗️3D building layer (JS)

map.on("style.load", () => {
  const layers = map.getStyle().layers;
  const labelLayerId = layers.find(
    (l) => l.type === "symbol" && l.layout["text-field"]
  ).id;

  map.addLayer(
    {
      id: "add-3d-buildings",
      source: "composite",
      "source-layer": "building",
      filter: ["==", "extrude", "true"],
      type: "fill-extrusion",
      minzoom: 15,
      paint: {
        "fill-extrusion-color": "#aaa",
        "fill-extrusion-height": [
          "interpolate",
          ["linear"],
          ["zoom"],
          15, 0,
          15.05, ["get", "height"]
        ],
        "fill-extrusion-base": [
          "interpolate",
          ["linear"],
          ["zoom"],
          15, 0,
          15.05, ["get", "min_height"]
        ],
        "fill-extrusion-opacity": 0.6
      }
    },
    labelLayerId
  );
});
            
⚠️If something doesn't show, check the console for style / source errors.
📨Streamlit → Mapbox

Sending the token & data to Map.html

Text replacement trick
In Dashboard.py, read Map.html as a string and replace placeholders:
  • __MAPBOX_KEY__ → real access token
  • Later: __HEXAGONS__ → GeoJSON hex grid
Think of it as sending a tiny text message from Python into your HTML.
🧵Injecting the API key

MAPBOX_API_KEY = st.secrets["MAPBOX_ACCESS_KEY"]

with open("./Map_Lecture.html", "r", encoding="utf-8") as f:
    mapbox_html = f.read()

mapbox_html = mapbox_html.replace("__MAPBOX_KEY__", MAPBOX_API_KEY)

components.html(mapbox_html, height=800)
            
🌡️Data pipeline

Downloading Landsat LST & reading data

Using helper script
We'll rely on a provided script:
Scripts/LST_Landsat.py

It handles:

  • Getting the city boundary
  • Generating an LST raster
  • Saving data to ./data/
📥Load & run

from Scripts.LST_Landsat import *

def load_data(city: str, path: str):
    get_city_boundary(city)
    lst = generate_LST()
    save_LST(city, lst, path)

# After running above:
# - boundary and LST raster exist in ./data/
CITY_BOUNDARY = gpd.read_file("./data/Enschede.geojson")
LST_ENSCHEDE = rasterio.open("./data/LST_Enschede.tif")
PET_ENSCHEDE_HEX = gpd.read_file("./data/Heat_Enschede.json")
            

From raster to hexagon grid

Why hexagons? 🐝
  • Visually pleasing & uniform neighborhoods
  • Good for aggregating raster values
  • H3 indexes are mapbox-friendly

We'll use tobler.util.h3fy (built on PySAL).

🧊Create hexagons

from tobler.util import h3fy

def create_hexagons(city: gpd.GeoDataFrame,
                    resolution: int):
    print("Creating hexagons...")
    hexes = h3fy(city, resolution=10, clip=True)

    hex_wgs84 = hexes.to_crs(epsg=4326)
    return hex_wgs84

Hexagons_ENSCHEDE = create_hexagons(CITY_BOUNDARY, 10)
            

Sending hexagons to MapboxGL

📡In Map.html

<script id="hexagons-enschede"
        type="application/json">
  __HEXAGONS__
</script>

<script>
map.on("load", () => {
  // ...
  map.addSource("hexagons", {
    type: "geojson",
    data: JSON.parse(
      document.getElementById("hexagons-enschede").textContent
    )
  });

  map.addLayer({
    id: "hexagons",
    type: "fill-extrusion",
    source: "hexagons",
    paint: {
      "fill-extrusion-color": "#32b318",
      "fill-extrusion-height": 100,
      "fill-extrusion-opacity": 0.9,
      "fill-extrusion-edge-radius": 1
    }
  });
});
</script>
            
🔄In Dashboard.py

hexagons_empty = create_hexagons(CITY_BOUNDARY, 10)
HEXAGONS = calculate_stats(
    "./data/LST_Enschede.tif",
    hexagons_empty,
    "mean"
)

with open("./Map_Lecture.html", "r", encoding="utf-8") as f:
    mapbox_html = f.read()

mapbox_html = mapbox_html.replace(
    "__HEXAGONS__", HEXAGONS.to_json()
)

components.html(mapbox_html, height=800)
            
🔁Refresh the app — do you see 3D hex columns now?
📊Indicators

Adding LST stats per hexagon

Zonal stats
Use rasterio.mask to clip the raster to each hexagon, then compute statistics (e.g. mean):

from rasterio.mask import mask

def calculate_stats(raster_path: str,
                    zones: gpd.GeoDataFrame,
                    stat: str):
    print("Calculating stats...")
    raster = rasterio.open(raster_path)

    def derive_stats(geom, data, **mask_kw):
        masked, mask_transform = mask(
            dataset=data,
            shapes=(geom,),
            crop=True,
            all_touched=True,
            filled=True
        )
        return masked

    zones[stat] = zones.geometry.apply(
        derive_stats,
        data=raster
    ).apply(np.mean)

    return zones
                
Max / Min / Mean LST
Max LST
?? °C
+1.5 °C vs baseline
Min LST
?? °C
±0.0 °C
Mean LST
?? °C
-0.24 °C

Showing indicators in Streamlit

📈st.metric layout

max_lst = round(LST_ENSCHEDE.read(1).max(), 2)
min_lst = round(LST_ENSCHEDE.read(1).min(), 2)
mean_lst = round(LST_ENSCHEDE.read(1).mean(), 2)

col1, col2, col3 = st.columns(3)

with col1 as col:
    st.metric("Max LST",
              f"{max_lst}°C",
              delta=f"{1.5}°C")

col2.metric("Min LST",
            f"{min_lst}°C",
            delta=f"{0}°C")

col3.metric("Mean LST",
            f"{mean_lst}°C",
            delta=f"{-0.24}°C")
            
Design tips
  • Align metrics visually with the map below or above
  • Use deltas to hint at trends / future comparisons
  • Later, add a small sparkline chart next to one of them
📊How could we visualize changes over time here?
🎚️Interactivity

Controlling hex size with a slider

🧩H3 resolution: 1 → 12

Smaller value → larger hexes; larger value → finer grid.

🧱Sidebar slider

with st.sidebar:
    st.markdown("## Parameters")
    hex_res = st.slider(
        "Hexagons size",
        1, 12, 8,
        key="resoliution"  # (typo kept from original)
    )
          
🧩Frontend → Backend

Drawing polygons on the map

Mapbox Draw plugin
Add CSS + JS in <head>:

<link rel="stylesheet"
      href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-draw/v1.5.1/mapbox-gl-draw.css"
      type="text/css">
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-draw/v1.4.3/mapbox-gl-draw.js">
</script>
                
✏️Enable polygon drawing

map.on("load", () => {
  // ...
  const draw = new MapboxDraw({
    displayControlsDefault: false,
    controls: {
      polygon: true,
      trash: true
    },
    defaultMode: "draw_polygon"
  });

  map.addControl(draw);
});
            
🧪Refresh the app — can you draw a polygon now?

Receiving the polygon with FastAPI

FastAPI endpoint

from fastapi import FastAPI
from pydantic import BaseModel
from typing import Any, Dict
import uvicorn

app = FastAPI()

class GeoJSONFeature(BaseModel):
    type: str
    geometry: Dict[str, Any]
    properties: Dict[str, Any] | None = None

@app.post("/polygon")
async def receive_polygon(feature: GeoJSONFeature):
    print("Polygon received:", feature)
    return {"status": "ok", "received": feature}

if __name__ == "__main__":
    uvicorn.run("fast_api:app",
                host="0.0.0.0",
                port=8000,
                reload=True)
                
Running the server

uv run fast_api.py
                

Keep this terminal open, then draw polygons in the dashboard and watch the FastAPI logs for "Polygon received".

🐞Getting CORS errors or timeouts? We fix that next.

Fixing CORS so front & back can talk

CORS middleware

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # dev only
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)
                

⚠️ For production, tighten allow_origins & methods. This is intentionally permissive for local experiments.

Flow recap
MapboxDraw polygon JS fetch to POST /polygon FastAPI receives GeoJSON Python updates raster / stats Streamlit shows updated indicators
🧠 Next challenges: change raster values inside polygons, recalc indicators, design new KPIs.
🧩Backend processing

Polygon processing

Create new file

cd ./Scripts
touch raster_processing.py
                
copy code

import json
from pathlib import Path

import numpy as np
import rasterio
from rasterio.features import geometry_mask
from shapely.geometry import shape
import geopandas as gpd


def edit_raster_polygon(
    feature: dict,
    raster_path: str,
    .
    .
    .
    

🤔 Question

What does this function do?

🧩 Question

How can we include this in our dashboard?

Implementing polygon processing

📥Import in fast_api.py
In your FastAPI code

Change your code to:


from raster_processing import edit_raster_polygon

@app.post("/polygon")
async def receive_polygon(feature: GeoJSONFeature):
    output_raster, stats = edit_raster_polygon(feature.dict(),
                                        raster_path="data/LST_Enschede.tif",
                                        out_path="data/LST_Enschede_modified.tif",
                                        mode="add",
                                        add_value=3                                   
                                        )
    print("Polygon received:", feature, "Output raster:", stats)
    return {"status": "ok", "output": output_raster}
                
Wrap-up

You now have a mini Digital Twin stack 🧠

What we built
  • Structured Streamlit dashboard with theming
  • Custom MapboxGL container embedded as HTML
  • Landsat LST → hexified via tobler
  • Global & per-hex LST indicators
  • Mapbox Draw polygons talking to FastAPI
Where to go next
  • Implement raster edits inside drawn polygons
  • Recompute indicators live based on changed areas
  • Add more KPIs (heat stress, housing, water, etc.)
  • Turn this into a reusable teaching template

🚀 Your turn!

💡Challenge
Challenge
  • Include a new dataset (raster or vector)
  • Add TWO new indicators
  • Add ONE widget
  • Add ONE chart

Good luck! 💻🌍