← Back to Blog
TypeScript2026-05-12Β·84 min read

A Real-Time Earthquake Map for Japan in 300 Lines of JavaScript β€” USGS GeoJSON + Leaflet

By SEN LLC

Browser-Side Geospatial Mapping: Architecting Real-Time Seismic Visualizations Without a Backend Proxy

Current Situation Analysis

Building real-time geospatial dashboards in the browser frequently collides with a hard infrastructure wall: official scientific data sources are rarely designed for client-side consumption. The Japan Meteorological Agency (JMA) maintains the most authoritative domestic seismic catalog, capturing events down to magnitude 1.0 with localized intensity metrics. However, attempting to fetch this data directly from a frontend application fails immediately. The JMA endpoints lack Access-Control-Allow-Origin headers, return heavily nested XML payloads, and route bulk historical downloads through gated scientific portals requiring institutional credentials.

This creates a common architectural misconception. Developers assume that because an endpoint is public, it is browser-compatible. In reality, government and scientific APIs prioritize batch processing, data integrity, and legacy system compatibility over real-time web consumption. The result is a forced backend proxy: a server that fetches XML, parses it, applies CORS headers, and serves JSON to the frontend. This adds infrastructure overhead, introduces a single point of failure, and increases deployment complexity for what should be a static visualization.

The practical alternative lies in leveraging globally distributed, web-native data feeds. The USGS FDSN (Federated Digital Seismograph Network) event service publishes a comprehensive global catalog with explicit browser-friendly design: CORS headers are set to *, authentication is unnecessary, and responses are delivered as standard GeoJSON. The trade-off is a higher magnitude threshold (M2.5+) and a 5–10 minute publishing latency. For monitoring dashboards, early warning visualizations, and educational tools, this latency is operationally acceptable. The architectural payoff is immediate: zero backend infrastructure, direct CDN caching, and a fully client-rendered experience that scales to thousands of concurrent viewers without server intervention.

WOW Moment: Key Findings

The decision to bypass official domestic feeds in favor of a global, web-native alternative fundamentally changes the deployment topology. Below is a comparative breakdown of the three primary approaches developers encounter when building Japan-focused seismic visualizations.

Data Source Browser Compatibility Payload Format Authentication Publishing Latency Magnitude Threshold
JMA Official ❌ Blocked (No CORS) XML Gated Accounts < 1 min M1.0+
P2P Quake Relay βœ… Enabled JSON None ~2 min M1.0+
USGS FDSN βœ… Enabled GeoJSON None 5-10 min M2.5+

Why this matters: The USGS FDSN endpoint enables a pure client-side architecture. By accepting a slightly higher magnitude threshold and a few minutes of latency, you eliminate the need for proxy servers, reduce operational costs to zero, and leverage browser-native caching mechanisms. This approach is ideal for real-time monitoring dashboards, academic demonstrations, and public safety visualizations where infrastructure simplicity outweighs the need for micro-earthquake detection.

Core Solution

Architecting a browser-native seismic map requires strict separation between data transformation logic and DOM/network operations. The following implementation demonstrates a production-ready pattern using TypeScript, Leaflet, and the USGS FDSN API.

1. Query Construction & Bounding Box Strategy

The FDSN service accepts time windows and geographic bounding boxes. For the Japanese archipelago, we define a generous envelope that captures both onshore activity and offshore subduction zones.

interface BoundingBox {
  minLat: number;
  maxLat: number;
  minLon: number;
  maxLon: number;
}

const JAPANESE_REGION: BoundingBox = {
  minLat: 24.0,
  maxLat: 46.0,
  minLon: 122.0,
  maxLon: 148.0,
};

export function constructFdsnEndpoint(
  windowHours: number,
  minMagnitude: number,
  referenceTimestamp: number = Date.now()
): string {
  const endTime = new Date(referenceTimestamp).toISOString();
  const startTime = new Date(referenceTimestamp - windowHours * 3600000).toISOString();

  const params = new URLSearchParams({
    format: 'geojson',
    starttime: startTime,
    endtime: endTime,
    minlatitude: String(JAPANESE_REGION.minLat),
    maxlatitude: String(JAPANESE_REGION.maxLat),
    minlongitude: String(JAPANESE_REGION.minLon),
    maxlongitude: String(JAPANESE_REGION.maxLon),
    minmagnitude: String(minMagnitude),
    orderby: 'time',
  });

  return `https://earthquake.usgs.gov/fdsnws/event/1/query?${params.toString()}`;
}

Rationale: Injecting referenceTimestamp instead of hardcoding Date.now() enables deterministic unit testing. The bounding box intentionally extends beyond political boundaries to capture tectonic plate interactions that frequently trigger felt events onshore.

2. Magnitude-to-Radius Scaling

Seismic magnitude follows a logarithmic scale (base-10). A magnitude 7 event releases roughly 32 times more energy than magnitude 6. Mapping energy directly to pixel radius causes catastrophic visual occlusion: a single M9 event would render a circle hundreds of pixels wide, burying surrounding data points.

export function computeVisualRadius(magnitude: number): number {
  if (magnitude == null || magnitude < 0) return 4;
  
  const rawRadius = 3 + (magnitude - 1) * 4.4;
  const clamped = Math.max(3, Math.min(38, rawRadius));
  
  return Math.round(clamped * 10) / 10; // Preserve single decimal precision
}

Rationale: Linear-in-magnitude scaling ensures perceptual consistency. Each magnitude step increases visual weight predictably without exponential growth. The upper clamp at 38px prevents great earthquakes from dominating the viewport, while the lower bound maintains visibility for minor tremors.

3. Depth-to-Color Interpolation

Earthquake impact correlates strongly with depth. A shallow M5 event causes direct structural stress, while a deep M5 event dissipates energy across a wider area with minimal surface damage. We encode depth using a monotonic color gradient that avoids hue cycling.

type ColorStop = [depthKm: number, rgb: [number, number, number]];

const DEPTH_PALETTE: ColorStop[] = [
  [0,   [239, 68, 68]],   // Surface: Red
  [30,  [249, 115, 22]],  // Shallow crust: Orange
  [70,  [234, 179, 8]],   // Mid crust: Yellow
  [150, [16, 185, 129]],  // Upper mantle: Teal
  [300, [6, 108, 181]],   // Slab intermediate: Blue
  [700, [30, 64, 175]],   // Deep slab: Navy
];

function interpolateChannel(val1: number, val2: number, t: number): number {
  return Math.round(val1 + (val2 - val1) * t);
}

export function deriveDepthHue(depthKm: number): string {
  if (depthKm <= DEPTH_PALETTE[0][0]) {
    const [_, rgb] = DEPTH_PALETTE[0];
    return `#${rgb.map(c => c.toString(16).padStart(2, '0')).join('')}`;
  }
  if (depthKm >= DEPTH_PALETTE[DEPTH_PALETTE.length - 1][0]) {
    const [_, rgb] = DEPTH_PALETTE[DEPTH_PALETTE.length - 1];
    return `#${rgb.map(c => c.toString(16).padStart(2, '0')).join('')}`;
  }

  for (let i = 0; i < DEPTH_PALETTE.length - 1; i++) {
    const [d1, c1] = DEPTH_PALETTE[i];
    const [d2, c2] = DEPTH_PALETTE[i + 1];
    
    if (depthKm >= d1 && depthKm <= d2) {
      const t = (depthKm - d1) / (d2 - d1);
      const blended: [number, number, number] = [
        interpolateChannel(c1[0], c2[0], t),
        interpolateChannel(c1[1], c2[1], t),
        interpolateChannel(c1[2], c2[2], t),
      ];
      return `#${blended.map(c => c.toString(16).padStart(2, '0')).join('')}`;
    }
  }
  return '#000000'; // Fallback
}

Rationale: The palette progresses monotonically from warm to cool tones. This prevents cognitive dissonance where identical colors represent vastly different depths due to hue wheel wrapping. Warm colors signal immediate surface risk; cool colors indicate deep tectonic processes.

4. Map Initialization & Tile Strategy

Leaflet paired with CARTO Dark raster tiles provides a keyless, attribution-compliant base layer. We deliberately cap zoom levels to maintain tile clarity and encourage detail exploration via popups.

import L from 'leaflet';

export function initializeSeismicMap(containerId: string): L.Map {
  const map = L.map(containerId, {
    zoomSnap: 0.5,
    zoomControl: true,
    attributionControl: true,
  }).setView([36.5, 138.0], 5);

  L.tileLayer(
    'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
    {
      attribution: '&copy; OpenStreetMap &copy; CARTO',
      maxZoom: 9,
      minZoom: 3,
      subdomains: 'abcd',
    }
  ).addTo(map);

  return map;
}

Rationale: Raster tiles at maxZoom: 9 ensure the entire archipelago remains visible without pixelation. Users requiring higher resolution can interact with individual markers. This balances performance, visual clarity, and API cost (zero).

5. Polling Cycle & State Synchronization

USGS does not provide server-sent events or WebSockets for seismic data. Polling is the only viable strategy. We implement a dual-timer system: one for data fetching, another for UI countdown synchronization.

interface RefreshState {
  lastFetch: number | null;
  intervalMs: number;
  fetchTimer: ReturnType<typeof setInterval> | null;
  countdownTimer: ReturnType<typeof setInterval> | null;
}

export function startRefreshCycle(
  fetchCallback: () => Promise<void>,
  updateCountdownUI: (remainingMs: number) => void,
  intervalMs: number = 300000 // 5 minutes
): RefreshState {
  const state: RefreshState = {
    lastFetch: null,
    intervalMs,
    fetchTimer: null,
    countdownTimer: null,
  };

  const executeFetch = async () => {
    state.lastFetch = Date.now();
    await fetchCallback();
  };

  executeFetch(); // Initial load
  state.fetchTimer = setInterval(executeFetch, intervalMs);

  state.countdownTimer = setInterval(() => {
    if (!state.lastFetch) return;
    const elapsed = Date.now() - state.lastFetch;
    const remaining = Math.max(0, state.intervalMs - elapsed);
    updateCountdownUI(remaining);
  }, 1000);

  return state;
}

Rationale: Background tabs throttle setInterval to ~1Hz in modern browsers. The countdown logic calculates remaining time dynamically rather than decrementing a counter, ensuring UI accuracy regardless of tab visibility. The initial fetch runs immediately to prevent a 5-minute blank screen on load.

Pitfall Guide

1. Assuming Public Endpoints Are Browser-Compatible

Explanation: Developers frequently attempt to fetch() official government or scientific APIs without verifying CORS headers. This results in silent network failures or blocked requests. Fix: Always inspect response headers during prototyping. If CORS is missing, implement a lightweight proxy, use a community relay, or switch to a web-native alternative like USGS FDSN.

2. Mapping Logarithmic Energy to Linear Pixels

Explanation: Using raw energy values or unclamped logarithmic scales for marker sizes causes visual occlusion. A single high-magnitude event will render a circle that covers dozens of smaller events. Fix: Apply linear-in-magnitude scaling with explicit upper/lower bounds. Test with extreme values (M1.0 and M9.0) to verify viewport coverage.

3. Cyclic Color Gradients for Continuous Variables

Explanation: Using a full hue wheel (red β†’ yellow β†’ green β†’ blue β†’ red) for depth or temperature creates cognitive ambiguity. Viewers cannot distinguish whether two red markers represent identical values or opposite extremes. Fix: Use monotonic gradients that progress in one direction. Anchor extremes to perceptually distinct colors (e.g., warm for shallow/danger, cool for deep/stable).

4. Ignoring Browser Tab Throttling

Explanation: setInterval and setTimeout are heavily throttled in background tabs to conserve battery. Counters that decrement blindly will drift out of sync with actual elapsed time. Fix: Calculate UI state based on absolute timestamps (Date.now() - lastFetch) rather than decrementing variables. Pause non-critical timers on visibilitychange if power efficiency is a priority.

5. Testing Third-Party API Contracts Directly

Explanation: Writing unit tests that hit live external endpoints creates flaky test suites. Network outages, rate limits, or schema changes will break your CI pipeline. Fix: Isolate pure transformation functions (URL builders, scaling logic, parsers) and test them with synthetic fixtures. Mock network calls. Treat external APIs as immutable contracts, not test subjects.

6. Unbounded Zoom on Raster Tile Layers

Explanation: Allowing users to zoom beyond the tile provider's maximum resolution results in blurry, pixelated maps and increased bandwidth consumption. Fix: Explicitly set maxZoom to match the tile provider's capability. Guide users toward interactive popups or detail views for higher-resolution inspection.

7. Hardcoding Refresh Intervals

Explanation: Embedding magic numbers like 300000 directly in polling logic makes the application inflexible and difficult to test. Fix: Extract intervals into configuration objects. Allow runtime overrides for testing environments or user preferences. Document the trade-off between data freshness and API rate limits.

Production Bundle

Action Checklist

  • Verify CORS headers on target endpoints before committing to a client-side architecture
  • Extract all data transformation logic into pure, testable functions with no DOM dependencies
  • Implement linear-in-magnitude scaling with explicit clamping to prevent visual occlusion
  • Design monotonic color gradients that avoid hue cycling for continuous variables
  • Use absolute timestamp calculations for polling countdowns to survive tab throttling
  • Cap raster tile zoom levels to maintain visual clarity and reduce bandwidth
  • Mock external API responses in unit tests; never test live third-party contracts
  • Document magnitude thresholds and latency expectations for end users

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Public monitoring dashboard Client-side polling + USGS FDSN Zero infrastructure, scales automatically, acceptable 5-10 min latency $0 server cost, CDN bandwidth only
Enterprise compliance / M1.0+ detection Backend proxy + JMA/P2P relay Required for low-magnitude data, XML parsing, and audit trails Proxy server costs, maintenance overhead
High-frequency trading / sub-second alerts WebSocket/SSE + dedicated data feed Polling cannot meet <1s latency requirements; requires push architecture High infrastructure cost, specialized data licensing
Academic / educational visualization Static GeoJSON export + Leaflet No real-time requirement, fully offline capable, reproducible Minimal hosting cost, static site deployment

Configuration Template

// config/seismic-map.ts
export const MAP_CONFIG = {
  region: {
    minLat: 24.0,
    maxLat: 46.0,
    minLon: 122.0,
    maxLon: 148.0,
  },
  display: {
    defaultZoom: 5,
    maxZoom: 9,
    minZoom: 3,
    zoomSnap: 0.5,
  },
  data: {
    windowHours: 168, // 7 days
    minMagnitude: 2.5,
    refreshIntervalMs: 300000, // 5 minutes
  },
  visual: {
    radius: {
      base: 3,
      scale: 4.4,
      min: 3,
      max: 38,
    },
    depthStops: [
      { km: 0, color: '#ef4444' },
      { km: 30, color: '#f97316' },
      { km: 70, color: '#eab308' },
      { km: 150, color: '#10b981' },
      { km: 300, color: '#066cb5' },
      { km: 700, color: '#1e40af' },
    ],
  },
};

Quick Start Guide

  1. Initialize the project: Create a new directory and run npm init -y. Install Leaflet types: npm install -D @types/leaflet.
  2. Create the module structure: Set up src/pure-logic.ts for scaling/color functions, src/map-controller.ts for Leaflet initialization, and src/index.ts for the polling loop.
  3. Wire the fetch cycle: Import constructFdsnEndpoint and startRefreshCycle. Pass a callback that calls fetch(), parses response.json(), and updates the marker layer.
  4. Deploy: Build with any standard bundler (Vite, esbuild, or webpack) and deploy the output to a static host (GitHub Pages, Netlify, or S3). No backend required.