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>
This commit is contained in:
Matthieu Ev
2025-08-05 22:25:55 +02:00
committed by GitHub
parent 47da993a16
commit dcd0fdce8f
2 changed files with 141 additions and 22 deletions

View File

@@ -14,11 +14,17 @@ export default class extends Controller {
connect() { connect() {
this.resizeObserver = new ResizeObserver(() => this.#draw()); this.resizeObserver = new ResizeObserver(() => this.#draw());
this.resizeObserver.observe(this.element); this.resizeObserver.observe(this.element);
this.tooltip = null;
this.#createTooltip();
this.#draw(); this.#draw();
} }
disconnect() { disconnect() {
this.resizeObserver?.disconnect(); this.resizeObserver?.disconnect();
if (this.tooltip) {
this.tooltip.remove();
this.tooltip = null;
}
} }
#draw() { #draw() {
@@ -26,6 +32,31 @@ export default class extends Controller {
if (!nodes.length || !links.length) return; 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 // Clear previous SVG
d3.select(this.element).selectAll("svg").remove(); d3.select(this.element).selectAll("svg").remove();
@@ -91,12 +122,13 @@ export default class extends Controller {
}); });
// Draw links // Draw links
svg const linksContainer = svg.append("g").attr("fill", "none");
.append("g")
.attr("fill", "none") const linkPaths = linksContainer
.selectAll("path") .selectAll("path")
.data(sankeyData.links) .data(sankeyData.links)
.join("path") .join("path")
.attr("class", "sankey-link")
.attr("d", (d) => { .attr("d", (d) => {
const sourceX = d.source.x1; const sourceX = d.source.x1;
const targetX = d.target.x0; 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", (d, i) => `url(#link-gradient-${d.source.index}-${d.target.index}-${i})`)
.attr("stroke-width", (d) => Math.max(1, d.width)) .attr("stroke-width", (d) => Math.max(1, d.width))
.append("title") .style("transition", "opacity 0.3s ease");
.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}%)`);
// Draw nodes // Draw nodes
const node = svg const nodeGroups = svg
.append("g") .append("g")
.selectAll("g") .selectAll("g")
.data(sankeyData.nodes) .data(sankeyData.nodes)
.join("g"); .join("g")
.style("transition", "opacity 0.3s ease");
const cornerRadius = 8; const cornerRadius = 8;
node.append("path") nodeGroups.append("path")
.attr("d", (d) => { .attr("d", (d) => {
const x0 = d.x0; const x0 = d.x0;
const y0 = d.y0; const y0 = d.y0;
@@ -174,14 +206,41 @@ export default class extends Controller {
return "var(--color-gray-500)"; // Fallback, likely unused with current data 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; const stimulusControllerInstance = this;
node nodeGroups
.append("text") .append("text")
.attr("x", (d) => (d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6)) .attr("x", (d) => (d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6))
.attr("y", (d) => (d.y1 + d.y0) / 2) .attr("y", (d) => (d.y1 + d.y0) / 2)
.attr("dy", "-0.2em") .attr("dy", "-0.2em")
.attr("text-anchor", (d) => (d.x0 < width / 2 ? "start" : "end")) .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) { .each(function (d) {
const textElement = d3.select(this); const textElement = d3.select(this);
textElement.selectAll("tspan").remove(); 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 })); .text(stimulusControllerInstance.currencySymbolValue + Number.parseFloat(d.value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }));
}); });
} }
}
#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}<br/>${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);
}
}
}

View File

@@ -25,24 +25,25 @@
<div class="w-full space-y-6 pb-24"> <div class="w-full space-y-6 pb-24">
<% if Current.family.accounts.any? %> <% if Current.family.accounts.any? %>
<section class="bg-container py-4 rounded-xl shadow-border-xs">
<%= render partial: "pages/dashboard/net_worth_chart", locals: {
balance_sheet: @balance_sheet,
period: @period
} %>
</section>
<section>
<%= render "pages/dashboard/balance_sheet", balance_sheet: @balance_sheet %>
</section>
<%= turbo_frame_tag "cashflow_sankey_section" do %> <%= turbo_frame_tag "cashflow_sankey_section" do %>
<section class="bg-container py-4 rounded-xl shadow-border-xs"> <section class="bg-container py-4 rounded-xl shadow-border-xs mb-6">
<%= render partial: "pages/dashboard/cashflow_sankey", locals: { <%= render partial: "pages/dashboard/cashflow_sankey", locals: {
sankey_data: @cashflow_sankey_data, sankey_data: @cashflow_sankey_data,
period: @cashflow_period period: @cashflow_period
} %> } %>
</section> </section>
<% end %> <% end %>
<section class="bg-container py-4 rounded-xl shadow-border-xs">
<%= render partial: "pages/dashboard/net_worth_chart", locals: {
balance_sheet: @balance_sheet,
period: @period
} %>
</section>
<section>
<%= render "pages/dashboard/balance_sheet", balance_sheet: @balance_sheet %>
</section>
<% else %> <% else %>
<section> <section>
<%= render "pages/dashboard/no_accounts_graph_placeholder" %> <%= render "pages/dashboard/no_accounts_graph_placeholder" %>