Programmatic zoom in d3.js to special value on timeline

Viewed 36

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>

0 Answers
Related