Skip to content
Merged

Dev #13

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
675 changes: 571 additions & 104 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@types/topojson-specification": "^1.0.5",
"happy-dom": "^15.11.7",
"sirv": "^3.0.0",
"esbuild": "^0.24.0",
"typescript": "^5.7.2",
"vite": "^6.0.3",
"vitest": "^2.1.8"
Expand Down
37 changes: 16 additions & 21 deletions src/data/sankeyGraph.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
buildOrientationFlowLinks,
computeNodeValues,
} from "../sankey/sankeyLinkPath";
import { listarAnos, parseSankeyNodeName } from "../utils/years";
import type {
CsvSankeyRow,
Expand Down Expand Up @@ -27,19 +31,21 @@ export function graphFromCsv(
: rows;

const nodeNames = new Set<string>();
const links: SankeyLink[] = [];
const csvLinks: SankeyLink[] = [];

for (const row of filteredRows) {
nodeNames.add(row.source);
nodeNames.add(row.target);
links.push({
csvLinks.push({
source: row.source,
target: row.target,
value: Number.parseInt(row.value, 10) || 0,
});
}

const nodes: SankeyNode[] = [...nodeNames].sort().map((name) => ({ name }));
const nodeValues = computeNodeValues(nodes, csvLinks);
const links = buildOrientationFlowLinks(nodes, nodeValues);
return indexGraph({ nodes, links });
}

Expand Down Expand Up @@ -77,26 +83,15 @@ export function graphFromVereadores(
}
}

const links: SankeyLink[] = [];
for (let i = 0; i < anos.length - 1; i++) {
const fromYear = anos[i];
const toYear = anos[i + 1];
const fromBuckets = bucketsByYear.get(fromYear);
const toBuckets = bucketsByYear.get(toYear);
if (!fromBuckets || !toBuckets) continue;

for (const [orientacao, value] of fromBuckets) {
if (value <= 0) continue;
const source = `${fromYear}-${String(orientacao).padStart(2, "0")}`;
const target = `${toYear}-${String(orientacao).padStart(2, "0")}`;
const targetValue = toBuckets.get(orientacao) ?? 0;
nodeNames.add(source);
nodeNames.add(target);
links.push({ source, target, value: Math.min(value, targetValue) || value });
}
}

const nodes: SankeyNode[] = [...nodeNames].sort().map((name) => ({ name }));
const nodeValues = new Map<string, number>();
for (const node of nodes) {
const parsed = parseSankeyNodeName(node.name);
if (!parsed) continue;
const buckets = bucketsByYear.get(String(parsed.year));
nodeValues.set(node.name, buckets?.get(parsed.orientacao) ?? 0);
}
const links = buildOrientationFlowLinks(nodes, nodeValues);
return indexGraph({ nodes, links });
}

Expand Down
32 changes: 21 additions & 11 deletions src/sankey/councilSankey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { drag, format, select, type Selection } from "d3";
import {
sankey,
sankeyJustify,
sankeyLinkHorizontal,
type SankeyLink,
type SankeyNode,
} from "d3-sankey";
Expand All @@ -14,6 +13,7 @@ import {
SANKEY_NODE_WIDTH,
sankeyLayoutExtent,
} from "./sankeyLayout";
import { sankeyFlowPath } from "./sankeyLinkPath";

type LayoutNode = SankeyNode<{}, {}> & { name: string };
type LayoutLink = SankeyLink<LayoutNode, {}>;
Expand Down Expand Up @@ -83,28 +83,39 @@ export class CouncilSankey {
.extent(extent);

const { nodes, links } = layout(layoutGraph);
const linkPath = sankeyLinkHorizontal();
const flowPath = (d: LayoutLink) =>
sankeyFlowPath(d.source as LayoutNode, d.target as LayoutNode);
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");
const dragContainer = innerG.node()!;

const linkSelection = innerG
.append("g")
.attr("class", "sankey-links")
.attr("fill", "none")
.attr("stroke", "#000")
.attr("stroke-opacity", 0.2)
.style("pointer-events", "none")
.selectAll<SVGPathElement, LayoutLink>("path")
.data(activeLinks)
.join("path")
.attr("class", (d) => (d.value ? "link" : "link zero"))
.attr("d", linkPath)
.attr("stroke-width", (d) => Math.max(1, d.width ?? 0))
.sort((a, b) => (b.width ?? 0) - (a.width ?? 0));
.attr("d", flowPath)
.attr("stroke", "none")
.style("fill", (d) =>
orientacaoToColor(
orientacaoFromSankeyNodeName((d.source as LayoutNode).name),
),
)
.style("fill-opacity", 0.45)
.sort((a, b) => {
const aH =
((a.target as LayoutNode).y1 ?? 0) -
((a.target as LayoutNode).y0 ?? 0);
const bH =
((b.target as LayoutNode).y1 ?? 0) -
((b.target as LayoutNode).y0 ?? 0);
return bH - aH;
});

linkSelection.append("title").text((d) => {
const s = d.source as LayoutNode;
Expand Down Expand Up @@ -158,8 +169,7 @@ export class CouncilSankey {
"transform",
`translate(${d.x0 ?? 0},${y})`,
);
layout.update(graphForUpdate);
linkSelection.attr("d", linkPath);
linkSelection.attr("d", flowPath);
});

nodeSelection.selectAll<SVGRectElement, LayoutNode>("rect").call(nodeDrag);
Expand Down
147 changes: 147 additions & 0 deletions src/sankey/sankeyLinkPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { parseSankeyNodeName } from "../utils/years";

export interface SankeyFlowNode {
x0?: number;
x1?: number;
y0?: number;
y1?: number;
}

export function orientationFromNode(name: string): number | null {
return parseSankeyNodeName(name)?.orientacao ?? null;
}

export function yearFromNode(name: string): number | null {
return parseSankeyNodeName(name)?.year ?? null;
}

export interface FlowLink {
source: string;
target: string;
value: number;
}

/** Cadeiras por nó: fluxo de entrada (eleição anterior); na primeira coluna, fluxo de saída. */
export function computeNodeValues(
nodes: { name: string }[],
links: {
source: string | number;
target: string | number;
value: number;
}[],
): Map<string, number> {
const values = new Map<string, number>();
const incoming = new Map<string, number>();
const outgoing = new Map<string, number>();

for (const node of nodes) {
values.set(node.name, 0);
incoming.set(node.name, 0);
outgoing.set(node.name, 0);
}

for (const link of links) {
const srcName =
typeof link.source === "string"
? link.source
: nodes[link.source as number]?.name;
const tgtName =
typeof link.target === "string"
? link.target
: nodes[link.target as number]?.name;
if (!srcName || !tgtName) continue;
const v = link.value;
incoming.set(tgtName, Math.max(incoming.get(tgtName) ?? 0, v));
outgoing.set(srcName, Math.max(outgoing.get(srcName) ?? 0, v));
}

for (const node of nodes) {
const inc = incoming.get(node.name) ?? 0;
const out = outgoing.get(node.name) ?? 0;
const hasIncomingLink = links.some((link) => {
const tgtName =
typeof link.target === "string"
? link.target
: nodes[link.target as number]?.name;
return tgtName === node.name;
});
values.set(node.name, hasIncomingLink ? inc : out);
}

return values;
}

/**
* Para cada orientação, liga cada ano ao próximo ano em que o grupo tem cadeiras (> 0),
* ignorando anos intermediários ausentes ou com valor zero.
*/
export function buildOrientationFlowLinks(
nodes: { name: string }[],
nodeValues: Map<string, number>,
): FlowLink[] {
const byOrientation = new Map<
number,
{ year: number; name: string; value: number }[]
>();

for (const node of nodes) {
const parsed = parseSankeyNodeName(node.name);
if (!parsed) continue;
const value = nodeValues.get(node.name) ?? 0;
if (value <= 0) continue;

let list = byOrientation.get(parsed.orientacao);
if (!list) {
list = [];
byOrientation.set(parsed.orientacao, list);
}
list.push({ year: parsed.year, name: node.name, value });
}

const links: FlowLink[] = [];
for (const entries of byOrientation.values()) {
entries.sort((a, b) => a.year - b.year);
for (let i = 0; i < entries.length - 1; i++) {
const src = entries[i]!;
const tgt = entries[i + 1]!;
links.push({
source: src.name,
target: tgt.name,
value: Math.min(src.value, tgt.value) || src.value || tgt.value,
});
}
}
return links;
}

/**
* Faixa preenchida entre a borda inferior direita da origem e a borda inferior esquerda
* do destino, com espessura igual à altura de cada nó (cadeiras).
*/
export function sankeyFlowPath(
source: SankeyFlowNode,
target: SankeyFlowNode,
curvature = 0.5,
): string {
const xSource = source.x1 ?? 0;
const xTarget = target.x0 ?? 0;
const ySourceBottom = source.y1 ?? 0;
const yTargetBottom = target.y1 ?? 0;
const hSource = Math.max(1, (source.y1 ?? 0) - (source.y0 ?? 0));
const hTarget = Math.max(1, (target.y1 ?? 0) - (target.y0 ?? 0));

const ySourceTop = ySourceBottom - hSource;
const yTargetTop = yTargetBottom - hTarget;

const gap = xTarget - xSource;
const c1 = xSource + gap * curvature;
const c2 = xTarget - gap * curvature;

return [
`M${xSource},${ySourceTop}`,
`C${c1},${ySourceTop} ${c2},${yTargetTop} ${xTarget},${yTargetTop}`,
`L${xTarget},${yTargetBottom}`,
`C${c2},${yTargetBottom} ${c1},${ySourceBottom} ${xSource},${ySourceBottom}`,
"Z",
].join("");
}
8 changes: 6 additions & 2 deletions tests/councilSankey.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ function miniGraph(): SankeyGraph {
}

describe("CouncilSankey", () => {
it("renderiza nós e links no SVG", () => {
it("renderiza nós e links preenchidos no SVG", () => {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.id = "sankeyChart";
const message = document.createElement("p");
Expand All @@ -23,7 +23,11 @@ describe("CouncilSankey", () => {
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);
const paths = svg.querySelectorAll(".sankey-links path");
expect(paths.length).toBeGreaterThan(0);
const firstPath = paths[0] as SVGPathElement;
expect(firstPath.getAttribute("d")).toMatch(/Z$/);
expect(firstPath.style.fillOpacity).toBe("0.45");
});

it("mostra estado vazio quando grafo não tem links", () => {
Expand Down
40 changes: 28 additions & 12 deletions tests/sankeyGraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,24 +36,40 @@ describe("graphFromVereadores", () => {
});
});

function loadCamaraRjRows() {
return 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 };
});
}

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());
const graph = graphFromCsv(loadCamaraRjRows(), 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);
}
});

it("cria ligação direta quando ano intermediário tem zero cadeiras", () => {
const rows = [
{ source: "2004-03", target: "2008-03", value: "5" },
{ source: "2008-03", target: "2012-03", value: "0" },
{ source: "2012-03", target: "2016-03", value: "3" },
];
const graph = graphFromCsv(rows, listarAnos());
const names = graph.nodes.map((n) => n.name);
const idx = (name: string) => names.indexOf(name);
const skipLink = graph.links.find(
(l) => l.source === idx("2008-03") && l.target === idx("2016-03"),
);
expect(skipLink).toBeDefined();
expect(skipLink!.value).toBe(3);
});
});
Loading
Loading