From bf8675882e16dce9d52a3f76eaf91f755b991a27 Mon Sep 17 00:00:00 2001 From: Rebecca Bol Date: Tue, 4 Feb 2025 17:21:15 -0800 Subject: [PATCH] Component | Line: Support interpolated dashed line for missing values --- .../src/components/line/line.component.ts | 10 ++- .../line/multi-patchy-line/index.tsx | 82 ++++++++++++++++++ .../xy-components/line/patchy-line/index.tsx | 85 +++++++++++++++++++ .../line/patchy-line/style.module.css | 22 +++++ packages/ts/src/components/line/config.ts | 6 ++ packages/ts/src/components/line/index.ts | 47 +++++++++- packages/ts/src/components/line/style.ts | 12 +++ packages/ts/src/components/line/types.ts | 2 +- packages/website/docs/xy-charts/Line.mdx | 20 +++++ 9 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 packages/dev/src/examples/xy-components/line/multi-patchy-line/index.tsx create mode 100644 packages/dev/src/examples/xy-components/line/patchy-line/index.tsx create mode 100644 packages/dev/src/examples/xy-components/line/patchy-line/style.module.css diff --git a/packages/angular/src/components/line/line.component.ts b/packages/angular/src/components/line/line.component.ts index 7e4a30795..6b3966cad 100644 --- a/packages/angular/src/components/line/line.component.ts +++ b/packages/angular/src/components/line/line.component.ts @@ -121,6 +121,12 @@ export class VisLineComponent implements LineConfigInterface, Afte /** Optional link cursor. Default: `null` */ @Input() cursor?: StringAccessor + + /** 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 | undefined @@ -142,8 +148,8 @@ export class VisLineComponent implements LineConfigInterface, Afte } private getConfig (): LineConfigInterface { - 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)[] keys.forEach(key => { if (config[key] === undefined) delete config[key] }) diff --git a/packages/dev/src/examples/xy-components/line/multi-patchy-line/index.tsx b/packages/dev/src/examples/xy-components/line/multi-patchy-line/index.tsx new file mode 100644 index 000000000..badac360f --- /dev/null +++ b/packages/dev/src/examples/xy-components/line/multi-patchy-line/index.tsx @@ -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 = { + 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 & { 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 = d => d.x + const [y, setY] = useState[]>() + const [color, setColor] = useState([]) + + 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(), ys: new Array>() }) + 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 => ` +
' : '">'} + ${item.name}: ${d[item.name] ?? '-'} +
` + ).join(''), [legendItems]) + + return ( +
+ +
+ + +
+ + + + + + `0${(Math.floor(d / 6)) + 1}:${d % 6}0pm`}/> + + +
+ ) +} diff --git a/packages/dev/src/examples/xy-components/line/patchy-line/index.tsx b/packages/dev/src/examples/xy-components/line/patchy-line/index.tsx new file mode 100644 index 000000000..a47cea07d --- /dev/null +++ b/packages/dev/src/examples/xy-components/line/patchy-line/index.tsx @@ -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 + 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 ( +
+
+ + + +
+
+ {testCases.map((val, i) => ( + data={combined} key={i} xDomain={[-0.2, 9.2]} yDomain={[0, 15]} height={200} width='100%'> + + + {showScatter && getY(i)(d) ?? undefined}/>} + `${d.x}, ${getY(i)(d)}`} color='var(--vis-color0)' strokeWidth='1px'/> + + + + + ))} +
+
+ ) +} diff --git a/packages/dev/src/examples/xy-components/line/patchy-line/style.module.css b/packages/dev/src/examples/xy-components/line/patchy-line/style.module.css new file mode 100644 index 000000000..72032a0eb --- /dev/null +++ b/packages/dev/src/examples/xy-components/line/patchy-line/style.module.css @@ -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; +} + diff --git a/packages/ts/src/components/line/config.ts b/packages/ts/src/components/line/config.ts index cb4545b25..24688ad28 100644 --- a/packages/ts/src/components/line/config.ts +++ b/packages/ts/src/components/line/config.ts @@ -24,6 +24,11 @@ export interface LineConfigInterface extends XYComponentConfigInterface; + /** 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 = { @@ -34,4 +39,5 @@ export const LineDefaultConfig: LineConfigInterface = { fallbackValue: undefined, highlightOnHover: false, cursor: null, + interpolateMissingData: false, } diff --git a/packages/ts/src/components/line/index.ts b/packages/ts/src/components/line/index.ts index cf6e05ef5..314c04b1a 100644 --- a/packages/ts/src/components/line/index.ts +++ b/packages/ts/src/components/line/index.ts @@ -83,23 +83,48 @@ export class Line extends XYComponentCore { 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, } }) @@ -123,12 +148,18 @@ export class Line extends XYComponentCore getString(data, config.cursor, i)) linesMerged.each((d, i, elements) => { const group = select(elements[i]) const linePath = group.select(`.${s.linePath}`) const lineSelectionHelper = group.select(`.${s.lineSelectionHelper}`) + const lineGaps = group.select(`.${s.interpolatedPath}`) const isLineVisible = d.visible const dashArray = getValue(data, config.lineDashArray, i) @@ -153,6 +184,18 @@ export class Line extends XYComponentCore +### 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: + + ({ 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'