Web [Charts]: Add experimental auto axis components (#39805)

* Polish SVGRoot components API

* Adjust truncation logic (change core components API)

* Fix line chart according to the new core components API

* Migrate BarChart component to the new core components abstractions

* Fix ts problems with scales and elements props

* Fix PR review comments (legacy styles, comment out lines, storybook stories code style)

* Turn on snapshots for axis demo
This commit is contained in:
Vova Kulikov 2022-08-08 16:35:15 +03:00 committed by GitHub
parent 6e1937d673
commit df62e03eb3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 643 additions and 219 deletions

View File

@ -1,18 +1,14 @@
import { ReactElement, SVGProps, useMemo, useRef, useState } from 'react'
import { ReactElement, SVGProps, useMemo } from 'react'
import { scaleBand, scaleLinear } from '@visx/scale'
import classNames from 'classnames'
import { ScaleBand } from 'd3-scale'
import { noop } from 'lodash'
import { AxisBottom, AxisLeft, getChartContentSizes, Tooltip } from '../../core'
import { SvgAxisBottom, SvgAxisLeft, SvgContent, SvgRoot } from '../../core/components/SvgRoot'
import { CategoricalLikeChart } from '../../types'
import { GroupedBars } from './components/GroupedBars'
import { StackedBars } from './components/StackedBars'
import { BarTooltipContent } from './components/TooltipContent'
import { Category, getGroupedCategories } from './utils/get-grouped-categories'
import styles from './BarChart.module.scss'
import { BarChartContent } from './BarChartContent'
import { getGroupedCategories } from './utils/get-grouped-categories'
const DEFAULT_LINK_GETTER = (): null => null
@ -23,18 +19,12 @@ interface BarChartProps<Datum> extends CategoricalLikeChart<Datum>, SVGProps<SVG
getCategory?: (datum: Datum) => string | undefined
}
interface ActiveSegment<Datum> {
category: Category<Datum>
datum: Datum
}
export function BarChart<Datum>(props: BarChartProps<Datum>): ReactElement {
const {
width: outerWidth,
height: outerHeight,
data,
stacked = false,
className,
getDatumName,
getDatumValue,
getDatumColor,
@ -44,26 +34,6 @@ export function BarChart<Datum>(props: BarChartProps<Datum>): ReactElement {
...attributes
} = props
const rootRef = useRef(null)
const [yAxisElement, setYAxisElement] = useState<SVGGElement | null>(null)
const [xAxisReference, setXAxisElement] = useState<SVGGElement | null>(null)
const [activeSegment, setActiveSegment] = useState<ActiveSegment<Datum> | null>(null)
const content = useMemo(
() =>
getChartContentSizes({
width: outerWidth,
height: outerHeight,
margin: {
top: 16,
right: 16,
left: yAxisElement?.getBBox().width,
bottom: xAxisReference?.getBBox().height,
},
}),
[yAxisElement, xAxisReference, outerWidth, outerHeight]
)
const categories = useMemo(
() => getGroupedCategories({ data, stacked, getCategory, getDatumName, getDatumValue }),
[data, stacked, getCategory, getDatumName, getDatumValue]
@ -73,19 +43,17 @@ export function BarChart<Datum>(props: BarChartProps<Datum>): ReactElement {
() =>
scaleBand<string>({
domain: categories.map(category => category.id),
range: [0, content.width],
padding: 0.2,
}),
[content, categories]
[categories]
)
const yScale = useMemo(
() =>
scaleLinear<number>({
domain: [0, Math.max(...categories.map(category => category.maxValue))],
range: [content.height, 0],
}),
[content, categories]
[categories]
)
const handleBarClick = (datum: Datum): void => {
@ -98,78 +66,32 @@ export function BarChart<Datum>(props: BarChartProps<Datum>): ReactElement {
onDatumLinkClick(datum)
}
const withActiveLink = activeSegment?.datum ? getDatumLink(activeSegment?.datum) : null
return (
<svg
ref={rootRef}
width={outerWidth}
height={outerHeight}
{...attributes}
className={classNames(className, styles.root, { [styles.rootWithHoveredLinkPoint]: withActiveLink })}
>
<AxisLeft
ref={setYAxisElement}
scale={yScale}
width={content.width}
height={content.height}
top={content.top}
left={content.left}
/>
<SvgRoot {...attributes} width={outerWidth} height={outerHeight} xScale={xScale} yScale={yScale}>
<SvgAxisLeft />
<SvgAxisBottom />
<AxisBottom
ref={setXAxisElement}
scale={xScale}
width={content.width}
top={content.bottom}
left={content.left}
/>
{stacked ? (
<StackedBars
categories={categories}
xScale={xScale}
yScale={yScale}
getDatumName={getDatumName}
getDatumValue={getDatumValue}
getDatumColor={getDatumColor}
left={content.left}
top={content.top}
height={content.height}
onBarHover={(datum, category) => setActiveSegment({ datum, category })}
onBarLeave={() => setActiveSegment(null)}
onBarClick={handleBarClick}
/>
) : (
<GroupedBars
categories={categories}
xScale={xScale}
yScale={yScale}
getDatumName={getDatumName}
getDatumValue={getDatumValue}
getDatumColor={getDatumColor}
getDatumLink={getDatumLink}
left={content.left}
top={content.top}
height={content.height}
width={content.width}
onBarHover={(datum, category) => setActiveSegment({ datum, category })}
onBarLeave={() => setActiveSegment(null)}
onBarClick={handleBarClick}
/>
)}
{activeSegment && rootRef.current && (
<Tooltip containerElement={rootRef.current}>
<BarTooltipContent
category={activeSegment.category}
activeBar={activeSegment.datum}
getDatumColor={getDatumColor}
getDatumValue={getDatumValue}
<SvgContent<ScaleBand<string>, any>>
{({ yScale, xScale, content }) => (
<BarChartContent<Datum>
// Visx axis interfaces doesn't support scaleLiner scale in
// axisScale interface
yScale={yScale}
xScale={xScale}
width={content.width}
height={content.height}
top={content.top}
left={content.left}
stacked={stacked}
categories={categories}
getDatumName={getDatumName}
getDatumValue={getDatumValue}
getDatumColor={getDatumColor}
getDatumLink={getDatumLink}
onBarClick={handleBarClick}
/>
</Tooltip>
)}
</svg>
)}
</SvgContent>
</SvgRoot>
)
}

View File

@ -0,0 +1,110 @@
import { ReactElement, SVGProps, useRef, useState } from 'react'
import { Group } from '@visx/group'
import classNames from 'classnames'
import { ScaleBand, ScaleLinear } from 'd3-scale'
import { Tooltip } from '../../core'
import { GroupedBars } from './components/GroupedBars'
import { StackedBars } from './components/StackedBars'
import { BarTooltipContent } from './components/TooltipContent'
import { Category } from './utils/get-grouped-categories'
import styles from './BarChartContent.module.scss'
interface ActiveSegment<Datum> {
category: Category<Datum>
datum: Datum
}
interface BarChartContentProps<Datum> extends SVGProps<SVGGElement> {
stacked: boolean
top: number
left: number
xScale: ScaleBand<string>
yScale: ScaleLinear<number, number>
categories: Category<Datum>[]
getDatumName: (datum: Datum) => string
getDatumValue: (datum: Datum) => number
getDatumColor: (datum: Datum) => string | undefined
getDatumLink: (datum: Datum) => string | undefined | null
onBarClick: (datum: Datum) => void
}
export function BarChartContent<Datum>(props: BarChartContentProps<Datum>): ReactElement {
const {
xScale,
yScale,
categories,
stacked,
top,
left,
width = 0,
height = 0,
getDatumName,
getDatumValue,
getDatumColor,
getDatumLink,
onBarClick,
...attributes
} = props
const rootRef = useRef<SVGGElement>(null)
const [activeSegment, setActiveSegment] = useState<ActiveSegment<Datum> | null>(null)
const withActiveLink = activeSegment?.datum ? getDatumLink(activeSegment?.datum) : null
return (
<Group
{...attributes}
innerRef={rootRef}
className={classNames(styles.root, { [styles.rootWithHoveredLinkPoint]: withActiveLink })}
>
{stacked ? (
<StackedBars
categories={categories}
xScale={xScale}
yScale={yScale}
getDatumName={getDatumName}
getDatumValue={getDatumValue}
getDatumColor={getDatumColor}
height={+height}
onBarHover={(datum, category) => setActiveSegment({ datum, category })}
onBarLeave={() => setActiveSegment(null)}
onBarClick={onBarClick}
/>
) : (
<GroupedBars
categories={categories}
xScale={xScale}
yScale={yScale}
getDatumName={getDatumName}
getDatumValue={getDatumValue}
getDatumColor={getDatumColor}
getDatumLink={getDatumLink}
height={+height}
width={+width}
onBarHover={(datum, category) => setActiveSegment({ datum, category })}
onBarLeave={() => setActiveSegment(null)}
onBarClick={onBarClick}
/>
)}
{activeSegment && rootRef.current && (
<Tooltip containerElement={rootRef.current}>
<BarTooltipContent
category={activeSegment.category}
activeBar={activeSegment.datum}
getDatumColor={getDatumColor}
getDatumValue={getDatumValue}
getDatumName={getDatumName}
/>
</Tooltip>
)}
</Group>
)
}

View File

@ -1,14 +1,15 @@
import { ReactElement, useMemo, useState, SVGProps, CSSProperties, useRef } from 'react'
import { AxisScale, TickFormatter } from '@visx/axis/lib/types'
import { Group } from '@visx/group'
import { scaleTime, scaleLinear } from '@visx/scale'
import { scaleTime, scaleLinear, getTicks } from '@visx/scale'
import { AnyD3Scale } from '@visx/scale/lib/types/Scale'
import { LinePath } from '@visx/shape'
import { voronoi } from '@visx/voronoi'
import classNames from 'classnames'
import { noop } from 'lodash'
import { AxisLeft, AxisBottom } from '../../core'
import { formatDateTick } from '../../core/components/axis/tick-formatters'
import { SeriesLikeChart } from '../../types'
import { Tooltip, TooltipContent, PointGlyph } from './components'
@ -24,7 +25,6 @@ import {
getChartContentSizes,
getMinMaxBoundaries,
SeriesWithData,
formatXTick,
} from './utils'
import styles from './LineChart.module.scss'
@ -67,6 +67,21 @@ const sortByDataKey = (dataKey: string | number | symbol, activeDataKey: string)
const identity = <T,>(argument: T): T => argument
interface GetScaleTicksInput {
scale: AnyD3Scale
space: number
pixelsPerTick?: number
}
export function getXScaleTicks<T>(input: GetScaleTicksInput): T[] {
const { scale, space, pixelsPerTick = 80 } = input
// Calculate desirable number of ticks
const numberTicks = Math.max(2, Math.floor(space / pixelsPerTick))
return getTicks(scale, numberTicks) as T[]
}
/**
* Visual component that renders svg line chart with pre-defined sizes, tooltip,
* voronoi area distribution.
@ -211,7 +226,8 @@ export function LineChart<D>(props: LineChartProps<D>): ReactElement | null {
width={content.width}
top={content.bottom}
left={content.left}
tickFormat={(formatXTick as unknown) as TickFormatter<AxisScale>}
tickValues={getXScaleTicks({ scale: xScale, space: content.width })}
tickFormat={formatDateTick}
/>
<Group top={content.top} left={content.left}>

View File

@ -4,8 +4,9 @@ import { isDefined } from '@sourcegraph/common'
import { H3 } from '@sourcegraph/wildcard'
import { TooltipList, TooltipListBlankItem, TooltipListItem } from '../../../../core'
import { formatDateTick } from '../../../../core/components/axis/tick-formatters'
import { Point } from '../../types'
import { isValidNumber, formatYTick, SeriesWithData, SeriesDatum, getDatumValue, getLineColor } from '../../utils'
import { isValidNumber, SeriesWithData, SeriesDatum, getDatumValue, getLineColor } from '../../utils'
import { getListWindow } from './utils/get-list-window'
@ -66,9 +67,10 @@ export function TooltipContent<Datum>(props: TooltipContentProps<Datum>): ReactE
<TooltipListBlankItem>... and {lines.leftRemaining} more</TooltipListBlankItem>
)}
{lines.window.map(line => {
const value = formatYTick(line.value)
const value = formatDateTick((line.value as unknown) as Date)
const isActiveLine = activePoint.seriesId === line.id
const stackedValue = isActiveLine && stacked ? formatYTick(activePoint.value) : null
const stackedValue =
isActiveLine && stacked ? formatDateTick((activePoint.value as unknown) as Date) : null
return (
<TooltipListItem

View File

@ -3,5 +3,5 @@ export { isValidNumber } from './data-guards'
export { generatePointsField } from './generate-points-field'
export { getChartContentSizes } from '../../../core/utils/get-chart-content-sizes'
export { getMinMaxBoundaries } from './get-min-max-boundary'
export { formatYTick, formatXTick, formatXLabel, getYScaleTicks, getXScaleTicks } from './ticks'
export * from './data-series-processing'

View File

@ -0,0 +1,74 @@
import { boolean } from '@storybook/addon-knobs'
import { Meta, Story } from '@storybook/react'
import { AxisScale } from '@visx/axis/lib/types'
import { ParentSize } from '@visx/responsive'
import { scaleBand, scaleLinear, scaleTime } from '@visx/scale'
import { WebStory } from '../../../components/WebStory'
import { formatDateTick } from './axis/tick-formatters'
import { SvgRoot, SvgAxisLeft, SvgAxisBottom, SvgContent } from './SvgRoot'
const StoryConfig: Meta = {
title: 'web/charts/core/axis',
decorators: [story => <WebStory>{() => story()}</WebStory>],
parameters: { chromatic: { disableSnapshots: false } },
}
export default StoryConfig
interface TemplateProps {
xScale: AxisScale
yScale: AxisScale
pixelsPerXTick?: number
formatXLabel?: (value: any) => string
color?: string
}
const SimpleChartTemplate: Story<TemplateProps> = args => (
<ParentSize style={{ width: 400, height: 400 }} debounceTime={0} className="flex-shrink-0">
{parent => (
<SvgRoot width={parent.width} height={parent.height} xScale={args.xScale} yScale={args.yScale}>
<SvgAxisLeft />
<SvgAxisBottom tickFormat={args.formatXLabel} pixelsPerTick={args.pixelsPerXTick} />
<SvgContent>
{({ content }) => <rect fill={args.color} width={content.width} height={content.height} />}
</SvgContent>
</SvgRoot>
)}
</ParentSize>
)
export const MainAxisDemo: Story = () => (
<section style={{ display: 'flex', flexWrap: 'wrap', gap: 20 }}>
<SimpleChartTemplate
xScale={scaleTime<number>({
domain: [new Date(2022, 8, 22), new Date(2022, 10, 22)],
nice: true,
clamp: true,
})}
yScale={scaleLinear({
domain: [0, boolean('useMaxValuesForYScale', false) ? 1000000000000000000000000000000000000 : 10000],
nice: true,
clamp: true,
})}
formatXLabel={formatDateTick}
pixelsPerXTick={20}
color="darkslateblue"
/>
<SimpleChartTemplate
xScale={scaleBand<string>({
domain: ['hello', 'worlddddddddddd', 'this', 'is', 'rotation', 'speaking'],
padding: 0.2,
})}
yScale={scaleLinear({
domain: [0, boolean('useMaxValuesForYScale', false) ? 1000000000000000000000000000000000000 : 10000],
nice: true,
clamp: true,
})}
color="pink"
/>
</section>
)

View File

@ -0,0 +1,240 @@
import {
createContext,
Dispatch,
FC,
PropsWithChildren,
ReactElement,
ReactNode,
SetStateAction,
SVGProps,
useContext,
useMemo,
useRef,
useState,
} from 'react'
import { AxisScale, TickRendererProps } from '@visx/axis'
import { Group } from '@visx/group'
import { scaleLinear } from '@visx/scale'
import { noop } from 'lodash'
import { useMergeRefs } from 'use-callback-ref'
import useResizeObserver from 'use-resize-observer'
import { createRectangle, EMPTY_RECTANGLE, Rectangle } from '@sourcegraph/wildcard'
import { AxisBottom, AxisLeft } from './axis/Axis'
import { getMaxTickWidth, Tick, TickProps } from './axis/Tick'
import { getXScaleTicks } from './axis/tick-formatters'
const DEFAULT_PADDING = { top: 16, right: 36, bottom: 0, left: 0 }
interface Padding {
top: number
right: number
bottom: number
left: number
}
interface SVGRootLayout {
width: number
height: number
yScale: AxisScale
xScale: AxisScale
content: Rectangle
setPadding: Dispatch<SetStateAction<Padding>>
}
const SVGRootContext = createContext<SVGRootLayout>({
width: 0,
height: 0,
xScale: scaleLinear(),
yScale: scaleLinear(),
content: EMPTY_RECTANGLE,
setPadding: noop,
})
interface SvgRootProps extends SVGProps<SVGSVGElement> {
width: number
height: number
yScale: AxisScale
xScale: AxisScale
}
/**
* SVG canvas root element. This component renders SVG element and
* calculates and prepares all important canvas measurements for x/y-axis,
* content and other chart elements.
*/
export const SvgRoot: FC<PropsWithChildren<SvgRootProps>> = props => {
const { width, height, yScale: yOriginalScale, xScale: xOriginalScale, children, ...attributes } = props
const [padding, setPadding] = useState<Padding>(DEFAULT_PADDING)
const contentRectangle = useMemo(
() =>
createRectangle(
padding.left,
padding.top,
width - padding.left - padding.right,
height - padding.top - padding.bottom
),
[width, height, padding]
)
const yScale = useMemo(() => yOriginalScale.copy().range([contentRectangle.height, 0]) as AxisScale, [
yOriginalScale,
contentRectangle,
])
const xScale = useMemo(() => xOriginalScale.copy().range([0, contentRectangle.width]) as AxisScale, [
xOriginalScale,
contentRectangle,
])
const context = useMemo<SVGRootLayout>(
() => ({
width,
height,
xScale,
yScale,
content: contentRectangle,
setPadding,
}),
[width, height, contentRectangle, xScale, yScale]
)
return (
<SVGRootContext.Provider value={context}>
<svg {...attributes} width={width} height={height}>
{children}
</svg>
</SVGRootContext.Provider>
)
}
interface SvgAxisLeftProps {}
export const SvgAxisLeft: FC<SvgAxisLeftProps> = props => {
const { content, yScale, setPadding } = useContext(SVGRootContext)
const handleResize = ({ width = 0 }): void => {
setPadding(padding => ({ ...padding, left: width }))
}
const { ref } = useResizeObserver({ onResize: handleResize })
return (
<AxisLeft
{...props}
ref={ref}
width={content.width}
height={content.height}
top={content.top}
left={content.left}
scale={yScale}
/>
)
}
const defaultToString = <T,>(tick: T): string => `${tick}`
const defaultTruncatedTick = (tick: string): string => (tick.length >= 15 ? `${tick.slice(0, 15)}...` : tick)
// TODO: Support reverse truncation for some charts https://github.com/sourcegraph/sourcegraph/issues/39879
export const reverseTruncatedTick = (tick: string): string => (tick.length >= 15 ? `...${tick.slice(-15)}` : tick)
interface SvgAxisBottomProps<Tick> {
tickFormat?: (tick: Tick) => string
pixelsPerTick?: number
maxRotateAngle?: number
getTruncatedTick?: (formattedTick: string) => string
}
export function SvgAxisBottom<Tick = string>(props: SvgAxisBottomProps<Tick>): ReactElement {
const {
pixelsPerTick = 0,
maxRotateAngle = 90,
tickFormat = defaultToString,
getTruncatedTick = defaultTruncatedTick,
} = props
const { content, xScale, setPadding } = useContext(SVGRootContext)
const axisGroupRef = useRef<SVGGElement>(null)
const { ref } = useResizeObserver<SVGGElement>({
// TODO: Fix corner cases with axis sizes see https://github.com/sourcegraph/sourcegraph/issues/39876
onResize: ({ height = 0 }) => setPadding(padding => ({ ...padding, bottom: height })),
})
const [, upperRangeBound] = xScale.range() as [number, number]
const ticks = getXScaleTicks<Tick>({ scale: xScale, space: content.width, pixelsPerTick })
const maxWidth = useMemo(() => {
const axisGroup = axisGroupRef.current
if (!axisGroup) {
return 0
}
return getMaxTickWidth(axisGroup, ticks.map(tickFormat))
}, [ticks, tickFormat])
const getXTickProps = (props: TickRendererProps): TickProps => {
const measuredSize = ticks.length * maxWidth
const rotate =
upperRangeBound < measuredSize
? maxRotateAngle * Math.min(1, (measuredSize / upperRangeBound - 0.8) / 2)
: 0
if (rotate) {
return {
...props,
// Truncate ticks only if we rotate them, this means truncate labels only
// when they overlap
getTruncatedTick,
transform: `rotate(${rotate}, ${props.x} ${props.y})`,
textAnchor: 'start',
}
}
return { ...props, textAnchor: 'middle' }
}
return (
<AxisBottom
ref={useMergeRefs([axisGroupRef, ref])}
scale={xScale}
width={content.width}
top={content.bottom}
left={content.left}
tickValues={ticks}
tickComponent={props => <Tick {...getXTickProps(props)} />}
tickFormat={tickFormat}
/>
)
}
interface SvgContentProps<XScale extends AxisScale, YScale extends AxisScale> {
children: (input: { xScale: XScale; yScale: YScale; content: Rectangle }) => ReactNode
}
/**
* Compound svg canvas component, to render actual chart content on
* SVG canvas with pre-calculated axes and paddings
*/
export function SvgContent<XScale extends AxisScale = AxisScale, YScale extends AxisScale = AxisScale>(
props: SvgContentProps<XScale, YScale>
): ReactElement {
const { children } = props
const { content, xScale, yScale } = useContext(SVGRootContext)
return (
<Group top={content.top} left={content.left} width={content.width} height={content.height}>
{children({
// We need to cast scales here because there is no other way to type context
// shared data in TS, React interfaces.
xScale: xScale as XScale,
yScale: yScale as YScale,
content,
})}
</Group>
)
}

View File

@ -27,13 +27,6 @@
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

View File

@ -1,29 +1,49 @@
import { forwardRef, memo } from 'react'
import { AxisLeft as VisxAxisLeft, AxisBottom as VisxAsixBottom } from '@visx/axis'
import { AxisScale, TickFormatter } from '@visx/axis/lib/types'
import {
AxisLeft as VisxAxisLeft,
AxisBottom as VisxAsixBottom,
TickLabelProps,
SharedAxisProps,
AxisScale,
} from '@visx/axis'
import { GridRows } from '@visx/grid'
import { Group } from '@visx/group'
import { TextProps } from '@visx/text'
import classNames from 'classnames'
import { formatYTick, getXScaleTicks, getYScaleTicks } from '../../../components/line-chart/utils'
import { getTickXProps, getTickYProps, Tick } from './Tick'
import { Tick } from './Tick'
import { formatYTick, getXScaleTicks, getYScaleTicks } from './tick-formatters'
import styles from './Axis.module.scss'
interface AxisLeftProps {
top: number
left: number
// TODO: Remove this prop generation, see https://github.com/sourcegraph/sourcegraph/issues/39874
const getTickYLabelProps: TickLabelProps<number> = (value, index, values): Partial<TextProps> => ({
dy: '0.25em',
textAnchor: 'end',
'aria-label': `Tick axis ${index + 1} of ${values.length}. Value: ${value}`,
})
type OwnSharedAxisProps = Omit<SharedAxisProps<AxisScale>, 'tickLabelProps'>
export interface AxisLeftProps extends OwnSharedAxisProps {
width: number
height: number
scale: AxisScale
}
export const AxisLeft = memo(
forwardRef<SVGGElement, AxisLeftProps>((props, reference) => {
const { scale, left, top, width, height } = props
const ticksValues = getYScaleTicks({ scale, space: height })
const {
scale,
left,
top,
width,
height,
tickComponent = Tick,
tickFormat = formatYTick,
tickValues = getYScaleTicks({ scale, space: height }),
...attributes
} = props
return (
<>
@ -33,22 +53,18 @@ export const AxisLeft = memo(
width={width}
height={height}
scale={scale}
tickValues={ticksValues}
tickValues={tickValues}
className={styles.gridLine}
/>
<Group
key={ticksValues.reduce((store, tick) => `${store}-${tick}`, '')}
innerRef={reference}
top={top}
left={left}
>
<Group innerRef={reference} top={top} left={left}>
<VisxAxisLeft
{...attributes}
scale={scale}
tickValues={ticksValues}
tickFormat={formatYTick}
tickLabelProps={getTickYProps}
tickComponent={Tick}
tickValues={tickValues}
tickFormat={tickFormat}
tickLabelProps={getTickYLabelProps}
tickComponent={tickComponent}
axisLineClassName={classNames(styles.axisLine, styles.axisLineVertical)}
tickClassName={classNames(styles.axisTick, styles.axisTickVertical)}
/>
@ -58,26 +74,30 @@ export const AxisLeft = memo(
})
)
interface AxisBottomProps {
top: number
left: number
AxisLeft.displayName = 'AxisLeft'
// TODO: Remove this prop generation, see https://github.com/sourcegraph/sourcegraph/issues/39874
const getTickXLabelProps: TickLabelProps<Date> = (value, index, values): Partial<TextProps> => ({
'aria-label': `Tick axis ${index + 1} of ${values.length}. Value: ${value}`,
textAnchor: 'middle',
})
interface AxisBottomProps extends OwnSharedAxisProps {
width: number
scale: AxisScale
tickFormat?: TickFormatter<AxisScale>
}
export const AxisBottom = memo(
forwardRef<SVGGElement, AxisBottomProps>((props, reference) => {
const { scale, top, left, width, tickFormat } = props
const { scale, top, left, width, tickValues, tickComponent = Tick, ...attributes } = props
return (
<Group innerRef={reference} top={top} left={left}>
<Group innerRef={reference} top={top} left={left} width={width}>
<VisxAsixBottom
{...attributes}
scale={scale}
tickValues={getXScaleTicks({ scale, space: width })}
tickFormat={tickFormat}
tickLabelProps={getTickXProps}
tickComponent={Tick}
tickComponent={tickComponent}
tickValues={tickValues ?? getXScaleTicks({ scale, space: width })}
tickLabelProps={getTickXLabelProps}
axisLineClassName={styles.axisLine}
tickClassName={styles.axisTick}
/>
@ -85,3 +105,5 @@ export const AxisBottom = memo(
)
})
)
AxisBottom.displayName = 'AxisBottom'

View File

@ -0,0 +1,4 @@
.tick {
fill: var(--text-muted);
font-size: 0.75rem;
}

View File

@ -1,35 +1,19 @@
import React from 'react'
import { FC } from 'react'
import { TickLabelProps, TickRendererProps } from '@visx/axis'
import { TickRendererProps } from '@visx/axis'
import { Group } from '@visx/group'
import { Text, TextProps } from '@visx/text'
import classNames from 'classnames'
import { formatXLabel } from '../../../components/line-chart/utils'
import styles from './Tick.module.scss'
export const getTickYProps: TickLabelProps<number> = (value, index, values): Partial<TextProps> => ({
dx: '-0.25em',
dy: '0.25em',
fill: '#222',
fontFamily: 'Arial',
fontSize: 10,
textAnchor: 'end',
'aria-label': `Tick axis ${index + 1} of ${values.length}. Value: ${value}`,
})
export interface TickProps extends TickRendererProps {
getTruncatedTick?: (lable: string) => string
}
export const getTickXProps: TickLabelProps<Date> = (value, index, values): Partial<TextProps> => ({
dy: '0.25em',
fill: '#222',
fontFamily: 'Arial',
fontSize: 10,
textAnchor: 'middle',
'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<React.PropsWithChildren<TickRendererProps>> = props => {
const { formattedValue, ...tickLabelProps } = props
/** Tick component displays tick label for each axis line of chart. */
export const Tick: FC<TickProps> = props => {
const { formattedValue = '', 'aria-label': ariaLabel, className, getTruncatedTick, ...tickLabelProps } = props
// Hack with Group + Text (aria hidden)
// Because the Text component renders text inside svg element and text element with tspan
@ -38,10 +22,37 @@ export const Tick: React.FunctionComponent<React.PropsWithChildren<TickRendererP
// 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 as TextProps)}>
{formattedValue}
<Group role="text" aria-label={ariaLabel}>
<Text aria-hidden={true} className={classNames(styles.tick, className)} {...(tickLabelProps as TextProps)}>
{getTruncatedTick ? getTruncatedTick(formattedValue) : formattedValue}
</Text>
</Group>
)
}
/**
* Text (labels) ticks measure helper. Since there is no way to measure text
* before rendering inside React tree we have to conduct pre-rendering measurements
* for ticks labels.
*
* It renders each labels (text tick) inside selection element with SVG text element
* and measures its sizes.
*/
export const getMaxTickWidth = (selection: Element, labels: string[]): number => {
const tester = document.createElementNS('http://www.w3.org/2000/svg', 'text')
// In order to sync Tick component and pre-rendering text styles which is vital for
// text measurements
tester.classList.add(styles.tick)
selection.append(tester)
const boundingBoxes = labels.map(label => {
tester.textContent = label
return tester.getBBox()
})
tester.remove()
return Math.max(...boundingBoxes.map(b => b.width))
}

View File

@ -20,7 +20,7 @@ export function formatYTick(number: number): string {
*
* Example: 01 Jan, 12 Feb, ...
*/
export const formatXTick = timeFormat('%d %b')
export const formatDateTick = timeFormat('%d %b')
/**
* Returns a formatted date text for points aria labels.
@ -37,11 +37,19 @@ interface GetScaleTicksInput {
pixelsPerTick?: number
}
export function getXScaleTicks(input: GetScaleTicksInput): number[] {
export function getXScaleTicks<T>(input: GetScaleTicksInput): T[] {
const { scale, space, pixelsPerTick = 80 } = input
const maxTicks = Math.max(Math.floor(space / pixelsPerTick), MINIMUM_NUMBER_OF_TICKS)
return getTicks(scale, maxTicks) as number[]
// Calculate desirable number of ticks
const numberTicks = Math.max(MINIMUM_NUMBER_OF_TICKS, Math.floor(space / pixelsPerTick))
let filteredTicks = getTicks(scale)
while (filteredTicks.length > numberTicks) {
filteredTicks = getHalvedTicks(filteredTicks)
}
return filteredTicks
}
/**
@ -83,7 +91,7 @@ export function getYScaleTicks(input: GetScaleTicksInput): number[] {
* removes all even index ticks with even number removes all
* odd index ticks.
*/
function getHalvedTicks(ticks: number[]): number[] {
function getHalvedTicks<T>(ticks: T[]): T[] {
const isOriginTickLengthOdd = !(ticks.length % 2)
const filteredTicks = []

View File

@ -9,7 +9,7 @@ import styles from './Tooltip.module.scss'
const TOOLTIP_PADDING = createRectangle(0, 0, 10, 10)
interface TooltipProps {
containerElement: SVGSVGElement
containerElement: Element
activeElement?: HTMLElement
}
@ -35,6 +35,10 @@ export const Tooltip: React.FunctionComponent<React.PropsWithChildren<TooltipPro
}, [activeElement])
useEffect(() => {
// We need this casting because Element type doesn't support
// pointer or mouse events in pointermove handlers
const element = containerElement as HTMLElement
function handleMove(event: PointerEvent): void {
setVirtualElement({
target: null,
@ -43,10 +47,10 @@ export const Tooltip: React.FunctionComponent<React.PropsWithChildren<TooltipPro
})
}
containerElement.addEventListener('pointermove', handleMove)
element.addEventListener('pointermove', handleMove)
return () => {
containerElement.removeEventListener('pointermove', handleMove)
element.removeEventListener('pointermove', handleMove)
}
}, [containerElement])

View File

@ -9,7 +9,7 @@ import { CodeInsightsBackendContext, SeriesChartContent, CodeInsightsGqlBackend
import { CaptureGroupCreationPage as CaptureGroupCreationPageComponent } from './CaptureGroupCreationPage'
export default {
title: 'web/insights/creation-ui/CaptureGroupCreationPage',
title: 'web/insights/creation-ui/capture-group/CaptureGroupCreationPage',
decorators: [story => <WebStory>{() => <div className="p-3 container web-content">{story()}</div>}</WebStory>],
parameters: {
chromatic: {

View File

@ -11,7 +11,7 @@ import { SERIES_MOCK_CHART } from '../../../../components'
import { ComputeInsightCreationPage as ComputeInsightCreationPageComponent } from './ComputeInsightCreationPage'
const defaultStory: Meta = {
title: 'web/insights/creation-ui/ComputeInsightCreationPage',
title: 'web/insights/creation-ui/compute/ComputeInsightCreationPage',
decorators: [story => <WebStory>{() => story()}</WebStory>],
parameters: {
chromatic: {

View File

@ -22,10 +22,10 @@ import {
useForm,
} from '../../../../../components'
import { useUiFeatures } from '../../../../../hooks'
import { ComputeLivePreview } from '../../ComputeLivePreview'
import { CreateComputeInsightFormFields } from '../types'
import { ComputeInsightMapPicker } from './ComputeInsightMapPicker'
import { ComputeLivePreview } from './ComputeLivePreview'
const INITIAL_INSIGHT_VALUES: CreateComputeInsightFormFields = {
series: [createDefaultEditSeries({ edit: true })],

View File

@ -2,14 +2,14 @@ import { Meta, Story } from '@storybook/react'
import { GroupByField } from '@sourcegraph/shared/src/graphql-operations'
import { WebStory } from '../../../../../components/WebStory'
import { CodeInsightsBackendStoryMock } from '../../../CodeInsightsBackendStoryMock'
import { BackendInsightDatum, SeriesChartContent } from '../../../core'
import { WebStory } from '../../../../../../../components/WebStory'
import { CodeInsightsBackendStoryMock } from '../../../../../CodeInsightsBackendStoryMock'
import { BackendInsightDatum, SeriesChartContent } from '../../../../../core'
import { ComputeLivePreview as ComputeLivePreviewComponent } from './ComputeLivePreview'
const defaultStory: Meta = {
title: 'web/insights/creation-ui/ComputeLivePreview',
title: 'web/insights/creation-ui/compute/ComputeLivePreview',
decorators: [story => <WebStory>{() => story()}</WebStory>],
}

View File

@ -5,9 +5,9 @@ import { groupBy } from 'lodash'
import { ErrorAlert } from '@sourcegraph/branded/src/components/alerts'
import { useDeepMemo } from '@sourcegraph/wildcard'
import { LegendItem, LegendList, Series } from '../../../../../charts'
import { BarChart } from '../../../../../charts/components/bar-chart/BarChart'
import { GroupByField } from '../../../../../graphql-operations'
import { LegendItem, LegendList, Series } from '../../../../../../../charts'
import { BarChart } from '../../../../../../../charts/components/bar-chart/BarChart'
import { GroupByField } from '../../../../../../../graphql-operations'
import {
LivePreviewUpdateButton,
LivePreviewCard,
@ -20,13 +20,13 @@ import {
StateStatus,
COMPUTE_MOCK_CHART,
EditableDataSeries,
} from '../../../components'
} from '../../../../../components'
import {
BackendInsightDatum,
CategoricalChartContent,
CodeInsightsBackendContext,
SeriesPreviewSettings,
} from '../../../core'
} from '../../../../../core'
interface LanguageUsageDatum {
name: string

View File

@ -11,7 +11,7 @@ import { getRandomLangStatsMock } from './components/live-preview-chart/constant
import { LangStatsInsightCreationPage as LangStatsInsightCreationPageComponent } from './LangStatsInsightCreationPage'
const defaultStory: Meta = {
title: 'web/insights/creation-ui/LangStatsInsightCreationPage',
title: 'web/insights/creation-ui/lang-stats/LangStatsInsightCreationPage',
decorators: [story => <WebStory>{() => story()}</WebStory>],
parameters: {
chromatic: {

View File

@ -11,7 +11,7 @@ import { SERIES_MOCK_CHART } from '../../../../components'
import { SearchInsightCreationPage as SearchInsightCreationPageComponent } from './SearchInsightCreationPage'
const defaultStory: Meta = {
title: 'web/insights/creation-ui/SearchInsightCreationPage',
title: 'web/insights/creation-ui/search/SearchInsightCreationPage',
decorators: [story => <WebStory>{() => story()}</WebStory>],
parameters: {
chromatic: {

View File

@ -396,7 +396,7 @@
"@stripe/react-stripe-js": "^1.8.0-0",
"@stripe/stripe-js": "^1.29.0",
"@visx/annotation": "^2.10.0",
"@visx/axis": "^2.10.0",
"@visx/axis": "^2.11.1",
"@visx/glyph": "^2.10.0",
"@visx/grid": "^2.10.0",
"@visx/group": "^2.10.0",
@ -486,7 +486,7 @@
"use-callback-ref": "^1.2.5",
"use-debounce": "^8.0.1",
"use-deep-compare-effect": "^1.6.1",
"use-resize-observer": "^7.0.0",
"use-resize-observer": "^9.0.2",
"utility-types": "^3.10.0",
"uuid": "^8.3.0",
"webext-domain-permission-toggle": "^1.0.1",

View File

@ -6710,16 +6710,16 @@
prop-types "^15.5.10"
react-use-measure "^2.0.4"
"@visx/axis@^2.10.0":
version "2.10.0"
resolved "https://registry.npmjs.org/@visx/axis/-/axis-2.10.0.tgz#613fde76653edbefb795cafe2e11fc4c2294db46"
integrity sha512-myEcXPzD7ZmKiXuhue2lpiuTDgl3Glhe1LB+xoUDS8ZAW76Asd6PwurjoxSnq3tHCz0EDBh7YlgApeFy3Bw38A==
"@visx/axis@^2.11.1":
version "2.11.1"
resolved "https://registry.npmjs.org/@visx/axis/-/axis-2.11.1.tgz#e1cc978ede9cce197cd7712183301e9e89c519e2"
integrity sha512-RZdT+yhAEOXtcLc3PgD14Xh5bJh5B64IG+zvHe3YVMkiFWGT1phy0sGTRRxritHke16ErB9vndx+pwVIGSkxAw==
dependencies:
"@types/react" "*"
"@visx/group" "2.10.0"
"@visx/point" "2.6.0"
"@visx/scale" "2.2.2"
"@visx/shape" "2.10.0"
"@visx/shape" "2.11.1"
"@visx/text" "2.10.0"
classnames "^2.3.1"
prop-types "^15.6.0"
@ -6831,6 +6831,24 @@
lodash "^4.17.21"
prop-types "^15.5.10"
"@visx/shape@2.11.1":
version "2.11.1"
resolved "https://registry.npmjs.org/@visx/shape/-/shape-2.11.1.tgz#ff41d839ad689fea53504597bd4bfbc8b7c20bbb"
integrity sha512-0ak3wTkXjExH7kzU62yXPu+RtuG35G1sNL58Ax4NBr4yKh2nTHcLRsMZ7k6yUG+wSjb8DgG/ywZ0bBGWXJYGbg==
dependencies:
"@types/d3-path" "^1.0.8"
"@types/d3-shape" "^1.3.1"
"@types/lodash" "^4.14.172"
"@types/react" "*"
"@visx/curve" "2.1.0"
"@visx/group" "2.10.0"
"@visx/scale" "2.2.2"
classnames "^2.3.1"
d3-path "^1.0.5"
d3-shape "^1.2.0"
lodash "^4.17.21"
prop-types "^15.5.10"
"@visx/text@2.10.0":
version "2.10.0"
resolved "https://registry.npmjs.org/@visx/text/-/text-2.10.0.tgz#2ff413f4dd35617e45b24d6082d7b856358afe4c"
@ -24952,12 +24970,12 @@ use-deep-compare-effect@^1.6.1:
"@types/react" "^17.0.0"
dequal "^2.0.2"
use-resize-observer@^7.0.0:
version "7.0.0"
resolved "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-7.0.0.tgz#15f0efbd5a4e08a8cc51901f21a89ba836f2116e"
integrity sha512-+RjrQsk/mL8aKy4TGBDiPkUv6whyeoGDMIZYk0gOGHOlnrsjImC+jG6lfAFcBCKAG9epGRL419adhDNdkDCQkA==
use-resize-observer@^9.0.2:
version "9.0.2"
resolved "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.0.2.tgz#25830221933d9b6e931850023305eb9d24379a6b"
integrity sha512-JOzsmF3/IDmtjG7OE5qXOP69LEpBpwhpLSiT1XgSr+uFRX0ftJHQnDaP7Xq+uhbljLYkJt67sqsbnyXBjiY8ig==
dependencies:
resize-observer-polyfill "^1.5.1"
"@juggle/resize-observer" "^3.3.1"
use-sidecar@^1.0.1, use-sidecar@^1.0.5:
version "1.0.5"