mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 14:31:25 +00:00
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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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" %>
|
||||||
|
|||||||
Reference in New Issue
Block a user