From e21ab9819f624b4e9fcaa41beaa47b0f086f2685 Mon Sep 17 00:00:00 2001 From: Michal Tajchert Date: Wed, 20 May 2026 21:17:35 +0200 Subject: [PATCH] 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- app/controllers/pages_controller.rb | 2 +- .../controllers/sankey_chart_controller.js | 301 ++++++++++++++---- app/javascript/utils/sankey_zoom.mjs | 149 +++++++++ .../pages/dashboard/_cashflow_sankey.html.erb | 12 +- .../dashboard/_cashflow_sankey_chart.html.erb | 22 ++ config/importmap.rb | 1 + config/initializers/mime_types.rb | 3 + config/locales/views/pages/en.yml | 1 + test/controllers/pages_controller_test.rb | 19 ++ test/javascript/utils/sankey_zoom_test.mjs | 128 ++++++++ 10 files changed, 557 insertions(+), 81 deletions(-) create mode 100644 app/javascript/utils/sankey_zoom.mjs create mode 100644 app/views/pages/dashboard/_cashflow_sankey_chart.html.erb create mode 100644 test/javascript/utils/sankey_zoom_test.mjs diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index e48335595..c52b7a883 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -152,7 +152,7 @@ class PagesController < ApplicationController add_node = ->(unique_key, display_name, value, percentage, color) { node_indices[unique_key] ||= begin - nodes << { name: display_name, value: value.to_f.round(2), percentage: percentage.to_f.round(1), color: color } + nodes << { id: unique_key, name: display_name, value: value.to_f.round(2), percentage: percentage.to_f.round(1), color: color } nodes.size - 1 end } diff --git a/app/javascript/controllers/sankey_chart_controller.js b/app/javascript/controllers/sankey_chart_controller.js index 15cbf374b..1af8a25a8 100644 --- a/app/javascript/controllers/sankey_chart_controller.js +++ b/app/javascript/controllers/sankey_chart_controller.js @@ -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; } diff --git a/app/javascript/utils/sankey_zoom.mjs b/app/javascript/utils/sankey_zoom.mjs new file mode 100644 index 000000000..15e27b9ae --- /dev/null +++ b/app/javascript/utils/sankey_zoom.mjs @@ -0,0 +1,149 @@ +const CASH_FLOW_NODE_ID = "cash_flow_node"; +const CASH_FLOW_NODE_NAME = "Cash Flow"; + +export function sankeyNodeHasChildren(data, nodeId) { + const graph = buildGraph(data); + const nodeIndex = graph.indexById.get(nodeId); + if (nodeIndex === undefined || graph.cashFlowIndex < 0) return false; + + return childIndexesFor(graph, nodeIndex).length > 0; +} + +export function zoomSankeyData(data, rootNodeId) { + const graph = buildGraph(data); + const rootIndex = graph.indexById.get(rootNodeId); + if (rootIndex === undefined || graph.cashFlowIndex < 0) return data; + + const includedIndexes = descendantIndexesFor(graph, rootIndex); + if (includedIndexes.size <= 1) return data; + + const orderedIndexes = graph.nodes + .map((_, index) => index) + .filter((index) => includedIndexes.has(index)); + const reindexed = new Map( + orderedIndexes.map((index, newIndex) => [index, newIndex]), + ); + + return { + ...data, + nodes: orderedIndexes.map((index) => ({ ...graph.nodes[index] })), + links: graph.links + .filter( + (link) => + includedIndexes.has(link.sourceIndex) && + includedIndexes.has(link.targetIndex), + ) + .map((link) => ({ + ...link.original, + source: reindexed.get(link.sourceIndex), + target: reindexed.get(link.targetIndex), + })), + }; +} + +function buildGraph(data) { + const nodes = data?.nodes || []; + const links = (data?.links || []).map((link) => { + const sourceIndex = linkIndex(link.source); + const targetIndex = linkIndex(link.target); + + return { + original: link, + sourceIndex, + targetIndex, + }; + }); + + const indexById = new Map( + nodes.map((node, index) => [nodeId(node, index), index]), + ); + const cashFlowIndex = nodes.findIndex( + (node) => + nodeId(node, -1) === CASH_FLOW_NODE_ID || + node.name === CASH_FLOW_NODE_NAME, + ); + + return { + nodes, + links, + indexById, + cashFlowIndex, + outbound: groupLinksBy(links, "sourceIndex"), + inbound: groupLinksBy(links, "targetIndex"), + }; +} + +function nodeId(node, index) { + return node?.id ?? index; +} + +function linkIndex(endpoint) { + return typeof endpoint === "object" ? endpoint.index : endpoint; +} + +function groupLinksBy(links, key) { + const groups = new Map(); + + links.forEach((link) => { + const index = link[key]; + if (!groups.has(index)) groups.set(index, []); + groups.get(index).push(link); + }); + + return groups; +} + +function descendantIndexesFor(graph, rootIndex) { + const included = new Set([rootIndex]); + const queue = [rootIndex]; + + while (queue.length) { + const currentIndex = queue.shift(); + + childIndexesFor(graph, currentIndex).forEach((childIndex) => { + if (included.has(childIndex)) return; + + included.add(childIndex); + queue.push(childIndex); + }); + } + + return included; +} + +function childIndexesFor(graph, nodeIndex) { + if (nodeIndex === graph.cashFlowIndex) { + return (graph.outbound.get(nodeIndex) || []).map((link) => link.targetIndex); + } + + if (canReach(graph, graph.cashFlowIndex, nodeIndex)) { + return (graph.outbound.get(nodeIndex) || []).map((link) => link.targetIndex); + } + + if (canReach(graph, nodeIndex, graph.cashFlowIndex)) { + return (graph.inbound.get(nodeIndex) || []).map((link) => link.sourceIndex); + } + + return []; +} + +function canReach(graph, startIndex, targetIndex) { + if (startIndex === targetIndex) return true; + + const visited = new Set([startIndex]); + const queue = [startIndex]; + + while (queue.length) { + const currentIndex = queue.shift(); + + for (const link of graph.outbound.get(currentIndex) || []) { + if (link.targetIndex === targetIndex) return true; + if (visited.has(link.targetIndex)) continue; + + visited.add(link.targetIndex); + queue.push(link.targetIndex); + } + } + + return false; +} diff --git a/app/views/pages/dashboard/_cashflow_sankey.html.erb b/app/views/pages/dashboard/_cashflow_sankey.html.erb index 5ec8de5ee..201e0c8b9 100644 --- a/app/views/pages/dashboard/_cashflow_sankey.html.erb +++ b/app/views/pages/dashboard/_cashflow_sankey.html.erb @@ -11,21 +11,13 @@ <% if sankey_data[:links].present? %>
-
+ <%= render "pages/dashboard/cashflow_sankey_chart", sankey_data: sankey_data %>
<%= render DS::Dialog.new(id: "cashflow-expanded-dialog", auto_open: false, width: "custom", disable_frame: true, content_class: "!w-[96vw] max-w-[1650px]", data: { action: "close->cashflow-expand#restore" }) do |dialog| %> <% dialog.with_header(title: t("pages.dashboard.cashflow_sankey.title")) %> <% dialog.with_body do %>
-
+ <%= render "pages/dashboard/cashflow_sankey_chart", sankey_data: sankey_data %>
<% end %> <% end %> diff --git a/app/views/pages/dashboard/_cashflow_sankey_chart.html.erb b/app/views/pages/dashboard/_cashflow_sankey_chart.html.erb new file mode 100644 index 000000000..d22b305ce --- /dev/null +++ b/app/views/pages/dashboard/_cashflow_sankey_chart.html.erb @@ -0,0 +1,22 @@ +<%# locals: (sankey_data:) %> +
+
+ <%= render DS::Button.new( + variant: :icon, + icon: "arrow-left", + type: "button", + title: t("pages.dashboard.cashflow_sankey.zoom_out"), + aria: { label: t("pages.dashboard.cashflow_sankey.zoom_out") }, + hidden: true, + data: { + sankey_chart_target: "zoomOutButton", + action: "sankey-chart#zoomOut" + } + ) %> +
+
+
diff --git a/config/importmap.rb b/config/importmap.rb index 12bf26d90..308591cbd 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -8,6 +8,7 @@ pin_all_from "app/javascript/controllers", under: "controllers" pin_all_from "app/components", under: "controllers", to: "" pin_all_from "app/javascript/services", under: "services", to: "services" pin_all_from "app/javascript/utils", under: "utils", to: "utils" +pin "utils/sankey_zoom", to: "utils/sankey_zoom.mjs" pin "@github/hotkey", to: "@github--hotkey.js" # @3.1.1 pin "@simonwep/pickr", to: "@simonwep--pickr.js" # @1.9.1 diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb index 549789cbf..e116f5753 100644 --- a/config/initializers/mime_types.rb +++ b/config/initializers/mime_types.rb @@ -5,3 +5,6 @@ Mime::Type.register "text/csv", :csv Mime::Type.register "application/pdf", :pdf Mime::Type.register "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", :xlsx + +# Register .mjs so Propshaft serves ES modules with the correct Content-Type. +Mime::Type.register "text/javascript", :mjs unless Mime::Type.lookup_by_extension(:mjs) diff --git a/config/locales/views/pages/en.yml b/config/locales/views/pages/en.yml index e5cd8e1d1..df0a8a70c 100644 --- a/config/locales/views/pages/en.yml +++ b/config/locales/views/pages/en.yml @@ -63,6 +63,7 @@ en: liability: "Liabilities" cashflow_sankey: title: "Cashflow" + zoom_out: "Back to full cashflow" no_data_title: "No cash flow data for this time period" no_data_description: "Add transactions to display cash flow data or expand the time period" add_transaction: "Add transaction" diff --git a/test/controllers/pages_controller_test.rb b/test/controllers/pages_controller_test.rb index 73c39f5a1..023a983f9 100644 --- a/test/controllers/pages_controller_test.rb +++ b/test/controllers/pages_controller_test.rb @@ -43,6 +43,25 @@ class PagesControllerTest < ActionDispatch::IntegrationTest assert_select "[data-controller='sankey-chart']" end + test "dashboard renders sankey chart zoom controls and stable node ids" do + parent_category = @family.categories.create!(name: "Shopping", color: "#FF5733") + subcategory = @family.categories.create!(name: "Groceries", parent: parent_category, color: "#33FF57") + + create_transaction(account: @family.accounts.first, name: "General shopping", amount: 100, category: parent_category) + create_transaction(account: @family.accounts.first, name: "Grocery store", amount: 50, category: subcategory) + + get root_path + + assert_response :ok + assert_select "[data-sankey-chart-target='zoomOutButton'][hidden]", count: 2 + + chart = css_select("[data-controller='sankey-chart']").first + sankey_data = JSON.parse(chart["data-sankey-chart-data-value"]) + + assert_includes sankey_data.fetch("nodes").map { |node| node.fetch("id") }, "cash_flow_node" + assert sankey_data.fetch("nodes").any? { |node| node.fetch("id").start_with?("expense_") } + end + test "changelog" do VCR.use_cassette("git_repository_provider/fetch_latest_release_notes") do get changelog_path diff --git a/test/javascript/utils/sankey_zoom_test.mjs b/test/javascript/utils/sankey_zoom_test.mjs new file mode 100644 index 000000000..ab0425b71 --- /dev/null +++ b/test/javascript/utils/sankey_zoom_test.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + sankeyNodeHasChildren, + zoomSankeyData, +} from "../../../app/javascript/utils/sankey_zoom.mjs"; + +test("zooms an expense category to the clicked root and descendants", () => { + const data = { + nodes: [ + { id: "cash_flow_node", name: "Cash Flow" }, + { id: "expense_shopping", name: "Shopping" }, + { id: "expense_sub_groceries", name: "Groceries" }, + { id: "expense_sub_clothes", name: "Clothes" }, + { id: "expense_dining", name: "Dining" }, + ], + links: [ + { source: 0, target: 1, value: 150 }, + { source: 1, target: 2, value: 100 }, + { source: 1, target: 3, value: 50 }, + { source: 0, target: 4, value: 75 }, + ], + currency_symbol: "$", + }; + + assert.equal(sankeyNodeHasChildren(data, "expense_shopping"), true); + assert.equal(sankeyNodeHasChildren(data, "expense_sub_groceries"), false); + + const zoomed = zoomSankeyData(data, "expense_shopping"); + + assert.deepEqual(zoomed.nodes.map((node) => node.id), [ + "expense_shopping", + "expense_sub_groceries", + "expense_sub_clothes", + ]); + assert.deepEqual( + zoomed.links.map((link) => [link.source, link.target, link.value]), + [ + [0, 1, 100], + [0, 2, 50], + ], + ); + assert.equal(zoomed.currency_symbol, "$"); +}); + +test("zooms an income category by following incoming child links", () => { + const data = { + nodes: [ + { id: "income_salary", name: "Salary" }, + { id: "cash_flow_node", name: "Cash Flow" }, + { id: "income_sub_bonus", name: "Bonus" }, + { id: "income_sub_equity", name: "Equity" }, + { id: "income_interest", name: "Interest" }, + ], + links: [ + { source: 0, target: 1, value: 250 }, + { source: 2, target: 0, value: 100 }, + { source: 3, target: 0, value: 150 }, + { source: 4, target: 1, value: 25 }, + ], + currency_symbol: "$", + }; + + assert.equal(sankeyNodeHasChildren(data, "income_salary"), true); + assert.equal(sankeyNodeHasChildren(data, "income_sub_bonus"), false); + + const zoomed = zoomSankeyData(data, "income_salary"); + + assert.deepEqual(zoomed.nodes.map((node) => node.id), [ + "income_salary", + "income_sub_bonus", + "income_sub_equity", + ]); + assert.deepEqual( + zoomed.links.map((link) => [link.source, link.target, link.value]), + [ + [1, 0, 100], + [2, 0, 150], + ], + ); +}); + +test("zooms the cashflow node to its expense (outbound) descendants", () => { + const data = { + nodes: [ + { id: "income_salary", name: "Salary" }, + { id: "cash_flow_node", name: "Cash Flow" }, + { id: "expense_shopping", name: "Shopping" }, + { id: "expense_sub_groceries", name: "Groceries" }, + ], + links: [ + { source: 0, target: 1, value: 200 }, + { source: 1, target: 2, value: 150 }, + { source: 2, target: 3, value: 100 }, + ], + }; + + assert.equal(sankeyNodeHasChildren(data, "cash_flow_node"), true); + + const zoomed = zoomSankeyData(data, "cash_flow_node"); + + assert.deepEqual(zoomed.nodes.map((node) => node.id), [ + "cash_flow_node", + "expense_shopping", + "expense_sub_groceries", + ]); + assert.deepEqual( + zoomed.links.map((link) => [link.source, link.target, link.value]), + [ + [0, 1, 150], + [1, 2, 100], + ], + ); +}); + +test("does not zoom malformed data without a cashflow node", () => { + const data = { + nodes: [ + { id: "expense_shopping", name: "Shopping" }, + { id: "expense_sub_groceries", name: "Groceries" }, + ], + links: [{ source: 0, target: 1, value: 100 }], + }; + + assert.equal(sankeyNodeHasChildren(data, "expense_shopping"), false); + assert.equal(zoomSankeyData(data, "expense_shopping"), data); +});