enhance(client): make heatmap available on about page
This commit is contained in:
		
							parent
							
								
									5ebcdb4f31
								
							
						
					
					
						commit
						2353b5f553
					
				
					 3 changed files with 248 additions and 223 deletions
				
			
		
							
								
								
									
										236
									
								
								packages/client/src/components/MkActiveUsersHeatmap.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										236
									
								
								packages/client/src/components/MkActiveUsersHeatmap.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,236 @@ | ||||||
|  | <template> | ||||||
|  | <div ref="rootEl"> | ||||||
|  | 	<MkLoading v-if="fetching"/> | ||||||
|  | 	<div v-else> | ||||||
|  | 		<canvas ref="chartEl"></canvas> | ||||||
|  | 	</div> | ||||||
|  | </div> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <script lang="ts" setup> | ||||||
|  | import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue'; | ||||||
|  | import { | ||||||
|  | 	Chart, | ||||||
|  | 	ArcElement, | ||||||
|  | 	LineElement, | ||||||
|  | 	BarElement, | ||||||
|  | 	PointElement, | ||||||
|  | 	BarController, | ||||||
|  | 	LineController, | ||||||
|  | 	CategoryScale, | ||||||
|  | 	LinearScale, | ||||||
|  | 	TimeScale, | ||||||
|  | 	Legend, | ||||||
|  | 	Title, | ||||||
|  | 	Tooltip, | ||||||
|  | 	SubTitle, | ||||||
|  | 	Filler, | ||||||
|  | } from 'chart.js'; | ||||||
|  | import { enUS } from 'date-fns/locale'; | ||||||
|  | import tinycolor from 'tinycolor2'; | ||||||
|  | import * as os from '@/os'; | ||||||
|  | import 'chartjs-adapter-date-fns'; | ||||||
|  | import { defaultStore } from '@/store'; | ||||||
|  | import { useChartTooltip } from '@/scripts/use-chart-tooltip'; | ||||||
|  | import { MatrixController, MatrixElement } from 'chartjs-chart-matrix'; | ||||||
|  | import { chartVLine } from '@/scripts/chart-vline'; | ||||||
|  | 
 | ||||||
|  | Chart.register( | ||||||
|  | 	ArcElement, | ||||||
|  | 	LineElement, | ||||||
|  | 	BarElement, | ||||||
|  | 	PointElement, | ||||||
|  | 	BarController, | ||||||
|  | 	LineController, | ||||||
|  | 	CategoryScale, | ||||||
|  | 	LinearScale, | ||||||
|  | 	TimeScale, | ||||||
|  | 	Legend, | ||||||
|  | 	Title, | ||||||
|  | 	Tooltip, | ||||||
|  | 	SubTitle, | ||||||
|  | 	Filler, | ||||||
|  | 	MatrixController, MatrixElement, | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | const alpha = (hex, a) => { | ||||||
|  | 	const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; | ||||||
|  | 	const r = parseInt(result[1], 16); | ||||||
|  | 	const g = parseInt(result[2], 16); | ||||||
|  | 	const b = parseInt(result[3], 16); | ||||||
|  | 	return `rgba(${r}, ${g}, ${b}, ${a})`; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const rootEl = $ref<HTMLDivElement>(null); | ||||||
|  | const chartEl = $ref<HTMLCanvasElement>(null); | ||||||
|  | const now = new Date(); | ||||||
|  | let chartInstance: Chart = null; | ||||||
|  | let fetching = $ref(true); | ||||||
|  | 
 | ||||||
|  | const { handler: externalTooltipHandler } = useChartTooltip({ | ||||||
|  | 	position: 'middle', | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | async function renderChart() { | ||||||
|  | 	if (chartInstance) { | ||||||
|  | 		chartInstance.destroy(); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	const wide = rootEl.offsetWidth > 700; | ||||||
|  | 	const narrow = rootEl.offsetWidth < 400; | ||||||
|  | 
 | ||||||
|  | 	const weeks = wide ? 50 : narrow ? 10 : 25; | ||||||
|  | 	const chartLimit = 7 * weeks; | ||||||
|  | 
 | ||||||
|  | 	const getDate = (ago: number) => { | ||||||
|  | 		const y = now.getFullYear(); | ||||||
|  | 		const m = now.getMonth(); | ||||||
|  | 		const d = now.getDate(); | ||||||
|  | 
 | ||||||
|  | 		return new Date(y, m, d - ago); | ||||||
|  | 	}; | ||||||
|  | 
 | ||||||
|  | 	const format = (arr) => { | ||||||
|  | 		return arr.map((v, i) => { | ||||||
|  | 			const dt = getDate(i); | ||||||
|  | 			const iso = `${dt.getFullYear()}-${(dt.getMonth() + 1).toString().padStart(2, '0')}-${dt.getDate().toString().padStart(2, '0')}`; | ||||||
|  | 			return { | ||||||
|  | 				x: iso, | ||||||
|  | 				y: dt.getDay(), | ||||||
|  | 				d: iso, | ||||||
|  | 				v, | ||||||
|  | 			}; | ||||||
|  | 		}); | ||||||
|  | 	}; | ||||||
|  | 
 | ||||||
|  | 	const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' }); | ||||||
|  | 
 | ||||||
|  | 	fetching = false; | ||||||
|  | 
 | ||||||
|  | 	await nextTick(); | ||||||
|  | 
 | ||||||
|  | 	const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; | ||||||
|  | 
 | ||||||
|  | 	// フォントカラー | ||||||
|  | 	Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); | ||||||
|  | 
 | ||||||
|  | 	const color = '#3498db'; | ||||||
|  | 
 | ||||||
|  | 	const max = Math.max(...raw.readWrite); | ||||||
|  | 
 | ||||||
|  | 	const marginEachCell = 4; | ||||||
|  | 
 | ||||||
|  | 	chartInstance = new Chart(chartEl, { | ||||||
|  | 		type: 'matrix', | ||||||
|  | 		data: { | ||||||
|  | 			datasets: [{ | ||||||
|  | 				label: 'Read & Write', | ||||||
|  | 				data: format(raw.readWrite), | ||||||
|  | 				pointRadius: 0, | ||||||
|  | 				borderWidth: 0, | ||||||
|  | 				borderJoinStyle: 'round', | ||||||
|  | 				borderRadius: 3, | ||||||
|  | 				backgroundColor(c) { | ||||||
|  | 					const value = c.dataset.data[c.dataIndex].v; | ||||||
|  | 					const a = value / max; | ||||||
|  | 					return alpha(color, a); | ||||||
|  | 				}, | ||||||
|  | 				fill: true, | ||||||
|  | 				width(c) { | ||||||
|  | 					const a = c.chart.chartArea ?? {}; | ||||||
|  | 					// 20週間 | ||||||
|  | 					return (a.right - a.left) / weeks - marginEachCell; | ||||||
|  | 				}, | ||||||
|  | 				height(c) { | ||||||
|  | 					const a = c.chart.chartArea ?? {}; | ||||||
|  | 					// 7日 | ||||||
|  | 					return (a.bottom - a.top) / 7 - marginEachCell; | ||||||
|  | 				}, | ||||||
|  | 			}], | ||||||
|  | 		}, | ||||||
|  | 		options: { | ||||||
|  | 			aspectRatio: wide ? 6 : narrow ? 1.8 : 3.2, | ||||||
|  | 			layout: { | ||||||
|  | 				padding: { | ||||||
|  | 					left: 8, | ||||||
|  | 					right: 0, | ||||||
|  | 					top: 0, | ||||||
|  | 					bottom: 0, | ||||||
|  | 				}, | ||||||
|  | 			}, | ||||||
|  | 			scales: { | ||||||
|  | 				x: { | ||||||
|  | 					type: 'time', | ||||||
|  | 					offset: true, | ||||||
|  | 					position: 'bottom', | ||||||
|  | 					time: { | ||||||
|  | 						unit: 'week', | ||||||
|  | 						round: 'week', | ||||||
|  | 						isoWeekday: 0, | ||||||
|  | 						displayFormats: { | ||||||
|  | 							week: 'MMM dd', | ||||||
|  | 						}, | ||||||
|  | 					}, | ||||||
|  | 					grid: { | ||||||
|  | 						display: false, | ||||||
|  | 						color: gridColor, | ||||||
|  | 						borderColor: 'rgb(0, 0, 0, 0)', | ||||||
|  | 					}, | ||||||
|  | 					ticks: { | ||||||
|  | 						display: true, | ||||||
|  | 						maxRotation: 0, | ||||||
|  | 						autoSkipPadding: 8, | ||||||
|  | 					}, | ||||||
|  | 				}, | ||||||
|  | 				y: { | ||||||
|  | 					offset: true, | ||||||
|  | 					reverse: true, | ||||||
|  | 					position: 'right', | ||||||
|  | 					grid: { | ||||||
|  | 						display: false, | ||||||
|  | 						color: gridColor, | ||||||
|  | 						borderColor: 'rgb(0, 0, 0, 0)', | ||||||
|  | 					}, | ||||||
|  | 					ticks: { | ||||||
|  | 						maxRotation: 0, | ||||||
|  | 						autoSkip: true, | ||||||
|  | 						padding: 1, | ||||||
|  | 						font: { | ||||||
|  | 							size: 9, | ||||||
|  | 						}, | ||||||
|  | 						callback: (value, index, values) => ['', 'Mon', '', 'Wed', '', 'Fri', ''][value], | ||||||
|  | 					}, | ||||||
|  | 				}, | ||||||
|  | 			}, | ||||||
|  | 			animation: false, | ||||||
|  | 			plugins: { | ||||||
|  | 				legend: { | ||||||
|  | 					display: false, | ||||||
|  | 				}, | ||||||
|  | 				tooltip: { | ||||||
|  | 					enabled: false, | ||||||
|  | 					callbacks: { | ||||||
|  | 						title(context) { | ||||||
|  | 							const v = context[0].dataset.data[context[0].dataIndex]; | ||||||
|  | 							return v.d; | ||||||
|  | 						}, | ||||||
|  | 						label(context) { | ||||||
|  | 							const v = context.dataset.data[context.dataIndex]; | ||||||
|  | 							return ['Active: ' + v.v]; | ||||||
|  | 						}, | ||||||
|  | 					}, | ||||||
|  | 					//mode: 'index', | ||||||
|  | 					animation: { | ||||||
|  | 						duration: 0, | ||||||
|  | 					}, | ||||||
|  | 					external: externalTooltipHandler, | ||||||
|  | 				}, | ||||||
|  | 			}, | ||||||
|  | 		}, | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | onMounted(async () => { | ||||||
|  | 	renderChart(); | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  | @ -34,6 +34,9 @@ | ||||||
| 			</div> | 			</div> | ||||||
| 		</div> | 		</div> | ||||||
| 	</div> | 	</div> | ||||||
|  | 	<div class="heatmap _panel"> | ||||||
|  | 		<MkActiveUsersHeatmap/> | ||||||
|  | 	</div> | ||||||
| 	<div class="subpub"> | 	<div class="subpub"> | ||||||
| 		<div class="sub"> | 		<div class="sub"> | ||||||
| 			<div class="title">Sub</div> | 			<div class="title">Sub</div> | ||||||
|  | @ -72,6 +75,7 @@ import MkChart from '@/components/MkChart.vue'; | ||||||
| import { useChartTooltip } from '@/scripts/use-chart-tooltip'; | import { useChartTooltip } from '@/scripts/use-chart-tooltip'; | ||||||
| import * as os from '@/os'; | import * as os from '@/os'; | ||||||
| import { i18n } from '@/i18n'; | import { i18n } from '@/i18n'; | ||||||
|  | import MkActiveUsersHeatmap from '@/components/MkActiveUsersHeatmap.vue'; | ||||||
| 
 | 
 | ||||||
| Chart.register( | Chart.register( | ||||||
| 	ArcElement, | 	ArcElement, | ||||||
|  | @ -196,6 +200,11 @@ onMounted(() => { | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	> .heatmap { | ||||||
|  | 		padding: 16px; | ||||||
|  | 		margin-bottom: 16px; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	> .subpub { | 	> .subpub { | ||||||
| 		display: flex; | 		display: flex; | ||||||
| 		gap: 16px; | 		gap: 16px; | ||||||
|  |  | ||||||
|  | @ -1,231 +1,11 @@ | ||||||
| <template> | <template> | ||||||
| <div> | <div class="_panel" :class="$style.root"> | ||||||
| 	<MkLoading v-if="fetching"/> | 	<MkActiveUsersHeatmap/> | ||||||
| 	<div v-show="!fetching" :class="$style.root" class="_panel"> |  | ||||||
| 		<canvas ref="chartEl"></canvas> |  | ||||||
| 	</div> |  | ||||||
| </div> | </div> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script lang="ts" setup> | <script lang="ts" setup> | ||||||
| import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue'; | import MkActiveUsersHeatmap from '@/components/MkActiveUsersHeatmap.vue'; | ||||||
| import { |  | ||||||
| 	Chart, |  | ||||||
| 	ArcElement, |  | ||||||
| 	LineElement, |  | ||||||
| 	BarElement, |  | ||||||
| 	PointElement, |  | ||||||
| 	BarController, |  | ||||||
| 	LineController, |  | ||||||
| 	CategoryScale, |  | ||||||
| 	LinearScale, |  | ||||||
| 	TimeScale, |  | ||||||
| 	Legend, |  | ||||||
| 	Title, |  | ||||||
| 	Tooltip, |  | ||||||
| 	SubTitle, |  | ||||||
| 	Filler, |  | ||||||
| } from 'chart.js'; |  | ||||||
| import { enUS } from 'date-fns/locale'; |  | ||||||
| import tinycolor from 'tinycolor2'; |  | ||||||
| import * as os from '@/os'; |  | ||||||
| import 'chartjs-adapter-date-fns'; |  | ||||||
| import { defaultStore } from '@/store'; |  | ||||||
| import { useChartTooltip } from '@/scripts/use-chart-tooltip'; |  | ||||||
| import { MatrixController, MatrixElement } from 'chartjs-chart-matrix'; |  | ||||||
| import { chartVLine } from '@/scripts/chart-vline'; |  | ||||||
| 
 |  | ||||||
| Chart.register( |  | ||||||
| 	ArcElement, |  | ||||||
| 	LineElement, |  | ||||||
| 	BarElement, |  | ||||||
| 	PointElement, |  | ||||||
| 	BarController, |  | ||||||
| 	LineController, |  | ||||||
| 	CategoryScale, |  | ||||||
| 	LinearScale, |  | ||||||
| 	TimeScale, |  | ||||||
| 	Legend, |  | ||||||
| 	Title, |  | ||||||
| 	Tooltip, |  | ||||||
| 	SubTitle, |  | ||||||
| 	Filler, |  | ||||||
| 	MatrixController, MatrixElement, |  | ||||||
| ); |  | ||||||
| 
 |  | ||||||
| const alpha = (hex, a) => { |  | ||||||
| 	const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; |  | ||||||
| 	const r = parseInt(result[1], 16); |  | ||||||
| 	const g = parseInt(result[2], 16); |  | ||||||
| 	const b = parseInt(result[3], 16); |  | ||||||
| 	return `rgba(${r}, ${g}, ${b}, ${a})`; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const chartEl = $ref<HTMLCanvasElement>(null); |  | ||||||
| const now = new Date(); |  | ||||||
| let chartInstance: Chart = null; |  | ||||||
| const weeks = 25; |  | ||||||
| const chartLimit = 7 * weeks; |  | ||||||
| let fetching = $ref(true); |  | ||||||
| 
 |  | ||||||
| const { handler: externalTooltipHandler } = useChartTooltip({ |  | ||||||
| 	position: 'middle', |  | ||||||
| }); |  | ||||||
| 
 |  | ||||||
| async function renderChart() { |  | ||||||
| 	if (chartInstance) { |  | ||||||
| 		chartInstance.destroy(); |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	const getDate = (ago: number) => { |  | ||||||
| 		const y = now.getFullYear(); |  | ||||||
| 		const m = now.getMonth(); |  | ||||||
| 		const d = now.getDate(); |  | ||||||
| 
 |  | ||||||
| 		return new Date(y, m, d - ago); |  | ||||||
| 	}; |  | ||||||
| 
 |  | ||||||
| 	const format = (arr) => { |  | ||||||
| 		return arr.map((v, i) => { |  | ||||||
| 			const dt = getDate(i); |  | ||||||
| 			const iso = `${dt.getFullYear()}-${(dt.getMonth() + 1).toString().padStart(2, '0')}-${dt.getDate().toString().padStart(2, '0')}`; |  | ||||||
| 			return { |  | ||||||
| 				x: iso, |  | ||||||
| 				y: dt.getDay(), |  | ||||||
| 				d: iso, |  | ||||||
| 				v, |  | ||||||
| 			}; |  | ||||||
| 		}); |  | ||||||
| 	}; |  | ||||||
| 
 |  | ||||||
| 	const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' }); |  | ||||||
| 
 |  | ||||||
| 	const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; |  | ||||||
| 
 |  | ||||||
| 	// フォントカラー |  | ||||||
| 	Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); |  | ||||||
| 
 |  | ||||||
| 	const color = '#3498db'; |  | ||||||
| 
 |  | ||||||
| 	const max = Math.max(...raw.readWrite); |  | ||||||
| 
 |  | ||||||
| 	const marginEachCell = 4; |  | ||||||
| 
 |  | ||||||
| 	chartInstance = new Chart(chartEl, { |  | ||||||
| 		type: 'matrix', |  | ||||||
| 		data: { |  | ||||||
| 			datasets: [{ |  | ||||||
| 				label: 'Read & Write', |  | ||||||
| 				data: format(raw.readWrite), |  | ||||||
| 				pointRadius: 0, |  | ||||||
| 				borderWidth: 0, |  | ||||||
| 				borderJoinStyle: 'round', |  | ||||||
| 				borderRadius: 3, |  | ||||||
| 				backgroundColor(c) { |  | ||||||
| 					const value = c.dataset.data[c.dataIndex].v; |  | ||||||
| 					const a = value / max; |  | ||||||
| 					return alpha(color, a); |  | ||||||
| 				}, |  | ||||||
| 				fill: true, |  | ||||||
| 				width(c) { |  | ||||||
| 					const a = c.chart.chartArea ?? {}; |  | ||||||
| 					// 20週間 |  | ||||||
| 					return (a.right - a.left) / weeks - marginEachCell; |  | ||||||
| 				}, |  | ||||||
| 				height(c) { |  | ||||||
| 					const a = c.chart.chartArea ?? {}; |  | ||||||
| 					// 7日 |  | ||||||
| 					return (a.bottom - a.top) / 7 - marginEachCell; |  | ||||||
| 				}, |  | ||||||
| 			}], |  | ||||||
| 		}, |  | ||||||
| 		options: { |  | ||||||
| 			aspectRatio: 3.2, |  | ||||||
| 			layout: { |  | ||||||
| 				padding: { |  | ||||||
| 					left: 8, |  | ||||||
| 					right: 0, |  | ||||||
| 					top: 0, |  | ||||||
| 					bottom: 0, |  | ||||||
| 				}, |  | ||||||
| 			}, |  | ||||||
| 			scales: { |  | ||||||
| 				x: { |  | ||||||
| 					type: 'time', |  | ||||||
| 					offset: true, |  | ||||||
| 					position: 'bottom', |  | ||||||
| 					time: { |  | ||||||
| 						unit: 'week', |  | ||||||
| 						round: 'week', |  | ||||||
| 						isoWeekday: 0, |  | ||||||
| 						displayFormats: { |  | ||||||
| 							week: 'MMM dd', |  | ||||||
| 						}, |  | ||||||
| 					}, |  | ||||||
| 					grid: { |  | ||||||
| 						display: false, |  | ||||||
| 						color: gridColor, |  | ||||||
| 						borderColor: 'rgb(0, 0, 0, 0)', |  | ||||||
| 					}, |  | ||||||
| 					ticks: { |  | ||||||
| 						display: true, |  | ||||||
| 						maxRotation: 0, |  | ||||||
| 						autoSkipPadding: 8, |  | ||||||
| 					}, |  | ||||||
| 				}, |  | ||||||
| 				y: { |  | ||||||
| 					offset: true, |  | ||||||
| 					reverse: true, |  | ||||||
| 					position: 'right', |  | ||||||
| 					grid: { |  | ||||||
| 						display: false, |  | ||||||
| 						color: gridColor, |  | ||||||
| 						borderColor: 'rgb(0, 0, 0, 0)', |  | ||||||
| 					}, |  | ||||||
| 					ticks: { |  | ||||||
| 						maxRotation: 0, |  | ||||||
| 						autoSkip: true, |  | ||||||
| 						padding: 1, |  | ||||||
| 						font: { |  | ||||||
| 							size: 9, |  | ||||||
| 						}, |  | ||||||
| 						callback: (value, index, values) => ['', 'Mon', '', 'Wed', '', 'Fri', ''][value], |  | ||||||
| 					}, |  | ||||||
| 				}, |  | ||||||
| 			}, |  | ||||||
| 			animation: false, |  | ||||||
| 			plugins: { |  | ||||||
| 				legend: { |  | ||||||
| 					display: false, |  | ||||||
| 				}, |  | ||||||
| 				tooltip: { |  | ||||||
| 					enabled: false, |  | ||||||
| 					callbacks: { |  | ||||||
| 						title(context) { |  | ||||||
| 							const v = context[0].dataset.data[context[0].dataIndex]; |  | ||||||
| 							return v.d; |  | ||||||
| 						}, |  | ||||||
| 						label(context) { |  | ||||||
| 							const v = context.dataset.data[context.dataIndex]; |  | ||||||
| 							return ['Active: ' + v.v]; |  | ||||||
| 						}, |  | ||||||
| 					}, |  | ||||||
| 					//mode: 'index', |  | ||||||
| 					animation: { |  | ||||||
| 						duration: 0, |  | ||||||
| 					}, |  | ||||||
| 					external: externalTooltipHandler, |  | ||||||
| 				}, |  | ||||||
| 			}, |  | ||||||
| 		}, |  | ||||||
| 	}); |  | ||||||
| 
 |  | ||||||
| 	fetching = false; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| onMounted(async () => { |  | ||||||
| 	renderChart(); |  | ||||||
| }); |  | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <style lang="scss" module> | <style lang="scss" module> | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue