d3js中如何同步不同折线图的位置工具提示?

问题描述 投票:0回答:1
  1. 目标:我正在将 D3.js 与 ReactJS 结合使用,我想将鼠标悬停在一个图表上,并在所有其他图表上显示带有自己数据的工具提示。
  2. 实际结果:工具提示在所有其他图表中显示相同悬停图表的数据。
  3. 代码:这是我尝试过的multiline-chart-tooltip-acoss-all-charts
  4. 更多详情:在recharts中有一个syncId属性,如果两个图表有相同的syncId,这两个图表可以同步位置工具提示。我想用D3JS同步位置工具提示。
  5. 问题出在这个文件中:ChartComponents/Tooltip.js。
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>
    );
  }; 
javascript reactjs d3.js recharts
1个回答
0
投票

我知道这晚了一年半,但希望这可以帮助其他人。

我通过让父组件管理应由工具提示显示的数据的索引解决了这个问题。每个图中都有两个

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;

© www.soinside.com 2019 - 2024. All rights reserved.