「ロンドン何時?ニューヨーク何時?」を瞬時に出すアプリは無数にある。でも、タイムゾーンが「政治」であって「経度」ではないことを可視化したツールは意外と少ない。地球は 24 時間で 360° 回るから、本来 15° ごとに 1 時間ずつズレるはず。でも実際の地図は: 中国全土が単一タイムゾーン (本来 5 つに分けるべき範囲)、インドが +05:30 の半端オフセット、ネパールが +05:45 という 15 分単位、マドリードが地理的にロンドンと同じ経度なのに +01:00 — 全部「歴史と政治の産物」。500 行で世界タイムゾーン可視化マップを作った。実装の hinge は
Intl.DateTimeFormatのtimeZoneName: "longOffset"と 「経度ベースの自然オフセット」との差を可視化する仕組み。
🌐 デモ: https://sen.ltd/portfolio/tz-world-map/
📦 GitHub: https://github.com/sen-ltd/tz-world-map
タイムゾーンは経度で決まらない、という前提
地球は 24 時間で 360° 回るので、15° の経度 = 1 時間の時差。経度ベースで自然に区切れば、世界は綺麗な 24 本の縦縞になる。実際の IANA タイムゾーンマップはそうなっていない。
代表的な「ズレ」:
-
中国: 国土の東西で本来 4-5 タイムゾーン跨ぐ範囲を、
Asia/Shanghai = +08:00の単一ゾーンで運用。ウルムチ (新疆) は東経 87.6°、自然オフセットだと +05:50 ぐらいだが、北京と同じ +8。約 130 分のズレ。 -
インド:
Asia/Kolkata = +05:30の半端 30 分オフセット。1947 年の独立時に英印で時間管理を統一した結果。 -
ネパール:
Asia/Kathmandu = +05:45。15 分単位のオフセット。1986 年に「インドとは違う時刻を持ちたい」という政治的理由で +05:30 から 15 分前進。 -
ニューファンドランド:
America/St_Johns = -03:30標準時。本土カナダ (Halifax = -04:00) と違う 30 分オフセット。 -
チャタム諸島 (NZ):
Pacific/Chatham = +12:45という地球で最も極端な 45 分オフセット。 - イラン / アフガニスタン / ミャンマー: いずれも 30 分オフセット (+03:30, +04:30, +06:30)。
- マドリード: 地理的にはロンドンと同じ経度 (0°近辺) なのに CET (+01:00)。第二次大戦中の 1940 年にフランコ政権がナチスドイツに時刻を合わせて以来そのまま。
- DST (サマータイム): 国ごとにバラバラで廃止傾向。ロシア 2014 年廃止、メキシコ 2022 年廃止、エジプト 2023 年復活。
これを可視化する。
中核: Intl.DateTimeFormat の longOffset
ブラウザ標準の Intl.DateTimeFormat に timeZoneName: "longOffset" を渡すと、IANA ゾーンの現在の UTC オフセットを "GMT+09:00" のような文字列で取れる。DST も自動考慮。もう Moment や date-fns-tz は要らない。
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));
}
実例:
getOffsetMinutes("Asia/Tokyo") // → 540 (+09:00)
getOffsetMinutes("Asia/Kolkata") // → 330 (+05:30)
getOffsetMinutes("Asia/Kathmandu") // → 345 (+05:45)
getOffsetMinutes("Pacific/Chatham") // → 765 (+12:45 NZ summer) or 825 (+13:45 NZDT)
そして全 IANA ゾーンのリストは Intl.supportedValuesOf("timeZone") で取れる。Chrome / Firefox / Safari すべてで 400+ ゾーンが返る。
export function listAllZones() {
if (typeof Intl.supportedValuesOf === "function") {
return [...Intl.supportedValuesOf("timeZone")].sort();
}
return [];
}
DST の判定: 冬と夏の比較
DST を観測してるかどうかは、こう判定する: 同年 1 月のオフセットと 7 月のオフセットを比べて、現在の値がそのどちらかと一致するか。
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);
// DST 観測なし: winter == summer。常に not DST。
if (winter === summer) return false;
// 標準時 = 「より小さい (より西の) オフセット」
// 北半球: standard = winter = jan の値。南半球: standard = our "summer" = jul の値。
const std = Math.min(winter, summer);
return now !== std;
}
Math.min(winter, summer) でひっかかった点: 北半球と南半球で「冬」が逆だから、Date の月だけ見て分岐するとバグる。**「より小さいオフセットが標準時 (DST じゃない側)」**という性質を使えば半球を意識せず判定できる。
検証:
// 北半球の例
test("Europe/London winter = +00:00 (GMT)", () =>
assert.equal(getOffsetMinutes("Europe/London", JAN15), 0));
test("Europe/London summer = +01:00 (BST)", () =>
assert.equal(getOffsetMinutes("Europe/London", JUL15), 60));
// 南半球の例
test("Australia/Sydney winter = +10:00 (no DST in JUL — Southern winter)", () =>
assert.equal(getOffsetMinutes("Australia/Sydney", JUL15), 600));
test("Australia/Sydney summer (= our January) = +11:00 (AEDT)", () =>
assert.equal(getOffsetMinutes("Australia/Sydney", JAN15), 660));
「経度で決まらない」の可視化
各都市の (lat, lon) と IANA オフセット が両方分かれば、「経度ベースの自然オフセット」との差を計算できる。
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 分未満
if (delta < 90) return "skewed"; // 30-90 分
return "very-skewed"; // 90 分以上
}
これでテーブルに「Skew vs. longitude」列を出せる:
| 都市 | 経度 | 自然 | 実 IANA | ズレ | 判定 |
|---|---|---|---|---|---|
| 東京 | 139.7° | +09:19 | +09:00 | -19 分 | aligned |
| マニラ | 121.0° | +08:04 | +08:00 | -4 分 | aligned |
| ウルムチ | 87.6° | +05:50 | +08:00 | +130 分 | very-skewed |
| マドリード | -3.7° | -00:15 | +01:00 | +75 分 | skewed |
| レイキャビク | -21.9° | -01:28 | +00:00 | +88 分 | skewed |
ウルムチが圧倒的にズレている。中国の単一タイムゾーン政策が地理的にいかに無理しているかが数字で見える。
100 都市のテーブル
データソースは自前。「全 IANA ゾーンに 1 つ以上」「半端オフセットの代表都市は全部入れる」「政治的にズレてる都市は outlier タグで色分け」というルールで約 100 件をハードコード:
export const CITIES = [
// UTC +05:30 (India / Sri Lanka — half-hour offset)
{ name: "Delhi", country: "India", zone: "Asia/Kolkata", lat: 28.6, lon: 77.2, kind: "outlier" },
{ name: "Mumbai", country: "India", zone: "Asia/Kolkata", lat: 19.1, lon: 72.9, kind: "outlier" },
// UTC +05:45 (Nepal — 15-minute offset!)
{ 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" }, // 圧倒的にズレてる
// ...
];
kind で色分け: capital 青、major 緑、outlier 赤 (経度から大きく離れた都市)。マップ上で赤いドットを見ると「ここは政治的にゴチャゴチャやってる地域だ」が一目で分かる。
SVG マップ (equirectangular)
世界地図投影は等距円筒図法 (equirectangular) を採用:
export function project(lat, lon, width, height) {
const x = ((lon + 180) / 360) * width;
const y = ((90 - lat) / 180) * height;
return { x, y };
}
なぜメルカトル図法じゃないか: メルカトルだとグリーンランドがアフリカと同じサイズに見える。**ここでの教育目的は「ここの dot がどの経度バンドにあるか」**なので、緯度方向の歪みは犠牲にして equirectangular で十分。
それから 15° ごとの経度メリディアン線を引く:
export function hourMeridians(width) {
const lines = [];
for (let h = -12; h <= 12; h++) {
const lon = h * 15;
const x = ((lon + 180) / 360) * width;
lines.push({ hour: h, lon, x });
}
return lines;
}
各ライン上に -12h, -9h, ..., +9h, +12h のラベルを出すことで、「マニラ (121°E) は +8h ラインの少し右にある」「ウルムチ (87°E) は +6h ライン付近なのに同じ +8 ゾーン」が視覚的に確認できる。
大陸シルエットは手書きで十分
世界地図の輪郭は GeoJSON / TopoJSON を使うのが王道だが、Natural Earth ですら 50KB はある。可視化の目的としてはそこまで精度要らないので、手書きの低解像度 SVG パスで十分:
const CONTINENT_PATHS = [
// North America (incl. Alaska + Greenland)
"M40,75 L100,60 L140,55 L180,65 ... Z",
// South America
"M260,275 L300,260 L330,275 ... Z",
// Europe, Africa, Asia, Australia, Antarctica strip
// ...
];
合計 8 パスで「これが世界地図だ」と分かるレベルになる。fill: #1c2d3f の暗いトーンで陸を塗ると、青系の海とコントラストが付いて見やすい。
テスト 53 個
core.js と projection.js は DOM 非依存なので Node の node:test で全部走る:
-
parseGmtOffsetの正常系・異常系 (8 ケース) -
getOffsetLabel出力フォーマット (6 ケース) - 固定オフセット zone の検証 (Tokyo, Kolkata, Kathmandu, Chatham, Shanghai)
- DST の北半球・南半球両方の挙動
-
classifyOffsetDeltaの境界 (Tokyo aligned, Madrid skewed, Ürümqi very-skewed) -
Intl.supportedValuesOfの存在と sorted check -
project / unprojectのラウンドトリップ -
hourMeridiansの 25 本がちょうど 15° 間隔
test("Asia/Kathmandu = +05:45 = 345 (Nepal)", () =>
assert.equal(getOffsetMinutes("Asia/Kathmandu", WINTER), 345));
test("Ürümqi very-skewed (China single timezone)", () =>
assert.equal(classifyOffsetDelta(480, 87.6), "very-skewed"));
test("(0, 180) → right edge", () => {
const { x } = project(0, 180, 1000, 500);
assert.equal(x, 1000);
});
設計
core.js ← Intl.DateTimeFormat ("longOffset"), DST 判定, オフセット数学 (DOM-free)
cities.js ← 100 都市カタログ ({zone, lat, lon, kind})
projection.js ← equirectangular project/unproject, hour meridians (DOM-free)
app.js ← SVG マップ + テーブル, 30 秒ごとに再描画
Date.now() を直接使わず new Date() を関数引数で受ける構造にしたので、特定日のオフセットを Node でテストできる。
試してみる
赤いドットを探してみてほしい:
- ウルムチ (China, 87°E なのに +8)
- インドの 2 都市 (+05:30)
- カトマンズ (+05:45)
- マドリード (政治的に +01:00)
- セント・ジョンズ (-03:30 標準時)
- チャタム諸島 (+12:45 ← 一番ぶっ飛んでる)
全部「地理ではなく歴史」の産物。
まとめ
-
Intl.DateTimeFormat({ timeZone, timeZoneName: "longOffset" })で DST 含めた正確な UTC オフセットがブラウザ標準で取れる。Moment は要らない。 -
Intl.supportedValuesOf("timeZone")で全 IANA ゾーン (400+) のリストが取れる。 - DST 判定は「冬と夏でオフセットが違うか」+「より小さいオフセットが標準時」で半球非依存に書ける。
- 経度ベースの「自然オフセット」と IANA オフセットの差を出すと、政治的なズレ (中国・スペイン・ニューファンドランド) が定量化できる。
- 世界地図の輪郭は 8 パスの手書き SVG で「これは世界地図」と認識できる。
- DOM フリーな計算層と SVG レンダリング層を分けると、計算側が Node で 53 個テストできる。
これは SEN 合同会社の OSS ポートフォリオ #257 です。https://sen.ltd/portfolio/
