mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 14:31:25 +00:00
feat: Add subcategory breakdown to Cash Flow Sankey and Reports (#639)
* feat: Add subcategory breakdown to Cash Flow and Reports Implements Discussion #546 - adds hierarchical category/subcategory visualization to both the Sankey chart and Reports breakdown tables. Sankey chart changes: - Income: subcategory → parent category → Cash Flow - Expense: Cash Flow → parent category → subcategory - Extracted process_category_totals helper to DRY up income/expense logic Reports breakdown changes: - Subcategories display nested under parent categories - Smaller dots and indented rows for visual hierarchy - Extracted _breakdown_table partial to eliminate duplication * fix: Dynamic node padding for Sankey chart with many nodes - Add dynamic nodePadding calculation to prevent padding from dominating chart height when there are many subcategory nodes - Extract magic numbers to static constants for configuration - Decompose monolithic #draw() into focused methods - Consolidate duplicate tooltip/currency formatting code - Modernize syntax with spread operators and optional chaining * fix: Hide overlapping Sankey labels, show on hover - Add label overlap detection by grouping nodes by column depth - Hide labels that would overlap with adjacent nodes - Show hidden labels on hover (node rectangle or connected links) - Add hover events to node rectangles (not just text) * fix: Use deterministic fallback colors for categories - Replace Category::COLORS.sample with Category::UNCATEGORIZED_COLOR for income categories in Sankey chart (was producing different colors on each page load) - Add nil color fallback in reports_controller for parent and root categories Addresses CodeRabbit review feedback. * fix: Expand CSS variable map for d3 color manipulation Add hex mappings for commonly used CSS variables so d3 can manipulate opacity for gradients and hover effects: - var(--color-destructive) -> #EC2222 - var(--color-gray-400) -> #9E9E9E - var(--color-gray-500) -> #737373 * test: Add tests for subcategory breakdown in dashboard and reports - Test dashboard renders Sankey chart with parent/subcategory transactions - Test reports groups transactions by parent and subcategories - Test reports handles categories with nil colors - Use EntriesTestHelper#create_transaction for cleaner test setup * Fix lint: use Number.NEGATIVE_INFINITY * Remove obsolete nil color test Category model now validates color presence, so nil color categories cannot exist. The fallback handling in reports_controller is still in place but the scenario is unreachable. * Update reports_controller.rb * FIX trade category --------- Co-authored-by: sokie <sokysrm@gmail.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
import * as d3 from "d3";
|
||||
import { sankey, sankeyLinkHorizontal } from "d3-sankey";
|
||||
import { sankey } from "d3-sankey";
|
||||
|
||||
// Connects to data-controller="sankey-chart"
|
||||
export default class extends Controller {
|
||||
@@ -11,6 +11,22 @@ export default class extends Controller {
|
||||
currencySymbol: { type: String, default: "$" }
|
||||
};
|
||||
|
||||
// Visual constants
|
||||
static HOVER_OPACITY = 0.4;
|
||||
static HOVER_FILTER = "saturate(1.3) brightness(1.1)";
|
||||
static EXTENT_MARGIN = 16;
|
||||
static MIN_NODE_PADDING = 4;
|
||||
static MAX_PADDING_RATIO = 0.4;
|
||||
static CORNER_RADIUS = 8;
|
||||
static DEFAULT_COLOR = "var(--color-gray-400)";
|
||||
static CSS_VAR_MAP = {
|
||||
"var(--color-success)": "#10A861",
|
||||
"var(--color-destructive)": "#EC2222",
|
||||
"var(--color-gray-400)": "#9E9E9E",
|
||||
"var(--color-gray-500)": "#737373"
|
||||
};
|
||||
static MIN_LABEL_SPACING = 28; // Minimum vertical space needed for labels (2 lines)
|
||||
|
||||
connect() {
|
||||
this.resizeObserver = new ResizeObserver(() => this.#draw());
|
||||
this.resizeObserver.observe(this.element);
|
||||
@@ -21,91 +37,63 @@ export default class extends Controller {
|
||||
|
||||
disconnect() {
|
||||
this.resizeObserver?.disconnect();
|
||||
if (this.tooltip) {
|
||||
this.tooltip.remove();
|
||||
this.tooltip = null;
|
||||
}
|
||||
this.tooltip?.remove();
|
||||
this.tooltip = null;
|
||||
}
|
||||
|
||||
#draw() {
|
||||
const { nodes = [], links = [] } = this.dataValue || {};
|
||||
|
||||
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();
|
||||
|
||||
const width = this.element.clientWidth || 600;
|
||||
const height = this.element.clientHeight || 400;
|
||||
|
||||
const svg = d3
|
||||
.select(this.element)
|
||||
const svg = d3.select(this.element)
|
||||
.append("svg")
|
||||
.attr("width", width)
|
||||
.attr("height", height);
|
||||
|
||||
const effectivePadding = this.#calculateNodePadding(nodes.length, height);
|
||||
const sankeyData = this.#generateSankeyData(nodes, links, width, height, effectivePadding);
|
||||
|
||||
this.#createGradients(svg, sankeyData.links);
|
||||
|
||||
const linkPaths = this.#drawLinks(svg, sankeyData.links);
|
||||
const { nodeGroups, hiddenLabels } = this.#drawNodes(svg, sankeyData.nodes, width);
|
||||
|
||||
this.#attachHoverEvents(linkPaths, nodeGroups, sankeyData, hiddenLabels);
|
||||
}
|
||||
|
||||
// Dynamic padding prevents padding from dominating when there are many nodes
|
||||
#calculateNodePadding(nodeCount, height) {
|
||||
const margin = this.constructor.EXTENT_MARGIN;
|
||||
const availableHeight = height - (margin * 2);
|
||||
const maxPaddingTotal = availableHeight * this.constructor.MAX_PADDING_RATIO;
|
||||
const gaps = Math.max(nodeCount - 1, 1);
|
||||
const dynamicPadding = Math.min(this.nodePaddingValue, Math.floor(maxPaddingTotal / gaps));
|
||||
return Math.max(this.constructor.MIN_NODE_PADDING, dynamicPadding);
|
||||
}
|
||||
|
||||
#generateSankeyData(nodes, links, width, height, nodePadding) {
|
||||
const margin = this.constructor.EXTENT_MARGIN;
|
||||
const sankeyGenerator = sankey()
|
||||
.nodeWidth(this.nodeWidthValue)
|
||||
.nodePadding(this.nodePaddingValue)
|
||||
.extent([
|
||||
[16, 16],
|
||||
[width - 16, height - 16],
|
||||
]);
|
||||
.nodePadding(nodePadding)
|
||||
.extent([[margin, margin], [width - margin, height - margin]]);
|
||||
|
||||
const sankeyData = sankeyGenerator({
|
||||
nodes: nodes.map((d) => Object.assign({}, d)),
|
||||
links: links.map((d) => Object.assign({}, d)),
|
||||
return sankeyGenerator({
|
||||
nodes: nodes.map(d => ({ ...d })),
|
||||
links: links.map(d => ({ ...d })),
|
||||
});
|
||||
}
|
||||
|
||||
// Define gradients for links
|
||||
#createGradients(svg, links) {
|
||||
const defs = svg.append("defs");
|
||||
|
||||
sankeyData.links.forEach((link, i) => {
|
||||
const gradientId = `link-gradient-${link.source.index}-${link.target.index}-${i}`;
|
||||
|
||||
const getStopColorWithOpacity = (nodeColorInput, opacity = 0.1) => {
|
||||
let colorStr = nodeColorInput || "var(--color-gray-400)";
|
||||
if (colorStr === "var(--color-success)") {
|
||||
colorStr = "#10A861"; // Hex for --color-green-600
|
||||
}
|
||||
// Add other CSS var to hex mappings here if needed
|
||||
|
||||
if (colorStr.startsWith("var(--")) { // Unmapped CSS var, use as is (likely solid)
|
||||
return colorStr;
|
||||
}
|
||||
|
||||
const d3Color = d3.color(colorStr);
|
||||
return d3Color ? d3Color.copy({ opacity: opacity }) : "var(--color-gray-400)";
|
||||
};
|
||||
|
||||
const sourceStopColor = getStopColorWithOpacity(link.source.color);
|
||||
const targetStopColor = getStopColorWithOpacity(link.target.color);
|
||||
|
||||
links.forEach((link, i) => {
|
||||
const gradientId = this.#gradientId(link, i);
|
||||
const gradient = defs.append("linearGradient")
|
||||
.attr("id", gradientId)
|
||||
.attr("gradientUnits", "userSpaceOnUse")
|
||||
@@ -114,155 +102,242 @@ export default class extends Controller {
|
||||
|
||||
gradient.append("stop")
|
||||
.attr("offset", "0%")
|
||||
.attr("stop-color", sourceStopColor);
|
||||
.attr("stop-color", this.#colorWithOpacity(link.source.color));
|
||||
|
||||
gradient.append("stop")
|
||||
.attr("offset", "100%")
|
||||
.attr("stop-color", targetStopColor);
|
||||
.attr("stop-color", this.#colorWithOpacity(link.target.color));
|
||||
});
|
||||
}
|
||||
|
||||
// Draw links
|
||||
const linksContainer = svg.append("g").attr("fill", "none");
|
||||
|
||||
const linkPaths = linksContainer
|
||||
#gradientId(link, index) {
|
||||
return `link-gradient-${link.source.index}-${link.target.index}-${index}`;
|
||||
}
|
||||
|
||||
#colorWithOpacity(nodeColor, opacity = 0.1) {
|
||||
const defaultColor = this.constructor.DEFAULT_COLOR;
|
||||
let colorStr = nodeColor || defaultColor;
|
||||
|
||||
// Map CSS variables to hex values for d3 color manipulation
|
||||
colorStr = this.constructor.CSS_VAR_MAP[colorStr] || colorStr;
|
||||
|
||||
// Unmapped CSS vars cannot be manipulated, return as-is
|
||||
if (colorStr.startsWith("var(--")) return colorStr;
|
||||
|
||||
const d3Color = d3.color(colorStr);
|
||||
return d3Color ? d3Color.copy({ opacity }) : defaultColor;
|
||||
}
|
||||
|
||||
#drawLinks(svg, links) {
|
||||
return svg.append("g")
|
||||
.attr("fill", "none")
|
||||
.selectAll("path")
|
||||
.data(sankeyData.links)
|
||||
.data(links)
|
||||
.join("path")
|
||||
.attr("class", "sankey-link")
|
||||
.attr("d", (d) => {
|
||||
const sourceX = d.source.x1;
|
||||
const targetX = d.target.x0;
|
||||
const path = d3.linkHorizontal()({
|
||||
source: [sourceX, d.y0],
|
||||
target: [targetX, d.y1]
|
||||
});
|
||||
return path;
|
||||
})
|
||||
.attr("stroke", (d, i) => `url(#link-gradient-${d.source.index}-${d.target.index}-${i})`)
|
||||
.attr("stroke-width", (d) => Math.max(1, d.width))
|
||||
.attr("d", d => d3.linkHorizontal()({
|
||||
source: [d.source.x1, d.y0],
|
||||
target: [d.target.x0, d.y1]
|
||||
}))
|
||||
.attr("stroke", (d, i) => `url(#${this.#gradientId(d, i)})`)
|
||||
.attr("stroke-width", d => Math.max(1, d.width))
|
||||
.style("transition", "opacity 0.3s ease");
|
||||
}
|
||||
|
||||
// Draw nodes
|
||||
const nodeGroups = svg
|
||||
.append("g")
|
||||
#drawNodes(svg, nodes, width) {
|
||||
const nodeGroups = svg.append("g")
|
||||
.selectAll("g")
|
||||
.data(sankeyData.nodes)
|
||||
.data(nodes)
|
||||
.join("g")
|
||||
.style("transition", "opacity 0.3s ease");
|
||||
|
||||
const cornerRadius = 8;
|
||||
|
||||
nodeGroups.append("path")
|
||||
.attr("d", (d) => {
|
||||
const x0 = d.x0;
|
||||
const y0 = d.y0;
|
||||
const x1 = d.x1;
|
||||
const y1 = d.y1;
|
||||
const h = y1 - y0;
|
||||
// const w = x1 - x0; // Not directly used in path string, but good for context
|
||||
.attr("d", d => this.#nodePath(d))
|
||||
.attr("fill", d => d.color || this.constructor.DEFAULT_COLOR)
|
||||
.attr("stroke", d => d.color ? "none" : "var(--color-gray-500)");
|
||||
|
||||
// Dynamic corner radius based on node height, maxed at 8
|
||||
const effectiveCornerRadius = Math.max(0, Math.min(cornerRadius, h / 2));
|
||||
const hiddenLabels = this.#addNodeLabels(nodeGroups, width, nodes);
|
||||
|
||||
const isSourceNode = d.sourceLinks && d.sourceLinks.length > 0 && (!d.targetLinks || d.targetLinks.length === 0);
|
||||
const isTargetNode = d.targetLinks && d.targetLinks.length > 0 && (!d.sourceLinks || d.sourceLinks.length === 0);
|
||||
return { nodeGroups, hiddenLabels };
|
||||
}
|
||||
|
||||
if (isSourceNode) { // Round left corners, flat right for "Total Income"
|
||||
if (h < effectiveCornerRadius * 2) {
|
||||
return `M ${x0},${y0} L ${x1},${y0} L ${x1},${y1} L ${x0},${y1} Z`;
|
||||
}
|
||||
return `M ${x0 + effectiveCornerRadius},${y0}
|
||||
L ${x1},${y0}
|
||||
L ${x1},${y1}
|
||||
L ${x0 + effectiveCornerRadius},${y1}
|
||||
Q ${x0},${y1} ${x0},${y1 - effectiveCornerRadius}
|
||||
L ${x0},${y0 + effectiveCornerRadius}
|
||||
Q ${x0},${y0} ${x0 + effectiveCornerRadius},${y0} Z`;
|
||||
}
|
||||
#nodePath(node) {
|
||||
const { x0, y0, x1, y1 } = node;
|
||||
const height = y1 - y0;
|
||||
const radius = Math.max(0, Math.min(this.constructor.CORNER_RADIUS, height / 2));
|
||||
|
||||
if (isTargetNode) { // Flat left corners, round right for Categories/Surplus
|
||||
if (h < effectiveCornerRadius * 2) {
|
||||
return `M ${x0},${y0} L ${x1},${y0} L ${x1},${y1} L ${x0},${y1} Z`;
|
||||
}
|
||||
return `M ${x0},${y0}
|
||||
L ${x1 - effectiveCornerRadius},${y0}
|
||||
Q ${x1},${y0} ${x1},${y0 + effectiveCornerRadius}
|
||||
L ${x1},${y1 - effectiveCornerRadius}
|
||||
Q ${x1},${y1} ${x1 - effectiveCornerRadius},${y1}
|
||||
L ${x0},${y1} Z`;
|
||||
}
|
||||
const isSourceNode = node.sourceLinks?.length > 0 && !node.targetLinks?.length;
|
||||
const isTargetNode = node.targetLinks?.length > 0 && !node.sourceLinks?.length;
|
||||
|
||||
// Fallback for intermediate nodes (e.g., "Cash Flow") - draw as a simple sharp-cornered rectangle
|
||||
return `M ${x0},${y0} L ${x1},${y0} L ${x1},${y1} L ${x0},${y1} Z`;
|
||||
})
|
||||
.attr("fill", (d) => d.color || "var(--color-gray-400)")
|
||||
.attr("stroke", (d) => {
|
||||
// If a node has an explicit color assigned (even if it's a gray variable),
|
||||
// it gets no stroke. Only truly un-colored nodes (falling back to default fill)
|
||||
// would get a stroke, but our current data structure assigns colors to all nodes.
|
||||
if (d.color) {
|
||||
return "none";
|
||||
}
|
||||
return "var(--color-gray-500)"; // Fallback, likely unused with current data
|
||||
});
|
||||
// Too small for rounded corners
|
||||
if (height < radius * 2) {
|
||||
return this.#rectPath(x0, y0, x1, y1);
|
||||
}
|
||||
|
||||
// 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();
|
||||
});
|
||||
if (isSourceNode) {
|
||||
return this.#roundedLeftPath(x0, y0, x1, y1, radius);
|
||||
}
|
||||
|
||||
const stimulusControllerInstance = this;
|
||||
nodeGroups
|
||||
.append("text")
|
||||
.attr("x", (d) => (d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6))
|
||||
.attr("y", (d) => (d.y1 + d.y0) / 2)
|
||||
if (isTargetNode) {
|
||||
return this.#roundedRightPath(x0, y0, x1, y1, radius);
|
||||
}
|
||||
|
||||
return this.#rectPath(x0, y0, x1, y1);
|
||||
}
|
||||
|
||||
#rectPath(x0, y0, x1, y1) {
|
||||
return `M ${x0},${y0} L ${x1},${y0} L ${x1},${y1} L ${x0},${y1} Z`;
|
||||
}
|
||||
|
||||
#roundedLeftPath(x0, y0, x1, y1, r) {
|
||||
return `M ${x0 + r},${y0}
|
||||
L ${x1},${y0}
|
||||
L ${x1},${y1}
|
||||
L ${x0 + r},${y1}
|
||||
Q ${x0},${y1} ${x0},${y1 - r}
|
||||
L ${x0},${y0 + r}
|
||||
Q ${x0},${y0} ${x0 + r},${y0} Z`;
|
||||
}
|
||||
|
||||
#roundedRightPath(x0, y0, x1, y1, r) {
|
||||
return `M ${x0},${y0}
|
||||
L ${x1 - r},${y0}
|
||||
Q ${x1},${y0} ${x1},${y0 + r}
|
||||
L ${x1},${y1 - r}
|
||||
Q ${x1},${y1} ${x1 - r},${y1}
|
||||
L ${x0},${y1} Z`;
|
||||
}
|
||||
|
||||
#addNodeLabels(nodeGroups, width, nodes) {
|
||||
const controller = this;
|
||||
const hiddenLabels = this.#calculateHiddenLabels(nodes);
|
||||
|
||||
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("text-anchor", d => d.x0 < width / 2 ? "start" : "end")
|
||||
.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();
|
||||
.style("opacity", d => hiddenLabels.has(d.index) ? 0 : 1)
|
||||
.style("transition", "opacity 0.2s ease")
|
||||
.each(function(d) {
|
||||
const textEl = d3.select(this);
|
||||
textEl.selectAll("tspan").remove();
|
||||
|
||||
// Node Name on the first line
|
||||
textElement.append("tspan")
|
||||
.text(d.name);
|
||||
textEl.append("tspan").text(d.name);
|
||||
|
||||
// Financial details on the second line
|
||||
const financialDetailsTspan = textElement.append("tspan")
|
||||
.attr("x", textElement.attr("x"))
|
||||
textEl.append("tspan")
|
||||
.attr("x", textEl.attr("x"))
|
||||
.attr("dy", "1.2em")
|
||||
.attr("class", "font-mono text-secondary")
|
||||
.style("font-size", "0.65rem"); // Explicitly set smaller font size
|
||||
.style("font-size", "0.65rem")
|
||||
.text(controller.#formatCurrency(d.value));
|
||||
});
|
||||
|
||||
financialDetailsTspan.append("tspan")
|
||||
.text(stimulusControllerInstance.currencySymbolValue + Number.parseFloat(d.value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }));
|
||||
return hiddenLabels;
|
||||
}
|
||||
|
||||
// Calculate which labels should be hidden to prevent overlap
|
||||
#calculateHiddenLabels(nodes) {
|
||||
const hiddenLabels = new Set();
|
||||
const minSpacing = this.constructor.MIN_LABEL_SPACING;
|
||||
|
||||
// Group nodes by column (using depth which d3-sankey assigns)
|
||||
const columns = new Map();
|
||||
nodes.forEach(node => {
|
||||
const depth = node.depth;
|
||||
if (!columns.has(depth)) columns.set(depth, []);
|
||||
columns.get(depth).push(node);
|
||||
});
|
||||
|
||||
// For each column, check for overlapping labels
|
||||
columns.forEach(columnNodes => {
|
||||
// Sort by vertical position
|
||||
columnNodes.sort((a, b) => ((a.y0 + a.y1) / 2) - ((b.y0 + b.y1) / 2));
|
||||
|
||||
let lastVisibleY = Number.NEGATIVE_INFINITY;
|
||||
|
||||
columnNodes.forEach(node => {
|
||||
const nodeY = (node.y0 + node.y1) / 2;
|
||||
|
||||
if (nodeY - lastVisibleY < minSpacing) {
|
||||
// Too close to previous visible label, hide this one
|
||||
hiddenLabels.add(node.index);
|
||||
} else {
|
||||
lastVisibleY = nodeY;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return hiddenLabels;
|
||||
}
|
||||
|
||||
#attachHoverEvents(linkPaths, nodeGroups, sankeyData, hiddenLabels) {
|
||||
const applyHover = (targetLinks) => {
|
||||
const targetSet = new Set(targetLinks);
|
||||
const connectedNodes = new Set(targetLinks.flatMap(l => [l.source, l.target]));
|
||||
|
||||
linkPaths
|
||||
.style("opacity", d => targetSet.has(d) ? 1 : this.constructor.HOVER_OPACITY)
|
||||
.style("filter", d => targetSet.has(d) ? this.constructor.HOVER_FILTER : "none");
|
||||
|
||||
nodeGroups.style("opacity", d => connectedNodes.has(d) ? 1 : this.constructor.HOVER_OPACITY);
|
||||
|
||||
// Show labels for connected nodes (even if normally hidden)
|
||||
nodeGroups.selectAll("text")
|
||||
.style("opacity", d => connectedNodes.has(d) ? 1 : (hiddenLabels.has(d.index) ? 0 : this.constructor.HOVER_OPACITY));
|
||||
};
|
||||
|
||||
const resetHover = () => {
|
||||
linkPaths.style("opacity", 1).style("filter", "none");
|
||||
nodeGroups.style("opacity", 1);
|
||||
// Restore hidden labels to hidden state
|
||||
nodeGroups.selectAll("text")
|
||||
.style("opacity", d => hiddenLabels.has(d.index) ? 0 : 1);
|
||||
};
|
||||
|
||||
linkPaths
|
||||
.on("mouseenter", (event, d) => {
|
||||
applyHover([d]);
|
||||
this.#showTooltip(event, d.value, d.percentage);
|
||||
})
|
||||
.on("mousemove", event => this.#updateTooltipPosition(event))
|
||||
.on("mouseleave", () => {
|
||||
resetHover();
|
||||
this.#hideTooltip();
|
||||
});
|
||||
|
||||
// Hover on node rectangles (not just text)
|
||||
nodeGroups.selectAll("path")
|
||||
.style("cursor", "default")
|
||||
.on("mouseenter", (event, d) => {
|
||||
const connectedLinks = sankeyData.links.filter(l => l.source === d || l.target === d);
|
||||
applyHover(connectedLinks);
|
||||
this.#showTooltip(event, d.value, d.percentage, d.name);
|
||||
})
|
||||
.on("mousemove", event => this.#updateTooltipPosition(event))
|
||||
.on("mouseleave", () => {
|
||||
resetHover();
|
||||
this.#hideTooltip();
|
||||
});
|
||||
|
||||
nodeGroups.selectAll("text")
|
||||
.on("mouseenter", (event, d) => {
|
||||
const connectedLinks = sankeyData.links.filter(l => l.source === d || l.target === d);
|
||||
applyHover(connectedLinks);
|
||||
this.#showTooltip(event, d.value, d.percentage, d.name);
|
||||
})
|
||||
.on("mousemove", event => this.#updateTooltipPosition(event))
|
||||
.on("mouseleave", () => {
|
||||
resetHover();
|
||||
this.#hideTooltip();
|
||||
});
|
||||
}
|
||||
|
||||
// Tooltip methods
|
||||
|
||||
#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")
|
||||
@@ -270,30 +345,15 @@ export default class extends Controller {
|
||||
.style("pointer-events", "none");
|
||||
}
|
||||
|
||||
#showTooltip(event, linkData) {
|
||||
this.#displayTooltip(event, linkData.value, linkData.percentage);
|
||||
}
|
||||
#showTooltip(event, value, percentage, title = null) {
|
||||
if (!this.tooltip) this.#createTooltip();
|
||||
|
||||
#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 formattedValue = this.#formatCurrency(value);
|
||||
const percentageText = percentage ? `${percentage}%` : "0%";
|
||||
|
||||
const content = title
|
||||
const content = title
|
||||
? `${title}<br/>${formattedValue} (${percentageText})`
|
||||
: `${formattedValue} (${percentageText})`;
|
||||
|
||||
|
||||
this.tooltip
|
||||
.html(content)
|
||||
.style("left", `${event.pageX + 10}px`)
|
||||
@@ -304,19 +364,23 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
#updateTooltipPosition(event) {
|
||||
if (this.tooltip) {
|
||||
this.tooltip
|
||||
.style("left", `${event.pageX + 10}px`)
|
||||
.style("top", `${event.pageY - 10}px`);
|
||||
}
|
||||
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);
|
||||
}
|
||||
this.tooltip
|
||||
?.transition()
|
||||
.duration(100)
|
||||
.style("opacity", 0);
|
||||
}
|
||||
}
|
||||
|
||||
#formatCurrency(value) {
|
||||
const formatted = Number.parseFloat(value).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
return this.currencySymbolValue + formatted;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user