This article was co-authored with generative AI. Facts have been checked against public documentation where feasible, but errors may remain. Please verify primary sources before relying on this for important decisions.
In the previous article I wrote up my first experiments with OpenDrift. This post continues from there.
I am also developing a separate JavaScript application (currently not public) that calculates the traditional Japanese lunar calendar, lunar age, tidal conditions, and sunrise/sunset times. This post records an experiment in connecting that app with a drift simulation. Specifically, the implementation passes a date computed in JavaScript to a Python drift simulation and displays the results in a JavaScript mapping library.
What Is Being Connected
| Tool | What it answers | Language |
|---|---|---|
| Calendar and tidal calculation app | Traditional calendar, lunar age, tidal height, and sunrise/sunset for a selected date | JavaScript |
| OpenDrift Leeway drift simulation | Drift trajectories based on monthly climatology for that date | Python |
The only shared language between tools from different ecosystems is "date" and "coordinates." The design is to pass a departure datetime computed in JavaScript to Python for the drift calculation, then return the resulting geodata to the JavaScript side for visualization.
Design
┌─────────────────────────────────┐ ┌─────────────────────────────────┐
│ Calendar/tidal app (JS) │ │ drift_sim (Python) │
│ ───────────────────────────── │ │ ───────────────────────────── │
│ - Lunar → Gregorian (HuTime) │ date │ - Climatology-based drift │
│ - Lunar age (astronomy-engine) │ ──────► │ - Returns GeoJSON results │
│ - Tides (Japan Coast Guard) │ coords │ │
│ - Sunrise/sunset (SunCalc) │ │ │
└─────────────────────────────────┘ └─────────────────────────────────┘
A CLI was built on the Python side as the entry point: it accepts a date and coordinates and returns a GeoJSON of drift trajectories.
GeoJSON was chosen because mapping libraries such as Leaflet, MapLibre GL JS, and Mapbox GL can render GeoJSON directly, eliminating the need for an additional transformation layer on the Python output.
Implementation
The bridge is roughly 220 lines of Python. It accepts a date and coordinates as CLI arguments, runs the drift simulation using monthly climatology for that date, and writes the trajectories out as GeoJSON.
The section that converts trajectories to GeoJSON:
def to_geojson(nc_path, *, departure_date, seed_lon, seed_lat, month, hours):
ds = xr.open_dataset(nc_path)
lon = ds["lon"].values
lat = ds["lat"].values
st = ds["status"].values
cats = ds["status"].attrs.get("flag_meanings", "").split()
active_idx = cats.index("active") if "active" in cats else -1
features = [{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [seed_lon, seed_lat]},
"properties": {"role": "seed", "departure_date": departure_date},
}]
for i in range(lon.shape[0]):
valid = ~np.isnan(lon[i])
if valid.sum() < 2:
continue
coords = [[float(x), float(y)]
for x, y in zip(lon[i][valid], lat[i][valid])]
final = int(st[i][valid][-1])
features.append({
"type": "Feature",
"geometry": {"type": "LineString", "coordinates": coords},
"properties": {
"particle_id": int(i),
"status": cats[final] if 0 <= final < len(cats) else f"code{final}",
"is_active": final == active_idx,
},
})
n_total = sum(1 for f in features if f["geometry"]["type"] == "LineString")
n_active = sum(1 for f in features if f["properties"].get("is_active") is True)
return {
"type": "FeatureCollection",
"metadata": {
"model": "OpenDrift Leeway (LIFE-RAFT-NB-1)",
"currents": "OSCAR v2.0 monthly climatology 2015-2020",
"wind": "NCEP/NCAR R1 monthly climatology 1991-2020",
"departure_date": departure_date,
"month_used": month,
"duration_hours": hours,
"seed": {"lon": seed_lon, "lat": seed_lat},
"summary": {
"n_total": n_total,
"n_active": n_active,
"n_stranded": n_total - n_active,
"escape_rate": n_active / n_total if n_total else 0.0,
},
},
"features": features,
}
CLI Usage
# 240-hour drift from off the coast of Naze Port, starting 2025-05-15
python bridge.py --date 2025-05-15
# Alternative departure point and duration
python bridge.py --date 2025-08-01 --lon 130.0 --lat 27.5 --hours 168
Sample output:
running drift sim: date=2025-05-15 month=5 seed=(129.65,28.3) hours=240
saved -> out/drift_2025-05-15.geojson (n=200, active=29, escape=14%)
The resulting GeoJSON file can be fed directly into Leaflet or MapLibre.
Calling from JavaScript
const response = await fetch(
`/api/drift?date=${departureDate}&lon=${lon}&lat=${lat}`
);
const geojson = await response.json();
L.geoJSON(geojson, {
style: feature => ({
color: feature.properties.status === "active" ? "#1f77b4" : "#888",
weight: feature.properties.status === "active" ? 1.2 : 0.5,
opacity: 0.6,
}),
}).addTo(map);
This renders a drift map on the mapping library for the departure date selected by the calendar and tidal calculation app.
Computation Time
Measured on a MacBook Pro (M1):
| Configuration | Time | GeoJSON Size |
|---|---|---|
| 50 particles, 5 days | ~10 seconds | ~130 KB |
| 200 particles, 10 days | ~26 seconds | ~620 KB |
The 500-particle case has not been measured, but assuming roughly linear scaling with particle count and duration, the estimate is ~1 minute / ~1.5 MB.
Latency is not low enough for real-time interaction, so a loading state indicator would be needed in a web deployment.
Server Deployment Options
OpenDrift depends on many packages (scipy, netCDF4, cartopy, geopandas, etc.) and has a large overall footprint, making lightweight serverless environments like Vercel's Python functions impractical.
Realistic deployment options:
- Run as a persistent Python server, wrapped thinly with FastAPI as a REST API
- Use a persistent PaaS such as Render, Fly.io, or Railway
- For academic use, skip web deployment entirely and have researchers run the CLI locally
Where to host is a separate question. At this stage the implementation works as a local CLI.
Lunar-to-Gregorian Conversion
Lunar calendar conversion is handled entirely on the JavaScript side. The calendar and tidal calculation app uses the HuTime open web API for the conversion, so only the resulting Gregorian date (YYYY-MM-DD) is passed to the Python bridge. The Python side has no knowledge of the lunar calendar, which keeps the boundary of responsibility clear.
const lunarDate = { year: 1700, month: 4, day: 15 };
const gregorian = await convertLunarToGregorian(lunarDate); // → "1700-05-23"
const moonPhase = getMoonPhase(gregorian);
const tide = await getTide(gregorian, location);
const drift = await fetch(
`/api/drift?date=${gregorian}&lon=...&lat=...`
);
Temporal Mismatch in the Data
The ocean current data (OSCAR) and wind data (NCEP/NCAR Reanalysis) fed into OpenDrift are both modern climatologies derived from observations from the 1990s onward. Entering a historical lunar date in the calendar app will produce drift trajectories based on "the typical conditions for that calendar month in the modern climate," not historical conditions. This temporal mismatch between the two tools should be stated clearly rather than obscured.
Tidal data is not yet integrated into the OpenDrift reader. Incorporating Japan Coast Guard predicted tidal data is a remaining task.
Visualization Prototype
To verify that the GeoJSON output is well-formed, a simple viewer was built using MapLibre GL JS. It can animate particle movement over time and allows switching between monthly climatologies via month-selector buttons.

The play button at the bottom of the screen advances time. A time slider allows scrubbing to any point; a speed selector (1× to 20×) controls playback rate. Blue circles show the current particle positions, lines show the trajectories traveled, and stranded particles turn red and stop.
The viewer uses two GeoJSON sources:
// Tails (LineString with coordinates up to time t)
map.addSource("tails", { type: "geojson", data: emptyFC });
map.addLayer({ id: "tails-active", type: "line", source: "tails",
filter: ["==", ["get", "is_active"], true],
paint: { "line-color": "#1f77b4", "line-width": 1.4 } });
map.addLayer({ id: "tails-stranded", type: "line", source: "tails",
filter: ["==", ["get", "is_active"], false],
paint: { "line-color": "#d62728", "line-width": 1.0 } });
// Heads (single Point at the current time t)
map.addSource("heads", { type: "geojson", data: emptyFC });
map.addLayer({ id: "heads-active", type: "circle", source: "heads",
filter: ["==", ["get", "state"], "active"],
paint: { "circle-radius": 3.5, "circle-color": "#1f77b4" } });
On each frame, the tails and heads sources are updated via setData(). Each LineString has 241 points at one-hour intervals (240 hours + starting point), so at frame t, coordinates.slice(0, t+1) is used as the tail and coordinates[t] as the current position.
The viewer is contained in a single HTML file. Placing the GeoJSON files output by bridge.py in data/ is all that is needed to run it. At this stage, four pre-generated monthly files are used with manual switching; once the API is in place, arbitrary dates can be computed on demand.
Next Steps
The Python side (the GeoJSON-returning bridge) and the basic viewer are now working. The next step is integrating a "trigger drift map from a selected date" UI into the calendar and tidal calculation app.


Comments
…