前回の記事「71歳の私が、AIと協働してFlutterアプリを3週間で作り上げて確信したこと」では、AI時代の「逆ピラミッド学習」についてお話ししました。
今回は、「世界時計」アプリを開発し、そこにタイムゾーンマップ機能を追加した経験から、ある確信に至りました。
AI時代に開発の仕方は大きく変わりましたが、本質的には30年前の発注者の仕事と変わっていない、ということです。
30年前、私がやっていた仕事
私がソフトウェア開発に本格的に関わったのは、県職員として財務会計システム開発のプロジェクトリーダーを務めたときでした。30年以上前のことです。
発注者側なので、コードを書く必要はありませんでした。仕様書の作成も業者に委託していたので、仕様書を書く必要もありませんでした。
私の狭義のソフトウェア開発に関する仕事は
- やりたいことを主張する
- 業者を説得する
- 出てきたものを評価する
- 修正を求める
これだけでした。当然のことですが、ソフトウェア開発には、プログラムを開発する以外の仕事が多いので楽な仕事ではありませんでした。システムの導入によって事務の流れをどう変えるか、関係部署とも協議して決めなければいけないし、それをマニュアル化し研修もする必要があります。
今、私がやっている仕事
「世界時計」アプリにタイムゾーンマップを追加する作業を進めています。
私の仕事は:
- やりたいことをAIに主張する
- AIに技術的な詳細を説明してもらう
- それをもとに基本的な仕様を作成する
- AIに詳細な仕様を作ってもらう
- それを使ってアプリを作ってもらう
- 出てきたものを評価する
- 修正を求める
30年前と本質的には何も変わっていません。
違うのは、相手が業者からAIに変わったことだけ。
業者よりも、知識は豊富だし、よく働くし、1分で実装してしまいます。
開発環境:月額$20で本格開発
| 用途 | ツール | コスト |
|---|---|---|
| 設計・判断 | Claude Pro(Opus 4.5) | 月額$20 |
| 実装 | Google Antigravity(Claude Sonnet 4.5) | 無料 |
| フレームワーク | Flutter | 無料 |
Google Antigravityは、2025年11月に公開されたVS CodeベースのIDEで、Claude Sonnet 4.5が無料で使えます。
この組み合わせで、月額$20だけで本格的な開発ができています。
自分の場合は、コードを書くのであれば、ChatGPTやGeminiよりも、Claudeが適していると感じています。
ChatGPTは、優秀でも余計なことをしてくれるし、Geminiは、記憶量が多いのは長所ですが、個人開発ではそれが邪魔をします。一番素直に相手をしてくれるのがClaudeです。Claudeは、最新バージョンでコードを書く時に、そのDocumentを指定するとそれに沿ったコードを書いてくれるのがいいです。
AIに主張し、説得する
タイムゾーンマップの開発で、私がAIにぶつけた問題の一部を紹介します。
私: 「世界地図にタイムゾーンを色分けして表示したい。データはありますか。」
Opus:
主要なデータソースとしては以下があります:
1. Natural Earth(推奨)
無料で商用利用可能な地理データセット。タイムゾーンのShapefileやGeoJSONが入手できます。
2. Timezone Boundary Builder
OpenStreetMapベースの精密なタイムゾーン境界データ。GeoJSON形式で配布されています。
3. 簡易的なアプローチ
精密な境界が不要なら、経度15度ごとに区切る方法もあります(UTC±0を基準に、15度東へ行くごとに+1時間)。ただし実際のタイムゾーンは政治的境界に沿うので、あくまで概念的な表示になります。
私: 「正確に表示したいのであれば、どれがいいですか?」
Opus:
正確さを求めるなら Timezone Boundary Builder が最適です。
理由:
- OpenStreetMapの最新データを基に定期的に更新されている
- 実際の政治的境界に沿った精密なポリゴンデータ
- 夏時間の境界変更なども反映される
- GeoJSON形式でそのまま使いやすい
データの種類(リリースページから選択):
| ファイル | 内容 |
|---|---|
timezones.geojson |
海洋部分を除いた陸地のみ |
timezones-with-oceans.geojson |
海洋も含む完全版 |
世界地図全体を色分けするなら timezones-with-oceans.geojson ですね。
注意点:
- ファイルサイズが大きい(完全版で約100MB以上)
- Flutterアプリに組み込むなら、簡略化(simplify)するか、タイル化を検討する必要がある
実際にダウンロードして中身を確認してみましょうか?サイズ感や構造を把握してから、flutter_mapでどう扱うか検討できます。
私: 「正確な境界が表示したい。日本のスマホ環境で100MBのデータを添付するのは問題がありますか?」
Opus:
日本のスマホ環境であれば、100MBは許容範囲だと思います。
日本の状況:
- 高速なWi-Fi環境が普及している
- モバイル回線も4G/5Gで高速
- 端末スペックも比較的高い(ストレージ128GB以上が主流)
- ユーザーも大きめのアプリに慣れている(ゲームアプリは数GBが普通)
ただし考慮すべき点:
- App Store / Google Playでのダウンロードサイズ表示で「100MB超」は少し目立つ
- 初回起動時のGeoJSONパース処理に時間がかかる可能性
- メモリ使用量が大きくなる
実用的な対策:
-
アセットを圧縮して同梱
- gzip圧縮すれば20〜30MB程度になる
- 初回起動時に展開
-
初回起動時に非同期でロード
- スプラッシュ画面やローディング表示でユーザーを待たせる
- パース完了後にキャッシュしておく
-
バイナリ形式に変換
- GeoJSONよりProtobufやFlatBuffersの方がパースが速い
まずは簡略化せずにそのまま組み込んでみて、実機で動作確認するのが早いかもしれません。問題があれば、その時点でmapshaperで間引くなり対策を考えましょうか。
私: 「それを仕様書にまとめて」
「仕様書」ができれば、AntigravityのSonnetに投げます。実装で大きな問題があればこの繰り返しです。
30年前、業者との打ち合わせでやっていたことと同じです。むしろ、Opusの方が優しいし優秀です。
現時点での仕様書を参考として、末尾に添付しておきます。
30年以上前とそんなに変わっていないと思います。
30年前と違うこと
30年前と本質は同じですが、決定的に違うことがあります。
仕様変更のコストがゼロ。
30年前、仕様書に不備があると大変でした。
- 「これは当初の仕様と違います」
- 「追加費用が発生します」
- 「スケジュールを再調整させてください」
AIなら、「あ、ここ違った。こう直して」で済みます。
数秒後には修正版のコードが出てきます。
仕様と実装を行き来できる
従来の発注では、仕様を確定してから発注する必要がありました。
でも、仕様を書く時点では、実装方法がわからないことも多い。
AIなら、試しに作ってもらって、動かしてみて、それから仕様を固められます。
この「行き来」ができることが、AI開発の本質的な革命だと思います。
まとめ:発注者の時代
AI時代は、全員が発注者になれる時代です。
30年前、発注者になるには組織の後ろ盾が必要でした。予算があり、業者を雇える立場にいなければ、発注者にはなれませんでした。
今は違います。月額$20で、誰でも発注者になれます。
必要なのは、
- やりたいことを主張する力
- AIを説得する力
- 出てきたものを評価する力
- 修正を求める力
でも、これさえも最初から必要なわけではありません。やりながら身につきます。
数字で見るAI開発者の時代
Anthropicの売上推移:
- 2024年:約10億ドル
- 2025年見通し:約90億ドル
- 2026年見通し:200〜260億ドル
Claudeは、年200〜1000ドルで、普通のソフトウェア開発者1人分の仕事をしてくれます。
世界のソフトウェア開発者は約3000万人。
この数字が意味するのは、来年には、人間の開発者と同じくらいのAI開発者が存在するようになるということです。
誰でも発注者になれる。でも、開発者の仕事は減る。
これが現実です。
「コードを書ける」だけでは、AIに仕事を奪われます。
テスター募集中!
現在、このアプリはApp Store / Google Play Storeでテスト公開中です。
AIと作った「世界時計アプリ」を、ぜひ触ってみてください。
📱 iOS(TestFlight)
https://testflight.apple.com/join/t1dWPyGc
🤖 Android(クローズドテスト)
Google Playのクローズドテストでは、正式公開前に12人以上のテスターが14日間連続でテストをする必要があります。個人開発者にとって、この「12人の壁」は意外と高いハードルです。
数分触っていただくだけでも構いません。ご協力いただける方は、ぜひテストにご参加ください。
① Googleグループに参加
https://groups.google.com/g/timej
② Play Storeからインストール
https://play.google.com/store/apps/details?id=net.timej.worldtime
プロジェクトリンク
参考資料:現時点でのタイムゾーンマップ仕様書(全文)
概要
世界地図上にタイムゾーンを色分けして表示し、ユーザーが視覚的に時差を理解できる機能。
お気に入り表示や、地点追加としても活用。
データ構造
タイムゾーンGeoJSONプロパティ
各タイムゾーンポリゴンは以下のプロパティを持つ:
| プロパティ | 型 | 説明 | 例 |
|---|---|---|---|
tzid |
string | タイムゾーンID | Asia/Tokyo |
offset |
int | 標準時UTCオフセット(分) |
540 (UTC+9) |
color_index |
int | 色インデックス(0-7) | 1 |
has_dst |
int | DST有無(0 or 1) | 0 |
order |
int | 表示順序 | 0 |
標準時オフセットの計算
# 現在の状態を取得
dt_now = tz.localize(now)
current_offset = dt_now.utcoffset()
current_dst = bool(dt_now.dst())
# 1年以内の次回DST変更を検出
# 標準時 = DSTでない方のオフセット
if current_dst:
standard_offset = next_offset # 次の変更後が標準時
else:
standard_offset = current_offset # 現在が標準時
色スキーム
4+4色スキーム (整数時間4色 + 30分単位4色):
| 色インデックス | 用途 | 例 |
|---|---|---|
| 0-3 | 整数時間 | UTC+0, UTC+3, UTC+6, UTC+9 |
| 4-7 | 30分単位 | UTC+5:30, UTC+9:30 |
計算式:
def get_color_index(offset_minutes):
offset_hours = offset_minutes / 60.0
is_half_hour = (offset_minutes % 60) != 0
if is_half_hour:
base_hour = int(offset_hours)
normalized = base_hour + 12
return 4 + (normalized % 4)
else:
normalized = round(offset_hours) + 12
return normalized % 4
レイヤー構造
表示順序(下から上)
- 背景色 (海の色)
-
海のタイムゾーンポリゴン (
is_ocean=1)- 不透明度: 1.0
- 境界線: 太さ1.5px、不透明度0.9
-
陸のタイムゾーンポリゴン (
is_ocean=0)- 不透明度: 1.0
- 境界線: 太さ1.5px、不透明度0.9
-
海岸線・国境線オーバーレイ
- 透明ポリゴン + グレーの境界線
- 太さ: 1.0px
- マーカー (お気に入り地点)
理由
- 海のポリゴンを下に配置することで、島が正しく表示される
- 島の場合、海と陸のタイムゾーンが重複しているため、陸を上に表示
タイムゾーンポリゴンの描画順序(重要・要調査)
[!IMPORTANT]
タイムゾーンデータには重複するポリゴンが存在します。特に以下のケース:
- 飛び地(Enclave): サンマリノ、バチカン、レソトなど、他国に完全に囲まれた小国
- 複雑な国境地域: インド/バングラデシュ、ベルギー/オランダなど
- 都市レベルのタイムゾーン: 大きなタイムゾーン内の小さな例外地域
描画順序が正しくないと、小さいタイムゾーンが大きいタイムゾーンに隠れてしまいます。
解決策の候補
-
面積ベースのソート
- ポリゴンの面積を計算
- 大きい順に描画(大→小)
- 小さいポリゴンが最後に描画され、上に表示される
-
階層的なレイヤー分け
- 大陸レベル(最下層)
- 国レベル(中間層)
- 都市/飛び地レベル(最上層)
-
Z-indexプロパティの追加
- データ生成時に優先度を計算
- GeoJSONに
z_indexプロパティを追加
データ調査が必要
- タイムゾーンデータの重複パターンを分析
- 面積計算アルゴリズムの実装
- 最適な描画順序の決定
- テストケースの作成(サンマリノ、バチカンなど)
UI要件
地図下部のUTCラベル表示
表示位置: 地図エリアの一番下
表示内容: 全タイムゾーンのUTCオフセット
表示例:
UTC-12 UTC-11 UTC-10 UTC-09 UTC-08 UTC-07 UTC-06 UTC-05
UTC-04 UTC-03 UTC-02 UTC-01 UTC+00 UTC+01 UTC+02 UTC+03
UTC+04 UTC+05 UTC+06 UTC+07 UTC+08 UTC+09 UTC+10 UTC+11
UTC+12 UTC+13 UTC+14
実装:
- 横スクロール可能
- 各ラベルは対応する色で表示
- フォントサイズ: 小さめ
タップ時のポップアップ
トリガー: ユーザーが地図上のタイムゾーンをタップ
表示内容:
UTC+09
Asia/Tokyo
実装:
- タップ位置の近くに表示
- 半透明の背景
- 自動的に数秒後に消える、または再タップで消える
凡例の削除
従来の凡例ウィジェット(TimezoneLegendWidget)は削除し、地図下部のUTCラベルで代替。
データ生成
必要なデータ
-
タイムゾーンGeoJSON (複数解像度)
- 超簡素化110m (ズーム0-1用)
- 標準110m (ズーム2-5用)
- 50m (ズーム6-7用)
- 10m (ズーム8用)
-
海岸線・国境GeoJSON (複数解像度)
- 同様に4段階
データ生成フロー
1. 元データダウンロード
↓
2. Pythonで前処理
- 標準時オフセット計算
- 色インデックス計算
- DST情報追加
- 海/陸区別追加
↓
3. mapshaperで簡素化
- tolerance調整で複数解像度生成
↓
4. GeoJSONとして保存
Pythonスクリプト
script/mbtiles/prepare_timezone_data.py:
-
get_timezone_info(tzid): タイムゾーン情報を取得 -
get_color_index(offset): 色インデックスを計算 - プロパティ追加:
offset,current_offset,color_index,has_dst,is_ocean
空間インデックス(重要)
概要
タイムゾーンポリゴンの効率的な検索のため、R-tree空間インデックスを使用します。これにより、地図上のタップ位置からタイムゾーンを高速に特定できます。
R-tree空間インデックスとは
R-tree (R木) は、地理空間データの効率的な検索のためのデータ構造です。
- 各ポリゴンのバウンディングボックス(最小外接矩形)を保存
- 階層的な木構造で管理
- 点検索(タップ位置)を**O(log n)**で実行
SQLiteでの実装
1. R-treeテーブルの作成
CREATE VIRTUAL TABLE timezone_spatial_index USING rtree(
timezone_id INTEGER PRIMARY KEY,
min_longitude REAL,
max_longitude REAL,
min_latitude REAL,
max_latitude REAL
);
2. バウンディングボックスの計算と挿入
def calculate_bounds(geometry):
"""GeoJSONジオメトリからバウンディングボックスを計算"""
coords = []
if geometry['type'] == 'Polygon':
coords = geometry['coordinates'][0]
elif geometry['type'] == 'MultiPolygon':
for polygon in geometry['coordinates']:
coords.extend(polygon[0])
lons = [c[0] for c in coords]
lats = [c[1] for c in coords]
return {
'min_lon': min(lons),
'max_lon': max(lons),
'min_lat': min(lats),
'max_lat': max(lats)
}
# インデックスへの挿入
for idx, feature in enumerate(geojson['features']):
bounds = calculate_bounds(feature['geometry'])
cursor.execute('''
INSERT INTO timezone_spatial_index
VALUES (?, ?, ?, ?, ?)
''', (idx, bounds['min_lon'], bounds['max_lon'],
bounds['min_lat'], bounds['max_lat']))
3. 点検索クエリ
-- タップ位置 (longitude, latitude) からタイムゾーンを検索
SELECT timezone_id
FROM timezone_spatial_index
WHERE min_longitude <= ? AND max_longitude >= ?
AND min_latitude <= ? AND max_latitude >= ?;
パフォーマンス比較
| 検索方法 | 計算量 | 検索時間(500タイムゾーン) |
|---|---|---|
| 全ポリゴン検索 | O(n) | ~100ms |
| R-tree検索 | O(log n) | ~1ms |
100倍以上の高速化
Dart/Flutterでの実装
// タップ位置からタイムゾーンを検索
Future<String?> findTimezoneAtPoint(LatLng point) async {
final db = await database;
final results = await db.query(
'timezone_spatial_index',
where: '''
min_longitude <= ? AND max_longitude >= ? AND
min_latitude <= ? AND max_latitude >= ?
''',
whereArgs: [point.longitude, point.longitude,
point.latitude, point.latitude],
);
// 候補が見つかったら、詳細な点内包判定を実行
for (var result in results) {
if (await isPointInPolygon(point, result['timezone_id'])) {
return result['timezone_name'];
}
}
return null;
}
データ生成フローへの統合
1. GeoJSONダウンロード
↓
2. Pythonで前処理
- タイムゾーン情報追加
- バウンディングボックス計算
↓
3. SQLiteデータベース生成
- タイムゾーンテーブル作成
- R-tree空間インデックス作成
↓
4. Flutterアプリに配置
注意点
-
バウンディングボックスは近似
- R-treeは候補を絞り込むだけ
- 最終的には詳細な点内包判定が必要
-
国際日付変更線の処理
- 経度が-180°/+180°をまたぐ場合の特別処理
-
メモリ使用量
- R-treeインデックスは追加のメモリを消費
- トレードオフ: 速度 vs メモリ
Dartコード構造
データモデル
class TimezonePolygonData {
final List<Polygon> oceanPolygons;
final List<Polygon> landPolygons;
}
サービス
TimezoneMapService:
-
getTimezonePolygons(bool isDark): タイムゾーンデータを取得 - キャッシュ: ライト/ダークモード別
ウィジェット
WorldMapWidget:
- 状態管理:
_timezoneOceanPolygons,_timezoneLandPolygons - レイヤー表示: 海→陸→境界線→マーカー
ズームレベル別の切り替え(今後の実装)
int getDataResolution(double zoom) {
if (zoom <= 1) return 0; // 超簡素化110m
if (zoom <= 5) return 1; // 標準110m
if (zoom <= 7) return 2; // 50m
return 3; // 10m
}
パフォーマンス最適化
-
キャッシュ戦略
- ライト/ダークモード別にキャッシュ
- 解像度別にキャッシュ
-
ポリゴン簡素化
- ズームアウト時は低解像度データを使用
- メモリ使用量を削減
-
レンダリング最適化
- 不要なポリゴンは描画しない
- 境界線の太さを調整
ラベル表示(必須機能)
ズームレベル別の表示戦略
| ズームレベル | 表示内容 | データソース | 例 |
|---|---|---|---|
| 0-2 | なし | - | - |
| 3-4 | 大陸名 | 手動定義 | Asia, Europe, Africa |
| 5-6 | 国名 | Natural Earth countries | Japan, USA, China |
| 7-8 | 主要都市名 | GeoNames (人口10万以上) | Tokyo, New York, London |
データ構造
大陸名(手動定義)
const continentLabels = [
{'name': 'Asia', 'lat': 34.0, 'lon': 100.0},
{'name': 'Europe', 'lat': 54.0, 'lon': 15.0},
{'name': 'Africa', 'lat': 0.0, 'lon': 20.0},
{'name': 'North America', 'lat': 45.0, 'lon': -100.0},
{'name': 'South America', 'lat': -15.0, 'lon': -60.0},
{'name': 'Oceania', 'lat': -25.0, 'lon': 135.0},
];
国名(Natural Earth)
GeoJSONプロパティ:
-
NAME: 英語名 -
NAME_JA: 日本語名(追加が必要) -
POP_EST: 人口(フィルタリング用) -
LAT: 中心緯度 -
LON: 中心経度
都市名(GeoNames)
既存のgeonames.dbを使用:
SELECT name, latitude, longitude, population
FROM cities
WHERE population > 100000
ORDER BY population DESC
ラベル表示の実装
レイヤー構成
// ズームレベルに応じて表示
if (zoom >= 3 && zoom < 5) {
// 大陸名レイヤー
MarkerLayer(markers: _continentLabels)
} else if (zoom >= 5 && zoom < 7) {
// 国名レイヤー
MarkerLayer(markers: _countryLabels)
} else if (zoom >= 7) {
// 都市名レイヤー
MarkerLayer(markers: _cityLabels)
}
ラベルスタイル
大陸名:
- フォントサイズ: 24px
- 太字
- 色: グレー(半透明)
- 影付き
国名:
- フォントサイズ: 18px
- 太字
- 色: ダークグレー
- 影付き
都市名:
- フォントサイズ: 14px
- 通常
- 色: 黒/白(テーマに応じて)
- 小さいドット付き
データ準備
1. 国名データの準備
# Natural Earthから国名と座標を抽出
import geopandas as gpd
countries = gpd.read_file('ne_10m_admin_0_countries.shp')
country_labels = []
for idx, row in countries.iterrows():
# 国の中心座標を計算
centroid = row.geometry.centroid
country_labels.append({
'name_en': row['NAME'],
'name_ja': row.get('NAME_JA', row['NAME']),
'lat': centroid.y,
'lon': centroid.x,
'population': row['POP_EST']
})
# JSONとして保存
with open('country_labels.json', 'w') as f:
json.dump(country_labels, f)
2. 都市名データの準備
既存のcreate_geonames_db.pyを使用。人口10万以上の都市を含める。
パフォーマンス最適化
-
表示数の制限
- ズームレベルに応じて表示する都市数を制限
- 人口順でソート
-
重複回避
- 近接するラベルは非表示
- 衝突検出アルゴリズム
-
キャッシュ
- ズームレベル別にラベルをキャッシュ
言語対応
- 日本語/英語切り替え
- ユーザー設定に応じて表示言語を変更
- 大陸名、国名、都市名すべてに対応
タイムゾーンラベル表示(必須機能)
表示内容
各タイムゾーンの目立つ位置(中央付近)に以下を表示:
UTC+09
10:58
1行目: UTCオフセット(例: UTC+09, UTC-05, UTC+05:30)
2行目: 現在の時刻(例: 10:58, 23:45)
表示位置
- タイムゾーンポリゴンの中心座標
- または、視認性の高い位置(海上タイムゾーンは海の中央)
ラベルスタイル
-
フォントサイズ:
- UTCオフセット: 16px(太字)
- 時刻: 20px(太字)
-
色:
- ライトモード: 黒(影付き)
- ダークモード: 白(影付き)
- 背景: 半透明の白/黒
- 影: ドロップシャドウで視認性向上
表示条件
- ズームレベル: 3以上で表示
- 更新頻度: 1分ごとに時刻を更新
- 重複回避: 近接するタイムゾーンのラベルは非表示
実装
// タイムゾーンごとの中心座標を計算
Map<String, LatLng> calculateTimezoneCenters(List<Polygon> polygons) {
// ポリゴンの重心を計算
}
// 現在時刻を取得
String getCurrentTime(String tzid) {
final location = tz.getLocation(tzid);
final now = tz.TZDateTime.now(location);
return DateFormat('HH:mm').format(now);
}
// ラベルを表示
MarkerLayer(
markers: _timezoneLabels.map((label) {
return Marker(
point: label.position,
width: 80,
height: 60,
child: Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.8),
borderRadius: BorderRadius.circular(4),
),
child: Column(
children: [
Text(label.utcOffset, style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
Text(label.currentTime, style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
],
),
),
);
}).toList(),
)
今後の拡張
-
インタラクティブ機能
- ホバー時にハイライト
- タイムゾーン選択機能
-
アニメーション
- 時間経過に応じた色変化
- DST切り替え時の視覚効果


