FormidableLabs / victory

A collection of composable React components for building interactive data visualizations
http://commerce.nearform.com/open-source/victory/
Other
10.92k stars 524 forks source link

HTML tooltips #576

Closed icd2k3 closed 4 years ago

icd2k3 commented 7 years ago

According to the docs:

The new element created from the passed flyoutComponent will be supplied with the following properties: x, y, dx, dy, index, datum, cornerRadius, pointerLength, pointerWidth, width, height, orientation, style, and events.

However, I am not seeing them passed to my custom flyout component (<Tooltip />'s props): ex

Code:

// ...
<VictoryScatter
  labelComponent={
    <VictoryTooltip
      flyoutComponent={<Tooltip />}
      renderInPortal
    />
// ...

Note that my custom <Tooltip /> component renders fine, but index and datum are not in the props object (image above). I've also tried setting labelComponent on my container instead, but no dice.

Thanks!

amaschas commented 7 years ago

I just went through this same process, and I learned that the tooltip component doesn't work at all as I expected it to. It's essentially composed of two parts: a flyout and a label. The flyout only renders the background of the tooltip, while the label only renders the text. You can see where they render here:

https://github.com/FormidableLabs/victory-core/blob/master/src/victory-tooltip/victory-tooltip.js#L275-L276

This doesn't make very much sense to me, and it makes implementing a custom tooltip overly complex. I understand the desire to use primitives to create more complex components, but they don't really fit the need very well in this case.

The documentation is also not particularly useful in this regard, since they give you the impression that the tooltips are lot more customizable than they actually are. The goal here should be a tooltip that can accept a single customizable component as an argument that gets the full set of contextual props and lets users easily override the layout.

icd2k3 commented 7 years ago

Thanks @amaschas I completely agree. In general tooltip customization feels very bare-bones within Victory.

It would also be great if tooltips were somehow decoupled from the svg viewport. Maybe as a div container that sits above the svg chart? For charts that have a lot of other elements around them like: chart It's very easy for the tooltip to be clipped by the chart viewport.

I think I'll try to implement a custom HTML container above the chart SVG with its own (invisible but hoverable) data points and tooltips. This would make for a good feature request I think...

boygirl commented 7 years ago

@icd2k3 mind if I split this into two issues? 1) make sure datum and index are passed through to Flyout 2) HTML tooltips feature request

icd2k3 commented 7 years ago

Works for me! Thanks @boygirl!

icd2k3 commented 7 years ago

For those who may be curious I found a temporary workaround for this. It's hacky, but works fine for my use case (unfortunately not IE11 compatible):

img

1.) VictoryChart set style to have overflow visible (so tooltips don't get cut off):

<VictoryChart style={{ parent: { overflow: 'visible' }}} />

2.) VictoryScatter component with tooltip for points:

<VictoryScatter
  labelComponent={
    <VictoryTooltip
      flyoutComponent={<Tooltip />}
      orientation={d => <TooltipContent average={d.average} />} // hacky workaround before https://github.com/FormidableLabs/victory-core/pull/244 is released
      renderInPortal
    />
  }
  labels=""
/>

3.) Tooltip component

const Tooltip = ({
  orientation, // hacky workaround before https://github.com/FormidableLabs/victory-core/pull/244 is released
  x,
  y,
}) => (
  <foreignObject
    height="0"
    width="0"
    x={Math.round(x)}
    y={Math.round(y)}
  >
    <MyCustomHTMLTooltip
      active
      label={
        <div>
          {orientation}
        </div>
      }
    />
  </foreignObject>
);

Note that I'm using orientation for the content as a hacky workaround before https://github.com/FormidableLabs/victory-core/pull/244 is released which will pass datum/index to the tooltip. Currently this workaround will cause PropType warnings from React.

4.) TooltipContent component:

const TooltipContent = ({
  average,
}) => (
  <div>
    <div>Average Rating</div>
    <div>
      {average}
    </div>
  </div>
);

There we have it! HTML tooltips in the SVG chart using foreignObject. Unfortunately this element isn't supported in IE11, but it's good enough for now! Hope it is helpful to others looking for a workaround before an official solution.

Thom1729 commented 7 years ago

FYI, I created a general solution inspired by VictoryPortal:

import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';

const portalContextType = { portalContexts: PropTypes.object, }

class PortalContext extends React.Component {
    static contextTypes      = portalContextType;
    static childContextTypes = portalContextType;

    getChildContext() {
        return {
            portalContexts: {
                ...this.context.portalContexts,
                [this.props.name]: this,
            }
        };
    }

    render() {
        return this.props.contents({
            portalContents: <div ref={node => { this.output_node = node; } } />,
        });
    }
}

class Portal extends React.Component {
    static contextTypes = portalContextType;

    destination() {
        const portalContext = this.context.portalContexts[this.props.name];
        return ReactDOM.findDOMNode(portalContext.output_node);
    }

    componentWillMount() {
        this.output = document.createElement('div');
        this.destination().appendChild(this.output);
        this.componentDidUpdate();
    }

    componentWillUnmount() {
        ReactDOM.unmountComponentAtNode(this.output);
        this.destination().removeChild(this.output);
        this.output = null;
    }

    componentDidUpdate() {
        if (this.props.children) ReactDOM.render(this.props.children, this.output);
    }

    render() { return null; }
}

const portalContextDecorator = options => Component => props =>
    <PortalContext
        { ...options }
        contents={ portalProps => <Component {...portalProps} {...props} /> }
    />;

export {
    Portal,
    portalContextDecorator,
}

Turning this into fully-functioning tooltips is left as an exercise for the reader, by which I mean I haven't quite finished it yet.

Utzel-Butzel commented 6 years ago

I'm using React-Portal as a Wrapper to show the tooltip outside the svg and without using foreignObject. The advantage is that it is working in IE11.

class ChartTooltip extends React.Component {
  static defaultEvents = VictoryTooltip.defaultEvents

  constructor(props) {
    super(props);
    this.state = {position: true};
  }

   calcTooltipPosition = (node, x, y) => {
    if (node && this.state.x !== x && this.state.y !== y) {
         this.setState({
          x: x,
          y: y,
          position: node.getBoundingClientRect()
        });
      }
  }

  render() {
    const {x, y, datum, color} = this.props;
    return (
      <g>
        <circle className="chart__line__invisible" cx={x} cy={y} r="1" ref={(node) => this.calcTooltipPosition(node, x, y)} />
          <Portal closeOnEsc closeOnOutsideClick isOpened={true}>
            <div className="tooltip" style={{left: this.state.position.x, top: this.state.position.y}}>
              Portal Content
            </div>
          </Portal>
      </g>
    );
  }
}
Utzel-Butzel commented 6 years ago

This is the way I use the Tooltip for Bar Charts. On Stacked Bar Charts I create an invisible BarChart with the BarTooltip covering the Stacks so the Tooltips hover area will use this one and there is only one tooltip for each stack.

import React from 'react'
import { VictoryTooltip } from 'victory'

import { TooltipPortal } from '../charts'

export class BarTooltip extends React.Component {
  static defaultEvents = VictoryTooltip.defaultEvents;

  render() {
    var {angle, events, realFrom, ...other} = this.props;
    return (
      <g>
         <VictoryTooltip 
            {...other} 
            labelComponent={ <EmptySvg /> }
            flyoutComponent={<TooltipPortal {...this.props} from={realFrom} showLine={false} />}
          />
      </g>
    );
  }
}

The Portal to display html-content across browsers. The .tooltip class must have some "position: fixed" styling.

import React from 'react'
import { VictoryTooltip } from 'victory'
import Portal from 'react-portal'

/* Tooltip */
class TooltipPortal extends React.Component {

  static defaultEvents = VictoryTooltip.defaultEvents

  constructor(props) {
    super(props);
    this.state = {position: true};
  }

  calcTooltipPosition = (node, x, y) => {
    if (node && this.state.x !== x) {
      this.setState({
        x: x,
        y: y,
        position: node.getBoundingClientRect()
      });
    }
  }

  render() {
    const {x, y, datum, offsetX, horizontal, manualPosition, padding, width} = this.props;

    const transform = offsetX ? `translate(${offsetX})` : '';
    var xTop = horizontal && padding ? (width + padding.left)/2  : x;
    var yTop = y;

    return (
      <g transform={transform} >

       <circle
          className="chart__line__invisible"
          cx={xTop}
          cy={yTop}
          r="1"
          ref={(node) => this.calcTooltipPosition(node, x, y)}
        />

        <Portal
          closeOnEsc
          closeOnOutsideClick
          isOpened={true}>
          <div
            className="tooltip"
            style={{left: this.state.position.left, top: this.state.position.top}}
          >
           Content
          </div>
        </Portal>
      </g>
    );
  }
}
export {TooltipPortal};
laurasilvani commented 6 years ago

@Utzel-Butzel The idea of overlapping charts works but I need to use node.getBoundingClientRect() + window.scrollY in order to get the correct y position for the tooltip, otherwise its position is calculated on the basis of the viewport, not on the entire document.

rsoni0809 commented 6 years ago

@Utzel-Butzel: Thanks for this idea. Could you please share working demo jsfiddle or other for your implementation ?

Utzel-Butzel commented 6 years ago

@laurasilvani I've used position: fixed to display the Tooltip. @rsoni0809 Yes, I can add an example

laurasilvani commented 6 years ago

@Utzel-Butzel Yes, I noticed the fixed position, but it happened I scrolled while the tooltip was visible and didn't like it was fixed. So I am setting a fixed width to the chart and added some calc involving the width and height of the chart to place the tooltip correctly

feralislatr commented 5 years ago

I have an approach similar to @icd2k3 using foreignObjects within a custom flyout, but it only seems to work in Chrome. Is this a known issue?

Hovering a slice here should bring up a tooltip in the center of the chart: https://codesandbox.io/s/7wmy73k0jx

boygirl commented 4 years ago

I'm closing this issue, as it will not be fixed within the library. The best solution is to create a custom label component that renders foreignObject wrapped html elements.

rstor1 commented 11 months ago

Having to use foreignObject for custom tooltips for web could really be used in the docs!