{"spec_id":"parliament-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// parliament-basic: Parliament Seat Chart\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Data: fictional legislature, ordered left-to-right along the political\n// spectrum. Independent sits centrist (between Liberal and Conservative)\n// rather than at a spectrum edge, since it is unaligned rather than extreme.\n// Colors come from a categorical scale, keyed by name, so the first party\n// (leftmost, \"Green Alliance\") lands on Imprint position 1 (#009E73). ------\nconst parties = [\n  { name: \"Green Alliance\", seats: 42 },\n  { name: \"Social Democratic Party\", seats: 96 },\n  { name: \"Liberal Party\", seats: 58 },\n  { name: \"Independent\", seats: 36 },\n  { name: \"Conservative Party\", seats: 121 },\n  { name: \"Reform Party\", seats: 47 },\n];\nconst color = d3.scaleOrdinal().domain(parties.map((p) => p.name)).range(t.palette);\nparties.forEach((p) => (p.color = color(p.name)));\nconst totalSeats = d3.sum(parties, (d) => d.seats);\nconst majority = Math.floor(totalSeats / 2) + 1;\n\n// --- Layout geometry ---------------------------------------------------------\nconst margin = { top: 100, bottom: 220, left: 70, right: 70 };\nconst plotW = width - margin.left - margin.right;\nconst plotH = height - margin.top - margin.bottom;\nconst cx = width / 2;\nconst cy = margin.top + plotH; // flat baseline of the semicircle\n\nconst rMax = Math.min(plotW / 2, plotH) - 40;\nconst rMin = rMax * 0.38;\n\n// Concentric arcs: pick a row count that keeps seats/row in a legible range,\n// then space rows evenly in radius between rMin and rMax.\nconst numRows = Math.max(4, Math.min(9, Math.round(Math.sqrt(totalSeats / 9))));\nconst radii = d3.range(numRows).map((k) =>\n  numRows === 1 ? rMax : rMin + (k * (rMax - rMin)) / (numRows - 1)\n);\n\n// Seats per row proportional to row circumference (~radius), largest-remainder\n// rounding so the row totals sum exactly to totalSeats.\nconst rawCounts = radii.map((r) => (r / d3.sum(radii)) * totalSeats);\nconst rowCounts = rawCounts.map(Math.floor);\nlet leftover = totalSeats - d3.sum(rowCounts);\nconst remainderOrder = d3.range(numRows)\n  .sort((a, b) => (rawCounts[b] - rowCounts[b]) - (rawCounts[a] - rowCounts[a]));\nfor (let k = 0; k < leftover; k++) rowCounts[remainderOrder[k]] += 1;\n\n// Seat radius bounded by the tightest row (arc length / seat count) so dots\n// never overlap along any row.\nconst anglePad = 0.05 * Math.PI;\nconst seatRadius = Math.min(\n  16,\n  Math.max(\n    4,\n    d3.min(\n      radii\n        .map((r, k) => (rowCounts[k] > 0 ? (r * (Math.PI - 2 * anglePad)) / rowCounts[k] / 2.4 : Infinity))\n    )\n  )\n);\n\n// --- Seat slots: one per row, angle sweeping left (pi) to right (0) --------\nconst slots = [];\nradii.forEach((r, k) => {\n  const n = rowCounts[k];\n  for (let j = 0; j < n; j++) {\n    const angle = n > 1\n      ? Math.PI - anglePad - (j * (Math.PI - 2 * anglePad)) / (n - 1)\n      : Math.PI / 2;\n    slots.push({ radius: r, angle });\n  }\n});\nslots.sort((a, b) => b.angle - a.angle); // leftmost (largest angle) first\n\n// Assign each slot to a party by cumulative seat count, preserving the\n// left-to-right party order so each party forms a contiguous angular wedge.\nlet cursor = 0;\nconst seatData = [];\nfor (const party of parties) {\n  for (let i = 0; i < party.seats; i++) {\n    const slot = slots[cursor++];\n    seatData.push({ ...slot, color: party.color, party: party.name });\n  }\n}\n\n// Majority threshold sits at the boundary between seat (majority-1) and seat majority\nconst thetaMaj = majority >= 2\n  ? (slots[majority - 1].angle + slots[majority - 2].angle) / 2\n  : slots[0].angle;\n\n// --- SVG mount ----------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst seatX = (r, a) => cx + r * Math.cos(a);\nconst seatY = (r, a) => cy - r * Math.sin(a);\n\n// --- Majority threshold line (drawn first, under the seats) -----------------\nsvg.append(\"line\")\n  .attr(\"x1\", seatX(rMin - 18, thetaMaj)).attr(\"y1\", seatY(rMin - 18, thetaMaj))\n  .attr(\"x2\", seatX(rMax + 26, thetaMaj)).attr(\"y2\", seatY(rMax + 26, thetaMaj))\n  .attr(\"stroke\", t.inkSoft).attr(\"stroke-width\", 1.5).attr(\"stroke-dasharray\", \"7,5\");\nsvg.append(\"text\")\n  .attr(\"x\", seatX(rMax + 40, thetaMaj)).attr(\"y\", seatY(rMax + 40, thetaMaj))\n  .attr(\"text-anchor\", Math.cos(thetaMaj) < 0 ? \"end\" : \"start\")\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\").style(\"font-style\", \"italic\")\n  .text(`Majority · ${majority} seats`);\n\n// --- Seats --------------------------------------------------------------------\nsvg.selectAll(\"circle.seat\").data(seatData).join(\"circle\")\n  .attr(\"class\", \"seat\")\n  .attr(\"cx\", (d) => seatX(d.radius, d.angle))\n  .attr(\"cy\", (d) => seatY(d.radius, d.angle))\n  .attr(\"r\", seatRadius)\n  .attr(\"fill\", (d) => d.color)\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1);\n\n// --- Governing coalition: the fewest, largest parties whose seats combined\n// cross the majority line — a concrete answer to \"which coalition governs?\"\n// rather than stopping at the threshold line alone. -------------------------\nconst bySeatsDesc = [...parties].sort((a, b) => b.seats - a.seats);\nconst coalition = [];\nlet coalitionSeats = 0;\nfor (const p of bySeatsDesc) {\n  if (coalitionSeats >= majority) break;\n  coalition.push(p);\n  coalitionSeats += p.seats;\n}\n\nconst partyExtent = new Map(\n  parties.map((p) => {\n    const angles = seatData.filter((d) => d.party === p.name).map((d) => d.angle);\n    return [p.name, [d3.min(angles), d3.max(angles)]];\n  })\n);\nconst bandR = rMax + 34;\nconst bandLine = d3.line();\nconst bandPoints = (aStart, aEnd, steps = 24) =>\n  d3.range(steps + 1).map((i) => {\n    const a = aStart + ((aEnd - aStart) * i) / steps;\n    return [seatX(bandR, a), seatY(bandR, a)];\n  });\n\ncoalition.forEach((p) => {\n  const [aMin, aMax] = partyExtent.get(p.name);\n  svg.append(\"path\")\n    .attr(\"d\", bandLine(bandPoints(aMin, aMax)))\n    .attr(\"fill\", \"none\")\n    .attr(\"stroke\", p.color)\n    .attr(\"stroke-width\", 6)\n    .attr(\"stroke-linecap\", \"round\")\n    .attr(\"opacity\", 0.65);\n});\nsvg.append(\"text\")\n  .attr(\"x\", width / 2).attr(\"y\", margin.top - 22)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\").style(\"font-style\", \"italic\")\n  .text(`Governing coalition — ${coalition.map((p) => p.name).join(\" + \")} (${coalitionSeats} seats)`);\n\n// --- Legend: swatch + name + seat count, wrapped into rows and centered ------\nconst legendG = svg.append(\"g\");\nconst itemGs = legendG.selectAll(\"g.item\").data(parties).join(\"g\").attr(\"class\", \"item\");\nitemGs.each(function (d) {\n  const g = d3.select(this);\n  g.append(\"circle\").attr(\"r\", 9).attr(\"cy\", -4).attr(\"fill\", d.color);\n  g.append(\"text\").attr(\"x\", 20).attr(\"y\", 2)\n    .attr(\"fill\", t.ink).style(\"font-size\", \"15px\")\n    .text(`${d.name} — ${d.seats}`);\n});\n\nconst gap = 44;\nconst itemWidths = itemGs.nodes().map((n) => n.getBBox().width);\nconst rows = [];\nlet current = [];\nlet currentWidth = 0;\nparties.forEach((d, i) => {\n  const w = itemWidths[i];\n  const addW = current.length === 0 ? w : w + gap;\n  if (currentWidth + addW > plotW && current.length > 0) {\n    rows.push(current);\n    current = [{ i, w }];\n    currentWidth = w;\n  } else {\n    current.push({ i, w });\n    currentWidth += addW;\n  }\n});\nif (current.length) rows.push(current);\n\nconst legendTop = cy + 44;\nconst rowHeight = 36;\nconst itemNodes = itemGs.nodes();\nrows.forEach((row, ri) => {\n  const totalW = d3.sum(row, (item) => item.w) + gap * (row.length - 1);\n  let x = cx - totalW / 2;\n  row.forEach((item) => {\n    d3.select(itemNodes[item.i]).attr(\"transform\", `translate(${x},${legendTop + ri * rowHeight})`);\n    x += item.w + gap;\n  });\n});\n\n// --- Title ----------------------------------------------------------------\nsvg.append(\"text\")\n  .attr(\"x\", width / 2).attr(\"y\", 48)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"22px\").style(\"font-weight\", \"600\")\n  .text(\"parliament-basic · javascript · d3 · anyplot.ai\");\n"}