Skip to content
Console

Map tiles

ExpTech serves radar reflectivity, satellite imagery, wind, QPESUMS precipitation forecasts, and other weather layers, plus base map, terrain, and streets-and-buildings tiles, as XYZ tiles (slippy map tiles). Weather layers update over time. Request the list endpoint for the times that exist, then put a time into the tile URL. You can add the tiles directly to MapLibre GL JS, Leaflet, OpenLayers, and other map libraries.

Layer Time list Tile URL Zoom Source
Radar reflectivity /api/v2/tiles/radar/list /api/v2/tiles/radar/{time}/{z}/{x}/{y}.webp 3–12 Central Weather Administration (CWA)
Satellite imagery /api/v2/tiles/satellite/{channel}/list /api/v2/tiles/satellite/{channel}/{style}/{time}/{z}/{x}/{y}.webp 0–11 Himawari-9 (JMA, via NOAA Open Data)
Wind /api/v2/tiles/wind/{model}/list /api/v2/tiles/wind/{model}/{cycle}/{time}/{z}/{x}/{y}.webp 0–11 NOAA GFS, ECMWF open data
QPESUMS /api/v2/tiles/qpesums/list See that page See that page CWA QPESUMS
Base map Not required (TileJSON) See that page See the TileJSON See that page
Upstream terms
  • Central Weather Administration (CWA): CWA's own data terms (such as the Open Government Data License) also apply; CWA's official releases take precedence.
  • JMA Himawari-9 (via NOAA Open Data): The terms JMA and NOAA Open Data set for Himawari data also apply.
  • NOAA, NOAA GFS: U.S. government data, generally free of use restrictions; do not imply NOAA endorses your product.
  • ECMWF open data: ECMWF open data is licensed under CC BY 4.0: credit ECMWF, name the licence and indicate that the data was processed.
  • OpenStreetMap: OpenStreetMap data is licensed under the ODbL: show "© OpenStreetMap contributors" on the map and link to https://www.openstreetmap.org/copyright .
  • OpenMapTiles: Tiles built on the OpenMapTiles schema must show "© OpenMapTiles".

Where to show it, examples and commercial-use requests: Data licensing and attribution.

No API Token is required. Every list and tile responds with Access-Control-Allow-Origin: *, so a browser can load them cross-origin.

Lists and tiles for weather layers are on different hosts:

  • Time lists: https://api.core-tnn1.exptech.dev
  • Tiles and binary files: https://static.core-tnn1.exptech.dev
  • Base map, terrain, and streets and buildings: https://lb.exptech.dev
  1. Request the list endpoint for the available times.
  2. Expand the list into Unix seconds. Radar and satellite lists are delta-encoded. See below.
  3. Build tile URLs only from times in the list. A time outside the list returns 404 or 500. The server does not substitute the nearest time.
  4. Add the URL template to the map. Leave {z}, {x}, and {y} for the map library to fill in.

A radar or satellite list is [oldest time, delta, delta, …]. Every value is Unix seconds, oldest first. The first number is the oldest time. Each later number is the gap from the previous time. When there is no data, the list is an empty array [].

[1794641400, 600, 600, 600]

Decoded, that is 1794641400, 1794642000, 1794642600, and 1794643200 (2026-11-14 15:30–16:00 Taiwan time).

// [oldest, Δ, Δ, …] → [time, time, …] (Unix seconds, oldest first)
function decodeTimes(list) {
const out = [];
for (let i = 0; i < list.length; i++) {
out.push(i === 0 ? list[0] : out[i - 1] + list[i]);
}
return out;
}

The wind list is different. Each entry is an object { "t": valid time, "c": model cycle time }. See Wind.

  • Coordinates: XYZ (Web Mercator, EPSG:3857). y increases from north to south, the same as OpenStreetMap. This is the XYZ scheme, which differs from TMS.
  • Size and format: 256 × 256 WebP, with an alpha channel. Areas with no data are transparent.
  • Zoom range: A z outside the range still returns 200. The body is a 1 × 1 transparent GIF (Content-Type: image/gif). Set minzoom and maxzoom in the map library, and scale up the highest zoom level past the maximum. Do not request tiles beyond it.
  • File extension: A URL ending in .png still returns WebP. Always use .webp.

The bytes for a given tile URL are fixed, because the time and the model cycle are in the URL. Cache them on the client for a long time.

Item Response headers Notes
Time lists (radar, satellite, wind, QPESUMS) No Cache-Control New data shows up in the list within about 1 second. Request the list again when you need the latest time.
Radar tiles No Cache-Control. Has ETag Send If-None-Match to get 304.
Satellite tiles Cache-Control: public, max-age=300. Has ETag A 404 is kept for about 10 seconds.
Wind tiles and WND1 binaries Cache-Control: public, max-age=1800. Has ETag A 404 is kept for about 10 seconds.
QPESUMS tiles Not confirmed A 404 is kept for about 5 seconds.

List responses are gzip-compressed when the client supports it (Content-Encoding: gzip). A browser fetch decompresses them. With curl, add --compressed.

On a map, set each layer’s suggested attribution (see Data license on this page) on the source’s attribution. The map library draws it in a corner of the map. When you stack layers, set attribution on each one. The radar layer in the examples below does this. For where to put the credit, and for more examples, see Data license & attribution.

Two radar-reflectivity animations you can run as-is, one with MapLibre GL JS and one with Leaflet.

Load the latest 12 radar times, preload them, and show them in turn. The base map uses OpenStreetMap’s public tiles so the example runs on its own. For a production service, use your own base map or the ExpTech base map.

<!doctype html>
<html lang="zh-Hant-TW">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Radar reflectivity animation (MapLibre GL JS)</title>
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/maplibre-gl.css" />
<script src="https://unpkg.com/[email protected]/dist/maplibre-gl.js"></script>
<style>
html, body, #map { height: 100%; margin: 0; }
#time { position: absolute; top: 10px; left: 10px; padding: 4px 8px; background: #fff; font: 14px sans-serif; }
</style>
</head>
<body>
<div id="map"></div>
<div id="time"></div>
<script>
const API = "https://api.core-tnn1.exptech.dev";
const STATIC = "https://static.core-tnn1.exptech.dev";
const FRAMES = 12; // How many of the latest times the animation uses
const INTERVAL = 500; // Milliseconds each frame stays on screen
const OPACITY = 0.8;
function decodeTimes(list) {
const out = [];
for (let i = 0; i < list.length; i++) {
out.push(i === 0 ? list[0] : out[i - 1] + list[i]);
}
return out;
}
function formatTime(sec) {
return new Date(sec * 1000).toLocaleString("zh-TW", { timeZone: "Asia/Taipei", hour12: false });
}
const map = new maplibregl.Map({
container: "map",
center: [121, 23.7],
zoom: 6,
style: {
version: 8,
sources: {
osm: {
type: "raster",
tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
tileSize: 256,
maxzoom: 19,
attribution: "© OpenStreetMap contributors",
},
},
layers: [{ id: "osm", type: "raster", source: "osm" }],
},
});
async function addRadar() {
const res = await fetch(`${API}/api/v2/tiles/radar/list`);
if (!res.ok) throw new Error(`radar list: HTTP ${res.status}`);
const times = decodeTimes(await res.json()).slice(-FRAMES);
if (times.length === 0) return;
// One source and layer per time. Add them all first (opacity 0) so the tiles preload, then show them in turn.
times.forEach((t, i) => {
map.addSource(`radar-${t}`, {
type: "raster",
tiles: [`${STATIC}/api/v2/tiles/radar/${t}/{z}/{x}/{y}.webp`],
tileSize: 256,
minzoom: 3,
maxzoom: 12,
attribution: "Data: Central Weather Administration (CWA), ExpTech Studio (exptech.dev)",
});
map.addLayer({
id: `radar-${t}`,
type: "raster",
source: `radar-${t}`,
paint: {
"raster-opacity": i === times.length - 1 ? OPACITY : 0,
"raster-fade-duration": 0,
},
});
});
const label = document.getElementById("time");
let current = times.length - 1;
label.textContent = formatTime(times[current]);
setInterval(() => {
map.setPaintProperty(`radar-${times[current]}`, "raster-opacity", 0);
current = (current + 1) % times.length;
map.setPaintProperty(`radar-${times[current]}`, "raster-opacity", OPACITY);
label.textContent = formatTime(times[current]);
}, INTERVAL);
}
map.on("load", () => {
addRadar().catch((err) => console.error(err));
});
</script>
</body>
</html>

For the latest frame only, take times[times.length - 1] and add one source and one layer.

<!doctype html>
<html lang="zh-Hant-TW">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Radar reflectivity animation (Leaflet)</title>
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
<style>
html, body, #map { height: 100%; margin: 0; }
.radar-time { padding: 4px 8px; background: #fff; font: 14px sans-serif; }
</style>
</head>
<body>
<div id="map"></div>
<script>
const API = "https://api.core-tnn1.exptech.dev";
const STATIC = "https://static.core-tnn1.exptech.dev";
const FRAMES = 12;
const INTERVAL = 500;
const OPACITY = 0.8;
function decodeTimes(list) {
const out = [];
for (let i = 0; i < list.length; i++) {
out.push(i === 0 ? list[0] : out[i - 1] + list[i]);
}
return out;
}
function formatTime(sec) {
return new Date(sec * 1000).toLocaleString("zh-TW", { timeZone: "Asia/Taipei", hour12: false });
}
const map = L.map("map").setView([23.7, 121], 7);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: "&copy; OpenStreetMap contributors",
}).addTo(map);
const label = L.control({ position: "topright" });
label.onAdd = () => L.DomUtil.create("div", "radar-time");
label.addTo(map);
async function addRadar() {
const res = await fetch(`${API}/api/v2/tiles/radar/list`);
if (!res.ok) throw new Error(`radar list: HTTP ${res.status}`);
const times = decodeTimes(await res.json()).slice(-FRAMES);
if (times.length === 0) return;
// One layer per time. Add them all first (opacity 0) so the tiles preload.
const layers = times.map((t, i) =>
L.tileLayer(`${STATIC}/api/v2/tiles/radar/${t}/{z}/{x}/{y}.webp`, {
minZoom: 3,
maxNativeZoom: 12, // Above 12, scale up level-12 tiles
maxZoom: 19,
opacity: i === times.length - 1 ? OPACITY : 0,
attribution: "Data: Central Weather Administration (CWA), ExpTech Studio (exptech.dev)",
}).addTo(map),
);
let current = times.length - 1;
label.getContainer().textContent = formatTime(times[current]);
setInterval(() => {
layers[current].setOpacity(0);
current = (current + 1) % layers.length;
layers[current].setOpacity(OPACITY);
label.getContainer().textContent = formatTime(times[current]);
}, INTERVAL);
}
addRadar().catch((err) => console.error(err));
</script>
</body>
</html>
  • Request only times from the list. For a time that does not exist, radar tiles return 500 (the body is a single newline). Satellite and wind tiles return 404 JSON {"error":"Data not found for specified timestamp"}.
  • The list gains and drops times. A page left open should refresh the list on a schedule (for example every 5 minutes) and remove times that are no longer in it.
  • Timestamps are Unix seconds. Do not use milliseconds for {time} or {cycle} in the URL.
  • The radar list is also available on the tile host (https://static.core-tnn1.exptech.dev), but that copy is cached for about an hour and is a poor way to find the latest time. Use the list host.
  • The OpenStreetMap public tiles used in the examples have a usage policy. For a production service, switch to another base map.