DEV Community

SEN LLC
SEN LLC

Posted on

A World Timezone Map in 500 Lines — and Why Time Zones Aren't Determined by Longitude

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 on Intl.DateTimeFormat with timeZoneName: "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

Screenshot

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));
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 outlier for 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" },
  // ...
];
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
];
Enter fullscreen mode Exit fullscreen mode

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:

  • parseGmtOffset for GMT, GMT+09:00, GMT-03:30, malformed input
  • getOffsetMinutes for fixed-offset zones (no DST) — Tokyo, Kolkata, Kathmandu, Shanghai, Chatham
  • DST behaviour in both hemispheres
  • classifyOffsetDelta for Tokyo (aligned), Madrid (skewed), Ürümqi (very-skewed), Manila (aligned)
  • Intl.supportedValuesOf returning >100 sorted zones
  • project / unproject roundtrip for null island, Tokyo, NYC, Sydney, both poles, both date-line edges
  • hourMeridians returning 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
Enter fullscreen mode Exit fullscreen mode

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)