Skip to content

Commit caf0189

Browse files
reb-devlee00678
authored andcommitted
Component | Line: Support interpolated dashed line for missing values
1 parent 1fd2b85 commit caf0189

File tree

9 files changed

+281
-5
lines changed

9 files changed

+281
-5
lines changed

packages/angular/src/components/line/line.component.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,12 @@ export class VisLineComponent<Datum> implements LineConfigInterface<Datum>, Afte
121121

122122
/** Optional link cursor. Default: `null` */
123123
@Input() cursor?: StringAccessor<Datum[]>
124+
125+
/** Enable interpolated line where data points are missing or fallbackValue is used.
126+
* You can customize the line's appearance with `--vis-line-gapfill-stroke-dasharray`
127+
* and `--vis-line-gapfill-stroke-opacity` CSS variables.
128+
* Default: `false` */
129+
@Input() interpolateMissingData: boolean
124130
@Input() data: Datum[]
125131

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

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

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import React, { useCallback, useEffect, useState } from 'react'
2+
import { VisAxis, VisBulletLegend, VisBulletLegendSelectors, VisCrosshair, VisLine, VisScatter, VisTooltip, VisXYContainer } from '@unovis/react'
3+
import { BulletLegendItemInterface, BulletShape, NumericAccessor, colors, CurveType } from '@unovis/ts'
4+
5+
import { ExampleViewerDurationProps } from '@src/components/ExampleViewer'
6+
7+
export const title = 'Interpolated Multi-Line Chart'
8+
export const subTitle = 'With interactive bullet legend'
9+
10+
const n = undefined
11+
const layers: Record<string, (number | undefined | null)[]> = {
12+
y0: [3, 5, 2, 5, n, 3, 4, 5, 4, 2, 5, 2, 4, 2, n, 5],
13+
y1: [2, 1, n, 2, 2, n, 1, 3, 2, n, 1, 4, 6, 4, 3, 2],
14+
y2: [5, 6, 7, n, 5, 7, 8, 7, 9, 6, n, 5, n, n, 9, 7],
15+
y3: [9, n, n, 8, n, n, 5, 6, 5, 5, 4, 3, 2, 1, 2, 0],
16+
}
17+
18+
type Datum = Record<keyof typeof layers, number> & { x: number }
19+
20+
const keys = Object.keys(layers) as (keyof Datum)[]
21+
const data = Array.from({ length: layers.y0.length }, (_, i) => ({
22+
x: i,
23+
...(keys.reduce((o, k) => ({ ...o, [k]: layers[k][i] }), {})),
24+
}))
25+
26+
export const component = (props: ExampleViewerDurationProps): JSX.Element => {
27+
const x: NumericAccessor<Datum> = d => d.x
28+
const [y, setY] = useState<NumericAccessor<Datum>[]>()
29+
const [color, setColor] = useState<string[]>([])
30+
31+
const [legendItems, setLegendItems] = useState(
32+
keys.map((name, i) => ({ name, inactive: false, color: colors[i], cursor: 'pointer' }))
33+
)
34+
35+
useEffect(() => {
36+
const updated = legendItems.reduce((obj, item) => {
37+
if (!item.inactive) obj.colors.push(item.color)
38+
obj.ys.push(d => (item.inactive ? null : d[item.name]))
39+
return obj
40+
}, { colors: new Array<string>(), ys: new Array<NumericAccessor<Datum>>() })
41+
setY(updated.ys)
42+
setColor(updated.colors)
43+
}, [legendItems])
44+
45+
const updateItems = useCallback((_: BulletLegendItemInterface, index: number) => {
46+
const newItems = [...legendItems]
47+
newItems[index].inactive = !newItems[index].inactive
48+
setLegendItems(newItems)
49+
}, [legendItems])
50+
51+
const tooltipTemplate = useCallback((d: Datum): string => legendItems.map(item => `
52+
<div style="font-size:12px;${item.inactive ? 'text-decoration:line-through;opacity:0.7;color:#ccc">' : '">'}
53+
<span style="color:${item.color};font-weight:${item.inactive ? '400' : '800'};">${item.name}</span>: ${d[item.name] ?? '-'}
54+
</div>`
55+
).join(''), [legendItems])
56+
57+
return (
58+
<div style={{ margin: 50 }}>
59+
<style>{`
60+
.square-legend .${VisBulletLegendSelectors.item} {
61+
--vis-legend-item-spacing: 10px;
62+
padding: 2px 4px;
63+
}
64+
.line-legend .${VisBulletLegendSelectors.bullet} { width: 16px !important; }
65+
.line-legend .${VisBulletLegendSelectors.bullet} path { stroke-dasharray: 5 3; }
66+
67+
`}</style>
68+
<div style={{ display: 'flex', width: 'max-content', padding: '10px 10px 0px 35px' }}>
69+
<VisBulletLegend className='square-legend' items={legendItems} bulletShape={BulletShape.Square} onLegendItemClick={updateItems}/>
70+
<VisBulletLegend className='line-legend' items={[{ name: 'No data', color: '#5558', shape: 'line' }]} />
71+
</div>
72+
<VisXYContainer yDomain={[0, 10]} data={data} height={400} duration={props.duration}>
73+
<VisLine lineWidth={2} curveType={CurveType.Linear} x={x} y={y} color={color} interpolateMissingData/>
74+
<VisScatter size={2} x={x} y={y} color={color}/>
75+
<VisCrosshair template={tooltipTemplate} color={color}/>
76+
<VisTooltip/>
77+
<VisAxis type='x' tickFormat={(d: number) => `0${(Math.floor(d / 6)) + 1}:${d % 6}0pm`}/>
78+
<VisAxis type='y'/>
79+
</VisXYContainer>
80+
</div>
81+
)
82+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import React, { useState } from 'react'
2+
import { VisXYContainer, VisLine, VisAxis, VisScatter, VisCrosshair, VisTooltip, VisAnnotations } from '@unovis/react'
3+
import { CurveType } from '@unovis/ts'
4+
5+
import { ExampleViewerDurationProps } from '@src/components/ExampleViewer'
6+
7+
import s from './style.module.css'
8+
9+
export const title = 'Patchy Line Chart'
10+
export const subTitle = 'Various test cases'
11+
12+
type TestCase = {
13+
title: string;
14+
data: (number | undefined | null)[];
15+
}
16+
17+
const testCases: TestCase[] = [
18+
{ title: 'Gaps in middle', data: [3, 1, undefined, 7, undefined, 1, 1, undefined, 0.5, 4] },
19+
{ title: 'Longer gaps', data: [2, 3, undefined, undefined, undefined, 12, 10, undefined, undefined, 2] },
20+
{ title: 'Gaps at ends', data: [7, undefined, 9, 10, 7, 4, 5, 2, undefined, 10] },
21+
{ title: 'Gaps at true ends', data: [undefined, 2, 10, 4, 5, 2, 6, 2, 3, undefined] },
22+
{ title: 'Gaps surrounding single point', data: [5, 3, 6, undefined, 2, undefined, 10, 8, 9, 5] },
23+
{ title: 'All undefined', data: [undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined] },
24+
{ title: 'Single point', data: [undefined, undefined, undefined, undefined, 10, undefined] },
25+
{ title: 'Missing every other point', data: [3, undefined, 12, undefined, 7, undefined, 5, undefined, 12] },
26+
{ title: 'Includes undefined and null values', data: [3, 5, undefined, 6, 7, null, 9, 10, undefined, 4] },
27+
28+
]
29+
30+
export const component = (props: ExampleViewerDurationProps): JSX.Element => {
31+
type Datum = Record<string, number>
32+
const combined = Array.from({ length: 10 }, (_, i) => ({
33+
x: i,
34+
...(testCases.reduce((obj, d, j) => ({
35+
...obj,
36+
[`y${j}`]: d.data[i],
37+
}), {})),
38+
}))
39+
const x = (d: Datum): number => d.x
40+
const getY = (i: number) => (d: Datum) => d[`y${i}`]
41+
42+
const fallbacks = [undefined, 0, 5, 10]
43+
const [fallbackValue, setFallbackValue] = useState(fallbacks[0])
44+
const [interpolation, setInterpolation] = useState(true)
45+
const [showScatter, setShowScatter] = useState(true)
46+
47+
return (
48+
<div className={s.patchyLineExample}>
49+
<div className={s.inputs}>
50+
<label>
51+
Fallback value:
52+
<select onChange={e => setFallbackValue(fallbacks[Number(e.target.value)])}>
53+
{fallbacks.map((o, i) => <option value={i}>{String(o)}</option>)}
54+
</select>
55+
</label>
56+
<label>
57+
Interpolate:<input type='checkbox' checked={interpolation} onChange={e => setInterpolation(e.target.checked)}/>
58+
</label>
59+
<label>
60+
Show Scatter: <input type='checkbox' checked={showScatter} onChange={e => setShowScatter(e.target.checked)}/>
61+
</label>
62+
</div>
63+
<div className={s.singleLines}>
64+
{testCases.map((val, i) => (
65+
<VisXYContainer<Datum> data={combined} key={i} xDomain={[-0.2, 9.2]} yDomain={[0, 15]} height={200} width='100%'>
66+
<VisAnnotations items={[{ content: val.title, x: '50%', y: 0, textAlign: 'center' }]}/>
67+
<VisLine
68+
curveType={CurveType.Linear}
69+
duration={props.duration}
70+
fallbackValue={fallbackValue}
71+
interpolateMissingData={interpolation}
72+
x={x}
73+
y={getY(i)}
74+
/>
75+
{showScatter && <VisScatter excludeFromDomainCalculation size={2} x={x} y={d => getY(i)(d) ?? undefined}/>}
76+
<VisCrosshair template={(d: Datum) => `${d.x}, ${getY(i)(d)}`} color='var(--vis-color0)' strokeWidth='1px'/>
77+
<VisTooltip/>
78+
<VisAxis type='x'/>
79+
<VisAxis type='y' domainLine={false}/>
80+
</VisXYContainer>
81+
))}
82+
</div>
83+
</div>
84+
)
85+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
.patchyLineExample {
2+
width: 100%;
3+
}
4+
5+
.inputs {
6+
font-family: 'Courier New', Courier, monospace;
7+
font-size: smaller;
8+
margin-bottom: 12px;
9+
}
10+
11+
.inputs > label {
12+
display: flex;
13+
align-items: center;
14+
}
15+
16+
.singleLines {
17+
display: grid;
18+
width: 100%;
19+
grid-template-columns: repeat(3, 1fr);
20+
column-gap: 10px;
21+
}
22+

packages/ts/src/components/line/config.ts

+6
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ export interface LineConfigInterface<Datum> extends XYComponentConfigInterface<D
2424
highlightOnHover?: boolean;
2525
/** Optional link cursor. Default: `null` */
2626
cursor?: StringAccessor<Datum[]>;
27+
/** Enable interpolated line where data points are missing or fallbackValue is used.
28+
* You can customize the line's appearance with `--vis-line-gapfill-stroke-dasharray`
29+
* and `--vis-line-gapfill-stroke-opacity` CSS variables.
30+
* Default: `false` */
31+
interpolateMissingData?: boolean;
2732
}
2833

2934
export const LineDefaultConfig: LineConfigInterface<unknown> = {
@@ -34,4 +39,5 @@ export const LineDefaultConfig: LineConfigInterface<unknown> = {
3439
fallbackValue: undefined,
3540
highlightOnHover: false,
3641
cursor: null,
42+
interpolateMissingData: false,
3743
}

packages/ts/src/components/line/index.ts

+45-2
Original file line numberDiff line numberDiff line change
@@ -83,23 +83,48 @@ export class Line<Datum> extends XYComponentCore<Datum, LineConfigInterface<Datu
8383
const lineData: LineData[] = yAccessors.map(a => {
8484
const ld: LineDatum[] = data.map((d, i) => {
8585
const rawValue = getNumber(d, a, i)
86+
8687
// If `rawValue` is not numerical or if it's not finite (`NaN`, `undefined`, ...), we replace it with `config.fallbackValue`
8788
const value = (isNumber(rawValue) || (rawValue === null)) && isFinite(rawValue) ? rawValue : config.fallbackValue
89+
const defined = config.interpolateMissingData
90+
? (isNumber(rawValue) || (rawValue === null)) && isFinite(rawValue)
91+
: isFinite(value)
92+
8893
return {
8994
x: lineDataX[i],
9095
y: this.yScale(value ?? 0),
91-
defined: isFinite(value),
96+
defined,
9297
value,
9398
}
9499
})
95-
96100
const defined = ld.reduce((def, d) => (d.defined || def), false)
101+
102+
let validGap = false
103+
const gaps = ld.reduce((acc, d, i) => {
104+
// Gaps include fallback values if configured.
105+
if (!d.defined && isFinite(config.fallbackValue)) {
106+
acc.push({ ...d, defined: true })
107+
}
108+
109+
if (!d.defined && !validGap) validGap = true
110+
111+
const isEndpoint = (i > 0 && !ld[i - 1].defined) || (i < ld.length - 1 && !ld[i + 1].defined)
112+
if (d.defined && isEndpoint) {
113+
// If no undefined points have been found since the last endpoint, we insert one to enforce breaks between adjacent gaps.
114+
if (!validGap) acc.push({ ...d, defined: false })
115+
acc.push(d)
116+
validGap = false
117+
}
118+
return acc
119+
}, [])
120+
97121
// If the line consists only of `null` values, we'll still render it but it'll be invisible.
98122
// Such trick allows us to have better animated transitions.
99123
const visible = defined && ld.some(d => d.value !== null)
100124
return {
101125
values: ld,
102126
defined,
127+
gaps,
103128
visible,
104129
}
105130
})
@@ -123,12 +148,18 @@ export class Line<Datum> extends XYComponentCore<Datum, LineConfigInterface<Datu
123148
.attr('class', s.lineSelectionHelper)
124149
.attr('d', this._emptyPath())
125150

151+
linesEnter.append('path')
152+
.attr('class', s.interpolatedPath)
153+
.attr('d', this._emptyPath())
154+
.style('opacity', 0)
155+
126156
const linesMerged = linesEnter.merge(lines)
127157
linesMerged.style('cursor', (d, i) => getString(data, config.cursor, i))
128158
linesMerged.each((d, i, elements) => {
129159
const group = select(elements[i])
130160
const linePath = group.select<SVGPathElement>(`.${s.linePath}`)
131161
const lineSelectionHelper = group.select(`.${s.lineSelectionHelper}`)
162+
const lineGaps = group.select(`.${s.interpolatedPath}`)
132163

133164
const isLineVisible = d.visible
134165
const dashArray = getValue<Datum[], number[]>(data, config.lineDashArray, i)
@@ -153,6 +184,18 @@ export class Line<Datum> extends XYComponentCore<Datum, LineConfigInterface<Datu
153184
lineSelectionHelper
154185
.attr('d', svgPathD)
155186
.attr('visibility', isLineVisible ? null : 'hidden')
187+
188+
if (hasUndefinedSegments && config.interpolateMissingData) {
189+
smartTransition(lineGaps, duration)
190+
.attr('d', this.lineGen(d.gaps))
191+
.attr('stroke', getColor(data, config.color, i))
192+
.attr('stroke-width', config.lineWidth - 1)
193+
.style('opacity', 1)
194+
} else {
195+
lineGaps.transition()
196+
.duration(duration)
197+
.style('opacity', 0)
198+
}
156199
})
157200

158201
smartTransition(lines.exit(), duration)

packages/ts/src/components/line/style.ts

+12
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ export const globalStyles = injectGlobal`
55
--vis-line-cursor: default;
66
--vis-line-stroke-dasharray: none;
77
--vis-line-stroke-dashoffset: 0;
8+
9+
--vis-line-gapfill-stroke-dasharray: 2 3;
10+
--vis-line-gapfill-stroke-opacity: 0.8;
11+
--vis-line-gapfill-stroke-dashoffset: 0;
812
}
913
`
1014

@@ -35,3 +39,11 @@ export const lineSelectionHelper = css`
3539
export const dim = css`
3640
opacity: 0.2;
3741
`
42+
43+
export const interpolatedPath = css`
44+
label: interpolated-path;
45+
fill: none;
46+
stroke-dasharray: var(--vis-line-gapfill-stroke-dasharray);
47+
stroke-dashoffset: var(--vis-line-gapfill-stroke-dashoffset);
48+
stroke-opacity: var(--vis-line-gapfill-stroke-opacity);
49+
`
+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
/** Data type for Line Generator: [x, y, defined] */
22
export type LineDatum = { x: number; y: number; value: number | null | undefined; defined: boolean }
3-
export type LineData = { values: LineDatum[]; defined: boolean; visible: boolean }
3+
export type LineData = { values: LineDatum[]; gaps: LineDatum[]; defined: boolean; visible: boolean }

packages/website/docs/xy-charts/Line.mdx

+20
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Similar to [mutlti-color configuration](#for-multiple-lines), you can provide an
5353
customize each line.
5454

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

74+
### Line Interpolation
75+
Alternatively, you can set the `interpolateMissingData` property to `true` to fill in the data gaps with a dashed line.
76+
If `fallbackValue` is set, those values will be plotted on the inteprolated line.
77+
Otherwise, it will be a smooth curve between defined points, like below:
78+
79+
<XYWrapper {...lineProps()}
80+
data={[1, 3, 4, undefined, undefined, undefined, 5, 7, 9, 6].map((y, x) => ({ x, y }))}
81+
showAxes
82+
interpolateMissingData={true}
83+
/>
84+
85+
You can customize the appearance of of the interpolated line with the following CSS varibles:
86+
87+
```css
88+
--vis-line-gapfill-stroke-dasharray: 2 3;
89+
--vis-line-gapfill-stroke-opacity: 0.8;
90+
--vis-line-gapfill-stroke-dashoffset: 0;
91+
```
92+
7393
## Events
7494
```ts
7595
import { Line } from '@unovis/ts'

0 commit comments

Comments
 (0)