Spaces:
Running
Running
| 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_() |