mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-08-02 09:14:40 +02:00
Add:Listening sessions calendar heat map
This commit is contained in:
parent
621444114f
commit
5a6867e98a
5 changed files with 300 additions and 7 deletions
273
client/components/stats/Heatmap.vue
Normal file
273
client/components/stats/Heatmap.vue
Normal file
|
@ -0,0 +1,273 @@
|
|||
<template>
|
||||
<div id="heatmap" class="w-full">
|
||||
<div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)">
|
||||
<p class="mb-2 px-1 text-sm text-gray-200">{{ Object.values(daysListening).length }} listening sessions in the last year</p>
|
||||
<div class="border border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }">
|
||||
<div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout">
|
||||
<div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div>
|
||||
|
||||
<div v-for="monthLabel in monthLabels" :key="monthLabel.id" :style="monthLabel.style" class="absolute top-0 left-0 text-gray-300">{{ monthLabel.label }}</div>
|
||||
|
||||
<div v-for="(block, index) in data" :key="block.dateString" :style="block.style" :data-index="index" class="absolute top-0 left-0 h-2.5 w-2.5 rounded-sm" />
|
||||
|
||||
<div class="flex py-2 px-4" :style="{ marginTop: innerHeight + 'px' }">
|
||||
<div class="flex-grow" />
|
||||
<p style="font-size: 10px; line-height: 10px" class="text-gray-400 px-1">Less</p>
|
||||
<div v-for="block in legendBlocks" :key="block.id" :style="block.style" class="h-2.5 w-2.5 rounded-sm" style="margin-left: 1.5px; margin-right: 1.5px" />
|
||||
<p style="font-size: 10px; line-height: 10px" class="text-gray-400 px-1">More</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
daysListening: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
contentWidth: 0,
|
||||
maxInnerWidth: 0,
|
||||
innerHeight: 13 * 7,
|
||||
blockWidth: 13,
|
||||
data: [],
|
||||
monthLabels: [],
|
||||
tooltipEl: null,
|
||||
tooltipTextEl: null,
|
||||
tooltipArrowEl: null,
|
||||
showingTooltipIndex: -1,
|
||||
outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.03)'],
|
||||
bgColors: ['rgb(45,45,45)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']
|
||||
// GH Colors
|
||||
// outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.05)'],
|
||||
// bgColors: ['rgb(22, 27, 34)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
weeksToShow() {
|
||||
return Math.min(52, Math.floor(this.maxInnerWidth / this.blockWidth) - 1)
|
||||
},
|
||||
innerWidth() {
|
||||
return (this.weeksToShow + 1) * 13
|
||||
},
|
||||
daysToShow() {
|
||||
return this.weeksToShow * 7 + this.dayOfWeekToday
|
||||
},
|
||||
dayOfWeekToday() {
|
||||
return new Date().getDay()
|
||||
},
|
||||
firstWeekStart() {
|
||||
return this.$addDaysToToday(-this.daysToShow)
|
||||
},
|
||||
dayLabels() {
|
||||
return [
|
||||
{
|
||||
label: 'Mon',
|
||||
style: {
|
||||
transform: `translate(${-25}px, ${13}px)`,
|
||||
lineHeight: '10px',
|
||||
fontSize: '10px'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Wed',
|
||||
style: {
|
||||
transform: `translate(${-25}px, ${13 * 3}px)`,
|
||||
lineHeight: '10px',
|
||||
fontSize: '10px'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Fri',
|
||||
style: {
|
||||
transform: `translate(${-25}px, ${13 * 5}px)`,
|
||||
lineHeight: '10px',
|
||||
fontSize: '10px'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
legendBlocks() {
|
||||
return [
|
||||
{
|
||||
id: 'legend-0',
|
||||
style: `background-color:${this.bgColors[0]};outline:1px solid ${this.outlineColors[0]};outline-offset:-1px;`
|
||||
},
|
||||
{
|
||||
id: 'legend-1',
|
||||
style: `background-color:${this.bgColors[1]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||
},
|
||||
{
|
||||
id: 'legend-2',
|
||||
style: `background-color:${this.bgColors[2]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||
},
|
||||
{
|
||||
id: 'legend-3',
|
||||
style: `background-color:${this.bgColors[3]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||
},
|
||||
{
|
||||
id: 'legend-4',
|
||||
style: `background-color:${this.bgColors[4]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
destroyTooltip() {
|
||||
if (this.tooltipEl) this.tooltipEl.remove()
|
||||
this.tooltipEl = null
|
||||
this.showingTooltipIndex = -1
|
||||
},
|
||||
createTooltip() {
|
||||
const tooltip = document.createElement('div')
|
||||
tooltip.className = 'absolute top-0 left-0 rounded bg-gray-500 text-white p-2 text-white max-w-xs pointer-events-none'
|
||||
tooltip.style.display = 'none'
|
||||
tooltip.id = 'heatmap-tooltip'
|
||||
|
||||
const tooltipText = document.createElement('p')
|
||||
tooltipText.innerText = 'Tooltip'
|
||||
tooltipText.style.fontSize = '10px'
|
||||
tooltipText.style.lineHeight = '10px'
|
||||
tooltip.appendChild(tooltipText)
|
||||
|
||||
const tooltipArrow = document.createElement('div')
|
||||
tooltipArrow.className = 'text-gray-500 arrow-down-small absolute -bottom-1 left-0 right-0 mx-auto'
|
||||
tooltip.appendChild(tooltipArrow)
|
||||
|
||||
this.tooltipEl = tooltip
|
||||
this.tooltipTextEl = tooltipText
|
||||
this.tooltipArrowEl = tooltipArrow
|
||||
|
||||
document.body.appendChild(this.tooltipEl)
|
||||
},
|
||||
showTooltip(index, block, rect) {
|
||||
if (this.tooltipEl && this.showingTooltipIndex === index) return
|
||||
if (!this.tooltipEl) {
|
||||
this.createTooltip()
|
||||
}
|
||||
|
||||
this.showingTooltipIndex = index
|
||||
this.tooltipEl.style.display = 'block'
|
||||
this.tooltipTextEl.innerHTML = block.value ? `<strong>${block.value} minutes listening</strong> on ${block.datePretty}` : `No listening sessions on ${block.datePretty}`
|
||||
|
||||
const calculateRect = this.tooltipEl.getBoundingClientRect()
|
||||
|
||||
const w = calculateRect.width / 2
|
||||
var left = rect.x - w
|
||||
var offsetX = 0
|
||||
if (left < 0) {
|
||||
offsetX = Math.abs(left)
|
||||
left = 0
|
||||
} else if (rect.x + w > window.innerWidth - 10) {
|
||||
offsetX = window.innerWidth - 10 - (rect.x + w)
|
||||
left += offsetX
|
||||
}
|
||||
|
||||
this.tooltipEl.style.transform = `translate(${left}px, ${rect.y - 32}px)`
|
||||
this.tooltipArrowEl.style.transform = `translate(${5 - offsetX}px, 0px)`
|
||||
},
|
||||
hideTooltip() {
|
||||
if (this.showingTooltipIndex >= 0 && this.tooltipEl) {
|
||||
this.tooltipEl.style.display = 'none'
|
||||
this.showingTooltipIndex = -1
|
||||
}
|
||||
},
|
||||
mouseover(e) {
|
||||
if (isNaN(e.target.dataset.index)) {
|
||||
this.hideTooltip()
|
||||
return
|
||||
}
|
||||
var block = this.data[e.target.dataset.index]
|
||||
var rect = e.target.getBoundingClientRect()
|
||||
this.showTooltip(e.target.dataset.index, block, rect)
|
||||
},
|
||||
mouseout(e) {
|
||||
this.hideTooltip()
|
||||
},
|
||||
buildData() {
|
||||
this.data = []
|
||||
|
||||
var maxValue = 0
|
||||
var minValue = 0
|
||||
Object.values(this.daysListening).forEach((val) => {
|
||||
if (val > maxValue) maxValue = val
|
||||
if (!minValue || val < minValue) minValue = val
|
||||
})
|
||||
const range = maxValue - minValue + 0.01
|
||||
|
||||
for (let i = 0; i < this.daysToShow + 1; i++) {
|
||||
const col = Math.floor(i / 7)
|
||||
const row = i % 7
|
||||
|
||||
const date = i === 0 ? this.firstWeekStart : this.$addDaysToDate(this.firstWeekStart, i)
|
||||
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
|
||||
const datePretty = this.$formatJsDate(date, 'MMM d, yyyy')
|
||||
const monthString = this.$formatJsDate(date, 'MMM')
|
||||
const value = this.daysListening[dateString] || 0
|
||||
const x = col * 13
|
||||
const y = row * 13
|
||||
|
||||
var bgColor = this.bgColors[0]
|
||||
var outlineColor = this.outlineColors[0]
|
||||
if (value) {
|
||||
outlineColor = this.outlineColors[1]
|
||||
var percentOfAvg = (value - minValue) / range
|
||||
var bgIndex = Math.floor(percentOfAvg * 4) + 1
|
||||
bgColor = this.bgColors[bgIndex] || 'red'
|
||||
}
|
||||
|
||||
this.data.push({
|
||||
date,
|
||||
dateString,
|
||||
datePretty,
|
||||
monthString,
|
||||
dayOfMonth: Number(dateString.split('-').pop()),
|
||||
yearString: dateString.split('-').shift(),
|
||||
value,
|
||||
col,
|
||||
row,
|
||||
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
|
||||
})
|
||||
}
|
||||
console.log('Data', this.data)
|
||||
|
||||
this.monthLabels = []
|
||||
var lastMonth = null
|
||||
for (let i = 0; i < this.data.length; i++) {
|
||||
if (this.data[i].monthString !== lastMonth) {
|
||||
const weekOfMonth = Math.floor(this.data[i].dayOfMonth / 7)
|
||||
if (weekOfMonth <= 2) {
|
||||
this.monthLabels.push({
|
||||
id: this.data[i].dateString + '-ml',
|
||||
label: this.data[i].monthString,
|
||||
style: {
|
||||
transform: `translate(${this.data[i].col * 13}px, -15px)`,
|
||||
lineHeight: '10px',
|
||||
fontSize: '10px'
|
||||
}
|
||||
})
|
||||
lastMonth = this.data[i].monthString
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
init() {
|
||||
const heatmapEl = document.getElementById('heatmap')
|
||||
this.contentWidth = heatmapEl.clientWidth
|
||||
this.maxInnerWidth = this.contentWidth - 52
|
||||
this.buildData()
|
||||
}
|
||||
},
|
||||
updated() {},
|
||||
mounted() {
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
Loading…
Add table
Add a link
Reference in a new issue