retention chart
This commit is contained in:
		
							parent
							
								
									2547c8c117
								
							
						
					
					
						commit
						8b73f215eb
					
				
					 5 changed files with 236 additions and 47 deletions
				
			
		|  | @ -132,12 +132,10 @@ async function renderChart() { | ||||||
| 				fill: true, | 				fill: true, | ||||||
| 				width(c) { | 				width(c) { | ||||||
| 					const a = c.chart.chartArea ?? {}; | 					const a = c.chart.chartArea ?? {}; | ||||||
| 					// 20週間 |  | ||||||
| 					return (a.right - a.left) / weeks - marginEachCell; | 					return (a.right - a.left) / weeks - marginEachCell; | ||||||
| 				}, | 				}, | ||||||
| 				height(c) { | 				height(c) { | ||||||
| 					const a = c.chart.chartArea ?? {}; | 					const a = c.chart.chartArea ?? {}; | ||||||
| 					// 7日 |  | ||||||
| 					return (a.bottom - a.top) / 7 - marginEachCell; | 					return (a.bottom - a.top) / 7 - marginEachCell; | ||||||
| 				}, | 				}, | ||||||
| 			}], | 			}], | ||||||
|  |  | ||||||
|  | @ -39,6 +39,7 @@ import * as os from '@/os'; | ||||||
| import { defaultStore } from '@/store'; | import { defaultStore } from '@/store'; | ||||||
| import { useChartTooltip } from '@/scripts/use-chart-tooltip'; | import { useChartTooltip } from '@/scripts/use-chart-tooltip'; | ||||||
| import { chartVLine } from '@/scripts/chart-vline'; | import { chartVLine } from '@/scripts/chart-vline'; | ||||||
|  | import { alpha } from '@/scripts/color'; | ||||||
| 
 | 
 | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
| 	src: { | 	src: { | ||||||
|  | @ -101,13 +102,6 @@ Chart.register( | ||||||
| 
 | 
 | ||||||
| const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); | const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); | ||||||
| const negate = arr => arr.map(x => -x); | const negate = arr => arr.map(x => -x); | ||||||
| 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 colors = { | const colors = { | ||||||
| 	blue: '#008FFB', | 	blue: '#008FFB', | ||||||
|  |  | ||||||
|  | @ -43,6 +43,13 @@ | ||||||
| 		</div> | 		</div> | ||||||
| 	</MkFolder> | 	</MkFolder> | ||||||
| 
 | 
 | ||||||
|  | 	<MkFolder class="item"> | ||||||
|  | 		<template #header>Retention rate</template> | ||||||
|  | 		<div class="_panel" :class="$style.retention"> | ||||||
|  | 			<MkRetentionHeatmap/> | ||||||
|  | 		</div> | ||||||
|  | 	</MkFolder> | ||||||
|  | 
 | ||||||
| 	<MkFolder class="item"> | 	<MkFolder class="item"> | ||||||
| 		<template #header>Federation</template> | 		<template #header>Federation</template> | ||||||
| 		<div :class="$style.federation"> | 		<div :class="$style.federation"> | ||||||
|  | @ -88,6 +95,7 @@ import * as os from '@/os'; | ||||||
| import { i18n } from '@/i18n'; | import { i18n } from '@/i18n'; | ||||||
| import MkActiveUsersHeatmap from '@/components/MkActiveUsersHeatmap.vue'; | import MkActiveUsersHeatmap from '@/components/MkActiveUsersHeatmap.vue'; | ||||||
| import MkFolder from '@/components/MkFolder.vue'; | import MkFolder from '@/components/MkFolder.vue'; | ||||||
|  | import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue'; | ||||||
| 
 | 
 | ||||||
| Chart.register( | Chart.register( | ||||||
| 	ArcElement, | 	ArcElement, | ||||||
|  | @ -224,6 +232,11 @@ onMounted(() => { | ||||||
| 	margin-bottom: 16px; | 	margin-bottom: 16px; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .retention { | ||||||
|  | 	padding: 16px; | ||||||
|  | 	margin-bottom: 16px; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| .federation { | .federation { | ||||||
| 	&:global { | 	&:global { | ||||||
| 		> .pies { | 		> .pies { | ||||||
|  |  | ||||||
							
								
								
									
										218
									
								
								packages/frontend/src/components/MkRetentionHeatmap.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										218
									
								
								packages/frontend/src/components/MkRetentionHeatmap.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,218 @@ | ||||||
|  | <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'; | ||||||
|  | import { alpha } from '@/scripts/color'; | ||||||
|  | 
 | ||||||
|  | Chart.register( | ||||||
|  | 	ArcElement, | ||||||
|  | 	LineElement, | ||||||
|  | 	BarElement, | ||||||
|  | 	PointElement, | ||||||
|  | 	BarController, | ||||||
|  | 	LineController, | ||||||
|  | 	CategoryScale, | ||||||
|  | 	LinearScale, | ||||||
|  | 	TimeScale, | ||||||
|  | 	Legend, | ||||||
|  | 	Title, | ||||||
|  | 	Tooltip, | ||||||
|  | 	SubTitle, | ||||||
|  | 	Filler, | ||||||
|  | 	MatrixController, MatrixElement, | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | 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 > 600; | ||||||
|  | 	const narrow = rootEl.offsetWidth < 400; | ||||||
|  | 
 | ||||||
|  | 	const maxDays = wide ? 20 : narrow ? 10 : 15; | ||||||
|  | 
 | ||||||
|  | 	const raw = await os.api('retention', { }); | ||||||
|  | 
 | ||||||
|  | 	const data = []; | ||||||
|  | 	for (const record of raw) { | ||||||
|  | 		let i = 0; | ||||||
|  | 		for (const date of Object.keys(record.data).sort((a, b) => new Date(a).getTime() - new Date(b).getTime())) { | ||||||
|  | 			data.push({ | ||||||
|  | 				x: i, | ||||||
|  | 				y: record.createdAt, | ||||||
|  | 				v: record.data[date], | ||||||
|  | 			}); | ||||||
|  | 			i++; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	console.log(data); | ||||||
|  | 
 | ||||||
|  | 	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 = defaultStore.state.darkMode ? '#b4e900' : '#86b300'; | ||||||
|  | 
 | ||||||
|  | 	// 視覚上の分かりやすさのため上から最も大きい3つの値の平均を最大値とする | ||||||
|  | 	//const max = raw.readWrite.slice().sort((a, b) => b - a).slice(0, 3).reduce((a, b) => a + b, 0) / 3; | ||||||
|  | 	const max = 4; | ||||||
|  | 
 | ||||||
|  | 	const marginEachCell = 6; | ||||||
|  | 
 | ||||||
|  | 	chartInstance = new Chart(chartEl, { | ||||||
|  | 		type: 'matrix', | ||||||
|  | 		data: { | ||||||
|  | 			datasets: [{ | ||||||
|  | 				label: 'Active', | ||||||
|  | 				data: data, | ||||||
|  | 				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 ?? {}; | ||||||
|  | 					return (a.right - a.left) / maxDays - marginEachCell; | ||||||
|  | 				}, | ||||||
|  | 				height(c) { | ||||||
|  | 					const a = c.chart.chartArea ?? {}; | ||||||
|  | 					return (a.bottom - a.top) / maxDays - (marginEachCell / 1.5); | ||||||
|  | 				}, | ||||||
|  | 			}], | ||||||
|  | 		}, | ||||||
|  | 		options: { | ||||||
|  | 			aspectRatio: wide ? 2 : narrow ? 2 : 2, | ||||||
|  | 			layout: { | ||||||
|  | 				padding: { | ||||||
|  | 					left: 8, | ||||||
|  | 					right: 0, | ||||||
|  | 					top: 0, | ||||||
|  | 					bottom: 0, | ||||||
|  | 				}, | ||||||
|  | 			}, | ||||||
|  | 			scales: { | ||||||
|  | 				x: { | ||||||
|  | 					position: 'top', | ||||||
|  | 					suggestedMax: maxDays, | ||||||
|  | 					grid: { | ||||||
|  | 						display: false, | ||||||
|  | 						color: gridColor, | ||||||
|  | 						borderColor: 'rgb(0, 0, 0, 0)', | ||||||
|  | 					}, | ||||||
|  | 					ticks: { | ||||||
|  | 						display: true, | ||||||
|  | 						maxRotation: 0, | ||||||
|  | 						autoSkipPadding: 8, | ||||||
|  | 					}, | ||||||
|  | 				}, | ||||||
|  | 				y: { | ||||||
|  | 					type: 'time', | ||||||
|  | 					min: new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate() - maxDays), | ||||||
|  | 					offset: true, | ||||||
|  | 					reverse: true, | ||||||
|  | 					position: 'left', | ||||||
|  | 					time: { | ||||||
|  | 						unit: 'day', | ||||||
|  | 						round: 'day', | ||||||
|  | 					}, | ||||||
|  | 					grid: { | ||||||
|  | 						display: false, | ||||||
|  | 						color: gridColor, | ||||||
|  | 						borderColor: 'rgb(0, 0, 0, 0)', | ||||||
|  | 					}, | ||||||
|  | 					ticks: { | ||||||
|  | 						maxRotation: 0, | ||||||
|  | 						autoSkip: true, | ||||||
|  | 						padding: 1, | ||||||
|  | 						font: { | ||||||
|  | 							size: 9, | ||||||
|  | 						}, | ||||||
|  | 					}, | ||||||
|  | 				}, | ||||||
|  | 			}, | ||||||
|  | 			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> | ||||||
|  | @ -1,49 +1,15 @@ | ||||||
| <template> | <template> | ||||||
| <div> | <div class="_panel" :class="$style.root"> | ||||||
| 	<MkLoading v-if="fetching"/> | 	<MkRetentionHeatmap/> | ||||||
| 	<div v-else :class="$style.root"> |  | ||||||
| 		<div v-for="row in retention" class="row"> |  | ||||||
| 			<div v-for="value in getValues(row)" v-tooltip="value.percentage" class="cell"> |  | ||||||
| 			</div> |  | ||||||
| 		</div> |  | ||||||
| 	</div> |  | ||||||
| </div> | </div> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script lang="ts" setup> | <script lang="ts" setup> | ||||||
| import { onMounted, onUnmounted, ref } from 'vue'; | import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue'; | ||||||
| import * as os from '@/os'; |  | ||||||
| import number from '@/filters/number'; |  | ||||||
| import { i18n } from '@/i18n'; |  | ||||||
| 
 |  | ||||||
| let retention: any = $ref(null); |  | ||||||
| let fetching = $ref(true); |  | ||||||
| 
 |  | ||||||
| function getValues(row) { |  | ||||||
| 	const data = []; |  | ||||||
| 	for (const key in row.data) { |  | ||||||
| 		data.push({ |  | ||||||
| 			date: new Date(key), |  | ||||||
| 			value: number(row.data[key]), |  | ||||||
| 			percentage: `${Math.ceil(row.data[key] / row.users) * 100}%`, |  | ||||||
| 		}); |  | ||||||
| 	} |  | ||||||
| 	data.sort((a, b) => a.date > b.date); |  | ||||||
| 	return data; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| onMounted(async () => { |  | ||||||
| 	retention = await os.apiGet('retention', {}); |  | ||||||
| 
 |  | ||||||
| 	fetching = false; |  | ||||||
| }); |  | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <style lang="scss" module> | <style lang="scss" module> | ||||||
| .root { | .root { | ||||||
| 
 | 	padding: 20px; | ||||||
| 	&:global { |  | ||||||
| 
 |  | ||||||
| 	} |  | ||||||
| } | } | ||||||
| </style> | </style> | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue