Skip to content

Commit 6ea2f2a

Browse files
committed
Component | Timeline: Update row label grouping and formatting
- Introduce a dedicated TimelineRowLabel type to encapsulate label data, including formatted labels. - Added the `rowLabelStyle` config option. - Update the `groupBy` utility to pass the index to the accessor.
1 parent 0b19cb4 commit 6ea2f2a

File tree

4 files changed

+50
-23
lines changed

4 files changed

+50
-23
lines changed

packages/ts/src/components/timeline/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { XYComponentConfigInterface, XYComponentDefaultConfig } from 'core/xy-co
44
import { WithOptional } from 'types/misc'
55
import { ColorAccessor, NumericAccessor, StringAccessor, GenericAccessor } from 'types/accessor'
66
import { TextAlign, Arrangement } from 'types'
7+
import type { TimelineRowLabel } from 'components/timeline/types'
78

89
export interface TimelineConfigInterface<Datum> extends WithOptional<XYComponentConfigInterface<Datum>, 'y'> {
910
// Items (Lines)
@@ -63,6 +64,8 @@ export interface TimelineConfigInterface<Datum> extends WithOptional<XYComponent
6364

6465
/** Show row labels when set to `true`. Default: `false`. Falls back to deprecated `showLabels` */
6566
showRowLabels?: boolean;
67+
/** Row label style as an object with the `{ [property-name]: value }` format. Default: `undefined` */
68+
rowLabelStyle?: GenericAccessor<Record<string, string>, TimelineRowLabel<Datum>>;
6669
/** Row label formatter function. Default: `undefined` */
6770
rowLabelFormatter?: (key: string) => string;
6871
/** Fixed label width in pixels. Labels longer than the specified value will be trimmed. Default: `undefined`. Falls back to deprecated `labelWidth`. */
@@ -112,6 +115,7 @@ export const TimelineDefaultConfig: TimelineConfigInterface<unknown> = {
112115

113116
showRowLabels: undefined,
114117
rowLabelFormatter: undefined,
118+
rowLabelStyle: undefined,
115119
rowLabelWidth: undefined,
116120
rowMaxLabelWidth: undefined,
117121
rowLabelTextAlign: TextAlign.Right,

packages/ts/src/components/timeline/index.ts

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import { drag, D3DragEvent } from 'd3-drag'
77
import { XYComponentCore } from 'core/xy-component'
88

99
// Utils
10-
import { isNumber, unique, arrayOfIndices, getMin, getMax, getString, getNumber, getValue } from 'utils/data'
10+
import { isNumber, arrayOfIndices, getMin, getMax, getString, getNumber, getValue, groupBy } from 'utils/data'
1111
import { smartTransition } from 'utils/d3'
1212
import { getColor } from 'utils/color'
1313
import { textAlignToAnchor, trimSVGText } from 'utils/text'
14-
import { guid } from 'utils'
14+
import { guid, isStringSvg, sanitizeSvgString } from 'utils'
1515

1616
// Types
1717
import { TextAlign, Spacing, Arrangement } from 'types'
@@ -22,6 +22,9 @@ import { TimelineDefaultConfig, TimelineConfigInterface } from './config'
2222
// Styles
2323
import * as s from './style'
2424

25+
// Local Types
26+
import type { TimelineRowLabel } from './types'
27+
2528
export class Timeline<Datum> extends XYComponentCore<Datum, TimelineConfigInterface<Datum>> {
2629
static selectors = s
2730
protected _defaultConfig = TimelineDefaultConfig as TimelineConfigInterface<Datum>
@@ -97,8 +100,8 @@ export class Timeline<Datum> extends XYComponentCore<Datum, TimelineConfigInterf
97100
if (config.showRowLabels ?? config.showLabels) {
98101
if (config.rowLabelWidth ?? config.labelWidth) this._labelWidth = (config.rowLabelWidth ?? config.labelWidth) + this._labelMargin
99102
else {
100-
const recordLabels = this._getRecordLabels(data)
101-
const longestLabel = recordLabels.reduce((acc, val) => acc.length > val.length ? acc : val, '')
103+
const rowLabels = this._getRowLabels(data)
104+
const longestLabel = rowLabels.reduce((longestLabel, l) => longestLabel.length > l.formattedLabel.length ? longestLabel : l.formattedLabel, '')
102105
const label = this._labelsGroup.append('text')
103106
.attr('class', s.label)
104107
.text(longestLabel)
@@ -127,14 +130,13 @@ export class Timeline<Datum> extends XYComponentCore<Datum, TimelineConfigInterf
127130
const yRange = this.yScale.range()
128131
const yStart = Math.min(...yRange)
129132
const yHeight = Math.abs(yRange[1] - yRange[0])
130-
const recordLabels = this._getRecordLabels(data)
131-
const recordLabelsUnique = unique(recordLabels)
132-
const numUniqueRecords = recordLabelsUnique.length
133-
const rowHeight = config.rowHeight || (yHeight / numUniqueRecords)
133+
const rowLabels = this._getRowLabels(data)
134+
const numRowLabels = rowLabels.length
135+
const rowHeight = config.rowHeight || (yHeight / numRowLabels)
134136

135137
// Ordinal scale to handle records on the same type
136138
const ordinalScale: ScaleOrdinal<string, number> = scaleOrdinal()
137-
ordinalScale.range(arrayOfIndices(numUniqueRecords))
139+
ordinalScale.range(arrayOfIndices(numRowLabels))
138140

139141
// Invisible Background rect to track events
140142
this._background
@@ -144,7 +146,7 @@ export class Timeline<Datum> extends XYComponentCore<Datum, TimelineConfigInterf
144146

145147
// Labels
146148
const labels = this._labelsGroup.selectAll<SVGTextElement, string>(`.${s.label}`)
147-
.data((config.showRowLabels ?? config.showLabels) ? recordLabelsUnique : [])
149+
.data((config.showRowLabels ?? config.showLabels) ? rowLabels : [])
148150

149151
const labelsEnter = labels.enter().append('text')
150152
.attr('class', s.label)
@@ -155,20 +157,27 @@ export class Timeline<Datum> extends XYComponentCore<Datum, TimelineConfigInterf
155157

156158
labelsEnter.merge(labels)
157159
.attr('x', xRange[0] - labelOffset)
158-
.attr('y', (label, i) => yStart + (ordinalScale(label) + 0.5) * rowHeight)
159-
.text(label => label)
160-
.style('text-anchor', textAlignToAnchor(config.rowLabelTextAlign as TextAlign))
160+
.attr('y', (l, i) => yStart + (ordinalScale(l.label) + 0.5) * rowHeight)
161+
.text(l => l.formattedLabel)
161162
.each((label, i, els) => {
162-
trimSVGText(select(els[i]), (config.rowLabelWidth ?? config.labelWidth) || (config.rowMaxLabelWidth ?? config.maxLabelWidth))
163+
const labelSelection = select(els[i])
164+
trimSVGText(labelSelection, (config.rowLabelWidth ?? config.labelWidth) || (config.rowMaxLabelWidth ?? config.maxLabelWidth))
165+
166+
// Apply custom label style if it has been provided
167+
const customStyle = getValue(label, config.rowLabelStyle)
168+
for (const [prop, value] of Object.entries(customStyle)) {
169+
labelSelection.style(prop, value)
170+
}
163171
})
172+
.style('text-anchor', textAlignToAnchor(config.rowLabelTextAlign as TextAlign))
164173

165174
labels.exit().remove()
166175

167176
// Row background rects
168177
const xStart = xRange[0]
169178
const timelineWidth = xRange[1] - xRange[0]
170-
const numRows = Math.max(Math.floor(yHeight / rowHeight), numUniqueRecords)
171-
const recordTypes: (string | undefined)[] = Array(numRows).fill(null).map((_, i) => recordLabelsUnique[i])
179+
const numRows = Math.max(Math.floor(yHeight / rowHeight), numRowLabels)
180+
const recordTypes = Array(numRows).fill(null).map((_, i) => rowLabels[i])
172181
const rects = this._rowsGroup.selectAll<SVGRectElement, number>(`.${s.row}`)
173182
.data(recordTypes)
174183

@@ -199,9 +208,6 @@ export class Timeline<Datum> extends XYComponentCore<Datum, TimelineConfigInterf
199208

200209
linesEnter.append('rect')
201210
.attr('class', s.line)
202-
.classed(s.rowOdd, config.alternatingRowColors
203-
? (d, i) => !(recordLabelsUnique.indexOf(this._getRecordKey(d, i)) % 2)
204-
: null)
205211
.style('fill', (d, i) => getColor(d, config.color, ordinalScale(this._getRecordKey(d, i))))
206212
.call(this._positionLines.bind(this), rowHeight)
207213

@@ -384,8 +390,16 @@ export class Timeline<Datum> extends XYComponentCore<Datum, TimelineConfigInterf
384390
return getString(d, this.config.lineRow ?? this.config.type) || `__${i}`
385391
}
386392

387-
private _getRecordLabels (data: Datum[]): string[] {
388-
return data.map((d, i) => getString(d, this.config.lineRow ?? this.config.type) || `${i + 1}`)
393+
private _getRowLabels (data: Datum[]): TimelineRowLabel<Datum>[] {
394+
const grouped = groupBy(data, (d, i) => getString(d, this.config.lineRow ?? this.config.type) || `${i + 1}`)
395+
396+
const rowLabels: TimelineRowLabel<Datum>[] = Object.entries(grouped).map(([key, items]) => ({
397+
label: key,
398+
formattedLabel: this.config.rowLabelFormatter?.(key) ?? key,
399+
data: items,
400+
}))
401+
402+
return rowLabels
389403
}
390404

391405
// Override the default XYComponent getXDataExtent method to take into account line lengths
@@ -396,3 +410,7 @@ export class Timeline<Datum> extends XYComponentCore<Datum, TimelineConfigInterf
396410
return [min, max]
397411
}
398412
}
413+
function sanitizeSvg (key: string): any {
414+
throw new Error('Function not implemented.')
415+
}
416+
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export type TimelineRowLabel<D> = {
2+
label: string;
3+
formattedLabel: string;
4+
data: D[];
5+
}

packages/ts/src/utils/data.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,9 @@ export const omit = <T extends Record<string | number | symbol, unknown>>(obj: T
144144
return obj
145145
}
146146

147-
export const groupBy = <T extends Record<string | number, any>> (arr: T[], accessor: (a: T) => string | number): Record<string | number, T[]> => {
147+
export const groupBy = <T extends Record<string | number, any>> (arr: T[], accessor: (a: T, index: number) => string | number): Record<string | number, T[]> => {
148148
return arr.reduce(
149-
(grouped, v, i, a, k = accessor(v)) => (((grouped[k] || (grouped[k] = [])).push(v), grouped)),
149+
(grouped, v, i, a, k = accessor(v, i)) => (((grouped[k] || (grouped[k] = [])).push(v), grouped)),
150150
{} as Record<string | number, T[]>
151151
)
152152
}

0 commit comments

Comments
 (0)