<!-- Liquid democracy delegation graph demo -->
<p class="hint">Each voter (A–H) votes <b>Yes</b>, <b>No</b>, or <b>delegates</b> to a neighbor. Click a node to cycle its state. Weight flows transitively through delegation chains. A red node is trapped in a guru cycle — its weight is lost.</p>
<div id="graph-area">
<svg id="graph-svg" viewBox="0 0 340 280" aria-label="Delegation graph showing vote flow between voters"></svg>
</div>
<div class="tally-row">
<span class="tally-label">Yes</span>
<span id="tally-yes" class="tally-val yes-val">0</span>
<span class="tally-sep">|</span>
<span class="tally-label">No</span>
<span id="tally-no" class="tally-val no-val">0</span>
<span class="tally-sep">|</span>
<span class="tally-label">Lost (cycle)</span>
<span id="tally-abstain" class="tally-val abs-val">0</span>
</div>
<div id="status" class="status"></div>
<div class="btns">
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
/* Styles for the liquid democracy delegation graph */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
#graph-area { width: 100%; overflow: hidden; border: 1px solid #cdd9e3; border-radius: 10px; background: #f7f9fb; }
#graph-svg { width: 100%; height: auto; display: block; }
.node circle { stroke-width: 2; cursor: pointer; transition: r .1s; }
.node circle:hover { r: 17; }
.node text { font: 700 13px system-ui, sans-serif; pointer-events: none; }
.node .weight-badge { font: 600 10px system-ui, sans-serif; fill: #fff; }
.node .weight-bg { opacity: .85; }
.edge { fill: none; stroke: #8899aa; stroke-width: 1.8; }
.edge.delegate-arrow { stroke: #1d3557; stroke-width: 2.2; marker-end: url(#arrow); }
.edge.cycle-edge { stroke: #e63946; stroke-width: 2.2; stroke-dasharray: 5 3; marker-end: url(#arrow-red); }
.tally-row { display: flex; align-items: center; gap: .5rem; margin: .55rem 0 .3rem; flex-wrap: wrap; font-size: .95rem; }
.tally-label { color: #555; }
.tally-val { font-weight: 700; font-size: 1.1rem; min-width: 1.6ch; text-align: center; }
.yes-val { color: #0a7d33; }
.no-val { color: #c92f3c; }
.abs-val { color: #888; }
.tally-sep { color: #ccc; padding: 0 .1rem; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.3em; color: #c92f3c; margin-bottom: .4rem; }
.btns { display: flex; gap: .5rem; }
button { font: 600 14px system-ui, sans-serif; padding: .42rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Liquid democracy: voters vote directly or delegate transitively.
// Node positions in SVG coordinate space
const NODES = [
{ id: 'A', x: 60, y: 55 },
{ id: 'B', x: 170, y: 30 },
{ id: 'C', x: 280, y: 55 },
{ id: 'D', x: 60, y: 145 },
{ id: 'E', x: 170, y: 145 },
{ id: 'F', x: 280, y: 145 },
{ id: 'G', x: 100, y: 235 },
{ id: 'H', x: 240, y: 235 }
];
// Each voter's possible delegate targets (the options cycle: yes → no → delegate-X → ...)
// A state is 'yes', 'no', or 'delegate-<id>'
// states: 'yes' | 'no' | 'delegate-B' | 'delegate-E' | etc.
const DELEGATE_OPTIONS = {
A: ['B', 'D'],
B: ['C', 'E'],
C: ['B', 'F'],
D: ['A', 'G'],
E: ['B', 'F', 'H'],
F: ['C', 'E'],
G: ['D', 'H'],
H: ['G', 'E']
};
// Initial state: a mix of direct voters and delegates
const DEFAULT_STATES = { A: 'yes', B: 'delegate-E', C: 'no', D: 'delegate-A', E: 'yes', F: 'delegate-C', G: 'delegate-D', H: 'no' };
let states = { ...DEFAULT_STATES };
const svg = document.getElementById('graph-svg');
const NS = 'http://www.w3.org/2000/svg';
// SVG arrowhead markers for delegation edges
function addMarker(id, color) {
const defs = svg.querySelector('defs') || (() => { const d = document.createElementNS(NS,'defs'); svg.prepend(d); return d; })();
const marker = document.createElementNS(NS, 'marker');
marker.setAttribute('id', id);
marker.setAttribute('markerWidth', '8');
marker.setAttribute('markerHeight', '8');
marker.setAttribute('refX', '7');
marker.setAttribute('refY', '3');
marker.setAttribute('orient', 'auto');
const path = document.createElementNS(NS, 'path');
path.setAttribute('d', 'M0,0 L0,6 L8,3 z');
path.setAttribute('fill', color);
marker.appendChild(path);
defs.appendChild(marker);
}
addMarker('arrow', '#1d3557');
addMarker('arrow-red', '#e63946');
// Resolve final vote weights after following all delegation chains
function resolveWeights() {
// Build a direct delegation map: id -> delegate id
const delegateTo = {};
for (const id in states) {
const s = states[id];
if (s.startsWith('delegate-')) delegateTo[id] = s.slice(9);
}
// Detect all nodes that are part of a delegation cycle
const inCycle = new Set();
for (const start in delegateTo) {
const visited = new Set();
let cur = start;
while (cur && delegateTo[cur]) {
if (visited.has(cur)) {
// We've looped — mark all nodes in this cycle
let c = cur;
do { inCycle.add(c); c = delegateTo[c]; } while (c !== cur);
break;
}
visited.add(cur);
cur = delegateTo[cur];
}
}
// Accumulate weight: each non-cycle voter contributes 1 to their terminal delegate
const weight = {};
NODES.forEach(n => { weight[n.id] = 0; });
for (const id of NODES.map(n => n.id)) {
if (inCycle.has(id)) continue; // Nodes in a cycle contribute 0 weight
// Follow the delegation chain until a terminal direct voter is found
let cur = id, depth = 0;
while (delegateTo[cur] && !inCycle.has(delegateTo[cur]) && depth < 20) {
cur = delegateTo[cur]; depth++;
}
if (!inCycle.has(cur)) weight[cur] += 1;
}
return { weight, inCycle, delegateTo };
}
function nodeById(id) { return NODES.find(n => n.id === id); }
function nodeColor(id, inCycle) {
if (inCycle.has(id)) return '#e63946'; // Red: trapped in cycle
const s = states[id];
if (s === 'yes') return '#0a7d33';
if (s === 'no') return '#c92f3c';
return '#1d3557'; // Blue: delegating
}
function stateLabel(id) {
const s = states[id];
if (s === 'yes') return 'Yes';
if (s === 'no') return 'No';
return '→' + s.slice(9); // Arrow prefix for delegate label
}
// Compute a curved quadratic bezier path between two nodes, with offset for parallel edges
function edgePath(fromNode, toNode, offset) {
const dx = toNode.x - fromNode.x, dy = toNode.y - fromNode.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const r = 15;
const ex = fromNode.x + dx / dist * r, ey = fromNode.y + dy / dist * r;
const tx = toNode.x - dx / dist * r, ty = toNode.y - dy / dist * r;
const mx = (ex + tx) / 2 - dy / dist * offset;
const my = (ey + ty) / 2 + dx / dist * offset;
return `M${ex},${ey} Q${mx},${my} ${tx},${ty}`;
}
function render() {
while (svg.firstChild && svg.firstChild.tagName !== 'defs') svg.removeChild(svg.firstChild);
// Remove all SVG children except the defs block
Array.from(svg.children).filter(c => c.tagName !== 'defs').forEach(c => svg.removeChild(c));
const { weight, inCycle, delegateTo } = resolveWeights();
// Draw delegation arrows between nodes
for (const fromId in delegateTo) {
const toId = delegateTo[fromId];
const from = nodeById(fromId), to = nodeById(toId);
const isCycleEdge = inCycle.has(fromId) && inCycle.has(toId) && delegateTo[fromId] === toId;
// Offset curved arrows when A delegates to B and B delegates to A
const reverseExists = delegateTo[toId] === fromId;
const offset = reverseExists ? 18 : 0;
const p = document.createElementNS(NS, 'path');
p.setAttribute('d', edgePath(from, to, offset));
p.setAttribute('class', 'edge ' + (isCycleEdge ? 'cycle-edge' : 'delegate-arrow'));
svg.appendChild(p);
}
// Draw voter circles with labels, weight badges, and state text
NODES.forEach(n => {
const g = document.createElementNS(NS, 'g');
g.setAttribute('class', 'node');
g.setAttribute('transform', `translate(${n.x},${n.y})`);
g.setAttribute('tabindex', '0');
g.setAttribute('role', 'button');
g.setAttribute('aria-label', i18nFmt('Voter %id%, click to change state', { id: n.id }));
const circle = document.createElementNS(NS, 'circle');
circle.setAttribute('r', '15');
circle.setAttribute('fill', nodeColor(n.id, inCycle));
circle.setAttribute('stroke', '#fff');
g.appendChild(circle);
const label = document.createElementNS(NS, 'text');
label.setAttribute('text-anchor', 'middle');
label.setAttribute('dominant-baseline', 'central');
label.setAttribute('fill', '#fff');
label.setAttribute('dy', weight[n.id] > 0 ? '-4' : '0');
label.textContent = n.id;
g.appendChild(label);
if (weight[n.id] > 0) {
const badge = document.createElementNS(NS, 'text');
badge.setAttribute('class', 'weight-badge');
badge.setAttribute('text-anchor', 'middle');
badge.setAttribute('dominant-baseline', 'central');
badge.setAttribute('fill', '#fff');
badge.setAttribute('dy', '7');
badge.textContent = i18nFmt('×%w%', { w: weight[n.id] });
g.appendChild(badge);
}
const stateText = document.createElementNS(NS, 'text');
stateText.setAttribute('text-anchor', 'middle');
stateText.setAttribute('fill', '#334');
stateText.setAttribute('font-size', '10');
stateText.setAttribute('y', '26');
stateText.textContent = stateLabel(n.id);
g.appendChild(stateText);
g.addEventListener('click', () => cycleState(n.id));
g.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') cycleState(n.id); });
svg.appendChild(g);
});
// Sum up yes/no/lost votes across all voters following delegation chains
let yes = 0, no = 0, abstain = 0;
for (const id of NODES.map(n => n.id)) {
if (inCycle.has(id)) continue;
let cur = id, depth = 0;
const trail = [id];
while (delegateTo[cur] && !inCycle.has(delegateTo[cur]) && depth < 20) {
cur = delegateTo[cur]; depth++; trail.push(cur);
}
if (inCycle.has(cur)) continue;
const vote = states[cur];
if (vote === 'yes') yes++;
else if (vote === 'no') no++;
else abstain++;
}
document.getElementById('tally-yes').textContent = yes;
document.getElementById('tally-no').textContent = no;
document.getElementById('tally-abstain').textContent = abstain;
// Show a warning message when guru cycles are detected
const statusEl = document.getElementById('status');
if (inCycle.size > 0) {
statusEl.textContent = i18nFmt('⚠ Guru cycle detected among: %ids%. Their weight is lost.', { ids: [...inCycle].join(', ') });
} else {
statusEl.textContent = '';
}
}
// Cycle a voter's state: yes -> no -> delegate-X -> delegate-Y -> ... -> yes
function cycleState(id) {
const opts = DELEGATE_OPTIONS[id] || [];
const seq = ['yes', 'no', ...opts.map(o => 'delegate-' + o)];
const cur = states[id];
const idx = seq.indexOf(cur);
states[id] = seq[(idx + 1) % seq.length];
render();
}
document.getElementById('btn-reset').addEventListener('click', () => {
states = { ...DEFAULT_STATES };
render();
});
render();
<!-- Liquid democracy delegation graph demo -->
<p class="hint">Each voter (A–H) votes <b>Yes</b>, <b>No</b>, or <b>delegates</b> to a neighbor. Click a node to cycle its state. Weight flows transitively through delegation chains. A red node is trapped in a guru cycle — its weight is lost.</p>
<div id="graph-area">
<svg id="graph-svg" viewBox="0 0 340 280" aria-label="Delegation graph showing vote flow between voters"></svg>
</div>
<div class="tally-row">
<span class="tally-label">Yes</span>
<span id="tally-yes" class="tally-val yes-val">0</span>
<span class="tally-sep">|</span>
<span class="tally-label">No</span>
<span id="tally-no" class="tally-val no-val">0</span>
<span class="tally-sep">|</span>
<span class="tally-label">Lost (cycle)</span>
<span id="tally-abstain" class="tally-val abs-val">0</span>
</div>
<div id="status" class="status"></div>
<div class="btns">
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
/* Styles for the liquid democracy delegation graph */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
#graph-area { width: 100%; overflow: hidden; border: 1px solid #cdd9e3; border-radius: 10px; background: #f7f9fb; }
#graph-svg { width: 100%; height: auto; display: block; }
.node circle { stroke-width: 2; cursor: pointer; transition: r .1s; }
.node circle:hover { r: 17; }
.node text { font: 700 13px system-ui, sans-serif; pointer-events: none; }
.node .weight-badge { font: 600 10px system-ui, sans-serif; fill: #fff; }
.node .weight-bg { opacity: .85; }
.edge { fill: none; stroke: #8899aa; stroke-width: 1.8; }
.edge.delegate-arrow { stroke: #1d3557; stroke-width: 2.2; marker-end: url(#arrow); }
.edge.cycle-edge { stroke: #e63946; stroke-width: 2.2; stroke-dasharray: 5 3; marker-end: url(#arrow-red); }
.tally-row { display: flex; align-items: center; gap: .5rem; margin: .55rem 0 .3rem; flex-wrap: wrap; font-size: .95rem; }
.tally-label { color: #555; }
.tally-val { font-weight: 700; font-size: 1.1rem; min-width: 1.6ch; text-align: center; }
.yes-val { color: #0a7d33; }
.no-val { color: #c92f3c; }
.abs-val { color: #888; }
.tally-sep { color: #ccc; padding: 0 .1rem; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.3em; color: #c92f3c; margin-bottom: .4rem; }
.btns { display: flex; gap: .5rem; }
button { font: 600 14px system-ui, sans-serif; padding: .42rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Liquid democracy: voters vote directly or delegate transitively.
// Node positions in SVG coordinate space
const NODES = [
{ id: 'A', x: 60, y: 55 },
{ id: 'B', x: 170, y: 30 },
{ id: 'C', x: 280, y: 55 },
{ id: 'D', x: 60, y: 145 },
{ id: 'E', x: 170, y: 145 },
{ id: 'F', x: 280, y: 145 },
{ id: 'G', x: 100, y: 235 },
{ id: 'H', x: 240, y: 235 }
];
// Each voter's possible delegate targets (the options cycle: yes → no → delegate-X → ...)
// A state is 'yes', 'no', or 'delegate-<id>'
// states: 'yes' | 'no' | 'delegate-B' | 'delegate-E' | etc.
const DELEGATE_OPTIONS = {
A: ['B', 'D'],
B: ['C', 'E'],
C: ['B', 'F'],
D: ['A', 'G'],
E: ['B', 'F', 'H'],
F: ['C', 'E'],
G: ['D', 'H'],
H: ['G', 'E']
};
// Initial state: a mix of direct voters and delegates
const DEFAULT_STATES = { A: 'yes', B: 'delegate-E', C: 'no', D: 'delegate-A', E: 'yes', F: 'delegate-C', G: 'delegate-D', H: 'no' };
let states = { ...DEFAULT_STATES };
const svg = document.getElementById('graph-svg');
const NS = 'http://www.w3.org/2000/svg';
// SVG arrowhead markers for delegation edges
function addMarker(id, color) {
const defs = svg.querySelector('defs') || (() => { const d = document.createElementNS(NS,'defs'); svg.prepend(d); return d; })();
const marker = document.createElementNS(NS, 'marker');
marker.setAttribute('id', id);
marker.setAttribute('markerWidth', '8');
marker.setAttribute('markerHeight', '8');
marker.setAttribute('refX', '7');
marker.setAttribute('refY', '3');
marker.setAttribute('orient', 'auto');
const path = document.createElementNS(NS, 'path');
path.setAttribute('d', 'M0,0 L0,6 L8,3 z');
path.setAttribute('fill', color);
marker.appendChild(path);
defs.appendChild(marker);
}
addMarker('arrow', '#1d3557');
addMarker('arrow-red', '#e63946');
// Resolve final vote weights after following all delegation chains
function resolveWeights() {
// Build a direct delegation map: id -> delegate id
const delegateTo = {};
for (const id in states) {
const s = states[id];
if (s.startsWith('delegate-')) delegateTo[id] = s.slice(9);
}
// Detect all nodes that are part of a delegation cycle
const inCycle = new Set();
for (const start in delegateTo) {
const visited = new Set();
let cur = start;
while (cur && delegateTo[cur]) {
if (visited.has(cur)) {
// We've looped — mark all nodes in this cycle
let c = cur;
do { inCycle.add(c); c = delegateTo[c]; } while (c !== cur);
break;
}
visited.add(cur);
cur = delegateTo[cur];
}
}
// Accumulate weight: each non-cycle voter contributes 1 to their terminal delegate
const weight = {};
NODES.forEach(n => { weight[n.id] = 0; });
for (const id of NODES.map(n => n.id)) {
if (inCycle.has(id)) continue; // Nodes in a cycle contribute 0 weight
// Follow the delegation chain until a terminal direct voter is found
let cur = id, depth = 0;
while (delegateTo[cur] && !inCycle.has(delegateTo[cur]) && depth < 20) {
cur = delegateTo[cur]; depth++;
}
if (!inCycle.has(cur)) weight[cur] += 1;
}
return { weight, inCycle, delegateTo };
}
function nodeById(id) { return NODES.find(n => n.id === id); }
function nodeColor(id, inCycle) {
if (inCycle.has(id)) return '#e63946'; // Red: trapped in cycle
const s = states[id];
if (s === 'yes') return '#0a7d33';
if (s === 'no') return '#c92f3c';
return '#1d3557'; // Blue: delegating
}
function stateLabel(id) {
const s = states[id];
if (s === 'yes') return 'Yes';
if (s === 'no') return 'No';
return '→' + s.slice(9); // Arrow prefix for delegate label
}
// Compute a curved quadratic bezier path between two nodes, with offset for parallel edges
function edgePath(fromNode, toNode, offset) {
const dx = toNode.x - fromNode.x, dy = toNode.y - fromNode.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const r = 15;
const ex = fromNode.x + dx / dist * r, ey = fromNode.y + dy / dist * r;
const tx = toNode.x - dx / dist * r, ty = toNode.y - dy / dist * r;
const mx = (ex + tx) / 2 - dy / dist * offset;
const my = (ey + ty) / 2 + dx / dist * offset;
return `M${ex},${ey} Q${mx},${my} ${tx},${ty}`;
}
function render() {
while (svg.firstChild && svg.firstChild.tagName !== 'defs') svg.removeChild(svg.firstChild);
// Remove all SVG children except the defs block
Array.from(svg.children).filter(c => c.tagName !== 'defs').forEach(c => svg.removeChild(c));
const { weight, inCycle, delegateTo } = resolveWeights();
// Draw delegation arrows between nodes
for (const fromId in delegateTo) {
const toId = delegateTo[fromId];
const from = nodeById(fromId), to = nodeById(toId);
const isCycleEdge = inCycle.has(fromId) && inCycle.has(toId) && delegateTo[fromId] === toId;
// Offset curved arrows when A delegates to B and B delegates to A
const reverseExists = delegateTo[toId] === fromId;
const offset = reverseExists ? 18 : 0;
const p = document.createElementNS(NS, 'path');
p.setAttribute('d', edgePath(from, to, offset));
p.setAttribute('class', 'edge ' + (isCycleEdge ? 'cycle-edge' : 'delegate-arrow'));
svg.appendChild(p);
}
// Draw voter circles with labels, weight badges, and state text
NODES.forEach(n => {
const g = document.createElementNS(NS, 'g');
g.setAttribute('class', 'node');
g.setAttribute('transform', `translate(${n.x},${n.y})`);
g.setAttribute('tabindex', '0');
g.setAttribute('role', 'button');
g.setAttribute('aria-label', i18nFmt('Voter %id%, click to change state', { id: n.id }));
const circle = document.createElementNS(NS, 'circle');
circle.setAttribute('r', '15');
circle.setAttribute('fill', nodeColor(n.id, inCycle));
circle.setAttribute('stroke', '#fff');
g.appendChild(circle);
const label = document.createElementNS(NS, 'text');
label.setAttribute('text-anchor', 'middle');
label.setAttribute('dominant-baseline', 'central');
label.setAttribute('fill', '#fff');
label.setAttribute('dy', weight[n.id] > 0 ? '-4' : '0');
label.textContent = n.id;
g.appendChild(label);
if (weight[n.id] > 0) {
const badge = document.createElementNS(NS, 'text');
badge.setAttribute('class', 'weight-badge');
badge.setAttribute('text-anchor', 'middle');
badge.setAttribute('dominant-baseline', 'central');
badge.setAttribute('fill', '#fff');
badge.setAttribute('dy', '7');
badge.textContent = i18nFmt('×%w%', { w: weight[n.id] });
g.appendChild(badge);
}
const stateText = document.createElementNS(NS, 'text');
stateText.setAttribute('text-anchor', 'middle');
stateText.setAttribute('fill', '#334');
stateText.setAttribute('font-size', '10');
stateText.setAttribute('y', '26');
stateText.textContent = stateLabel(n.id);
g.appendChild(stateText);
g.addEventListener('click', () => cycleState(n.id));
g.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') cycleState(n.id); });
svg.appendChild(g);
});
// Sum up yes/no/lost votes across all voters following delegation chains
let yes = 0, no = 0, abstain = 0;
for (const id of NODES.map(n => n.id)) {
if (inCycle.has(id)) continue;
let cur = id, depth = 0;
const trail = [id];
while (delegateTo[cur] && !inCycle.has(delegateTo[cur]) && depth < 20) {
cur = delegateTo[cur]; depth++; trail.push(cur);
}
if (inCycle.has(cur)) continue;
const vote = states[cur];
if (vote === 'yes') yes++;
else if (vote === 'no') no++;
else abstain++;
}
document.getElementById('tally-yes').textContent = yes;
document.getElementById('tally-no').textContent = no;
document.getElementById('tally-abstain').textContent = abstain;
// Show a warning message when guru cycles are detected
const statusEl = document.getElementById('status');
if (inCycle.size > 0) {
statusEl.textContent = i18nFmt('⚠ Guru cycle detected among: %ids%. Their weight is lost.', { ids: [...inCycle].join(', ') });
} else {
statusEl.textContent = '';
}
}
// Cycle a voter's state: yes -> no -> delegate-X -> delegate-Y -> ... -> yes
function cycleState(id) {
const opts = DELEGATE_OPTIONS[id] || [];
const seq = ['yes', 'no', ...opts.map(o => 'delegate-' + o)];
const cur = states[id];
const idx = seq.indexOf(cur);
states[id] = seq[(idx + 1) % seq.length];
render();
}
document.getElementById('btn-reset').addEventListener('click', () => {
states = { ...DEFAULT_STATES };
render();
});
render();
Notice how a single expert delegate can accumulate the weight of many passive voters. Delegating is free — but if a delegation cycle forms (A→B→A), no one in the cycle can vote: the weight simply vanishes. The demo detects and highlights such guru cycles.
Comments
Loading comments...