| |
| """ |
| Live Supply Chain Map — visual dashboard for judges. |
| |
| Shows a world map with: |
| - Ports (green=open, red=closed, yellow=reduced) |
| - Shipping routes (blue=open, red=blocked) |
| - Shipments moving along routes |
| - Disruptions highlighted |
| - Real-time stats panel |
| |
| Generates an HTML file that opens in browser. |
| Run: python3 visualize.py |
| """ |
|
|
| import json |
| import webbrowser |
| import os |
| from world import SupplyChainWorld |
| from real_data import REAL_PORTS, REAL_ROUTES |
|
|
|
|
| def generate_map_html(world: SupplyChainWorld, filename: str = "supply_chain_map.html") -> str: |
| """Generate an interactive HTML map of the supply chain.""" |
|
|
| |
| ports_js = [] |
| for p in REAL_PORTS: |
| status = world.ports.get(p["id"], {}).get("status", "open") |
| color = {"open": "#22c55e", "closed": "#ef4444", "reduced": "#eab308"}.get(status, "#666") |
| throughput = p.get("throughput_teu", 0) |
| ports_js.append({ |
| "id": p["id"], "name": p["name"], "lat": p["lat"], "lon": p["lon"], |
| "status": status, "color": color, "throughput": throughput, |
| "country": p.get("country", ""), |
| }) |
|
|
| |
| routes_js = [] |
| for r in REAL_ROUTES: |
| src_port = next((p for p in REAL_PORTS if p["id"] == r["from"]), None) |
| dst_port = next((p for p in REAL_PORTS if p["id"] == r["to"]), None) |
| if not src_port or not dst_port: |
| continue |
| route_info = world.routes.get(r["from"], {}).get(r["to"], {}) |
| status = route_info.get("status", "open") |
| color = "#3b82f6" if status == "open" else "#ef4444" |
| routes_js.append({ |
| "from_lat": src_port["lat"], "from_lon": src_port["lon"], |
| "to_lat": dst_port["lat"], "to_lon": dst_port["lon"], |
| "cost": r["cost_per_teu"], "days": r["transit_days"], |
| "mode": r.get("mode", "ocean"), "status": status, "color": color, |
| "carrier": r.get("carrier", ""), |
| }) |
|
|
| |
| shipments_js = [] |
| for s in world.shipments.values(): |
| port = world.ports.get(s.current_location, {}) |
| p_data = next((p for p in REAL_PORTS if p["id"] == s.current_location), None) |
| if p_data: |
| color = {"pending": "#fbbf24", "in_transit": "#3b82f6", "delivered": "#22c55e", "lost": "#ef4444"}.get(s.status, "#666") |
| shipments_js.append({ |
| "id": s.id, "product": s.product, "value": s.value_usd, |
| "lat": p_data["lat"], "lon": p_data["lon"], |
| "status": s.status, "color": color, |
| }) |
|
|
| |
| disruptions_js = [ |
| {"type": d.type, "severity": d.severity, "desc": d.description, |
| "active": d.active, "start": d.start_day, "end": d.end_day} |
| for d in world.disruptions |
| ] |
|
|
| |
| total_value = sum(s.value_usd for s in world.shipments.values()) |
| stats = { |
| "day": world.day, "total_days": world.total_days, |
| "pending": sum(1 for s in world.shipments.values() if s.status == "pending"), |
| "in_transit": sum(1 for s in world.shipments.values() if s.status == "in_transit"), |
| "delivered": sum(1 for s in world.shipments.values() if s.status == "delivered"), |
| "lost": sum(1 for s in world.shipments.values() if s.status == "lost"), |
| "delivered_value": world.delivered_value, |
| "lost_value": world.lost_value, |
| "shipping_cost": world.total_shipping_cost, |
| "total_value": total_value, |
| } |
|
|
| html = f"""<!DOCTYPE html> |
| <html><head> |
| <meta charset="utf-8"> |
| <title>SupplyChainEnv — Live Map</title> |
| <style> |
| * {{ margin:0; padding:0; box-sizing:border-box; }} |
| body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; }} |
| .header {{ padding: 16px 24px; background: #1e293b; border-bottom: 1px solid #334155; display: flex; justify-content: space-between; align-items: center; }} |
| .header h1 {{ font-size: 20px; color: #f8fafc; }} |
| .header .badge {{ background: #22c55e; color: #000; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; }} |
| .container {{ display: flex; height: calc(100vh - 56px); }} |
| .map {{ flex: 1; position: relative; background: #1e293b; overflow: hidden; }} |
| .sidebar {{ width: 360px; background: #0f172a; border-left: 1px solid #334155; overflow-y: auto; padding: 16px; }} |
| svg {{ width: 100%; height: 100%; }} |
| .stat-card {{ background: #1e293b; border-radius: 8px; padding: 12px 16px; margin-bottom: 8px; }} |
| .stat-label {{ font-size: 11px; color: #94a3b8; text-transform: uppercase; letter-spacing: 1px; }} |
| .stat-value {{ font-size: 24px; font-weight: 700; margin-top: 2px; }} |
| .stat-value.green {{ color: #22c55e; }} |
| .stat-value.red {{ color: #ef4444; }} |
| .stat-value.blue {{ color: #3b82f6; }} |
| .stat-value.yellow {{ color: #eab308; }} |
| .disruption {{ background: #1e293b; border-left: 3px solid #ef4444; border-radius: 4px; padding: 8px 12px; margin-bottom: 6px; font-size: 13px; }} |
| .disruption.active {{ border-color: #ef4444; background: #291515; }} |
| .disruption.upcoming {{ border-color: #eab308; }} |
| .section-title {{ font-size: 13px; font-weight: 600; color: #94a3b8; margin: 16px 0 8px; text-transform: uppercase; letter-spacing: 1px; }} |
| .port-label {{ font-size: 10px; fill: #cbd5e1; text-anchor: middle; pointer-events: none; }} |
| .tooltip {{ position: absolute; background: #1e293b; border: 1px solid #475569; border-radius: 6px; padding: 8px 12px; font-size: 12px; pointer-events: none; display: none; z-index: 10; }} |
| </style> |
| </head><body> |
| <div class="header"> |
| <h1>SupplyChainEnv — Global Trade Network</h1> |
| <span class="badge">Day {stats['day']}/{stats['total_days']} | {world.difficulty.upper()}</span> |
| </div> |
| <div class="container"> |
| <div class="map"> |
| <svg viewBox="-180 -90 360 180" preserveAspectRatio="xMidYMid meet"> |
| <rect x="-180" y="-90" width="360" height="180" fill="#0c1222"/> |
| <!-- Simplified world outline --> |
| <ellipse cx="0" cy="0" rx="170" ry="80" fill="none" stroke="#1e293b" stroke-width="0.5"/> |
| <!-- Routes --> |
| {"".join(f'<line x1="{r["from_lon"]}" y1="{-r["from_lat"]}" x2="{r["to_lon"]}" y2="{-r["to_lat"]}" stroke="{r["color"]}" stroke-width="{"0.8" if r["status"]=="open" else "1.2"}" opacity="{"0.4" if r["status"]=="open" else "0.8"}" stroke-dasharray="{"" if r["status"]=="open" else "2,2"}"/>' for r in routes_js)} |
| <!-- Ports --> |
| {"".join(f'<circle cx="{p["lon"]}" cy="{-p["lat"]}" r="2.5" fill="{p["color"]}" stroke="#fff" stroke-width="0.3"/><text x="{p["lon"]}" y="{-p["lat"]-4}" class="port-label">{p["name"]}</text>' for p in ports_js)} |
| <!-- Shipments --> |
| {"".join(f'<circle cx="{s["lon"]}" cy="{-s["lat"]}" r="1.5" fill="{s["color"]}" opacity="0.9"><animate attributeName="r" values="1;2.5;1" dur="2s" repeatCount="indefinite"/></circle>' for s in shipments_js)} |
| </svg> |
| </div> |
| <div class="sidebar"> |
| <div class="stat-card"><div class="stat-label">Shipments Pending</div><div class="stat-value yellow">{stats['pending']}</div></div> |
| <div class="stat-card"><div class="stat-label">In Transit</div><div class="stat-value blue">{stats['in_transit']}</div></div> |
| <div class="stat-card"><div class="stat-label">Delivered</div><div class="stat-value green">{stats['delivered']} (${stats['delivered_value']:,.0f})</div></div> |
| <div class="stat-card"><div class="stat-label">Lost</div><div class="stat-value red">{stats['lost']} (${stats['lost_value']:,.0f})</div></div> |
| <div class="stat-card"><div class="stat-label">Shipping Cost</div><div class="stat-value">${stats['shipping_cost']:,.0f}</div></div> |
| <div class="stat-card"><div class="stat-label">Total Cargo Value</div><div class="stat-value">${stats['total_value']:,.0f}</div></div> |
| |
| <div class="section-title">Active Disruptions</div> |
| {"".join(f'<div class="disruption {"active" if d["active"] else "upcoming"}"><strong>{d["type"].upper()}</strong> [{d["severity"]}]<br/>{d["desc"][:80]}<br/>Day {d["start"]}-{d["end"]}</div>' for d in disruptions_js)} |
| |
| <div class="section-title">Legend</div> |
| <div style="font-size:12px; line-height:1.8;"> |
| <span style="color:#22c55e;">●</span> Port Open |
| <span style="color:#ef4444;">●</span> Port Closed |
| <span style="color:#eab308;">●</span> Reduced<br/> |
| <span style="color:#3b82f6;">▬</span> Route Open |
| <span style="color:#ef4444;">▬</span> Route Blocked<br/> |
| <span style="color:#fbbf24;">●</span> Pending |
| <span style="color:#3b82f6;">●</span> In Transit |
| <span style="color:#22c55e;">●</span> Delivered |
| </div> |
| |
| <div class="section-title" style="margin-top:20px;">Data Sources</div> |
| <div style="font-size:11px; color:#64748b; line-height:1.6;"> |
| Port throughput: UNCTAD 2023<br/> |
| Shipping rates: Freightos Baltic Index Q1 2024<br/> |
| Disruptions: Lloyd's List, WHO, USGS 2017-2024<br/> |
| LPI scores: World Bank 2023 |
| </div> |
| </div> |
| </div> |
| </body></html>""" |
|
|
| with open(filename, "w") as f: |
| f.write(html) |
| return os.path.abspath(filename) |
|
|
|
|
| def main(): |
| """Run the demo agent and generate map at each stage.""" |
| from server.supply_chain_environment import SupplyChainEnvironment |
| from models import SupplyChainAction |
|
|
| def tool(name, args=None): |
| return SupplyChainAction(action_type="ToolCallAction", tool_name=name, arguments=args or {}) |
|
|
| env = SupplyChainEnvironment() |
| env.reset(seed=42, difficulty="hard") |
|
|
| |
| obs = env.step(tool("view_shipments")) |
| for s in obs.tool_result["shipments"]: |
| if s["status"] == "pending": |
| path_obs = env.step(tool("find_path", {"from_port": s["current_location"], "to_warehouse": s["destination"]})) |
| path = path_obs.tool_result.get("path") |
| if path: |
| env.step(tool("route_shipment", {"shipment_id": s["id"], "route": path})) |
|
|
| |
| for _ in range(15): |
| obs = env.step(tool("advance_day")) |
| if obs.done: |
| break |
|
|
| |
| path = generate_map_html(env._world) |
| print(f"Map generated: {path}") |
| webbrowser.open(f"file://{path}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|