feat(dashboard): zoom into cashflow sankey categories (#1807)

* feat(dashboard): zoom into cashflow sankey categories

Click a category node on the dashboard cashflow Sankey to focus on it and
its descendants only; a back button restores the full view. Clicking the
Cash Flow node zooms to the expense (outbound) side.

- Pure utility (app/javascript/utils/sankey_zoom.js) computes the
  descendant subgraph from a clicked node, with direction inferred by
  reachability from the cash flow node (outbound for expense, inbound
  for income).
- Stable node ids emitted from the controller so the JS can identify
  nodes across re-renders.
- Stimulus controller adds chart + zoomOutButton targets, fade
  transition, and only sets a pointer cursor when a node has children.
- Node:test coverage for expense, income, cash-flow, and malformed-data
  cases; \"type\": \"module\" added to package.json so the .js util is
  ESM-compatible under Node.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(dashboard): extract cashflow sankey chart partial

Deduplicate sankey chart markup between inline and expanded dialog views,
and reset zoom state when chart data changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(js): rename sankey_zoom util to .mjs to drop project-wide ESM flag

Removes "type": "module" from package.json to avoid implicitly switching
every .js file in the project to ESM (a future footgun for any .js config
file added by Biome, Vite, etc.). Renames the utility to .mjs so node --test
can import the ES module directly, and adds an explicit importmap pin since
pin_all_from only globs .js/.jsm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(assets): register .mjs MIME type for Propshaft

Propshaft derives Content-Type from Mime::Type.lookup_by_extension, which
returns nil for :mjs by default. Browsers refuse to execute ES modules
served with an empty Content-Type, breaking the sankey_zoom util loaded
via importmap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal Tajchert
2026-05-20 21:17:35 +02:00
committed by GitHub
parent 675a7164ed
commit e21ab9819f
10 changed files with 557 additions and 81 deletions

View File

@@ -1,14 +1,17 @@
import { Controller } from "@hotwired/stimulus";
import * as d3 from "d3";
import { sankey } from "d3-sankey";
import { sankeyNodeHasChildren, zoomSankeyData } from "utils/sankey_zoom";
// Connects to data-controller="sankey-chart"
export default class extends Controller {
static targets = ["chart", "zoomOutButton"];
static values = {
data: Object,
nodeWidth: { type: Number, default: 15 },
nodePadding: { type: Number, default: 20 },
currencySymbol: { type: String, default: "$" }
currencySymbol: { type: String, default: "$" },
};
// Visual constants
@@ -18,64 +21,158 @@ export default class extends Controller {
static MIN_NODE_PADDING = 4;
static MAX_PADDING_RATIO = 0.4;
static CORNER_RADIUS = 8;
static ZOOM_TRANSITION_MS = 220;
static DEFAULT_COLOR = "var(--color-gray-400)";
static CSS_VAR_MAP = {
"var(--color-success)": "#10A861",
"var(--color-destructive)": "#EC2222",
"var(--color-gray-400)": "#9E9E9E",
"var(--color-gray-500)": "#737373"
"var(--color-gray-500)": "#737373",
};
static MIN_LABEL_SPACING = 28; // Minimum vertical space needed for labels (2 lines)
connect() {
this.connected = true;
this.zoomRootId = null;
this.resizeObserver = new ResizeObserver(() => this.#draw());
this.resizeObserver.observe(this.element);
this.resizeObserver.observe(this.#chartElement());
this.tooltip = null;
this.#createTooltip();
this.#syncZoomControls();
this.#draw();
}
dataValueChanged() {
if (!this.connected) return;
this.zoomRootId = null;
this.#syncZoomControls();
this.#draw();
}
disconnect() {
this.connected = false;
this.resizeObserver?.disconnect();
clearTimeout(this.drawTimeout);
this.tooltip?.remove();
this.tooltip = null;
}
#draw() {
const { nodes = [], links = [] } = this.dataValue || {};
zoomOut() {
if (!this.zoomRootId) return;
this.zoomRootId = null;
this.#syncZoomControls();
this.#draw({ animate: true });
}
#draw({ animate = false } = {}) {
const { nodes = [], links = [] } = this.#visibleData();
if (!nodes.length || !links.length) return;
// Hide tooltip and reset any hover states before redrawing
this.#hideTooltip();
d3.select(this.element).selectAll("svg").remove();
const chartElement = this.#chartElement();
const chart = d3.select(chartElement);
const width = this.element.clientWidth || 600;
const height = this.element.clientHeight || 400;
clearTimeout(this.drawTimeout);
chart.selectAll("svg").interrupt();
const svg = d3.select(this.element)
if (animate) {
chart
.selectAll("svg")
.transition()
.duration(this.constructor.ZOOM_TRANSITION_MS / 2)
.style("opacity", 0)
.remove();
this.drawTimeout = setTimeout(() => {
this.#render(nodes, links, true);
}, this.constructor.ZOOM_TRANSITION_MS / 2);
} else {
chart.selectAll("svg").remove();
this.#render(nodes, links, false);
}
}
#render(nodes, links, animate) {
const chartElement = this.#chartElement();
const chart = d3.select(chartElement);
const width = chartElement.clientWidth || 600;
const height = chartElement.clientHeight || 400;
const svg = chart
.append("svg")
.attr("width", width)
.attr("height", height);
.attr("height", height)
.style("opacity", animate ? 0 : 1);
const effectivePadding = this.#calculateNodePadding(nodes.length, height);
const sankeyData = this.#generateSankeyData(nodes, links, width, height, effectivePadding);
const sankeyData = this.#generateSankeyData(
nodes,
links,
width,
height,
effectivePadding,
);
this.#createGradients(svg, sankeyData.links);
const linkPaths = this.#drawLinks(svg, sankeyData.links);
const { nodeGroups, hiddenLabels } = this.#drawNodes(svg, sankeyData.nodes, width);
const { nodeGroups, hiddenLabels } = this.#drawNodes(
svg,
sankeyData.nodes,
width,
);
this.#attachHoverEvents(linkPaths, nodeGroups, sankeyData, hiddenLabels);
if (animate) {
svg
.transition()
.duration(this.constructor.ZOOM_TRANSITION_MS / 2)
.style("opacity", 1);
}
}
#chartElement() {
return this.hasChartTarget ? this.chartTarget : this.element;
}
#visibleData() {
if (!this.zoomRootId) return this.dataValue || {};
return zoomSankeyData(this.dataValue, this.zoomRootId);
}
#syncZoomControls() {
this.zoomOutButtonTargets.forEach((button) => {
button.hidden = !this.zoomRootId;
});
}
#zoomIn(node) {
if (!node.id || !sankeyNodeHasChildren(this.#visibleData(), node.id))
return;
this.zoomRootId = node.id;
this.#syncZoomControls();
this.#draw({ animate: true });
}
// Dynamic padding prevents padding from dominating when there are many nodes
#calculateNodePadding(nodeCount, height) {
const margin = this.constructor.EXTENT_MARGIN;
const availableHeight = height - (margin * 2);
const maxPaddingTotal = availableHeight * this.constructor.MAX_PADDING_RATIO;
const availableHeight = height - margin * 2;
const maxPaddingTotal =
availableHeight * this.constructor.MAX_PADDING_RATIO;
const gaps = Math.max(nodeCount - 1, 1);
const dynamicPadding = Math.min(this.nodePaddingValue, Math.floor(maxPaddingTotal / gaps));
const dynamicPadding = Math.min(
this.nodePaddingValue,
Math.floor(maxPaddingTotal / gaps),
);
return Math.max(this.constructor.MIN_NODE_PADDING, dynamicPadding);
}
@@ -84,11 +181,14 @@ export default class extends Controller {
const sankeyGenerator = sankey()
.nodeWidth(this.nodeWidthValue)
.nodePadding(nodePadding)
.extent([[margin, margin], [width - margin, height - margin]]);
.extent([
[margin, margin],
[width - margin, height - margin],
]);
return sankeyGenerator({
nodes: nodes.map(d => ({ ...d })),
links: links.map(d => ({ ...d })),
nodes: nodes.map((d) => ({ ...d })),
links: links.map((d) => ({ ...d })),
});
}
@@ -97,17 +197,20 @@ export default class extends Controller {
links.forEach((link, i) => {
const gradientId = this.#gradientId(link, i);
const gradient = defs.append("linearGradient")
const gradient = defs
.append("linearGradient")
.attr("id", gradientId)
.attr("gradientUnits", "userSpaceOnUse")
.attr("x1", link.source.x1)
.attr("x2", link.target.x0);
gradient.append("stop")
gradient
.append("stop")
.attr("offset", "0%")
.attr("stop-color", this.#colorWithOpacity(link.source.color));
gradient.append("stop")
gradient
.append("stop")
.attr("offset", "100%")
.attr("stop-color", this.#colorWithOpacity(link.target.color));
});
@@ -132,32 +235,37 @@ export default class extends Controller {
}
#drawLinks(svg, links) {
return svg.append("g")
return svg
.append("g")
.attr("fill", "none")
.selectAll("path")
.data(links)
.join("path")
.attr("class", "sankey-link")
.attr("d", d => d3.linkHorizontal()({
source: [d.source.x1, d.y0],
target: [d.target.x0, d.y1]
}))
.attr("d", (d) =>
d3.linkHorizontal()({
source: [d.source.x1, d.y0],
target: [d.target.x0, d.y1],
}),
)
.attr("stroke", (d, i) => `url(#${this.#gradientId(d, i)})`)
.attr("stroke-width", d => Math.max(1, d.width))
.attr("stroke-width", (d) => Math.max(1, d.width))
.style("transition", "opacity 0.3s ease");
}
#drawNodes(svg, nodes, width) {
const nodeGroups = svg.append("g")
const nodeGroups = svg
.append("g")
.selectAll("g")
.data(nodes)
.join("g")
.style("transition", "opacity 0.3s ease");
nodeGroups.append("path")
.attr("d", d => this.#nodePath(d))
.attr("fill", d => d.color || this.constructor.DEFAULT_COLOR)
.attr("stroke", d => d.color ? "none" : "var(--color-gray-500)");
nodeGroups
.append("path")
.attr("d", (d) => this.#nodePath(d))
.attr("fill", (d) => d.color || this.constructor.DEFAULT_COLOR)
.attr("stroke", (d) => (d.color ? "none" : "var(--color-gray-500)"));
const hiddenLabels = this.#addNodeLabels(nodeGroups, width, nodes);
@@ -167,10 +275,15 @@ export default class extends Controller {
#nodePath(node) {
const { x0, y0, x1, y1 } = node;
const height = y1 - y0;
const radius = Math.max(0, Math.min(this.constructor.CORNER_RADIUS, height / 2));
const radius = Math.max(
0,
Math.min(this.constructor.CORNER_RADIUS, height / 2),
);
const isSourceNode = node.sourceLinks?.length > 0 && !node.targetLinks?.length;
const isTargetNode = node.targetLinks?.length > 0 && !node.sourceLinks?.length;
const isSourceNode =
node.sourceLinks?.length > 0 && !node.targetLinks?.length;
const isTargetNode =
node.targetLinks?.length > 0 && !node.sourceLinks?.length;
// Too small for rounded corners
if (height < radius * 2) {
@@ -215,14 +328,18 @@ export default class extends Controller {
const controller = this;
const hiddenLabels = this.#calculateHiddenLabels(nodes);
nodeGroups.append("text")
.attr("x", d => d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6)
.attr("y", d => (d.y1 + d.y0) / 2)
nodeGroups
.append("text")
.attr("x", (d) => (d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6))
.attr("y", (d) => (d.y1 + d.y0) / 2)
.attr("dy", "-0.2em")
.attr("text-anchor", d => d.x0 < width / 2 ? "start" : "end")
.attr("class", "text-xs font-medium text-primary fill-current select-none")
.attr("text-anchor", (d) => (d.x0 < width / 2 ? "start" : "end"))
.attr(
"class",
"text-xs font-medium text-primary fill-current select-none",
)
.style("cursor", "default")
.style("opacity", d => hiddenLabels.has(d.index) ? 0 : 1)
.style("opacity", (d) => (hiddenLabels.has(d.index) ? 0 : 1))
.style("transition", "opacity 0.2s ease")
.each(function (d) {
const textEl = d3.select(this);
@@ -230,7 +347,8 @@ export default class extends Controller {
textEl.append("tspan").text(d.name);
textEl.append("tspan")
textEl
.append("tspan")
.attr("x", textEl.attr("x"))
.attr("dy", "1.2em")
.attr("class", "font-mono text-secondary")
@@ -244,26 +362,28 @@ export default class extends Controller {
// Calculate which labels should be hidden to prevent overlap
#calculateHiddenLabels(nodes) {
const hiddenLabels = new Set();
const height = this.element.clientHeight || 400;
const height = this.#chartElement().clientHeight || 400;
const isLargeGraph = height > 600;
const minSpacing = isLargeGraph ? this.constructor.MIN_LABEL_SPACING * 0.7 : this.constructor.MIN_LABEL_SPACING;
const minSpacing = isLargeGraph
? this.constructor.MIN_LABEL_SPACING * 0.7
: this.constructor.MIN_LABEL_SPACING;
// Group nodes by column (using depth which d3-sankey assigns)
const columns = new Map();
nodes.forEach(node => {
nodes.forEach((node) => {
const depth = node.depth;
if (!columns.has(depth)) columns.set(depth, []);
columns.get(depth).push(node);
});
// For each column, check for overlapping labels
columns.forEach(columnNodes => {
columns.forEach((columnNodes) => {
// Sort by vertical position
columnNodes.sort((a, b) => ((a.y0 + a.y1) / 2) - ((b.y0 + b.y1) / 2));
columnNodes.sort((a, b) => (a.y0 + a.y1) / 2 - (b.y0 + b.y1) / 2);
let lastVisibleY = Number.NEGATIVE_INFINITY;
columnNodes.forEach(node => {
columnNodes.forEach((node) => {
const nodeY = (node.y0 + node.y1) / 2;
const nodeHeight = node.y1 - node.y0;
@@ -284,25 +404,41 @@ export default class extends Controller {
#attachHoverEvents(linkPaths, nodeGroups, sankeyData, hiddenLabels) {
const applyHover = (targetLinks) => {
const targetSet = new Set(targetLinks);
const connectedNodes = new Set(targetLinks.flatMap(l => [l.source, l.target]));
const connectedNodes = new Set(
targetLinks.flatMap((l) => [l.source, l.target]),
);
linkPaths
.style("opacity", d => targetSet.has(d) ? 1 : this.constructor.HOVER_OPACITY)
.style("filter", d => targetSet.has(d) ? this.constructor.HOVER_FILTER : "none");
.style("opacity", (d) =>
targetSet.has(d) ? 1 : this.constructor.HOVER_OPACITY,
)
.style("filter", (d) =>
targetSet.has(d) ? this.constructor.HOVER_FILTER : "none",
);
nodeGroups.style("opacity", d => connectedNodes.has(d) ? 1 : this.constructor.HOVER_OPACITY);
nodeGroups.style("opacity", (d) =>
connectedNodes.has(d) ? 1 : this.constructor.HOVER_OPACITY,
);
// Show labels for connected nodes (even if normally hidden)
nodeGroups.selectAll("text")
.style("opacity", d => connectedNodes.has(d) ? 1 : (hiddenLabels.has(d.index) ? 0 : this.constructor.HOVER_OPACITY));
nodeGroups
.selectAll("text")
.style("opacity", (d) =>
connectedNodes.has(d)
? 1
: hiddenLabels.has(d.index)
? 0
: this.constructor.HOVER_OPACITY,
);
};
const resetHover = () => {
linkPaths.style("opacity", 1).style("filter", "none");
nodeGroups.style("opacity", 1);
// Restore hidden labels to hidden state
nodeGroups.selectAll("text")
.style("opacity", d => hiddenLabels.has(d.index) ? 0 : 1);
nodeGroups
.selectAll("text")
.style("opacity", (d) => (hiddenLabels.has(d.index) ? 0 : 1));
};
linkPaths
@@ -310,33 +446,56 @@ export default class extends Controller {
applyHover([d]);
this.#showTooltip(event, d.value, d.percentage);
})
.on("mousemove", event => this.#updateTooltipPosition(event))
.on("mousemove", (event) => this.#updateTooltipPosition(event))
.on("mouseleave", () => {
resetHover();
this.#hideTooltip();
});
// Hover on node rectangles (not just text)
nodeGroups.selectAll("path")
.style("cursor", "default")
nodeGroups
.selectAll("path")
.style("cursor", (d) =>
sankeyNodeHasChildren(this.#visibleData(), d.id)
? "pointer"
: "default",
)
.on("mouseenter", (event, d) => {
const connectedLinks = sankeyData.links.filter(l => l.source === d || l.target === d);
const connectedLinks = sankeyData.links.filter(
(l) => l.source === d || l.target === d,
);
applyHover(connectedLinks);
this.#showTooltip(event, d.value, d.percentage, d.name);
})
.on("mousemove", event => this.#updateTooltipPosition(event))
.on("mousemove", (event) => this.#updateTooltipPosition(event))
.on("click", (event, d) => {
event.stopPropagation();
this.#zoomIn(d);
})
.on("mouseleave", () => {
resetHover();
this.#hideTooltip();
});
nodeGroups.selectAll("text")
nodeGroups
.selectAll("text")
.style("cursor", (d) =>
sankeyNodeHasChildren(this.#visibleData(), d.id)
? "pointer"
: "default",
)
.on("mouseenter", (event, d) => {
const connectedLinks = sankeyData.links.filter(l => l.source === d || l.target === d);
const connectedLinks = sankeyData.links.filter(
(l) => l.source === d || l.target === d,
);
applyHover(connectedLinks);
this.#showTooltip(event, d.value, d.percentage, d.name);
})
.on("mousemove", event => this.#updateTooltipPosition(event))
.on("mousemove", (event) => this.#updateTooltipPosition(event))
.on("click", (event, d) => {
event.stopPropagation();
this.#zoomIn(d);
})
.on("mouseleave", () => {
resetHover();
this.#hideTooltip();
@@ -347,9 +506,13 @@ export default class extends Controller {
#createTooltip() {
const dialog = this.element.closest("dialog");
this.tooltip = d3.select(dialog || document.body)
this.tooltip = d3
.select(dialog || document.body)
.append("div")
.attr("class", "bg-gray-700 text-white text-sm p-2 rounded pointer-events-none absolute z-50 top-0")
.attr(
"class",
"bg-gray-700 text-white text-sm p-2 rounded pointer-events-none absolute z-50 top-0",
)
.style("opacity", 0)
.style("pointer-events", "none");
}
@@ -381,9 +544,7 @@ export default class extends Controller {
const x = isInDialog ? event.clientX : event.pageX;
const y = isInDialog ? event.clientY : event.pageY;
this.tooltip
?.style("left", `${x + 10}px`)
.style("top", `${y - 10}px`);
this.tooltip?.style("left", `${x + 10}px`).style("top", `${y - 10}px`);
}
}
@@ -400,7 +561,7 @@ export default class extends Controller {
#formatCurrency(value) {
const formatted = Number.parseFloat(value).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
maximumFractionDigits: 2,
});
return this.currencySymbolValue + formatted;
}