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 = """ """ 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"""
{label}
{value}
""" extra_html = "" if is_alternative: extra_html = """
⚠️ Alternative Option
""" html = f"""
{title}
{subtitle}
{grid_html}
{extra_html}
""" 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_()