diff --git a/packages/frontend/src/components/MkActiveUsersHeatmap.vue b/packages/frontend/src/components/MkActiveUsersHeatmap.vue index 6b89a012f2..744193dd5f 100644 --- a/packages/frontend/src/components/MkActiveUsersHeatmap.vue +++ b/packages/frontend/src/components/MkActiveUsersHeatmap.vue @@ -132,12 +132,10 @@ async function renderChart() { 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; }, }], diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index fbbc231b88..94cffa12cd 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -39,6 +39,7 @@ import * as os from '@/os'; import { defaultStore } from '@/store'; import { useChartTooltip } from '@/scripts/use-chart-tooltip'; import { chartVLine } from '@/scripts/chart-vline'; +import { alpha } from '@/scripts/color'; const props = defineProps({ src: { @@ -101,13 +102,6 @@ Chart.register( const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); 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 = { blue: '#008FFB', diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue index 41f6f9ffd5..f01bb7426c 100644 --- a/packages/frontend/src/components/MkInstanceStats.vue +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -43,6 +43,13 @@ </div> </MkFolder> + <MkFolder class="item"> + <template #header>Retention rate</template> + <div class="_panel" :class="$style.retention"> + <MkRetentionHeatmap/> + </div> + </MkFolder> + <MkFolder class="item"> <template #header>Federation</template> <div :class="$style.federation"> @@ -88,6 +95,7 @@ import * as os from '@/os'; import { i18n } from '@/i18n'; import MkActiveUsersHeatmap from '@/components/MkActiveUsersHeatmap.vue'; import MkFolder from '@/components/MkFolder.vue'; +import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue'; Chart.register( ArcElement, @@ -224,6 +232,11 @@ onMounted(() => { margin-bottom: 16px; } +.retention { + padding: 16px; + margin-bottom: 16px; +} + .federation { &:global { > .pies { diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue new file mode 100644 index 0000000000..cdfbd0d9a2 --- /dev/null +++ b/packages/frontend/src/components/MkRetentionHeatmap.vue @@ -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> diff --git a/packages/frontend/src/pages/admin/overview.retention.vue b/packages/frontend/src/pages/admin/overview.retention.vue index feac6f8118..25de8a57c1 100644 --- a/packages/frontend/src/pages/admin/overview.retention.vue +++ b/packages/frontend/src/pages/admin/overview.retention.vue @@ -1,49 +1,15 @@ <template> -<div> - <MkLoading v-if="fetching"/> - <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 class="_panel" :class="$style.root"> + <MkRetentionHeatmap/> </div> </template> <script lang="ts" setup> -import { onMounted, onUnmounted, ref } from '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; -}); +import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue'; </script> <style lang="scss" module> .root { - - &:global { - - } + padding: 20px; } </style>