Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add <Legend> into <LineChart> #14

Merged
merged 16 commits into from
Mar 12, 2019
Merged

Add <Legend> into <LineChart> #14

merged 16 commits into from
Mar 12, 2019

Conversation

chenesan
Copy link
Contributor

@chenesan chenesan commented Mar 4, 2019

Purpose

Add <Legend> component, and integrate it into <LineChart>.

Change

graph

  • Add <Legend> and <LegendGroup>.
  • Fix ordinal color scale: Make colors.sequential.scheme be ReadonlyArray<string>[] to include multiple color arrays to fix ordinal color scale not map color properly.
  • Consider legend width in getInnerGraphDimension so graph width won't include legend width.
  • Fix typescript error when render <HoverLayer> without passing defaultProps (throttleTime, handleHover).

chart

  • Add <LegendGroup> and related encoding props into <LineChart>.

UI screenshot

nominal color field line chart

2019-03-05 5 22 36

ordinal color field line chart

2019-03-05 5 22 51

line chart without legend

2019-03-05 5 23 04

line chart with custom legend

2019-03-05 5 23 26

orient=left

螢幕快照 2019-03-11 下午2 17 15

orient=top

螢幕快照 2019-03-11 下午2 17 24

orient=bottom

螢幕快照 2019-03-11 下午2 17 31

direction=horizontal

螢幕快照 2019-03-11 下午2 17 50

direction=vertical

螢幕快照 2019-03-11 下午2 17 43

Risks

None.

Todos

  • Consider legend width/height in getInnerGraphDimension.
  • Add basic legend encoding to allow customize legend.

@chenesan chenesan self-assigned this Mar 4, 2019
@chenesan chenesan force-pushed the feature/legend branch 3 times, most recently from 6f52591 to c9502bb Compare March 5, 2019 09:15
@@ -108,12 +110,75 @@ const lineData = [
y={{ type: 'quantitative', field: 'y' }}
color={{
field: "size",
type: "quantitative",
type: "ordinal",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's quantitative the label in legend will be independent to data point value (size) and paint a discrete scale. This should be ordinal so the legend will exactly match size.

</div>
</Playground>

### Multiple lines Without legend
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the screenshot in PR description :-)

}}
/>
</div>
</Playground>

### Multiple lines with custom legend
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the screenshot in PR description :-)

const dimension = useContainerDimension(chartRef);
const { width: legendWidth } = useContainerDimension(legendRef);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The legend should be "outside" of the graph. So we have to have a ref on the <LegendGroup>, detect its width and bring it into getInnerGraphDimension to make sure graph will not intersect with legend.

encoding: color,
colors: theme.colors,
})
: null;
Copy link
Contributor Author

@chenesan chenesan Mar 5, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make it return null because we can use non null operator in https://github.com/iCHEF/transcharts/pull/14/files#diff-d2fea036f66e094259cb8577ae881e96R258 . Not sure if there's better way, though.

@@ -99,7 +112,7 @@ export interface Theme {
/** colors used for nominal data */
category: ReadonlyArray<string>;
sequential: {
scheme: ReadonlyArray<string>;
scheme: ReadonlyArray<ReadonlyArray<string>>;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adjustment is because scaleOrdinal (we used in getColorScale) will map field value to color one by one from the start of scheme array, which will make mapped color too light when domain size is much smaller than the scheme size. For example when we only have large, small 2 sizes but have 9 colors in scheme, the scaleOrdinal will map this 2 sizes to the 0th and 1st color in the color scheme, which is too light to read it clearly.

To fix this, the scheme should be an array of array of color (which is https://github.com/d3/d3-scale-chromatic#schemeBlues). When configure color scale, just use the color scheme as large as the domain size, so the scale could map color properly.

};
},
[],
[containerRef],
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The containerRef would be null when attaching ref on legend and be updated after first render. We have to add containerRef to detect the change.

() => {
// connect the resize observer on mounted
resizeObsrRef.current = new resizeObserverPolyfill(debouncedResize);
resizeObsrRef.current.observe(containerRef.current!);
if (containerRef.current) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the containerRef.current could be null when detecting <LegendGroup> width, we have to check it here.

@@ -64,18 +64,22 @@ export function useContainerDimension(
[],
);

useEffect(
useLayoutEffect(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not that sure. But it seems that we should "wait" for the layout change (element attached) and bind the observer.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you elaborate on why you're choosing useLayoutEffect over useEffect?
I believe the later is preferred by the official docs of React Hooks.
Ref: https://reactjs.org/docs/hooks-reference.html#uselayouteffect

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After some thinking I agree that this should be useEffect. What I want is I'd like to make sure when the effect is running the container element has been in DOM. But since the containerRef would been changed from null to the html element before the effect running, useEffect should be better one.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think after you remove the following statements in you could use useEffect without any issue:

if (graphWidth <= 0 || graphHeight <= 0) {
  return null;
}

collisionComponents,
throttleTime = 180,
}) => {
export const HoverLayer = (props: HoverLayerProps) => {
Copy link
Contributor Author

@chenesan chenesan Mar 5, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The point of this fix is, even we have default argument (handleHover and throttleTime) in HoverLayer, we still get typescript error when rendering it without passing them:

/* oops */
<HoverLayer
  setHoveredPosAndIndex={...}
  clearHovering={...}
  collisionPoints={...}
/>

It's because FunctionComponent<HoverLayerProps> type won't consider the default arguments and regard the handleHover and throttleTime are required. So I fixed them with the indication in react-typescript-cheatsheet.

(This won't make docz break so I didn't find out this until I try to build it today 😢 😭 💣 . I think we should fix the difference between docz build and lerna run prepublish and add CI check as soon as possible.)

const SCALE_TYPE_TO_LEGEND = {
ordinal: LegendOrdinal,
sequential: LegendLinear,
};
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently we only accept two type of scale for legend. I think it's fine and we can have more if needed.

<div>
<div>{title}</div>
<LegendComponent scale={scale}>
{render}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We pass the render as children render prop in vx Legend. See the vx legend gallery and vx legend doc

* LegendGroup will return corresponding legend.
*
*/
export const LegendGroup = forwardRef((props: LegendGroupProps, ref: RefObject<HTMLDivElement>) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason to have LegendGroup is because

  • We may have multiple legends in one place if there are multiple scales (color / shape / size...)
  • I'd like to handle encoding / legend config outside our own Legend.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The second reason is fair enough, but I have some hard time imaging the usage of first reason.
Can you picture a rough example of how a <LegendGroup> can benefit multiple legends?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something like this:
2019-03-06 5 53 07

@@ -15,7 +15,7 @@ export const themes = {
colors: {
category: schemeCategory10,
sequential: {
scheme: schemeBlues[9],
scheme: schemeBlues,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@@ -42,13 +42,14 @@ const getColorScaleSetting = ({
};
}
case 'ordinal': {
const domain = map(data, field).sort((a, b) => Number(a) - Number(b));
const scale = scaleOrdinal(colors.sequential.scheme).domain(domain);
const domain = sortedUniq(map(data, field).sort((a, b) => Number(a) - Number(b)));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have to make sure the element is unique or the scaleOrdinal will treat same element in domain as different value and map it incorrectly.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice change 👍. I think you could put this note into the comment.

@chenesan chenesan force-pushed the feature/legend branch 3 times, most recently from 6205b24 to 757fd90 Compare March 5, 2019 10:07
@chenesan chenesan changed the title [WIP] Add <Legend> into <LineChart> Add <Legend> into <LineChart> Mar 5, 2019
@chenesan chenesan changed the base branch from feature/color-scale to develop March 5, 2019 10:08
@chenesan chenesan requested review from hsunpei, tz5514 and zhusee2 March 5, 2019 10:09
data={multiLinesData}
x={{ type: 'quantitative', field: 'x' }}
y={{ type: 'quantitative', field: 'y' }}
color={{
Copy link
Contributor

@zhusee2 zhusee2 Mar 6, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a user, I think the color prop has became too complex here.
It obviously contains much more than its literal name color

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm... I agree that it's becoming larger. And that's because I tried to use the same (not fully equal to) schema as data encoding in vega-lite. With this we can have (mostly) same properties for different visual feature (color / opacity / size...) to make the api more consistent. And the legend is also included in encoding object.

Also See #5 for some information about this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that leaving vx-legend to handle the custom legend is a nice design here. Legends are shown by default if the color scale is provided and users are not required to customize the legends.

We may adopt similar approaches to let users customize the tooltip so that our API could be more consistent.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For users familiar with vx it might be great as they can share existing experiences.
I was thinking it could be even better if we can design a more easy-to-use API, especially we are creating a new library.

But we can talk about that later, there's no hurry to block this PR.

Copy link
Contributor Author

@chenesan chenesan Mar 8, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not that sure in the legend props of color (That would be another problem that if legend should be under color) if we should

  1. have the same props (except hide and render) as vx-legend (even just pass the remaining properties of legend down to <Legend> of vx-legend)
  2. have the same props as vega-lite legend config.

Personally I may prefer to (1) because it's easier to implement and also intuitive to vx user.

Copy link
Contributor

@hsunpei hsunpei left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great integration with the vx-legend library and elegant implementation of the <LegendGroup>

I think properties like direction if the legend and it's orient could be furtherly added since charts may not share the same placement of the legend when the layout is different.

It would be nice if a separate document about the usages of <Legend> in docz be included to let users know about how to customize the legends 😃 .

scaleType: string;
scale: (val: any) => string;
title: string;
render?: FunctionComponent | null | undefined;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the type of render be: (labels: ReadonlyArray<object>) => React.ReactNode ?

* LegendGroup will return corresponding legend.
*
*/
export const LegendGroup = forwardRef((props: LegendGroupProps, ref: RefObject<HTMLDivElement>) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you may destruct the props here

Suggested change
export const LegendGroup = forwardRef((props: LegendGroupProps, ref: RefObject<HTMLDivElement>) => {
export const LegendGroup = forwardRef(({ color }: LegendGroupProps, ref: RefObject<HTMLDivElement>) => {

const { color } = props;
if (!color || (color.legend && color.legend.hide)) {
return null;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice design decision that takes multiple scales into account 👍 .
Maybe TODO: ... comments about supporting other scales could be added here.

@@ -42,13 +42,14 @@ const getColorScaleSetting = ({
};
}
case 'ordinal': {
const domain = map(data, field).sort((a, b) => Number(a) - Number(b));
const scale = scaleOrdinal(colors.sequential.scheme).domain(domain);
const domain = sortedUniq(map(data, field).sort((a, b) => Number(a) - Number(b)));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice change 👍. I think you could put this note into the comment.

data={multiLinesData}
x={{ type: 'quantitative', field: 'x' }}
y={{ type: 'quantitative', field: 'y' }}
color={{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that leaving vx-legend to handle the custom legend is a nice design here. Legends are shown by default if the color scale is provided and users are not required to customize the legends.

We may adopt similar approaches to let users customize the tooltip so that our API could be more consistent.

import { Legend } from './Legend';

interface LegendGroupProps {
color?: ColorScale & { legend?: LegendConfig };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some props for configuring the placement like the direction and orient (see Legend of vx-lite) could be further included, since these properties are supported in vx-legend and users may want to apply different settings of direction and orient in different charts.

@@ -64,18 +64,22 @@ export function useContainerDimension(
[],
);

useEffect(
useLayoutEffect(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think after you remove the following statements in you could use useEffect without any issue:

if (graphWidth <= 0 || graphHeight <= 0) {
  return null;
}

@chenesan chenesan force-pushed the feature/legend branch 3 times, most recently from bd37a3c to 2f95e78 Compare March 11, 2019 04:28
@chenesan
Copy link
Contributor Author

chenesan commented Mar 11, 2019

@garfieldduck @zhusee2 I just added orient and direction in legend config and create independent legend document page. The screenshot has been updated in PR description. It's ok to review this again now :)

Copy link
Contributor

@hsunpei hsunpei left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent implementations of legends and nice documentation. 💯🚀

Copy link
Contributor

@zhusee2 zhusee2 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM as a batch

default: {
break;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(optional comment)
I believe it's possible to save some duplicated codes by making your switch cases fall-through, and maybe also by checking outer values outside the switch block:

let graphWidth = (outerWidth <= 0) ? DEFAULT_LENGTH : undefined;
let graphHeight = (outerHeight <= 0) ? DEFAULT_LENGTH : undefined;

switch (orient) {
    case LEFT:
    case RIGHT:
        graphWidth = graphWidth || value;
        graphHeight = graphHeight || value;
        break;
    case TOP:
    case BOTTOM:
        // also do the calc
        break;
}

@chenesan chenesan merged commit 12f0ab7 into develop Mar 12, 2019
@chenesan chenesan deleted the feature/legend branch March 12, 2019 06:48
@chenesan chenesan mentioned this pull request Mar 18, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants