Spaces:
Running
Running
File size: 17,881 Bytes
b5e87e1 aba9311 b5e87e1 aba9311 b5e87e1 1a2b0fa b5e87e1 1a2b0fa b5e87e1 1a2b0fa b5e87e1 aba9311 b5e87e1 aba9311 b5e87e1 1a2b0fa 6b71d3d 21b3112 1a2b0fa 21b3112 b5e87e1 6b71d3d aba9311 b5e87e1 1a2b0fa b5e87e1 1a2b0fa b5e87e1 1a2b0fa b5e87e1 1a2b0fa b5e87e1 1a2b0fa 21b3112 b5e87e1 1a2b0fa b5e87e1 1a2b0fa b5e87e1 21b3112 b5e87e1 21b3112 b5e87e1 1a2b0fa 21b3112 cec5bde b5e87e1 cec5bde b5e87e1 cec5bde b5e87e1 cec5bde b5e87e1 cec5bde b5e87e1 21b3112 b5e87e1 cec5bde b5e87e1 cec5bde b5e87e1 21b3112 b5e87e1 1a2b0fa b5e87e1 1a2b0fa b5e87e1 cec5bde b5e87e1 1a2b0fa b5e87e1 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 |
import folium
from folium import plugins
from core.helpers import decode_polyline
from src.infra.logger import get_logger
logger = get_logger(__name__)
CSS_STYLE = """
<style>
.map-card {
font-family: 'Roboto', 'Helvetica Neue', Arial, sans-serif;
width: 300px;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0,0,0,0.15);
}
.card-header {
padding: 12px 15px;
color: white;
font-weight: bold;
font-size: 14px;
display: flex;
align-items: center;
justify-content: space-between;
}
.card-body {
padding: 15px;
font-size: 13px;
color: #333;
background-color: #fff;
}
.card-subtitle {
font-weight: 600;
margin-bottom: 10px;
font-size: 14px;
color: #2c3e50;
border-bottom: 1px solid #eee;
padding-bottom: 8px;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.info-item {
background: #f8f9fa;
padding: 8px;
border-radius: 6px;
text-align: center;
border: 1px solid #e9ecef;
}
.info-label {
font-size: 10px;
color: #7f8c8d;
text-transform: uppercase;
margin-bottom: 2px;
letter-spacing: 0.5px;
}
.info-value {
font-weight: bold;
font-size: 12px;
color: #2c3e50;
}
.cost-badge {
background: #ffebee;
color: #c62828;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: bold;
display: inline-block;
margin-top: 10px;
width: 100%;
text-align: center;
box-sizing: border-box;
}
</style>
"""
def create_popup_html(title, subtitle, color, metrics, is_alternative=False):
"""
產生漂亮的 HTML 卡片字串
"""
bg_color = {
'green': '#2ecc71',
'blue': '#3498db',
'red': '#e74c3c',
'gray': '#7f8c8d',
'route': '#4285F4'
}.get(color, '#34495e')
grid_html = ""
for label, value in metrics.items():
if value:
grid_html += f"""
<div class="info-item">
<div class="info-label">{label}</div>
<div class="info-value">{value}</div>
</div>
"""
extra_html = ""
if is_alternative:
extra_html = """
<div class="cost-badge">⚠️ Alternative Option</div>
"""
html = f"""
<div class="map-card">
<div class="card-header" style="background: {bg_color};">
<span>{title}</span>
<i class="fa fa-info-circle" style="opacity: 0.8;"></i>
</div>
<div class="card-body">
<div class="card-subtitle">{subtitle}</div>
<div class="info-grid">
{grid_html}
</div>
{extra_html}
</div>
</div>
"""
return html
def create_animated_map(structured_data=None):
"""
LifeFlow AI - Interactive Map Generator
"""
# 1. 初始化地圖
center_lat, center_lon = 25.033, 121.565
m = folium.Map(location=[center_lat, center_lon], zoom_start=13, tiles="OpenStreetMap",
height=520,
width="100%"
)
m.get_root().html.add_child(folium.Element(CSS_STYLE))
if not structured_data:
return m._repr_html_()
try:
# --- 資料提取 ---
timeline = structured_data.get("timeline", [])
precise_result = structured_data.get("precise_traffic_result", {})
legs = precise_result.get("legs", [])
tasks_detail = structured_data.get("tasks_detail", [])
raw_tasks = structured_data.get("tasks", [])
route_info = structured_data.get("route", [])
# 建立查找表
index_to_name = {stop.get("stop_index"): stop.get("location") for stop in timeline}
poi_id_to_name = {}
for t in raw_tasks:
for cand in t.get("candidates", []):
if cand.get("poi_id"):
poi_id_to_name[cand["poi_id"]] = cand.get("name")
# 🔥 新增:Task ID 對應到 Stop Sequence (第幾站)
# 用來顯示 "Stop 1", "Stop 2" 而不是 "Task 123"
task_id_to_seq = {}
for r in route_info:
if r.get("task_id"):
task_id_to_seq[r["task_id"]] = r.get("step", 0)
bounds = []
THEMES = [
('#2ecc71', 'green'),
('#3498db', 'blue'),
('#e74c3c', 'red'),
('#f39c12', 'orange')
]
# --- Layer 1: 路線 ---
route_group = folium.FeatureGroup(name="🚗 Main Route", show=True)
all_coords = []
for leg in legs:
if leg.get("polyline"):
all_coords.extend(decode_polyline(leg["polyline"]))
if all_coords:
plugins.AntPath(
locations=all_coords, color="#555", weight=8, dash_array=[10, 20],
opacity=0.2, pulse_color='#FFFFFF', hardware_acceleration=True
).add_to(route_group)
for i, leg in enumerate(legs):
poly_str = leg.get("polyline")
if not poly_str: continue
decoded = decode_polyline(poly_str)
bounds.extend(decoded)
dist = leg.get("distance_meters", 0)
dur = leg.get("duration_seconds", 0) // 60
from_idx = leg.get("from_index")
to_idx = leg.get("to_index")
from_n = index_to_name.get(from_idx, f"Point {from_idx}")
to_n = index_to_name.get(to_idx, f"Point {to_idx}")
popup_html = create_popup_html(
title=f"LEG {i + 1} ROUTE",
subtitle=f"{from_n} ➔ {to_n}",
color="route",
metrics={"Duration": f"{dur} min", "Distance": f"{dist / 1000:.1f} km"}
)
folium.PolyLine(
locations=decoded, color="#4285F4", weight=6, opacity=0.9,
tooltip=f"Leg {i + 1}: {dur} min",
popup=folium.Popup(popup_html, max_width=320)
).add_to(route_group)
route_group.add_to(m)
# --- Layer 2: 備用方案 (Alternatives) ---
for idx, task in enumerate(tasks_detail):
theme_idx = (idx + 1) % len(THEMES)
theme_color, theme_name = THEMES[theme_idx]
# 🔥 優化 Group Name: 使用 Stop 順序和地點名稱
tid = task.get("task_id")
seq_num = task_id_to_seq.get(tid, "?") # 獲取順序 (如: 1, 2)
chosen_pid = task.get("chosen_poi", {}).get("poi_id")
loc_name = poi_id_to_name.get(chosen_pid, f"Place") # 獲取地點名稱
# 顯示格式: "🔹 Stop 1 Alt: Taipei 101"
group_name = f"🔹 Stop {seq_num} Alt: {loc_name}"
alt_group = folium.FeatureGroup(name=group_name, show=True)
chosen = task.get("chosen_poi", {})
center_lat, center_lng = chosen.get("lat"), chosen.get("lng")
if center_lat and center_lng:
for alt in task.get("alternative_pois", []):
alat, alng = alt.get("lat"), alt.get("lng")
if alat and alng:
bounds.append([alat, alng])
folium.PolyLine(
locations=[[center_lat, center_lng], [alat, alng]],
color=theme_color, weight=2, dash_array='5, 5', opacity=0.6
).add_to(alt_group)
poi_name = poi_id_to_name.get(alt.get("poi_id"), "Alternative Option")
extra_min = alt.get("delta_travel_time_min", 0)
extra_dist = alt.get("delta_travel_distance_m", 0)
popup_html = create_popup_html(
title="ALTERNATIVE POI",
subtitle=poi_name,
color="gray",
metrics={
"Extra duration": f"+{extra_min} min",
"Extra distance": f"+{extra_dist} m"
},
is_alternative=True
)
folium.CircleMarker(
location=[alat, alng], radius=7,
color=theme_color, fill=True, fill_color="white", fill_opacity=1,
popup=folium.Popup(popup_html, max_width=320),
tooltip=f"Alternatives: {poi_name}"
).add_to(alt_group)
alt_group.add_to(m)
# --- Layer 3: 主要站點 ---
stops_group = folium.FeatureGroup(name="📍 Main travel stops", show=True)
stops_group = folium.FeatureGroup(name="📍 Main travel stops", show=True)
for i, stop in enumerate(timeline):
coords = stop.get("coordinates", {})
lat, lng = coords.get("lat"), coords.get("lng")
if lat and lng:
bounds.append([lat, lng])
# 🔥🔥🔥 [修正重點] 讓主站點顏色與備用方案同步 🔥🔥🔥
# 原本邏輯:中間點全部強制為 Blue (THEMES[1])
# 新邏輯:使用與 Task 相同的餘數循環 (Modulo Cycle)
theme_idx = i % len(THEMES)
color_code, theme_name = THEMES[theme_idx]
loc_name = stop.get("location", "")
popup_html = create_popup_html(
title=f"STOP {i + 1}",
subtitle=loc_name,
color=theme_name,
metrics={
"Arrival time": stop.get("time", ""),
"Weather": stop.get("weather", ""),
"Air quality": stop.get("aqi", {}).get("label", "")
}
)
# 圖示形狀邏輯 (保持不變:起終點特殊,中間點通用)
if i == 0:
icon_type = 'play' # 起點
elif i == len(timeline) - 1:
icon_type = 'flag-checkered' # 終點
else:
icon_type = 'map-marker' # 中間點
icon = folium.Icon(color=theme_name, icon=icon_type, prefix='fa')
folium.Marker(
location=[lat, lng], icon=icon,
popup=folium.Popup(popup_html, max_width=320),
tooltip=f"Station {i + 1}: {loc_name}"
).add_to(stops_group)
stops_group.add_to(m)
# --- 控制元件 ---
# 🔥 修改: collapsed=True 預設收合
folium.LayerControl(collapsed=True).add_to(m)
if bounds:
m.fit_bounds(bounds, padding=(50, 50))
except Exception as e:
logger.error(f"Folium map error: {e}", exc_info=True)
return m._repr_html_()
m.save("tt.html")
return m._repr_html_()
if __name__ == "__main__":
test_data = {'status': 'OK', 'total_travel_time_min': 19, 'total_travel_distance_m': 7567, 'metrics': {'total_tasks': 2, 'completed_tasks': 2, 'completion_rate_pct': 100.0, 'original_distance_m': 15478, 'optimized_distance_m': 7567, 'distance_saved_m': 7911, 'distance_improvement_pct': 51.1, 'original_duration_min': 249, 'optimized_duration_min': 229, 'time_saved_min': 20, 'time_improvement_pct': 8.4, 'route_efficiency_pct': 91.7}, 'route': [{'step': 0, 'node_index': 0, 'arrival_time': '2025-11-27T10:00:00+08:00', 'departure_time': '2025-11-27T10:00:00+08:00', 'type': 'depot', 'task_id': None, 'poi_id': None, 'service_duration_min': 0}, {'step': 1, 'node_index': 1, 'arrival_time': '2025-11-27T10:17:57+08:00', 'departure_time': '2025-11-27T12:17:57+08:00', 'type': 'task_poi', 'task_id': '1', 'poi_id': 'ChIJH56c2rarQjQRphD9gvC8BhI', 'service_duration_min': 120}, {'step': 2, 'node_index': 7, 'arrival_time': '2025-11-27T12:19:05+08:00', 'departure_time': '2025-11-27T13:49:05+08:00', 'type': 'task_poi', 'task_id': '2', 'poi_id': 'ChIJQXcl6LarQjQRGUMnQ18F0lE', 'service_duration_min': 90}], 'visited_tasks': ['1', '2'], 'skipped_tasks': [], 'tasks_detail': [{'task_id': '1', 'priority': 'HIGH', 'visited': True, 'chosen_poi': {'node_index': 1, 'poi_id': 'ChIJH56c2rarQjQRphD9gvC8BhI', 'lat': 25.033976, 'lng': 121.56453889999999, 'interval_idx': 0}, 'alternative_pois': []}, {'task_id': '2', 'priority': 'HIGH', 'visited': True, 'chosen_poi': {'node_index': 7, 'poi_id': 'ChIJQXcl6LarQjQRGUMnQ18F0lE', 'lat': 25.033337099999997, 'lng': 121.56465960000001, 'interval_idx': 0}, 'alternative_pois': [{'node_index': 10, 'poi_id': 'ChIJwQWwVe6rQjQRRGA4WzYdO2U', 'lat': 25.036873699999997, 'lng': 121.5679503, 'interval_idx': 0, 'delta_travel_time_min': 7, 'delta_travel_distance_m': 2003}, {'node_index': 6, 'poi_id': 'ChIJ01XRzrurQjQRnp5ZsHbAAuE', 'lat': 25.039739800000003, 'lng': 121.5665985, 'interval_idx': 0, 'delta_travel_time_min': 9, 'delta_travel_distance_m': 2145}, {'node_index': 5, 'poi_id': 'ChIJaeY0sNCrQjQRBmpF8-RmywQ', 'lat': 25.0409656, 'lng': 121.5429975, 'interval_idx': 0, 'delta_travel_time_min': 24, 'delta_travel_distance_m': 6549}]}], 'tasks': [{'task_id': '1', 'priority': 'HIGH', 'service_duration_min': 120, 'time_window': {'earliest_time': '2025-11-27T10:00:00+08:00', 'latest_time': '2025-11-27T22:00:00+08:00'}, 'candidates': [{'poi_id': 'ChIJH56c2rarQjQRphD9gvC8BhI', 'name': 'Taipei 101', 'lat': 25.033976, 'lng': 121.56453889999999, 'rating': None, 'time_window': None}]}, {'task_id': '2', 'priority': 'HIGH', 'service_duration_min': 90, 'time_window': {'earliest_time': '2025-11-27T11:30:00+08:00', 'latest_time': '2025-11-27T14:30:00+08:00'}, 'candidates': [{'poi_id': 'ChIJbbvUtW6pQjQRLvK71hSUXN8', 'name': 'Din Tai Fung Mitsukoshi Nanxi Restaurant', 'lat': 25.0523074, 'lng': 121.5211037, 'rating': 4.4, 'time_window': None}, {'poi_id': 'ChIJA-U6X-epQjQR-T9BLmEfUlc', 'name': 'Din Tai Fung Xinsheng Branch', 'lat': 25.033889, 'lng': 121.5321338, 'rating': 4.6, 'time_window': None}, {'poi_id': 'ChIJbTKSE4KpQjQRXDZZI57v-pM', 'name': 'Din Tai Fung Xinyi Branch', 'lat': 25.0335035, 'lng': 121.53011799999999, 'rating': 4.4, 'time_window': None}, {'poi_id': 'ChIJaeY0sNCrQjQRBmpF8-RmywQ', 'name': 'Din Tai Fung Fuxing Restaurant', 'lat': 25.0409656, 'lng': 121.5429975, 'rating': 4.5, 'time_window': None}, {'poi_id': 'ChIJ01XRzrurQjQRnp5ZsHbAAuE', 'name': 'Din Tai Fung A4 Branch', 'lat': 25.039739800000003, 'lng': 121.5665985, 'rating': 4.5, 'time_window': None}, {'poi_id': 'ChIJQXcl6LarQjQRGUMnQ18F0lE', 'name': 'Din Tai Fung 101', 'lat': 25.033337099999997, 'lng': 121.56465960000001, 'rating': 4.5, 'time_window': None}, {'poi_id': 'ChIJ7y2qTJeuQjQRbpDcQImxLO0', 'name': 'Din Tai Fung Tianmu Restaurant', 'lat': 25.105072000000003, 'lng': 121.52447500000001, 'rating': 4.5, 'time_window': None}, {'poi_id': 'ChIJxRJqSBmoQjQR3gtSHvJgkZk', 'name': 'Din Tai Fung Mega City Restaurant', 'lat': 25.0135467, 'lng': 121.46675080000001, 'rating': 4.5, 'time_window': None}, {'poi_id': 'ChIJwQWwVe6rQjQRRGA4WzYdO2U', 'name': 'Din Tai Fung A13 Branch', 'lat': 25.036873699999997, 'lng': 121.5679503, 'rating': 4.5, 'time_window': None}, {'poi_id': 'ChIJ34CbayyoQjQRDbCGQgDk_RY', 'name': 'Ding Tai Feng', 'lat': 25.0059716, 'lng': 121.48668440000002, 'rating': 3.6, 'time_window': None}]}], 'global_info': {'language': 'en-US', 'plan_type': 'TRIP', 'departure_time': '2025-11-27T10:00:00+08:00', 'start_location': {'name': 'Taipei Main Station', 'lat': 25.0474428, 'lng': 121.5170955}}, 'traffic_summary': {'total_distance_km': 7.567, 'total_duration_min': 15}, 'precise_traffic_result': {'total_distance_meters': 7567, 'total_duration_seconds': 925, 'total_residence_time_minutes': 210, 'total_time_seconds': 13525, 'start_time': '2025-11-27 10:00:00+08:00', 'end_time': '2025-11-27 05:45:25+00:00', 'stops': [{'lat': 25.0474428, 'lng': 121.5170955}, {'lat': 25.033976, 'lng': 121.56453889999999}, {'lat': 25.033337099999997, 'lng': 121.56465960000001}], 'legs': [{'from_index': 0, 'to_index': 1, 'travel_mode': 'DRIVE', 'distance_meters': 7366, 'duration_seconds': 857, 'departure_time': '2025-11-27T02:00:00+00:00', 'polyline': 'm_{wCqttdVQbAu@KwDe@ELMTKl@C\\@LC^@PLPRJdC\\LLe@bEIj@_@|@I\\?P}EGMERqAAyAf@iD\\sC`AqKTmDJYXoDn@oFH{A`@{PNoB\\wChAyFfCaLrAcHnAyH|BeMZcCRmCNoGVcJPoI@{CIwBc@mHw@aNGeCD}BpBm^Ak@LyDB_@KmD]qDu@iGNs@SuASsBdUPZ@FcNP{ODcItBfANBxClA^D`A@As@D{@HyGpFBtD?pADlKB?|@FD`@@lAE'}, {'from_index': 1, 'to_index': 2, 'travel_mode': 'DRIVE', 'distance_meters': 201, 'duration_seconds': 68, 'departure_time': '2025-11-27T04:14:17+00:00', 'polyline': 'mmxwC}d~dVfBAXGJON]l@?CrC'}]}, 'solved_waypoints': [{'lat': 25.0474428, 'lng': 121.5170955}, {'lat': 25.033976, 'lng': 121.56453889999999}, {'lat': 25.033337099999997, 'lng': 121.56465960000001}], 'timeline': [{'stop_index': 0, 'time': '10:00', 'location': 'start point', 'address': '', 'weather': 'Rain, 20.76°C', 'aqi': {'aqi': 2, 'label': 'AQI 2 🟡'}, 'travel_time_from_prev': '0 mins', 'coordinates': {'lat': 25.0474428, 'lng': 121.5170955}}, {'stop_index': 1, 'time': '10:14', 'location': 'Taipei 101', 'address': '', 'weather': 'Rain, 20.75°C', 'aqi': {'aqi': 2, 'label': 'AQI 2 🟡'}, 'travel_time_from_prev': '14 mins', 'coordinates': {'lat': 25.033976, 'lng': 121.56453889999999}}, {'stop_index': 2, 'time': '12:15', 'location': 'Din Tai Fung Mitsukoshi Nanxi Restaurant', 'address': '', 'weather': 'Rain, 20.75°C', 'aqi': {'aqi': 2, 'label': 'AQI 2 🟡'}, 'travel_time_from_prev': '1 mins', 'coordinates': {'lat': 25.033337099999997, 'lng': 121.56465960000001}}]}
create_animated_map(structured_data=test_data)
|