diff --git a/packages/client/package.json b/packages/client/package.json index bebad2bf9c..0af8ffac0b 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -22,6 +22,7 @@ "browser-image-resizer": "git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.3", "chart.js": "4.1.1", "chartjs-adapter-date-fns": "3.0.0", + "chartjs-chart-matrix": "^1.3.0", "chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-zoom": "2.0.0", "compare-versions": "5.0.1", diff --git a/packages/client/src/pages/admin/overview.heatmap.vue b/packages/client/src/pages/admin/overview.heatmap.vue new file mode 100644 index 0000000000..ce5b684ae8 --- /dev/null +++ b/packages/client/src/pages/admin/overview.heatmap.vue @@ -0,0 +1,233 @@ +<template> +<div> + <MkLoading v-if="fetching"/> + <div v-show="!fetching" :class="$style.root" class="_panel"> + <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 chartEl = $ref<HTMLCanvasElement>(null); +const now = new Date(); +let chartInstance: Chart = null; +const chartLimit = 7 * 20; +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: 4, + 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) / 20 - marginEachCell; + }, + height(c) { + const a = c.chart.chartArea ?? {}; + // 7日 + return (a.bottom - a.top) / 7 - marginEachCell; + }, + }], + }, + options: { + aspectRatio: 2.8, + 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() { + return ''; + }, + label(context) { + const v = context.dataset.data[context.dataIndex]; + return ['d: ' + v.d, 'v: ' + v.v.toFixed(2)]; + }, + }, + //mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + }, + }, + }); + + fetching = false; +} + +onMounted(async () => { + renderChart(); +}); +</script> + +<style lang="scss" module> +.root { + padding: 20px; +} +</style> diff --git a/packages/client/src/pages/admin/overview.vue b/packages/client/src/pages/admin/overview.vue index 67f612e6b5..cc5b9b7b68 100644 --- a/packages/client/src/pages/admin/overview.vue +++ b/packages/client/src/pages/admin/overview.vue @@ -5,34 +5,47 @@ <template #header>Stats</template> <XStats/> </MkFolder> + <MkFolder class="item"> <template #header>Active users</template> <XActiveUsers/> </MkFolder> + + <MkFolder class="item"> + <template #header>Heatmap</template> + <XHeatmap/> + </MkFolder> + <MkFolder class="item"> <template #header>Moderators</template> <XModerators/> </MkFolder> + <MkFolder class="item"> <template #header>Federation</template> <XFederation/> </MkFolder> + <MkFolder class="item"> <template #header>Instances</template> <XInstances/> </MkFolder> + <MkFolder class="item"> <template #header>Ap requests</template> <XApRequests/> </MkFolder> + <MkFolder class="item"> <template #header>New users</template> <XUsers/> </MkFolder> + <MkFolder class="item"> <template #header>Deliver queue</template> <XQueue domain="deliver"/> </MkFolder> + <MkFolder class="item"> <template #header>Inbox queue</template> <XQueue domain="inbox"/> @@ -51,6 +64,7 @@ import XUsers from './overview.users.vue'; import XActiveUsers from './overview.active-users.vue'; import XStats from './overview.stats.vue'; import XModerators from './overview.moderators.vue'; +import XHeatmap from './overview.heatmap.vue'; import MkTagCloud from '@/components/MkTagCloud.vue'; import { version, url } from '@/config'; import * as os from '@/os'; diff --git a/yarn.lock b/yarn.lock index 25cd2980e7..3fdc9145fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4956,6 +4956,15 @@ __metadata: languageName: node linkType: hard +"chartjs-chart-matrix@npm:^1.3.0": + version: 1.3.0 + resolution: "chartjs-chart-matrix@npm:1.3.0" + peerDependencies: + chart.js: ">=3.0.0" + checksum: d29a08f3ffd888a1b6c45be2cbeb8987c145a74b07a713c84001860669b200931517746c475537dd0893c57a739115fa96a68d3a113013aff28f3bee4494d5cc + languageName: node + linkType: hard + "chartjs-plugin-gradient@npm:0.6.1": version: 0.6.1 resolution: "chartjs-plugin-gradient@npm:0.6.1" @@ -5165,6 +5174,7 @@ __metadata: browser-image-resizer: "git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.3" chart.js: 4.1.1 chartjs-adapter-date-fns: 3.0.0 + chartjs-chart-matrix: ^1.3.0 chartjs-plugin-gradient: 0.6.1 chartjs-plugin-zoom: 2.0.0 compare-versions: 5.0.1