1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

世界タイムゾーンマップを 500 行で作る — なぜ timezone は経度では決まらないのか (中国・インド・ネパールの 15 分単位オフセット)

1
Posted at

「ロンドン何時?ニューヨーク何時?」を瞬時に出すアプリは無数にある。でも、タイムゾーンが「政治」であって「経度」ではないことを可視化したツールは意外と少ない。地球は 24 時間で 360° 回るから、本来 15° ごとに 1 時間ずつズレるはず。でも実際の地図は: 中国全土が単一タイムゾーン (本来 5 つに分けるべき範囲)、インドが +05:30 の半端オフセット、ネパールが +05:45 という 15 分単位、マドリードが地理的にロンドンと同じ経度なのに +01:00 — 全部「歴史と政治の産物」。500 行で世界タイムゾーン可視化マップを作った。実装の hinge は Intl.DateTimeFormattimeZoneName: "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:4515 分単位のオフセット。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.DateTimeFormatlongOffset

ブラウザ標準の Intl.DateTimeFormattimeZoneName: "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.jsprojection.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/

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?