Skip to content

Commit

Permalink
Component | Line: Support interpolated dashed line for missing values
Browse files Browse the repository at this point in the history
  • Loading branch information
reb-dev committed Feb 13, 2025
1 parent 9e0ccfd commit 6c524ee
Show file tree
Hide file tree
Showing 9 changed files with 281 additions and 5 deletions.
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 @@ -33,5 +38,6 @@ export const LineDefaultConfig: LineConfigInterface<unknown> = {
lineDashArray: undefined,
fallbackValue: undefined,
highlightOnHover: false,
interpolateMissingData: false,
cursor: null,
}
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

0 comments on commit 6c524ee

Please sign in to comment.