const Tooltip = ({
xScale,
yScale,
width,
height,
data,
margin,
anchorEl,
children,
currentIndex,
tooltipColor,
...props
}) => {
const ref = useRef(null);
const drawLine = useCallback(
(x) => {
d3.select(ref.current)
.select(".tooltipLine")
.attr("x1", x)
.attr("x2", x)
.attr("y1", -margin.top)
.attr("y2", height);
},
[ref, height, margin]
);
const drawLineForOthers = useCallback(
(x) => {
d3.selectAll(".tooltip")
.select(".tooltipLine")
.attr("x1", x)
.attr("x2", x)
.attr("y1", -margin.top)
.attr("y2", height);
},
[ref, height, margin]
);
const drawContent = useCallback(
(x) => {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
// console.log('xScale.invert(x)', convertTZ(xScale.invert(x), tz));
const tooltipContent = d3.select(ref.current).select(".tooltipContent");
tooltipContent.attr("transform", (cur, i, nodes) => {
const nodeWidth = nodes[i].getBoundingClientRect().width || 0;
const translateX = nodeWidth + x > width ? x - nodeWidth - 12 : x + 8;
return `translate(${translateX}, ${-margin.top})`;
});
tooltipContent
.select(".contentTitle")
.text("Le " + d3.timeFormat("%d %b %Y, %H:%M")(xScale.invert(x)));
// .text(d3.timeFormat('%b %d, %Y %Z')(convertTZ(xScale.invert(x), tz)));
},
[xScale, margin, width]
);
const drawContentForOthers = useCallback(
(x) => {
// console.log('xScale.invert(x)', xScale.invert(x));
const tooltipContentOthers = d3
.selectAll(".tooltip")
.select(".tooltipContent");
tooltipContentOthers.attr("transform", (cur, i, nodes) => {
const nodeWidth = nodes[i].getBoundingClientRect().width || 0;
const translateX = nodeWidth + x > width ? x - nodeWidth - 12 : x + 8;
return `translate(${translateX}, ${-margin.top})`;
});
tooltipContentOthers
.select(".contentTitle")
.text("Le " + d3.timeFormat("%d %b %Y, %H:%M")(xScale.invert(x)));
// .text(d3.timeFormat('%b %d, %Y %H:%M')(xScale.invert(x)));
// .text(
// d3.timeFormat('%b %d, %Y %H:%M')(convertTZ(xScale.invert(x) + '', tz))
// );
},
[xScale, margin, width]
);
const drawBackground = useCallback(() => {
// reset background size to defaults
const contentBackground = d3
.select(ref.current)
.select(".contentBackground");
contentBackground.attr("width", 125).attr("height", 20);
// calculate new background size
const tooltipContentElement = d3
.select(ref.current)
.select(".tooltipContent")
.node();
if (!tooltipContentElement) return;
const contentSize = tooltipContentElement.getBoundingClientRect();
contentBackground
.attr("width", contentSize.width + 8)
.attr(
"height",
contentSize.height > 80
? contentSize.height + 10
: contentSize.height + 4
);
}, []);
const drawBackgroundForOthers = useCallback(() => {
// reset background size to defaults for others
const contentBackgroundOthers = d3
.selectAll(".tooltip")
.select(".contentBackground")
.filter((item, i) => currentIndex !== i);
contentBackgroundOthers.attr("width", 125).attr("height", 40);
// calculate new background size for others
const tooltipContentElementOthers = d3
.selectAll(".tooltip")
.select(".tooltipContent")
.node();
if (!tooltipContentElementOthers) return;
const contentSizeOthers = tooltipContentElementOthers.getBoundingClientRect();
contentBackgroundOthers
.attr("width", contentSizeOthers.width + 8)
.attr(
"height",
contentSizeOthers.height > 80
? contentSizeOthers.height + 26
: contentSizeOthers.height + 4
);
}, []);
const onChangePosition = useCallback((d, i, isVisible) => {
d3.select(ref.current)
.selectAll(".performanceItemMarketValue")
.filter((td, tIndex) => tIndex === i)
.text(d && d.value && !isVisible ? "No data" : d && financial(d.value));
const maxNameWidth = d3.max(
d3.select(ref.current).selectAll(".performanceItemName").nodes(),
(node) => node.getBoundingClientRect().width
);
d3.select(ref.current)
.selectAll(".performanceItemValue")
.attr(
"transform",
(datum, index, nodes) =>
`translate(${
nodes[index].previousSibling.getBoundingClientRect().width + 14
},4)`
);
d3.select(ref.current)
.selectAll(".performanceItemMarketValue")
.attr("transform", `translate(${maxNameWidth + 60},4)`);
}, []);
const followPoints = useCallback(
(e) => {
const [x] = [e.layerX - margin.left];
const xDate = xScale.invert(x);
const bisectDate = d3.bisector((d) => d.date).left;
let baseXPos = 0;
drawCirclesOnLine();
drawCirclesOnLineForOthers();
drawLine(baseXPos);
drawContent(baseXPos);
drawBackground();
drawLineForOthers(baseXPos);
drawContentForOthers(baseXPos);
drawBackgroundForOthers();
function drawCirclesOnLine() {
d3.select(ref.current)
.selectAll(".tooltipLinePoint")
.attr("transform", (cur, i) => {
const index = bisectDate(data[i].items, xDate, 1);
const d0 = data[i].items[index - 1];
const d1 = data[i].items[index];
let d;
if (d1) d = xDate - d0.date > d1.date - xDate ? d1 : d0;
if (d && d.date === undefined && d.value === undefined) {
// move point out of container
return "translate(-100,-100)";
}
const xPos = d && xScale(d.date) ? xScale(d.date) : 0;
if (i === 0) {
baseXPos = xPos;
}
let isVisible = true;
if (xPos !== baseXPos) {
isVisible = false;
}
const yPos = d && yScale(d.value) ? yScale(d.value) : 0;
onChangePosition(d, i, isVisible);
return isVisible
? `translate(${xPos}, ${yPos})`
: "translate(-100,-100)";
});
}
function drawCirclesOnLineForOthers() {
d3.selectAll(".tooltip")
.selectAll(".tooltipLinePoint")
.attr("transform", (cur, i) => {
const index = data[i] && bisectDate(data[i].items, xDate, 1);
const d0 = data[i] && data[i].items[index - 1];
const d1 = data[i] && data[i].items[index];
let d = {};
if (d1) d = xDate - d0.date > d1.date - xDate ? d1 : d0;
if (d && d.date === undefined && d.value === undefined) {
// move point out of container
return "translate(-100,-100)";
}
const xPos = d && xScale(d.date) ? xScale(d.date) : 0;
if (i === 0) {
baseXPos = xPos;
}
let isVisible = true;
if (xPos !== baseXPos) {
isVisible = false;
}
const yPos = d && yScale(d.value) ? yScale(d.value) : 0;
// onChangePositionForOthers(d, i, isVisible);
return isVisible
? `translate(${xPos}, ${yPos})`
: "translate(-100,-100)";
});
}
},
[
// anchorEl,
xScale,
yScale,
data,
margin.left,
drawLine,
drawContent,
drawBackground,
onChangePosition,
drawLineForOthers,
drawContentForOthers,
drawBackgroundForOthers
// onChangePositionForOthers
]
);
useEffect(() => {
d3.select(anchorEl)
.on("mouseout.tooltip", () => {
d3.select(ref.current).attr("opacity", 0);
d3.selectAll(".tooltip").attr("opacity", 0);
})
.on("mouseover.tooltip", () => {
d3.select(ref.current).attr("opacity", 1);
d3.selectAll(".tooltip").attr("opacity", 1);
})
.on("mousemove.tooltip", (e) => {
d3.select(ref.current)
.selectAll(".tooltipLinePoint")
.attr("opacity", 1);
d3.selectAll(".tooltip")
.selectAll(".tooltipLinePoint")
.attr("opacity", 1);
followPoints(e);
});
}, [anchorEl, followPoints]);
if (!data.length) return null;
return (
<g ref={ref} opacity={0} {...props}>
<line className="tooltipLine" />
<g className="tooltipContent">
<rect
style={{ fill: tooltipColor }}
className="contentBackground"
rx={4}
ry={4}
opacity={0.4}
/>
<text className="contentTitle" transform="translate(4,14)" />
<g className="content" transform="translate(4,32)">
{data.map(({ name, color }, i) => (
<g key={name + "_" + i} transform={`translate(6,${22 * i})`}>
<circle r={6} fill={color} />
<text className="performanceItemName" transform="translate(10,4)">
{trimText(name, 14)}
</text>
<text className="performanceItemMarketValue" />
</g>
))}
</g>
</g>
{data.map(({ name, color }, index) => (
<circle
fill={color}
className="tooltipLinePoint"
r={6}
key={name + "_" + index}
opacity={0}
/>
))}
</g>
);
};
我知道这晚了一年半,但希望这可以帮助其他人。
我通过让父组件管理应由工具提示显示的数据的索引解决了这个问题。每个图中都有两个
useEffect
。一个渲染数据,另一个处理工具提示渲染和交互。每个工具提示元素都由 svg 添加并保存到引用中。在第二个使用效果中,引用用于访问更新工具提示所需的元素。
如果链接失败,这里有一个 App.js
import "./styles.css";
import LinePlot from "./LiinePlot";
import React, { useState } from "react";
const generateRandomData = () => {
const numPoints = 20;
const initialTime = 1;
const timeIncrement = 1;
const data = Array.from({ length: numPoints }, (_, index) => ({
time: initialTime + index * timeIncrement,
value: Math.random() * 10, // You can adjust the range of random values as needed
}));
return data;
};
const randomData1 = generateRandomData();
const randomData2 = generateRandomData();
const randomData3 = generateRandomData();
export default function App() {
const [activeIndex, setActiveIndex] = useState(null);
return (
<div className="App">
<LinePlot
key={"first"}
data={randomData1}
label={"First"}
units={"s"}
activeIndex={activeIndex}
setActiveIndex={setActiveIndex}
/>
<LinePlot
key={"first"}
data={randomData2}
label={"First"}
units={"s"}
activeIndex={activeIndex}
setActiveIndex={setActiveIndex}
/>
<LinePlot
key={"first"}
data={randomData3}
label={"First"}
units={"s"}
activeIndex={activeIndex}
setActiveIndex={setActiveIndex}
/>
</div>
);
}
和线图
import React, { useEffect, useRef } from "react";
import * as d3 from "d3";
const LinePlot = ({
data,
label,
units,
labelFontSize,
tickFontSize,
toolTipFontSize,
height,
precision,
setActiveIndex,
activeIndex,
}) => {
const svgRef = useRef();
const tooltipGroupRef = useRef();
const verticalLineRef = useRef();
const tooltipTextTimeRef = useRef();
const tooltipTextValueRef = useRef();
const dotRef = useRef();
const xRef = useRef();
const yRef = useRef();
const width = height * 1.618; // golden ratio determins width based off of the height
const marginTop = 30;
const marginRight = 20;
const marginBottom = 80;
const marginLeft = width * 0.15;
useEffect(() => {
// x and y scales
const x = d3
.scaleLinear()
.domain([0, 1.02 * d3.max(data, (d) => d.time)])
.range([marginLeft, width - marginRight]);
const y = d3
.scaleLinear()
.domain([
0.98 * d3.min(data, (d) => d.value),
1.02 * d3.max(data, (d) => d.value),
])
.range([height - marginBottom, marginTop]);
xRef.current = x;
yRef.current = y;
// Declare the line generator.
const lineGenerator = d3
.line()
.x((d) => x(d.time))
.y((d) => y(d.value));
const svg = d3.select(svgRef.current);
// remove old elements
svg.selectAll("g").remove();
svg.selectAll("path").remove();
svg
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;")
.style("background", "white")
.style("border-radius", "8px")
.style("box-shadow", "0px 0px 10px #c6c6c6")
.style("pointer-events", "all");
// Add the x-axis.
svg
.append("g")
.attr("transform", `translate(0,${height - marginBottom})`)
.style("font-size", `${tickFontSize}px`)
.call(
d3
.axisBottom(x)
.ticks(width / 80)
.tickSizeOuter(0)
)
.call((g) =>
g
.append("text")
.attr("x", width / 2)
.attr("y", 2.5 * tickFontSize)
.attr("fill", "currentColor")
.attr("text-anchor", "middle")
.style("font-size", `${labelFontSize}px`)
.text(`time (s)`)
);
// Add the y-axis, remove the domain line, add grid lines and a label.
svg
.append("g")
.attr("transform", `translate(${marginLeft},0)`)
.style("font-size", `${tickFontSize}px`)
.call(
d3
.axisLeft(y)
.ticks(height / 40) // add grid lines
.tickFormat((value) => value.toExponential(precision))
)
.call((g) => g.select(".domain").remove()) // remove the y axis spine
.call((g) =>
g
.selectAll(".tick line") // set the tick color
.attr("stroke-opacity", 0.1)
)
.call((g) =>
g
.selectAll(".tick line")
.clone() // copy the ticks and stretch them to make horizontal grid lines
.attr("x2", width - marginLeft - marginRight)
.attr("stroke-opacity", 0.1)
)
.call((g) =>
g
.append("text")
.attr("x", -marginLeft + 5)
.attr("y", labelFontSize)
.attr("fill", "currentColor")
.attr("text-anchor", "start")
.style("font-size", `${labelFontSize}px`)
.text(`${label} (${units})`)
);
// Append a path for the line.
svg
.append("path")
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("d", lineGenerator(data));
// Add the transparent vertical line.
verticalLineRef.current = svg
.append("line")
.attr("class", "vertical-line")
.style("stroke", "black")
.style("stroke-width", "1px")
.style("opacity", 0);
tooltipGroupRef.current = svg
.append("g")
.style("opacity", 0)
.attr("transform", `translate(5, ${height - 2 * toolTipFontSize})`);
tooltipTextTimeRef.current = tooltipGroupRef.current
.append("text")
.style("font-size", `${toolTipFontSize}px`)
.style("dominant-baseline", "hanging");
tooltipTextValueRef.current = tooltipGroupRef.current
.append("text")
.style("font-size", `${toolTipFontSize}px`)
.style("dominant-baseline", "hanging")
.attr("dy", "1em");
dotRef.current = svg
.append("circle")
.attr("r", 4)
.style("fill", "steelblue")
.style("opacity", 0);
}, [data]);
// syncrhonizing tooltips across the plots
useEffect(() => {
const x = xRef.current;
const y = yRef.current;
const showToolTip = () => {
verticalLineRef.current.style("opacity", 0.3);
tooltipGroupRef.current.style("opacity", 1);
dotRef.current.style("opacity", 1);
};
const hideToolTip = () => {
verticalLineRef.current.style("opacity", 0);
tooltipGroupRef.current.style("opacity", 0);
dotRef.current.style("opacity", 0);
};
const updateTooltip = (index) => {
const activeData = data[index];
verticalLineRef.current
.attr("x1", x(activeData.time))
.attr("x2", x(activeData.time))
.attr("y1", marginTop)
.attr("y2", height - marginBottom + marginTop);
tooltipTextTimeRef.current.text(`${activeData.time} (s)`);
tooltipTextValueRef.current.text(`${activeData.value} (${units})`);
dotRef.current
.attr("cx", x(activeData.time))
.attr("cy", y(activeData.value));
};
const svg = d3.select(svgRef.current);
svg
.on("mouseover", () => {
showToolTip();
})
.on("mouseleave", () => {
hideToolTip();
setActiveIndex(null);
})
.on("mousemove", (event) => {
const bisect = d3.bisector((d) => d.time).center;
const index = bisect(data, x.invert(d3.pointer(event)[0]));
updateTooltip(index);
setActiveIndex(index);
});
if (activeIndex !== null) {
showToolTip();
updateTooltip(activeIndex);
} else {
hideToolTip();
}
}, [data, activeIndex]);
return (
<div
style={{
padding: `4px`,
}}
>
<svg ref={svgRef} />
</div>
);
};
LinePlot.defaultProps = {
label: "",
units: "",
labelFontSize: 16,
tickFontSize: 14,
toolTipFontSize: 18,
height: 400,
precision: 3,
};
export default LinePlot;