diff --git a/packages/dev/src/examples/xy-components/timeline/index.tsx b/packages/dev/src/examples/xy-components/timeline/index.tsx index e8f9645de..65ba83506 100644 --- a/packages/dev/src/examples/xy-components/timeline/index.tsx +++ b/packages/dev/src/examples/xy-components/timeline/index.tsx @@ -13,7 +13,7 @@ export const transitionComponent: TransitionComponentProps = { }, component: (props) => ( - d.timestamp} duration={props.duration}/> + d.timestamp} duration={props.duration ?? 1000}/> ), } diff --git a/packages/dev/src/examples/xy-components/timeline/timeline-animation/index.tsx b/packages/dev/src/examples/xy-components/timeline/timeline-animation/index.tsx new file mode 100644 index 000000000..30fa158c0 --- /dev/null +++ b/packages/dev/src/examples/xy-components/timeline/timeline-animation/index.tsx @@ -0,0 +1,53 @@ +import React, { useEffect } from 'react' +import { VisXYContainer, VisTimeline } from '@unovis/react' + +import { ExampleViewerDurationProps } from '@src/components/ExampleViewer/index' +import { TextAlign } from '@unovis/ts' + +export const title = 'Animation Tweaking' +export const subTitle = 'Control enter/exit position' + +export const component = (props: ExampleViewerDurationProps): JSX.Element => { + const [shouldRenderData, setShouldRenderData] = React.useState(true) + const intervalIdRef = React.useRef(null) + const data = Array(10).fill(0).map((_, i) => ({ + timestamp: Date.now() + i * 1000 * 60 * 60 * 24, + length: 1000 * 60 * 60 * 24, + id: i.toString(), + type: `Row ${i}`, + lineWidth: 5 + Math.random() * 15, + })) + + type Datum = typeof data[number] + + useEffect(() => { + clearInterval(intervalIdRef.current!) + intervalIdRef.current = setInterval(() => { + setShouldRenderData(should => !should) + }, 2000) + }, []) + + return (<> + + data={shouldRenderData ? data : []} + height={300} + duration={1500} + > + d.id} + lineRow={(d: Datum) => d.type as string} + x={(d: Datum) => d.timestamp} + rowHeight={undefined} + lineWidth={(d) => d.lineWidth} + lineCap + showEmptySegments + showRowLabels + rowLabelTextAlign={TextAlign.Left} + duration={props.duration} + animationLineEnterPosition={[undefined, -110]} + animationLineExitPosition={[1000, undefined]} + /> + + + ) +} diff --git a/packages/dev/src/examples/xy-components/timeline/timeline-arrows/icon.svg b/packages/dev/src/examples/xy-components/timeline/timeline-arrows/icon.svg new file mode 100644 index 000000000..da82966f5 --- /dev/null +++ b/packages/dev/src/examples/xy-components/timeline/timeline-arrows/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/dev/src/examples/xy-components/timeline/timeline-arrows/index.tsx b/packages/dev/src/examples/xy-components/timeline/timeline-arrows/index.tsx new file mode 100644 index 000000000..576556666 --- /dev/null +++ b/packages/dev/src/examples/xy-components/timeline/timeline-arrows/index.tsx @@ -0,0 +1,82 @@ +import React, { useMemo, useState, useEffect } from 'react' +import { VisXYContainer, VisTimeline, VisAxis } from '@unovis/react' + +import { ExampleViewerDurationProps } from '@src/components/ExampleViewer/index' +import { Arrangement, TextAlign, TimelineArrow } from '@unovis/ts' + +// Icons +import icon from './icon.svg?raw' + +export const title = 'Timeline Arrows' +export const subTitle = 'Between the lines' + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const generateData = (length: number) => Array(length).fill(0).map((_, i) => ({ + timestamp: Date.now() + i * 1000 * 60 * 60 * 24 + Math.random() * 1000 * 60 * 60 * 24, + length: 1000 * 60 * 60 * 24, + id: i.toString(), + type: `Row ${i}`, + lineWidth: 5 + Math.random() * 15, +})) + +export const component = (props: ExampleViewerDurationProps): JSX.Element => { + const lineIconSize = 25 + const [data, setData] = useState(() => generateData(15)) + + useEffect(() => { + const interval = setInterval(() => { setData(generateData(12)) }, 6000) + return () => clearInterval(interval) + }, []) + + type Datum = typeof data[number] + + const arrows = data.map((d, i) => { + if (i === data.length - 1) return undefined + + return { + id: `arrow-${i}`, + xSource: d.timestamp + d.length, + xSourceOffsetPx: 12, + xTargetOffsetPx: 0, + lineSourceId: d.id, + lineTargetId: data[i + 1].id, + lineSourceMarginPx: (lineIconSize - d.lineWidth) / 2, + lineTargetMarginPx: 2, + } + }).filter(Boolean) as TimelineArrow[] + + const svgDefs = useMemo(() => `${icon}`, []) + return (<> + + data={data} + height={400} + svgDefs={svgDefs} + > + d.id} + lineRow={(d: Datum) => d.type as string} + x={(d: Datum) => d.timestamp} + rowHeight={40} + lineWidth={(d) => d.lineWidth} + lineCap + showEmptySegments + showRowLabels + rowLabelTextAlign={TextAlign.Left} + duration={props.duration} + lineEndIcon={'#circle_check_filled'} + lineEndIconSize={lineIconSize} + lineStartIconColor={'#fff'} + lineEndIconColor={'rgb(38, 86, 201)'} + lineEndIconArrangement={Arrangement.Outside} + arrows={arrows} + /> + new Date(x).toDateString()} + duration={props.duration} + /> + + + ) +} diff --git a/packages/dev/src/examples/xy-components/timeline/timeline-bleed/icon.svg b/packages/dev/src/examples/xy-components/timeline/timeline-bleed/icon.svg new file mode 100644 index 000000000..da82966f5 --- /dev/null +++ b/packages/dev/src/examples/xy-components/timeline/timeline-bleed/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/dev/src/examples/xy-components/timeline/timeline-bleed/index.tsx b/packages/dev/src/examples/xy-components/timeline/timeline-bleed/index.tsx new file mode 100644 index 000000000..f78b5ec65 --- /dev/null +++ b/packages/dev/src/examples/xy-components/timeline/timeline-bleed/index.tsx @@ -0,0 +1,48 @@ +import React, { useMemo } from 'react' +import { VisXYContainer, VisTimeline, VisAxis, VisTooltip, VisCrosshair } from '@unovis/react' +import { Arrangement } from '@unovis/ts' + +import { ExampleViewerDurationProps } from '@src/components/ExampleViewer/index' + +// Icons +import icon from './icon.svg?raw' + +export const title = 'Timeline: Bleed' +export const subTitle = 'Automatic bleed calculation' + +export const component = (props: ExampleViewerDurationProps): JSX.Element => { + const data = useMemo(() => [ + { timestamp: 0, duration: 20, row: 'Long Row' }, + { timestamp: 0, duration: 0, row: 'Empty Row 1' }, + { timestamp: 10, duration: 0, row: 'Empty Row 1' }, + { timestamp: 15, duration: 0, row: 'Empty Row 1' }, + { timestamp: 20, duration: 0, row: 'Empty Row 1' }, + { timestamp: 18, duration: 0, row: 'Empty Row 2' }, + { timestamp: 24, duration: 0, row: 'Empty Row 2' }, + { timestamp: 27, duration: 0, row: 'Empty Row 2' }, + ], []) + + + return (<> + + + lineRow={(d) => d.row as string} + lineDuration={(d) => d.duration} + x={(d) => d.timestamp} + rowHeight={50} + lineWidth={undefined} + showEmptySegments={true} + // lineEndIcon={'#circle_check_filled'} + lineStartIcon={'#circle_check_filled'} + // lineEndIconArrangement={Arrangement.Outside} + lineStartIconArrangement={Arrangement.Outside} + showRowLabels + duration={props.duration} + lineCap={true} + /> + (new Date(x).getTime()).toString()} duration={props.duration}/> + + + + ) +} diff --git a/packages/dev/src/examples/xy-components/timeline/timeline-empty-segments/index.tsx b/packages/dev/src/examples/xy-components/timeline/timeline-empty-segments/index.tsx index 2c55e6d9c..7ea890907 100644 --- a/packages/dev/src/examples/xy-components/timeline/timeline-empty-segments/index.tsx +++ b/packages/dev/src/examples/xy-components/timeline/timeline-empty-segments/index.tsx @@ -1,23 +1,34 @@ -import React from 'react' +import React, { useMemo } from 'react' import { VisXYContainer, VisTimeline, VisAxis, VisTooltip, VisCrosshair } from '@unovis/react' import { TimeDataRecord, generateTimeSeries } from '@src/utils/data' import { ExampleViewerDurationProps } from '@src/components/ExampleViewer/index' -export const title = 'Timeline: Negative Lengths' -export const subTitle = 'Generated Data' +export const title = 'Timeline: Empty Segments' +export const subTitle = 'Small and Negative Lengths' export const component = (props: ExampleViewerDurationProps): JSX.Element => { const [showEmptySegments, toggleEmptySegments] = React.useState(true) - const data = generateTimeSeries(10).map((d, i) => ({ + const [lineCap, setLineCap] = React.useState(false) + const data = useMemo(() => generateTimeSeries(50).map((d, i) => ({ ...d, length: [d.length, 0, -d.length][i % 3], type: ['Positive', 'Zero', 'Negative'][i % 3], - })) + })), []) + return (<> - +
toggleEmptySegments(e.target.checked)}/>
+
setLineCap(e.target.checked)}/>
data={data} height={200}> - d.timestamp} rowHeight={50} showEmptySegments={showEmptySegments} showLabels duration={props.duration}/> + d.type as string} + x={(d: TimeDataRecord) => d.timestamp} + rowHeight={50} + showEmptySegments={showEmptySegments} + showRowLabels + duration={props.duration} + lineCap={lineCap} + /> new Date(x).toDateString()} duration={props.duration}/> `${Intl.DateTimeFormat().format(d.timestamp)}: ${Math.round(d.length / Math.pow(10, 7))}m`}/> diff --git a/packages/dev/src/examples/xy-components/timeline/timeline-label-alignment/index.tsx b/packages/dev/src/examples/xy-components/timeline/timeline-label-alignment/index.tsx new file mode 100644 index 000000000..6ae0c125b --- /dev/null +++ b/packages/dev/src/examples/xy-components/timeline/timeline-label-alignment/index.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import { VisXYContainer, VisTimeline, VisAxis } from '@unovis/react' + +import { TimeDataRecord, generateTimeSeries } from '@src/utils/data' +import { ExampleViewerDurationProps } from '@src/components/ExampleViewer/index' +import { TextAlign } from '@unovis/ts' + +export const title = 'Label Alignment & Style' +export const subTitle = 'X Domain, auto line width' + +export const component = (props: ExampleViewerDurationProps): JSX.Element => { + const data = generateTimeSeries(25, 20, 10).map((d, i) => ({ + ...d, + type: `Row ${i}`, + })) + + const xDomain = [data[0].timestamp + 100000, data[data.length - 1].timestamp - 10000] as [number, number] + return (<> + + data={data} + height={300} + xDomain={xDomain} + margin={{ + left: 10, + right: 10, + }} + > + d.type as string} + x={(d: TimeDataRecord) => d.timestamp} + rowHeight={20} + lineWidth={undefined} + lineCap + showRowLabels + rowLabelTextAlign={TextAlign.Left} + duration={props.duration} + rowLabelStyle={rowLabel => rowLabel.label === 'Row 24' + ? ({ fill: 'rgb(237, 116, 128)', cursor: 'pointer', 'text-decoration': 'underline', transform: 'translateX(5px)' }) + : undefined + } + /> + new Date(x).toDateString()} + duration={props.duration} + /> + + + ) +} diff --git a/packages/dev/src/examples/xy-components/timeline/timeline-line-icons/icon.svg b/packages/dev/src/examples/xy-components/timeline/timeline-line-icons/icon.svg new file mode 100644 index 000000000..c59bb13fc --- /dev/null +++ b/packages/dev/src/examples/xy-components/timeline/timeline-line-icons/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/dev/src/examples/xy-components/timeline/timeline-line-icons/index.tsx b/packages/dev/src/examples/xy-components/timeline/timeline-line-icons/index.tsx new file mode 100644 index 000000000..e7ce8bed1 --- /dev/null +++ b/packages/dev/src/examples/xy-components/timeline/timeline-line-icons/index.tsx @@ -0,0 +1,55 @@ +import React, { useMemo } from 'react' +import { VisXYContainer, VisTimeline, VisAxis } from '@unovis/react' + +import { generateTimeSeries } from '@src/utils/data' +import { ExampleViewerDurationProps } from '@src/components/ExampleViewer/index' +import { Arrangement, TextAlign } from '@unovis/ts' + +// Icons +import icon from './icon.svg?raw' + +export const title = 'Line Icons' +export const subTitle = 'Start / End icons' + +export const component = (props: ExampleViewerDurationProps): JSX.Element => { + const data = generateTimeSeries(25, 20, 10).map((d, i) => ({ + ...d, + type: `Row ${i}`, + lineWidth: 5 + Math.random() * 15, + })) + type Datum = typeof data[number] + + const svgDefs = useMemo(() => `${icon}`, []) + return (<> + + data={data} + height={300} + svgDefs={svgDefs} + > + d.type as string} + x={(d: Datum) => d.timestamp} + rowHeight={20} + lineWidth={(d) => d.lineWidth} + lineCap + rowLabelTextAlign={TextAlign.Left} + duration={props.duration} + lineStartIcon={'#circle_pending_filled'} + lineEndIcon={'#circle_check_filled'} + lineEndIconSize={25} + lineStartIconColor={'rgb(38, 86, 201)'} + lineEndIconColor={'rgb(38, 86, 201)'} + lineEndIconArrangement={Arrangement.Outside} + lineStartIconArrangement={Arrangement.Outside} + showRowLabels + /> + new Date(x).toDateString()} + duration={props.duration} + /> + + + ) +} diff --git a/packages/dev/src/examples/xy-components/timeline/timeline-row-icons/icon.svg b/packages/dev/src/examples/xy-components/timeline/timeline-row-icons/icon.svg new file mode 100644 index 000000000..da21d23d0 --- /dev/null +++ b/packages/dev/src/examples/xy-components/timeline/timeline-row-icons/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/dev/src/examples/xy-components/timeline/timeline-row-icons/index.tsx b/packages/dev/src/examples/xy-components/timeline/timeline-row-icons/index.tsx new file mode 100644 index 000000000..d6c24ce23 --- /dev/null +++ b/packages/dev/src/examples/xy-components/timeline/timeline-row-icons/index.tsx @@ -0,0 +1,49 @@ +import React, { useMemo } from 'react' +import { VisXYContainer, VisTimeline, VisAxis } from '@unovis/react' + +import { generateTimeSeries } from '@src/utils/data' +import { ExampleViewerDurationProps } from '@src/components/ExampleViewer/index' +import { TextAlign } from '@unovis/ts' + +// Icons +import icon from './icon.svg?raw' + +export const title = 'Row Icons' +export const subTitle = 'Before text labels' + +export const component = (props: ExampleViewerDurationProps): JSX.Element => { + const data = generateTimeSeries(7, 20, 10).map((d, i) => ({ + ...d, + type: `Row ${i}`, + lineWidth: 5 + Math.random() * 15, + })) + type Datum = typeof data[number] + + const svgDefs = useMemo(() => `${icon}`, []) + return (<> + + data={data} + height={300} + svgDefs={svgDefs} + > + d.type as string} + x={(d: Datum) => d.timestamp} + rowHeight={undefined} + lineWidth={(d) => d.lineWidth} + lineCap + rowLabelTextAlign={TextAlign.Left} + duration={props.duration} + rowIcon={() => ({ href: '#chevron_down', size: 20, color: 'rgb(38, 86, 201)' })} + showRowLabels + /> + new Date(x).toDateString()} + duration={props.duration} + /> + + + ) +} diff --git a/packages/dev/src/examples/xy-components/timeline/timeline-tooltip/index.tsx b/packages/dev/src/examples/xy-components/timeline/timeline-tooltip/index.tsx index a694666b9..c12118237 100644 --- a/packages/dev/src/examples/xy-components/timeline/timeline-tooltip/index.tsx +++ b/packages/dev/src/examples/xy-components/timeline/timeline-tooltip/index.tsx @@ -1,6 +1,6 @@ import React from 'react' import { VisXYContainer, VisTimeline, VisAxis, VisTooltip } from '@unovis/react' -import { Timeline } from '@unovis/ts' +import { Timeline, TimelineRowLabel } from '@unovis/ts' import { TimeDataRecord, generateTimeSeries } from '@src/utils/data' import { ExampleViewerDurationProps } from '@src/components/ExampleViewer/index' @@ -14,12 +14,19 @@ export const component = (props: ExampleViewerDurationProps): JSX.Element => { })) return (<> data={data} height={300}> - d.timestamp} rowHeight={50} lineWidth={10} showLabels duration={props.duration}/> + d.type as string} + x={(d: TimeDataRecord) => d.timestamp} + rowHeight={50} + lineWidth={10} + showRowLabels + duration={props.duration} + /> new Date(x).toDateString()} duration={props.duration}/> `${(new Date(d.timestamp)).toDateString()} — ${(new Date(d.timestamp + d.length)).toDateString()}`, - [Timeline.selectors.row]: (label: string) => `Timeline Row ${label}`, + [Timeline.selectors.row]: (l: TimelineRowLabel) => `Timeline Row ${l.label}`, }}/> diff --git a/packages/ts/src/components/axis/index.ts b/packages/ts/src/components/axis/index.ts index 41c946184..69f423747 100644 --- a/packages/ts/src/components/axis/index.ts +++ b/packages/ts/src/components/axis/index.ts @@ -13,7 +13,7 @@ import { FitMode, TextAlign, TrimMode, UnovisText, UnovisTextOptions, VerticalAl // Utils import { smartTransition } from 'utils/d3' -import { renderTextToSvgTextElement, trimSVGText } from 'utils/text' +import { renderTextToSvgTextElement, textAlignToAnchor, trimSVGText } from 'utils/text' import { isEqual } from 'utils/data' import { rectIntersect } from 'utils/misc' @@ -411,7 +411,7 @@ export class Axis extends XYComponentCore text') - const textAnchor = this._getTickTextAnchor(tickTextAlign as TextAlign) + const textAnchor = textAlignToAnchor(tickTextAlign as TextAlign) const translateX = type === AxisType.X ? 0 : this._getYTickTextTranslate(tickTextAlign as TextAlign, position as Position) @@ -422,15 +422,6 @@ export class Axis extends XYComponentCore extends Partial extends WithOptional, 'y'> { + // Items (Lines) + /** @deprecated This property has been renamed to `key` */ + type?: StringAccessor; + /** @deprecated This property has been renamed to `lineDuration` */ + length?: NumericAccessor; + /** @deprecated This property has been renamed to `lineCursor` */ + cursor?: StringAccessor; + /** Timeline item row accessor function. Records with the `lineRow` will be plotted in one row. Default: `undefined` */ + lineRow?: StringAccessor; + /** Timeline item duration accessor function. Default: `undefined`. Falls back to the deprecated `length` property */ + lineDuration?: NumericAccessor; /** Timeline item color accessor function. Default: `d => d.color` */ color?: ColorAccessor; /** Width of the timeline items. Default: `8` */ lineWidth?: NumericAccessor; /** Display rounded ends for timeline items. Default: `true` */ lineCap?: boolean; + /** Provide a href to an SVG defined in container's `svgDefs` to display an icon at the start of the line. Default: undefined */ + lineStartIcon?: StringAccessor; + /** Line start icon color accessor function. Default: `undefined` */ + lineStartIconColor?: StringAccessor; + /** Line start icon size accessor function. Default: `undefined` */ + lineStartIconSize?: NumericAccessor; + /** Line start icon arrangement configuration. Controls how the icon is positioned relative to the line. + * Accepts values from the Arrangement enum: `Arrangement.Start`, `Arrangement.Middle`, `Arrangement.End` or a string equivalent. + * Default: `Arrangement.Inside` */ + lineStartIconArrangement?: GenericAccessor; + /** Provide a href to an SVG defined in container's `svgDefs` to display an icon at the end of the line. Default: undefined */ + lineEndIcon?: StringAccessor; + /** Line end icon color accessor function. Default: `undefined` */ + lineEndIconColor?: StringAccessor; + /** Line end icon size accessor function. Default: `undefined` */ + lineEndIconSize?: NumericAccessor; + /** Line end icon arrangement configuration. Controls how the icon is positioned relative to the line. + * Accepts values from the Arrangement enum: `Arrangement.Start`, `Arrangement.Middle`, `Arrangement.End` or a string equivalent. + * Default: `Arrangement.Inside` */ + lineEndIconArrangement?: GenericAccessor; + /** Configurable Timeline item cursor when hovering over. Default: `undefined` */ + lineCursor?: StringAccessor; + /** Sets the minimum line length to 1 pixel for better visibility of small values. Default: `false` */ + showEmptySegments?: boolean; + /** Timeline row height. Default: `22` */ rowHeight?: number; - /** Timeline item length accessor function. Default: `d => d.length` */ - length?: NumericAccessor; - /** Timeline item type accessor function. Records of one type will be plotted in one row. Default: `d => d.type` */ - type?: StringAccessor; - /** Configurable Timeline item cursor when hovering over. Default: `null` */ - cursor?: StringAccessor; - /** Show item type labels when set to `true`. Default: `false` */ + /** Alternating row colors. Default: `true` */ + alternatingRowColors?: boolean; + + // Row Labels + /** @deprecated This property has been renamed to `showRowLabels */ showLabels?: boolean; - /** Fixed label width in pixels. Labels longer than the specified value will be trimmed. Default: `undefined` */ + /** @deprecated This property has been renamed to `rowLabelWidth */ labelWidth?: number; - /** Maximum label width in pixels. Labels longer than the specified value will be trimmed. Default: `120` */ + /** @deprecated This property has been renamed to `rowMaxLabelWidth */ maxLabelWidth?: number; - /** Alternating row colors. Default: `true` */ - alternatingRowColors?: boolean; + + /** Show row labels when set to `true`. Default: `false`. Falls back to deprecated `showLabels` */ + showRowLabels?: boolean; + /** Row label style as an object with the `{ [property-name]: value }` format. Default: `undefined` */ + rowLabelStyle?: GenericAccessor, TimelineRowLabel>; + /** Row label formatter function. Default: `undefined` */ + rowLabelFormatter?: (key: string, items: Datum[], i: number) => string; + /** Provide an icon href to be displayed before the row label. Default: `undefined` */ + rowIcon?: (key: string, items: Datum[], i: number) => TimelineRowIcon | undefined; + /** Fixed label width in pixels. Labels longer than the specified value will be trimmed. Default: `undefined`. Falls back to deprecated `labelWidth`. */ + rowLabelWidth?: number; + /** Maximum label width in pixels. Labels longer than the specified value will be trimmed. Default: `undefined`. Falls back to deprecated `maxLabelWidth`. */ + rowMaxLabelWidth?: number; + /** Text alignment for labels: `TextAlign.Left`, `TextAlign.Center` or `TextAlign.Right`. Default: `TextAlign.Right` */ + rowLabelTextAlign?: TextAlign | `${TextAlign}`; + + // Arrows + arrows?: TimelineArrow[]; + + // Animation + /** Control the animation by specify the initial position for new lines as [x, y]. Default: `undefined` */ + animationLineEnterPosition?: + [number | undefined | null, number | undefined | null] | + ((d: Datum & TimelineLineRenderState, i: number, data: (Datum & TimelineLineRenderState)[]) => [number | undefined, number | undefined]) | + undefined; + /** Control the animation by specify the destination position for exiting lines as [x, y]. Default: `undefined` */ + animationLineExitPosition?: [number | undefined | null, number | undefined | null] | + ((d: Datum & TimelineLineRenderState, i: number, data: (Datum & TimelineLineRenderState)[]) => [number | undefined, number | undefined]) | + undefined; + + // Callbacks /** Scrolling callback function: `(scrollTop: number) => void`. Default: `undefined` */ onScroll?: (scrollTop: number) => void; - /** Sets the minimum line length to 1 pixel for better visibility of small values. Default: `false` */ - showEmptySegments?: boolean; } export const TimelineDefaultConfig: TimelineConfigInterface = { ...XYComponentDefaultConfig, id: undefined, + + // Items (Lines) + cursor: undefined, // Deprecated (see above) + type: (d: unknown): string => (d as { type: string }).type, // Deprecated (see above) + length: (d: unknown): number => (d as { length: number }).length, // Deprecated (see above) color: (d: unknown): string => (d as { color: string }).color, + lineRow: undefined, + lineDuration: undefined, lineWidth: 8, lineCap: false, + lineCursor: undefined, + showEmptySegments: false, + lineStartIcon: undefined, + lineStartIconColor: undefined, + lineStartIconSize: undefined, + lineStartIconArrangement: Arrangement.Inside, + lineEndIcon: undefined, + lineEndIconColor: undefined, + lineEndIconSize: undefined, + lineEndIconArrangement: Arrangement.Inside, + + // Rows rowHeight: 22, - length: (d: unknown): number => (d as { length: number }).length, - type: (d: unknown): string => (d as { type: string }).type, - cursor: null, - labelWidth: undefined, - showLabels: false, - maxLabelWidth: 120, alternatingRowColors: true, + + // Row Labels + showLabels: false, // Deprecated (see above) + labelWidth: undefined, // Deprecated (see above) + maxLabelWidth: 120, // Deprecated (see above) + + showRowLabels: undefined, + rowLabelFormatter: undefined, + rowIcon: undefined, + rowLabelStyle: undefined, + rowLabelWidth: undefined, + rowMaxLabelWidth: undefined, + rowLabelTextAlign: TextAlign.Right, + + // Arrows + arrows: undefined, + + // Animation + animationLineEnterPosition: undefined, + + // Callbacks onScroll: undefined, - showEmptySegments: false, } diff --git a/packages/ts/src/components/timeline/constants.ts b/packages/ts/src/components/timeline/constants.ts new file mode 100644 index 000000000..3bb9bca4c --- /dev/null +++ b/packages/ts/src/components/timeline/constants.ts @@ -0,0 +1,3 @@ +export const TIMELINE_DEFAULT_ARROW_HEAD_LENGTH = 8 +export const TIMELINE_DEFAULT_ARROW_HEAD_WIDTH = 6 +export const TIMELINE_DEFAULT_ARROW_MARGIN = 3 diff --git a/packages/ts/src/components/timeline/index.ts b/packages/ts/src/components/timeline/index.ts index 4dc9dbbe2..19103b0c1 100644 --- a/packages/ts/src/components/timeline/index.ts +++ b/packages/ts/src/components/timeline/index.ts @@ -1,17 +1,22 @@ import { select, Selection } from 'd3-selection' import { Transition } from 'd3-transition' +import { max, min, minIndex } from 'd3-array' import { scaleOrdinal, ScaleOrdinal } from 'd3-scale' import { drag, D3DragEvent } from 'd3-drag' -import { max } from 'd3-array' // Core import { XYComponentCore } from 'core/xy-component' // Utils -import { isNumber, unique, arrayOfIndices, getMin, getMax, getString, getNumber } from 'utils/data' +import { isNumber, arrayOfIndices, getMin, getMax, getString, getNumber, getValue, groupBy, isPlainObject, isFunction } from 'utils/data' import { smartTransition } from 'utils/d3' import { getColor } from 'utils/color' -import { trimSVGText } from 'utils/text' +import { textAlignToAnchor, trimSVGText } from 'utils/text' +import { arrowPolylinePath } from 'utils/path' +import { guid } from 'utils/misc' + +// Types +import { TextAlign, Spacing, Arrangement } from 'types' // Config import { TimelineDefaultConfig, TimelineConfigInterface } from './config' @@ -19,12 +24,27 @@ import { TimelineDefaultConfig, TimelineConfigInterface } from './config' // Styles import * as s from './style' +// Local Types +import type { TimelineArrow, TimelineArrowRenderState, TimelineLineRenderState, TimelineRowLabel } from './types' + +// Constants +import { TIMELINE_DEFAULT_ARROW_HEAD_LENGTH, TIMELINE_DEFAULT_ARROW_HEAD_WIDTH, TIMELINE_DEFAULT_ARROW_MARGIN } from './constants' + +// Utils +import { getIconBleed } from './utils' + export class Timeline extends XYComponentCore> { static selectors = s protected _defaultConfig = TimelineDefaultConfig as TimelineConfigInterface public config: TimelineConfigInterface = this._defaultConfig events = { + [Timeline.selectors.background]: { + wheel: this._onMouseWheel.bind(this), + }, + [Timeline.selectors.label]: { + wheel: this._onMouseWheel.bind(this), + }, [Timeline.selectors.rows]: { wheel: this._onMouseWheel.bind(this), }, @@ -35,8 +55,10 @@ export class Timeline extends XYComponentCore private _rowsGroup: Selection + private _arrowsGroup: Selection private _linesGroup: Selection private _labelsGroup: Selection + private _rowIconsGroup: Selection private _scrollBarGroup: Selection private _scrollBarBackground: Selection private _scrollBarHandle: Selection @@ -46,6 +68,14 @@ export class Timeline extends XYComponentCore constructor (config?: TimelineConfigInterface) { super() @@ -54,10 +84,20 @@ export class Timeline extends XYComponentCore extends XYComponentCore): void { + super.setConfig(config) + } + + public setData (data: Datum[]): void { + super.setData(data) + } + + get bleed (): Spacing { const { config, datamodel: { data } } = this + const rowLabels = this._getRowLabels(data) + const rowHeight = config.rowHeight || (this._height / rowLabels.length) + const hasIcons = rowLabels.some(l => l.iconHref) + const maxIconSize = max(rowLabels.map(l => l.iconSize || 0)) // We calculate the longest label width to set the bleed values accordingly - let labelsBleed = 0 - if (config.showLabels) { - if (config.labelWidth) labelsBleed = config.labelWidth + this._labelMargin + if (config.showRowLabels ?? config.showLabels) { + if (config.rowLabelWidth ?? config.labelWidth) this._labelWidth = (config.rowLabelWidth ?? config.labelWidth) + this._labelMargin else { - const recordLabels = this._getRecordLabels(data) - const longestLabel = recordLabels.reduce((acc, val) => acc.length > val.length ? acc : val, '') + const longestLabel = rowLabels.reduce((longestLabel, l) => longestLabel.formattedLabel.length > l.formattedLabel.length ? longestLabel : l, rowLabels[0]) const label = this._labelsGroup.append('text') .attr('class', s.label) - .text(longestLabel) - .call(trimSVGText, config.maxLabelWidth) + .text(longestLabel?.formattedLabel || '') + .call(trimSVGText, config.rowMaxLabelWidth ?? config.maxLabelWidth) + const labelWidth = label.node().getBBox().width - this._labelsGroup.empty() + label.remove() const tolerance = 1.15 // Some characters are wider than others so we add a little of extra space to take that into account - labelsBleed = labelWidth ? tolerance * labelWidth + this._labelMargin : 0 + this._labelWidth = labelWidth ? tolerance * labelWidth + this._labelMargin : 0 } } - const maxLineWidth = this._getMaxLineWidth() + // There can be multiple start / end items with the same timestamp, so we need to find the shortest one + const minTimestamp = min(data, (d, i) => getNumber(d, config.x, i)) + const dataMin = data.filter((d, i) => getNumber(d, config.x, i) === minTimestamp) + const dataMinShortestItemIdx = minIndex(dataMin, (d, i) => this._getLineDuration(d, i)) + const firstItemIdx = data.findIndex(d => d === dataMin[dataMinShortestItemIdx]) + const firstItem = data[firstItemIdx] + + const maxTimestamp = max(data, (d, i) => getNumber(d, config.x, i) + this._getLineDuration(d, i)) + const dataMax = data.filter((d, i) => getNumber(d, config.x, i) + this._getLineDuration(d, i) === maxTimestamp) + const dataMaxShortestItemIdx = minIndex(dataMax, (d, i) => this._getLineDuration(d, i)) + const lastItemIdx = data.findIndex(d => d === dataMax[dataMaxShortestItemIdx]) + const lastItem = data[lastItemIdx] + + // Small segments bleed + const lineBleed = [1, 1] as [number, number] + if (config.showEmptySegments && config.lineCap && firstItem && lastItem) { + const firstItemStart = getNumber(firstItem, config.x, firstItemIdx) + const firstItemEnd = getNumber(firstItem, config.x, firstItemIdx) + this._getLineDuration(firstItem, firstItemIdx) + const lastItemStart = getNumber(lastItem, config.x, lastItemIdx) + const lastItemEnd = getNumber(lastItem, config.x, lastItemIdx) + this._getLineDuration(lastItem, lastItemIdx) + const fullTimeRange = lastItemEnd - firstItemStart + const firstItemHeight = this._getLineWidth(firstItem, firstItemIdx, rowHeight) + const lastItemHeight = this._getLineWidth(lastItem, lastItemIdx, rowHeight) + + if ((firstItemEnd - firstItemStart) / fullTimeRange * this._width < firstItemHeight) lineBleed[0] = firstItemHeight / 2 + if ((lastItemEnd - lastItemStart) / fullTimeRange * this._width < lastItemHeight) lineBleed[1] = lastItemHeight / 2 + } + this._lineBleed = lineBleed + + // Icon bleed + const iconBleed = [0, 0] as [number, number] + if (config.lineStartIcon) { + iconBleed[0] = max(data, (d, i) => getIconBleed(d, i, config.lineStartIcon, config.lineStartIconSize, config.lineStartIconArrangement, rowHeight)) + } + + if (config.lineEndIcon) { + iconBleed[1] = max(data, (d, i) => getIconBleed(d, i, config.lineEndIcon, config.lineEndIconSize, config.lineEndIconArrangement, rowHeight)) + } + + this._rowIconBleed = iconBleed + return { top: 0, bottom: 0, - left: maxLineWidth / 2 + labelsBleed, - right: maxLineWidth / 2 + this._scrollBarWidth + this._scrollBarMargin, + left: this._labelWidth + iconBleed[0] + (hasIcons ? maxIconSize : 0) + lineBleed[0], + right: this._scrollBarWidth + this._scrollBarMargin + iconBleed[1] + lineBleed[1], } } @@ -113,14 +204,15 @@ export class Timeline extends XYComponentCore() + .range(arrayOfIndices(numRowLabels)) + .domain(rowLabels.map(l => l.label)) - // Ordinal scale to handle records on the same type - const ordinalScale: ScaleOrdinal = scaleOrdinal() - ordinalScale.range(arrayOfIndices(numUniqueRecords)) + const lineDataPrepared = this._prepareLinesData(data, yOrdinalScale, rowHeight) // Invisible Background rect to track events this._background @@ -128,68 +220,199 @@ export class Timeline extends XYComponentCore>(`.${s.rowIcon}`) + .data(rowLabels.filter(d => d.iconSize), l => l?.label) + + const rowIconsEnter = rowIcons.enter().append('use') + .attr('class', s.rowIcon) + .attr('x', 0) + .attr('width', l => l.iconSize) + .attr('height', l => l.iconSize) + .attr('y', l => yStart + (yOrdinalScale(l.label) + 0.5) * rowHeight - l.iconSize / 2) + .style('opacity', 0) + + smartTransition(rowIconsEnter.merge(rowIcons), duration) + .attr('href', l => l.iconHref) + .attr('x', 0) + .attr('y', l => yStart + (yOrdinalScale(l.label) + 0.5) * rowHeight - l.iconSize / 2) + .attr('width', l => l.iconSize) + .attr('height', l => l.iconSize) + .style('color', l => l.iconColor) + .style('opacity', 1) + + smartTransition(rowIcons.exit(), duration) + .style('opacity', 0) + .remove() + // Labels - const labels = this._labelsGroup.selectAll(`.${s.label}`) - .data(config.showLabels ? recordLabelsUnique : []) + const labels = this._labelsGroup.selectAll>(`.${s.label}`) + .data((config.showRowLabels ?? config.showLabels) ? rowLabels : [], l => l?.label) + + const labelOffset = config.rowLabelTextAlign === TextAlign.Center ? this._labelWidth / 2 + : config.rowLabelTextAlign === TextAlign.Left ? this._labelWidth + : this._labelMargin + const xStart = xRange[0] - this._rowIconBleed[0] - this._lineBleed[0] + const labelXStart = xStart - labelOffset const labelsEnter = labels.enter().append('text') .attr('class', s.label) + .attr('x', labelXStart) + .attr('y', l => yStart + (yOrdinalScale(l.label) + 0.5) * rowHeight) + .style('opacity', 0) - labelsEnter.merge(labels) - .attr('x', xRange[0] - maxLineWidth / 2 - this._labelMargin) - .attr('y', (label, i) => yStart + (ordinalScale(label) + 0.5) * config.rowHeight) - .text(label => label) + const labelsMerged = labelsEnter.merge(labels) + .text(l => l.formattedLabel) .each((label, i, els) => { - trimSVGText(select(els[i]), config.labelWidth || config.maxLabelWidth) + const labelSelection = select(els[i]) + trimSVGText(labelSelection, (config.rowLabelWidth ?? config.labelWidth) || (config.rowMaxLabelWidth ?? config.maxLabelWidth)) + + // Apply custom label style if it has been provided + const customStyle = getValue(label, config.rowLabelStyle) + if (!isPlainObject(customStyle)) return + + for (const [prop, value] of Object.entries(customStyle)) { + labelSelection.style(prop, value) + } }) + .style('text-anchor', textAlignToAnchor(config.rowLabelTextAlign as TextAlign)) - labels.exit().remove() + smartTransition(labelsMerged, duration) + .attr('x', labelXStart) + .attr('y', l => yStart + (yOrdinalScale(l.label) + 0.5) * rowHeight) + .style('opacity', 1) + + smartTransition(labels.exit(), duration) + .style('opacity', 0) + .remove() // Row background rects - const xStart = xRange[0] - const numRows = Math.max(Math.floor(yHeight / config.rowHeight), numUniqueRecords) - const recordTypes: (string | undefined)[] = Array(numRows).fill(null).map((_, i) => recordLabelsUnique[i]) + const timelineWidth = xRange[1] - xRange[0] + this._rowIconBleed[0] + this._rowIconBleed[1] + this._lineBleed[0] + this._lineBleed[1] + const numRows = Math.max(Math.floor(yHeight / rowHeight), numRowLabels) + const recordTypes = Array(numRows).fill(null).map((_, i) => rowLabels[i]) const rects = this._rowsGroup.selectAll(`.${s.row}`) .data(recordTypes) const rectsEnter = rects.enter().append('rect') .attr('class', s.row) + .attr('x', xStart) + .attr('width', timelineWidth) + .attr('y', (_, i) => yStart + i * rowHeight) + .attr('height', rowHeight) + .style('opacity', 0) - rectsEnter.merge(rects) + const rectsMerged = rectsEnter.merge(rects) .classed(s.rowOdd, config.alternatingRowColors ? (_, i) => !(i % 2) : null) - .attr('x', xStart - maxLineWidth / 2) - .attr('width', xRange[1] - xStart + maxLineWidth) - .attr('y', (_, i) => yStart + i * config.rowHeight) - .attr('height', config.rowHeight) - rects.exit().remove() + smartTransition(rectsMerged, duration) + .attr('x', xStart) + .attr('width', timelineWidth) + .attr('y', (_, i) => yStart + i * rowHeight) + .attr('height', rowHeight) + .style('opacity', 1) + + smartTransition(rects.exit(), duration) + .style('opacity', 0) + .remove() // Lines - const lines = this._linesGroup.selectAll(`.${s.line}`) - .data(data, (d: Datum, i) => getString(d, config.id, i) ?? [ - this._getRecordType(d, i), getNumber(d, config.x, i), - ].join('-')) + const lines = this._linesGroup.selectAll(`.${s.lineGroup}`) + .data(lineDataPrepared, (d: Datum & TimelineLineRenderState) => d._id) - const linesEnter = lines.enter().append('rect') - .attr('class', s.line) - .classed(s.rowOdd, config.alternatingRowColors - ? (d, i) => !(recordLabelsUnique.indexOf(this._getRecordType(d, i)) % 2) - : null) - .style('fill', (d, i) => getColor(d, config.color, ordinalScale(this._getRecordType(d, i)))) - .call(this._positionLines.bind(this), ordinalScale) - .attr('transform', 'translate(0, 10)') + const linesEnter = lines.enter().append('g') + .attr('class', s.lineGroup) .style('opacity', 0) + .attr('transform', (d, i) => { + const configuredPos = isFunction(config.animationLineEnterPosition) + ? config.animationLineEnterPosition(d, i, lineDataPrepared) + : config.animationLineEnterPosition + const [x, y] = [configuredPos?.[0] ?? d._xPx, configuredPos?.[1] ?? d._yPx] + return `translate(${x}, ${y})` + }) - const linesMerged = linesEnter.merge(lines) - .style('fill', (d, i) => getColor(d, config.color, ordinalScale(this._getRecordType(d, i)))) - .style('cursor', (d, i) => getString(d, config.cursor, i)) - .call(this._positionLines.bind(this), ordinalScale) + linesEnter.append('rect') + .attr('class', s.line) + .style('fill', (d, i) => getColor(d, config.color, yOrdinalScale(this._getRecordKey(d, i)))) + .call(this._renderLines.bind(this), rowHeight) + + linesEnter.append('use').attr('class', s.lineStartIcon) + linesEnter.append('use').attr('class', s.lineEndIcon) + const linesMerged = linesEnter.merge(lines) smartTransition(linesMerged, duration) - .attr('transform', 'translate(0, 0)') + .attr('transform', d => `translate(${d._xPx + d._xOffsetPx}, ${d._yPx})`) + .style('opacity', 1) + + const lineRectElementsSelection = linesMerged.selectAll(`.${s.line}`) + .data(d => [d]) + smartTransition(lineRectElementsSelection, duration) + .style('fill', (d, i) => getColor(d, config.color, yOrdinalScale(this._getRecordKey(d, i)))) + .style('cursor', (d, i) => getString(d, config.lineCursor ?? config.cursor, i)) + .call(this._renderLines.bind(this), rowHeight) + + linesMerged.selectAll(`.${s.lineStartIcon}`) + .data(d => [d]) + .attr('href', (d, i) => getString(d, config.lineStartIcon, i)) + .attr('x', (d, i) => { + const iconSize = d._startIconSize + const iconArrangement = d._startIconArrangement + const offset = iconArrangement === Arrangement.Inside ? 0 + : iconArrangement === Arrangement.Center ? -iconSize / 2 + : -iconSize + return offset + }) + .attr('y', d => (-(d._startIconSize - d._height) / 2) || 0) + .attr('width', d => d._startIconSize) + .attr('height', d => d._startIconSize) + .style('color', d => d._startIconColor) + + linesMerged.selectAll(`.${s.lineEndIcon}`) + .data(d => [d]) + .attr('href', (d, i) => getString(d, config.lineEndIcon, i)) + .attr('x', (d, i) => { + const lineLength = d._lengthCorrected + const iconSize = d._endIconSize + const iconArrangement = d._endIconArrangement + const offset = iconArrangement === Arrangement.Inside ? -iconSize + : iconArrangement === Arrangement.Center ? -iconSize / 2 + : 0 + return lineLength + offset + }) + .attr('y', d => -((d._endIconSize - d._height) / 2) || 0) + .attr('width', d => d._endIconSize) + .attr('height', d => d._endIconSize) + .style('color', d => d._endIconColor) + + const linesExit = lines.exit() + smartTransition(linesExit, duration) + .style('opacity', 0) + .attr('transform', (d, i) => { + const configuredPos = isFunction(config.animationLineExitPosition) + ? config.animationLineExitPosition(d, i, lineDataPrepared) + : config.animationLineExitPosition + const [x, y] = [configuredPos?.[0] ?? d._xPx, configuredPos?.[1] ?? d._yPx] + return `translate(${x}, ${y})` + }) + .remove() + + // Arrows + const arrowsData = this._prepareArrowsData(data, yOrdinalScale, rowHeight) + const arrows = this._arrowsGroup.selectAll(`.${s.arrow}`) + .data(arrowsData ?? [], d => d.id) + + const arrowsEnter = arrows.enter().append('path') + .attr('class', s.arrow) + .style('opacity', 0) + + smartTransition(arrowsEnter.merge(arrows), duration) + .attr('d', (d) => arrowPolylinePath( + d._points, + d.arrowHeadLength ?? TIMELINE_DEFAULT_ARROW_HEAD_LENGTH, + d.arrowHeadWidth ?? TIMELINE_DEFAULT_ARROW_HEAD_WIDTH + )) .style('opacity', 1) - smartTransition(lines.exit(), duration) + smartTransition(arrows.exit(), duration) .style('opacity', 0) .remove() @@ -216,40 +439,156 @@ export class Timeline extends XYComponentCore | Transition, - ordinalScale: ScaleOrdinal - ): void { + private _getLineLength (d: Datum, i: number): number { + const { config, xScale } = this + const x = getNumber(d, config.x, i) + const length = getNumber(d, config.lineDuration ?? config.length, i) ?? 0 + + const lineLength = xScale(x + length) - xScale(x) + return lineLength + } + + private _getLineWidth (d: Datum, i: number, rowHeight: number): number { + const { config } = this + return getNumber(d, config.lineWidth, i) ?? Math.max(Math.floor(rowHeight / 2), 1) + } + + private _getLineDuration (d: Datum, i: number): number { + const { config } = this + return getNumber(d, config.lineDuration ?? config.length, i) ?? 0 + } + + private _prepareLinesData (data: Datum[], rowOrdinalScale: ScaleOrdinal, rowHeight: number): (Datum & TimelineLineRenderState)[] { const { config, xScale, yScale } = this const yRange = yScale.range() const yStart = Math.min(...yRange) - selection.each((d, i, elements) => { - const x = getNumber(d, config.x, i) - const y = ordinalScale(this._getRecordType(d, i)) * config.rowHeight - const length = getNumber(d, config.length, i) ?? 0 + return data.map((d, i) => { + const id = getString(d, config.id, i) ?? [ + this._getRecordKey(d, i), getNumber(d, config.x, i), + ].join('-') - // Rect dimensions - const height = getNumber(d, config.lineWidth, i) - const width = xScale(x + length) - xScale(x) + const lineWidth = this._getLineWidth(d, i, rowHeight) + const lineLength = this._getLineLength(d, i) - if (width < 0) { + if (lineLength < 0) { console.warn('Unovis | Timeline: Line segments should not have negative lengths. Setting to 0.') } - select(elements[i]) - .attr('x', xScale(x)) - .attr('y', yStart + y + (config.rowHeight - height) / 2) - .attr('width', config.showEmptySegments - ? Math.max(config.lineCap ? height : 1, width) - : Math.max(0, width)) - .attr('height', height) - .attr('rx', config.lineCap ? height / 2 : null) + const isLineTooShort = config.showEmptySegments && config.lineCap && (lineLength < lineWidth) + const lineLengthCorrected = config.showEmptySegments + ? Math.max(config.lineCap ? lineWidth : 1, lineLength) + : Math.max(0, lineLength) + + const x = xScale(getNumber(d, config.x, i)) + const y = yStart + rowOrdinalScale(this._getRecordKey(d, i)) * rowHeight + (rowHeight - lineWidth) / 2 + const xOffset = isLineTooShort ? -(lineLengthCorrected - lineLength) / 2 : 0 + + return { + ...d, + _id: id, + _xPx: x, + _yPx: y, + _xOffsetPx: xOffset, + _length: lineLength, + _height: lineWidth, + _lengthCorrected: lineLengthCorrected, + _startIconSize: getNumber(d, config.lineStartIconSize, i) ?? lineWidth, + _endIconSize: getNumber(d, config.lineEndIconSize, i) ?? lineWidth, + _startIconColor: getString(d, config.lineStartIconColor, i), + _endIconColor: getString(d, config.lineEndIconColor, i), + _startIconArrangement: getValue(d, config.lineStartIconArrangement, i) ?? Arrangement.Outside, + _endIconArrangement: getValue(d, config.lineEndIconArrangement, i) ?? Arrangement.Outside, + } }) } + private _prepareArrowsData (data: Datum[], rowOrdinalScale: ScaleOrdinal, rowHeight: number): (TimelineArrow & TimelineArrowRenderState)[] { + const { config } = this + + const arrowsData: (TimelineArrow & TimelineArrowRenderState)[] = config.arrows?.map(a => { + const sourceLineIndex = data.findIndex((d, i) => getString(d, config.id, i) === a.lineSourceId) + const targetLineIndex = data.findIndex((d, i) => getString(d, config.id, i) === a.lineTargetId) + const sourceLine = data[sourceLineIndex] + const targetLine = data[targetLineIndex] + + if (!sourceLine || !targetLine) { + console.warn('Unovis | Timeline: Arrow references a non-existent line. Skipping...', a) + return undefined + } + + const sourceLineY = rowOrdinalScale(this._getRecordKey(sourceLine, sourceLineIndex)) * rowHeight + rowHeight / 2 + const targetLineY = rowOrdinalScale(this._getRecordKey(targetLine, targetLineIndex)) * rowHeight + rowHeight / 2 + const sourceLineWidth = this._getLineWidth(sourceLine, sourceLineIndex, rowHeight) + const targetLineWidth = this._getLineWidth(targetLine, targetLineIndex, rowHeight) + + const x1 = (a.xSource + ? this.xScale(a.xSource) + : this.xScale(getNumber(sourceLine, config.x, sourceLineIndex)) + this._getLineLength(sourceLine, sourceLineIndex) + ) + (a.xSourceOffsetPx ?? 0) + const targetLineLength = this._getLineLength(targetLine, targetLineIndex) + const isTargetLineTooShort = config.showEmptySegments && config.lineCap && (targetLineLength < targetLineWidth) + const targetLineStart = this.xScale(getNumber(targetLine, config.x, targetLineIndex)) + (isTargetLineTooShort ? -targetLineWidth / 2 : 0) + const x2 = (a.xTarget ? this.xScale(a.xTarget) : targetLineStart) + (a.xTargetOffsetPx ?? 0) + const isX2OutsideTargetLineStart = (x2 < targetLineStart) || (x2 > targetLineStart) + + // Points array + const sourceMargin = a.lineSourceMarginPx ?? TIMELINE_DEFAULT_ARROW_MARGIN + const targetMargin = a.lineTargetMarginPx ?? TIMELINE_DEFAULT_ARROW_MARGIN + const y1 = sourceLineY < targetLineY ? sourceLineY + sourceLineWidth / 2 + sourceMargin : sourceLineY - sourceLineWidth / 2 - sourceMargin + const y2 = sourceLineY < targetLineY ? targetLineY - targetLineWidth / 2 - targetMargin : targetLineY + targetLineWidth / 2 + targetMargin + const arrowHeadLength = a.arrowHeadLength ?? TIMELINE_DEFAULT_ARROW_HEAD_LENGTH + const isForwardArrow = x1 < x2 && !isX2OutsideTargetLineStart + const threshold = arrowHeadLength + (isForwardArrow ? targetMargin : 0) + + const points = [[x1, y1]] as [number, number][] + if (Math.abs(x2 - x1) > threshold) { + if (isForwardArrow) { + points.push([x1, (y1 + targetLineY) / 2]) // A dummy point to enable smooth transitions when arrows change + points.push([x1, targetLineY]) + points.push([x2 - targetMargin, targetLineY]) + } else { + const verticalOffset = Math.sign(targetLineY - sourceLineY) * (rowHeight / 4) + points.push([x1, y2 - verticalOffset]) + points.push([x2, y2 - verticalOffset]) + points.push([x2, y2]) + } + } else { + const quarterOffset = (y2 - y1) / 4 + points.push([x1, y1 + quarterOffset]) // A dummy point to enable smooth transitions + points.push([x1, y1 + 3 * quarterOffset]) // A dummy point to enable smooth transitions + points.push([x1, y2]) + } + + return { + ...a, + _points: points, + } + }).filter(Boolean) + return arrowsData + } + + + private _renderLines ( + selection: Selection | Transition + ): void { + const { config } = this + + selection + .attr('width', d => d._lengthCorrected) + .attr('height', d => d._height) + .attr('rx', d => config.lineCap ? d._height / 2 : null) + } + private _onScrollbarDrag (event: D3DragEvent): void { const yRange = this.yScale.range() const yHeight = Math.abs(yRange[1] - yRange[0]) @@ -277,31 +616,43 @@ export class Timeline extends XYComponentCore getNumber(d, config.lineWidth, i)) ?? 0 + private _getRecordKey (d: Datum, i: number): string { + return getString(d, this.config.lineRow ?? this.config.type) || `__${i}` } - private _getRecordType (d: Datum, i: number): string { - return getString(d, this.config.type) || `__${i}` - } + private _getRowLabels (data: Datum[]): TimelineRowLabel[] { + const grouped = groupBy(data, (d, i) => getString(d, this.config.lineRow ?? this.config.type) || `${i + 1}`) + + const rowLabels: TimelineRowLabel[] = Object.entries(grouped).map(([key, items], i) => { + const icon = this.config.rowIcon?.(key, items, i) + return { + label: key, + formattedLabel: this.config.rowLabelFormatter?.(key, items, i) ?? key, + iconHref: icon?.href, + iconSize: icon?.size, + iconColor: icon?.color, + data: items, + } + }) - private _getRecordLabels (data: Datum[]): string[] { - return data.map((d, i) => getString(d, this.config.type) || `${i + 1}`) + return rowLabels } // Override the default XYComponent getXDataExtent method to take into account line lengths getXDataExtent (): number[] { const { config, datamodel } = this const min = getMin(datamodel.data, config.x) - const max = getMax(datamodel.data, (d, i) => getNumber(d, config.x, i) + (getNumber(d, config.length, i) ?? 0)) + const max = getMax(datamodel.data, (d, i) => getNumber(d, config.x, i) + (getNumber(d, config.lineDuration ?? config.length, i) ?? 0)) return [min, max] } } diff --git a/packages/ts/src/components/timeline/style.ts b/packages/ts/src/components/timeline/style.ts index c8b815c1d..12e76f9ba 100644 --- a/packages/ts/src/components/timeline/style.ts +++ b/packages/ts/src/components/timeline/style.ts @@ -15,9 +15,15 @@ export const globalStyles = injectGlobal` --vis-timeline-label-font-size: 12px; --vis-timeline-label-color: #6C778C; + --vis-timeline-arrow-color: #6C778C; + --vis-timeline-arrow-stroke-width: 1.5; + --vis-timeline-cursor: default; --vis-timeline-line-color: var(--vis-color-main); --vis-timeline-line-stroke-width: 0; + --vis-timeline-line-hover-stroke-width: 0; + --vis-timeline-line-hover-stroke-color: #6C778C; + // The line stroke color variable is not defined by default // to allow it to fallback to the corresponding row background color /* --vis-timeline-line-stroke-color: none; */ @@ -27,6 +33,8 @@ export const globalStyles = injectGlobal` --vis-dark-timeline-scrollbar-background-color: #292B34; --vis-dark-timeline-scrollbar-color: #6C778C; --vis-dark-timeline-label-color: #EFF5F8; + --vis-dark-timeline-arrow-color: #EFF5F8; + --vis-dark-timeline-line-hover-stroke-color: #EFF5F8; } body.theme-dark ${`.${root}`} { @@ -35,6 +43,8 @@ export const globalStyles = injectGlobal` --vis-timeline-scrollbar-background-color: var(--vis-dark-timeline-scrollbar-background-color); --vis-timeline-scrollbar-color: var(--vis-dark-timeline-scrollbar-color); --vis-timeline-label-color: var(--vis-dark-timeline-label-color); + --vis-timeline-arrow-color: var(--vis-dark-timeline-arrow-color); + --vis-timeline-line-hover-stroke-color: var(--vis-dark-timeline-line-hover-stroke-color); } ` @@ -46,6 +56,10 @@ export const lines = css` label: lines; ` +export const lineGroup = css` + label: line-group; +` + export const line = css` label: line; fill: var(--vis-timeline-line-color); @@ -57,6 +71,30 @@ export const line = css` &.odd { stroke: var(--vis-timeline-line-stroke-color, var(--vis-timeline-row-odd-fill-color)); } + + :hover { + stroke-width: var(--vis-timeline-line-hover-stroke-width); + stroke: var(--vis-timeline-line-hover-stroke-color); + } +` + +export const lineStartIcon = css` + label: line-start-icon; +` + +export const lineEndIcon = css` + label: line-end-icon; +` + +export const arrows = css` + label: arrows; +` + +export const arrow = css` + label: arrow; + fill: none; + stroke: var(--vis-timeline-arrow-color); + stroke-width: var(--vis-timeline-arrow-stroke-width); ` export const rows = css` @@ -100,3 +138,11 @@ export const label = css` text-anchor: end; user-select: none; ` + +export const rowIcons = css` + label: row-icons; +` + +export const rowIcon = css` + label: row-icon; +` diff --git a/packages/ts/src/components/timeline/types.ts b/packages/ts/src/components/timeline/types.ts new file mode 100644 index 000000000..1221157f7 --- /dev/null +++ b/packages/ts/src/components/timeline/types.ts @@ -0,0 +1,67 @@ +import { Arrangement } from 'types/position' + +export type TimelineRowIcon = { + href: string; + size: number; + color: string; +} + +export type TimelineRowLabel = { + label: string; + formattedLabel: string; + data: D[]; + iconHref?: string; + iconSize?: number; + iconColor?: string; +} + +export type TimelineArrow = { + id?: string; + /** The optional x position of the arrow start. By default the arrow will be placed at the source line's end */ + xSource?: number; + /** The optional x position of the arrow end. By default the arrow will be placed at the target line's start */ + xTarget?: number; + /** The horizontal offset of the arrow in pixels. Default: `undefined` */ + xSourceOffsetPx?: number; + /** The horizontal offset of the arrow in pixels. Default: `undefined` */ + xTargetOffsetPx?: number; + /** The id of the line start element. */ + lineSourceId: string; + /** The id of the line end element. */ + lineTargetId: string; + /** The margin between the line source and the arrow in pixels. Default: `undefined` */ + lineSourceMarginPx?: number; + /** The margin between the line target and the arrow in pixels. Default: `undefined` */ + lineTargetMarginPx?: number; + /** The length of the arrowhead in pixels. Default: `8` */ + arrowHeadLength?: number; + /** The width of the arrowhead in pixels. Default: `6` */ + arrowHeadWidth?: number; +} + +export type TimelineArrowRenderState = { + _points: [number, number][]; +} + +export type TimelineLineRenderState = { + _id: string; + /** The x position of the line start pixels. */ + _xPx: number; + /** The y position of the line in pixels. */ + _yPx: number; + /** The x offset of the line in pixels. Applied the the lines is too short. See the `_lengthCorrected` property */ + _xOffsetPx: number; + /** The height of the line in pixels. */ + _height: number; + /** The length of the line in pixels. */ + _length: number; + /** When the line is too short, the length is corrected to be + * at least the line height when `config.lineCap` is set to `true` */ + _lengthCorrected: number; + _startIconSize: number; + _endIconSize: number; + _startIconColor: string; + _endIconColor: string; + _startIconArrangement: `${Arrangement}`; + _endIconArrangement: `${Arrangement}`; +} diff --git a/packages/ts/src/components/timeline/utils.ts b/packages/ts/src/components/timeline/utils.ts new file mode 100644 index 000000000..96b652902 --- /dev/null +++ b/packages/ts/src/components/timeline/utils.ts @@ -0,0 +1,22 @@ +import type { NumericAccessor, StringAccessor } from 'types/accessor' +import { Arrangement } from 'types/position' +import { getNumber, getString, getValue } from 'utils/data' + +export const getIconBleed = ( + datum: Datum, + idx: number, + icon: StringAccessor, + iconSize: NumericAccessor, + iconArrangement: StringAccessor, + rowHeight: number +): number => { + const iconValue = getString(datum, icon, idx) + if (!iconValue) return 0 + + const size = getNumber(datum, iconSize, idx) || rowHeight / 2 + const arrangement = getValue(datum, iconArrangement, idx) + + return (arrangement === Arrangement.Outside) ? size + : (arrangement === Arrangement.Center) ? size / 2 + : 0 +} diff --git a/packages/ts/src/containers/xy-container/config.ts b/packages/ts/src/containers/xy-container/config.ts index 92bf69928..652702b8e 100644 --- a/packages/ts/src/containers/xy-container/config.ts +++ b/packages/ts/src/containers/xy-container/config.ts @@ -89,6 +89,8 @@ export interface XYContainerConfigInterface extends ContainerConfigInterf scaleByDomain?: boolean; /** Annotations component. Default: `undefined` */ annotations?: Annotations | undefined; + /** Extend the clip path by the specified number of pixels. Default: `2` */ + clipPathExtend?: number; } @@ -117,5 +119,7 @@ export const XYContainerDefaultConfig: XYContainerConfigInterface = { preventEmptyDomain: null, scaleByDomain: false, + + clipPathExtend: 2, } diff --git a/packages/ts/src/containers/xy-container/index.ts b/packages/ts/src/containers/xy-container/index.ts index 5ac47986e..881b62802 100644 --- a/packages/ts/src/containers/xy-container/index.ts +++ b/packages/ts/src/containers/xy-container/index.ts @@ -242,9 +242,9 @@ export class XYContainer extends ContainerCore { this._renderAxes(this._firstRender ? 0 : customDuration) - // Clip RectsetSize + // Clip Rect // Extending the clipping path to allow small overflow (e.g. Line will looks better that way when it touches the edges) - const clipPathExtension = 2 + const clipPathExtension = config.clipPathExtend this._clipPath.select('rect') .attr('x', -clipPathExtension) .attr('y', -clipPathExtension) diff --git a/packages/ts/src/types.ts b/packages/ts/src/types.ts index 953a722af..fd44ba38a 100644 --- a/packages/ts/src/types.ts +++ b/packages/ts/src/types.ts @@ -30,3 +30,4 @@ export * from 'components/bullet-legend/types' export * from 'components/xy-labels/types' export * from 'components/nested-donut/types' export * from 'components/annotations/types' +export * from 'components/timeline/types' diff --git a/packages/ts/src/types/position.ts b/packages/ts/src/types/position.ts index 0ce396526..be184884d 100644 --- a/packages/ts/src/types/position.ts +++ b/packages/ts/src/types/position.ts @@ -15,6 +15,7 @@ export enum PositionStrategy { export enum Arrangement { Inside = 'inside', Outside = 'outside', + Center = 'center', } export enum Orientation { diff --git a/packages/ts/src/utils/data.ts b/packages/ts/src/utils/data.ts index be6ea6575..c7f7052a4 100644 --- a/packages/ts/src/utils/data.ts +++ b/packages/ts/src/utils/data.ts @@ -144,9 +144,9 @@ export const omit = >(obj: T return obj } -export const groupBy = > (arr: T[], accessor: (a: T) => string | number): Record => { +export const groupBy = > (arr: T[], accessor: (a: T, index: number) => string | number): Record => { return arr.reduce( - (grouped, v, i, a, k = accessor(v)) => (((grouped[k] || (grouped[k] = [])).push(v), grouped)), + (grouped, v, i, a, k = accessor(v, i)) => (((grouped[k] || (grouped[k] = [])).push(v), grouped)), {} as Record ) } diff --git a/packages/ts/src/utils/path.ts b/packages/ts/src/utils/path.ts index 2c2258a29..e94cb9f8c 100644 --- a/packages/ts/src/utils/path.ts +++ b/packages/ts/src/utils/path.ts @@ -205,3 +205,141 @@ export function scoreRectPath ({ x, y, w, h, r = 0, score = 1 }: ScoreRectPathOp export function convertLineToArc (path: Path | string, r: number): string { return path.toString().replace(/L(?-?\d*\.?\d*),(?-?\d+\.?\d*)/gm, (_, x, y) => `A ${r} ${r} 0 0 0 ${x} ${y}`) } + +/** + * Generate an SVG path string for an arrow that follows a polyline path. + * The arrow is composed of line segments between points and a triangular arrowhead at the end. + * + * @param opts - ArrowPolylinePathOptions object containing array of points and optional head dimensions. + * @returns SVG path string for the arrow. + */ +export function arrowPolylinePath ( + points: [number, number][], + arrowHeadLength = 8, + arrowHeadWidth = 6, + smoothing = 5 +): string { + if (points.length < 2) return '' + + // Calculate total path length + let totalLength = 0 + for (let i = 0; i < points.length - 1; i++) { + const [x1, y1] = points[i] + const [x2, y2] = points[i + 1] + totalLength += Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)) + } + + // If the total length is zero or nearly zero, don't draw anything + if (totalLength === 0) return '' + + // Let the default values be modifiable based on the line length + let headLength = arrowHeadLength + let headWidth = arrowHeadWidth + + // If the line is very short, scale down the arrow head dimensions + const threshold = arrowHeadLength * 2 + if (totalLength < threshold) { + const scale = totalLength / threshold + headLength *= scale + headWidth *= scale + } + + // Ensure the arrow head length is never longer than the line itself + headLength = Math.min(headLength / 2, totalLength) + + // Get the last two points for arrowhead calculation + const [lastX, lastY] = points[points.length - 1] + const [prevX, prevY] = points[points.length - 2] + + // Calculate direction vector for the last segment + const dx = lastX - prevX + const dy = lastY - prevY + const segmentLength = Math.sqrt(dx * dx + dy * dy) + const ux = dx / segmentLength + const uy = dy / segmentLength + + // Tail point of the arrow (where the arrowhead starts) + const tailX = lastX - headLength * ux + const tailY = lastY - headLength * uy + + // Perpendicular vector for arrowhead width calculation + const perpX = -uy + const perpY = ux + + // Calculate the two base points of the arrowhead triangle + const leftX = tailX + (headWidth / 2) * perpX + const leftY = tailY + (headWidth / 2) * perpY + const rightX = tailX - (headWidth / 2) * perpX + const rightY = tailY - (headWidth / 2) * perpY + + // Build the path + const pathParts = [] + + if (points.length === 2) { + // For a single segment, create a curved path + const [startX, startY] = points[0] + + // Adjust smoothing based on segment length + const adjustedSmoothing = Math.min(smoothing, segmentLength / 3) + + // Calculate control points for a cubic Bézier curve with adjusted smoothing + const cp1x = startX + ux * adjustedSmoothing + const cp1y = startY + uy * adjustedSmoothing + perpY * adjustedSmoothing * 0.5 + + const cp2x = tailX - ux * adjustedSmoothing + const cp2y = tailY - uy * adjustedSmoothing + perpY * adjustedSmoothing * 0.5 + + // Start path and add cubic Bézier curve + pathParts.push(`M${startX},${startY}`) + pathParts.push(`C${cp1x},${cp1y} ${cp2x},${cp2y} ${lastX},${lastY}`) + } else { + // For multiple segments, use smooth Bézier corners with absolute smoothing + pathParts.push(`M${points[0][0]},${points[0][1]}`) + + for (let i = 0; i < points.length - 2; i++) { + const [x1, y1] = points[i] + const [x2, y2] = points[i + 1] + const [x3, y3] = points[i + 2] + + // Calculate vectors for the current and next segment + const v1x = x2 - x1 + const v1y = y2 - y1 + const v2x = x3 - x2 + const v2y = y3 - y2 + + // Calculate lengths of segments + const len1 = Math.sqrt(v1x * v1x + v1y * v1y) + const len2 = Math.sqrt(v2x * v2x + v2y * v2y) + + // Calculate unit vectors + const u1x = v1x / len1 + const u1y = v1y / len1 + const u2x = v2x / len2 + const u2y = v2y / len2 + + // Adjust smoothing based on the minimum segment length + const minSegmentLength = Math.min(len1, len2) + const adjustedSmoothing = Math.min(smoothing, minSegmentLength / 3) + + // Calculate the corner points and control points with adjusted smoothing + const corner1x = x2 - u1x * adjustedSmoothing + const corner1y = y2 - u1y * adjustedSmoothing + const corner2x = x2 + u2x * adjustedSmoothing + const corner2y = y2 + u2y * adjustedSmoothing + + // Add line to approach point + pathParts.push(`L${corner1x},${corner1y}`) + + // Add cubic Bézier curve for the corner + pathParts.push(`C${x2},${y2} ${x2},${y2} ${corner2x},${corner2y}`) + } + + // Add the final line segment to the tail point + pathParts.push(`L${lastX},${lastY}`) + } + + // Add the arrowhead + pathParts.push(`M${leftX},${leftY} L${lastX},${lastY} L${rightX},${rightY}`) + + return pathParts.join(' ') +} diff --git a/packages/ts/src/utils/text.ts b/packages/ts/src/utils/text.ts index 6947abbeb..dcedb0adb 100644 --- a/packages/ts/src/utils/text.ts +++ b/packages/ts/src/utils/text.ts @@ -12,6 +12,15 @@ import { getTextAnchorFromTextAlign } from 'types/svg' // Styles import { getFontWidthToHeightRatio, UNOVIS_TEXT_DEFAULT, UNOVIS_TEXT_SEPARATOR_DEFAULT, UNOVIS_TEXT_HYPHEN_CHARACTER_DEFAULT } from 'styles/index' +export const textAlignToAnchor = (textAlign: TextAlign): string | null => { + switch (textAlign) { + case TextAlign.Left: return 'start' + case TextAlign.Right: return 'end' + case TextAlign.Center: return 'middle' + default: return null + } +} + /** * Converts a kebab-case string to camelCase. * @@ -576,3 +585,4 @@ export function renderTextIntoFrame ( group.textContent = '' group.appendChild(parsedSvgCode) } +