From 2353b5f55334aa83e2051c334606ac518c579c05 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Tue, 27 Dec 2022 12:30:34 +0900
Subject: [PATCH] enhance(client): make heatmap available on about page

---
 .../src/components/MkActiveUsersHeatmap.vue   | 236 ++++++++++++++++++
 .../client/src/components/MkInstanceStats.vue |   9 +
 .../src/pages/admin/overview.heatmap.vue      | 226 +----------------
 3 files changed, 248 insertions(+), 223 deletions(-)
 create mode 100644 packages/client/src/components/MkActiveUsersHeatmap.vue

diff --git a/packages/client/src/components/MkActiveUsersHeatmap.vue b/packages/client/src/components/MkActiveUsersHeatmap.vue
new file mode 100644
index 0000000000..02b2eeeb36
--- /dev/null
+++ b/packages/client/src/components/MkActiveUsersHeatmap.vue
@@ -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>
diff --git a/packages/client/src/components/MkInstanceStats.vue b/packages/client/src/components/MkInstanceStats.vue
index 5f5f64b0c2..7199c33c35 100644
--- a/packages/client/src/components/MkInstanceStats.vue
+++ b/packages/client/src/components/MkInstanceStats.vue
@@ -34,6 +34,9 @@
 			</div>
 		</div>
 	</div>
+	<div class="heatmap _panel">
+		<MkActiveUsersHeatmap/>
+	</div>
 	<div class="subpub">
 		<div class="sub">
 			<div class="title">Sub</div>
@@ -72,6 +75,7 @@ import MkChart from '@/components/MkChart.vue';
 import { useChartTooltip } from '@/scripts/use-chart-tooltip';
 import * as os from '@/os';
 import { i18n } from '@/i18n';
+import MkActiveUsersHeatmap from '@/components/MkActiveUsersHeatmap.vue';
 
 Chart.register(
 	ArcElement,
@@ -196,6 +200,11 @@ onMounted(() => {
 		}
 	}
 
+	> .heatmap {
+		padding: 16px;
+		margin-bottom: 16px;
+	}
+
 	> .subpub {
 		display: flex;
 		gap: 16px;
diff --git a/packages/client/src/pages/admin/overview.heatmap.vue b/packages/client/src/pages/admin/overview.heatmap.vue
index 0bf1fa0a42..16d1c83b9f 100644
--- a/packages/client/src/pages/admin/overview.heatmap.vue
+++ b/packages/client/src/pages/admin/overview.heatmap.vue
@@ -1,231 +1,11 @@
 <template>
-<div>
-	<MkLoading v-if="fetching"/>
-	<div v-show="!fetching" :class="$style.root" class="_panel">
-		<canvas ref="chartEl"></canvas>
-	</div>
+<div class="_panel" :class="$style.root">
+	<MkActiveUsersHeatmap/>
 </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 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();
-});
+import MkActiveUsersHeatmap from '@/components/MkActiveUsersHeatmap.vue';
 </script>
 
 <style lang="scss" module>