Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 16 additions & 14 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,21 +56,23 @@ <h1>Panorama Eleitoral — Municípios do RJ</h1>
<option value="TO" disabled>Tocantins</option>
</select>

<section id="mapas" aria-labelledby="mapa-titulo">
<h2 id="mapa-titulo" class="visually-hidden">Mapa de prefeituras</h2>
<svg id="mainChart" role="img" aria-label="Mapa coroplético dos municípios do Rio de Janeiro"></svg>
<div id="timescroll" role="group" aria-label="Navegação por ano eleitoral">
<button type="button" id="previous" aria-label="Ano anterior">Anterior</button>
<div id="ano-atual" aria-live="polite" aria-atomic="true"></div>
<button type="button" id="next" aria-label="Próximo ano">Próximo</button>
</div>
</section>
<div class="viz-row">
<section id="mapas" aria-labelledby="mapa-titulo">
<h2 id="mapa-titulo" class="visually-hidden">Mapa de prefeituras</h2>
<svg id="mainChart" role="img" aria-label="Mapa coroplético dos municípios do Rio de Janeiro"></svg>
<div id="timescroll" role="group" aria-label="Navegação por ano eleitoral">
<button type="button" id="previous" aria-label="Ano anterior">Anterior</button>
<div id="ano-atual" aria-live="polite" aria-atomic="true"></div>
<button type="button" id="next" aria-label="Próximo ano">Próximo</button>
</div>
</section>

<section id="sankey" aria-labelledby="sankey-titulo">
<h2 id="sankey-titulo" class="visually-hidden">Composição da câmara municipal</h2>
<p id="sankey-message" class="sankey-message sankey-message--hint"></p>
<svg id="sankeyChart" role="img" aria-label="Diagrama Sankey da câmara municipal" aria-hidden="true"></svg>
</section>
<section id="sankey" aria-labelledby="sankey-titulo">
<h2 id="sankey-titulo" class="visually-hidden">Composição da câmara municipal</h2>
<p id="sankey-message" class="sankey-message sankey-message--hint"></p>
<svg id="sankeyChart" role="img" aria-label="Diagrama Sankey da câmara municipal" aria-hidden="true"></svg>
</section>
</div>

<aside id="lista-features" aria-label="Legenda e instruções"></aside>
</main>
Expand Down
50 changes: 50 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
43 changes: 30 additions & 13 deletions public/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -38,7 +40,7 @@ body {

#topBar {
text-align: center;
max-width: var(--max-width);
max-width: var(--viz-max-width);
width: 100%;
}

Expand All @@ -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;
Expand Down Expand Up @@ -152,6 +168,7 @@ body {
padding: 1rem;
border-radius: 4px;
line-height: 1.5;
min-height: 4.5rem;
}

.sankey-message--hint {
Expand Down
32 changes: 12 additions & 20 deletions src/sankey/councilSankey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LayoutNode, {}>;

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<SVGSVGElement, unknown, null, undefined>;
Expand Down Expand Up @@ -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) => ({
Expand All @@ -75,25 +77,18 @@ export class CouncilSankey {
};

const layout = sankey<LayoutNode, LayoutLink>()
.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();
const activeLinks = links.filter((l) => l.value > 0);
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
Expand Down Expand Up @@ -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(
Expand All @@ -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);
}
Expand Down
43 changes: 43 additions & 0 deletions src/sankey/sankeyLayout.ts
Original file line number Diff line number Diff line change
@@ -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));
}
39 changes: 39 additions & 0 deletions tests/councilSankey.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
12 changes: 12 additions & 0 deletions tests/fixtures/sankey-mini.json
Original file line number Diff line number Diff line change
@@ -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 }
]
}
Loading
Loading