diff --git a/app/javascript/controllers/sankey_chart_controller.js b/app/javascript/controllers/sankey_chart_controller.js index 9601b088e..952941397 100644 --- a/app/javascript/controllers/sankey_chart_controller.js +++ b/app/javascript/controllers/sankey_chart_controller.js @@ -14,11 +14,17 @@ export default class extends Controller { connect() { this.resizeObserver = new ResizeObserver(() => this.#draw()); this.resizeObserver.observe(this.element); + this.tooltip = null; + this.#createTooltip(); this.#draw(); } disconnect() { this.resizeObserver?.disconnect(); + if (this.tooltip) { + this.tooltip.remove(); + this.tooltip = null; + } } #draw() { @@ -26,6 +32,31 @@ export default class extends Controller { if (!nodes.length || !links.length) return; + // Constants + const HOVER_OPACITY = 0.4; + const HOVER_FILTER = "saturate(1.3) brightness(1.1)"; + + // Hover utility functions + const applyHoverEffect = (targetLinks, allLinks, allNodes) => { + const targetLinksSet = new Set(targetLinks); + allLinks + .style("opacity", (linkData) => targetLinksSet.has(linkData) ? 1 : HOVER_OPACITY) + .style("filter", (linkData) => targetLinksSet.has(linkData) ? HOVER_FILTER : "none"); + + const connectedNodes = new Set(); + targetLinks.forEach(link => { + connectedNodes.add(link.source); + connectedNodes.add(link.target); + }); + + allNodes.style("opacity", (nodeData) => connectedNodes.has(nodeData) ? 1 : HOVER_OPACITY); + }; + + const resetHoverEffect = (allLinks, allNodes) => { + allLinks.style("opacity", 1).style("filter", "none"); + allNodes.style("opacity", 1); + }; + // Clear previous SVG d3.select(this.element).selectAll("svg").remove(); @@ -91,12 +122,13 @@ export default class extends Controller { }); // Draw links - svg - .append("g") - .attr("fill", "none") + const linksContainer = svg.append("g").attr("fill", "none"); + + const linkPaths = linksContainer .selectAll("path") .data(sankeyData.links) .join("path") + .attr("class", "sankey-link") .attr("d", (d) => { const sourceX = d.source.x1; const targetX = d.target.x0; @@ -108,19 +140,19 @@ export default class extends Controller { }) .attr("stroke", (d, i) => `url(#link-gradient-${d.source.index}-${d.target.index}-${i})`) .attr("stroke-width", (d) => Math.max(1, d.width)) - .append("title") - .text((d) => `${nodes[d.source.index].name} → ${nodes[d.target.index].name}: ${this.currencySymbolValue}${Number.parseFloat(d.value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} (${d.percentage}%)`); + .style("transition", "opacity 0.3s ease"); // Draw nodes - const node = svg + const nodeGroups = svg .append("g") .selectAll("g") .data(sankeyData.nodes) - .join("g"); + .join("g") + .style("transition", "opacity 0.3s ease"); const cornerRadius = 8; - node.append("path") + nodeGroups.append("path") .attr("d", (d) => { const x0 = d.x0; const y0 = d.y0; @@ -174,14 +206,41 @@ export default class extends Controller { return "var(--color-gray-500)"; // Fallback, likely unused with current data }); + // Add hover events to links after creating nodes + linkPaths + .on("mouseenter", (event, d) => { + applyHoverEffect([d], linkPaths, nodeGroups); + this.#showTooltip(event, d); + }) + .on("mousemove", (event) => this.#updateTooltipPosition(event)) + .on("mouseleave", () => { + resetHoverEffect(linkPaths, nodeGroups); + this.#hideTooltip(); + }); + const stimulusControllerInstance = this; - node + 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") + .attr("class", "text-xs font-medium text-primary fill-current select-none") + .style("cursor", "default") + .on("mouseenter", (event, d) => { + // Find all links connected to this node + const connectedLinks = sankeyData.links.filter(link => + link.source === d || link.target === d + ); + + applyHoverEffect(connectedLinks, linkPaths, nodeGroups); + this.#showNodeTooltip(event, d); + }) + .on("mousemove", (event) => this.#updateTooltipPosition(event)) + .on("mouseleave", () => { + resetHoverEffect(linkPaths, nodeGroups); + this.#hideTooltip(); + }) .each(function (d) { const textElement = d3.select(this); textElement.selectAll("tspan").remove(); @@ -201,4 +260,63 @@ export default class extends Controller { .text(stimulusControllerInstance.currencySymbolValue + Number.parseFloat(d.value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })); }); } -} \ No newline at end of file + + #createTooltip() { + // Create tooltip element once and reuse it + this.tooltip = d3.select("body") + .append("div") + .attr("class", "bg-gray-700 text-white text-sm p-2 rounded pointer-events-none absolute z-50") + .style("opacity", 0) + .style("pointer-events", "none"); + } + + #showTooltip(event, linkData) { + this.#displayTooltip(event, linkData.value, linkData.percentage); + } + + #showNodeTooltip(event, nodeData) { + this.#displayTooltip(event, nodeData.value, nodeData.percentage, nodeData.name); + } + + #displayTooltip(event, value, percentage, title = null) { + if (!this.tooltip) { + this.#createTooltip(); + } + + // Format the tooltip content + const formattedValue = this.currencySymbolValue + Number.parseFloat(value).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + const percentageText = percentage ? `${percentage}%` : "0%"; + + const content = title + ? `${title}
${formattedValue} (${percentageText})` + : `${formattedValue} (${percentageText})`; + + this.tooltip + .html(content) + .style("left", `${event.pageX + 10}px`) + .style("top", `${event.pageY - 10}px`) + .transition() + .duration(100) + .style("opacity", 1); + } + + #updateTooltipPosition(event) { + if (this.tooltip) { + this.tooltip + .style("left", `${event.pageX + 10}px`) + .style("top", `${event.pageY - 10}px`); + } + } + + #hideTooltip() { + if (this.tooltip) { + this.tooltip + .transition() + .duration(100) + .style("opacity", 0); + } + } +} diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 2444f6cf2..4e06b2705 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -25,24 +25,25 @@
<% if Current.family.accounts.any? %> -
- <%= render partial: "pages/dashboard/net_worth_chart", locals: { - balance_sheet: @balance_sheet, - period: @period - } %> -
-
- <%= render "pages/dashboard/balance_sheet", balance_sheet: @balance_sheet %> -
- <%= turbo_frame_tag "cashflow_sankey_section" do %> -
+
<%= render partial: "pages/dashboard/cashflow_sankey", locals: { sankey_data: @cashflow_sankey_data, period: @cashflow_period } %>
<% end %> + +
+ <%= render partial: "pages/dashboard/net_worth_chart", locals: { + balance_sheet: @balance_sheet, + period: @period + } %> +
+ +
+ <%= render "pages/dashboard/balance_sheet", balance_sheet: @balance_sheet %> +
<% else %>
<%= render "pages/dashboard/no_accounts_graph_placeholder" %>