Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ea59ef2
feat: add /maps viewer with STAC-backed display metadata
turban May 7, 2026
e3b6ccf
fix: fit map to configured instance extent on load
turban May 7, 2026
93ebb26
fix: use hex colormap format expected by zarr-layer
turban May 7, 2026
cbd1f73
fix: replace colormap CDN import with inline interpolated stops
turban May 7, 2026
b581471
fix: watch html/yaml files for reload; add colormap debug log
turban May 7, 2026
1d9104a
fix: pass zarrVersion from STAC asset metadata to ZarrLayer
turban May 7, 2026
dd663b9
feat: use chroma-js for colormap generation
turban May 7, 2026
2cf0a87
refactor: remove redundant title-case in buildColormap, chroma-js is …
turban May 7, 2026
f863125
feat: rename /maps to /map
turban May 7, 2026
70c2850
feat: add management UI at /manage for ingestion and sync
turban May 7, 2026
80897df
feat: add layer opacity and nodata fill value to map viewer
turban May 8, 2026
ad10bc4
refactor: rename maps.html to map-viewer.html
turban May 8, 2026
46e1f59
feat: add color legend to map viewer
turban May 8, 2026
d50f1b7
feat: switch to OpenFreeMap vector tiles basemap
turban May 8, 2026
a981f38
feat: switch to OpenFreeMap vector tiles basemap
turban May 8, 2026
066294c
feat: render data layer below country boundaries and labels
turban May 8, 2026
d06a8ee
feat: remove layer opacity
turban May 8, 2026
2d59bc0
fix: use kelvin range for ERA5 temperature display
turban May 8, 2026
d2b1ad2
fix: set explicit opacity 1 on zarr layer
turban May 8, 2026
71493b7
fix: center ERA5 temperature scale on 0°C (273 K)
turban May 8, 2026
01cbd38
fix: widen ERA5 temperature scale to ±40 K around 0°C
turban May 8, 2026
17ea77a
fix: raise boundary and label layers above zarr layer after add
turban May 8, 2026
a99114c
fix: only zoom to extent on first dataset load
turban May 8, 2026
04ecfd4
fix: remove per-dataset fitBounds, extent zoom handled on page load
turban May 8, 2026
8a235db
fix: update CHIRPS3 nodata to -9999, WorldPop colormap to reds with a…
turban May 8, 2026
c4e780c
feat: rename maps.html to map-viewer.html, fix display ranges and nod…
turban May 8, 2026
059dc13
feat: merge feat/manage-ui into feat/maps-viewer
turban May 8, 2026
3c6556e
Merge remote-tracking branch 'origin/main' into feat/maps-viewer
turban May 9, 2026
2b276a0
feat: use MapLibre globe projection in map viewer
turban May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ sync: ## Install dependencies with uv
uv sync

run: openapi ## Start the app with uvicorn
uv run uvicorn climate_api.main:app --reload
uv run uvicorn climate_api.main:app --reload --reload-include "*.html" --reload-include "*.yaml" --reload-include "*.yml"

lint: ## Check linting, formatting, and types (no autofix)
uv run ruff check .
Expand Down
6 changes: 5 additions & 1 deletion src/climate_api/data/datasets/chirps3.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@
sync_execution: append
sync_availability:
latest_available_function: climate_api.providers.availability.chirps3_daily_latest_available
ingestion:
ingestion:
function: dhis2eo.data.chc.chirps3.daily.download
units: mm
resolution: 5 km x 5 km
source: CHIRPS v3
source_url: https://www.chc.ucsb.edu/data/chirps3
display:
colormap: blues
range: [0.0, 20.0]
nodata: -9999.0
7 changes: 7 additions & 0 deletions src/climate_api/data/datasets/era5_land.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
resolution: 9 km x 9 km
source: ERA5-Land Reanalysis
source_url: https://earthdatahub.destine.eu/collections/era5/datasets/reanalysis-era5-land
display:
colormap: rdbu_r
range: [233.0, 313.0]

- id: era5land_precipitation_hourly
name: Total precipitation (ERA5-Land)
Expand All @@ -38,3 +41,7 @@
resolution: 9 km x 9 km
source: ERA5-Land Reanalysis
source_url: https://earthdatahub.destine.eu/collections/era5/datasets/reanalysis-era5-land
display:
colormap: blues
range: [0.0, 5.0]
nodata: 0.0
4 changes: 4 additions & 0 deletions src/climate_api/data/datasets/worldpop.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@
resolution: 100m x 100m
source: WorldPop Global2
source_url: https://hub.worldpop.org/project/categories?id=3
display:
colormap: reds
range: [0.0, 25.0]
nodata: 0.0
34 changes: 32 additions & 2 deletions src/climate_api/stac/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
CATALOG_DESCRIPTION = "Published Climate API GeoZarr datasets"
STAC_VERSION = "1.1.0"
DATACUBE_EXTENSION = "https://stac-extensions.github.io/datacube/v2.3.0/schema.json"
RENDER_EXTENSION = "https://stac-extensions.github.io/render/v2.0.0/schema.json"
ZARR_EXTENSION = "https://stac-extensions.github.io/zarr/v1.1.0/schema.json"
DEFAULT_STAC_LICENSE = "various"
SPATIAL_STEP_DECIMALS = 8
Expand Down Expand Up @@ -89,11 +90,16 @@ def build_collection(dataset_id: str, request: Request) -> dict[str, object]:
collection_payload["stac_version"] = STAC_VERSION
collection_payload["description"] = template.description
collection_payload["title"] = template.title
renders = _build_renders(artifact, source_dataset)
extensions = {DATACUBE_EXTENSION, ZARR_EXTENSION}
if renders is not None:
collection_payload["renders"] = renders
extensions.add(RENDER_EXTENSION)
existing_extensions = collection_payload.get("stac_extensions", [])
if isinstance(existing_extensions, list):
collection_payload["stac_extensions"] = sorted({*existing_extensions, DATACUBE_EXTENSION, ZARR_EXTENSION})
collection_payload["stac_extensions"] = sorted({*existing_extensions, *extensions})
else:
collection_payload["stac_extensions"] = sorted([DATACUBE_EXTENSION, ZARR_EXTENSION])
collection_payload["stac_extensions"] = sorted(extensions)
collection_payload["links"] = template_links
assets = collection_payload.setdefault("assets", {})
zarr_from_xstac = assets.get("zarr", {}) if isinstance(assets, dict) else {}
Expand Down Expand Up @@ -427,6 +433,30 @@ def _zarr_open_kwargs(artifact: ArtifactRecord) -> dict[str, bool | None]:
return {"consolidated": _zarr_consolidated_flag(_artifact_store_path(artifact))}


def _build_renders(artifact: ArtifactRecord, source_dataset: dict[str, Any]) -> dict[str, Any] | None:
display = source_dataset.get("display")
if not isinstance(display, dict):
return None
colormap_name = display.get("colormap")
value_range = display.get("range")
if not isinstance(colormap_name, str) or not isinstance(value_range, list) or len(value_range) != 2:
return None
render: dict[str, Any] = {
"title": artifact.dataset_name,
"assets": ["zarr"],
"rescale": [[float(value_range[0]), float(value_range[1])]],
"colormap_name": colormap_name,
"climate_api:variable": artifact.variable,
}
nodata = display.get("nodata")
if nodata is not None:
render["nodata"] = float(nodata)
units = source_dataset.get("convert_units") or source_dataset.get("units")
if isinstance(units, str):
render["climate_api:units"] = units
return {"default": render}


def _zarr_consolidated_flag(artifact_path: str) -> bool | None:
if "://" in artifact_path:
return None
Expand Down
94 changes: 93 additions & 1 deletion src/climate_api/system/routes.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"""Root API endpoints."""

import sys
import urllib.parse
from importlib.metadata import version as _pkg_version

from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse, Response
from starlette.responses import RedirectResponse

from .schemas import AppInfo, HealthStatus, Status
from .templates import ROOT_RESPONSES, app_version, render_landing, root_json, wants_json
from .templates import ROOT_RESPONSES, app_version, render_landing, render_manage, render_maps, root_json, wants_json

router = APIRouter()

Expand All @@ -21,6 +23,96 @@ def read_index(request: Request) -> Response:
return HTMLResponse(render_landing(app_version, base))


@router.get("/map", response_class=HTMLResponse, include_in_schema=False)
def maps(request: Request) -> HTMLResponse:
"""Return the interactive map viewer."""
base = str(request.base_url).rstrip("/")
return HTMLResponse(render_maps(base))


@router.get("/manage", response_class=HTMLResponse, include_in_schema=False)
def manage(
request: Request,
message: str | None = None,
error: str | None = None,
) -> HTMLResponse:
"""Return the management interface for ingestion and sync operations."""
base = str(request.base_url).rstrip("/")
return HTMLResponse(render_manage(app_version, base, message=message, error=error))


@router.post("/manage/ingest", include_in_schema=False)
async def manage_ingest(request: Request) -> RedirectResponse:
"""Handle ingest form submission and redirect to the management page."""
from fastapi import HTTPException

from climate_api.data_registry.services.datasets import get_dataset
from climate_api.extents.services import get_extent
from climate_api.ingestions.services import create_artifact

base = str(request.base_url).rstrip("/")
try:
form = await request.form()
dataset_id = str(form.get("dataset_id", ""))
start = str(form.get("start", ""))
end = str(form.get("end", "")) or None
publish = "publish" in form
overwrite = "overwrite" in form

template = get_dataset(dataset_id)
if template is None:
msg = urllib.parse.quote(f"Dataset template '{dataset_id}' not found")
return RedirectResponse(f"{base}/manage?error={msg}", status_code=303)

extent = get_extent()
resolved_bbox = list(extent["bbox"]) if extent else None
extent_id = extent["id"] if extent else None
country_code = extent.get("country_code") if extent else None

create_artifact(
dataset=template,
start=start,
end=end,
extent_id=extent_id,
bbox=resolved_bbox,
country_code=country_code,
overwrite=overwrite,
prefer_zarr=True,
publish=publish,
)
name = urllib.parse.quote(template.get("name", dataset_id))
return RedirectResponse(f"{base}/manage?message=Ingested+{name}", status_code=303)
except HTTPException as exc:
msg = urllib.parse.quote(str(exc.detail))
return RedirectResponse(f"{base}/manage?error={msg}", status_code=303)
except Exception as exc:
msg = urllib.parse.quote(str(exc))
return RedirectResponse(f"{base}/manage?error={msg}", status_code=303)


@router.post("/manage/sync", include_in_schema=False)
async def manage_sync(request: Request) -> RedirectResponse:
"""Handle sync form submission and redirect to the management page."""
from fastapi import HTTPException

from climate_api.ingestions.services import sync_dataset

base = str(request.base_url).rstrip("/")
try:
form = await request.form()
dataset_id = str(form.get("dataset_id", ""))
publish = "publish" in form

sync_dataset(dataset_id=dataset_id, end=None, prefer_zarr=True, publish=publish)
return RedirectResponse(f"{base}/manage?message=Sync+completed", status_code=303)
except HTTPException as exc:
msg = urllib.parse.quote(str(exc.detail))
return RedirectResponse(f"{base}/manage?error={msg}", status_code=303)
except Exception as exc:
msg = urllib.parse.quote(str(exc))
return RedirectResponse(f"{base}/manage?error={msg}", status_code=303)


@router.get("/health")
def health() -> HealthStatus:
"""Return health status for container health checks."""
Expand Down
61 changes: 50 additions & 11 deletions src/climate_api/system/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

import importlib.resources
import logging
from datetime import date
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version
from typing import Any

import jinja2
from fastapi import Request

from climate_api.data_registry.services import datasets as registry_datasets
from climate_api.extents.services import get_extent
from climate_api.ingestions.services import list_datasets

Expand Down Expand Up @@ -94,23 +96,60 @@ def wants_json(request: Request) -> bool:
return json_q >= 0 and (html_q < 0 or json_q >= html_q)


def render_landing(version: str, base: str) -> str:
"""Render the root landing page with live instance status."""
def render_maps(base: str) -> str:
"""Render the map viewer page."""
return get_template("map-viewer.html").render(base=base)


def _load_extent() -> dict[str, Any] | None:
try:
extent: dict[str, Any] | None = get_extent()
return get_extent()
except ValueError:
extent = None
return None
except Exception:
_log.exception("Unexpected error loading extent")
return None


def _load_templates() -> list[dict[str, Any]]:
try:
return registry_datasets.list_datasets()
except Exception:
_log.exception("Unexpected error loading extent for landing page")
extent = None
_log.exception("Unexpected error loading dataset templates")
return []


def _load_datasets() -> list[Any]:
try:
datasets = list_datasets().items
return list_datasets().items
except Exception:
_log.exception("Unexpected error loading datasets for landing page")
datasets = []
_log.exception("Unexpected error loading datasets")
return []


def render_landing(version: str, base: str) -> str:
"""Render the root landing page with live instance status."""
return get_template("landing_page.html").render(
version=version,
base=base,
extent=extent,
datasets=datasets,
extent=_load_extent(),
datasets=_load_datasets(),
templates=_load_templates(),
)


def render_manage(version: str, base: str, message: str | None = None, error: str | None = None) -> str:
"""Render the management page."""
today = date.today().isoformat()
year_ago = date.today().replace(year=date.today().year - 1).isoformat()
return get_template("manage.html").render(
version=version,
base=base,
extent=_load_extent(),
templates=_load_templates(),
datasets=_load_datasets(),
today=today,
year_ago=year_ago,
message=message,
error=error,
)
51 changes: 51 additions & 0 deletions src/climate_api/templates/landing_page.html
Original file line number Diff line number Diff line change
Expand Up @@ -264,10 +264,53 @@ <h2>Datasets <span class="badge">{{ datasets | length }}</span></h2>
{% endif %}
</div>

<!-- Available templates -->
<div class="card">
<h2>Available dataset templates <span class="badge">{{ templates | length }}</span></h2>
{% if templates %}
<table>
<thead>
<tr>
<th>Name</th>
<th>Variable</th>
<th>Period</th>
<th>Source</th>
</tr>
</thead>
<tbody>
{% for t in templates %}
<tr>
<td>{{ t.name }}</td>
<td>{{ t.variable }}</td>
<td>{{ t.period_type }}</td>
<td>
{% if t.source_url %}
<a href="{{ t.source_url }}">{{ t.source or t.id }}</a>
{% else %}
{{ t.source or '—' }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="note">No dataset templates found.</p>
{% endif %}
</div>

<!-- Links -->
<div class="card">
<h2>Explore</h2>
<ul class="links-list">
<li>
<span class="link-label"
><a href="{{ base }}/manage">Manage</a></span
>
<span class="link-desc"
>Ingest and sync datasets without using the API directly</span
>
</li>
<li>
<span class="link-label"
><a href="{{ base }}/docs">API documentation</a></span
Expand All @@ -276,6 +319,14 @@ <h2>Explore</h2>
>Interactive Swagger UI for all endpoints</span
>
</li>
<li>
<span class="link-label"
><a href="{{ base }}/map">Map viewer</a></span
>
<span class="link-desc"
>Browse published datasets on an interactive map</span
>
</li>
<li>
<span class="link-label"
><a href="{{ base }}/stac/catalog.json">STAC Catalog</a></span
Expand Down
Loading
Loading