Web: Add chart package with stat vis components [Line Chart] (#29372)

* Add chart package

* Add line chart 

* Add tooltip

* Add legend list component

* Add line chart story and index re-exports

* Add shift limiter to floating panel position logic
This commit is contained in:
Vova Kulikov 2022-01-12 19:44:11 +03:00 committed by GitHub
parent 909ada54a2
commit 7db7d39634
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1790 additions and 4 deletions

View File

@ -0,0 +1,7 @@
.root {
pointer-events: bounding-box;
&--with-hovered-link-point {
cursor: pointer;
}
}

View File

@ -0,0 +1,341 @@
import { Meta } from '@storybook/react'
import React from 'react'
import { WebStory } from '../../../components/WebStory'
import { LineChartSeries } from './types'
import { LineChart, LegendList, ParentSize } from '.'
export default {
title: 'web/charts/line',
decorators: [story => <WebStory>{() => story()}</WebStory>],
} as Meta
interface StandardDatum {
a: number | null
b: number | null
c: number | null
x: number | null
}
export const PlainChart = () => {
const DATA: StandardDatum[] = [
{ x: 1588965700286 - 4 * 24 * 60 * 60 * 1000, c: 5000, a: 4000, b: 15000 },
{ x: 1588965700286 - 3 * 24 * 60 * 60 * 1000, c: 5000, a: 4000, b: 26000 },
{ x: 1588965700286 - 2 * 24 * 60 * 60 * 1000, c: 5000, a: 5600, b: 20000 },
{ x: 1588965700286 - 1 * 24 * 60 * 60 * 1000, c: 5000, a: 9800, b: 19000 },
{ x: 1588965700286, c: 5000, a: 6000, b: 17000 },
]
const SERIES: LineChartSeries<StandardDatum>[] = [
{
dataKey: 'a',
name: 'A metric',
color: 'var(--blue)',
linkURLs: [
'https://google.com/search',
'https://google.com/search',
'https://google.com/search',
'https://google.com/search',
'https://google.com/search',
],
},
{
dataKey: 'b',
name: 'B metric',
color: 'var(--warning)',
linkURLs: [
'https://yandex.com/search',
'https://yandex.com/search',
'https://yandex.com/search',
'https://yandex.com/search',
'https://yandex.com/search',
],
},
{
dataKey: 'c',
name: 'C metric',
color: 'var(--green)',
linkURLs: [
'https://twitter.com/search',
'https://twitter.com/search',
'https://twitter.com/search',
'https://twitter.com/search',
'https://twitter.com/search',
],
},
]
return <LineChart width={400} height={400} data={DATA} series={SERIES} xAxisKey="x" />
}
export const WithLegendExample = () => {
const DATA: StandardDatum[] = [
{ x: 1588965700286 - 4 * 24 * 60 * 60 * 1000, c: 5000, a: 4000, b: 15000 },
{ x: 1588965700286 - 3 * 24 * 60 * 60 * 1000, c: 5000, a: 4000, b: 26000 },
{ x: 1588965700286 - 2 * 24 * 60 * 60 * 1000, c: 5000, a: 5600, b: 20000 },
{ x: 1588965700286 - 1 * 24 * 60 * 60 * 1000, c: 5000, a: 9800, b: 19000 },
{ x: 1588965700286, c: 5000, a: 6000, b: 17000 },
]
const SERIES: LineChartSeries<StandardDatum>[] = [
{
dataKey: 'a',
name: 'A metric',
color: 'var(--blue)',
linkURLs: [
'https://google.com/search',
'https://google.com/search',
'https://google.com/search',
'https://google.com/search',
'https://google.com/search',
],
},
{
dataKey: 'b',
name: 'B metric',
color: 'var(--warning)',
linkURLs: [
'https://yandex.com/search',
'https://yandex.com/search',
'https://yandex.com/search',
'https://yandex.com/search',
'https://yandex.com/search',
],
},
{
dataKey: 'c',
name: 'C metric',
color: 'var(--green)',
linkURLs: [
'https://twitter.com/search',
'https://twitter.com/search',
'https://twitter.com/search',
'https://twitter.com/search',
'https://twitter.com/search',
],
},
]
return (
<div className="d-flex flex-column" style={{ width: 400, height: 400 }}>
<ParentSize className="flex-1">
{({ width, height }) => (
<LineChart<StandardDatum> width={width} height={height} data={DATA} series={SERIES} xAxisKey="x" />
)}
</ParentSize>
<LegendList series={SERIES} />
</div>
)
}
interface HugeDataDatum {
series0: number | null
series1: number | null
dateTime: number
}
export const WithHugeData = () => {
const DATA: HugeDataDatum[] = [
{ dateTime: 1606780800000, series0: 8394074, series1: 1001777 },
{
dateTime: 1609459200000,
series0: 839476900,
series1: 100180700,
},
{ dateTime: 1612137600000, series0: 8395504, series1: 1001844 },
{
dateTime: 1614556800000,
series0: 839684900,
series1: 1001966,
},
{ dateTime: 1617235200000, series0: 8397911, series1: 1002005 },
{
dateTime: 1619827200000,
series0: 839922700,
series1: 100202500,
},
{ dateTime: 1622505600000, series0: 8400349, series1: 1002137 },
{
dateTime: 1625097600000,
series0: 840148500,
series1: 100218000,
},
{ dateTime: 1627776000000, series0: 8402574, series1: 1002280 },
{
dateTime: 1630454400000,
series0: 840362900,
series1: 100237600,
},
{ dateTime: 1633046400000, series0: 8374023, series1: null },
{
dateTime: 1635724800000,
series0: 837455000,
series1: null,
},
]
const SERIES: LineChartSeries<HugeDataDatum>[] = [
{ name: 'Fix', dataKey: 'series0', color: 'var(--oc-indigo-7)' },
{ name: 'Revert', dataKey: 'series1', color: 'var(--oc-orange-7)' },
]
return (
<div style={{ width: 400, height: 400 }}>
<ParentSize>
{({ width, height }) => (
<LineChart<HugeDataDatum>
width={width}
height={height}
data={DATA}
series={SERIES}
xAxisKey="dateTime"
/>
)}
</ParentSize>
</div>
)
}
interface ZeroOneDatum {
a: number
x: number
}
export const WithZeroOneData = () => {
const DATA: ZeroOneDatum[] = [
{ x: 1588965700286 - 4 * 24 * 60 * 60 * 1000, a: 0 },
{ x: 1588965700286 - 2 * 24 * 60 * 60 * 1000, a: 1 },
]
const SERIES: LineChartSeries<ZeroOneDatum>[] = [
{
dataKey: 'a',
name: 'A metric',
color: 'var(--blue)',
},
]
return (
<div style={{ width: 400, height: 400 }}>
<ParentSize>
{({ width, height }) => (
<LineChart<ZeroOneDatum> width={width} height={height} data={DATA} series={SERIES} xAxisKey="x" />
)}
</ParentSize>
</div>
)
}
interface StepDatum {
series0: number
dateTime: number
}
export const WithDataSteps = () => {
const DATA_WITH_STEP: StepDatum[] = [
{ dateTime: 1604188800000, series0: 3725 },
{
dateTime: 1606780800000,
series0: 3725,
},
{ dateTime: 1609459200000, series0: 3725 },
{
dateTime: 1612137600000,
series0: 3725,
},
{ dateTime: 1614556800000, series0: 3725 },
{
dateTime: 1617235200000,
series0: 3725,
},
{ dateTime: 1619827200000, series0: 3728 },
{
dateTime: 1622505600000,
series0: 3827,
},
{ dateTime: 1625097600000, series0: 3827 },
{
dateTime: 1627776000000,
series0: 3827,
},
{ dateTime: 1630458631000, series0: 3053 },
{
dateTime: 1633452311000,
series0: 3053,
},
{ dateTime: 1634952495000, series0: 3053 },
]
const SERIES: LineChartSeries<StepDatum>[] = [
{
dataKey: 'series0',
name: 'A metric',
color: 'var(--blue)',
},
]
return (
<div style={{ width: 400, height: 400 }}>
<ParentSize>
{({ width, height }) => (
<LineChart<StepDatum>
width={width}
height={height}
data={DATA_WITH_STEP}
series={SERIES}
xAxisKey="dateTime"
/>
)}
</ParentSize>
</div>
)
}
interface DatumWithMissingData {
a: number | null
b: number | null
x: number
}
export const WithDataMissingValues = () => {
const DATA_WITH_STEP: DatumWithMissingData[] = [
{ x: 1588965700286 - 4 * 24 * 60 * 60 * 1000, a: null, b: null },
{ x: 1588965700286 - 3 * 24 * 60 * 60 * 1000, a: null, b: null },
{ x: 1588965700286 - 2 * 24 * 60 * 60 * 1000, a: 94, b: 200 },
{ x: 1588965700286 - 1.5 * 24 * 60 * 60 * 1000, a: 134, b: null },
{ x: 1588965700286 - 1.3 * 24 * 60 * 60 * 1000, a: null, b: 150 },
{ x: 1588965700286 - 1 * 24 * 60 * 60 * 1000, a: 134, b: 190 },
{ x: 1588965700286, a: 123, b: 170 },
]
const SERIES: LineChartSeries<DatumWithMissingData>[] = [
{
dataKey: 'a',
name: 'A metric',
color: 'var(--blue)',
},
{
dataKey: 'b',
name: 'B metric',
color: 'var(--warning)',
},
]
return (
<div style={{ width: 400, height: 400 }}>
<ParentSize>
{({ width, height }) => (
<LineChart<DatumWithMissingData>
width={width}
height={height}
data={DATA_WITH_STEP}
series={SERIES}
xAxisKey="x"
/>
)}
</ParentSize>
</div>
)
}

View File

@ -0,0 +1,202 @@
import { curveLinear } from '@visx/curve'
import { Group } from '@visx/group'
import { scaleTime, scaleLinear } from '@visx/scale'
import { LinePath } from '@visx/shape'
import { voronoi } from '@visx/voronoi'
import classNames from 'classnames'
import { noop } from 'lodash'
import React, { ReactElement, useMemo, useRef, useState } from 'react'
import { AxisBottom, AxisLeft } from './components/axis/Axis'
import { NonActiveBackground } from './components/NonActiveBackground'
import { PointGlyph } from './components/PointGlyph'
import { Tooltip, TooltipContent } from './components/tooltip/Tooltip'
import { useChartEventHandlers } from './hooks/event-listeners'
import styles from './LineChart.module.scss'
import { LineChartSeries, Point } from './types'
import { isValidNumber } from './utils/data-guards'
import { getSeriesWithData } from './utils/data-series-processing'
import { generatePointsField } from './utils/generate-points-field'
import { getChartContentSizes } from './utils/get-chart-content-sizes'
import { getMinMaxBoundaries } from './utils/get-min-max-boundary'
export interface LineChartContentProps<D extends object> {
width: number
height: number
/** An array of data objects, with one element for each step on the X axis. */
data: D[]
/** The series (lines) of the chart. */
series: LineChartSeries<D>[]
/**
* The key in each data object for the X value this line should be
* calculated from.
*/
xAxisKey: keyof D
/**
* Callback runs whenever a point-zone (zone around point) and point itself
* on the chart is clicked.
*/
onDatumClick?: (event: React.MouseEvent) => void
}
/**
* Visual component that renders svg line chart with pre-defined sizes, tooltip,
* voronoi area distribution.
*/
export function LineChart<D extends object>(props: LineChartContentProps<D>): ReactElement | null {
const { width: outerWidth, height: outerHeight, data, series, xAxisKey, onDatumClick = noop } = props
const [activePoint, setActivePoint] = useState<Point & { element?: Element }>()
const yAxisReference = useRef<SVGGElement>(null)
const xAxisReference = useRef<SVGGElement>(null)
const { width, height, margin } = useMemo(
() =>
getChartContentSizes({
width: outerWidth,
height: outerHeight,
margin: {
top: 10,
right: 10,
left: yAxisReference.current?.getBoundingClientRect().width ?? 30,
bottom: xAxisReference.current?.getBoundingClientRect().height ?? 30,
},
}),
[outerWidth, outerHeight, yAxisReference]
)
const { minX, maxX, minY, maxY } = useMemo(() => getMinMaxBoundaries({ data, series, xAxisKey }), [
data,
series,
xAxisKey,
])
const xScale = useMemo(
() =>
scaleTime({
domain: [minX, maxX],
range: [margin.left, width],
nice: true,
}),
[minX, maxX, margin.left, width]
)
const yScale = useMemo(
() =>
scaleLinear({
domain: [minY, maxY],
range: [height, margin.top],
nice: true,
}),
[minY, maxY, margin.top, height]
)
const points = useMemo(() => generatePointsField({ data, series, xAxisKey, yScale, xScale }), [
data,
series,
xAxisKey,
yScale,
xScale,
])
const dataSeries = useMemo(() => getSeriesWithData({ data, series }), [data, series])
const voronoiLayout = useMemo(
() =>
voronoi<Point>({
x: point => point.x,
y: point => point.y,
width,
height,
})(points),
[width, height, points]
)
const handlers = useChartEventHandlers({
onPointerMove: point => {
const closestPoint = voronoiLayout.find(point.x, point.y)
if (closestPoint && closestPoint.data.id !== activePoint?.id) {
setActivePoint(closestPoint.data)
}
},
onPointerLeave: () => setActivePoint(undefined),
onClick: event => {
if (activePoint?.linkUrl) {
onDatumClick(event)
window.open(activePoint.linkUrl)
}
},
})
return (
<svg
width={outerWidth}
height={outerHeight}
className={classNames(styles.root, { [styles.rootWithHoveredLinkPoint]: activePoint?.linkUrl })}
{...handlers}
>
<AxisLeft
ref={yAxisReference}
scale={yScale}
width={width}
height={height}
top={margin.top}
left={margin.left}
/>
<AxisBottom ref={xAxisReference} scale={xScale} top={margin.top + height} width={width} />
<NonActiveBackground
data={data}
series={series}
xAxisKey={xAxisKey}
width={width}
height={height}
top={margin.top}
left={margin.left}
xScale={xScale}
/>
<Group top={margin.top}>
{dataSeries.map(line => (
<LinePath
key={line.dataKey as string}
data={line.data}
curve={curveLinear}
defined={datum => isValidNumber(datum[line.dataKey])}
x={datum => xScale(+datum[xAxisKey])}
y={datum => yScale(+datum[line.dataKey])}
stroke={line.color}
strokeWidth={2}
strokeLinecap="round"
/>
))}
{points.map(point => (
<PointGlyph
key={point.id}
left={point.x}
top={point.y}
active={activePoint?.id === point.id}
color={point.color}
linkURL={point.linkUrl}
onClick={onDatumClick}
onFocus={event => setActivePoint({ ...point, element: event.target })}
onBlur={() => setActivePoint(undefined)}
/>
))}
</Group>
{activePoint && (
<Tooltip reference={activePoint.element}>
<TooltipContent data={data} series={series} xAxisKey={xAxisKey} activePoint={activePoint} />
</Tooltip>
)}
</svg>
)
}

View File

@ -0,0 +1,91 @@
import { AxisScale } from '@visx/axis/lib/types'
import { PatternLines } from '@visx/pattern'
import React, { ReactElement, useMemo } from 'react'
import { LineChartSeries } from '../types'
import { isValidNumber } from '../utils/data-guards'
const PATTERN_ID = 'xy-chart-pattern'
export interface NonActiveBackgroundProps<Datum extends object> {
data: Datum[]
series: LineChartSeries<Datum>[]
xAxisKey: keyof Datum
xScale: AxisScale
width: number
height: number
left: number
top: number
}
/**
* Displays custom pattern background for area where we don't have any data points.
* Example:
*
* `````````````````` 10
* ``````````````````
* `````````````````` 9
* ``````````````````
* `````````````````` 8
* ``````````````````
* `````````````````` 7
* ``````````````````
* `````````````````` 6
* ``````````````````
* `````````````````` 5
*
* Where ` is a non-active background
*/
export function NonActiveBackground<Datum extends object>(props: NonActiveBackgroundProps<Datum>): ReactElement | null {
const { data, series, xAxisKey, xScale, top, left, width, height } = props
const backgroundWidth = useMemo(() => {
// For non active background's width we need to find first non nullable element
const firstNonNullablePoints: (Datum | undefined)[] = series.map(line =>
data.find(datum => isValidNumber(datum[line.dataKey]))
)
const lastNullablePointX = firstNonNullablePoints.reduce((xCoord, datum) => {
// In case if the first non nullable element is the first element
// of data that means we don't need to render non active background.
if (!datum || datum === data[0]) {
return null
}
// Get x point from datum by x accessor
return +datum[xAxisKey]
}, null as Date | number | null)
// If we didn't find any non-nullable elements we don't need to render
// non-active background.
if (!lastNullablePointX) {
return 0
}
// Convert x value of first non nullable point to reals svg coordinate
const xValue = xScale?.(lastNullablePointX) ?? 0
return +xValue
}, [data, series, xAxisKey, xScale])
// Early return values not available in context or we don't need render
// non active background.
if (!backgroundWidth || !width || !height) {
return null
}
return (
<>
<PatternLines
id={PATTERN_ID}
width={16}
height={16}
orientation={['diagonal']}
stroke="var(--border-color)"
strokeWidth={2}
/>
<rect x={left} y={top} width={backgroundWidth - left} height={height} fill={`url(#${PATTERN_ID})`} />
</>
)
}

View File

@ -0,0 +1,43 @@
import { GlyphDot } from '@visx/glyph'
import React, { FocusEventHandler, MouseEventHandler } from 'react'
import { MaybeLink } from '../../../../views/components/view/content/chart-view-content/charts/MaybeLink'
interface PointGlyphProps {
top: number
left: number
color: string
active: boolean
linkURL?: string
onClick: MouseEventHandler<Element>
onFocus: FocusEventHandler<Element>
onBlur: FocusEventHandler<Element>
}
export const PointGlyph: React.FunctionComponent<PointGlyphProps> = props => {
const { top, left, color, active, linkURL, onFocus, onBlur, onClick } = props
return (
<MaybeLink
to={linkURL}
target="_blank"
rel="noopener"
onClick={onClick}
onFocus={onFocus}
onBlur={onBlur}
role={linkURL ? 'link' : 'graphics-dataunit'}
>
<GlyphDot
tabIndex={linkURL ? -1 : 0}
onFocus={onFocus}
onBlur={onBlur}
cx={left}
cy={top}
stroke={color}
fill="var(--body-bg)"
strokeWidth={active ? 3 : 2}
r={active ? 6 : 4}
/>
</MaybeLink>
)
}

View File

@ -0,0 +1,43 @@
// We are not able to add our own classnames for visx grid/axis elements
// Because of that we have to use nested CSS selectors to override default
// styles of these elements.
.grid-line {
line {
stroke: var(--border-color-2);
stroke-width: 1;
}
}
.axis-line {
stroke: var(--border-color);
stroke-width: 1;
&--vertical {
// Hide line axis visually and hide from voice over
stroke-width: 0;
display: none;
}
}
.axis-tick {
// small tick line
line {
stroke: var(--border-color);
stroke-width: 1;
}
// tick label
text {
fill: var(--text-muted);
font-size: 0.75rem;
font-weight: 400;
}
&--vertical {
line {
// Hide line ticks visually and hide them from voice over
stroke-width: 0;
}
}
}

View File

@ -0,0 +1,73 @@
import { AxisLeft as VisxAxisLeft, AxisBottom as VisxAsixBottom } from '@visx/axis'
import { AxisScale } from '@visx/axis/lib/types'
import { GridRows } from '@visx/grid'
import { Group } from '@visx/group'
import classNames from 'classnames'
import React, { forwardRef } from 'react'
import { formatXTick, formatYTick, getXScaleTicks, getYScaleTicks } from '../../utils/ticks'
import styles from './Axis.module.scss'
import { getTickXProps, getTickYProps, Tick } from './Tick'
interface AxisLeftProps {
top: number
left: number
width: number
height: number
scale: AxisScale
}
export const AxisLeft = forwardRef<SVGGElement, AxisLeftProps>((props, reference) => {
const { scale, left, top, width, height } = props
return (
<>
<GridRows
top={top}
left={left}
width={width - left}
height={height}
scale={scale}
tickValues={getYScaleTicks({ scale, space: height })}
className={styles.gridLine}
/>
<Group innerRef={reference} top={top} left={left}>
<VisxAxisLeft
scale={scale}
tickValues={getYScaleTicks({ scale, space: height })}
tickFormat={formatYTick}
tickLabelProps={getTickYProps}
tickComponent={Tick}
axisLineClassName={classNames(styles.axisLine, styles.axisLineVertical)}
tickClassName={classNames(styles.axisTick, styles.axisTickVertical)}
/>
</Group>
</>
)
})
interface AxisBottomProps {
top: number
width: number
scale: AxisScale
}
export const AxisBottom = forwardRef<SVGGElement, AxisBottomProps>((props, reference) => {
const { scale, top, width } = props
return (
<Group innerRef={reference} top={top}>
<VisxAsixBottom
scale={scale}
tickValues={getXScaleTicks({ scale, space: width })}
tickFormat={formatXTick}
tickLabelProps={getTickXProps}
tickComponent={Tick}
axisLineClassName={styles.axisLine}
tickClassName={styles.axisTick}
/>
</Group>
)
})

View File

@ -0,0 +1,40 @@
import { bottomTickLabelProps } from '@visx/axis/lib/axis/AxisBottom'
import { leftTickLabelProps } from '@visx/axis/lib/axis/AxisLeft'
import { TickLabelProps, TickRendererProps } from '@visx/axis/lib/types'
import { Group } from '@visx/group'
import { Text } from '@visx/text'
import { TextProps } from '@visx/text/lib/Text'
import React from 'react'
import { formatXLabel } from '../../utils/ticks'
export const getTickYProps: TickLabelProps<number> = (value, index, values): Partial<TextProps> => ({
...leftTickLabelProps(),
'aria-label': `Tick axis ${index + 1} of ${values.length}. Value: ${value}`,
})
export const getTickXProps: TickLabelProps<Date> = (value, index, values): Partial<TextProps> => ({
...bottomTickLabelProps(),
'aria-label': `Tick axis ${index + 1} of ${values.length}. Value: ${formatXLabel(value)}`,
})
/**
* Tick component displays tick label for each axis line of chart.
*/
export const Tick: React.FunctionComponent<TickRendererProps> = props => {
const { formattedValue, ...tickLabelProps } = props
// Hack with Group + Text (aria hidden)
// Because the Text component renders text inside of svg element and text element with tspan
// this makes another nested group for a11y tree. To avoid "group - end group"
// phrase in voice over we hide nested children from a11y tree and put explicit aria-label
// on the parent Group element with role text
return (
// eslint-disable-next-line jsx-a11y/aria-role
<Group role="text" aria-label={tickLabelProps['aria-label']}>
<Text aria-hidden={true} {...tickLabelProps}>
{formattedValue}
</Text>
</Group>
)
}

View File

@ -0,0 +1,25 @@
.container {
width: 100%;
height: 100%;
overflow: auto;
position: relative;
}
.content {
width: 1875rem;
height: 1875rem;
display: flex;
align-items: center;
justify-content: center;
}
.target {
height: 10rem;
}
.floating {
background-color: #0b70db;
padding: 0.25rem;
border-radius: 0.24rem;
min-height: 20rem;
}

View File

@ -0,0 +1,38 @@
import { Meta } from '@storybook/react'
import React, { useState } from 'react'
import { WebStory } from '../../../../../components/WebStory'
import { FloatingPanel } from './FloatingPanel'
import styles from './FloatingPanel.story.module.scss'
export default {
title: 'views/floating-panel',
decorators: [story => <WebStory>{() => story()}</WebStory>],
} as Meta
export const FloatingPanelExample = () => {
const [buttonElement, setButtonElement] = useState<HTMLButtonElement | null>(null)
return (
<div className={styles.container}>
<div className={styles.content}>
<button className={styles.target} ref={setButtonElement}>
Hello
</button>
{buttonElement && (
<FloatingPanel
className={styles.floating}
strategy="absolute"
placement="right-end"
target={buttonElement}
>
World <br />
World <br />
</FloatingPanel>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,128 @@
import { Placement, VirtualElement, Strategy, flip } from '@floating-ui/core'
import { getScrollParents, computePosition, shift, limitShift, offset } from '@floating-ui/dom'
import React, { forwardRef, useEffect, useLayoutEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
export type Target = Element | VirtualElement
interface FloatingPanelProps extends React.HTMLAttributes<HTMLDivElement> {
target: Target
placement?: Placement
strategy?: Strategy
padding?: number
}
export function isElement(value: unknown): value is Element {
return value instanceof window.Element
}
/**
* Render floating panel element (tooltip, popover) according to target position,
* parents scroll box rectangles, floating settings (like placement and target sizes)
*/
export const FloatingPanel: React.FunctionComponent<FloatingPanelProps> = props => {
const { target, placement = 'right', strategy = 'absolute', children, padding = 10, ...otherProps } = props
const floating = useRef<HTMLDivElement>(null)
useEffect(() => {
const floatingElement = floating.current
if (!floatingElement) {
return
}
async function update(): Promise<void> {
if (!floatingElement) {
return
}
const parents = [
...(isElement(target) ? getScrollParents(target) : []),
...getScrollParents(floatingElement),
] as Element[]
const { x: xCoordinate, y: yCoordinate, middlewareData } = await computePosition(target, floatingElement, {
placement,
strategy,
middleware: [
shift({ limiter: limitShift(), boundary: parents }),
offset(padding),
flip({ boundary: parents }),
],
})
const { referenceHidden } = middlewareData.hide ?? {}
Object.assign(floatingElement.style, {
position: strategy,
top: 0,
left: 0,
visibility: referenceHidden ? 'hidden' : 'visible',
transform: `translate(${Math.round(xCoordinate ?? 0)}px,${Math.round(yCoordinate ?? 0)}px)`,
})
}
// Initial calculation on component mount
// eslint-disable-next-line @typescript-eslint/no-floating-promises
update()
const parents = [...(isElement(target) ? getScrollParents(target) : []), ...getScrollParents(floatingElement)]
for (const parent of parents) {
parent.addEventListener('scroll', update)
parent.addEventListener('resize', update)
}
return () => {
for (const parent of parents) {
parent.removeEventListener('scroll', update)
parent.removeEventListener('resize', update)
}
}
}, [floating, placement, strategy, target, padding])
return (
<FloatingPanelContent {...otherProps} portal={strategy === 'fixed'} ref={floating}>
{children}
</FloatingPanelContent>
)
}
interface FloatingPanelContentProps extends React.HTMLAttributes<HTMLDivElement> {
portal: boolean
}
const FloatingPanelContent = forwardRef<HTMLDivElement, FloatingPanelContentProps>((props, reference) => {
const { portal, children, ...otherProps } = props
const containerReference = useRef(document.createElement('div'))
// Add a container element right after the body tag
useLayoutEffect(() => {
const element = containerReference.current
if (!portal) {
return
}
document.body.append(element)
return () => {
element.remove()
}
}, [containerReference, portal])
return portal ? (
createPortal(
<div ref={reference} {...otherProps}>
{children}
</div>,
containerReference.current
)
) : (
<div ref={reference} {...otherProps}>
{children}
</div>
)
})

View File

@ -0,0 +1,25 @@
.legend-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
// Reset ul styles (bullets, paddings, margins)
list-style: none;
margin: 0;
padding: 0;
line-height: 1.2;
}
.legend-item {
display: flex;
align-items: baseline;
}
.legend-mark {
align-self: baseline;
width: 0.5rem;
height: 0.5rem;
flex-shrink: 0;
margin-right: 0.25rem;
border-radius: 50%;
}

View File

@ -0,0 +1,31 @@
import classNames from 'classnames'
import React from 'react'
import { LineChartSeries } from '../../types'
import { getLineColor } from '../../utils/colors'
import styles from './LegendList.module.scss'
interface LegendListProps {
series: LineChartSeries<any>[]
className?: string
}
export const LegendList: React.FunctionComponent<LegendListProps> = props => {
const { series, className } = props
return (
<ul className={classNames(styles.legendList, className)}>
{series.map(line => (
<li key={line.dataKey.toString()} className={styles.legendItem}>
<div
/* eslint-disable-next-line react/forbid-dom-props */
style={{ backgroundColor: getLineColor(line) }}
className={styles.legendMark}
/>
{line.name}
</li>
))}
</ul>
)
}

View File

@ -0,0 +1,46 @@
.tooltip {
pointer-events: none;
box-shadow: var(--box-shadow);
border: 1px solid var(--border-color);
color: var(--body-color);
background: var(--color-bg-1);
padding: 0.5rem;
}
.tooltip-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 12rem;
max-width: 20rem;
}
.item {
display: flex;
align-items: baseline;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-weight: normal;
font-size: 0.75rem;
}
.legend-text {
flex-grow: 1;
}
.legend-value {
margin-left: 1rem;
}
.mark {
align-self: baseline;
width: 0.5rem;
height: 0.5rem;
flex-shrink: 0;
margin-right: 0.25rem;
border-radius: 50%;
}

View File

@ -0,0 +1,147 @@
import React, { ReactElement, useEffect, useMemo, useState } from 'react'
import { isDefined } from '@sourcegraph/common'
import { LineChartSeries, Point } from '../../types'
import { isValidNumber } from '../../utils/data-guards'
import { formatYTick } from '../../utils/ticks'
import { FloatingPanel, Target } from '../floating-panel/FloatingPanel'
import styles from './Tooltip.module.scss'
import { getListWindow } from './utils/get-list-window'
/**
* Default value for line color in case if we didn't get color for line from content config.
*/
export const DEFAULT_LINE_STROKE = 'var(--gray-07)'
export const getLineStroke = <Datum extends object>(line: LineChartSeries<Datum>): string =>
line?.color ?? DEFAULT_LINE_STROKE
interface TooltipProps {
reference?: Target
}
export const Tooltip: React.FunctionComponent<TooltipProps> = props => {
const { reference } = props
const [virtualElement, setVirtualElement] = useState<Target>()
useEffect(() => {
function handleMove(event: PointerEvent): void {
setVirtualElement({
getBoundingClientRect: () => ({
width: 0,
height: 0,
x: event.clientX,
y: event.clientY,
top: event.clientY,
left: event.clientX,
right: event.clientX,
bottom: event.clientY,
}),
})
}
window.addEventListener('pointermove', handleMove)
return () => {
window.removeEventListener('pointermove', handleMove)
}
}, [])
useEffect(() => {
if (!reference) {
return
}
setVirtualElement(reference)
}, [reference])
if (!virtualElement) {
return null
}
return (
<FloatingPanel className={styles.tooltip} target={virtualElement} strategy="fixed" placement="right-start">
{props.children}
</FloatingPanel>
)
}
const MAX_ITEMS_IN_TOOLTIP = 10
export interface TooltipContentProps<Datum extends object> {
series: LineChartSeries<Datum>[]
data: Datum[]
activePoint: Point
xAxisKey: keyof Datum
}
/**
* Display tooltip content for XYChart.
* It consists of title - datetime for current x point and list of all nearest y points.
*/
export function TooltipContent<Datum extends object>(props: TooltipContentProps<Datum>): ReactElement | null {
const { data, activePoint, series, xAxisKey } = props
const lines = useMemo(() => {
if (!activePoint) {
return { window: [], leftRemaining: 0, rightRemaining: 0 }
}
const currentDatum = data[activePoint.index]
const sortedSeries = [...series]
.map(line => {
const value = currentDatum[line.dataKey]
if (!isValidNumber(value)) {
return
}
return { ...line, value }
})
.filter(isDefined)
.sort((lineA, lineB) => lineB.value - lineA.value)
// Find index of hovered point
const hoveredSeriesIndex = sortedSeries.findIndex(line => line.dataKey === activePoint.seriesKey)
// Normalize index of hovered point
const centerIndex = hoveredSeriesIndex !== -1 ? hoveredSeriesIndex : Math.floor(sortedSeries.length / 2)
return getListWindow(sortedSeries, centerIndex, MAX_ITEMS_IN_TOOLTIP)
}, [series, activePoint, data])
const dateString = new Date(+data[activePoint.index][xAxisKey]).toDateString()
return (
<>
<h3>{dateString}</h3>
<ul className={styles.tooltipList}>
{lines.leftRemaining > 0 && <li className={styles.item}>... and {lines.leftRemaining} more</li>}
{lines.window.map(line => {
const value = formatYTick(line.value)
const datumKey = activePoint.seriesKey
const backgroundColor = datumKey === line.dataKey ? 'var(--secondary-2)' : ''
/* eslint-disable react/forbid-dom-props */
return (
<li key={line.dataKey as string} className={styles.item} style={{ backgroundColor }}>
<div style={{ backgroundColor: getLineStroke(line) }} className={styles.mark} />
<span className={styles.legendText}>{line?.name ?? 'unknown series'}</span>
<span className={styles.legendValue}>
{' '}
{value === null || Number.isNaN(value) ? '' : value}{' '}
</span>
</li>
)
})}
{lines.rightRemaining > 0 && <li className={styles.item}>... and {lines.rightRemaining} more</li>}
</ul>
</>
)
}

View File

@ -0,0 +1,37 @@
import expect from 'expect'
import { getListWindow } from './get-list-window'
describe('getListWindow', () => {
it('should return unmodified list in case if list has less element than size', () => {
expect(getListWindow([1, 2, 3, 4, 5], 2, 10)).toStrictEqual({
window: [1, 2, 3, 4, 5],
leftRemaining: 0,
rightRemaining: 0,
})
})
it('should return correct window if list has enough items from both sides around pivot index', () => {
expect(getListWindow([1, 2, 3, 4, 5], 2, 3)).toStrictEqual({
window: [2, 3, 4],
leftRemaining: 1,
rightRemaining: 1,
})
})
it('should return correct window if list has enough items only at right side around pivot index', () => {
expect(getListWindow([1, 2, 3, 4, 5], 1, 4)).toStrictEqual({
window: [1, 2, 3, 4],
leftRemaining: 0,
rightRemaining: 1,
})
})
it('should return correct window if list has enough items only at left side around pivot index', () => {
expect(getListWindow([1, 2, 3, 4, 5], 3, 4)).toStrictEqual({
window: [2, 3, 4, 5],
leftRemaining: 1,
rightRemaining: 0,
})
})
})

View File

@ -0,0 +1,52 @@
export interface ListWindow<T> {
window: T[]
leftRemaining: number
rightRemaining: number
}
/**
* Return window (sub-list) around point with {@link index} and with size
* equals to {@link size}.
*
* Example:
* list - 1,2,3,4,5,6,7 index - 3, size - 3
* result 3,4,5 because 1,2, * 3, 4, 5 * 6, 7
* remaining left 2 (1,2) remaining right 2 (6, 7)
*/
export function getListWindow<T>(list: T[], index: number, size: number): ListWindow<T> {
if (list.length < size) {
return { window: list, leftRemaining: 0, rightRemaining: 0 }
}
let left = index
let right = index
const window = [list[index]]
while (window.length < size) {
const nextLeft = left - 1
const leftElement = list[nextLeft]
if (leftElement) {
left--
window.unshift(leftElement)
}
const nextRight = right + 1
const rightElement = list[nextRight]
if (rightElement) {
right++
window.push(rightElement)
}
if (!leftElement && !rightElement) {
break
}
}
return {
window,
leftRemaining: Math.max(left, 0),
rightRemaining: Math.max(list.length - 1 - right, 0),
}
}

View File

@ -0,0 +1,54 @@
import { localPoint } from '@visx/event'
import { Point } from '@visx/point'
import { MouseEvent, MouseEventHandler, PointerEventHandler } from 'react'
interface UseChartEventHandlersProps {
onPointerMove: (point: Point) => void
onPointerLeave: () => void
onClick: MouseEventHandler<SVGSVGElement>
}
interface ChartHandlers {
onPointerMove: PointerEventHandler<SVGSVGElement>
onPointerLeave: PointerEventHandler<SVGSVGElement>
onClick: MouseEventHandler<SVGSVGElement>
}
/**
* Provides special svg|chart-specific handlers for mouse/touch events.
*/
export function useChartEventHandlers(props: UseChartEventHandlersProps): ChartHandlers {
const { onPointerMove, onPointerLeave, onClick } = props
const handleMouseMove: MouseEventHandler<SVGGElement> = event => {
const point = localPoint(event.currentTarget, event)
if (!point) {
return
}
onPointerMove(point)
}
const handleMouseOut = (event: MouseEvent): void => {
let relatedTarget = event.relatedTarget as Element
while (relatedTarget) {
// go up the parent chain and check if we're still inside currentElem
// then that's an internal transition ignore it
if (relatedTarget === event.currentTarget) {
return
}
relatedTarget = relatedTarget?.parentNode as Element
}
onPointerLeave()
}
return {
onPointerMove: handleMouseMove,
onPointerLeave: handleMouseOut,
onClick,
}
}

View File

@ -0,0 +1,3 @@
export { ParentSize } from '@visx/responsive'
export { LineChart } from './LineChart'
export { LegendList } from './components/legend-list/LegendList'

View File

@ -0,0 +1,31 @@
export interface LineChartSeries<D> {
/**
* The key in each data object for the values this line should be
* calculated from.
*/
dataKey: keyof D
/**
* Links for data series points. Note that for points that don't have
* values for the series this list still should have an undefined URL
* in order to keep the linkURLs array and the common data array equal by length.
*/
linkURLs?: string[]
/** The name of the line shown in the legend and tooltip. */
name?: string
/** The CSS color of the line. */
color?: string
}
export interface Point {
id: string
seriesKey: string
index: number
value: number
color: string
x: number
y: number
linkUrl?: string
}

View File

@ -0,0 +1,10 @@
import { LineChartSeries } from '../types'
/**
* Default value for line color in case if we didn't get color for line from content config.
*/
export const DEFAULT_LINE_STROKE = 'var(--gray-07)'
export function getLineColor(series: LineChartSeries<any>): string {
return series.color ?? DEFAULT_LINE_STROKE
}

View File

@ -0,0 +1,3 @@
export function isValidNumber(value: unknown): value is number {
return value !== null && typeof value === 'number' && !Number.isNaN(value) && Number.isFinite(value)
}

View File

@ -0,0 +1,80 @@
import { LineChartSeries } from '../types'
import { isValidNumber } from './data-guards'
export interface LineChartSeriesWithData<Datum> extends LineChartSeries<Datum> {
data: Datum[]
}
interface SeriesWithDataInput<Datum extends object> {
data: Datum[]
series: LineChartSeries<Datum>[]
}
/**
* Processes data list (sort it) and extracts data from the datum object and merge it with
* series (line) data.
*
* Example:
* ```
* data: [
* { a: 2, b: 3, x: 2},
* { a: 4, b: 5, x: 3},
* { a: null, b: 6, x: 4}
* ] series: [
* { name: a, data: [{ y: 2, x: 2 }, { y: 4, x: 3 }],
* { name: b, data: [{ y: 3, x: 2 }, { y: 5, x: 3 }, { y: 6, x: 4 }]
* ]
* ```
*/
export function getSeriesWithData<Datum extends object>(
input: SeriesWithDataInput<Datum>
): LineChartSeriesWithData<Datum>[] {
const { data, series } = input
return (
series
// Separate datum object by series lines
.map<LineChartSeriesWithData<Datum>>(line => ({
...line,
// Filter select series data from the datum object and process this points array
data: getFilteredSeriesData(data, datum => datum[line.dataKey]),
}))
)
}
/**
* Filters series data list, preserves null value at the beginning of the series data list
* and removes null value between the points.
*
* ```
* Null value Real point Null value Real point
*
*
*
*
*
*
*
*
*
*
*
*```
*/
function getFilteredSeriesData<Datum>(data: Datum[], yAccessor: (d: Datum) => unknown): Datum[] {
const firstNonNullablePointIndex = Math.max(
data.findIndex(datum => isValidNumber(yAccessor(datum))),
0
)
// Preserve null values at the beginning of the series data list
// but remove null holes between the points further.
const nullBeginningValues = data.slice(0, firstNonNullablePointIndex)
const pointsWithoutHoles = data
// Get values after null area
.slice(firstNonNullablePointIndex)
.filter(datum => isValidNumber(yAccessor(datum)))
return [...nullBeginningValues, ...pointsWithoutHoles]
}

View File

@ -0,0 +1,32 @@
import { ScaleLinear, ScaleTime } from 'd3-scale'
import { LineChartSeries, Point } from '../types'
import { isValidNumber } from './data-guards'
interface PointsFieldInput<Datum extends object> {
data: Datum[]
series: LineChartSeries<Datum>[]
xScale: ScaleTime<number, number>
yScale: ScaleLinear<number, number>
xAxisKey: keyof Datum
}
export function generatePointsField<Datum extends object>(input: PointsFieldInput<Datum>): Point[] {
const { data, series, xScale, yScale, xAxisKey } = input
return data.flatMap((datum, index) =>
series
.filter(line => isValidNumber(datum[line.dataKey]))
.map<Point>(line => ({
id: `${line.dataKey as string}-${index}`,
seriesKey: line.dataKey as string,
value: +datum[line.dataKey],
x: xScale(+datum[xAxisKey]),
y: yScale(+datum[line.dataKey]),
color: line.color ?? 'green',
linkUrl: line.linkURLs?.[index],
index,
}))
)
}

View File

@ -0,0 +1,36 @@
import { Optional } from 'utility-types'
interface GetChartContentSizesInput {
width: number
height: number
margin?: Optional<Margin>
}
interface Margin {
top: number
right: number
bottom: number
left: number
}
interface ChartContentSizes {
width: number
height: number
margin: Margin
}
export function getChartContentSizes(input: GetChartContentSizesInput): ChartContentSizes {
const { width, height, margin = {} } = input
const { top, left, bottom, right } = {
top: margin.top ?? 0,
right: margin.right ?? 0,
bottom: margin.bottom ?? 0,
left: margin.left ?? 0,
}
return {
width: width - left - right,
height: height - top - bottom,
margin: { top, left, bottom, right },
}
}

View File

@ -0,0 +1,47 @@
import { LineChartSeries } from '../types'
interface MinMaxBoundariesInput<D extends object> {
data: D[]
series: LineChartSeries<D>[]
xAxisKey: keyof D
}
interface Boundaries {
minX: number
minY: number
maxX: number
maxY: number
}
/**
* Calculates min/max ranges for Y (across all available data series) and X axis
* (time interval) global for all lines on the chart.
*/
export function getMinMaxBoundaries<D extends object>(props: MinMaxBoundariesInput<D>): Boundaries {
const { data, series, xAxisKey } = props
let minX
let maxX
let minY
let maxY
for (const datum of data) {
minX = Math.min(+datum[xAxisKey], minX ?? +datum[xAxisKey])
maxX = Math.max(+datum[xAxisKey], maxX ?? +datum[xAxisKey])
for (const line of series) {
minY ??= +datum[line.dataKey]
maxY ??= +datum[line.dataKey]
minY = Math.min(+datum[line.dataKey], minY)
maxY = Math.max(+datum[line.dataKey], maxY)
}
}
;[minY, maxY, minX, maxX] = [minY ?? 0, maxY ?? 0, minX ?? 0, maxX ?? 0]
// Expand range for better ticks looking in case if we got a flat data series dataset
;[minY, maxY] = minY === maxY ? [maxY - maxY / 2, maxY + maxY / 2] : [minY, maxY]
return { minX, minY, maxX, maxY }
}

View File

@ -0,0 +1,98 @@
import { getTicks } from '@visx/scale'
import { AnyD3Scale } from '@visx/scale/lib/types/Scale'
import { format } from 'd3-format'
import { timeFormat } from 'd3-time-format'
const SI_PREFIX_FORMATTER = format('~s')
export function formatYTick(number: number): string {
// D3 formatter doesn't support float numbers properly
if (!Number.isInteger(number)) {
return number.toString()
}
return SI_PREFIX_FORMATTER(number)
}
/**
* Returns a formatted time text. It's used primary for X axis tick's text nodes.
* Number of month day + short name of month.
*
* Example: 01 Jan, 12 Feb, ...
*/
export const formatXTick = timeFormat('%d %b')
/**
* Returns a formatted date text for points aria labels.
*
* Example: 2021 January 21 Thursday
*/
export const formatXLabel = timeFormat('%d %B %A')
const MINIMUM_NUMBER_OF_TICKS = 2
interface GetScaleTicksInput {
scale: AnyD3Scale
space: number
pixelsPerTick?: number
}
export function getXScaleTicks(input: GetScaleTicksInput): number[] {
const { scale, space, pixelsPerTick = 80 } = input
const maxTicks = Math.max(Math.floor(space / pixelsPerTick), MINIMUM_NUMBER_OF_TICKS)
return getTicks(scale, maxTicks) as number[]
}
/**
* Returns list of not formatted (raw) Y axis ticks.
* Example: 1000, 1500, 2000, ...
*
* Number of lines (ticks) is based on chart height value and our expectation
* around label density on the chart (no more than 1 tick in each 40px, see
* HEIGHT_PER_TICK const)
*/
export function getYScaleTicks(input: GetScaleTicksInput): number[] {
const { scale, space, pixelsPerTick = 40 } = input
// Generate max density ticks (d3 scale generation)
const ticks = getTicks(scale) as number[]
if (ticks.length <= 2) {
return ticks
}
// Calculate desirable number of ticks
const numberTicks = Math.max(MINIMUM_NUMBER_OF_TICKS, Math.floor(space / pixelsPerTick))
let filteredTicks = ticks
while (filteredTicks.length > numberTicks) {
filteredTicks = getHalvedTicks(filteredTicks)
}
return filteredTicks
}
/**
* Cut off half of tick elements from the list based on
* original number of ticks. With odd number of original ticks
* removes all even index ticks with even number removes all
* odd index ticks.
*/
function getHalvedTicks(ticks: number[]): number[] {
const isOriginTickLengthOdd = !(ticks.length % 2)
const filteredTicks = []
for (let index = ticks.length; index >= 1; index--) {
if (isOriginTickLengthOdd) {
if (index % 2 === 0) {
filteredTicks.unshift(ticks[index - 1])
}
} else if (index % 2) {
filteredTicks.unshift(ticks[index - 1])
}
}
return filteredTicks
}

View File

@ -0,0 +1 @@
export * from './components/line-chart'

View File

@ -327,6 +327,8 @@
},
"dependencies": {
"@apollo/client": "3.4.7",
"@floating-ui/dom": "^0.1.8",
"@floating-ui/react-dom": "^0.3.4",
"@reach/accordion": "^0.10.2",
"@reach/combobox": "^0.15.2",
"@reach/dialog": "^0.11.2",

View File

@ -1701,6 +1701,26 @@
dependencies:
"@figspec/components" "^1.0.0"
"@floating-ui/core@^0.3.0":
version "0.3.1"
resolved "https://registry.npmjs.org/@floating-ui/core/-/core-0.3.1.tgz#3dde0ad0724d4b730567c92f49f0950910e18871"
integrity sha512-ensKY7Ub59u16qsVIFEo2hwTCqZ/r9oZZFh51ivcLGHfUwTn8l1Xzng8RJUe91H/UP8PeqeBronAGx0qmzwk2g==
"@floating-ui/dom@^0.1.8":
version "0.1.10"
resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-0.1.10.tgz#ce304136a52c71ef157826d2ebf52d68fa2deed5"
integrity sha512-4kAVoogvQm2N0XE0G6APQJuCNuErjOfPW8Ux7DFxh8+AfugWflwVJ5LDlHOwrwut7z/30NUvdtHzQ3zSip4EzQ==
dependencies:
"@floating-ui/core" "^0.3.0"
"@floating-ui/react-dom@^0.3.4":
version "0.3.4"
resolved "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-0.3.4.tgz#7dc7c09a4f645308521bcf2a58c45b4e9aa846d4"
integrity sha512-/rnS+pXHLQC+q/koZD8f2O1utyJJCAzOvhxDfKBUUiEzVZmZE0ryzmpIcvs+3zUudpv8xP7cmWWSRi7Y1DsSdg==
dependencies:
"@floating-ui/dom" "^0.1.8"
use-isomorphic-layout-effect "^1.1.1"
"@gql2ts/from-query@^1.9.0":
version "1.9.0"
resolved "https://registry.npmjs.org/@gql2ts/from-query/-/from-query-1.9.0.tgz#ae92a4fa3df005df57eb835b371d7964644b9beb"
@ -23323,10 +23343,10 @@ use-deep-compare-effect@^1.6.1:
"@types/react" "^17.0.0"
dequal "^2.0.2"
use-isomorphic-layout-effect@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.0.0.tgz#f56b4ed633e1c21cd9fc76fe249002a1c28989fb"
integrity sha512-JMwJ7Vd86NwAt1jH7q+OIozZSIxA4ND0fx6AsOe2q1H8ooBUp5aN6DvVCqZiIaYU6JaMRJGyR0FO7EBCIsb/Rg==
use-isomorphic-layout-effect@^1.0.0, use-isomorphic-layout-effect@^1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.1.tgz#7bb6589170cd2987a152042f9084f9effb75c225"
integrity sha512-L7Evj8FGcwo/wpbv/qvSfrkHFtOpCzvM5yl2KVyDJoylVuSvzphiiasmjgQPttIGBAy2WKiBNR98q8w7PiNgKQ==
use-latest@^1.0.0:
version "1.1.0"