feat: polish & windviz & deploy

This commit is contained in:
Anatoly Antonov 2026-05-30 06:29:39 +09:00
parent 81b8e763bd
commit 465ad00f7b
78 changed files with 20622 additions and 2154 deletions

View file

@ -0,0 +1,51 @@
# Wind layer demo
A minimal browser client that renders the predictor's wind field as an
animated particle layer using [Leaflet](https://leafletjs.com/) and
[leaflet-velocity](https://github.com/onaci/leaflet-velocity).
The predictor's `GET /api/v1/wind/field` endpoint emits the
[wind-js-server](https://github.com/danwild/wind-js-server) "gfs.json" format
(a two-element `[U, V]` array of `{header, data}` records), which is exactly
what leaflet-velocity and [sakitam-fdd/wind-layer](https://github.com/sakitam-fdd/wind-layer)
consume — so no transformation is needed in the frontend.
## Running
Serve this directory and the predictor from the same origin (or set `API` in
`index.html` to the predictor's base URL and rely on the predictor's CORS
headers):
```bash
# Terminal 1: the predictor (must have a dataset loaded for real data)
./bin/predictor
# Terminal 2: serve the demo
cd examples/wind-demo && python3 -m http.server 8090
# open http://localhost:8090 (set API="http://localhost:8080" in index.html)
```
## API contract
`GET /api/v1/wind/field` query parameters (all optional):
| Param | Default | Meaning |
|---|---|---|
| `time` | dataset epoch | RFC3339 forecast time to sample |
| `altitude` | `0` | altitude in metres |
| `min_lat`,`max_lat`,`min_lng`,`max_lng` | global | bounding box (degrees) |
| `step` | `1.0` | grid resolution in degrees (min `0.25`) |
`GET /api/v1/wind/meta` returns the active dataset's source, epoch, suggested
altitudes, and bounding box so a client can populate its controls.
The full OpenAPI definition is served at `/openapi.yaml`, with a browsable
ReDoc rendering at `/docs`.
## Minimal fetch
```js
const res = await fetch("/api/v1/wind/field?altitude=10000&step=2");
const data = await res.json(); // [ {header, data}, {header, data} ]
L.velocityLayer({ data }).addTo(map);
```

View file

@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>stratoflights-predictor — wind layer demo</title>
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- leaflet-velocity: consumes the wind-js-server JSON this API emits -->
<link rel="stylesheet" href="https://unpkg.com/leaflet-velocity@1.7.0/dist/leaflet-velocity.css" />
<script src="https://unpkg.com/leaflet-velocity@1.7.0/dist/leaflet-velocity.js"></script>
<style>
html, body, #map { height: 100%; margin: 0; }
#controls {
position: absolute; z-index: 1000; top: 10px; right: 10px;
background: #fff; padding: 8px 10px; border-radius: 6px;
font: 13px sans-serif; box-shadow: 0 1px 4px rgba(0,0,0,.3);
}
#controls label { display: block; margin: 4px 0; }
</style>
</head>
<body>
<div id="map"></div>
<div id="controls">
<strong>Wind layer</strong>
<label>Altitude (m):
<input id="altitude" type="number" value="10000" step="1000" style="width:80px">
</label>
<label>Step (deg):
<input id="step" type="number" value="2" step="0.5" min="0.25" style="width:60px">
</label>
<button id="reload">Reload</button>
<div id="status"></div>
</div>
<script>
// Base URL of the predictor API. Same-origin by default.
const API = "";
const map = L.map("map").setView([30, 0], 2);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "&copy; OpenStreetMap",
}).addTo(map);
let velocityLayer = null;
// fetchWindField pulls the leaflet-velocity-compatible grid from the API.
//
// The endpoint returns a two-element array [uComponent, vComponent], each
// with a {header, data} object — exactly the gfs.json / wind-js-server
// shape leaflet-velocity and wind-layer expect.
async function fetchWindField({ altitude, step, time, bbox } = {}) {
const q = new URLSearchParams();
if (altitude != null) q.set("altitude", altitude);
if (step != null) q.set("step", step);
if (time) q.set("time", time);
if (bbox) {
q.set("min_lat", bbox.minLat); q.set("max_lat", bbox.maxLat);
q.set("min_lng", bbox.minLng); q.set("max_lng", bbox.maxLng);
}
const res = await fetch(`${API}/api/v1/wind/field?` + q.toString());
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
return res.json();
}
async function reload() {
const status = document.getElementById("status");
const altitude = Number(document.getElementById("altitude").value);
const step = Number(document.getElementById("step").value);
status.textContent = "loading…";
try {
const data = await fetchWindField({ altitude, step });
if (velocityLayer) map.removeLayer(velocityLayer);
velocityLayer = L.velocityLayer({
displayValues: true,
displayOptions: {
velocityType: "Wind",
displayPosition: "bottomleft",
displayEmptyString: "No wind data",
},
data,
maxVelocity: 60,
}).addTo(map);
status.textContent = `loaded ${data[0].header.nx}×${data[0].header.ny} grid`;
} catch (err) {
status.textContent = "error: " + err.message;
console.error(err);
}
}
document.getElementById("reload").addEventListener("click", reload);
reload();
</script>
</body>
</html>