diff --git a/index.html b/index.html
index ef4a237..d6deffe 100755
--- a/index.html
+++ b/index.html
@@ -56,21 +56,23 @@
Panorama Eleitoral — Municípios do RJ
-
- Mapa de prefeituras
-
-
-
+
+
+ Mapa de prefeituras
+
+
+
-
- Composição da câmara municipal
-
-
-
+
+ Composição da câmara municipal
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index d3e952f..f00dad7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
"@types/node": "^22.10.0",
"@types/topojson-client": "^3.1.5",
"@types/topojson-specification": "^1.0.5",
+ "happy-dom": "^15.11.7",
"sirv": "^3.0.0",
"typescript": "^5.7.2",
"vite": "^6.0.3",
@@ -1809,6 +1810,19 @@
"robust-predicates": "^3.0.2"
}
},
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@@ -1911,6 +1925,22 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/happy-dom": {
+ "version": "15.11.7",
+ "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-15.11.7.tgz",
+ "integrity": "sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "entities": "^4.5.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-mimetype": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -3452,6 +3482,26 @@
}
}
},
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+ "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
diff --git a/package.json b/package.json
index 990d304..ca05e0f 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"@types/node": "^22.10.0",
"@types/topojson-client": "^3.1.5",
"@types/topojson-specification": "^1.0.5",
+ "happy-dom": "^15.11.7",
"sirv": "^3.0.0",
"typescript": "^5.7.2",
"vite": "^6.0.3",
diff --git a/public/styles/main.css b/public/styles/main.css
index 6509010..e8c51ee 100644
--- a/public/styles/main.css
+++ b/public/styles/main.css
@@ -3,6 +3,8 @@
--color-right: #4575b4;
--color-focus: #234;
--max-width: 1000px;
+ --viz-max-width: 1900px;
+ --sankey-column-width: 850px;
--font-sans: system-ui, sans-serif;
}
@@ -38,7 +40,7 @@ body {
#topBar {
text-align: center;
- max-width: var(--max-width);
+ max-width: var(--viz-max-width);
width: 100%;
}
@@ -49,39 +51,53 @@ body {
#centerblock {
width: 100%;
- max-width: var(--max-width);
+ max-width: var(--viz-max-width);
}
-#uf-option {
- display: block;
- margin: 1rem 0;
- font-size: 1rem;
+.viz-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, var(--sankey-column-width));
+ gap: 1rem;
+ align-items: start;
+ margin: 1.5rem 0;
+}
+
+@media (max-width: 1100px) {
+ .viz-row {
+ grid-template-columns: 1fr;
+ }
}
#mapas,
#sankey {
- margin: 1.5rem 0;
+ margin: 0;
+ min-width: 0;
}
-#mainChart,
-#sankeyChart {
+#mainChart {
+ display: block;
width: 100%;
max-width: var(--max-width);
height: auto;
}
-#mainChart {
- display: block;
-}
-
#sankeyChart {
display: none;
+ width: 100%;
+ max-width: var(--sankey-column-width);
+ height: auto;
}
#sankeyChart.is-visible {
display: block;
}
+#uf-option {
+ display: block;
+ margin: 1rem 0;
+ font-size: 1rem;
+}
+
#timescroll {
display: flex;
flex-wrap: wrap;
@@ -152,6 +168,7 @@ body {
padding: 1rem;
border-radius: 4px;
line-height: 1.5;
+ min-height: 4.5rem;
}
.sankey-message--hint {
diff --git a/src/sankey/councilSankey.ts b/src/sankey/councilSankey.ts
index a42780e..8d664c3 100644
--- a/src/sankey/councilSankey.ts
+++ b/src/sankey/councilSankey.ts
@@ -9,16 +9,16 @@ import {
import { SANKEY_HEIGHT, SANKEY_WIDTH } from "../config";
import type { SankeyGraph as AppSankeyGraph } from "../types/election";
import { orientacaoFromSankeyNodeName, orientacaoToColor } from "../utils/colors";
+import {
+ clampSankeyNodeY,
+ SANKEY_NODE_WIDTH,
+ sankeyLayoutExtent,
+} from "./sankeyLayout";
type LayoutNode = SankeyNode<{}, {}> & { name: string };
type LayoutLink = SankeyLink;
const UNITS = "Cadeiras";
-const MARGIN = { top: 20, right: 10, bottom: 50, left: 10 };
-const INNER_HEIGHT = SANKEY_HEIGHT - 100;
-/** Mesmo deslocamento do site legado (camara.js) */
-const CHART_OFFSET_X = 100;
-const CHART_OFFSET_Y = 55;
export class CouncilSankey {
private readonly svg: Selection;
@@ -65,6 +65,8 @@ export class CouncilSankey {
this.messageEl.hidden = true;
this.svg.classed("is-visible", true).attr("aria-hidden", "false").style("display", null);
+ const { extent, yMin, yMax } = sankeyLayoutExtent();
+
const layoutGraph = {
nodes: graph.nodes.map((n) => ({ name: n.name })),
links: graph.links.map((l) => ({
@@ -75,13 +77,10 @@ export class CouncilSankey {
};
const layout = sankey()
- .nodeWidth(50)
+ .nodeWidth(SANKEY_NODE_WIDTH)
.nodePadding(30)
.nodeAlign(sankeyJustify)
- .extent([
- [MARGIN.left, MARGIN.top],
- [SANKEY_WIDTH - MARGIN.right, INNER_HEIGHT],
- ]);
+ .extent(extent);
const { nodes, links } = layout(layoutGraph);
const linkPath = sankeyLinkHorizontal();
@@ -89,11 +88,7 @@ export class CouncilSankey {
const activeNodes = nodes.filter((n) => (n.value ?? 0) > 0);
const graphForUpdate = { nodes: activeNodes, links: activeLinks };
- const innerG = this.chartG
- .append("g")
- .attr("class", "sankey-inner")
- .attr("transform", `translate(${CHART_OFFSET_X},${CHART_OFFSET_Y})`);
-
+ const innerG = this.chartG.append("g").attr("class", "sankey-inner");
const dragContainer = innerG.node()!;
const linkSelection = innerG
@@ -156,10 +151,7 @@ export class CouncilSankey {
})
.on("drag", function (event, d) {
const nodeHeight = (d.y1 ?? 0) - (d.y0 ?? 0);
- const y = Math.max(
- MARGIN.top,
- Math.min(INNER_HEIGHT - nodeHeight, event.y),
- );
+ const y = clampSankeyNodeY(event.y, nodeHeight, yMin, yMax);
d.y0 = y;
d.y1 = y + nodeHeight;
select(this.parentNode as SVGGElement).attr(
@@ -175,7 +167,7 @@ export class CouncilSankey {
this.chartG
.append("text")
.attr("class", "chartTitle2")
- .attr("transform", `translate(${SANKEY_WIDTH / 2},25)`)
+ .attr("transform", `translate(${SANKEY_WIDTH / 2},22)`)
.attr("text-anchor", "middle")
.text(title);
}
diff --git a/src/sankey/sankeyLayout.ts b/src/sankey/sankeyLayout.ts
new file mode 100644
index 0000000..027ed65
--- /dev/null
+++ b/src/sankey/sankeyLayout.ts
@@ -0,0 +1,43 @@
+import { SANKEY_HEIGHT, SANKEY_WIDTH } from "../config";
+
+export const SANKEY_NODE_WIDTH = 50;
+export const SANKEY_MARGIN = { top: 20, right: 10, bottom: 50, left: 10 };
+export const SANKEY_TITLE_HEIGHT = 35;
+
+export function sankeyInnerHeight(sankeyHeight = SANKEY_HEIGHT): number {
+ return sankeyHeight - 100;
+}
+
+export interface SankeyLayoutExtent {
+ innerHeight: number;
+ extent: [[number, number], [number, number]];
+ yMin: number;
+ yMax: number;
+}
+
+export function sankeyLayoutExtent(
+ sankeyWidth = SANKEY_WIDTH,
+ sankeyHeight = SANKEY_HEIGHT,
+ nodeWidth = SANKEY_NODE_WIDTH,
+): SankeyLayoutExtent {
+ const innerHeight = sankeyInnerHeight(sankeyHeight);
+ const yMin = SANKEY_MARGIN.top + SANKEY_TITLE_HEIGHT;
+ return {
+ innerHeight,
+ extent: [
+ [SANKEY_MARGIN.left, yMin],
+ [sankeyWidth - SANKEY_MARGIN.right - nodeWidth, innerHeight],
+ ],
+ yMin,
+ yMax: innerHeight,
+ };
+}
+
+export function clampSankeyNodeY(
+ y: number,
+ nodeHeight: number,
+ yMin: number,
+ yMax: number,
+): number {
+ return Math.max(yMin, Math.min(yMax - nodeHeight, y));
+}
diff --git a/tests/councilSankey.test.ts b/tests/councilSankey.test.ts
new file mode 100644
index 0000000..1d543b4
--- /dev/null
+++ b/tests/councilSankey.test.ts
@@ -0,0 +1,39 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { CouncilSankey } from "../src/sankey/councilSankey";
+import type { SankeyGraph } from "../src/types/election";
+
+function miniGraph(): SankeyGraph {
+ return JSON.parse(
+ readFileSync(join(process.cwd(), "tests/fixtures/sankey-mini.json"), "utf-8"),
+ ) as SankeyGraph;
+}
+
+describe("CouncilSankey", () => {
+ it("renderiza nós e links no SVG", () => {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.id = "sankeyChart";
+ const message = document.createElement("p");
+ message.id = "sankey-message";
+
+ const chart = new CouncilSankey(svg, message);
+ chart.render(miniGraph(), "Teste");
+
+ expect(svg.classList.contains("is-visible")).toBe(true);
+ expect(message.hidden).toBe(true);
+ expect(svg.querySelectorAll(".node rect").length).toBeGreaterThan(0);
+ expect(svg.querySelectorAll(".sankey-links path").length).toBeGreaterThan(0);
+ });
+
+ it("mostra estado vazio quando grafo não tem links", () => {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ const message = document.createElement("p");
+
+ const chart = new CouncilSankey(svg, message);
+ chart.render({ nodes: [], links: [] }, "Vazio");
+
+ expect(svg.classList.contains("is-visible")).toBe(false);
+ expect(message.hidden).toBe(false);
+ });
+});
diff --git a/tests/fixtures/sankey-mini.json b/tests/fixtures/sankey-mini.json
new file mode 100644
index 0000000..cd1d011
--- /dev/null
+++ b/tests/fixtures/sankey-mini.json
@@ -0,0 +1,12 @@
+{
+ "nodes": [
+ { "name": "2004-01" },
+ { "name": "2004-03" },
+ { "name": "2008-01" },
+ { "name": "2008-03" }
+ ],
+ "links": [
+ { "source": 0, "target": 2, "value": 5 },
+ { "source": 1, "target": 3, "value": 8 }
+ ]
+}
diff --git a/tests/sankeyGraph.test.ts b/tests/sankeyGraph.test.ts
index 9c384b5..a5828dc 100644
--- a/tests/sankeyGraph.test.ts
+++ b/tests/sankeyGraph.test.ts
@@ -35,3 +35,25 @@ describe("graphFromVereadores", () => {
expect(graph!.links.length).toBeGreaterThan(0);
});
});
+
+describe("graphFromCsv Rio", () => {
+ it("produz links com value > 0 e índices válidos", () => {
+ const rows = readFileSync(
+ join(process.cwd(), "data/camara-rj.csv"),
+ "utf-8",
+ )
+ .trim()
+ .split("\n")
+ .slice(1)
+ .map((line) => {
+ const [source, target, value] = line.split(",");
+ return { source, target, value };
+ });
+ const graph = graphFromCsv(rows, listarAnos());
+ expect(graph.links.some((l) => l.value > 0)).toBe(true);
+ for (const link of graph.links) {
+ expect(link.source).toBeGreaterThanOrEqual(0);
+ expect(link.target).toBeLessThan(graph.nodes.length);
+ }
+ });
+});
diff --git a/tests/sankeyLayout.test.ts b/tests/sankeyLayout.test.ts
new file mode 100644
index 0000000..e8a92bc
--- /dev/null
+++ b/tests/sankeyLayout.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import { SANKEY_HEIGHT, SANKEY_WIDTH } from "../src/config";
+import {
+ clampSankeyNodeY,
+ SANKEY_NODE_WIDTH,
+ sankeyLayoutExtent,
+} from "../src/sankey/sankeyLayout";
+
+describe("sankeyLayoutExtent", () => {
+ it("mantém nós dentro da largura do viewBox", () => {
+ const { extent } = sankeyLayoutExtent();
+ const [, [xMax]] = extent;
+ expect(xMax + SANKEY_NODE_WIDTH).toBeLessThanOrEqual(SANKEY_WIDTH);
+ });
+
+ it("reserva espaço vertical para o título", () => {
+ const { yMin } = sankeyLayoutExtent();
+ expect(yMin).toBeGreaterThan(20);
+ expect(yMin).toBeLessThan(SANKEY_HEIGHT / 4);
+ });
+});
+
+describe("clampSankeyNodeY", () => {
+ it("limita deslocamento vertical do nó", () => {
+ const { yMin, yMax } = sankeyLayoutExtent();
+ const h = 40;
+ expect(clampSankeyNodeY(0, h, yMin, yMax)).toBe(yMin);
+ expect(clampSankeyNodeY(9999, h, yMin, yMax)).toBe(yMax - h);
+ expect(clampSankeyNodeY(100, h, yMin, yMax)).toBe(100);
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index 19384e8..c92f360 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -3,5 +3,6 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
+ environment: "happy-dom",
},
});