// dirty hack to vertically align legends across multiple charts // however, without monospace font, it's still not perfectly aligned // waiting for https://github.com/chartjs/Chart.js/discussions/9890 const LEGEND_CHARACTERS = 20 // https://hihayk.github.io/scale/#4/6/50/80/-51/67/20/14/276749/39/103/73/white const baseColors = ['#112836', '#163B43', '#1C4F4D', '#215B4C', '#276749', '#437C57', '#5F9167', '#7DA67C', '#9FBA98', '#BFCEB5', '#DCE2D3'] const projectsCanvas = document.getElementById('chart-projects') const osCanvas = document.getElementById('chart-os') const editorsCanvas = document.getElementById('chart-editor') const languagesCanvas = document.getElementById('chart-language') const machinesCanvas = document.getElementById('chart-machine') const labelsCanvas = document.getElementById('chart-label') const branchesCanvas = document.getElementById('chart-branches') const entitiesCanvas = document.getElementById('chart-entities') const projectContainer = document.getElementById('project-container') const osContainer = document.getElementById('os-container') const editorContainer = document.getElementById('editor-container') const languageContainer = document.getElementById('language-container') const machineContainer = document.getElementById('machine-container') const labelContainer = document.getElementById('label-container') const branchContainer = document.getElementById('branch-container') const entityContainer = document.getElementById('entity-container') const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer, labelContainer, branchContainer, entityContainer] const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas, labelsCanvas, branchesCanvas, entitiesCanvas] const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines, wakapiData.labels, wakapiData.branches, wakapiData.entities] let topNPickers = [...document.getElementsByClassName('top-picker')] topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value))) topNPickers.forEach(e => { const idx = parseInt(e.attributes['data-entity'].value) e.max = data[idx].length e.value = Math.min(e.max, 9) }) let charts = [] let showTopN = [] Chart.defaults.color = "#E2E8F0" Chart.defaults.borderColor = "#242b3a" Chart.defaults.font.family = 'Source Sans 3, Roboto, Helvetica Neue, Arial, sens-serif' String.prototype.toHHMMSS = function () { const sec_num = parseInt(this, 10) let hours = Math.floor(sec_num / 3600) let minutes = Math.floor((sec_num - (hours * 3600)) / 60) let seconds = sec_num - (hours * 3600) - (minutes * 60) if (hours < 10) { hours = '0' + hours } if (minutes < 10) { minutes = '0' + minutes } if (seconds < 10) { seconds = '0' + seconds } return `${hours}:${minutes}:${seconds}` } function draw(subselection) { function getTooltipOptions(key) { return { callbacks: { label: (item) => { const d = wakapiData[key][item.dataIndex] return ` ${d.key}: ${d.total.toString().toHHMMSS()}` }, title: () => 'Total Time', footer: () => key === 'projects' ? 'Click for details' : null } } } function filterLegendItem(item) { item.text = item.text.length > LEGEND_CHARACTERS ? item.text.slice(0, LEGEND_CHARACTERS - 3).padEnd(LEGEND_CHARACTERS, '.') : item.text item.text = item.text.padEnd(LEGEND_CHARACTERS + 3) return true } function shouldUpdate(index) { return !subselection || (subselection.includes(index) && data[index].length >= showTopN[index]) } charts .filter((c, i) => shouldUpdate(i)) .forEach(c => c.destroy()) const vibrantColors = JSON.parse(window.localStorage.getItem('wakapi_vibrant_colors') || false); let projectChart = projectsCanvas && !projectsCanvas.classList.contains('hidden') && shouldUpdate(0) ? new Chart(projectsCanvas.getContext('2d'), { //type: 'horizontalBar', type: "bar", data: { datasets: [{ data: wakapiData.projects .slice(0, Math.min(showTopN[0], wakapiData.projects.length)) .map(p => parseInt(p.total)), backgroundColor: wakapiData.projects.map((p, i) => { const c = hexToRgb(vibrantColors ? getRandomColor(p.key) : getColor(p.key, i % baseColors.length)) return `rgba(${c.r}, ${c.g}, ${c.b}, 1)` }), hoverBackgroundColor: wakapiData.projects.map((p, i) => { const c = hexToRgb(vibrantColors ? getRandomColor(p.key) : getColor(p.key, i % baseColors.length)) return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)` }), }], labels: wakapiData.projects .slice(0, Math.min(showTopN[0], wakapiData.projects.length)) .map(p => p.key) }, options: { indexAxis: 'y', scales: { xAxes: { title: { display: true, text: 'Duration (hh:mm:ss)', }, ticks: { callback: (label) => label.toString().toHHMMSS(), } } }, plugins: { legend: { display: false, }, tooltip: getTooltipOptions('projects'), }, maintainAspectRatio: false, onClick: (event, data) => { const idx = data[0].index const name = wakapiData.projects[idx].key const url = new URL(window.location.href) url.searchParams.set('project', name) window.location.href = url.href }, onHover: (event, elem) => { event.native.target.style.cursor = elem[0] ? 'pointer' : 'default' } } }) : null let osChart = osCanvas && !osCanvas.classList.contains('hidden') && shouldUpdate(1) ? new Chart(osCanvas.getContext('2d'), { type: 'pie', data: { datasets: [{ data: wakapiData.operatingSystems .slice(0, Math.min(showTopN[1], wakapiData.operatingSystems.length)) .map(p => parseInt(p.total)), backgroundColor: wakapiData.operatingSystems.map((p, i) => { const c = hexToRgb(vibrantColors ? (osColors[p.key.toLowerCase()] || getRandomColor(p.key)) : getColor(p.key, i)) return `rgba(${c.r}, ${c.g}, ${c.b}, 1)` }), hoverBackgroundColor: wakapiData.operatingSystems.map((p, i) => { const c = hexToRgb(vibrantColors ? (osColors[p.key.toLowerCase()] || getRandomColor(p.key)) : getColor(p.key, i)) return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)` }), borderWidth: 0 }], labels: wakapiData.operatingSystems .slice(0, Math.min(showTopN[1], wakapiData.operatingSystems.length)) .map(p => p.key) }, options: { plugins: { tooltip: getTooltipOptions('operatingSystems'), legend: { position: 'right', labels: { filter: filterLegendItem }, }, }, maintainAspectRatio: false, } }) : null let editorChart = editorsCanvas && !editorsCanvas.classList.contains('hidden') && shouldUpdate(2) ? new Chart(editorsCanvas.getContext('2d'), { type: 'pie', data: { datasets: [{ data: wakapiData.editors .slice(0, Math.min(showTopN[2], wakapiData.editors.length)) .map(p => parseInt(p.total)), backgroundColor: wakapiData.editors.map((p, i) => { const c = hexToRgb(vibrantColors ? (editorColors[p.key.toLowerCase()] || getRandomColor(p.key)) : getColor(p.key, i)) return `rgba(${c.r}, ${c.g}, ${c.b}, 1)` }), hoverBackgroundColor: wakapiData.editors.map((p, i) => { const c = hexToRgb(vibrantColors ? (editorColors[p.key.toLowerCase()] || getRandomColor(p.key)) : getColor(p.key, i)) return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)` }), borderWidth: 0 }], labels: wakapiData.editors .slice(0, Math.min(showTopN[2], wakapiData.editors.length)) .map(p => p.key) }, options: { plugins: { tooltip: getTooltipOptions('editors'), legend: { position: 'right', labels: { filter: filterLegendItem }, }, }, maintainAspectRatio: false, } }) : null let languageChart = languagesCanvas && !languagesCanvas.classList.contains('hidden') && shouldUpdate(3) ? new Chart(languagesCanvas.getContext('2d'), { type: 'pie', data: { datasets: [{ data: wakapiData.languages .slice(0, Math.min(showTopN[3], wakapiData.languages.length)) .map(p => parseInt(p.total)), backgroundColor: wakapiData.languages.map(p => { const c = hexToRgb(languageColors[p.key.toLowerCase()] || getRandomColor(p.key)) return `rgba(${c.r}, ${c.g}, ${c.b}, 1)` }), hoverBackgroundColor: wakapiData.languages.map(p => { const c = hexToRgb(languageColors[p.key.toLowerCase()] || getRandomColor(p.key)) return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)` }), borderWidth: 0 }], labels: wakapiData.languages .slice(0, Math.min(showTopN[3], wakapiData.languages.length)) .map(p => p.key) }, options: { plugins: { tooltip: getTooltipOptions('languages'), legend: { position: 'right', labels: { filter: filterLegendItem }, title: { display: true, } }, }, maintainAspectRatio: false, } }) : null let machineChart = machinesCanvas && !machinesCanvas.classList.contains('hidden') && shouldUpdate(4) ? new Chart(machinesCanvas.getContext('2d'), { type: 'pie', data: { datasets: [{ data: wakapiData.machines .slice(0, Math.min(showTopN[4], wakapiData.machines.length)) .map(p => parseInt(p.total)), backgroundColor: wakapiData.machines.map((p, i) => { const c = hexToRgb(vibrantColors ? getRandomColor(p.key) : getColor(p.key, i)) return `rgba(${c.r}, ${c.g}, ${c.b}, 1)` }), hoverBackgroundColor: wakapiData.machines.map((p, i) => { const c = hexToRgb(vibrantColors ? getRandomColor(p.key) : getColor(p.key, i)) return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)` }), borderWidth: 0 }], labels: wakapiData.machines .slice(0, Math.min(showTopN[4], wakapiData.machines.length)) .map(p => p.key) }, options: { plugins: { tooltip: getTooltipOptions('machines'), legend: { position: 'right', labels: { filter: filterLegendItem }, }, }, maintainAspectRatio: false, } }) : null let labelChart = labelsCanvas && !labelsCanvas.classList.contains('hidden') && shouldUpdate(5) ? new Chart(labelsCanvas.getContext('2d'), { type: 'pie', data: { datasets: [{ data: wakapiData.labels .slice(0, Math.min(showTopN[5], wakapiData.labels.length)) .map(p => parseInt(p.total)), backgroundColor: wakapiData.labels.map((p, i) => { const c = hexToRgb(vibrantColors ? getRandomColor(p.key) : getColor(p.key, i)) return `rgba(${c.r}, ${c.g}, ${c.b}, 1)` }), hoverBackgroundColor: wakapiData.labels.map((p, i) => { const c = hexToRgb(vibrantColors ? getRandomColor(p.key) : getColor(p.key, i)) return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)` }), borderWidth: 0 }], labels: wakapiData.labels .slice(0, Math.min(showTopN[5], wakapiData.labels.length)) .map(p => p.key) }, options: { plugins: { tooltip: getTooltipOptions('labels'), legend: { position: 'right', labels: { filter: filterLegendItem }, }, }, maintainAspectRatio: false, } }) : null let branchChart = branchesCanvas && !branchesCanvas.classList.contains('hidden') && shouldUpdate(6) ? new Chart(branchesCanvas.getContext('2d'), { type: "bar", data: { datasets: [{ data: wakapiData.branches .slice(0, Math.min(showTopN[6], wakapiData.branches.length)) .map(p => parseInt(p.total)), backgroundColor: wakapiData.branches.map((p, i) => { const c = hexToRgb(vibrantColors ? getRandomColor(p.key) : getColor(p.key, i % baseColors.length)) return `rgba(${c.r}, ${c.g}, ${c.b}, 1)` }), hoverBackgroundColor: wakapiData.branches.map((p, i) => { const c = hexToRgb(vibrantColors ? getRandomColor(p.key) : getColor(p.key, i % baseColors.length)) return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)` }), }], labels: wakapiData.branches .slice(0, Math.min(showTopN[6], wakapiData.branches.length)) .map(p => p.key) }, options: { indexAxis: 'y', scales: { xAxes: { title: { display: true, text: 'Duration (hh:mm:ss)', }, ticks: { callback: (label) => label.toString().toHHMMSS(), } } }, plugins: { legend: { display: false, }, tooltip: getTooltipOptions('branches'), }, maintainAspectRatio: false, } }) : null let entityChart = entitiesCanvas && !entitiesCanvas.classList.contains('hidden') && shouldUpdate(7) ? new Chart(entitiesCanvas.getContext('2d'), { //type: 'horizontalBar', type: "bar", data: { datasets: [{ data: wakapiData.entities .slice(0, Math.min(showTopN[7], wakapiData.entities.length)) .map(p => parseInt(p.total)), backgroundColor: wakapiData.entities.map((p, i) => { const c = hexToRgb(vibrantColors ? getRandomColor(p.key) : getColor(p.key, i % baseColors.length)) return `rgba(${c.r}, ${c.g}, ${c.b}, 1)` }), hoverBackgroundColor: wakapiData.entities.map((p, i) => { const c = hexToRgb(vibrantColors ? getRandomColor(p.key) : getColor(p.key, i % baseColors.length)) return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)` }), }], labels: wakapiData.entities .slice(0, Math.min(showTopN[7], wakapiData.entities.length)) .map(p => extractFile(p.key)) }, options: { indexAxis: 'y', scales: { xAxes: { title: { display: true, text: 'Duration (hh:mm:ss)', }, ticks: { callback: (label) => label.toString().toHHMMSS(), } } }, plugins: { legend: { display: false, }, tooltip: getTooltipOptions('entities'), }, maintainAspectRatio: false, onClick: (event, data) => { const idx = data[0].index const name = wakapiData.entities[idx].key const url = new URL(window.location.href) url.searchParams.set('project', name) window.location.href = url.href }, onHover: (event, elem) => { event.native.target.style.cursor = elem[0] ? 'pointer' : 'default' } } }) : null charts[0] = projectChart ? projectChart : charts[0] charts[1] = osChart ? osChart : charts[1] charts[2] = editorChart ? editorChart : charts[2] charts[3] = languageChart ? languageChart : charts[3] charts[4] = machineChart ? machineChart : charts[4] charts[5] = labelChart ? labelChart : charts[5] charts[6] = branchChart ? branchChart : charts[6] charts[7] = entityChart ? entityChart : charts[7] } function parseTopN() { showTopN = topNPickers.map(e => parseInt(e.value)) } function togglePlaceholders(mask) { const placeholderElements = containers.map(c => c ? c.querySelector('.placeholder-container') : null) for (let i = 0; i < mask.length; i++) { if (placeholderElements[i] === null) { continue; } if (!mask[i]) { canvases[i].classList.add('hidden') placeholderElements[i].classList.remove('hidden') } else { canvases[i].classList.remove('hidden') placeholderElements[i].classList.add('hidden') } } } function getPresentDataMask() { return data.map(list => (list ? list.reduce((acc, e) => acc + e.total, 0) : 0) > 0) } function getColor(seed, index) { if (index < baseColors.length) return baseColors[(index + 5) % baseColors.length] return getRandomColor(seed) } function getRandomColor(seed) { seed = seed ? seed : '1234567' Math.seedrandom(seed) var letters = '0123456789ABCDEF'.split('') var color = '#' for (var i = 0; i < 6; i++) { color += letters[Math.floor(Math.random() * 16)] } return color } // https://stackoverflow.com/a/5624139/3112139 function hexToRgb(hex) { var shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; hex = hex.replace(shorthandRegex, function (m, r, g, b) { return r + r + g + g + b + b; }); var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) } : null; } function swapCharts(showEntity, hideEntity) { document.getElementById(`${showEntity}-container`).parentElement.classList.remove('hidden') document.getElementById(`${hideEntity}-container`).parentElement.classList.add('hidden') } function extractFile(filePath) { const delimiter = filePath.includes('\\') ? '\\' : '/' // windows style path? return filePath.split(delimiter).at(-1) } window.addEventListener('load', function () { topNPickers.forEach(e => e.addEventListener('change', () => { parseTopN() draw([parseInt(e.attributes['data-entity'].value)]) })) parseTopN() togglePlaceholders(getPresentDataMask()) draw() })