From dcd0fdce8f0f97d4715705b4d2e6d207e496bf4b Mon Sep 17 00:00:00 2001
From: Matthieu Ev <95125079+matthieuEv@users.noreply.github.com>
Date: Tue, 5 Aug 2025 22:25:55 +0200
Subject: [PATCH] Better Sankey chart (#67)
* feat: added hover effect on the sankey chart
* small tweek for the opacity
* Update app/javascript/controllers/sankey_chart_controller.js
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Signed-off-by: Matthieu Ev <95125079+matthieuEv@users.noreply.github.com>
* Update app/javascript/controllers/sankey_chart_controller.js
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Signed-off-by: Matthieu Ev <95125079+matthieuEv@users.noreply.github.com>
* feat: add tooltip to sankey chart
* feat: switch sankey-graph and net-worth-graph
---------
Signed-off-by: Matthieu Ev <95125079+matthieuEv@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
---
.../controllers/sankey_chart_controller.js | 140 ++++++++++++++++--
app/views/pages/dashboard.html.erb | 23 +--
2 files changed, 141 insertions(+), 22 deletions(-)
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 @@