How to implement zoom to some value on the timeline? For example I want to see only last 3 years from today on the screen?
I tried to use zoom.scaleBy and zoom.scaleTo but it didn't work for me.
My code (also here):
const data = {
"history": [
{
"change_date": "2001-04-03",
"change_kind_name": "Реєстрація",
"change_kind_level": 1,
"change_kind_id": "reg",
"change_description": "Зареєстровано, запис від 17.01.2005",
"change_color": "green"
},
{
"change_date": "2005-01-17",
"change_kind_name": "Реєстрація",
"change_kind_level": 1,
"change_kind_id": "reg",
"change_description": "Зареєстровано",
"change_color": "green"
},
{
"change_date": "2017-12-27",
"change_kind_name": "Припинено",
"change_kind_level": 1,
"change_kind_id": "unreg",
"change_description": "Знято з реєстрації",
"change_color": "red"
}
],
"history_state": [
{
"start_date": "2001-04-03",
"state_name": "діє",
"state_color": "green"
},
{
"start_date": "2005-01-17",
"state_name": "діє",
"state_color": "green"
},
{
"start_date": "2017-12-27",
"state_name": "припинено",
"state_color": "red"
}
],
"history_fin_activ": [
{
"start_date": "2001-04-01",
"end_date": "2012-12-31",
"state_name": "дані відсутні",
"state_color": "gray"
},
{
"start_date": "2013-01-01",
"end_date": "2013-12-31",
"state_name": "неактивний",
"state_color": "red"
},
{
"start_date": "2014-01-01",
"end_date": "2017-12-31",
"state_name": "активний",
"state_color": "green"
},
{
"start_date": "2018-01-01",
"end_date": "2018-06-30",
"state_name": "неактивний",
"state_color": "red"
},
{
"start_date": "2018-07-01",
"end_date": "2018-09-30",
"state_name": "активний",
"state_color": "green"
},
{
"start_date": "2018-10-01",
"end_date": "2021-06-30",
"state_name": "неактивний",
"state_color": "red"
},
{
"start_date": "2021-07-01",
"end_date": "2021-09-30",
"state_name": "дані відсутні",
"state_color": "gray"
},
{
"start_date": "2021-10-01",
"end_date": "2022-03-31",
"state_name": "неактивний",
"state_color": "red"
},
{
"start_date": "2022-04-01",
"end_date": "2022-09-30",
"state_name": "дані відсутні",
"state_color": "gray"
}
]
}
const DEFAULT_OPTIONS = {
// width: 700,
height: 400,
paddings: {
top: 10,
right: 50,
left: 50,
bottom: 30
}
}
const POINT_RADIUS = 3
const RECT_BORDER_RADIUS = 8
const STROKE_WIDTH = 1
const ACTIVITY_STROKE_WIDTH = POINT_RADIUS + (STROKE_WIDTH * 2)
const TEXT_RECT_HEIGHT = 20
// const POINTER_INDENT_X = 30
const POINTER_INDENT_Y = 50
const ACTIVE_LINE_INDENT = 0
const FIN_ACTIVITY_STROKE_WIDTH = 4
const FIN_ACTIVITY_RECT_HEIGHT = 24
const FIN_ACTIVITY_RECT_BORDER_RADIUS = FIN_ACTIVITY_RECT_HEIGHT / 2
const FIN_ACTIVITY_LINE_INDENT = FIN_ACTIVITY_RECT_HEIGHT + 5
const LEGEND_INDENT = 30
const LEGEND_FONT_SIZE = 10
const LEGEND_INTERVAL = 4
const COLORS = {
DarkBlue: '#004071',
green: '#0b7923',
DarkOrange: '#d16c1a',
red: '#8b1b1b',
LightBlue: '#1d6bc9',
Violet: '#5b3f8e',
gray: 'gray'
}
function splitLongLabel(label) {
return label
.split('\n')
.join('<br>')
}
function groupData(arr) {
for (let i = 0; i < arr.length; i++) {
arr[i].date = new Date(arr[i].change_date)
for (let j = 0; j < i; j++) {
if (!arr[j]) continue
if (arr[i].change_kind_level === arr[j].change_kind_level && arr[i].change_date === arr[j].change_date) {
arr[j] = {
...arr[j],
change_kind_name: arr[j].change_kind_name + ', ' + arr[i].change_kind_name,
change_kind_id: arr[j].change_kind_id + ', ' + arr[i].change_kind_id,
change_description: arr[j].change_description + '\n' + arr[i].change_description
}
delete arr[i]
}
}
}
return arr.filter(i => i)
}
function prepareHistoryData(history) {
for (let i = 0; i < history.length; i++) {
history[i].end_date = history[i].end_date || history[i + 1]?.start_date || new Date(Date.now()).toISOString().split('T')[0]
history[i].startDate = new Date(history[i].start_date)
history[i].endDate = new Date(history[i].end_date)
}
return history
}
function timeline(selector, data, options) {
options = {...DEFAULT_OPTIONS, ...options}
const width = options.width || document.querySelector(selector).clientWidth
let height = options.height
const paddings = {...options.paddings}
const formattedData = groupData([...data.history])
const historyData = prepareHistoryData([...data.history_state])
const historyFinActiveData = prepareHistoryData([...data.history_fin_activ])
const legendData = []
// checking for financial activity and increasing the bottom padding ----------------------------
if (historyFinActiveData.length) {
const newLegendData = [
{
text: 'green',
pointColor: COLORS.green
},
{
text: 'red',
pointColor: COLORS.red
},
{
text: 'gray',
pointColor: COLORS.gray
}
]
legendData.push(...newLegendData)
paddings.bottom += LEGEND_FONT_SIZE + LEGEND_INTERVAL
}
const line = d3.line()
// .curve(d3.curveBasis)
// create svg element ---------------------------------------------------------------------------
const svg = d3.select(selector)
.append('svg')
.attr('width', width)
.attr('height', height)
// append container -----------------------------------------------------------------------------
const container = svg.append('g')
.attr('class', 'chart-container')
.attr('transform', `translate(${paddings.left},0)`)
// create scales for x-axis and colors ----------------------------------------------------------
const max = new Date(Date.now())
const min = d3.min([
d3.min(formattedData, d => d.date),
d3.min(historyData, d => d.startDate)
])
const scaleX = d3.scaleTime()
.domain([
min,
max
])
.range([0, width - paddings.left - paddings.right])
.nice()
const scaleColor = d3.scaleLinear()
.domain([1, 2, 3])
.range(['#0b7923', '#d16c1a', '#8b1b1b', '#1d6bc9'])
// configure x-axis -----------------------------------------------------------------------------
const periodLength = max.getFullYear() - min.getFullYear() // in years
const quaters = {
0: 'I',
3: 'II',
6: 'III',
9: 'IV'
}
const getLabelFormat = d => {
if (periodLength > 2) {
return d.getFullYear()
} else {
return `${d.getFullYear()} - ${quaters[d.getMonth()]}`
}
}
let tickFrequency = [10]
if (periodLength < 10) {
if (periodLength > 2) {
tickFrequency = [d3.timeYear.every(1)]
} else {
tickFrequency = [d3.timeMonth.every(3)]
}
}
const axisX = d3.axisBottom(scaleX)
.tickArguments(tickFrequency)
.tickFormat(getLabelFormat)
// appending x-axis to container ----------------------------------------------------------------
container.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0, ${height - paddings.bottom})`)
.call(axisX)
// adding inactive part of timeline -------------------------------------------------------------
const inactiveData = [
{
startDate: scaleX.domain()[0],
endDate: min,
state_color: '#cdcdcd'
},
{
startDate: max,
endDate: scaleX.domain()[1],
state_color: '#cdcdcd'
}
]
historyData.push(...inactiveData)
// appending activity line to chart -------------------------------------------------------------
container.selectAll('.activity-line')
.data(historyData)
.enter()
.append('line')
.attr('class', 'activity-line')
.attr('x1', d => scaleX(d.startDate))
.attr('x2', d => scaleX(d.endDate))
.attr('stroke', d => d.state_color)
.attr('stroke-width', ACTIVITY_STROKE_WIDTH)
.attr('transform', `translate(0, ${height - paddings.bottom - ACTIVE_LINE_INDENT})`)
// appending financial activity line to chart ---------------------------------------------------
container.selectAll('.fin-activity-line')
.data(historyFinActiveData)
.enter()
.append('rect')
.attr('x', d => scaleX(d.startDate))
.attr('y', 0)
.attr('width', d => {
return scaleX(d.endDate) - scaleX(d.startDate)
})
.attr('height', FIN_ACTIVITY_RECT_HEIGHT)
// .attr('rx', FIN_ACTIVITY_RECT_BORDER_RADIUS)
.attr('rx', d => {
const rectWidth = scaleX(d.endDate) - scaleX(d.startDate)
return rectWidth > FIN_ACTIVITY_RECT_HEIGHT ? FIN_ACTIVITY_RECT_BORDER_RADIUS : 5
})
// .attr('ry', FIN_ACTIVITY_RECT_BORDER_RADIUS)
.attr('ry', d => {
const rectWidth = scaleX(d.endDate) - scaleX(d.startDate)
return rectWidth > FIN_ACTIVITY_RECT_HEIGHT ? FIN_ACTIVITY_RECT_BORDER_RADIUS : 5
})
.attr('class', 'fin-activity-line')
.attr('stroke', 'white')
.attr('stroke-width', FIN_ACTIVITY_STROKE_WIDTH)
.attr('fill', d => COLORS[d.state_color])
.attr('transform', `translate(0, ${height - paddings.bottom - FIN_ACTIVITY_LINE_INDENT})`)
// binding all data with 'g' elements and translate them to right x position --------------------
const points = container.selectAll('.point')
.data(formattedData)
.enter()
.append('g')
.attr('class', 'point')
.attr('transform', d => {
const positionX = scaleX(d.date)
const positionY = height - paddings.bottom
return `translate(${positionX}, ${positionY})`
})
// creating tooltip container -------------------------------------------------------------------
const tooltip = d3.select('body')
.append('div')
.attr('class', 'd3-tooltip')
.style('position', 'absolute')
.style('display', 'none')
.style('border-radius', '4px')
.style('border', '1px solid #ccc')
.style('background-color', 'white')
.style('padding', '5px')
.style('font-size', '10px')
.style('font-family', 'sans-serif')
.style('max-width', '300px')
// appending circle elements to 'g' elements ----------------------------------------------------
points.append('circle')
.attr('r', POINT_RADIUS)
.attr('fill', '#fff')
.attr('stroke', d => scaleColor(d.change_kind_level))
.attr('stroke-width', STROKE_WIDTH)
// appending path elements to 'g' elements ------------------------------------------------------
points.append('path')
.attr('d', () => {
return line([
[0, -POINT_RADIUS - STROKE_WIDTH / 2],
[0, -POINTER_INDENT_Y + STROKE_WIDTH / 2]
])
})
.attr('fill', 'none')
.attr('stroke', 'black')
.attr('stroke-width', STROKE_WIDTH)
// calculate the label and width for all rectangles ---------------------------------------------
formattedData.forEach(d => {
let label = d.change_kind_name
if (label.length > 25) {
label = label.slice(0, 25) + '...'
}
d.label = label
d.width = label.length * 6 + STROKE_WIDTH * 6 + 10
})
// appending rect elements to 'g' elements ------------------------------------------------------
points.append('rect')
.attr('width', d => d.width)
.attr('x', d => -(d.width / 6))
.attr('y', (d, i) => {
let coordY = -POINTER_INDENT_Y
for (let j = 0; j < i; j++) {
if (scaleX(formattedData[j].date) === scaleX(d.date)) {
coordY -= TEXT_RECT_HEIGHT
}
}
return coordY - TEXT_RECT_HEIGHT + STROKE_WIDTH
})
.attr('height', TEXT_RECT_HEIGHT - STROKE_WIDTH)
.attr('rx', RECT_BORDER_RADIUS)
.attr('ry', RECT_BORDER_RADIUS)
.attr('stroke', d => scaleColor(d.change_kind_level))
.attr('stroke-width', STROKE_WIDTH)
.attr('fill', '#fff')
// appending text to 'g' elements ---------------------------------------------------------------
points.append('text')
.attr('x', d => d.width / 3)
.attr('y', (d, i) => {
let coordY = -POINTER_INDENT_Y - (TEXT_RECT_HEIGHT / 3)
for (let j = 0; j < i; j++) {
if (scaleX(formattedData[j].date) === scaleX(d.date)) {
coordY -= TEXT_RECT_HEIGHT
}
}
return coordY
})
.text(d => d.label)
.attr('font-family', 'sans-serif')
.attr('font-size', 10)
.attr('font-weight', 'bold')
.attr('text-anchor', 'middle')
.attr('fill', d => scaleColor(d.change_kind_level))
// appending rect elements to 'g' elements again for correct work of tooltip --------------------
points.append('rect')
.attr('width', d => d.width)
.attr('x', d => -(d.width / 6))
.attr('y', (d, i) => {
let coordY = -POINTER_INDENT_Y
for (let j = 0; j < i; j++) {
if (scaleX(formattedData[j].date) === scaleX(d.date)) {
coordY -= TEXT_RECT_HEIGHT
}
}
return coordY - TEXT_RECT_HEIGHT + STROKE_WIDTH
})
.attr('height', TEXT_RECT_HEIGHT - STROKE_WIDTH)
.attr('rx', RECT_BORDER_RADIUS)
.attr('ry', RECT_BORDER_RADIUS)
.attr('fill', d => scaleColor(d.change_kind_level))
.attr('opacity', 0)
.on('mouseover', (event, data) => {
event.target.style.opacity = 0.2
tooltip
.html(`
<strong>${data.change_date}</strong><br>
${splitLongLabel(data.change_description)}
`)
.style('display', 'block')
const tooltipHTML = document.querySelector('.d3-tooltip')
const position = scaleX(data.date) > width / 2
? event.pageX - tooltipHTML.clientWidth - 5 + 'px'
: event.pageX + 5 + 'px'
tooltip
.style('left', position)
.style('top', event.pageY + 5 + 'px')
})
.on('mouseout', event => {
event.target.style.opacity = 0
tooltip
.style('display', 'none')
})
// appending "today mark" to container ----------------------------------------------------------
const todayData = [{
beginDate: new Date(Date.now()),
endDate: scaleX.domain()[1]
}]
const todayMark = container.selectAll('.today')
.data(todayData)
.enter()
.append('g')
.attr('class', 'today')
.attr('transform', d => {
const positionX = scaleX(d.beginDate)
const positionY = height - paddings.bottom
return `translate(${positionX}, ${positionY})`
})
// appending rect to "today mark" --------------------------------------------------------------
todayMark.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', d => scaleX(d.endDate) - scaleX(d.beginDate))
.attr('height', 20)
.attr('fill', 'rgba(189, 189, 189, 0.2)')
// appending legend to svg ----------------------------------------------------------------------
const legend = svg.append('g')
.attr('class', 'legend')
.attr('transform', `translate(${paddings.left}, ${height - paddings.bottom + LEGEND_INDENT})`)
const rows = legend.selectAll('.row')
.data(legendData)
.enter()
.append('g')
.attr('class', 'row')
.attr('transform', (d, i) => {
const coordX = ((width / legendData.length) * i)
return `translate(${coordX}, 0)`
})
rows.append('line')
.attr('x1', 0)
.attr('x2', 0)
.attr('y1', 0)
.attr('y2', 0)
.attr('stroke', (d) => d.pointColor)
.attr('stroke-width', 10)
.attr('stroke-linecap', 'round')
rows.append('text')
.attr('x', 10)
.attr('y', LEGEND_FONT_SIZE / 3)
.text(d => d.text)
.attr('font-family', 'sans-serif')
.attr('font-size', LEGEND_FONT_SIZE)
// fit chart ------------------------------------------------------------------------------------
const opt = container.node().getBBox()
height = opt.height + paddings.top + paddings.bottom
svg.attr('height', height)
svg.selectAll('.point')
.attr('transform', d => {
const positionX = scaleX(d.date)
const positionY = height - paddings.bottom
return `translate(${positionX}, ${positionY})`
})
svg.selectAll('.x-axis')
.attr('transform', `translate(0, ${height - paddings.bottom})`)
svg.selectAll('.activity-line')
.attr('transform', `translate(0, ${height - paddings.bottom - ACTIVE_LINE_INDENT})`)
svg.selectAll('.fin-activity-line')
.attr('transform', `translate(0, ${height - paddings.bottom - FIN_ACTIVITY_LINE_INDENT})`)
svg.selectAll('.today')
.attr('transform', d => {
const positionX = scaleX(d.beginDate)
const positionY = paddings.top
return `translate(${positionX}, ${positionY})`
})
.selectAll('rect')
.attr('height', height - paddings.top - paddings.bottom + 20)
svg.selectAll('.legend')
.attr('transform', `translate(${paddings.left}, ${height - paddings.bottom + LEGEND_INDENT})`)
function zoomed(event) {
scaleX
// .range([0, width - paddings.left - paddings.right].map(d => event.transform.applyX(d)))
.range(
[
event.transform.applyX(0),
event.transform.applyX(width) - paddings.left - paddings.right
]
)
svg.selectAll('.point')
.attr('transform', d => {
const positionX = scaleX(d.date)
const positionY = height - paddings.bottom
return `translate(${positionX}, ${positionY})`
})
svg.selectAll('.x-axis').call(axisX)
svg.selectAll('.activity-line')
.attr('x1', d => scaleX(d.startDate))
.attr('x2', d => scaleX(d.endDate))
svg.selectAll('.fin-activity-line')
.attr('x', d => scaleX(d.startDate))
.attr('width', d => {
return scaleX(d.endDate) - scaleX(d.startDate)
})
.attr('rx', d => {
const rectWidth = scaleX(d.endDate) - scaleX(d.startDate)
return rectWidth > FIN_ACTIVITY_RECT_HEIGHT ? FIN_ACTIVITY_RECT_BORDER_RADIUS : 5
})
.attr('ry', d => {
const rectWidth = scaleX(d.endDate) - scaleX(d.startDate)
return rectWidth > FIN_ACTIVITY_RECT_HEIGHT ? FIN_ACTIVITY_RECT_BORDER_RADIUS : 5
})
svg.selectAll('.today')
.attr('transform', d => {
const positionX = scaleX(d.beginDate)
const positionY = paddings.top
return `translate(${positionX}, ${positionY})`
})
.selectAll('rect')
.attr('width', d => scaleX(d.endDate) - scaleX(d.beginDate))
}
function getScaleExtent() {
const minDate = d3.min(formattedData, d => d.date)
const today = new Date(Date.now())
const oneYearAgo = new Date(Date.now())
oneYearAgo.setFullYear(today.getFullYear() - 1)
// one year scaling
if (oneYearAgo - minDate < 0) {
// If timeline shorter than 1 year - no zoom
return [1,1]
} else {
const todayMaxValueX = scaleX(today)
const oneYearAgoValueX = scaleX(oneYearAgo)
const diff = Math.abs(todayMaxValueX - oneYearAgoValueX)
const realWidth = scaleX(scaleX.domain()[1]) - scaleX(scaleX.domain()[0])
return [1, realWidth / diff]
}
}
const extent = [[0, 0], [width, height]]
const scaleExtent = getScaleExtent()
const zoom = d3.zoom()
.scaleExtent(scaleExtent)
.translateExtent(extent)
.extent(extent)
.on('zoom', zoomed)
svg.call(zoom)
function destroy() {
d3.select(selector).select('svg').remove()
d3.select('.d3-tooltip').remove()
window.removeEventListener('resize', resize)
}
function resize() {
destroy()
timeline(selector, data, options)
}
window.addEventListener('resize', resize)
function setZoomScale(scale, diff) {
const pointToScale = [
width,
height
]
svg.call(zoom.scaleBy, scale, pointToScale)
}
function setZoomForNLastYears(numberOfYearsAgo) {
const minDate = d3.min(formattedData, d => d.date)
const today = new Date(Date.now())
const nYearAgo = new Date(Date.now())
nYearAgo.setFullYear(today.getFullYear() - numberOfYearsAgo)
if (nYearAgo - minDate < 0) {
return
}
const todayMaxValueX = scaleX(today)
const nYearAgoValueX = scaleX(nYearAgo)
const diff = Math.abs(todayMaxValueX - nYearAgoValueX)
const realWidth = width - paddings.left - paddings.right
setZoomScale(realWidth / diff, diff)
}
setZoomForNLastYears(3)
return {
destroy
}
}
timeline('#test', data)
#test {
margin: 0 auto;
border: 1px solid black;
width: 700px;
border-radius: 5px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.6.0/d3.min.js"></script>
<div id="test"></div>