LifeFlow-AI / core /visualizers.py
Marco310's picture
feat: improve UI/UX
ecbe9e4
raw
history blame
11.4 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%" 讓它自動填滿父容器 (ui/theme.py 定義的 650px)
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}
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_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: 備用方案 (分組修正) ---
# 🔥 Map修正 2: 不再使用單一的 alt_group,而是為每個任務建立獨立的 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_name = poi_id_to_name.get(chosen.get("poi_id"), f"Task {idx + 1}")
# 🔥 建立該任務專屬的 Group,並預設顯示 (show=True)
# 圖層名稱範例: "↳ Alt: Taipei 101"
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)
poi_name = poi_id_to_name.get(alt.get("poi_id"), "Alternative Option")
extra_min = alt.get("delta_travel_time_min", 0)
popup_html = create_popup_html(
title="ALTERNATIVE",
subtitle=poi_name,
color="gray",
metrics={
"Add. Time": f"+{extra_min} min",
},
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)
# 將這個任務的專屬 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")
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", "")
popup_html = create_popup_html(
title=f"STOP {i + 1}",
subtitle=loc_name,
color=theme_name,
metrics={
"Arrival": stop.get("time", ""),
"Weather": stop.get("weather", "").split(',')[0],
"AQI": stop.get("aqi", {}).get("label", "").split(' ')[-1]
}
)
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_()