{"spec_id":"flowmap-origin-destination","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nflowmap-origin-destination: Origin-Destination Flow Map\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 87/100 | Updated: 2026-05-20\n\"\"\"\n\nimport os\nimport sys\n\n\n# Remove this script's directory from sys.path to prevent self-import\n# (this file is named pygal.py, which shadows the installed pygal package)\n_here = os.path.dirname(os.path.abspath(__file__))\nwhile _here in sys.path:\n    sys.path.remove(_here)\ndel _here\n\nimport cairosvg\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\nIMPRINT = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\")\n\n# Data: Trade flows between major European ports\nnp.random.seed(42)\n\nports = {\n    \"Rotterdam\": (51.92, 4.48),\n    \"Hamburg\": (53.55, 9.99),\n    \"Antwerp\": (51.22, 4.40),\n    \"London\": (51.51, -0.13),\n    \"Le Havre\": (49.49, 0.11),\n    \"Barcelona\": (41.39, 2.17),\n    \"Marseille\": (43.30, 5.37),\n    \"Genoa\": (44.41, 8.93),\n    \"Valencia\": (39.47, -0.38),\n    \"Lisbon\": (38.72, -9.14),\n    \"Piraeus\": (37.94, 23.65),\n    \"Copenhagen\": (55.68, 12.57),\n    \"Gdansk\": (54.35, 18.65),\n    \"Dublin\": (53.35, -6.26),\n}\n\nflow_pairs = [\n    (\"Rotterdam\", \"Hamburg\", 850),\n    (\"Rotterdam\", \"Antwerp\", 720),\n    (\"Rotterdam\", \"London\", 680),\n    (\"Hamburg\", \"Copenhagen\", 450),\n    (\"Hamburg\", \"Gdansk\", 380),\n    (\"Antwerp\", \"Le Havre\", 520),\n    (\"Le Havre\", \"Barcelona\", 340),\n    (\"Barcelona\", \"Valencia\", 420),\n    (\"Barcelona\", \"Marseille\", 480),\n    (\"Marseille\", \"Genoa\", 390),\n    (\"Genoa\", \"Barcelona\", 360),\n    (\"Valencia\", \"Lisbon\", 280),\n    (\"Lisbon\", \"Le Havre\", 310),\n    (\"Piraeus\", \"Genoa\", 290),\n    (\"Piraeus\", \"Marseille\", 250),\n    (\"London\", \"Dublin\", 410),\n    (\"Rotterdam\", \"Copenhagen\", 370),\n    (\"Hamburg\", \"London\", 320),\n    (\"Antwerp\", \"Barcelona\", 260),\n    (\"Copenhagen\", \"Gdansk\", 220),\n]\n\nflow_data = []\nfor origin, dest, flow in flow_pairs:\n    o_lat, o_lon = ports[origin]\n    d_lat, d_lon = ports[dest]\n    flow_data.append(\n        {\n            \"origin_lat\": o_lat,\n            \"origin_lon\": o_lon,\n            \"dest_lat\": d_lat,\n            \"dest_lon\": d_lon,\n            \"flow\": flow,\n            \"origin_name\": origin,\n            \"dest_name\": dest,\n        }\n    )\n\nlat_min, lat_max = 35.0, 58.0\nlon_min, lon_max = -12.0, 28.0\n\ncoastlines = [\n    # Iberian Peninsula\n    [\n        (-9.5, 37.0),\n        (-9.2, 37.5),\n        (-9.0, 38.0),\n        (-9.0, 38.7),\n        (-8.8, 39.5),\n        (-8.5, 40.5),\n        (-8.5, 42.0),\n        (-5.0, 43.5),\n        (-2.0, 43.5),\n        (0.5, 43.0),\n        (3.0, 42.5),\n        (1.5, 41.5),\n        (0.5, 40.5),\n        (0.0, 39.5),\n        (-0.5, 38.0),\n        (-1.5, 37.5),\n        (-5.0, 36.0),\n        (-6.0, 36.2),\n        (-9.5, 37.0),\n    ],\n    # Southern France + Italian Riviera + Adriatic\n    [\n        (3.0, 42.5),\n        (4.5, 43.2),\n        (6.0, 43.0),\n        (7.5, 43.5),\n        (9.5, 44.0),\n        (10.5, 44.5),\n        (12.0, 44.8),\n        (13.5, 45.5),\n        (13.0, 45.0),\n        (12.5, 44.0),\n        (14.0, 42.5),\n        (15.5, 41.5),\n        (16.0, 41.0),\n    ],\n    # Italian peninsula boot\n    [\n        (9.5, 44.0),\n        (10.0, 43.5),\n        (11.0, 42.0),\n        (12.5, 41.0),\n        (14.0, 40.0),\n        (15.5, 39.5),\n        (16.5, 38.5),\n        (18.0, 40.0),\n        (17.5, 40.5),\n        (16.0, 41.0),\n    ],\n    # Greece and Aegean coast\n    [\n        (20.0, 40.0),\n        (21.0, 39.0),\n        (22.0, 37.5),\n        (23.0, 38.0),\n        (24.0, 38.5),\n        (25.5, 39.0),\n        (26.0, 40.0),\n        (25.5, 41.0),\n        (24.0, 41.5),\n        (22.0, 41.0),\n        (20.0, 40.0),\n    ],\n    # Northern European coast (France to Poland)\n    [\n        (-6.0, 50.0),\n        (0.0, 50.5),\n        (2.0, 51.0),\n        (4.0, 52.0),\n        (5.0, 53.0),\n        (6.5, 53.5),\n        (8.0, 54.0),\n        (9.0, 54.5),\n        (10.0, 55.0),\n        (11.0, 55.5),\n        (12.0, 56.0),\n        (14.0, 55.5),\n        (16.0, 55.5),\n        (18.0, 55.0),\n        (20.0, 54.5),\n    ],\n    # Great Britain\n    [\n        (-6.0, 50.0),\n        (-5.0, 50.0),\n        (-3.5, 50.5),\n        (-2.0, 51.0),\n        (-0.5, 51.5),\n        (1.5, 51.5),\n        (1.0, 52.5),\n        (0.0, 53.0),\n        (-2.0, 54.0),\n        (-3.0, 54.0),\n        (-4.0, 54.5),\n        (-5.0, 55.0),\n        (-5.5, 56.0),\n        (-6.0, 56.0),\n        (-5.5, 57.0),\n        (-5.0, 58.0),\n    ],\n    # Ireland\n    [\n        (-10.0, 51.5),\n        (-8.5, 51.5),\n        (-6.0, 51.5),\n        (-5.8, 52.5),\n        (-6.0, 53.5),\n        (-7.0, 54.5),\n        (-8.0, 55.5),\n        (-9.5, 55.0),\n        (-10.0, 53.5),\n        (-10.0, 51.5),\n    ],\n]\n\n\ndef make_bezier_curves(flows, n_segments=20):\n    curves = []\n    for flow in flows:\n        o_lat, o_lon = flow[\"origin_lat\"], flow[\"origin_lon\"]\n        d_lat, d_lon = flow[\"dest_lat\"], flow[\"dest_lon\"]\n        magnitude = flow[\"flow\"]\n        label = f\"{flow['origin_name']} → {flow['dest_name']}: {magnitude} units\"\n        mid_lon = (o_lon + d_lon) / 2\n        mid_lat = (o_lat + d_lat) / 2\n        dx, dy = d_lon - o_lon, d_lat - o_lat\n        length = np.sqrt(dx * dx + dy * dy)\n        if length > 0:\n            perp_x, perp_y = -dy / length, dx / length\n            offset = min(length * 0.2, 2.5)\n            ctrl_lon = mid_lon + perp_x * offset\n            ctrl_lat = mid_lat + perp_y * offset\n        else:\n            ctrl_lon, ctrl_lat = mid_lon, mid_lat\n        for i in range(n_segments + 1):\n            t = i / n_segments\n            lon = (1 - t) ** 2 * o_lon + 2 * (1 - t) * t * ctrl_lon + t**2 * d_lon\n            lat = (1 - t) ** 2 * o_lat + 2 * (1 - t) * t * ctrl_lat + t**2 * d_lat\n            curves.append({\"value\": (lon, lat), \"label\": label})\n        curves.append({\"value\": (None, None)})\n    return curves\n\n\n# Style — INK_SOFT for coastlines (neutral), then Okabe-Ito for data series\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=(INK_SOFT,) + IMPRINT,\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    tooltip_font_size=36,\n    font_family=\"sans-serif\",\n    stroke_width=2.5,\n)\n\n# Chart\nchart = pygal.XY(\n    width=3200,\n    height=1800,\n    style=custom_style,\n    title=\"flowmap-origin-destination · python · pygal · anyplot.ai\",\n    x_title=\"Longitude\",\n    y_title=\"Latitude\",\n    show_legend=True,\n    legend_at_bottom=False,\n    legend_box_size=40,\n    truncate_legend=-1,\n    margin=80,\n    margin_top=160,\n    margin_bottom=120,\n    margin_right=200,\n    margin_left=180,\n    range=(lat_min, lat_max),\n    xrange=(lon_min, lon_max),\n    show_dots=True,\n    stroke=True,\n    dots_size=16,\n    tooltip_border_radius=10,\n    show_x_guides=True,\n    show_y_guides=True,\n)\n\n# Coastlines — drawn first (lowest z-order)\ncoastline_points = []\nfor segment in coastlines:\n    for lon, lat in segment:\n        coastline_points.append({\"value\": (lon, lat), \"label\": \"Coastline\"})\n    coastline_points.append({\"value\": (None, None)})\n\nchart.add(\"Coastlines\", coastline_points, stroke=True, show_dots=False, stroke_style={\"width\": 2})\n\n# Sort by magnitude so high flows are drawn last (on top)\nsorted_flows = sorted(flow_data, key=lambda f: f[\"flow\"])\nlow_flows = [f for f in sorted_flows if f[\"flow\"] < 400]\nmedium_flows = [f for f in sorted_flows if 400 <= f[\"flow\"] < 600]\nhigh_flows = [f for f in sorted_flows if f[\"flow\"] >= 600]\n\nchart.add(\n    \"Low (220–399 units)\",\n    make_bezier_curves(low_flows),\n    stroke=True,\n    show_dots=False,\n    stroke_style={\"width\": 6, \"linecap\": \"round\"},\n)\nchart.add(\n    \"Medium (400–599 units)\",\n    make_bezier_curves(medium_flows),\n    stroke=True,\n    show_dots=False,\n    stroke_style={\"width\": 14, \"linecap\": \"round\"},\n)\nchart.add(\n    \"High (600–850 units)\",\n    make_bezier_curves(high_flows),\n    stroke=True,\n    show_dots=False,\n    stroke_style={\"width\": 28, \"linecap\": \"round\"},\n)\n\n# Port cities — drawn last (on top of all arcs)\ncity_points = []\ndrawn_locations = set()\nfor flow in flow_data:\n    o_key = (round(flow[\"origin_lat\"], 2), round(flow[\"origin_lon\"], 2))\n    if o_key not in drawn_locations:\n        city_points.append({\"value\": (flow[\"origin_lon\"], flow[\"origin_lat\"]), \"label\": flow[\"origin_name\"]})\n        drawn_locations.add(o_key)\n    d_key = (round(flow[\"dest_lat\"], 2), round(flow[\"dest_lon\"], 2))\n    if d_key not in drawn_locations:\n        city_points.append({\"value\": (flow[\"dest_lon\"], flow[\"dest_lat\"]), \"label\": flow[\"dest_name\"]})\n        drawn_locations.add(d_key)\n\nchart.add(\"Port Cities\", city_points, dots_size=18, stroke=False)\n\n# Save — inject arc transparency via CSS (pygal stroke_style doesn't expose opacity)\nsvg_bytes = chart.render()\nsvg_str = svg_bytes.decode(\"utf-8\")\narc_opacity = \".serie-1{opacity:0.6;}.serie-2{opacity:0.6;}.serie-3{opacity:0.6;}\"\nsvg_str = svg_str.replace(\"</style>\", arc_opacity + \"</style>\", 1)\nsvg_out = svg_str.encode(\"utf-8\")\ncairosvg.svg2png(bytestring=svg_out, write_to=f\"plot-{THEME}.png\")\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(svg_out)\n"}