From d4bbce6ba57dcf1afad273a12fa8a1c7eae8674d Mon Sep 17 00:00:00 2001 From: Armin Samii Date: Tue, 24 Mar 2026 13:39:46 -0400 Subject: [PATCH] decimate sankey data (actually: 1/10th the JS for large elections) --- static/sankey/sankey-wrapper.js | 20 +++++++++++ templates/sankey/sankey-nonblocking.html | 25 ++++++++------ visualizer/sankey/graphToD3.py | 43 ++++++++++++++---------- 3 files changed, 60 insertions(+), 28 deletions(-) diff --git a/static/sankey/sankey-wrapper.js b/static/sankey/sankey-wrapper.js index ca445ed9..d7e7edfd 100644 --- a/static/sankey/sankey-wrapper.js +++ b/static/sankey/sankey-wrapper.js @@ -1,3 +1,23 @@ +function decompressGraph(c, charMap) { + // charMap: single char -> candidate name, ordered by elimination order. + // A char's position (charCode - 'a') is its color index. + const nodes = c.nodes.map(([ch, round, value, w, e]) => ({ + name: charMap[ch], + round, + value, + isWinner: w, + isEliminated: e, + index: ch.charCodeAt(0) - 97, + })); + const links = c.links.map(([source, target, value]) => ({ + source, + target, + candidateIndex: nodes[source].index, + value, + })); + return { nodes, links }; +} + function makeSankey(graph, numRounds, numCandidates, numWinners, longestLabelApxWidth, totalVotesPerRound, colorThemeIndex) { // Below are crazy heuristics to try to get the graph to look good // on a variety of sizes. diff --git a/templates/sankey/sankey-nonblocking.html b/templates/sankey/sankey-nonblocking.html index c82d3a15..159c6ec0 100644 --- a/templates/sankey/sankey-nonblocking.html +++ b/templates/sankey/sankey-nonblocking.html @@ -14,15 +14,20 @@ diff --git a/visualizer/sankey/graphToD3.py b/visualizer/sankey/graphToD3.py index 0dc33dd8..772d0817 100644 --- a/visualizer/sankey/graphToD3.py +++ b/visualizer/sankey/graphToD3.py @@ -14,24 +14,29 @@ def __init__(self, graph): js += f'numWinners = {graph.summarize().numWinners} ;\n' js += f'longestLabelApxWidth = {longestLabelApxWidth};\n' js += f'totalVotesPerRound = {totalVotesPerRound};\n' - js += 'graph = {"nodes" : [], "links" : []};\n' - - # Maps Candidates to a unique index. Used for color indexing. - indices = {candidate: i for i, candidate in enumerate(graph.eliminationOrder)} + # Maps Candidates to a single char key (a-z) ordered by elimination order. + # Position in charMap doubles as the color index. Supports up to 26 candidates. + elimination_order = list(graph.eliminationOrder) + candidate_to_char = {c: chr(ord('a') + i) for i, c in enumerate(elimination_order)} + char_map = {chr(ord('a') + i): c.name for i, c in enumerate(elimination_order)} nodeIndices = {} - for i, node in enumerate(graph.nodes): + nodes = [] + for node in graph.nodes: # Skip inactive (exhausted) nodes if not node.candidate.isActive: continue - nodeIndices[node] = i - js += f'graph.nodes.push({{ "name": {json.dumps(node.label)},\n' - js += f' "round": {node.roundNum},\n' - js += f' "value": {node.count},\n' - js += f' "isWinner": {int(node.isWinner)},\n' - js += f' "isEliminated": {int(node.isEliminated)},\n' - js += f' "index": "{indices[node.candidate]}"}});\n' + nodeIndices[node] = len(nodes) + nodes.append([ + candidate_to_char[node.candidate], + node.roundNum, + node.count, + int(node.isWinner), + int(node.isEliminated), + ]) + + links = [] for link in graph.links: # Skip inactive (exhausted) nodes if not link.source.candidate.isActive: @@ -39,10 +44,12 @@ def __init__(self, graph): if not link.target.candidate.isActive: continue - sourceIndex = nodeIndices[link.source] - targetIndex = nodeIndices[link.target] - js += f'graph.links.push({{ "source": {sourceIndex},\n' - js += f' "target": {targetIndex},\n' - js += f' "candidateIndex": {indices[link.source.candidate]},\n' - js += f' "value": {link.value:0.3f} }});\n' + links.append([ + nodeIndices[link.source], + nodeIndices[link.target], + round(link.value, 3), + ]) + + js += f'charMap = {json.dumps(char_map)};\n' + js += f'graphCompressed = {json.dumps({"nodes": nodes, "links": links})};\n' self.js = js