LifeFlow-AI / core /visualizers.py
Marco310's picture
feat: Improved stop point color in visualizer.py
cec5bde
raw
history blame
17.9 kB
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)