Files
sure/app/javascript/utils/sankey_zoom.mjs
Michal Tajchert e21ab9819f 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>
2026-05-20 21:17:35 +02:00

150 lines
3.7 KiB
JavaScript

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