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__ 地区 +
+ +
+
レイヤー選択
__LAYER_BTNS__
+
凡例
__LEGEND_HTML__
+
統計
+
+
__DISTRICT_COUNT__
地区数
+
__ALERT_COUNT__
__ALERT_LABEL__
+
+
+
+ +
+
🧭
+
+ + +
+
+ +
+
+ +
📊 高さ = データ強度ブロックが高いほど値が大きい
+
© OpenMythos City Intelligence  |  ドラッグ: 回転  /  スクロール: ズーム  /  右ドラッグ: 移動
+

3D マップを読み込み中...

+ + + + + + + + +""" + + +# ─── 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) のため ではなく canvas/Three.js を使用 + data = CityMapData("Tokyo", _sample_districts(), MapLayer.TRAFFIC) + html = generate_html(data) + assert "canvas" in html or "three" in html.lower() + + def test_layer_traffic_html(self): + data = CityMapData("Tokyo", _sample_districts(), MapLayer.TRAFFIC) + html = generate_html(data) + assert "交通量" in html + + def test_layer_noise_html(self): + data = CityMapData("Tokyo", _sample_districts(), MapLayer.NOISE) + html = generate_html(data) + assert "騒音" in html + + def test_layer_disaster_html(self): + data = CityMapData("Tokyo", _sample_districts(), MapLayer.DISASTER) + html = generate_html(data) + assert "災害" in html + + def test_empty_districts(self): + data = CityMapData("Empty", [], MapLayer.TRAFFIC) + html = generate_html(data) + assert isinstance(html, str) + assert "Empty" in html + + +class TestCityMapStore: + def test_set_and_get(self): + _, store = _make_builder() + data = CityMapData("Tokyo", _sample_districts()) + store.set("Tokyo", data) + assert store.get("Tokyo") is not None + + def test_get_nonexistent(self): + _, store = _make_builder() + assert store.get("NoCity") is None + + def test_list_cities(self): + _, store = _make_builder() + store.set("Tokyo", CityMapData("Tokyo", [])) + store.set("Osaka", CityMapData("Osaka", [])) + cities = store.list_cities() + assert "Tokyo" in cities + assert "Osaka" in cities + + def test_count(self): + _, store = _make_builder() + store.set("Tokyo", CityMapData("Tokyo", [])) + store.set("Osaka", CityMapData("Osaka", [])) + assert store.count() == 2 + + +class TestCityMapBuilder: + def test_build_stores_data(self): + builder, store = _make_builder() + builder.build("Tokyo", _sample_districts()) + assert store.get("Tokyo") is not None + + def test_build_district_count(self): + builder, _ = _make_builder() + data = builder.build("Tokyo", _sample_districts()) + assert len(data.districts) == 3 + + def test_get_html_returns_string(self): + builder, _ = _make_builder() + builder.build("Tokyo", _sample_districts()) + html = builder.get_html("Tokyo") + assert isinstance(html, str) + assert " 1000 diff --git a/tokyo_map_preview.html b/tokyo_map_preview.html new file mode 100644 index 0000000..212063a --- /dev/null +++ b/tokyo_map_preview.html @@ -0,0 +1,426 @@ + + + + + +OpenMythos 3D City Map — Tokyo + + + +
+ +
+ 🗺 + Tokyo City Map + 🚗 交通量  |  23 地区 +
+ +
+
レイヤー選択
+
凡例
通常走行
やや混雑
渋滞
完全停滞
+
統計
+
+
23
地区数
+
4
混雑地区
+
+
+
+ +
+
🧭
+
+ + +
+
+ +
+
+ +
📊 高さ = データ強度ブロックが高いほど値が大きい
+
© OpenMythos City Intelligence  |  ドラッグ: 回転  /  スクロール: ズーム  /  右ドラッグ: 移動
+

3D マップを読み込み中...

+ + + + + + + + + \ No newline at end of file