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); +});