|
import { |
|
Axis, |
|
axisBottom, |
|
axisTop, |
|
ScaleOrdinal, |
|
ScaleTime, |
|
scaleOrdinal, |
|
scaleTime, |
|
Selection, |
|
select, |
|
CountableTimeInterval, |
|
timeHour, |
|
timeFormat, |
|
ZoomBehavior, |
|
zoom as d3Zoom, |
|
zoomIdentity, |
|
} from 'd3' |
|
|
|
// ============================================================================ |
|
// TypeScript Type Definitions |
|
// ============================================================================ |
|
|
|
export type DisplayType = 'circle' | 'rect' |
|
|
|
export interface TimelineTime { |
|
starting_time: number | Date |
|
ending_time: number | Date |
|
label?: string |
|
color?: string |
|
id?: string |
|
display?: DisplayType |
|
[key: string]: any |
|
} |
|
|
|
export interface TimelineDatum { |
|
times: TimelineTime[] |
|
label?: string |
|
class?: string |
|
id?: string |
|
icon?: string |
|
[key: string]: any |
|
} |
|
|
|
export type TimelineData = TimelineDatum[] |
|
|
|
export interface TimelineMargin { |
|
left: number |
|
right: number |
|
top: number |
|
bottom: number |
|
} |
|
|
|
export interface TimelineTickFormat { |
|
format?: (date: Date) => string |
|
tickTime?: CountableTimeInterval |
|
tickInterval?: number |
|
tickSize?: number |
|
tickValues?: Date[] | null |
|
numTicks?: number |
|
} |
|
|
|
export interface TimelineAxisTickFormat { |
|
stroke: string |
|
spacing: string |
|
} |
|
|
|
export interface TimelineLineFormat { |
|
marginTop: number |
|
marginBottom: number |
|
width: number |
|
color: string | ScaleOrdinal<string, string> |
|
} |
|
|
|
export type TimelineCallback<T = any> = ( |
|
data: TimelineTime, |
|
index: number, |
|
datum: TimelineDatum, |
|
) => T |
|
|
|
export interface TimelineBuilder { |
|
(selection: Selection<SVGSVGElement, TimelineData, any, any>): void |
|
margin(margin: TimelineMargin): TimelineBuilder |
|
orient(orientation: 'bottom' | 'top'): TimelineBuilder |
|
itemHeight(height: number): TimelineBuilder |
|
itemMargin(margin: number): TimelineBuilder |
|
navMargin(margin: number): TimelineBuilder |
|
height(height: number): TimelineBuilder |
|
width(width: number): TimelineBuilder |
|
display(displayType: DisplayType): TimelineBuilder |
|
labelFormat(formatter: (label: string) => string): TimelineBuilder |
|
tickFormat(format: TimelineTickFormat): TimelineBuilder |
|
hover(callback: TimelineCallback<void>): TimelineBuilder |
|
mouseover(callback: TimelineCallback<void>): TimelineBuilder |
|
mouseout(callback: TimelineCallback<void>): TimelineBuilder |
|
click(callback: TimelineCallback<void>): TimelineBuilder |
|
scroll(callback: (x: number, scale: ScaleTime<number, number>) => void): TimelineBuilder |
|
colors(colorScale: ScaleOrdinal<string, string> | string[]): TimelineBuilder |
|
beginning(time: number | Date): TimelineBuilder |
|
ending(time: number | Date): TimelineBuilder |
|
labelMargin(margin: number): TimelineBuilder |
|
rotateTicks(degrees: number): TimelineBuilder |
|
stack(): TimelineBuilder |
|
relativeTime(): TimelineBuilder |
|
showBorderLine(): TimelineBuilder |
|
showBorderFormat(format: TimelineLineFormat): TimelineBuilder |
|
showToday(): TimelineBuilder |
|
showTodayFormat(format: TimelineLineFormat): TimelineBuilder |
|
colorProperty(property: string | null): TimelineBuilder |
|
rowSeparators(color: string | null): TimelineBuilder |
|
background( |
|
color: string | null | ((datum: TimelineDatum, index: number) => string), |
|
): TimelineBuilder |
|
showTimeAxis(): TimelineBuilder |
|
showAxisTop(): TimelineBuilder |
|
showAxisCalendarYear(): TimelineBuilder |
|
showTimeAxisTick(): TimelineBuilder |
|
fullLengthBackgrounds(): TimelineBuilder |
|
showTimeAxisTickFormat(format: TimelineAxisTickFormat): TimelineBuilder |
|
showAxisHeaderBackground(bgColor?: string): TimelineBuilder |
|
navigate( |
|
navigateBackwards: (beginning: number | Date, chartData: TimelineData) => void, |
|
navigateForwards: (ending: number | Date, chartData: TimelineData) => void, |
|
): TimelineBuilder |
|
} |
|
|
|
// ============================================================================ |
|
// D3 v7 Timeline Implementation |
|
// ============================================================================ |
|
|
|
const DISPLAY_TYPES: DisplayType[] = ['circle', 'rect'] |
|
|
|
// Extended color scheme (20 colors) similar to D3 v3's category20 |
|
const CATEGORY20_COLORS = [ |
|
'#1f77b4', |
|
'#aec7e8', |
|
'#ff7f0e', |
|
'#ffbb78', |
|
'#2ca02c', |
|
'#98df8a', |
|
'#d62728', |
|
'#ff9896', |
|
'#9467bd', |
|
'#c5b0d5', |
|
'#8c564b', |
|
'#c49c94', |
|
'#e377c2', |
|
'#f7b6d2', |
|
'#7f7f7f', |
|
'#c7c7c7', |
|
'#bcbd22', |
|
'#dbdb8d', |
|
'#17becf', |
|
'#9edae5', |
|
] |
|
|
|
export function timeline(): TimelineBuilder { |
|
let hover: TimelineCallback<void> = () => {} |
|
let mouseover: TimelineCallback<void> = () => {} |
|
let mouseout: TimelineCallback<void> = () => {} |
|
let click: TimelineCallback<void> = () => {} |
|
let scroll: (x: number, scale: ScaleTime<number, number>) => void = () => {} |
|
let labelFunction: (label: string) => string = (label) => label |
|
let navigateLeft: (beginning: number | Date, chartData: TimelineData) => void = () => {} |
|
let navigateRight: (ending: number | Date, chartData: TimelineData) => void = () => {} |
|
let orient: 'bottom' | 'top' = 'bottom' |
|
let width: number | null = null |
|
let height: number | null = null |
|
let rowSeparatorsColor: string | null = null |
|
let backgroundColor: string | null | ((datum: TimelineDatum, index: number) => string) = null |
|
let tickFormat: TimelineTickFormat = { |
|
format: timeFormat('%I %p'), |
|
tickTime: timeHour, |
|
tickInterval: 1, |
|
tickSize: 6, |
|
tickValues: null, |
|
} |
|
let colorCycle: ScaleOrdinal<string, string> = scaleOrdinal(CATEGORY20_COLORS) |
|
let colorPropertyName: string | null = null |
|
let display: DisplayType = 'rect' |
|
let beginning: number | Date = 0 |
|
let labelMargin = 0 |
|
let ending: number | Date = 0 |
|
let margin: TimelineMargin = { left: 30, right: 30, top: 30, bottom: 30 } |
|
let stacked = false |
|
let rotateTicks: number | boolean = false |
|
let timeIsRelative = false |
|
let fullLengthBackgrounds = false |
|
let itemHeight = 20 |
|
let itemMargin = 5 |
|
let navMargin = 60 |
|
let showTimeAxis = true |
|
let showAxisTop = false |
|
let showTodayLine = false |
|
let timeAxisTick = false |
|
let timeAxisTickFormat: TimelineAxisTickFormat = { stroke: 'stroke-dasharray', spacing: '4 10' } |
|
let showTodayFormat: TimelineLineFormat = { |
|
marginTop: 25, |
|
marginBottom: 0, |
|
width: 1, |
|
color: '#1f77b4', |
|
} |
|
let showBorderLine = false |
|
let showBorderFormat: TimelineLineFormat = { |
|
marginTop: 25, |
|
marginBottom: 0, |
|
width: 1, |
|
color: '#1f77b4', |
|
} |
|
let showAxisHeaderBackground = false |
|
let showAxisNav = false |
|
let showAxisCalendarYear = false |
|
let axisBgColor = 'white' |
|
let chartData: TimelineData = [] |
|
|
|
// ============================================================================ |
|
// Helper Functions |
|
// ============================================================================ |
|
|
|
const appendTimeAxis = ( |
|
g: Selection<SVGGElement, TimelineData, any, any>, |
|
xAxis: Axis<Date | import('d3').NumberValue>, |
|
yPosition: number, |
|
) => { |
|
if (showAxisHeaderBackground) { |
|
appendAxisHeaderBackground(g, 0, 0) |
|
} |
|
|
|
if (showAxisNav) { |
|
appendTimeAxisNav(g) |
|
} |
|
|
|
g.append('g').attr('class', 'axis').attr('transform', `translate(0, ${yPosition})`).call(xAxis) |
|
} |
|
|
|
const appendTimeAxisCalendarYear = (nav: Selection<SVGGElement, any, any, any>) => { |
|
const beginningDate = beginning instanceof Date ? beginning : new Date(beginning) |
|
const endingDate = ending instanceof Date ? ending : new Date(ending) |
|
|
|
let calendarLabel = beginningDate.getFullYear().toString() |
|
|
|
if (beginningDate.getFullYear() !== endingDate.getFullYear()) { |
|
calendarLabel = `${beginningDate.getFullYear()}-${endingDate.getFullYear()}` |
|
} |
|
|
|
nav |
|
.append('text') |
|
.attr('transform', 'translate(20, 0)') |
|
.attr('x', 0) |
|
.attr('y', 14) |
|
.attr('class', 'calendarYear') |
|
.text(calendarLabel) |
|
} |
|
|
|
const appendTimeAxisNav = (g: Selection<SVGGElement, TimelineData, any, any>) => { |
|
const timelineBlocks = 6 |
|
const leftNavMargin = margin.left - navMargin |
|
const w = width as number |
|
const incrementValue = (w - margin.left) / timelineBlocks |
|
const rightNavMargin = w - margin.right - incrementValue + navMargin |
|
|
|
const nav = g.append('g').attr('class', 'axis').attr('transform', 'translate(0, 20)') |
|
|
|
if (showAxisCalendarYear) { |
|
appendTimeAxisCalendarYear(nav) |
|
} |
|
|
|
nav |
|
.append('text') |
|
.attr('transform', `translate(${leftNavMargin}, 0)`) |
|
.attr('x', 0) |
|
.attr('y', 14) |
|
.attr('class', 'chevron') |
|
.text('<') |
|
.on('click', () => navigateLeft(beginning, chartData)) |
|
|
|
nav |
|
.append('text') |
|
.attr('transform', `translate(${rightNavMargin}, 0)`) |
|
.attr('x', 0) |
|
.attr('y', 14) |
|
.attr('class', 'chevron') |
|
.text('>') |
|
.on('click', () => navigateRight(ending, chartData)) |
|
} |
|
|
|
const appendAxisHeaderBackground = ( |
|
g: Selection<SVGGElement, TimelineData, any, any>, |
|
xAxis: number, |
|
yAxis: number, |
|
) => { |
|
g.insert('rect') |
|
.attr('class', 'row-green-bar') |
|
.attr('x', xAxis) |
|
.attr('width', width as number) |
|
.attr('y', yAxis) |
|
.attr('height', itemHeight) |
|
.attr('fill', axisBgColor) |
|
} |
|
|
|
const appendTimeAxisTick = ( |
|
g: Selection<SVGGElement, TimelineData, any, any>, |
|
xAxis: Axis<Date | import('d3').NumberValue>, |
|
maxStack: number, |
|
) => { |
|
g.append('g') |
|
.attr('class', 'axis') |
|
.attr('transform', `translate(0, ${margin.top + (itemHeight + itemMargin) * maxStack})`) |
|
.attr(timeAxisTickFormat.stroke, timeAxisTickFormat.spacing) |
|
.call( |
|
xAxis |
|
.tickFormat(() => '') |
|
.tickSize(-(margin.top + (itemHeight + itemMargin) * (maxStack - 1) + 3)), |
|
) |
|
} |
|
|
|
const appendBackgroundBar = ( |
|
yAxisMapping: Record<number, number>, |
|
index: number, |
|
g: Selection<SVGGElement, TimelineData, any, any>, |
|
data: TimelineTime[], |
|
datum: TimelineDatum, |
|
) => { |
|
const greenbarYAxis = (itemHeight + itemMargin) * yAxisMapping[index] + margin.top |
|
const w = width as number |
|
|
|
g.selectAll('svg') |
|
.data(data) |
|
.enter() |
|
.insert('rect') |
|
.attr('class', 'row-green-bar') |
|
.attr('x', fullLengthBackgrounds ? 0 : margin.left) |
|
.attr('width', fullLengthBackgrounds ? w : w - margin.right - margin.left) |
|
.attr('y', greenbarYAxis) |
|
.attr('height', itemHeight) |
|
.attr( |
|
'fill', |
|
backgroundColor instanceof Function ? backgroundColor(datum, index) : backgroundColor, |
|
) |
|
} |
|
|
|
const appendLabel = ( |
|
gParent: Selection<SVGSVGElement, TimelineData, any, any>, |
|
yAxisMapping: Record<number, number>, |
|
index: number, |
|
hasLabel: boolean, |
|
datum: TimelineDatum, |
|
) => { |
|
const fullItemHeight = itemHeight + itemMargin |
|
const rowsDown = margin.top + fullItemHeight / 2 + fullItemHeight * (yAxisMapping[index] || 1) |
|
|
|
gParent |
|
.append('text') |
|
.attr('class', 'timeline-label') |
|
.attr('transform', `translate(${labelMargin}, ${rowsDown})`) |
|
.text(hasLabel ? labelFunction(datum.label!) : datum.id!) |
|
.on('click', () => click({} as TimelineTime, index, datum)) |
|
} |
|
|
|
// ============================================================================ |
|
// Main Timeline Function |
|
// ============================================================================ |
|
|
|
function timelineFunction(gParent: Selection<SVGSVGElement, TimelineData, any, any>) { |
|
const g = gParent.append('g') |
|
const gParentNode = gParent.node() |
|
if (!gParentNode) return |
|
|
|
const gParentSize = gParentNode.getBoundingClientRect() |
|
const gParentItem = select(gParentNode) |
|
|
|
const yAxisMapping: Record<number, number> = {} |
|
let maxStack = 1 |
|
let minTime: number | Date = 0 |
|
let maxTime: number | Date = 0 |
|
|
|
setWidth() |
|
|
|
// Check if the user wants relative time |
|
// If so, subtract the first timestamp from each subsequent timestamps |
|
if (timeIsRelative) { |
|
let originTime: number | Date = 0 |
|
|
|
g.each((d) => { |
|
d.forEach((datum, index) => { |
|
datum.times.forEach((time, j) => { |
|
const startTime = |
|
time.starting_time instanceof Date ? time.starting_time.getTime() : time.starting_time |
|
const endTime = |
|
time.ending_time instanceof Date ? time.ending_time.getTime() : time.ending_time |
|
|
|
if (index === 0 && j === 0) { |
|
originTime = startTime |
|
time.starting_time = 0 |
|
time.ending_time = endTime - (originTime as number) |
|
} else { |
|
time.starting_time = startTime - (originTime as number) |
|
time.ending_time = endTime - (originTime as number) |
|
} |
|
}) |
|
}) |
|
}) |
|
} |
|
|
|
// Check how many stacks we're gonna need |
|
// Do this here so that we can draw the axis before the graph |
|
if (stacked || ending === 0 || beginning === 0) { |
|
g.each((d) => { |
|
d.forEach((datum, index) => { |
|
// Create y mapping for stacked graph |
|
if (stacked && !(index in yAxisMapping)) { |
|
yAxisMapping[index] = maxStack |
|
maxStack++ |
|
} |
|
|
|
// Figure out beginning and ending times if they are unspecified |
|
datum.times.forEach((time) => { |
|
const startTime = |
|
time.starting_time instanceof Date ? time.starting_time.getTime() : time.starting_time |
|
const endTime = |
|
time.ending_time instanceof Date ? time.ending_time.getTime() : time.ending_time |
|
|
|
if (beginning === 0) { |
|
if (startTime < (minTime as number) || (minTime === 0 && timeIsRelative === false)) { |
|
minTime = startTime |
|
} |
|
} |
|
if (ending === 0) { |
|
if (endTime > (maxTime as number)) { |
|
maxTime = endTime |
|
} |
|
} |
|
}) |
|
}) |
|
}) |
|
|
|
if (ending === 0) { |
|
ending = maxTime |
|
} |
|
if (beginning === 0) { |
|
beginning = minTime |
|
} |
|
} |
|
|
|
const w = width as number |
|
const scaleFactor = |
|
(1 / (toNumber(ending) - toNumber(beginning))) * (w - margin.left - margin.right) |
|
|
|
// Draw the axis |
|
const xScale = scaleTime() |
|
.domain([toDate(beginning), toDate(ending)]) |
|
.range([margin.left, w - margin.right]) |
|
|
|
const xAxis = (orient === 'bottom' ? axisBottom(xScale) : axisTop(xScale)).tickSize( |
|
tickFormat.tickSize || 6, |
|
) |
|
|
|
if (tickFormat.format) { |
|
xAxis.tickFormat(tickFormat.format as any) |
|
} |
|
|
|
if (tickFormat.tickValues != null) { |
|
xAxis.tickValues(tickFormat.tickValues) |
|
} else if (tickFormat.tickTime && tickFormat.tickInterval) { |
|
xAxis.ticks(tickFormat.tickTime, tickFormat.tickInterval) |
|
} else if (tickFormat.numTicks) { |
|
xAxis.ticks(tickFormat.numTicks) |
|
} |
|
|
|
// Helper functions for positioning |
|
const getXPos = (d: TimelineTime): number => { |
|
return margin.left + (toNumber(d.starting_time) - toNumber(beginning)) * scaleFactor |
|
} |
|
|
|
const getXTextPos = (d: TimelineTime): number => { |
|
return margin.left + (toNumber(d.starting_time) - toNumber(beginning)) * scaleFactor + 5 |
|
} |
|
|
|
const getStackPosition = (index: number): number => { |
|
if (stacked) { |
|
return margin.top + (itemHeight + itemMargin) * yAxisMapping[index] |
|
} |
|
return margin.top |
|
} |
|
|
|
const getStackTextPosition = (index: number): number => { |
|
if (stacked) { |
|
return margin.top + (itemHeight + itemMargin) * yAxisMapping[index] + itemHeight * 0.75 |
|
} |
|
return margin.top + itemHeight * 0.75 |
|
} |
|
|
|
// Draw the chart |
|
g.each((d) => { |
|
chartData = d |
|
d.forEach((datum, index) => { |
|
const data = datum.times |
|
const hasLabel = typeof datum.label !== 'undefined' |
|
|
|
// Issue warning about using id per data set. Ids should be individual to data elements |
|
if (typeof datum.id !== 'undefined') { |
|
console.warn( |
|
"d3Timeline Warning: Ids per dataset is deprecated in favor of a 'class' key. Ids are now per data element.", |
|
) |
|
} |
|
|
|
if (backgroundColor) { |
|
appendBackgroundBar(yAxisMapping, index, g, data, datum) |
|
} |
|
|
|
g.selectAll('svg') |
|
.data(data) |
|
.enter() |
|
.append((d) => { |
|
return document.createElementNS( |
|
'http://www.w3.org/2000/svg', |
|
'display' in d ? (d.display as string) : display, |
|
) |
|
}) |
|
.attr('x', getXPos) |
|
.attr('y', () => getStackPosition(index)) |
|
.attr('width', (d) => { |
|
return (toNumber(d.ending_time) - toNumber(d.starting_time)) * scaleFactor |
|
}) |
|
.attr('cy', () => getStackPosition(index) + itemHeight / 2) |
|
.attr('cx', getXPos) |
|
.attr('r', itemHeight / 2) |
|
.attr('height', itemHeight) |
|
.style('fill', (d) => { |
|
if (d.color) return d.color |
|
if (colorPropertyName) { |
|
const dColorPropName = d[colorPropertyName] |
|
if (dColorPropName) { |
|
return colorCycle(dColorPropName) |
|
} else { |
|
return colorCycle(datum[colorPropertyName]!) |
|
} |
|
} |
|
return colorCycle(index.toString()) |
|
}) |
|
.on('mousemove', (event, d) => { |
|
hover(d, index, datum) |
|
}) |
|
.on('mouseover', (event, d) => { |
|
mouseover(d, index, datum) |
|
}) |
|
.on('mouseout', (event, d) => { |
|
mouseout(d, index, datum) |
|
}) |
|
.on('click', (event, d) => { |
|
click(d, index, datum) |
|
}) |
|
.attr('class', () => { |
|
return datum.class ? `timelineSeries_${datum.class}` : `timelineSeries_${index}` |
|
}) |
|
.attr('id', (d, i) => { |
|
// Use deprecated id field |
|
if (datum.id && !d.id) { |
|
return `timelineItem_${datum.id}` |
|
} |
|
return d.id ? d.id : `timelineItem_${index}_${i}` |
|
}) |
|
|
|
g.selectAll('svg') |
|
.data(data) |
|
.enter() |
|
.append('text') |
|
.attr('x', getXTextPos) |
|
.attr('y', () => getStackTextPosition(index)) |
|
.text((d) => d.label || '') |
|
|
|
if (rowSeparatorsColor) { |
|
const lineYAxis = |
|
itemHeight + |
|
itemMargin / 2 + |
|
margin.top + |
|
(itemHeight + itemMargin) * yAxisMapping[index] |
|
gParent |
|
.append('line') |
|
.attr('class', 'row-separator') |
|
.attr('x1', 0 + margin.left) |
|
.attr('x2', w - margin.right) |
|
.attr('y1', lineYAxis) |
|
.attr('y2', lineYAxis) |
|
.attr('stroke-width', 1) |
|
.attr('stroke', rowSeparatorsColor) |
|
} |
|
|
|
// Add the label |
|
if (hasLabel) { |
|
appendLabel(gParent, yAxisMapping, index, hasLabel, datum) |
|
} |
|
|
|
if (typeof datum.icon !== 'undefined') { |
|
gParent |
|
.append('image') |
|
.attr('class', 'timeline-label') |
|
.attr( |
|
'transform', |
|
`translate(0, ${margin.top + (itemHeight + itemMargin) * yAxisMapping[index]})`, |
|
) |
|
.attr('xlink:href', datum.icon) |
|
.attr('width', margin.left) |
|
.attr('height', itemHeight) |
|
} |
|
}) |
|
}) |
|
|
|
const belowLastItem = margin.top + (itemHeight + itemMargin) * maxStack |
|
const aboveFirstItem = margin.top |
|
const timeAxisYPosition = showAxisTop ? aboveFirstItem : belowLastItem |
|
|
|
if (showTimeAxis) { |
|
appendTimeAxis(g, xAxis, timeAxisYPosition) |
|
} |
|
if (timeAxisTick) { |
|
appendTimeAxisTick(g, xAxis, maxStack) |
|
} |
|
|
|
if (w > gParentSize.width) { |
|
const move = (event: any) => { |
|
const x = Math.min(0, Math.max(gParentSize.width - w, event.transform.x)) |
|
zoomBehavior.transform(gParent, zoomIdentity.translate(x, 0)) |
|
g.attr('transform', `translate(${x}, 0)`) |
|
scroll(x * scaleFactor, xScale) |
|
} |
|
|
|
const zoomBehavior: ZoomBehavior<SVGSVGElement, any> = d3Zoom<SVGSVGElement, any>() |
|
.scaleExtent([1, 1]) // Disable scaling, only allow panning |
|
.on('zoom', move) |
|
|
|
gParent.attr('class', 'scrollable').call(zoomBehavior) |
|
} |
|
|
|
if (rotateTicks) { |
|
g.selectAll<SVGTextElement, unknown>('.tick text').attr('transform', function () { |
|
const bbox = this.getBBox() |
|
return `rotate(${rotateTicks})translate(${bbox.width / 2 + 10}, ${bbox.height / 2})` |
|
}) |
|
} |
|
|
|
const gNode = g.node() |
|
const gSize = gNode ? gNode.getBoundingClientRect() : { height: 0, top: 0 } |
|
setHeight() |
|
|
|
if (showBorderLine) { |
|
g.each((d) => { |
|
d.forEach((datum) => { |
|
const times = datum.times |
|
times.forEach((time) => { |
|
appendLine(xScale(toDate(time.starting_time))!, showBorderFormat) |
|
appendLine(xScale(toDate(time.ending_time))!, showBorderFormat) |
|
}) |
|
}) |
|
}) |
|
} |
|
|
|
if (showTodayLine) { |
|
const todayLine = xScale(new Date()) |
|
if (todayLine) appendLine(todayLine, showTodayFormat) |
|
} |
|
|
|
function setHeight() { |
|
const h = height |
|
if (!h && !gParentItem.attr('height')) { |
|
if (itemHeight) { |
|
// Set height based off of item height |
|
height = gSize.height + gSize.top - gParentSize.top |
|
// Set bounding rectangle height |
|
select(gParentNode).attr('height', height) |
|
} else { |
|
throw new Error('height of the timeline is not set') |
|
} |
|
} else { |
|
if (!h) { |
|
height = parseInt(gParentItem.attr('height')) |
|
} else { |
|
gParentItem.attr('height', h) |
|
} |
|
} |
|
} |
|
|
|
function setWidth() { |
|
const w = width |
|
if (!w && !gParentSize.width) { |
|
try { |
|
const attrWidth = gParentItem.attr('width') |
|
if (!attrWidth) { |
|
throw new Error( |
|
'width of the timeline is not set. As of Firefox 27, timeline().width(x) needs to be explicitly set in order to render', |
|
) |
|
} |
|
width = parseInt(attrWidth) |
|
} catch (err) { |
|
console.log(err) |
|
} |
|
} else if (!(w && gParentSize.width)) { |
|
try { |
|
width = parseInt(gParentItem.attr('width')) |
|
} catch (err) { |
|
console.log(err) |
|
} |
|
} |
|
// If both are set, do nothing |
|
} |
|
|
|
function appendLine(lineScale: number, lineFormat: TimelineLineFormat) { |
|
const h = height as number |
|
gParent |
|
.append('line') |
|
.attr('x1', lineScale) |
|
.attr('y1', lineFormat.marginTop) |
|
.attr('x2', lineScale) |
|
.attr('y2', h - lineFormat.marginBottom) |
|
.style( |
|
'stroke', |
|
typeof lineFormat.color === 'string' |
|
? lineFormat.color |
|
: lineFormat.color(lineScale.toString()), |
|
) |
|
.style('stroke-width', lineFormat.width) |
|
} |
|
} |
|
|
|
// ============================================================================ |
|
// Utility Functions |
|
// ============================================================================ |
|
|
|
function toNumber(value: number | Date): number { |
|
return value instanceof Date ? value.getTime() : value |
|
} |
|
|
|
function toDate(value: number | Date): Date { |
|
return value instanceof Date ? value : new Date(value) |
|
} |
|
|
|
// ============================================================================ |
|
// Builder Pattern Methods |
|
// ============================================================================ |
|
|
|
const builder = timelineFunction as TimelineBuilder |
|
|
|
builder.margin = function (p: TimelineMargin) { |
|
margin = p |
|
return builder |
|
} |
|
|
|
builder.orient = function (orientation: 'bottom' | 'top') { |
|
orient = orientation |
|
return builder |
|
} |
|
|
|
builder.itemHeight = function (h: number) { |
|
itemHeight = h |
|
return builder |
|
} |
|
|
|
builder.itemMargin = function (h: number) { |
|
itemMargin = h |
|
return builder |
|
} |
|
|
|
builder.navMargin = function (h: number) { |
|
navMargin = h |
|
return builder |
|
} |
|
|
|
builder.height = function (h: number) { |
|
height = h |
|
return builder |
|
} |
|
|
|
builder.width = function (w: number) { |
|
width = w |
|
return builder |
|
} |
|
|
|
builder.display = function (displayType: DisplayType) { |
|
if (DISPLAY_TYPES.includes(displayType)) { |
|
display = displayType |
|
} |
|
return builder |
|
} |
|
|
|
builder.labelFormat = function (f: (label: string) => string) { |
|
labelFunction = f |
|
return builder |
|
} |
|
|
|
builder.tickFormat = function (format: TimelineTickFormat) { |
|
tickFormat = format |
|
return builder |
|
} |
|
|
|
builder.hover = function (hoverFunc: TimelineCallback<void>) { |
|
hover = hoverFunc |
|
return builder |
|
} |
|
|
|
builder.mouseover = function (mouseoverFunc: TimelineCallback<void>) { |
|
mouseover = mouseoverFunc |
|
return builder |
|
} |
|
|
|
builder.mouseout = function (mouseoutFunc: TimelineCallback<void>) { |
|
mouseout = mouseoutFunc |
|
return builder |
|
} |
|
|
|
builder.click = function (clickFunc: TimelineCallback<void>) { |
|
click = clickFunc |
|
return builder |
|
} |
|
|
|
builder.scroll = function (scrollFunc: (x: number, scale: ScaleTime<number, number>) => void) { |
|
scroll = scrollFunc |
|
return builder |
|
} |
|
|
|
builder.colors = function (colorFormat: ScaleOrdinal<string, string> | string[]) { |
|
if (Array.isArray(colorFormat)) { |
|
colorCycle = scaleOrdinal(colorFormat) |
|
} else { |
|
colorCycle = colorFormat |
|
} |
|
return builder |
|
} |
|
|
|
builder.beginning = function (b: number | Date) { |
|
beginning = b |
|
return builder |
|
} |
|
|
|
builder.ending = function (e: number | Date) { |
|
ending = e |
|
return builder |
|
} |
|
|
|
builder.labelMargin = function (m: number) { |
|
labelMargin = m |
|
return builder |
|
} |
|
|
|
builder.rotateTicks = function (degrees: number) { |
|
rotateTicks = degrees |
|
return builder |
|
} |
|
|
|
builder.stack = function () { |
|
stacked = !stacked |
|
return builder |
|
} |
|
|
|
builder.relativeTime = function () { |
|
timeIsRelative = !timeIsRelative |
|
return builder |
|
} |
|
|
|
builder.showBorderLine = function () { |
|
showBorderLine = !showBorderLine |
|
return builder |
|
} |
|
|
|
builder.showBorderFormat = function (borderFormat: TimelineLineFormat) { |
|
showBorderFormat = borderFormat |
|
return builder |
|
} |
|
|
|
builder.showToday = function () { |
|
showTodayLine = !showTodayLine |
|
return builder |
|
} |
|
|
|
builder.showTodayFormat = function (todayFormat: TimelineLineFormat) { |
|
showTodayFormat = todayFormat |
|
return builder |
|
} |
|
|
|
builder.colorProperty = function (colorProp: string | null) { |
|
colorPropertyName = colorProp |
|
return builder |
|
} |
|
|
|
builder.rowSeparators = function (color: string | null) { |
|
rowSeparatorsColor = color |
|
return builder |
|
} |
|
|
|
builder.background = function ( |
|
color: string | null | ((datum: TimelineDatum, index: number) => string), |
|
) { |
|
backgroundColor = color |
|
return builder |
|
} |
|
|
|
builder.showTimeAxis = function () { |
|
showTimeAxis = !showTimeAxis |
|
return builder |
|
} |
|
|
|
builder.showAxisTop = function () { |
|
showAxisTop = !showAxisTop |
|
return builder |
|
} |
|
|
|
builder.showAxisCalendarYear = function () { |
|
showAxisCalendarYear = !showAxisCalendarYear |
|
return builder |
|
} |
|
|
|
builder.showTimeAxisTick = function () { |
|
timeAxisTick = !timeAxisTick |
|
return builder |
|
} |
|
|
|
builder.fullLengthBackgrounds = function () { |
|
fullLengthBackgrounds = !fullLengthBackgrounds |
|
return builder |
|
} |
|
|
|
builder.showTimeAxisTickFormat = function (format: TimelineAxisTickFormat) { |
|
timeAxisTickFormat = format |
|
return builder |
|
} |
|
|
|
builder.showAxisHeaderBackground = function (bgColor?: string) { |
|
showAxisHeaderBackground = !showAxisHeaderBackground |
|
if (bgColor) { |
|
axisBgColor = bgColor |
|
} |
|
return builder |
|
} |
|
|
|
builder.navigate = function ( |
|
navigateBackwards: (beginning: number | Date, chartData: TimelineData) => void, |
|
navigateForwards: (ending: number | Date, chartData: TimelineData) => void, |
|
) { |
|
navigateLeft = navigateBackwards |
|
navigateRight = navigateForwards |
|
showAxisNav = !showAxisNav |
|
return builder |
|
} |
|
|
|
return builder |
|
} |
|
|
|
// ============================================================================ |
|
// Module Exports & D3 Integration |
|
// ============================================================================ |
|
|
|
export default timeline |
|
|
|
// Augment d3 namespace by attaching timeline to it |
|
export function extendD3(d3Instance: any): typeof d3 { |
|
d3Instance.timeline = timeline |
|
return d3Instance |
|
} |