mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 17:31:43 +00:00
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:
parent
909ada54a2
commit
7db7d39634
@ -0,0 +1,7 @@
|
||||
.root {
|
||||
pointer-events: bounding-box;
|
||||
|
||||
&--with-hovered-link-point {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
341
client/web/src/charts/components/line-chart/LineChart.story.tsx
Normal file
341
client/web/src/charts/components/line-chart/LineChart.story.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
202
client/web/src/charts/components/line-chart/LineChart.tsx
Normal file
202
client/web/src/charts/components/line-chart/LineChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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})`} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
})
|
||||
@ -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%;
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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%;
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
3
client/web/src/charts/components/line-chart/index.ts
Normal file
3
client/web/src/charts/components/line-chart/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { ParentSize } from '@visx/responsive'
|
||||
export { LineChart } from './LineChart'
|
||||
export { LegendList } from './components/legend-list/LegendList'
|
||||
31
client/web/src/charts/components/line-chart/types.ts
Normal file
31
client/web/src/charts/components/line-chart/types.ts
Normal 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
|
||||
}
|
||||
10
client/web/src/charts/components/line-chart/utils/colors.ts
Normal file
10
client/web/src/charts/components/line-chart/utils/colors.ts
Normal 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
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export function isValidNumber(value: unknown): value is number {
|
||||
return value !== null && typeof value === 'number' && !Number.isNaN(value) && Number.isFinite(value)
|
||||
}
|
||||
@ -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]
|
||||
}
|
||||
@ -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,
|
||||
}))
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
98
client/web/src/charts/components/line-chart/utils/ticks.ts
Normal file
98
client/web/src/charts/components/line-chart/utils/ticks.ts
Normal 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
|
||||
}
|
||||
1
client/web/src/charts/index.ts
Normal file
1
client/web/src/charts/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './components/line-chart'
|
||||
@ -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",
|
||||
|
||||
28
yarn.lock
28
yarn.lock
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user