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", }, });