Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Component | Line: Support interpolated dashed line for missing values #532

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/angular/src/components/line/line.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ export class VisLineComponent<Datum> implements LineConfigInterface<Datum>, Afte

/** Optional link cursor. Default: `null` */
@Input() cursor?: StringAccessor<Datum[]>

/** Enable interpolated line where data points are missing or fallbackValue is used.
* You can customize the line's appearance with `--vis-line-gapfill-stroke-dasharray`
* and `--vis-line-gapfill-stroke-opacity` CSS variables.
* Default: `false` */
@Input() interpolateMissingData: boolean
@Input() data: Datum[]

component: Line<Datum> | undefined
Expand All @@ -142,8 +148,8 @@ export class VisLineComponent<Datum> implements LineConfigInterface<Datum>, Afte
}

private getConfig (): LineConfigInterface<Datum> {
const { duration, events, attributes, x, y, id, color, xScale, yScale, excludeFromDomainCalculation, curveType, lineWidth, lineDashArray, fallbackValue, highlightOnHover, cursor } = this
const config = { duration, events, attributes, x, y, id, color, xScale, yScale, excludeFromDomainCalculation, curveType, lineWidth, lineDashArray, fallbackValue, highlightOnHover, cursor }
const { duration, events, attributes, x, y, id, color, xScale, yScale, excludeFromDomainCalculation, curveType, lineWidth, lineDashArray, fallbackValue, highlightOnHover, cursor, interpolateMissingData } = this
const config = { duration, events, attributes, x, y, id, color, xScale, yScale, excludeFromDomainCalculation, curveType, lineWidth, lineDashArray, fallbackValue, highlightOnHover, cursor, interpolateMissingData }
const keys = Object.keys(config) as (keyof LineConfigInterface<Datum>)[]
keys.forEach(key => { if (config[key] === undefined) delete config[key] })

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, { useCallback, useEffect, useState } from 'react'
import { VisAxis, VisBulletLegend, VisBulletLegendSelectors, VisCrosshair, VisLine, VisScatter, VisTooltip, VisXYContainer } from '@unovis/react'
import { BulletLegendItemInterface, BulletShape, NumericAccessor, colors, CurveType } from '@unovis/ts'

import { ExampleViewerDurationProps } from '@src/components/ExampleViewer'

export const title = 'Interpolated Multi-Line Chart'
export const subTitle = 'With interactive bullet legend'

const n = undefined
const layers: Record<string, (number | undefined | null)[]> = {
y0: [3, 5, 2, 5, n, 3, 4, 5, 4, 2, 5, 2, 4, 2, n, 5],
y1: [2, 1, n, 2, 2, n, 1, 3, 2, n, 1, 4, 6, 4, 3, 2],
y2: [5, 6, 7, n, 5, 7, 8, 7, 9, 6, n, 5, n, n, 9, 7],
y3: [9, n, n, 8, n, n, 5, 6, 5, 5, 4, 3, 2, 1, 2, 0],
}

type Datum = Record<keyof typeof layers, number> & { x: number }

const keys = Object.keys(layers) as (keyof Datum)[]
const data = Array.from({ length: layers.y0.length }, (_, i) => ({
x: i,
...(keys.reduce((o, k) => ({ ...o, [k]: layers[k][i] }), {})),
}))

export const component = (props: ExampleViewerDurationProps): JSX.Element => {
const x: NumericAccessor<Datum> = d => d.x
const [y, setY] = useState<NumericAccessor<Datum>[]>()
const [color, setColor] = useState<string[]>([])

const [legendItems, setLegendItems] = useState(
keys.map((name, i) => ({ name, inactive: false, color: colors[i], cursor: 'pointer' }))
)

useEffect(() => {
const updated = legendItems.reduce((obj, item) => {
if (!item.inactive) obj.colors.push(item.color)
obj.ys.push(d => (item.inactive ? null : d[item.name]))
return obj
}, { colors: new Array<string>(), ys: new Array<NumericAccessor<Datum>>() })
setY(updated.ys)
setColor(updated.colors)
}, [legendItems])

const updateItems = useCallback((_: BulletLegendItemInterface, index: number) => {
const newItems = [...legendItems]
newItems[index].inactive = !newItems[index].inactive
setLegendItems(newItems)
}, [legendItems])

const tooltipTemplate = useCallback((d: Datum): string => legendItems.map(item => `
<div style="font-size:12px;${item.inactive ? 'text-decoration:line-through;opacity:0.7;color:#ccc">' : '">'}
<span style="color:${item.color};font-weight:${item.inactive ? '400' : '800'};">${item.name}</span>: ${d[item.name] ?? '-'}
</div>`
).join(''), [legendItems])

return (
<div style={{ margin: 50 }}>
<style>{`
.square-legend .${VisBulletLegendSelectors.item} {
--vis-legend-item-spacing: 10px;
padding: 2px 4px;
}
.line-legend .${VisBulletLegendSelectors.bullet} { width: 16px !important; }
.line-legend .${VisBulletLegendSelectors.bullet} path { stroke-dasharray: 5 3; }

`}</style>
<div style={{ display: 'flex', width: 'max-content', padding: '10px 10px 0px 35px' }}>
<VisBulletLegend className='square-legend' items={legendItems} bulletShape={BulletShape.Square} onLegendItemClick={updateItems}/>
<VisBulletLegend className='line-legend' items={[{ name: 'No data', color: '#5558', shape: 'line' }]} />
</div>
<VisXYContainer yDomain={[0, 10]} data={data} height={400} duration={props.duration}>
<VisLine lineWidth={2} curveType={CurveType.Linear} x={x} y={y} color={color} interpolateMissingData/>
<VisScatter size={2} x={x} y={y} color={color}/>
<VisCrosshair template={tooltipTemplate} color={color}/>
<VisTooltip/>
<VisAxis type='x' tickFormat={(d: number) => `0${(Math.floor(d / 6)) + 1}:${d % 6}0pm`}/>
<VisAxis type='y'/>
</VisXYContainer>
</div>
)
}
85 changes: 85 additions & 0 deletions packages/dev/src/examples/xy-components/line/patchy-line/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import React, { useState } from 'react'
import { VisXYContainer, VisLine, VisAxis, VisScatter, VisCrosshair, VisTooltip, VisAnnotations } from '@unovis/react'
import { CurveType } from '@unovis/ts'

import { ExampleViewerDurationProps } from '@src/components/ExampleViewer'

import s from './style.module.css'

export const title = 'Patchy Line Chart'
export const subTitle = 'Various test cases'

type TestCase = {
title: string;
data: (number | undefined | null)[];
}

const testCases: TestCase[] = [
{ title: 'Gaps in middle', data: [3, 1, undefined, 7, undefined, 1, 1, undefined, 0.5, 4] },
{ title: 'Longer gaps', data: [2, 3, undefined, undefined, undefined, 12, 10, undefined, undefined, 2] },
{ title: 'Gaps at ends', data: [7, undefined, 9, 10, 7, 4, 5, 2, undefined, 10] },
{ title: 'Gaps at true ends', data: [undefined, 2, 10, 4, 5, 2, 6, 2, 3, undefined] },
{ title: 'Gaps surrounding single point', data: [5, 3, 6, undefined, 2, undefined, 10, 8, 9, 5] },
{ title: 'All undefined', data: [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined] },
{ title: 'Single point', data: [undefined, undefined, undefined, undefined, 10, undefined] },
{ title: 'Missing every other point', data: [3, undefined, 12, undefined, 7, undefined, 5, undefined, 12] },
{ title: 'Includes undefined and null values', data: [3, 5, undefined, 6, 7, null, 9, 10, undefined, 4] },

]

export const component = (props: ExampleViewerDurationProps): JSX.Element => {
type Datum = Record<string, number>
const combined = Array.from({ length: 10 }, (_, i) => ({
x: i,
...(testCases.reduce((obj, d, j) => ({
...obj,
[`y${j}`]: d.data[i],
}), {})),
}))
const x = (d: Datum): number => d.x
const getY = (i: number) => (d: Datum) => d[`y${i}`]

const fallbacks = [undefined, 0, 5, 10]
const [fallbackValue, setFallbackValue] = useState(fallbacks[0])
const [interpolation, setInterpolation] = useState(true)
const [showScatter, setShowScatter] = useState(true)

return (
<div className={s.patchyLineExample}>
<div className={s.inputs}>
<label>
Fallback value:
<select onChange={e => setFallbackValue(fallbacks[Number(e.target.value)])}>
{fallbacks.map((o, i) => <option value={i}>{String(o)}</option>)}
</select>
</label>
<label>
Interpolate:<input type='checkbox' checked={interpolation} onChange={e => setInterpolation(e.target.checked)}/>
</label>
<label>
Show Scatter: <input type='checkbox' checked={showScatter} onChange={e => setShowScatter(e.target.checked)}/>
</label>
</div>
<div className={s.singleLines}>
{testCases.map((val, i) => (
<VisXYContainer<Datum> data={combined} key={i} xDomain={[-0.2, 9.2]} yDomain={[0, 15]} height={200} width='100%'>
<VisAnnotations items={[{ content: val.title, x: '50%', y: 0, textAlign: 'center' }]}/>
<VisLine
curveType={CurveType.Linear}
duration={props.duration}
fallbackValue={fallbackValue}
interpolateMissingData={interpolation}
x={x}
y={getY(i)}
/>
{showScatter && <VisScatter excludeFromDomainCalculation size={2} x={x} y={d => getY(i)(d) ?? undefined}/>}
<VisCrosshair template={(d: Datum) => `${d.x}, ${getY(i)(d)}`} color='var(--vis-color0)' strokeWidth='1px'/>
<VisTooltip/>
<VisAxis type='x'/>
<VisAxis type='y' domainLine={false}/>
</VisXYContainer>
))}
</div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.patchyLineExample {
width: 100%;
}

.inputs {
font-family: 'Courier New', Courier, monospace;
font-size: smaller;
margin-bottom: 12px;
}

.inputs > label {
display: flex;
align-items: center;
}

.singleLines {
display: grid;
width: 100%;
grid-template-columns: repeat(3, 1fr);
column-gap: 10px;
}

6 changes: 6 additions & 0 deletions packages/ts/src/components/line/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export interface LineConfigInterface<Datum> extends XYComponentConfigInterface<D
highlightOnHover?: boolean;
/** Optional link cursor. Default: `null` */
cursor?: StringAccessor<Datum[]>;
/** Enable interpolated line where data points are missing or fallbackValue is used.
* You can customize the line's appearance with `--vis-line-gapfill-stroke-dasharray`
* and `--vis-line-gapfill-stroke-opacity` CSS variables.
* Default: `false` */
interpolateMissingData?: boolean;
}

export const LineDefaultConfig: LineConfigInterface<unknown> = {
Expand All @@ -34,4 +39,5 @@ export const LineDefaultConfig: LineConfigInterface<unknown> = {
fallbackValue: undefined,
highlightOnHover: false,
cursor: null,
interpolateMissingData: false,
}
47 changes: 45 additions & 2 deletions packages/ts/src/components/line/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,23 +83,48 @@ export class Line<Datum> extends XYComponentCore<Datum, LineConfigInterface<Datu
const lineData: LineData[] = yAccessors.map(a => {
const ld: LineDatum[] = data.map((d, i) => {
const rawValue = getNumber(d, a, i)

// If `rawValue` is not numerical or if it's not finite (`NaN`, `undefined`, ...), we replace it with `config.fallbackValue`
const value = (isNumber(rawValue) || (rawValue === null)) && isFinite(rawValue) ? rawValue : config.fallbackValue
const defined = config.interpolateMissingData
? (isNumber(rawValue) || (rawValue === null)) && isFinite(rawValue)
: isFinite(value)

return {
x: lineDataX[i],
y: this.yScale(value ?? 0),
defined: isFinite(value),
defined,
value,
}
})

const defined = ld.reduce((def, d) => (d.defined || def), false)

let validGap = false
const gaps = ld.reduce((acc, d, i) => {
// Gaps include fallback values if configured.
if (!d.defined && isFinite(config.fallbackValue)) {
acc.push({ ...d, defined: true })
}

if (!d.defined && !validGap) validGap = true

const isEndpoint = (i > 0 && !ld[i - 1].defined) || (i < ld.length - 1 && !ld[i + 1].defined)
if (d.defined && isEndpoint) {
// If no undefined points have been found since the last endpoint, we insert one to enforce breaks between adjacent gaps.
if (!validGap) acc.push({ ...d, defined: false })
acc.push(d)
validGap = false
}
return acc
}, [])

// If the line consists only of `null` values, we'll still render it but it'll be invisible.
// Such trick allows us to have better animated transitions.
const visible = defined && ld.some(d => d.value !== null)
return {
values: ld,
defined,
gaps,
visible,
}
})
Expand All @@ -123,12 +148,18 @@ export class Line<Datum> extends XYComponentCore<Datum, LineConfigInterface<Datu
.attr('class', s.lineSelectionHelper)
.attr('d', this._emptyPath())

linesEnter.append('path')
.attr('class', s.interpolatedPath)
.attr('d', this._emptyPath())
.style('opacity', 0)

const linesMerged = linesEnter.merge(lines)
linesMerged.style('cursor', (d, i) => getString(data, config.cursor, i))
linesMerged.each((d, i, elements) => {
const group = select(elements[i])
const linePath = group.select<SVGPathElement>(`.${s.linePath}`)
const lineSelectionHelper = group.select(`.${s.lineSelectionHelper}`)
const lineGaps = group.select(`.${s.interpolatedPath}`)

const isLineVisible = d.visible
const dashArray = getValue<Datum[], number[]>(data, config.lineDashArray, i)
Expand All @@ -153,6 +184,18 @@ export class Line<Datum> extends XYComponentCore<Datum, LineConfigInterface<Datu
lineSelectionHelper
.attr('d', svgPathD)
.attr('visibility', isLineVisible ? null : 'hidden')

if (hasUndefinedSegments && config.interpolateMissingData) {
smartTransition(lineGaps, duration)
.attr('d', this.lineGen(d.gaps))
.attr('stroke', getColor(data, config.color, i))
.attr('stroke-width', config.lineWidth - 1)
.style('opacity', 1)
} else {
lineGaps.transition()
.duration(duration)
.style('opacity', 0)
}
})

smartTransition(lines.exit(), duration)
Expand Down
12 changes: 12 additions & 0 deletions packages/ts/src/components/line/style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export const globalStyles = injectGlobal`
--vis-line-cursor: default;
--vis-line-stroke-dasharray: none;
--vis-line-stroke-dashoffset: 0;

--vis-line-gapfill-stroke-dasharray: 2 3;
--vis-line-gapfill-stroke-opacity: 0.8;
--vis-line-gapfill-stroke-dashoffset: 0;
}
`

Expand Down Expand Up @@ -35,3 +39,11 @@ export const lineSelectionHelper = css`
export const dim = css`
opacity: 0.2;
`

export const interpolatedPath = css`
label: interpolated-path;
fill: none;
stroke-dasharray: var(--vis-line-gapfill-stroke-dasharray);
stroke-dashoffset: var(--vis-line-gapfill-stroke-dashoffset);
stroke-opacity: var(--vis-line-gapfill-stroke-opacity);
`
2 changes: 1 addition & 1 deletion packages/ts/src/components/line/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
/** Data type for Line Generator: [x, y, defined] */
export type LineDatum = { x: number; y: number; value: number | null | undefined; defined: boolean }
export type LineData = { values: LineDatum[]; defined: boolean; visible: boolean }
export type LineData = { values: LineDatum[]; gaps: LineDatum[]; defined: boolean; visible: boolean }
20 changes: 20 additions & 0 deletions packages/website/docs/xy-charts/Line.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Similar to [mutlti-color configuration](#for-multiple-lines), you can provide an
customize each line.

## Dealing with missing data
### Fallback Value
In the case of missing data (when the data values are `undefined`, `NaN`, `''`, etc ...), you can assign a fallback value
for _Line_ using the `fallbackValue` config property. The default value is `undefined`, which means that the line will
break in the areas of no data and continue when the data appears again. If you set `fallbackValue` to `null`, the values
Expand All @@ -70,6 +71,25 @@ Consider the following example, where the dataset contains `undefined` values ov
defaultValue={7}
showAxes/>

### Line Interpolation
Alternatively, you can set the `interpolateMissingData` property to `true` to fill in the data gaps with a dashed line.
If `fallbackValue` is set, those values will be plotted on the inteprolated line.
Otherwise, it will be a smooth curve between defined points, like below:

<XYWrapper {...lineProps()}
data={[1, 3, 4, undefined, undefined, undefined, 5, 7, 9, 6].map((y, x) => ({ x, y }))}
showAxes
interpolateMissingData={true}
/>

You can customize the appearance of of the interpolated line with the following CSS varibles:

```css
--vis-line-gapfill-stroke-dasharray: 2 3;
--vis-line-gapfill-stroke-opacity: 0.8;
--vis-line-gapfill-stroke-dashoffset: 0;
```

## Events
```ts
import { Line } from '@unovis/ts'
Expand Down
Loading