用户学习记录calendar-heatmap

拓拔曦
2023-12-01
  • 依赖d3.js 以及 moment.js
function calendarHeatmap() {
  // defaults
  var width = 750
  var height = 130
  var legendWidth = 150
  var selector = 'body'
  var SQUARE_LENGTH = 12
  var SQUARE_PADDING = 2
  var MONTH_LABEL_PADDING = 6
  var now = moment()
    .endOf('day')
    .toDate()
  var yearAgo = moment()
    .startOf('day')
    .subtract(1, 'year')
    .toDate()
  var startDate = null
  var data = []
  var max = null
  var colorRange = ['#D8E6E7', '#218380']
  var tooltipEnabled = true
  var tooltipUnit = 'contribution'
  var legendEnabled = true
  var onClick = null
  var weekStart = 0 //0 for Sunday, 1 for Monday
  var locale = {
    months: [
      'Jan',
      'Feb',
      'Mar',
      'Apr',
      'May',
      'Jun',
      'Jul',
      'Aug',
      'Sep',
      'Oct',
      'Nov',
      'Dec',
    ],
    days: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
    No: 'No',
    on: 'on',
    Less: 'Less',
    More: 'More',
  }

  // setters and getters
  chart.data = function(value) {
    if (!arguments.length) {
      return date
    }
    date = value
    return chart
  }

  chart.max = function(value) {
    if (!arguments.length) {
      return max
    }
    max = value
    return chart
  }

  chart.selector = function(value) {
    if (!arguments.length) {
      return selector
    }
    selector = value
    return chart
  }

  chart.startDate = function(value) {
    if (!arguments.length) {
      return startDate
    }
    yearAgo = value
    now = moment(value)
      .endOf('day')
      .add(1, 'year')
      .toDate()
    return chart
  }

  chart.colorRange = function(value) {
    if (!arguments.length) {
      return colorRange
    }
    colorRange = value
    return chart
  }

  chart.tooltipEnabled = function(value) {
    if (!arguments.length) {
      return tooltipEnabled
    }
    tooltipEnabled = value
    return chart
  }

  chart.tooltipUnit = function(value) {
    if (!arguments.length) {
      return tooltipUnit
    }
    tooltipUnit = value
    return chart
  }

  chart.legendEnabled = function(value) {
    if (!arguments.length) {
      return legendEnabled
    }
    legendEnabled = value
    return chart
  }

  chart.onClick = function(value) {
    if (!arguments.length) {
      return onClick()
    }
    onClick = value
    return chart
  }

  chart.locale = function(value) {
    if (!arguments.length) {
      return locale
    }
    locale = value
    return chart
  }

  function chart() {
    d3.select(chart.selector())
      .selectAll('svg.calendar-heatmap')
      .remove() // remove the existing chart, if it exists

    var dateRange = d3.time.days(yearAgo, now) // generates an array of date objects within the specified range
    var monthRange = d3.time.months(
      moment(yearAgo)
        .startOf('month')
        .toDate(),
      now
    ) // it ignores the first month if the 1st date is after the start of the month
    var firstDate = moment(dateRange[0])
    if (max === null) {
      max = d3.max(chart.data(), function(d) {
        return d.count
      })
    } // max data value

    // color range
    var color = d3.scale
      .linear()
      .range(chart.colorRange())
      .domain([0, max])
    var tooltip
    var dayRects

    drawChart()

    function drawChart() {
      var svg = d3
        .select(chart.selector())
        .style('position', 'relative')
        .append('svg')
        .attr('width', width)
        .attr('class', 'calendar-heatmap')
        .attr('height', height)
        .style('padding', '0px')

      dayRects = svg.selectAll('.day-cell').data(dateRange) //  array of days for the last yr

      dayRects
        .enter()
        .append('rect')
        .attr('class', 'day-cell')
        .attr('width', SQUARE_LENGTH)
        .attr('height', SQUARE_LENGTH)
        .attr('fill', function(d) {
          let colorNum
          if (countForDate(d) === 0) {
            colorNum = 0
          } else if (30 > countForDate(d) && countForDate(d) > 0) {
            colorNum = 30
          } else if (60 > countForDate(d) && countForDate(d) >= 30) {
            colorNum = 40
          } else if (120 > countForDate(d) && countForDate(d) >= 60) {
            colorNum = 60
          } else if (countForDate(d) >= 120) {
            colorNum = 120
          }
          return color(colorNum)
        })
        .attr('x', function(d, i) {
          var cellDate = moment(d)
          var result =
            cellDate.week() -
            firstDate.week() +
            firstDate.weeksInYear() *
              (cellDate.weekYear() - firstDate.weekYear())
          return result * (SQUARE_LENGTH + SQUARE_PADDING)
        })
        .attr('y', function(d, i) {
          return (
            MONTH_LABEL_PADDING +
            formatWeekday(d.getDay()) * (SQUARE_LENGTH + SQUARE_PADDING)
          )
        })
      if (typeof onClick === 'function') {
        dayRects.on('click', function(d) {
          var count = countForDate(d)
          onClick({ date: d, count: count })
        })
      }
      if (chart.tooltipEnabled()) {
        dayRects
          .on('mouseenter', function(d, i) {
            tooltip = d3
              .select(chart.selector())
              .append('div')
              .attr('class', 'day-cell-tooltip')
              .html(tooltipHTMLForDate(d))
              .style('left', function() {
                return Math.floor(i / 7) * SQUARE_LENGTH + 'px'
              })
              .style('top', function() {
                return (
                  formatWeekday(d.getDay()) * SQUARE_LENGTH +
                  MONTH_LABEL_PADDING + 15 +
                  'px'
                )
              })
          })
          .on('mouseleave', function(d, i) {
            tooltip.remove()
          })
      }

      if (chart.legendEnabled()) {
        var colorRange = [color(0)]
        for (var i = 4; i > 0; i--) {
          colorRange.push(color(max / i))
        }
        var legendGroup = svg.append('g')
        legendGroup
          .selectAll('.calendar-heatmap-legend')
          .data(colorRange)
          .enter()
          .append('rect')
          .attr('class', 'calendar-heatmap-legend')
          .attr('width', SQUARE_LENGTH)
          .attr('height', SQUARE_LENGTH)
          .attr('x', function(d, i) {
            return width - legendWidth + (i + 1) * 13
          })
          .attr('y', height - SQUARE_PADDING * 6)
          .attr('fill', function(d) {
            return d
          })
          .on('mouseover', function(d, i) {
            legendTooltip = d3
              .select(chart.selector())
              .append('div')
              .attr('class', 'day-legend-tooltip')
              .html(legendTooltipHTMLForDate(i))
              .style('left', function(d, i) {
                return width - legendWidth + (i + 1) * 13 + 'px'
              })
              .style('top', function() {
                return height - SQUARE_PADDING * 8 + 15 + 'px'
              })
          })
          .on('mouseout', function(d, i) {
            legendTooltip.remove()
          })

        legendGroup
          .append('text')
          .attr(
            'class',
            'calendar-heatmap-legend-text calendar-heatmap-legend-text-less'
          )
          .attr('x', width - legendWidth - 13)
          .attr('y', height - SQUARE_PADDING)
          .text(locale.Less)

        legendGroup
          .append('text')
          .attr(
            'class',
            'calendar-heatmap-legend-text calendar-heatmap-legend-text-more'
          )
          .attr(
            'x',
            width - legendWidth + SQUARE_PADDING + (colorRange.length + 1) * 13
          )
          .attr('y', height - SQUARE_PADDING)
          .text(locale.More)
      }

      dayRects.exit().remove()
      var monthLabels = svg
        .selectAll('.month')
        .data(monthRange)
        .enter()
        .append('text')
        .attr('class', 'month-name')
        .style()
        .text(function(d) {
          return locale.months[d.getMonth()]
        })
        .attr('x', function(d, i) {
          var matchIndex = 0
          dateRange.find(function(element, index) {
            matchIndex = index + 5
            return (
              moment(d).isSame(element, 'month') &&
              moment(d).isSame(element, 'year')
            )
          })

          return Math.floor(matchIndex / 7) * (SQUARE_LENGTH + SQUARE_PADDING)
        })
        .attr('y', -5) // fix these to the top

      locale.days.forEach(function(day, index) {
        index = formatWeekday(index)
        if (index % 2) {
          svg
            .append('text')
            .attr('class', 'day-initial')
            .attr(
              'transform',
              'translate(-16,' +
                (SQUARE_LENGTH + SQUARE_PADDING) * (index + 1) +
                ')'
            )
            .style('text-anchor', 'middle')
            .attr('dy', '2')
            .text(day)
        }
      })
    }

    function pluralizedTooltipUnit(count) {
      if ('string' === typeof tooltipUnit) {
        return tooltipUnit
      }
      for (var i in tooltipUnit) {
        var _rule = tooltipUnit[i]
        var _min = _rule.min
        var _max = _rule.max || _rule.min
        _max = _max === 'Infinity' ? Infinity : _max
        if (count >= _min && count <= _max) {
          return _rule.unit
        }
      }
    }

    function tooltipHTMLForDate(d) {
      var dateStr = moment(d).format('YYYY-MM-DD')
      var count = countForDate(d)
      return (
        '<span><strong>' +
        (count ? count : 0) +
        ' ' +
        pluralizedTooltipUnit(count) +
        '</strong> ' +
        locale.on +
        ' ' +
        dateStr +
        '</span>'
      )
    }

    function legendTooltipHTMLForDate(num) {
      switch (num) {
        case 0:
          return '<span><strong>有效学习时间为0</strong></span>'
          break
        case 1:
          return '<span><strong>0min≤有效学习时间<30min</strong></span>'
          break
        case 2:
          return '<span><strong>30min≤有效学习时间<60min</strong></span>'
          break
        case 3:
          return '<span><strong>60min≤有效学习时间<120min</strong></span>'
          break
        case 4:
          return '<span><strong>有效学习时间为≥120min</strong></span>'
          break
        default:
          // statements_def
          break
      }
    }

    function countForDate(d) {
      var count = 0
      var match = chart.data().find(function(element, index) {
        return moment(element.date).isSame(d, 'day')
      })
      if (match) {
        count = match.count
      }
      return count
    }

    function formatWeekday(weekDay) {
      if (weekStart === 1) {
        if (weekDay === 0) {
          return 6
        } else {
          return weekDay - 1
        }
      }
      return weekDay
    }

    var daysOfChart = chart.data().map(function(day) {
      return day.date.toDateString()
    })

    dayRects
      .filter(function(d) {
        return daysOfChart.indexOf(d.toDateString()) > -1
      })
      .attr('fill', function(d, i) {
        let colorNum
        if (chart.data()[i].count === 0) {
          colorNum = 0
        } else if (30 > chart.data()[i].count && chart.data()[i].count > 0) {
          colorNum = 30
        } else if (chart.data()[i].count >= 30 && chart.data()[i].count < 60) {
          colorNum = 40
        } else if (120 > chart.data()[i].count && chart.data()[i].count >= 60) {
          colorNum = 60
        } else if (chart.data()[i].count >= 120) {
          colorNum = 120
        }
        return color(colorNum)
      })
  }

  return chart
}

// polyfill for Array.find() method
/* jshint ignore:start */
if (!Array.prototype.find) {
  Array.prototype.find = function(predicate) {
    if (this === null) {
      throw new TypeError('Array.prototype.find called on null or undefined')
    }
    if (typeof predicate !== 'function') {
      throw new TypeError('predicate must be a function')
    }
    var list = Object(this)
    var length = list.length >>> 0
    var thisArg = arguments[1]
    var value

    for (var i = 0; i < length; i++) {
      value = list[i]
      if (predicate.call(thisArg, value, i, list)) {
        return value
      }
    }
    return undefined
  }
}
/* jshint ignore:end */

使用方法:

<template>
  <div class="user-heatmap">
    <div class="user-record-head">
      <div class="user-record-title">
        我的学习记录
      </div>
      <div class="user-record-desc">
        <span>
          当前连续学习
          <strong>
            {{ userStudyRecord.current_studying_days }}
          </strong>
          天
        </span>
        <span>
          最大连续学习
          <strong>
            {{ userStudyRecord.max_studying_days }}
          </strong>
          天
        </span>
        <span>
          总学习天数
          <strong>
            {{ userStudyRecord.total_study_days }}
          </strong>
          天
        </span>
      </div>
    </div>
    <div
      id="calendar-heatmap"
      class="heatmap"
    />
    <div class="year">
      <i
        v-if="leftBtn"
        class="fa fa-caret-left"
        @click="preYear"
      />
      {{ startYear }}-{{ endYear }}
      <i
        v-if="rightBtn"
        class="fa fa-caret-right"
        @click="nextYear"
      />
    </div>
  </div>
</template>
<script>
import { mapActions } from 'vuex'

export default {
  props: {
    userStudyRecord: {
      type: Object,
      required: true,
    },
  },
  data() {
    return {
      startYear: '',
      endYear: '',
    }
  },
  computed: {
    query() {
      return this.$route.query
    },
  },
  async mounted() {
    this.initYear()
    this.getCalendarHeatmap(this.userStudyRecord.records, this.startYear)
  },
  methods: {
    ...mapActions('user', ['getUserStudyRecord']),
    // 记录表的基本配置
    getCalendarHeatmap(mapData, startTime) {
      const chartData = []
      for (let i = 0; i < mapData.length; i++) {
        mapData[i].date = moment(mapData[i].timestamp * 1000).toDate()
        chartData.push(mapData[i])
        startTime = moment(startTime).toDate()
        const heatmap = calendarHeatmap()
          .data(chartData)
          .selector('#calendar-heatmap')
          .startDate(startTime)
          .tooltipEnabled(true)
          .tooltipUnit('min')
          .max(120)
          .colorRange(['#eee', '#08bf91'])
        heatmap()
      }
    },
    initYear() {
      this.startYear = moment(
        moment(this.userStudyRecord.records[0].timestamp * 1000).toDate()
      ).get('year')
      this.endYear = moment(
        moment(
          this.userStudyRecord.records[this.userStudyRecord.records.length - 1]
            .timestamp * 1000
        ).toDate()
      ).get('year')
    },
  },
}
</script>
<style lang="scss" scoped>
.user-heatmap {
  position: relative;
  height: 240px;
  padding: 25px 27px;
  background: white;
  .user-record-head {
    .user-record-title {
      float: left;
      font-size: 16px;
      color: #565656;
    }
    .user-record-desc {
      float: right;
      font-size: 14px;
      color: #999;
      span {
        margin-left: 10px;
      }
    }
  }
  .heatmap {
    padding: 46px 0 0 15px;
  }
  .year {
    position: absolute;
    top: 182px;
    left: 44px;
    font-size: 14px;
    color: #a4a4a4;
    i {
      cursor: pointer;
    }
  }
}
</style>
<style lang="scss">
.user-heatmap {
  text.month-name,
  text.calendar-heatmap-legend-text,
  text.day-initial {
    font-size: 10px;
    fill: inherit;
    font-family: Helvetica, arial, 'Open Sans', sans-serif;
  }
  rect.day-cell:hover {
    stroke: #555555;
    stroke-width: 1px;
  }
  .day-cell-tooltip,
  .day-legend-tooltip {
    position: absolute;
    z-index: 9999;
    padding: 5px 9px;
    color: #bbbbbb;
    font-size: 12px;
    background: rgba(0, 0, 0, 0.85);
    border-radius: 3px;
    text-align: center;
  }
  .day-cell-tooltip > span,
  .day-legend-tooltip > span {
    display: inline-block;
    white-space: nowrap;
    font-family: Helvetica, arial, 'Open Sans', sans-serif;
  }

  .calendar-heatmap {
    box-sizing: initial;
  }
}
</style>
 类似资料: