LifeFlow-AI / core /visualizers.py
Marco310's picture
"feat(viz): add alt distance and stop rating to map popups"
9f2e319
Raw History Blame Contribute Delete
12.5 kB
import folium
from folium import plugins
from folium.plugins import PolyLineTextPath
from core.helpers import decode_polyline
from src.infra.logger import get_logger
logger = get_logger(__name__)
# 🔥 CSS 修正:加入 body/html 的重置,確保地圖填滿 iframe,不會有白邊
CSS_STYLE = """
<style>
html, body {
height: 100%;
margin: 0;
padding: 0;
}
.folium-map {
height: 100%;
width: 100%;
}
.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',
'orange': '#f39c12',
'purple': '#9b59b6',
'gray': '#7f8c8d',
'route': '#4285F4'
}.get(color, '#34495e')
if color.startswith('#'):
bg_color = color
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
"""
center_lat, center_lon = 25.033, 121.565
if structured_data and structured_data.get("global_info", {}).get("start_location"):
sl = structured_data["global_info"]["start_location"]
if "lat" in sl and "lng" in sl:
center_lat, center_lon = sl["lat"], sl["lng"]
# 🔥 Map修正 1: height="100%" 讓它自動填滿父容器
m = folium.Map(location=[center_lat, center_lon], zoom_start=13, tiles="OpenStreetMap",
height="100%",
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}
# 🔥 Data Prep 1: 建立 POI 詳細資訊對照表 (包含 Name 和 Rating)
poi_ref = {}
for t in raw_tasks:
for cand in t.get("candidates", []):
pid = cand.get("poi_id")
if pid:
poi_ref[pid] = {
"name": cand.get("name"),
"rating": cand.get("rating")
}
# 🔥 Data Prep 2: 建立 Step 到 POI ID 的對照表 (用於將 Timeline 連結到 Rating)
step_to_poi_id = {}
for r in route_info:
step_id = r.get("step")
pid = r.get("poi_id")
if step_id is not None and pid:
step_to_poi_id[step_id] = pid
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'),
('#9b59b6', 'purple')
]
# --- Layer 1: 路線 ---
route_group = folium.FeatureGroup(name="🚗 Route Path", show=True)
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}")
theme_idx = to_idx % len(THEMES)
color_hex, color_name = THEMES[theme_idx]
popup_html = create_popup_html(
title=f"LEG {i + 1}",
subtitle=f"{from_n} ➔ {to_n}",
color=color_hex,
metrics={"Duration": f"{dur} min", "Distance": f"{dist / 1000:.1f} km"}
)
line = folium.PolyLine(
locations=decoded,
color=color_hex,
weight=6,
opacity=0.8,
tooltip=f"Leg {i + 1}: {dur} min",
popup=folium.Popup(popup_html, max_width=320)
)
line.add_to(route_group)
PolyLineTextPath(
line,
" ➤ ",
repeat=True,
offset=7,
attributes={'fill': color_hex, 'font-weight': 'bold', 'font-size': '18'}
).add_to(route_group)
route_group.add_to(m)
# --- Layer 2: 備用方案 (FeatureGroup 分組) ---
for idx, task in enumerate(tasks_detail):
tid = task.get("task_id")
step_seq = task_id_to_seq.get(tid, 0)
theme_idx = step_seq % len(THEMES)
theme_color, theme_name = THEMES[theme_idx]
chosen = task.get("chosen_poi", {})
alternatives = task.get("alternative_pois", [])
if not chosen or not alternatives:
continue
center_lat, center_lng = chosen.get("lat"), chosen.get("lng")
if not center_lat or not center_lng:
continue
# 取得主地點名稱
chosen_pid = chosen.get("poi_id")
chosen_name = poi_ref.get(chosen_pid, {}).get("name", f"Task {idx + 1}")
specific_alt_group = folium.FeatureGroup(
name=f"↳ Alt: {chosen_name}",
show=True
)
for alt in alternatives:
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.5
).add_to(specific_alt_group)
alt_pid = alt.get("poi_id")
poi_name = poi_ref.get(alt_pid, {}).get("name", "Alternative Option")
extra_min = alt.get("delta_travel_time_min", 0)
# 🔥 更新:獲取 delta distance (注意 JSON key 是 'm' 而非 'meters')
extra_dist = alt.get("delta_travel_distance_m", 0)
popup_html = create_popup_html(
title="ALTERNATIVE",
subtitle=poi_name,
color="gray",
metrics={
"Add. Time": f"+{extra_min} min",
"Add. Dist": f"+{extra_dist} m" # 🔥 新增距離顯示
},
is_alternative=True
)
folium.CircleMarker(
location=[alat, alng], radius=5,
color=theme_color, fill=True, fill_color="white", fill_opacity=1,
popup=folium.Popup(popup_html, max_width=320),
tooltip=f"Alt: {poi_name}"
).add_to(specific_alt_group)
specific_alt_group.add_to(m)
# --- Layer 3: 主要站點 ---
stops_group = folium.FeatureGroup(name="📍 Stops", show=True)
for i, stop in enumerate(timeline):
coords = stop.get("coordinates", {})
lat, lng = coords.get("lat"), coords.get("lng")
stop_idx = stop.get("stop_index")
if lat and lng:
bounds.append([lat, lng])
theme_idx = i % len(THEMES)
color_code, theme_name = THEMES[theme_idx]
loc_name = stop.get("location", "")
# 🔥 更新:透過 stop_index -> route -> poi_id -> rating 獲取評分
rating_display = ""
if stop_idx is not None:
# 嘗試從 route map 找到 poi_id
pid = step_to_poi_id.get(stop_idx)
if pid and pid in poi_ref:
rating_val = poi_ref[pid].get("rating")
if rating_val:
rating_display = f"{rating_val} ⭐"
metrics_data = {
"Arrival": stop.get("time", ""),
"Weather": stop.get("weather", "").split(',')[0],
"AQI": stop.get("aqi", {}).get("label", "").split(' ')[-1]
}
# 🔥 新增 Rating 到 Metrics
if rating_display:
metrics_data["Rating"] = rating_display
popup_html = create_popup_html(
title=f"STOP {i + 1}",
subtitle=loc_name,
color=theme_name,
metrics=metrics_data
)
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"{i + 1}. {loc_name}"
).add_to(stops_group)
stops_group.add_to(m)
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_()
return m._repr_html_()