A quick bit of context first: JQuake's real application is written in Java by its original author, and our role in the project is mostly on the support side, web hosting, helping users, and feeding back ideas. This post is about something separate: a proof-of-concept client I built for myself on Tauri v2, to find out what a modern, web-rendered take on the live map could feel like. It is an experiment, not a replacement, and I did not have the original app's source to copy from. The seismology here is well-understood material that I reimplemented from public principles and then checked against tests.
Why build a proof of concept at all
The Java app does its job and has users who rely on it. I was not trying to replace it. I was curious about a narrower question: if you started a JQuake-style client today, with the rendering and language tooling that exists now, how fluid could the live map be, and how cleanly could the pieces separate? A throwaway prototype is the cheapest way to answer that honestly, because nothing is precious and you can throw it away.
Tauri v2 turned out to be the right shape for the experiment. The heavy, correctness-sensitive work lives in Rust: a once-per-second loop that fetches a small image from Japan's KMONI/BOSAI network, reads each station's color, and turns that into a seismic intensity. The presentation layer is React and TypeScript, where the ecosystem for maps and UI is strongest. The two halves talk across a narrow, typed boundary, so the seismic logic never touches the rendering churn, and the UI never blocks on a network request.
One image a second, decoded pixel by pixel
The live network does not hand you a tidy JSON array of intensities. It publishes a small raster image roughly once per second, where each monitoring station is a colored dot. So the very first job in Rust is to read color back into a number. The mapping is HSV based: convert each station's pixel to hue, saturation, and value, gate out anything too dim or too desaturated to be a real reading, then run hue through a piecewise polynomial fit to recover a floating-point intensity.
pub fn color2position(h: f64, s: f64, v: f64) -> f64 {
if v <= 0.1 || s <= 0.75 {
return 0.0; // too dim or too gray to be a real reading
}
if h > 0.1476 {
// 6th-degree fit across most of the hue range
280.31 * h.powi(6) - 916.05 * h.powi(5) + 1142.6 * h.powi(4)
- 709.95 * h.powi(3) + 234.65 * h.powi(2) - 40.27 * h + 3.2217
} else if h > 0.001 {
// 4th-degree fit for the red-orange end
120.87 * h.powi(4) - 40.129 * h.powi(3) + 5.8593 * h.powi(2)
- 2.4588 * h + 0.90571
} else {
-0.005171 * v.powi(2) - 0.3282 * v + 1.2236
}
}
The intensity (fShindo) then comes out as 10 * position - 3, and a separate conversion turns that into ground acceleration in gal, capped at 980. The two thresholds in that first if are the whole reason a noisy image does not produce phantom earthquakes: a faint or washed-out pixel is treated as zero rather than a weak signal, which keeps the rest of the pipeline honest. A small detail like the saturation gate at 0.75 is exactly what a prototype is for; you only learn its value by getting it slightly wrong first.
Keeping the map smooth with thousands of live points
There are about 1,744 stations, and every one can change color on a one-second cadence along a continuous scale from a calm blue at rest through green and yellow to red. My first instinct, one React component per station, falls apart immediately: re-rendering a tree of nearly two thousand nodes every second will never stay smooth. The fix was to stop thinking of stations as components and start thinking of them as data.
I put the base map on MapLibre GL and render the stations as a single GPU-backed circle layer fed by one GeoJSON source. Color and radius are expressed as data-driven style functions, so the GPU does the interpolation rather than JavaScript. Radius interpolates against zoom, and the per-feature color comes straight from the decoded scale:
<Layer
id="station-circles"
type="circle"
paint={{
"circle-radius": ["interpolate", ["linear"], ["zoom"], 4, 2, 8, 6, 14, 24],
"circle-color": ["get", "color"],
"circle-stroke-width": ["case", [">", ["get", "warning"], 0], 1.5, 0.3],
}}
/>
The other half of staying smooth is sending less data. The Rust loop does not broadcast all 1,744 stations every tick. It emits deltas: only the stations whose state actually changed. The payload is deliberately tiny per station.
- Each delta carries just
id,f_shindo, an integershindolevel, a precomputed CSScolorhex, avisibleflag, and awarninglevel. - The frontend keeps a
Map<number, StationDelta>and mutates it in place, then swaps the reference once to trigger a single React render. - The color is computed in Rust, not the browser, so the UI never recomputes a scale; it just reads a string.
The payoff is that panning across the whole country while the grid pulses stays fluid, because on a quiet second the map might be told to update only a handful of dots instead of all of them.
Reimplementing the detection idea, carefully
The most interesting part of any seismic client is telling a real cluster of shaking from a single noisy sensor. The principle is easy to state: a lone station spiking is almost never an earthquake, so you only raise a flag when neighbors corroborate the motion. The hard part is defining "neighbor" without hand-tuned radii that fall apart where stations are sparse.
I lean on the delaunator crate to build a Delaunay triangulation of the station grid, treating longitude as x and latitude as y. The triangle edges give each station a clean, distance-aware set of rank-1 neighbors; walking one hop further produces rank-2 neighbors. Edges are then distance-filtered, with a generous cap (around 290 km) for low-sensitivity remote stations and a tighter one (around 117 km) elsewhere, so an isolated island sensor still finds partners without a dense-grid station reaching unrealistically far.
let points: Vec<delaunator::Point> = visible.iter()
.map(|&i| delaunator::Point { x: stations[i].lon as f64, y: stations[i].lat as f64 })
.collect();
let triangulation = delaunator::triangulate(&points);
Detection itself weighs evidence rather than counting raw spikes. A rank-1 neighbor that is already rising counts fully; a rank-2 neighbor counts about a third. There are bonuses for synchronized acceleration and for arrival times that line up within a couple of seconds, and a "pre-bug" guard that quiets a station stuck high for too long unless its neighbors back it up. Once enough corroboration crosses a sensitivity-scaled threshold, the station earns a warning level. Keeping all of this in Rust means the UI thread is never doing seismology.
When several stations trigger together, a separate step estimates the epicenter by gradient descent on relative P-wave arrival times. It starts at the first station to trigger, then walks a small search grid of nine compass directions across three depth steps for up to 200 iterations, minimizing the mean squared difference between observed and theoretical arrival-time gaps. A final pass refines depth across 42 trial values from 10 to 680 km, and the estimate is rejected outright if its residual error stays above an acceptance threshold. A wrong answer is worse than no answer, so it is allowed to give up.
Treating a prototype like real code
Even for a proof of concept I held two rules from the first commit: zero compiler warnings and a real test suite. Both sound like overhead at the start, and both pay for themselves almost immediately.
A green build with no warnings is a build you can read. The warnings you tolerate today become the noise that hides the one that actually mattered tomorrow.
Tests matter even more here, because so much of the value lives in logic you cannot eyeball. You cannot confirm by staring at a running map that red decodes to the right intensity, that the neighbor graph corroborates a cluster correctly, or that descent converges on a plausible epicenter. So the suite pins down the pieces I could not verify by sight: HSV conversion for pure red, green, and blue; the brightness and saturation gates returning zero; the acceleration cap at 980 gal; and the epicenter solver correctly refusing to run with too few stations. Twenty-five small tests is not a lot, but it is the difference between trusting a prototype and merely hoping it works.
One more bit of discipline was network manners. The client makes one request per second, fetches sequentially rather than hammering in parallel, and backs off when the next image is not ready yet. None of that is glamorous, but a polite client is the only kind you should point at someone else's public infrastructure. The release build is also tuned to ship small, with link-time optimization and size-oriented optimization on, since a desktop monitor that sits open all day should be light.
What I took away
- A throwaway prototype is a gentle way to answer a feel question like "how smooth could this be" without disturbing the real app that people depend on.
- Split the work along its natural seam: correctness-critical decoding and detection in Rust, presentation in the web layer, talking over a tiny typed delta.
- For thousands of live points, treat the map as data. Let the GPU recolor one styled source layer, and send only what changed each tick.
- Two small thresholds, a saturation gate and an error cutoff, did more for correctness than any amount of clever rendering. Knowing when to output nothing is its own feature.
- Reimplementing a known technique is a real chance to understand it, as long as you validate with tests instead of assuming the math is right.