Earth rotates 360° in 24 hours, so 15° of longitude equals 1 hour of solar time. If timezones were geographic, the world map would be 24 clean vertical stripes. It isn't. China spans 5 longitude-natural timezones but runs one (
Asia/Shanghai= +08:00). India uses a half-hour offset (+05:30). Nepal uses a 15-minute offset (+05:45). Chatham Islands NZ uses +12:45. Madrid is geographically on the meridian of London but is on Central European Time. I built a world timezone visualizer in 500 lines of vanilla JavaScript that pins ~100 cities across all populated offsets and highlights the political ones. The implementation hinges onIntl.DateTimeFormatwithtimeZoneName: "longOffset"and a small algorithm to classify how far each IANA zone deviates from its longitude-natural offset.
🌐 Demo: https://sen.ltd/portfolio/tz-world-map/
📦 GitHub: https://github.com/sen-ltd/tz-world-map
Time zones are political, not geographic
A "natural" world map would have 24 vertical bands. The actual IANA timezone map is uglier:
-
China: administratively a single zone (
Asia/Shanghai= +08:00) across what should be 5 timezones. Ürümqi at 87.6°E is pinned to +08:00 despite a longitude-natural offset of ~+05:50 — about 130 minutes off. -
India:
Asia/Kolkata= +05:30, set at partition in 1947. -
Nepal:
Asia/Kathmandu= +05:45 — a 15-minute offset, advanced from +05:30 in 1986 specifically to differentiate from India. -
Chatham Islands NZ:
Pacific/Chatham= +12:45 standard / +13:45 DST — the world's most extreme 45-minute offset. -
Newfoundland:
America/St_Johns= -03:30 standard, different from mainland Canada. - Iran / Afghanistan / Myanmar: all half-hour zones (+03:30, +04:30, +06:30).
- Madrid: geographically near London's meridian but uses CET (+01:00) — Franco's Spain aligned with Nazi Germany in 1940 and never moved back.
- DST: phased out in many countries (Russia 2014, Mexico 2022) and partially restored in others (Egypt 2023).
The visualization makes all of this legible.
The core API: Intl.DateTimeFormat's longOffset
Browser-standard Intl.DateTimeFormat with timeZoneName: "longOffset" returns the current UTC offset for any IANA zone as a string like "GMT+09:00". DST is handled automatically. Moment and date-fns-tz are unnecessary.
export function getOffsetMinutes(zone, date = new Date()) {
const dtf = new Intl.DateTimeFormat("en-US", {
timeZone: zone,
timeZoneName: "longOffset",
});
const parts = dtf.formatToParts(date);
const name = parts.find((p) => p.type === "timeZoneName")?.value || "GMT";
return parseGmtOffset(name);
}
export function parseGmtOffset(name) {
if (name === "GMT" || name === "UTC") return 0;
const m = /^GMT([+-])(\d{1,2})(?::(\d{2}))?$/.exec(name);
if (!m) throw new Error(`unparseable: ${name}`);
const sign = m[1] === "+" ? 1 : -1;
return sign * (parseInt(m[2]) * 60 + (m[3] ? parseInt(m[3]) : 0));
}
Examples:
getOffsetMinutes("Asia/Tokyo") // → 540 (+09:00)
getOffsetMinutes("Asia/Kolkata") // → 330 (+05:30)
getOffsetMinutes("Asia/Kathmandu") // → 345 (+05:45)
getOffsetMinutes("Pacific/Chatham") // → 765 (+12:45) or 825 (+13:45 NZDT)
The full IANA list is available via Intl.supportedValuesOf("timeZone") — every evergreen browser returns 400+ zones.
DST detection: compare winter to summer
Detecting whether a zone is currently observing DST is:
export function isDST(zone, date = new Date()) {
const year = date.getUTCFullYear();
const jan = new Date(Date.UTC(year, 0, 15, 12, 0, 0));
const jul = new Date(Date.UTC(year, 6, 15, 12, 0, 0));
const now = getOffsetMinutes(zone, date);
const winter = getOffsetMinutes(zone, jan);
const summer = getOffsetMinutes(zone, jul);
// Zones without DST: winter == summer. Always not-DST.
if (winter === summer) return false;
// "Standard time" = the SMALLER (more west) of the two offsets.
// Northern hemisphere: standard = winter = jan. Southern: standard = our "summer" = jul.
// Using min() makes this hemisphere-agnostic.
const std = Math.min(winter, summer);
return now !== std;
}
The clever bit: Math.min(winter, summer) sidesteps hemisphere logic entirely. If you naively check "is it summer in this date's month?" you'd get Southern Hemisphere zones wrong (Sydney's DST is JANUARY, not July). The "smaller offset is standard" property holds both hemispheres.
Verified:
// Northern
test("Europe/London winter = +00:00", () =>
assert.equal(getOffsetMinutes("Europe/London", JAN15), 0));
test("Europe/London summer = +01:00", () =>
assert.equal(getOffsetMinutes("Europe/London", JUL15), 60));
// Southern
test("Sydney winter (JUL) = +10:00", () =>
assert.equal(getOffsetMinutes("Australia/Sydney", JUL15), 600));
test("Sydney summer (JAN) = +11:00 (AEDT)", () =>
assert.equal(getOffsetMinutes("Australia/Sydney", JAN15), 660));
Quantifying the political skew
Given a city's (lat, lon) and its IANA offset, you can compute the longitude-natural offset and the deviation:
export function longitudeOffsetHours(lon) {
return lon / 15; // 15° = 1 hour
}
export function classifyOffsetDelta(actualMin, lon) {
const naturalMin = longitudeOffsetHours(lon) * 60;
const delta = Math.abs(actualMin - naturalMin);
if (delta < 30) return "aligned"; // <30 min
if (delta < 90) return "skewed"; // 30-90 min
return "very-skewed"; // ≥90 min
}
Spot-checks:
| City | Lon | Natural | IANA | Skew |
|---|---|---|---|---|
| Tokyo | 139.7° | +09:19 | +09:00 | -19 min (aligned) |
| Manila | 121.0° | +08:04 | +08:00 | -4 min (aligned) |
| Ürümqi | 87.6° | +05:50 | +08:00 | +130 min (very-skewed) |
| Madrid | -3.7° | -00:15 | +01:00 | +75 min (skewed) |
| Reykjavík | -21.9° | -01:28 | +00:00 | +88 min (skewed) |
Ürümqi is the loudest case: China's single-timezone policy clearly stretches it.
The 100-city table
The data source is hand-curated. Rules:
- One city per major IANA zone family
- All half-hour and quarter-hour zones get a representative city
- Politically displaced cities tagged
outlierfor red highlighting
export const CITIES = [
// UTC +05:30
{ name: "Delhi", country: "India", zone: "Asia/Kolkata", lat: 28.6, lon: 77.2, kind: "outlier" },
// UTC +05:45
{ name: "Kathmandu", country: "Nepal", zone: "Asia/Kathmandu", lat: 27.7, lon: 85.3, kind: "outlier" },
// UTC +08:00 (China single TZ)
{ name: "Beijing", country: "China", zone: "Asia/Shanghai", lat: 39.9, lon: 116.4, kind: "capital" },
{ name: "Ürümqi", country: "China", zone: "Asia/Shanghai", lat: 43.8, lon: 87.6, kind: "outlier" },
// UTC +12:45
{ name: "Chatham Islands", country: "NZ", zone: "Pacific/Chatham", lat: -43.9, lon: -176.5, kind: "outlier" },
// ...
];
SVG map: equirectangular
Equirectangular projection — the simplest, just (lat, lon) → (y, x):
export function project(lat, lon, width, height) {
const x = ((lon + 180) / 360) * width;
const y = ((90 - lat) / 180) * height;
return { x, y };
}
Why not Mercator: Mercator makes Greenland look as big as Africa. Equirectangular's constant longitude scale is exactly what we want for "which 15° band is this city in?"
15° meridian lines are drawn every hour, labeled -12h ... +12h:
for (let h = -12; h <= 12; h++) {
const lon = h * 15;
const x = ((lon + 180) / 360) * width;
drawVerticalLine(x);
}
Now you can eyeball: "Manila is at the +8h line, exactly where it should be. Ürümqi is at the +6h line but the same +8 zone — that's the China problem."
Continent silhouettes by hand
Natural Earth GeoJSON is ~50 KB minimum. For this pedagogical visualization, eight hand-drawn SVG paths suffice:
const CONTINENT_PATHS = [
"M40,75 L100,60 L140,55 ... Z", // North America
"M260,275 L300,260 L330,275 ... Z", // South America
// Europe, Africa, Asia, Australia, Antarctica
];
Each continent traced as a low-res polygon. Total cost: 8 path strings × ~30 points = ~2 KB. The shapes are recognizable as continents; that's enough.
Tests: 53
core.js and projection.js are DOM-free, so all 53 tests run under Node's node:test:
-
parseGmtOffsetforGMT,GMT+09:00,GMT-03:30, malformed input -
getOffsetMinutesfor fixed-offset zones (no DST) — Tokyo, Kolkata, Kathmandu, Shanghai, Chatham - DST behaviour in both hemispheres
-
classifyOffsetDeltafor Tokyo (aligned), Madrid (skewed), Ürümqi (very-skewed), Manila (aligned) -
Intl.supportedValuesOfreturning >100 sorted zones -
project / unprojectroundtrip for null island, Tokyo, NYC, Sydney, both poles, both date-line edges -
hourMeridiansreturning exactly 25 lines at 15° intervals
Architecture
core.js ← Intl.DateTimeFormat ("longOffset"), DST detection, offset math (DOM-free)
cities.js ← ~100 curated cities catalog
projection.js ← equirectangular project / unproject, hour meridians (DOM-free)
app.js ← SVG render, live table update, 30s tick
core.js and projection.js take Date as an argument (not via Date.now()), so tests can pin behaviour for arbitrary instants.
Try it
Hunt for the red dots: Ürümqi (87°E but +08), the two India cities (+05:30), Kathmandu (+05:45), Madrid (politically EU), St. John's (-03:30 standard), Chatham Islands (+12:45). All history, not geography.
Takeaways
-
Intl.DateTimeFormat({ timeZone, timeZoneName: "longOffset" })gives you exact, DST-aware UTC offsets for any IANA zone. No date library needed. -
Intl.supportedValuesOf("timeZone")enumerates all 400+ IANA zones. - DST detection is hemisphere-agnostic if you compare winter to summer and declare "smaller offset = standard time."
-
Quantifying skew (
actualOffset - longitude*60) surfaces the politically-displaced zones: China, Spain, Newfoundland, Chatham. - 8 hand-traced SVG paths beat a 50KB GeoJSON for a pedagogical world map.
- DOM-free core + projection modules let Node test 53 properties without a browser.
This is OSS portfolio #257 from SEN LLC (Tokyo). https://sen.ltd/portfolio/

Top comments (0)