File size: 17,881 Bytes
b5e87e1
 
aba9311
 
 
 
 
b5e87e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aba9311
b5e87e1
1a2b0fa
 
 
 
 
b5e87e1
 
 
 
1a2b0fa
b5e87e1
 
 
 
 
 
 
 
 
 
1a2b0fa
b5e87e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aba9311
b5e87e1
 
aba9311
b5e87e1
 
 
 
1a2b0fa
6b71d3d
21b3112
1a2b0fa
21b3112
 
b5e87e1
 
6b71d3d
aba9311
b5e87e1
 
 
 
 
 
 
 
 
1a2b0fa
b5e87e1
1a2b0fa
b5e87e1
 
 
 
 
 
 
 
1a2b0fa
 
 
 
 
 
 
b5e87e1
 
 
1a2b0fa
 
 
 
b5e87e1
 
1a2b0fa
21b3112
b5e87e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1a2b0fa
b5e87e1
 
 
 
 
 
 
 
 
 
 
 
 
 
1a2b0fa
 
 
 
 
 
 
 
 
 
b5e87e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21b3112
 
b5e87e1
 
 
 
 
 
 
 
21b3112
b5e87e1
 
 
 
1a2b0fa
21b3112
cec5bde
 
b5e87e1
 
 
cec5bde
b5e87e1
 
cec5bde
 
 
 
 
 
b5e87e1
 
cec5bde
b5e87e1
cec5bde
 
 
b5e87e1
21b3112
 
 
b5e87e1
 
cec5bde
 
 
 
 
 
 
 
 
b5e87e1
cec5bde
b5e87e1
 
 
21b3112
b5e87e1
 
 
 
 
1a2b0fa
 
b5e87e1
 
 
 
1a2b0fa
b5e87e1
 
 
cec5bde
 
b5e87e1
 
1a2b0fa
b5e87e1
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
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)