diff --git a/Plans.md b/Plans.md
index 1ee78aa..5c1905a 100644
--- a/Plans.md
+++ b/Plans.md
@@ -54,8 +54,9 @@
| 75 | **環境センサー/乗り換え最適化/インフラダッシュボード** | `skills/env_sensor.py` `skills/transfer_optimizer.py` `skills/infra_dashboard.py` | 4319 | v0.78 |
| 76 | **交通量分析/エネルギーモニタリング/群衆予測** | `skills/traffic_analyzer.py` `skills/energy_monitor.py` `skills/crowd_predictor.py` | 4381 | v0.79 |
| 77 | **災害アラート/水質モニタリング/騒音マッピング** | `skills/disaster_alert.py` `skills/water_quality.py` `skills/noise_mapper.py` | 4446 | v0.80 |
+| 78 | **3D 都市マップビジュアライゼーション** | `skills/city_map_viz.py` `tokyo_map_preview.html` | 4487 | v0.81 |
-> **累計テスト数**: 4446 PASS (Sprint 77: +65) — **Sprint 78 候補検討中**
+> **累計テスト数**: 4487 PASS (Sprint 78: +41) — **Sprint 79 候補検討中**
---
@@ -422,6 +423,59 @@
---
+## Sprint 78 詳細 (完了)
+
+### Sprint 78: 3D 都市マップビジュアライゼーション — v0.81.0
+> SVGベースからThree.js WebGLへの昇華。東京都内20地区をリアルタイム3Dで描画。
+
+#### 78A: SVG 2D 都市マップ基盤 (`skills/city_map_viz.py`)
+| task-id | 説明 | 状態 |
+|---------|------|------|
+| 78A.1 | `MapLayer` (5種: 交通量/騒音/群衆密度/エネルギー/災害) × `DistrictData` | cc:完了 |
+| 78A.2 | `CityMapData` / `generate_html` — SVGダークテーマ+ツールチップ+凡例+統計サイドバー | cc:完了 |
+| 78A.3 | `TOKYO_DISTRICTS` プリセット — 20地区 (新宿〜荒川) | cc:完了 |
+| 78A.4 | カラーパレット: 交通量/騒音/群衆密度/エネルギー/災害アラート 5系統 | cc:完了 |
+
+#### 78B: Three.js 3D アニメーション拡張 (`skills/city_map_viz.py` + `tokyo_map_preview.html`)
+| task-id | 説明 | 状態 |
+|---------|------|------|
+| 78B.1 | Google Maps ライトテーマへカラーパレット刷新 | cc:完了 |
+| 78B.2 | 川アニメーション: 隅田川・多摩川・荒川 ShaderMaterial UV スクロール波紋 | cc:完了 |
+| 78B.3 | 地形: 富士山(雪冠付き) + 背景丘陵5本 (south-west) | cc:完了 |
+| 78B.4 | 背景ビル 320棟: `seededRandom` InstancedMesh (地区/川/東京湾と重複回避) | cc:完了 |
+| 78B.5 | ランドマーク: 東京都庁・東京タワー・東京スカイツリー・羽田空港滑走路×2 | cc:完了 |
+| 78B.6 | 電車アニメーション: 山手線(緑ループ)・中央線(橙)・東海道線(赤) | cc:完了 |
+| 78B.7 | 公園ツリー: `ConeGeometry` × 5本/公園, `seededRandom` 配置 | cc:完了 |
+| 78B.8 | レンダーループに `dt` クロック駆動を統合 | cc:完了 |
+
+#### 78C: API 公開 (`serve/api.py`)
+| task-id | 説明 | 状態 |
+|---------|------|------|
+| 78C.1 | `/v1/viz/citymap/{city}` — HTMLResponse (Three.js 3D マップ) | cc:完了 |
+| 78C.2 | `/v1/viz/citymap/{city}/data` — JSON データ取得 | cc:完了 |
+| 78C.3 | `/v1/viz/citymap` POST — カスタムデータ可視化 | cc:完了 |
+| 78C.4 | `/v1/viz/cities` — 対応都市一覧 | cc:完了 |
+
+| 78.T | `tests/test_sprint78.py` — 41 PASS (累計 4487) | cc:完了 |
+
+---
+
+## Sprint 79 候補テーマ
+
+> **方針**: 地図ビジュアライゼーション系を深化させるか、広告マーケ系に戻るか選択。
+
+| Option | テーマ | コアモジュール | 理由 |
+|--------|--------|--------------|------|
+| **A** | **地図 WebSocket リアルタイム更新** | `skills/city_map_realtime.py` | 電車/混雑をWebSocket Push で動的更新。Three.js 3D との相性◎ |
+| **B** | **AR/VR 地図エクスポート** | `skills/city_map_xr.py` | glTF/USD 形式で3DモデルをExport。Unity/Unreal/Apple Vision Pro連携 |
+| **C** | **地図AI ナレッジグラフ** | `skills/city_knowledge_graph.py` | 駅・地区・施設をGraphで表現。NLQ「新宿から徒歩10分のカフェ」クエリ対応 |
+| **D** | **広告×地理情報 ジオターゲティング** | `skills/geo_targeting.py` | 地区データ×広告キャンペーンを統合。位置属性ベースのCTR予測 |
+| **E** | **都市シミュレーション エージェント** | `skills/city_agent.py` | 「電車遅延→混雑→広告効果低下」を自律エージェントでシミュレーション |
+| **F** | **トレースコンパイラ** ★論文移植 | `skills/trace_compiler.py` | arXiv:2608.02680 TraceCompiler — エージェント実行ログ (`.claude/harness-logs/`) を採掘し、成功パターンを再利用可能な決定論的スキルに自動コンパイル。Harness自己改善ループと直接連携 |
+| **G** | **自己適応LLM実行エンジン** ★論文移植 | `skills/self_adapting.py` | arXiv:2506.10943 Self-Adapting LMs — Sprint 20-30「育つAI」の延長。実行フィードバックでモデル挙動を動的調整するアダプタ層 |
+
+---
+
## 技術的知見メモ
- `freqs_cis` は必ず `[:T]` スライス (apply_rope ブロードキャストエラー防止)
diff --git a/open_mythos/skills/city_map_viz.py b/open_mythos/skills/city_map_viz.py
new file mode 100644
index 0000000..ed3d4d4
--- /dev/null
+++ b/open_mythos/skills/city_map_viz.py
@@ -0,0 +1,804 @@
+"""
+Sprint 78A — 都市マップビジュアライゼーション (v3: Three.js 3D)
+
+Google Maps 風 3D ライトテーマ。Three.js によるブロック都市マップ。
+レイヤー切替時に 3D ブロックの色と高さがリアルタイムで変化する。
+"""
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Dict, List, Optional
+
+
+# ─── Enums ────────────────────────────────────────────────────────
+
+
+class MapLayer(str, Enum):
+ TRAFFIC = "traffic"
+ NOISE = "noise"
+ CROWD = "crowd"
+ ENERGY = "energy"
+ DISASTER = "disaster"
+
+
+# ─── カラーパレット(Google スタイル)─────────────────────────────
+
+_TRAFFIC_COLORS = {
+ "clear": "#34a853",
+ "moderate": "#fbbc04",
+ "congested": "#ea8600",
+ "gridlock": "#d93025",
+}
+_NOISE_COLORS = {
+ "compliant": "#34a853",
+ "near_limit": "#fbbc04",
+ "violation": "#d93025",
+}
+_CROWD_COLORS = {
+ "sparse": "#e8f0fe",
+ "normal": "#4285f4",
+ "crowded": "#ea8600",
+ "packed": "#d93025",
+}
+_DISASTER_COLORS = {
+ "info": "#4285f4",
+ "watch": "#fbbc04",
+ "warning": "#ea8600",
+ "critical": "#d93025",
+}
+_ENERGY_COLORS = {
+ "normal": "#34a853",
+ "high": "#fbbc04",
+ "critical": "#d93025",
+}
+_NO_DATA_COLOR = "#e8e0d0"
+
+# ─── 3D ブロック高さ(値が高いほど深刻 → 高いブロック)──────────
+
+_H_TRAFFIC = {"clear": 3, "moderate": 8, "congested": 17, "gridlock": 27}
+_H_NOISE = {"compliant": 3, "near_limit": 10, "violation": 22}
+_H_CROWD = {"sparse": 3, "normal": 8, "crowded": 17, "packed": 27}
+_H_ENERGY = {"normal": 4, "high": 14, "critical": 25}
+_H_DISASTER = {"info": 7, "watch": 13, "warning": 19, "critical": 30}
+
+
+# ─── Data Classes ─────────────────────────────────────────────────
+
+
+@dataclass
+class DistrictData:
+ """地区単位の統合データ。"""
+ name: str
+ x: float
+ y: float
+ width: float = 18.0
+ height: float = 14.0
+ traffic_level: Optional[str] = None
+ noise_status: Optional[str] = None
+ crowd_level: Optional[str] = None
+ energy_status: Optional[str] = None
+ disaster_level: Optional[str] = None
+
+ def to_dict(self) -> dict:
+ return {
+ "name": self.name,
+ "x": self.x, "y": self.y,
+ "width": self.width, "height": self.height,
+ "traffic_level": self.traffic_level,
+ "noise_status": self.noise_status,
+ "crowd_level": self.crowd_level,
+ "energy_status": self.energy_status,
+ "disaster_level": self.disaster_level,
+ }
+
+
+@dataclass
+class CityMapData:
+ """都市マップの全データ。"""
+ city: str
+ districts: List[DistrictData] = field(default_factory=list)
+ active_layer: MapLayer = MapLayer.TRAFFIC
+
+ def to_dict(self) -> dict:
+ return {
+ "city": self.city,
+ "active_layer": self.active_layer.value,
+ "districts": [d.to_dict() for d in self.districts],
+ }
+
+
+# ─── ヘルパー ──────────────────────────────────────────────────────
+
+
+def _district_color(district: DistrictData, layer: MapLayer) -> str:
+ """レイヤーに応じた地区の塗り色。None → _NO_DATA_COLOR。"""
+ if layer == MapLayer.TRAFFIC:
+ return _TRAFFIC_COLORS.get(district.traffic_level, _NO_DATA_COLOR)
+ elif layer == MapLayer.NOISE:
+ return _NOISE_COLORS.get(district.noise_status, _NO_DATA_COLOR)
+ elif layer == MapLayer.CROWD:
+ return _CROWD_COLORS.get(district.crowd_level, _NO_DATA_COLOR)
+ elif layer == MapLayer.ENERGY:
+ return _ENERGY_COLORS.get(district.energy_status, _NO_DATA_COLOR)
+ elif layer == MapLayer.DISASTER:
+ if district.disaster_level:
+ return _DISASTER_COLORS.get(district.disaster_level, _NO_DATA_COLOR)
+ return "#e8f5e9"
+ return _NO_DATA_COLOR
+
+
+def _district_3d_height(district: DistrictData, layer: MapLayer) -> float:
+ """レイヤー×ステータスに応じた 3D ブロック高さ。"""
+ if layer == MapLayer.TRAFFIC:
+ return float(_H_TRAFFIC.get(district.traffic_level, 3))
+ elif layer == MapLayer.NOISE:
+ return float(_H_NOISE.get(district.noise_status, 3))
+ elif layer == MapLayer.CROWD:
+ return float(_H_CROWD.get(district.crowd_level, 3))
+ elif layer == MapLayer.ENERGY:
+ return float(_H_ENERGY.get(district.energy_status, 4))
+ elif layer == MapLayer.DISASTER:
+ return float(_H_DISASTER.get(district.disaster_level, 3))
+ return 4.0
+
+
+def _legend_html(layer: MapLayer) -> str:
+ if layer == MapLayer.TRAFFIC:
+ items = [("#34a853","通常走行"),("#fbbc04","やや混雑"),("#ea8600","渋滞"),("#d93025","完全停滞")]
+ elif layer == MapLayer.NOISE:
+ items = [("#34a853","規制内"),("#fbbc04","規制値近傍"),("#d93025","規制超過")]
+ elif layer == MapLayer.CROWD:
+ items = [("#e8f0fe","閑散"),("#4285f4","通常"),("#ea8600","混雑"),("#d93025","超混雑")]
+ elif layer == MapLayer.ENERGY:
+ items = [("#34a853","通常"),("#fbbc04","高使用"),("#d93025","異常")]
+ elif layer == MapLayer.DISASTER:
+ items = [("#e8f5e9","アラートなし"),("#4285f4","情報"),("#fbbc04","注意"),("#ea8600","警戒"),("#d93025","緊急")]
+ else:
+ items = []
+ return "".join(
+ f'
{lb}
'
+ for c, lb in items
+ )
+
+
+# ─── HTML テンプレート(Three.js 3D)─────────────────────────────
+# __PLACEHOLDER__ マーカーを generate_html() で .replace() 置換する
+
+_PAGE_TEMPLATE = """
+
+
+
+
+OpenMythos 3D City Map — __CITY_NAME__
+
+
+
+
+
+
+ 🗺
+ __CITY_NAME__ City Map
+ __LAYER_ICON__ __LAYER_LABEL__ | __DISTRICT_COUNT__ 地区
+
+
+
+
+
+
統計
+
+
+
__ALERT_COUNT__
__ALERT_LABEL__
+
+
+
+
+
+
+
+
+
+📊 高さ = データ強度ブロックが高いほど値が大きい
+© OpenMythos City Intelligence | ドラッグ: 回転 / スクロール: ズーム / 右ドラッグ: 移動
+
+
+
+
+
+
+
+
+
+"""
+
+
+# ─── generate_html ────────────────────────────────────────────────
+
+
+def generate_html(map_data: CityMapData, width: int = 1000, height: int = 700) -> str:
+ """Three.js 3D 都市マップ HTML を生成する。レイヤー切替で色・高さが変化。"""
+ layer = map_data.active_layer
+
+ layer_label = {
+ MapLayer.TRAFFIC:"交通量", MapLayer.NOISE:"騒音",
+ MapLayer.CROWD:"群衆密度", MapLayer.ENERGY:"エネルギー",
+ MapLayer.DISASTER:"災害アラート",
+ }.get(layer, layer.value)
+
+ layer_icon = {
+ MapLayer.TRAFFIC:"🚗", MapLayer.NOISE:"🔊",
+ MapLayer.CROWD:"👥", MapLayer.ENERGY:"⚡", MapLayer.DISASTER:"⚠️",
+ }.get(layer, "📍")
+
+ total = len(map_data.districts)
+
+ # 統計
+ if layer == MapLayer.TRAFFIC:
+ alert_n = sum(1 for d in map_data.districts if d.traffic_level in ("congested","gridlock"))
+ stat_label = "混雑地区"
+ elif layer == MapLayer.NOISE:
+ alert_n = sum(1 for d in map_data.districts if d.noise_status == "violation")
+ stat_label = "規制超過"
+ elif layer == MapLayer.CROWD:
+ alert_n = sum(1 for d in map_data.districts if d.crowd_level in ("crowded","packed"))
+ stat_label = "混雑地区"
+ elif layer == MapLayer.ENERGY:
+ alert_n = sum(1 for d in map_data.districts if d.energy_status in ("high","critical"))
+ stat_label = "高消費地区"
+ elif layer == MapLayer.DISASTER:
+ alert_n = sum(1 for d in map_data.districts if d.disaster_level in ("warning","critical"))
+ stat_label = "警戒以上"
+ else:
+ alert_n = 0; stat_label = "地区数"
+
+ alert_color = "#d93025" if alert_n > 0 else "#34a853"
+
+ # レイヤーボタン HTML (onclick に button 自身を渡す → switchLayer で active 管理)
+ layer_btns = "".join(
+ f''
+ for lv, ic, lb in [
+ ("traffic","🚗","交通量"), ("noise","🔊","騒音"),
+ ("crowd","👥","群衆密度"), ("energy","⚡","エネルギー"),
+ ("disaster","⚠️","災害アラート"),
+ ]
+ )
+
+ legend = _legend_html(layer)
+
+ # ── 全レイヤーの色・高さを地区ごとに事前計算して JS に埋め込む ──
+ dist_data = []
+ for d in map_data.districts:
+ layers_js: Dict[str, dict] = {}
+ for lyr in MapLayer:
+ c = _district_color(d, lyr)
+ h = _district_3d_height(d, lyr)
+ layers_js[lyr.value] = {"c": int(c.lstrip("#"), 16), "h": h}
+ dist_data.append({
+ "name": d.name,
+ "x": d.x, "z": d.y, "w": d.width, "d": d.height,
+ "traffic": d.traffic_level or "",
+ "noise": d.noise_status or "",
+ "crowd": d.crowd_level or "",
+ "energy": d.energy_status or "",
+ "disaster": d.disaster_level or "",
+ "layers": layers_js,
+ })
+
+ dist_json = json.dumps(dist_data, ensure_ascii=False)
+
+ return (
+ _PAGE_TEMPLATE
+ .replace("__CITY_NAME__", map_data.city)
+ .replace("__LAYER_LABEL__", layer_label)
+ .replace("__LAYER_ICON__", layer_icon)
+ .replace("__DISTRICT_COUNT__", str(total))
+ .replace("__ALERT_COUNT__", str(alert_n))
+ .replace("__ALERT_COLOR__", alert_color)
+ .replace("__ALERT_LABEL__", stat_label)
+ .replace("__DISTRICTS_JSON__", dist_json)
+ .replace("__ACTIVE_LAYER__", layer.value)
+ .replace("__LEGEND_HTML__", legend)
+ .replace("__LAYER_BTNS__", layer_btns)
+ )
+
+
+# ─── Store / Builder ──────────────────────────────────────────────
+
+
+class CityMapStore:
+ def __init__(self) -> None:
+ self._maps: Dict[str, CityMapData] = {}
+
+ def set(self, city: str, data: CityMapData) -> None:
+ self._maps[city] = data
+
+ def get(self, city: str) -> Optional[CityMapData]:
+ return self._maps.get(city)
+
+ def list_cities(self) -> List[str]:
+ return list(self._maps.keys())
+
+ def count(self) -> int:
+ return len(self._maps)
+
+
+class CityMapBuilder:
+ def __init__(self, store: Optional[CityMapStore] = None) -> None:
+ self.store = store or CityMapStore()
+
+ def build(
+ self,
+ city: str,
+ districts: List[DistrictData],
+ active_layer: MapLayer = MapLayer.TRAFFIC,
+ ) -> CityMapData:
+ data = CityMapData(city=city, districts=districts, active_layer=active_layer)
+ self.store.set(city, data)
+ return data
+
+ def get_html(self, city: str, layer: Optional[MapLayer] = None) -> Optional[str]:
+ data = self.store.get(city)
+ if data is None:
+ return None
+ if layer is not None:
+ data.active_layer = layer
+ return generate_html(data)
+
+
+# ─── プリセット: 東京 23 区(全特別区・地理的配置)────────────────
+#
+# ※ 東京特別区は 23 区(24 区ではない)
+# viewBox 0 0 100 76 / Three.js 世界座標: x=0-99, z=0-75
+#
+# レイアウト(行 × 列):
+# row1(y=1) : 練馬 板橋 北区 足立[wide] 葛飾
+# row2(y=15): 杉並 豊島 文京 荒川 墨田 江戸川[tall]
+# row3(y=29): 中野 新宿 千代田 台東 江東
+# row4(y=43): 世田谷[tall] 渋谷 港 中央 大田[wide+tall]
+# row5(y=56): 目黒 品川[wide]
+#
+# 東京湾: 右下 (x>65, z>55) / 隅田川: x≈64.5 縦断 / 多摩川: z≈63 横断
+
+TOKYO_DISTRICTS: List[DistrictData] = [
+
+ # ── 北部 row 1 (y=1) ──────────────────────────────────────────
+ DistrictData("練馬", x=1, y=1, width=20, height=13,
+ traffic_level="moderate", noise_status="compliant", crowd_level="normal", energy_status="normal", disaster_level=None),
+ DistrictData("板橋", x=22, y=1, width=14, height=13,
+ traffic_level="moderate", noise_status="near_limit", crowd_level="normal", energy_status="normal", disaster_level=None),
+ DistrictData("北区", x=37, y=1, width=13, height=13,
+ traffic_level="moderate", noise_status="compliant", crowd_level="normal", energy_status="normal", disaster_level=None),
+ DistrictData("足立", x=51, y=1, width=27, height=14, # 大きい区
+ traffic_level="clear", noise_status="compliant", crowd_level="sparse", energy_status="normal", disaster_level=None),
+ DistrictData("葛飾", x=79, y=1, width=20, height=14,
+ traffic_level="clear", noise_status="compliant", crowd_level="sparse", energy_status="normal", disaster_level=None),
+
+ # ── 北中部 row 2 (y=15) ───────────────────────────────────────
+ DistrictData("杉並", x=1, y=15, width=20, height=13,
+ traffic_level="moderate", noise_status="compliant", crowd_level="normal", energy_status="normal", disaster_level=None),
+ DistrictData("豊島", x=22, y=14, width=14, height=14, # 池袋エリア
+ traffic_level="congested", noise_status="near_limit", crowd_level="crowded", energy_status="normal", disaster_level=None),
+ DistrictData("文京", x=37, y=15, width=13, height=13, # 東大・東京ドーム
+ traffic_level="moderate", noise_status="compliant", crowd_level="normal", energy_status="normal", disaster_level=None),
+ DistrictData("荒川", x=51, y=15, width=13, height=13,
+ traffic_level="clear", noise_status="compliant", crowd_level="sparse", energy_status="normal", disaster_level=None),
+ DistrictData("墨田", x=65, y=15, width=13, height=13,
+ traffic_level="clear", noise_status="compliant", crowd_level="sparse", energy_status="normal", disaster_level=None),
+ DistrictData("江戸川", x=79, y=15, width=20, height=27, # 東端・広大
+ traffic_level="clear", noise_status="compliant", crowd_level="sparse", energy_status="normal", disaster_level=None),
+
+ # ── 中部 row 3 (y=29) ─────────────────────────────────────────
+ DistrictData("中野", x=1, y=29, width=20, height=13, # 杉並の東
+ traffic_level="clear", noise_status="compliant", crowd_level="normal", energy_status="normal", disaster_level=None),
+ DistrictData("新宿", x=22, y=28, width=14, height=14, # 西新宿・歌舞伎町
+ traffic_level="congested", noise_status="violation", crowd_level="packed", energy_status="high", disaster_level=None),
+ DistrictData("千代田", x=37, y=28, width=13, height=13, # 皇居・丸の内
+ traffic_level="gridlock", noise_status="violation", crowd_level="packed", energy_status="critical", disaster_level="warning"),
+ DistrictData("台東", x=51, y=28, width=13, height=13, # 浅草・上野
+ traffic_level="moderate", noise_status="near_limit", crowd_level="crowded", energy_status="normal", disaster_level=None),
+ DistrictData("江東", x=65, y=28, width=13, height=14, # 湾岸・お台場隣接
+ traffic_level="moderate", noise_status="near_limit", crowd_level="normal", energy_status="high", disaster_level="watch"),
+
+ # ── 南中部 row 4 (y=42-43) ────────────────────────────────────
+ DistrictData("世田谷", x=1, y=43, width=20, height=22, # 最大区・住宅地
+ traffic_level="clear", noise_status="compliant", crowd_level="normal", energy_status="normal", disaster_level=None),
+ DistrictData("渋谷", x=22, y=42, width=14, height=13, # 若者の街
+ traffic_level="moderate", noise_status="near_limit", crowd_level="crowded", energy_status="normal", disaster_level=None),
+ DistrictData("港", x=37, y=42, width=13, height=13, # 六本木・芝公園
+ traffic_level="moderate", noise_status="near_limit", crowd_level="crowded", energy_status="high", disaster_level=None),
+ DistrictData("中央", x=51, y=42, width=13, height=13, # 銀座・築地
+ traffic_level="congested", noise_status="near_limit", crowd_level="crowded", energy_status="high", disaster_level=None),
+ DistrictData("大田", x=65, y=42, width=34, height=22, # 羽田空港・最大面積
+ traffic_level="clear", noise_status="compliant", crowd_level="normal", energy_status="high", disaster_level=None),
+
+ # ── 南部 row 5 (y=56) ─────────────────────────────────────────
+ DistrictData("目黒", x=22, y=56, width=14, height=13, # 目黒川
+ traffic_level="clear", noise_status="compliant", crowd_level="sparse", energy_status="normal", disaster_level=None),
+ DistrictData("品川", x=37, y=56, width=27, height=13, # 品川駅・リニア予定地
+ traffic_level="moderate", noise_status="compliant", crowd_level="normal", energy_status="normal", disaster_level=None),
+]
diff --git a/serve/api.py b/serve/api.py
index 518c021..edbdebc 100644
--- a/serve/api.py
+++ b/serve/api.py
@@ -9283,3 +9283,105 @@ def noise_map(city: str):
@app.get("/v1/noise/report/{city}", tags=["noise"], summary="都市騒音レポート — Sprint 77C")
def noise_city_report(city: str):
return _noise_mapper.city_report(city).to_dict()
+
+
+# ---------------------------------------------------------------------------
+# Sprint 78 — 都市マップビジュアライゼーション
+# ---------------------------------------------------------------------------
+
+from fastapi.responses import HTMLResponse
+from open_mythos.skills.city_map_viz import (
+ CityMapBuilder as _MapBuilder,
+ CityMapStore as _MapStore,
+ CityMapData as _CityMapData,
+ DistrictData as _DistrictData,
+ MapLayer as _MapLayer,
+ TOKYO_DISTRICTS as _TOKYO_DISTRICTS,
+ generate_html as _generate_html,
+)
+
+_map_store = _MapStore()
+_map_builder = _MapBuilder(store=_map_store)
+
+# 東京プリセットを初期ロード
+_map_builder.build("Tokyo", _TOKYO_DISTRICTS, active_layer=_MapLayer.TRAFFIC)
+
+
+@app.get(
+ "/v1/viz/citymap/{city}",
+ tags=["visualization"],
+ summary="都市マップ HTML — Sprint 78",
+ response_class=HTMLResponse,
+)
+def viz_citymap(city: str, layer: str = "traffic"):
+ try:
+ layer_e = _MapLayer(layer)
+ except ValueError:
+ raise HTTPException(400, f"Invalid layer: {layer}. Choose from: {[l.value for l in _MapLayer]}")
+ html = _map_builder.get_html(city, layer_e)
+ if html is None:
+ raise HTTPException(404, f"City map not found: {city}")
+ return HTMLResponse(content=html)
+
+
+@app.get(
+ "/v1/viz/citymap/{city}/data",
+ tags=["visualization"],
+ summary="都市マップ JSON データ — Sprint 78",
+)
+def viz_citymap_data(city: str):
+ data = _map_store.get(city)
+ if data is None:
+ raise HTTPException(404, f"City map not found: {city}")
+ return data.to_dict()
+
+
+class _DistrictIn(BaseModel):
+ name: str
+ x: float = Field(ge=0.0, le=100.0)
+ y: float = Field(ge=0.0, le=100.0)
+ width: float = Field(default=18.0, ge=1.0)
+ height: float = Field(default=14.0, ge=1.0)
+ traffic_level: Optional[str] = None
+ noise_status: Optional[str] = None
+ crowd_level: Optional[str] = None
+ energy_status: Optional[str] = None
+ disaster_level: Optional[str] = None
+
+
+class _CityMapIn(BaseModel):
+ city: str
+ districts: List[_DistrictIn]
+ active_layer: str = "traffic"
+
+
+@app.post(
+ "/v1/viz/citymap",
+ tags=["visualization"],
+ summary="都市マップ登録 — Sprint 78",
+)
+def viz_citymap_create(body: _CityMapIn):
+ try:
+ layer_e = _MapLayer(body.active_layer)
+ except ValueError:
+ raise HTTPException(400, f"Invalid active_layer: {body.active_layer}")
+ districts = [
+ _DistrictData(
+ name=d.name, x=d.x, y=d.y, width=d.width, height=d.height,
+ traffic_level=d.traffic_level, noise_status=d.noise_status,
+ crowd_level=d.crowd_level, energy_status=d.energy_status,
+ disaster_level=d.disaster_level,
+ )
+ for d in body.districts
+ ]
+ data = _map_builder.build(body.city, districts, active_layer=layer_e)
+ return {"ok": True, "city": data.city, "district_count": len(data.districts)}
+
+
+@app.get(
+ "/v1/viz/cities",
+ tags=["visualization"],
+ summary="登録済み都市一覧 — Sprint 78",
+)
+def viz_cities():
+ return {"cities": _map_store.list_cities(), "count": _map_store.count()}
diff --git a/tests/test_sprint78.py b/tests/test_sprint78.py
new file mode 100644
index 0000000..3f05d55
--- /dev/null
+++ b/tests/test_sprint78.py
@@ -0,0 +1,253 @@
+"""
+Sprint 78 テスト — 都市マップビジュアライゼーション
+"""
+import pytest
+
+from open_mythos.skills.city_map_viz import (
+ CityMapBuilder, CityMapStore, CityMapData, DistrictData,
+ MapLayer, generate_html, TOKYO_DISTRICTS,
+ _district_color, _legend_html,
+)
+
+
+def _make_builder():
+ store = CityMapStore()
+ builder = CityMapBuilder(store=store)
+ return builder, store
+
+
+def _sample_districts():
+ return [
+ DistrictData("新宿", x=5, y=5, traffic_level="congested", noise_status="violation",
+ crowd_level="packed", energy_status="high", disaster_level="warning"),
+ DistrictData("渋谷", x=25, y=5, traffic_level="clear", noise_status="compliant",
+ crowd_level="sparse", energy_status="normal", disaster_level=None),
+ DistrictData("品川", x=45, y=5, traffic_level="moderate", noise_status="near_limit",
+ crowd_level="normal", energy_status="normal", disaster_level=None),
+ ]
+
+
+class TestDistrictData:
+ def test_to_dict_keys(self):
+ d = DistrictData("新宿", x=5, y=5, traffic_level="congested")
+ dd = d.to_dict()
+ for key in ["name", "x", "y", "width", "height", "traffic_level",
+ "noise_status", "crowd_level", "energy_status", "disaster_level"]:
+ assert key in dd
+
+ def test_defaults(self):
+ d = DistrictData("駅前", x=10, y=10)
+ assert d.traffic_level is None
+ assert d.width == 18.0
+ assert d.height == 14.0
+
+
+class TestCityMapData:
+ def test_to_dict(self):
+ districts = _sample_districts()
+ data = CityMapData(city="Tokyo", districts=districts)
+ dd = data.to_dict()
+ assert dd["city"] == "Tokyo"
+ assert len(dd["districts"]) == 3
+ assert "active_layer" in dd
+
+ def test_default_layer(self):
+ data = CityMapData(city="Tokyo", districts=[])
+ assert data.active_layer == MapLayer.TRAFFIC
+
+
+class TestDistrictColor:
+ def test_traffic_congested(self):
+ d = DistrictData("A", x=0, y=0, traffic_level="congested")
+ assert _district_color(d, MapLayer.TRAFFIC) == "#ea8600" # Google orange
+
+ def test_traffic_clear(self):
+ d = DistrictData("A", x=0, y=0, traffic_level="clear")
+ assert _district_color(d, MapLayer.TRAFFIC) == "#34a853" # Google green
+
+ def test_noise_violation(self):
+ d = DistrictData("A", x=0, y=0, noise_status="violation")
+ assert _district_color(d, MapLayer.NOISE) == "#d93025" # Google red
+
+ def test_noise_compliant(self):
+ d = DistrictData("A", x=0, y=0, noise_status="compliant")
+ assert _district_color(d, MapLayer.NOISE) == "#34a853"
+
+ def test_crowd_packed(self):
+ d = DistrictData("A", x=0, y=0, crowd_level="packed")
+ assert _district_color(d, MapLayer.CROWD) == "#d93025"
+
+ def test_crowd_sparse(self):
+ d = DistrictData("A", x=0, y=0, crowd_level="sparse")
+ assert _district_color(d, MapLayer.CROWD) == "#e8f0fe" # Google light blue
+
+ def test_energy_critical(self):
+ d = DistrictData("A", x=0, y=0, energy_status="critical")
+ assert _district_color(d, MapLayer.ENERGY) == "#d93025"
+
+ def test_disaster_critical(self):
+ d = DistrictData("A", x=0, y=0, disaster_level="critical")
+ assert _district_color(d, MapLayer.DISASTER) == "#d93025"
+
+ def test_disaster_none(self):
+ d = DistrictData("A", x=0, y=0, disaster_level=None)
+ color = _district_color(d, MapLayer.DISASTER)
+ assert color == "#e8f5e9"
+
+ def test_missing_status_fallback(self):
+ d = DistrictData("A", x=0, y=0)
+ color = _district_color(d, MapLayer.TRAFFIC)
+ assert color == "#e8e0d0" # map land neutral color
+
+
+class TestLegendHtml:
+ def test_traffic_legend(self):
+ html = _legend_html(MapLayer.TRAFFIC)
+ assert "通常走行" in html
+ assert "渋滞" in html
+
+ def test_noise_legend(self):
+ html = _legend_html(MapLayer.NOISE)
+ assert "規制内" in html
+ assert "規制超過" in html
+
+ def test_crowd_legend(self):
+ html = _legend_html(MapLayer.CROWD)
+ assert "閑散" in html
+ assert "超混雑" in html
+
+ def test_disaster_legend(self):
+ html = _legend_html(MapLayer.DISASTER)
+ assert "緊急" in html
+
+ def test_energy_legend(self):
+ html = _legend_html(MapLayer.ENERGY)
+ assert "通常" in html
+
+
+class TestGenerateHtml:
+ def test_returns_string(self):
+ data = CityMapData("Tokyo", _sample_districts(), MapLayer.TRAFFIC)
+ html = generate_html(data)
+ assert isinstance(html, str)
+
+ def test_contains_city_name(self):
+ data = CityMapData("Tokyo", _sample_districts(), MapLayer.TRAFFIC)
+ html = generate_html(data)
+ assert "Tokyo" in html
+
+ def test_contains_district_names(self):
+ data = CityMapData("Tokyo", _sample_districts(), MapLayer.TRAFFIC)
+ html = generate_html(data)
+ assert "新宿" in html
+ assert "渋谷" in html
+
+ def test_contains_canvas_or_three(self):
+ # v3 は Three.js 3D (WebGL canvas) のため