plouc / nivo

nivo provides a rich set of dataviz components, built on top of the awesome d3 and React libraries
https://nivo.rocks
MIT License
13.19k stars 1.03k forks source link

dynamically determine best tooltip anchor #580

Open humanchimp opened 5 years ago

humanchimp commented 5 years ago

Version of nivo tested: 0.58.0

Is your feature request related to a problem? Please describe. Our concern/feature request is primarily related to the line chart, but this affects other chart types too.

After upgrading to a recent version of nivo, the tooltips stopped being positioned relatively such that they don't overflow the bounding box. In a previous version we were using (0.31.0, specifically), the tooltip would adjust by anchoring itself on the opposite side of the cursor when the pointer reached the midpoint. Now it seems the scheme has changed and an anchor attribute must be provided.

I don't think this option will let us dynamically position the tooltip to avoid colliding with the boundary.

Describe the solution you'd like It would be better for us if the tooltip would dynamically anchor itself like it used to. Alternatively, we'd like to be able to use a hook to determine the best anchor dynamically

Describe alternatives you've considered I considered sending a PR to modify the positioning logic, but I don't feel comfortable with this approach.

https://github.com/plouc/nivo/blob/master/packages/tooltip/src/hooks.js#L29-L41

const bounds = container.current.getBoundingClientRect()
let x = event.clientX - bounds.left
let y = event.clientY - bounds.top

if (anchor === 'left' || anchor === 'right') {
  if (x < bounds.width / 2) anchor = 'right'
  else anchor = 'left'
}

Basically, I cannot imagine something like this being mergeable, although it does work well for my application.

I would like to solve this problem by potentially allowing an "auto" option for anchor, or something like that.

Additional context I sincerely hope this is a feature request, and not simply a demonstration of ignorance on my part. I tried to figure this out myself before resorting to this request

chirgjn commented 5 years ago

Hey if no one is working on this, should I submit a PR I need this fix for a project at work 😁

chirgjn commented 5 years ago

@plouc is the change by @humanchimp enough? I was thing to check the bounds of the tooltip with the container to determine if it fits and change the anchor if that's not the case.

Also what are your thoughts on letting the user opt into this behaviour via a flag in props? Because sometimes I'm ok when my tooltips get outside the container.

shihlinlu commented 5 years ago

Agreed with @chirgjn about the flag prop option instead of making it dynamic in all cases.

humanchimp commented 5 years ago

i don't think nivo is deprecated? As the author of this issue, I was doubtful that my approach, which was just copypasta from elsewhere in the codebase, would be high quality enough to merge. I have been using a fork of just the @nivo/tooltip package which applies my patch, and that works for my needs, and means that actually having my changes merged upstream is not a priority. If not having #631 merged in is causing you difficulty, I recommend doing likewise for now.

kkkrist commented 5 years ago

I'm using your patch and it works great for my use cases. Btw, I'm applying it via patch-package, so I don't need to fork it or change my npm deps.

Also I just want to say I appreciate your humility very much.

yocontra commented 4 years ago

Has anyone managed to get this to work on both the X and Y axis? The code above works great for X but Y is a little trickier, since you need to compute the height of the tooltip to do it AFAIK.

serj-prog commented 4 years ago

The issue still exists. Is anybody going to work on it?

jeffshek commented 4 years ago

A workaround I used here was to use a css position: absolute on the tooltip. (Thx for your work on Nivo, this package is amazzzzzing!)

<ResponsiveLine tooltip={TooltipFormat}/>

const TooltipFormat = ({ point }, ...rest) => {
    // example, app.betterself.io/demo 
    return <StyledDivWithAbsolutePosition/>
}

image

Luke1298 commented 4 years ago

It would also be cool if we could choose which way we wanted our tooltips to anchor; it seems like the library is choosing right for tooltip "slices" and top for everything else.

stale[bot] commented 3 years ago

This issue has been automatically marked as stale. If this issue is still affecting you, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize it yet. If you have any new additional information, please include it with your comment!

8annoy commented 3 years ago

bump

ozcanonur commented 3 years ago

Bump. I solved mine with position absolute and dynamically setting left/right depending on the hover position. You can get the hover position from the node prop that is given by you for the custom tooltip. Thanks, @jeffshek.

celestemartins commented 3 years ago

Bump. I solved mine with position absolute and dynamically setting left/right depending on the hover position. You can get the hover position from the node prop that is given by you for the custom tooltip. Thanks, @jeffshek.

Hey @ozcanonur can you share how did you do that? I'm facing the same issue here. Thanks!

LeninSG21 commented 3 years ago

@celestemartins I did the following:

  1. Send a prop to my Tooltip component indicating the number of elements in the x-axis

  2. Check whether the given point is in the first or second half of the chart like so:

    const isFirstHalf = point.index < numElementsX / 2;

  3. Apply the appropriate css style, depending on what you want to achieve.

ex.

import React from 'react';
import { Point } from '@nivo/line';

const CustomTooltip = ({ point, numElementsX } : { point: Point, numElementsX: number }) => {
  const isFirstHalf = point.index < numElementsX / 2;
  return (
     <div 
       style = {{
         position: 'absolute',
         left: isFirstHalf ? 30 : 0,
         right: isFirstHalf ? 0 : 30,
       }}
      >
         // your tooltip
      </div>
    );
};

Note: I actually used clsx to select the appropriate css class. But it is the same concept

admc commented 3 years ago

update: the left / right styles and absolute positioning seem to be a reasonable temporary fix.

Love this project. Currently working on a ResponsiveSwarmPlot and struggling with this issue and coming from recharts where the tooltip finds a way to always stay in the viewport, i'd like to replicate that behavior. My company would be happy to fund someones work to get a thorough / high quality patch in to the project if that helps! Thanks again.

bunge12 commented 3 years ago

@celestemartins I did the following:

  1. Send a prop to my Tooltip component indicating the number of elements in the x-axis
  2. Check whether the given point is in the first or second half of the chart like so: const isFirstHalf = point.index < numElementsX / 2;
  3. Apply the appropriate css style, depending on what you want to achieve.

ex.

import React from 'react';
import { Point } from '@nivo/line';

const CustomTooltip = ({ point, numElementsX } : { point: Point, numElementsX: number }) => {
  const isFirstHalf = point.index < numElementsX / 2;
  return (
     <div 
       style = {{
         position: 'absolute',
         left: isFirstHalf ? 30 : 0,
         right: isFirstHalf ? 0 : 30,
       }}
      >
         // your tooltip
      </div>
    );
};

Note: I actually used clsx to select the appropriate css class. But it is the same concept

Thanks for your help!

admc commented 3 years ago

This approach was really helpful for me, I wound up using the onMouseMove event and ResizeObserver to keep track of the width and height of the container, and the mouse position which made it pretty easy to figure out in which quadrant of the graph the tooltip would be rendered and adjust the top/left offsets accordingly!

hadasmaimon commented 3 years ago

you can workaround with breaking word:

word-wrap: break-word; /* All browsers since IE 5.5+ */
overflow-wrap: break-word; /* Renamed property in CSS3 draft spec */
max-width: 10rem;
stale[bot] commented 3 years ago

This issue has been automatically marked as stale. If this issue is still affecting you, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize it yet. If you have any new additional information, please include it with your comment!

ncknuna commented 3 years ago

bump :)

wiznotwiz commented 3 years ago

Bump

rohanj-0606 commented 3 years ago

Bump

rohanj-0606 commented 3 years ago

Bump

tpulido commented 2 years ago

Bump

tpulido commented 2 years ago

@celestemartins I did the following:

  1. Send a prop to my Tooltip component indicating the number of elements in the x-axis
  2. Check whether the given point is in the first or second half of the chart like so: const isFirstHalf = point.index < numElementsX / 2;
  3. Apply the appropriate css style, depending on what you want to achieve.

ex.

import React from 'react';
import { Point } from '@nivo/line';

const CustomTooltip = ({ point, numElementsX } : { point: Point, numElementsX: number }) => {
  const isFirstHalf = point.index < numElementsX / 2;
  return (
     <div 
       style = {{
         position: 'absolute',
         left: isFirstHalf ? 30 : 0,
         right: isFirstHalf ? 0 : 30,
       }}
      >
         // your tooltip
      </div>
    );
};

Note: I actually used clsx to select the appropriate css class. But it is the same concept

Thanks! This also helped me for a bar chart Tooltip, just using transform instead of left/right different.

const Tooltip = (props) => {
  const { bar } = props;
  const isFirstHalf = bar.index <= 6;

  return (
    <div
      className="chart-tooltip"
      style={{
        position: "absolute",
        transform: isFirstHalf ? "translate(0,0)" : "translate(-260px,0)",
      }}
    >
     // ...content
    </div>
  );
};
amack-butter commented 2 years ago

First, thank you @plouc for the amazing Nivo library, and for others for posting their wrappers. Is there any plan to make these changes an official part of the library?

doiali commented 2 years ago

bump

Danamorah commented 2 years ago

Thanks for the examples i did the following using <ResponsiveLine/>: the <div>also have an position:absolute

ex.

import React from 'react';

const basicTooltip = (props : any) => {
  const {point} = props
  const isFirstHalf = point.x < 941 / 2;
  return (
     <div
        style={isFirstHalf ? { left: 0 } : { right: 0 }}

         //tooltip content
      </div>
    );
};

<ResponsiveLine
     tooltip={basicTooltip}
/>
pramodkandel commented 2 years ago

bump

stale[bot] commented 2 years ago

This issue has been automatically marked as stale. If this issue is still affecting you, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize it yet. If you have any new additional information, please include it with your comment!

ncknuna commented 2 years ago

bump

stale[bot] commented 2 years ago

Closing this issue after a prolonged period of inactivity. If this issue is still present in the latest release, please create a new issue with up-to-date information. Thank you!

studiosciences commented 1 year ago

I ended up creating a new custom layer and used our UI component library for the tooltip. This was fairly straight forward, worked better, and was more consistent with our overall UX.

damianr13 commented 1 year ago

I know the issue is closed but someone might reach this issue by googling the problem, same as I did.

The solutions above are either specific for bar charts, or they use hardcoded values like the 941 width in @Danamorah 's example.

Instead of determining wether one is on the left or right part of the chart, I propose determining wether the tooltip fits to the left and if so, show it there. I also address the some issue on the Y axis.

My code:

export interface NonOverflowTooltipWrapperProps {
  point: { x: number; y: number };
  innerComponent: React.ReactNode;
}

const NonOverflowTooltipWrapper = (props: NonOverflowTooltipWrapperProps) => {
  const [tooltipSize, setTooltipSize] = React.useState<{
    width: number;
    height: number;
  }>({ width: 0, height: 0 });

  // dynamically get the size of the tooltip
  React.useEffect(() => {
    const tooltip = document.querySelector(".nivo_tooltip");
    if (tooltip) {
      const { width, height } = tooltip.getBoundingClientRect();
      setTooltipSize({ width, height });
    }
  }, [setTooltipSize]);

  // only show it to the right of the pointer when we are close to the left edge
  const translateX = React.useMemo(
    () =>
      props.point.x < (tooltipSize.width * 1.3) / 2 ? 0 : -tooltipSize.width,
    [tooltipSize, props.point.x]
  );

  // only show it below the pointer when we are close to the top edge
  const translateY = React.useMemo(
    () =>
      props.point.y < (tooltipSize.height * 1.3) / 2 ? 0 : -tooltipSize.height,
    [tooltipSize, props.point.y]
  );

  return (
    <div
      className={"nivo_tooltip"}
      style={{
        position: "absolute",
        transform: `translate(${translateX}px, ${translateY}px)`,
        background: "#ffffff",
        padding: "12px 16px",
        width: "fit-content",
      }}
    >
      <div style={{ position: "relative" }}>{props.innerComponent}</div>
    </div>
  );
};

Using this component we don't need to know (or hardcode) the actual size of the chart.

WilliamABradley commented 8 months ago

Based on @damianr13's solution, I came up with this:

import { useState, useEffect, useMemo, useRef } from "react";

export interface NonOverflowTooltipProps {
  point: { x: number; y: number };
  container: React.RefObject<HTMLDivElement>;
  children: React.ReactNode;
}

export function NonOverflowTooltip(props: NonOverflowTooltipProps) {
  const ref = useRef<HTMLDivElement>(null);
  const [containerSize, setContainerSize] = useState<{
    width: number;
    height: number;
  }>({ width: 0, height: 0 });
  const [tooltipSize, setTooltipSize] = useState<{
    width: number;
    height: number;
  }>({ width: 0, height: 0 });

  // dynamically get the size of the container
  useEffect(() => {
    const container = props.container.current;
    if (container) {
      const { width, height } = container.getBoundingClientRect();
      setContainerSize({ width, height });
    }
  }, [setContainerSize, props.container]);

  // dynamically get the size of the tooltip
  useEffect(() => {
    const tooltip = ref.current;
    if (tooltip) {
      const { width, height } = tooltip.getBoundingClientRect();
      setTooltipSize({ width, height });
    }
  }, [setTooltipSize]);

  const offsetHorizontal = useMemo(() => {
    // only show it to the right of the pointer when we are close to the left edge
    if (props.point.x < tooltipSize.width) {
      return tooltipSize.width / 3;
    }

    // only show it to the left of the pointer when we are close to the right edge
    const rightEdge = containerSize.width - props.point.x;
    if (rightEdge < tooltipSize.width) {
      return -(tooltipSize.width / 3);
    }

    return 0;
  }, [tooltipSize.width, props.point.x, containerSize.width]);

  const offsetVertical = useMemo(() => {
    // only show it above the pointer when we are close to the bottom edge
    if (props.point.y > containerSize.height - tooltipSize.height) {
      return -tooltipSize.height;
    }

    const bottomEdge = containerSize.height - props.point.y;
    if (bottomEdge < tooltipSize.height) {
      return -tooltipSize.height;
    }

    return 0;
  }, [tooltipSize.height, props.point.y, containerSize.height]);

  return (
    <div
      ref={ref}
      style={{
        position: "relative",
        left: offsetHorizontal,
        right: 0,
        top: offsetVertical,
        bottom: 0,
      }}
    >
      {props.children}
    </div>
  );
}

The benefit here is the layout and style is calculated by the children props, rather than the weird layout sizing I got using that solution + the tooltip will be joined with the crosshair.

Switched the query selector with react refs, so multiple graphs won't cause tooltip layout issues, however, a parent container ref needs to be passed in to get the surrounding bounds (For right side/bottom side positioning).