{"spec_id":"sankey-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nsankey-basic: Basic Sankey Diagram\nLibrary: pygal 3.1.3 | Python 3.13.14\nQuality: 89/100 | Updated: 2026-07-25\n\"\"\"\n\nimport os\nimport sys\nfrom itertools import permutations\n\n\n# Pop script dir so this file (pygal.py) doesn't shadow the installed pygal package\n_script_dir = sys.path.pop(0)\nimport cairosvg\nfrom pygal.style import Style\n\n\nsys.path.insert(0, _script_dir)\n\n# Theme tokens\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\", \"#99B314\")\n\n# pygal Style is the single source of truth for all Imprint tokens.\n# pygal has no native Sankey chart class, so the diagram itself is built as raw\n# SVG below (also true of every other pygal Sankey in this codebase) — but every\n# color/font value the SVG uses is read back off this Style object rather than\n# hardcoded twice.\nchart_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=IMPRINT,\n    title_font_size=66,\n    label_font_size=56,\n    value_font_size=36,\n    font_family=\"sans-serif\",\n)\n\n# Read all visual tokens from the Style object — single source of truth\nBG = chart_style.background\nFG = chart_style.foreground\nFG_SUBTLE = chart_style.foreground_subtle\nPALETTE = chart_style.colors\nLABEL_SIZE = chart_style.label_font_size\nVALUE_SIZE = chart_style.value_font_size\nFONT = chart_style.font_family\n\n# Canvas — 3200x1800 landscape (Step 0 hard contract)\nWIDTH = 3200\nHEIGHT = 1800\nMARGIN_L = 620\nMARGIN_R = 400\nMARGIN_T = 170\nMARGIN_B = 100\nNODE_W = 36\nNODE_GAP = 26\nBREATHING_ROOM = 0.90  # shrink node/link scale so columns don't touch top/bottom margins\n\n# Dominant flows get higher opacity to direct attention to key pathways\nALPHA_DOMINANT = 0.72\nALPHA_DEFAULT = 0.48\nDOMINANT_THRESHOLD = 20  # MLD (million liters per day)\n\n# Thinnest ribbons get a same-color stroke halo so they stay visible once the\n# 3200px canvas is downscaled to the ~400px mobile width (previous review:\n# ~2px flows vanished on mobile)\nMIN_RIBBON_PX = 48\n\n# Data — municipal water distribution, sources to end-use sectors (MLD)\nnode_labels = [\n    \"Mountain Reservoir\",\n    \"Groundwater Wells\",\n    \"River Intake\",\n    \"Desalination Plant\",\n    \"Residential\",\n    \"Agriculture\",\n    \"Industrial\",\n    \"Municipal\",\n]\nN_SRC = 4  # first 4 are sources; rest are targets\n\nflows = [\n    (0, 4, 28),  # Mountain Reservoir -> Residential  <- dominant\n    (0, 5, 12),  # Mountain Reservoir -> Agriculture\n    (0, 7, 6),  # Mountain Reservoir -> Municipal\n    (1, 4, 18),  # Groundwater Wells -> Residential\n    (1, 5, 32),  # Groundwater Wells -> Agriculture   <- dominant\n    (1, 6, 9),  # Groundwater Wells -> Industrial\n    (1, 7, 3),  # Groundwater Wells -> Municipal\n    (2, 4, 10),  # River Intake -> Residential\n    (2, 5, 15),  # River Intake -> Agriculture\n    (2, 6, 22),  # River Intake -> Industrial         <- dominant\n    (2, 7, 5),  # River Intake -> Municipal\n    (3, 4, 14),  # Desalination Plant -> Residential\n    (3, 5, 2),  # Desalination Plant -> Agriculture\n    (3, 6, 8),  # Desalination Plant -> Industrial\n    (3, 7, 4),  # Desalination Plant -> Municipal\n]\n\n# Compute per-node totals\nnode_total = [0] * len(node_labels)\nfor src, tgt, val in flows:\n    node_total[src] += val\n    node_total[tgt] += val\n\n# Crossing-minimization: with 4x4 nodes both columns can be exhaustively\n# searched (4! x 4! = 576 combinations) to find the vertical stacking order\n# that minimizes link crossings — previous review flagged the unordered\n# layout as a dense \"hairball\" in the middle of the diagram.\ntgt_indices = list(range(N_SRC, len(node_labels)))\n\n\ndef _count_crossings(src_perm, tgt_perm):\n    src_pos = {node: pos for pos, node in enumerate(src_perm)}\n    tgt_pos = {node: pos for pos, node in enumerate(tgt_perm)}\n    crossings = 0\n    for i in range(len(flows)):\n        s1, t1, _ = flows[i]\n        for j in range(i + 1, len(flows)):\n            s2, t2, _ = flows[j]\n            if s1 == s2 or t1 == t2:\n                continue\n            if (src_pos[s1] - src_pos[s2]) * (tgt_pos[t1] - tgt_pos[t2]) < 0:\n                crossings += 1\n    return crossings\n\n\nsrc_order, tgt_order = min(\n    ((sp, tp) for sp in permutations(range(N_SRC)) for tp in permutations(tgt_indices)),\n    key=lambda pair: _count_crossings(*pair),\n)\n\n# Layout: vertical scale so the taller column fills available height, with\n# breathing room left top/bottom (previous review: whitespace too tight)\navail_h = HEIGHT - MARGIN_T - MARGIN_B\nn_src_gaps = N_SRC - 1\nn_tgt_gaps = len(node_labels) - N_SRC - 1\nscale = (avail_h - max(n_src_gaps, n_tgt_gaps) * NODE_GAP) / sum(node_total[:N_SRC]) * BREATHING_ROOM\n\n# Node y positions, indexed by original node index. Stacking order within\n# each column follows src_order/tgt_order (the crossing-minimized order),\n# not the raw node index.\nnode_x = [0.0] * len(node_labels)\nnode_y0 = [0.0] * len(node_labels)\nnode_y1 = [0.0] * len(node_labels)\n\n# Source nodes (left column)\nsrc_block_h = sum(node_total[i] * scale for i in range(N_SRC)) + n_src_gaps * NODE_GAP\ny = MARGIN_T + (avail_h - src_block_h) / 2\nfor i in src_order:\n    h = node_total[i] * scale\n    node_x[i] = MARGIN_L\n    node_y0[i] = y\n    node_y1[i] = y + h\n    y += h + NODE_GAP\n\n# Target nodes (right column)\ntgt_block_h = sum(node_total[i] * scale for i in tgt_indices) + n_tgt_gaps * NODE_GAP\ny = MARGIN_T + (avail_h - tgt_block_h) / 2\nfor i in tgt_order:\n    h = node_total[i] * scale\n    node_x[i] = WIDTH - MARGIN_R - NODE_W\n    node_y0[i] = y\n    node_y1[i] = y + h\n    y += h + NODE_GAP\n\n# Link paths (cubic bezier ribbons). Each node's incident flows are stacked\n# by the *other* endpoint's column position (not data-definition order) so\n# ribbons don't gratuitously twist right where they leave/enter a node —\n# this complements the src_order/tgt_order column reorder above in cutting\n# down the crossing \"hairball\" the previous review flagged.\nsrc_pos = {node: pos for pos, node in enumerate(src_order)}\ntgt_pos = {node: pos for pos, node in enumerate(tgt_order)}\nsrc_link_order = sorted(range(len(flows)), key=lambda i: (flows[i][0], tgt_pos[flows[i][1]]))\ntgt_link_order = sorted(range(len(flows)), key=lambda i: (flows[i][1], src_pos[flows[i][0]]))\n\ny1t_by_flow = [0.0] * len(flows)\nsrc_cursor = list(node_y0[:N_SRC])\nfor i in src_link_order:\n    src, _tgt, val = flows[i]\n    y1t_by_flow[i] = src_cursor[src]\n    src_cursor[src] += val * scale\n\ny2t_by_flow = [0.0] * len(flows)\ntgt_cursor = list(node_y0[N_SRC:])\nfor i in tgt_link_order:\n    _src, tgt, val = flows[i]\n    tgt_local = tgt - N_SRC\n    y2t_by_flow[i] = tgt_cursor[tgt_local]\n    tgt_cursor[tgt_local] += val * scale\n\nlink_data = []\nfor i, (src, tgt, val) in enumerate(flows):\n    h = val * scale\n    x1 = node_x[src] + NODE_W\n    y1t = y1t_by_flow[i]\n    y1b = y1t + h\n    x2 = node_x[tgt]\n    y2t = y2t_by_flow[i]\n    y2b = y2t + h\n\n    cx = (x1 + x2) / 2\n    path = (\n        f\"M {x1:.1f},{y1t:.1f} \"\n        f\"C {cx:.1f},{y1t:.1f} {cx:.1f},{y2t:.1f} {x2:.1f},{y2t:.1f} \"\n        f\"L {x2:.1f},{y2b:.1f} \"\n        f\"C {cx:.1f},{y2b:.1f} {cx:.1f},{y1b:.1f} {x1:.1f},{y1b:.1f} Z\"\n    )\n    c = PALETTE[src]  # color drawn from Style object palette\n    r, g, b = int(c[1:3], 16), int(c[3:5], 16), int(c[5:7], 16)\n    alpha = ALPHA_DOMINANT if val >= DOMINANT_THRESHOLD else ALPHA_DEFAULT\n    dominant = val >= DOMINANT_THRESHOLD\n    # Ribbon midpoint for annotation placement\n    ribbon_mid_y = (y1t + y1b + y2t + y2b) / 4\n    tooltip = f\"{node_labels[src]} → {node_labels[tgt]}: {val} MLD\"\n    # Same-color stroke halo widens the visible ribbon for thin flows without\n    # disturbing the node-band geometry (previous review: ~2 MLD ribbons\n    # shrank to ~2px once downscaled to the 400px mobile width)\n    thin_halo = max(0.0, MIN_RIBBON_PX - h)\n    link_data.append((f\"rgba({r},{g},{b},{alpha})\", path, dominant, cx, ribbon_mid_y, val, tooltip, thin_halo))\n\n# Title — scale fontsize down for long titles (see plot-generator.md \"Title\n# fontsize must scale with title length\")\ntitle_text = \"Water Distribution Network · sankey-basic · python · pygal · anyplot.ai\"\ntitle_ratio = 67 / len(title_text) if len(title_text) > 67 else 1.0\ntitle_size = max(44, round(chart_style.title_font_size * title_ratio))\n\n# Build SVG string. pygal ships no Sankey chart class, so nodes/links are drawn\n# as raw SVG (colors/fonts still sourced from chart_style above). Hover\n# highlighting + native <title> tooltips give the HTML output real\n# interactivity even without pygal's own chart JS.\nparts = [\n    f'<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{WIDTH}\" height=\"{HEIGHT}\" viewBox=\"0 0 {WIDTH} {HEIGHT}\">',\n    \"<style>.flow{transition:opacity 0.15s ease}.flow:hover{opacity:0.95 !important}.node:hover{opacity:0.85}</style>\",\n    f'<rect width=\"{WIDTH}\" height=\"{HEIGHT}\" fill=\"{BG}\"/>',\n    # Title — font size from chart_style.title_font_size, scaled for length\n    f'<text x=\"{WIDTH // 2}\" y=\"{MARGIN_T // 2}\" text-anchor=\"middle\" '\n    f'dominant-baseline=\"middle\" font-family=\"{FONT}\" font-size=\"{title_size}\" '\n    f'font-weight=\"700\" fill=\"{FG}\">{title_text}</text>',\n    '<g id=\"links\">',\n]\n\n# Non-dominant flows drawn first (background layer)\nfor fill, path, dominant, _cx, _ribbon_mid_y, _val, tooltip, thin_halo in link_data:\n    if not dominant:\n        stroke = f' stroke=\"{fill}\" stroke-width=\"{thin_halo:.1f}\"' if thin_halo > 0 else ' stroke=\"none\"'\n        parts.append(f'<path class=\"flow\" d=\"{path}\" fill=\"{fill}\"{stroke}><title>{tooltip}</title></path>')\n\n# Dominant flows drawn on top with annotation showing their magnitude\nfor fill, path, dominant, cx, ribbon_mid_y, val, tooltip, thin_halo in link_data:\n    if dominant:\n        stroke = f' stroke=\"{fill}\" stroke-width=\"{thin_halo:.1f}\"' if thin_halo > 0 else ' stroke=\"none\"'\n        parts.append(f'<path class=\"flow\" d=\"{path}\" fill=\"{fill}\"{stroke}><title>{tooltip}</title></path>')\n        parts.append(\n            f'<text x=\"{cx:.1f}\" y=\"{ribbon_mid_y:.1f}\" text-anchor=\"middle\" '\n            f'dominant-baseline=\"middle\" font-family=\"{FONT}\" font-size=\"{VALUE_SIZE}\" '\n            f'font-weight=\"700\" fill=\"{FG}\" opacity=\"0.80\">{val} MLD</text>'\n        )\n\nparts.append(\"</g>\")\n\n# Nodes\nparts.append('<g id=\"nodes\">')\nfor i in range(len(node_labels)):\n    color = PALETTE[i] if i < N_SRC else INK_SOFT\n    x = node_x[i]\n    y0 = node_y0[i]\n    h = node_y1[i] - node_y0[i]\n    parts.append(\n        f'<rect class=\"node\" x=\"{x:.1f}\" y=\"{y0:.1f}\" width=\"{NODE_W}\" height=\"{h:.1f}\" '\n        f'fill=\"{color}\" rx=\"5\"><title>{node_labels[i]}: {node_total[i]} MLD</title></rect>'\n    )\nparts.append(\"</g>\")\n\n# Labels — font sizes from chart_style.label_font_size / chart_style.value_font_size\nparts.append('<g id=\"labels\">')\nfor i in range(len(node_labels)):\n    y_mid = (node_y0[i] + node_y1[i]) / 2\n    label = node_labels[i]\n    val_str = f\"{node_total[i]} MLD\"\n    if i < N_SRC:\n        tx = node_x[i] - 24\n        anchor = \"end\"\n    else:\n        tx = node_x[i] + NODE_W + 24\n        anchor = \"start\"\n    parts.append(\n        f'<text x=\"{tx:.1f}\" y=\"{y_mid - 30:.1f}\" text-anchor=\"{anchor}\" '\n        f'dominant-baseline=\"middle\" font-family=\"{FONT}\" font-size=\"{LABEL_SIZE}\" '\n        f'font-weight=\"500\" fill=\"{FG}\">{label}</text>'\n    )\n    parts.append(\n        f'<text x=\"{tx:.1f}\" y=\"{y_mid + 34:.1f}\" text-anchor=\"{anchor}\" '\n        f'dominant-baseline=\"middle\" font-family=\"{FONT}\" font-size=\"{VALUE_SIZE}\" '\n        f'fill=\"{FG_SUBTLE}\">{val_str}</text>'\n    )\nparts.append(\"</g>\")\nparts.append(\"</svg>\")\n\nsvg_content = \"\\n\".join(parts)\n\n# Save HTML (pygal-style interactive output — hover a flow or node for its tooltip)\nhtml_content = (\n    f'<!DOCTYPE html><html><head><meta charset=\"utf-8\">'\n    f\"<title>sankey-basic · pygal · anyplot.ai</title>\"\n    f\"<style>body{{margin:0;background:{BG}}}</style></head>\"\n    f\"<body>{svg_content}</body></html>\"\n)\nwith open(f\"plot-{THEME}.html\", \"w\", encoding=\"utf-8\") as fh:\n    fh.write(html_content)\n\n# Save PNG via cairosvg (same pipeline pygal.render_to_png uses internally)\ncairosvg.svg2png(bytestring=svg_content.encode(\"utf-8\"), write_to=f\"plot-{THEME}.png\")\n"}