Streamlit + MapboxGL + Landsat LST — from zero folder to 3D hexagons 🚀
toblerDashboard.py and
Map.html.
mkdir -p My_folder/Dashboard
cd My_folder/Dashboard
touch Dashboard.py Map.html
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
pip install uv # Windows / generic
brew install uv # macOS
# Inside My_folder/Dashboard
uv venv
# Activate (typical patterns)
# Windows
.\.venv\Scripts\activate
# macOS / Linux
source .venv/bin/activate
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.
.streamlitconfig.toml inside
[theme]
primaryColor = "#3b80cfff"
backgroundColor = "#2a2a2a"
secondaryBackgroundColor = "#121212"
textColor = "#ffffff"
linkColor = "#3b80cfff"
borderColor = "#7c7c7c"
showWidgetBorder = true
font = "sans serif"
st.secrets
MAPBOX_ACCESS_KEY = "pk.YOURKEYHERE"
Later in Python:
MAPBOX_API_KEY = st.secrets["MAPBOX_ACCESS_KEY"]
Dashboard.py:
# ==== Import packages ====
# ==== Page layout ====
# ==== Read data ===
# ==== Functions ====
# ==== Sidebar ====
# ==== Main panel ====
# ==== Map ====
# ==== Footer ====
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")
components.html
Dashboard.py (Streamlit)Map.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>
<head>
<div id="map"> in
<body>
<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>
zoom-based interpolation for smooth growth
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
);
});
Dashboard.py, read Map.html as
a string and replace placeholders:
__MAPBOX_KEY__ → real access token__HEXAGONS__ → GeoJSON hex grid
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)
It handles:
./data/
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")
We'll use tobler.util.h3fy (built on
PySAL).
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)
<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>
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)
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_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")
Smaller value → larger hexes; larger value → finer grid.
with st.sidebar:
st.markdown("## Parameters")
hex_res = st.slider(
"Hexagons size",
1, 12, 8,
key="resoliution" # (typo kept from original)
)
<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>
map.on("load", () => {
// ...
const draw = new MapboxDraw({
displayControlsDefault: false,
controls: {
polygon: true,
trash: true
},
defaultMode: "draw_polygon"
});
map.addControl(draw);
});
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)
uv run fast_api.py
Keep this terminal open, then draw polygons in the dashboard and watch the FastAPI logs for "Polygon received".
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.
POST /polygon
⟶
FastAPI receives GeoJSON
⟶
Python updates raster / stats
⟶
Streamlit shows updated indicators
cd ./Scripts
touch raster_processing.py
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,
.
.
.
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}
Good luck! 💻🌍