<template> <div class="cbbedffa"> <canvas ref="chartEl"></canvas> <div v-if="fetching" class="fetching"> <MkLoading/> </div> </div> </template> <script lang="ts"> import { defineComponent, onMounted, ref, watch, PropType } from 'vue'; import { Chart, ArcElement, LineElement, BarElement, PointElement, BarController, LineController, CategoryScale, LinearScale, TimeScale, Legend, Title, Tooltip, SubTitle, Filler, } from 'chart.js'; import 'chartjs-adapter-date-fns'; import { enUS } from 'date-fns/locale'; import zoomPlugin from 'chartjs-plugin-zoom'; import * as os from '@/os'; import { defaultStore } from '@/store'; Chart.register( ArcElement, LineElement, BarElement, PointElement, BarController, LineController, CategoryScale, LinearScale, TimeScale, Legend, Title, Tooltip, SubTitle, Filler, zoomPlugin, ); 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 = ['#008FFB', '#00E396', '#FEB019', '#FF4560']; const getColor = (i) => { return colors[i % colors.length]; }; export default defineComponent({ props: { src: { type: String, required: true, }, args: { type: Object, required: false, }, limit: { type: Number, required: false, default: 90 }, span: { type: String as PropType<'hour' | 'day'>, required: true, }, detailed: { type: Boolean, required: false, default: false }, stacked: { type: Boolean, required: false, default: false }, aspectRatio: { type: Number, required: false, default: null }, }, setup(props) { const now = new Date(); let chartInstance: Chart = null; let data: { series: { name: string; type: 'line' | 'area'; color?: string; borderDash?: number[]; hidden?: boolean; data: { x: number; y: number; }[]; }[]; } = null; const chartEl = ref<HTMLCanvasElement>(null); const fetching = ref(true); const getDate = (ago: number) => { const y = now.getFullYear(); const m = now.getMonth(); const d = now.getDate(); const h = now.getHours(); return props.span === 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago); }; const format = (arr) => { return arr.map((v, i) => ({ x: getDate(i).getTime(), y: v })); }; const render = () => { if (chartInstance) { chartInstance.destroy(); } const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; // フォントカラー Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); chartInstance = new Chart(chartEl.value, { type: 'line', data: { labels: new Array(props.limit).fill(0).map((_, i) => getDate(i).toLocaleString()).slice().reverse(), datasets: data.series.map((x, i) => ({ parsing: false, label: x.name, data: x.data.slice().reverse(), pointRadius: 0, tension: 0, borderWidth: 2, borderColor: x.color ? x.color : getColor(i), borderDash: x.borderDash || [], borderJoinStyle: 'round', backgroundColor: alpha(x.color ? x.color : getColor(i), 0.1), fill: x.type === 'area', hidden: !!x.hidden, })), }, options: { aspectRatio: props.aspectRatio || 2.5, layout: { padding: { left: 0, right: 0, top: 0, bottom: 0, }, }, scales: { x: { type: 'time', time: { stepSize: 1, unit: props.span === 'day' ? 'month' : 'day', }, grid: { color: gridColor, borderColor: 'rgb(0, 0, 0, 0)', }, ticks: { display: props.detailed, }, adapters: { date: { locale: enUS, }, }, min: getDate(props.limit).getTime(), }, y: { position: 'left', stacked: props.stacked, grid: { color: gridColor, borderColor: 'rgb(0, 0, 0, 0)', }, ticks: { display: props.detailed, }, }, }, interaction: { intersect: false, }, plugins: { legend: { display: props.detailed, position: 'bottom', labels: { boxWidth: 16, }, }, tooltip: { mode: 'index', animation: { duration: 0, }, }, zoom: { pan: { enabled: true, }, zoom: { wheel: { enabled: true, }, pinch: { enabled: true, }, drag: { enabled: false, }, mode: 'x', }, limits: { x: { min: 'original', max: 'original', }, y: { min: 'original', max: 'original', }, } }, }, }, plugins: [{ id: 'vLine', beforeDraw(chart, args, options) { if (chart.tooltip._active && chart.tooltip._active.length) { const activePoint = chart.tooltip._active[0]; const ctx = chart.ctx; const x = activePoint.element.x; const topY = chart.scales.y.top; const bottomY = chart.scales.y.bottom; ctx.save(); ctx.beginPath(); ctx.moveTo(x, bottomY); ctx.lineTo(x, topY); ctx.lineWidth = 1; ctx.strokeStyle = vLineColor; ctx.stroke(); ctx.restore(); } } }] }); }; const exportData = () => { // TODO }; const fetchFederationInstancesChart = async (total: boolean): Promise<typeof data> => { const raw = await os.api('charts/federation', { limit: props.limit, span: props.span }); return { series: [{ name: 'Instances', type: 'area', data: format(total ? raw.instance.total : sum(raw.instance.inc, negate(raw.instance.dec)) ), }], }; }; const fetchNotesChart = async (type: string): Promise<typeof data> => { const raw = await os.api('charts/notes', { limit: props.limit, span: props.span }); return { series: [{ name: 'All', type: 'line', borderDash: [5, 5], data: format(type == 'combined' ? sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)) : sum(raw[type].inc, negate(raw[type].dec)) ), }, { name: 'Renotes', type: 'area', data: format(type == 'combined' ? sum(raw.local.diffs.renote, raw.remote.diffs.renote) : raw[type].diffs.renote ), }, { name: 'Replies', type: 'area', data: format(type == 'combined' ? sum(raw.local.diffs.reply, raw.remote.diffs.reply) : raw[type].diffs.reply ), }, { name: 'Normal', type: 'area', data: format(type == 'combined' ? sum(raw.local.diffs.normal, raw.remote.diffs.normal) : raw[type].diffs.normal ), }], }; }; const fetchNotesTotalChart = async (): Promise<typeof data> => { const raw = await os.api('charts/notes', { limit: props.limit, span: props.span }); return { series: [{ name: 'Combined', type: 'line', data: format(sum(raw.local.total, raw.remote.total)), }, { name: 'Local', type: 'area', data: format(raw.local.total), }, { name: 'Remote', type: 'area', data: format(raw.remote.total), }], }; }; const fetchUsersChart = async (total: boolean): Promise<typeof data> => { const raw = await os.api('charts/users', { limit: props.limit, span: props.span }); return { series: [{ name: 'Combined', type: 'line', data: format(total ? sum(raw.local.total, raw.remote.total) : sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)) ), }, { name: 'Local', type: 'area', data: format(total ? raw.local.total : sum(raw.local.inc, negate(raw.local.dec)) ), }, { name: 'Remote', type: 'area', data: format(total ? raw.remote.total : sum(raw.remote.inc, negate(raw.remote.dec)) ), }], }; }; const fetchActiveUsersChart = async (): Promise<typeof data> => { const raw = await os.api('charts/active-users', { limit: props.limit, span: props.span }); return { series: [{ name: 'Combined', type: 'line', data: format(sum(raw.local.users, raw.remote.users)), }, { name: 'Local', type: 'area', data: format(raw.local.users), }, { name: 'Remote', type: 'area', data: format(raw.remote.users), }], }; }; const fetchDriveChart = async (): Promise<typeof data> => { const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); return { bytes: true, series: [{ name: 'All', type: 'line', borderDash: [5, 5], data: format( sum( raw.local.incSize, negate(raw.local.decSize), raw.remote.incSize, negate(raw.remote.decSize) ) ), }, { name: 'Local +', type: 'area', data: format(raw.local.incSize), }, { name: 'Local -', type: 'area', data: format(negate(raw.local.decSize)), }, { name: 'Remote +', type: 'area', data: format(raw.remote.incSize), }, { name: 'Remote -', type: 'area', data: format(negate(raw.remote.decSize)), }], }; }; const fetchDriveTotalChart = async (): Promise<typeof data> => { const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); return { bytes: true, series: [{ name: 'Combined', type: 'line', data: format(sum(raw.local.totalSize, raw.remote.totalSize)), }, { name: 'Local', type: 'area', data: format(raw.local.totalSize), }, { name: 'Remote', type: 'area', data: format(raw.remote.totalSize), }], }; }; const fetchDriveFilesChart = async (): Promise<typeof data> => { const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); return { series: [{ name: 'All', type: 'line', borderDash: [5, 5], data: format( sum( raw.local.incCount, negate(raw.local.decCount), raw.remote.incCount, negate(raw.remote.decCount) ) ), }, { name: 'Local +', type: 'area', data: format(raw.local.incCount), }, { name: 'Local -', type: 'area', data: format(negate(raw.local.decCount)), }, { name: 'Remote +', type: 'area', data: format(raw.remote.incCount), }, { name: 'Remote -', type: 'area', data: format(negate(raw.remote.decCount)), }], }; }; const fetchDriveFilesTotalChart = async (): Promise<typeof data> => { const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); return { series: [{ name: 'Combined', type: 'line', data: format(sum(raw.local.totalCount, raw.remote.totalCount)), }, { name: 'Local', type: 'area', data: format(raw.local.totalCount), }, { name: 'Remote', type: 'area', data: format(raw.remote.totalCount), }], }; }; const fetchInstanceRequestsChart = async (): Promise<typeof data> => { const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { series: [{ name: 'In', type: 'area', color: '#008FFB', data: format(raw.requests.received) }, { name: 'Out (succ)', type: 'area', color: '#00E396', data: format(raw.requests.succeeded) }, { name: 'Out (fail)', type: 'area', color: '#FEB019', data: format(raw.requests.failed) }] }; }; const fetchInstanceUsersChart = async (total: boolean): Promise<typeof data> => { const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Users', type: 'area', color: '#008FFB', data: format(total ? raw.users.total : sum(raw.users.inc, negate(raw.users.dec)) ) }] }; }; const fetchInstanceNotesChart = async (total: boolean): Promise<typeof data> => { const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Notes', type: 'area', color: '#008FFB', data: format(total ? raw.notes.total : sum(raw.notes.inc, negate(raw.notes.dec)) ) }] }; }; const fetchInstanceFfChart = async (total: boolean): Promise<typeof data> => { const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Following', type: 'area', color: '#008FFB', data: format(total ? raw.following.total : sum(raw.following.inc, negate(raw.following.dec)) ) }, { name: 'Followers', type: 'area', color: '#00E396', data: format(total ? raw.followers.total : sum(raw.followers.inc, negate(raw.followers.dec)) ) }] }; }; const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof data> => { const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { bytes: true, series: [{ name: 'Drive usage', type: 'area', color: '#008FFB', data: format(total ? raw.drive.totalUsage : sum(raw.drive.incUsage, negate(raw.drive.decUsage)) ) }] }; }; const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof data> => { const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Drive files', type: 'area', color: '#008FFB', data: format(total ? raw.drive.totalFiles : sum(raw.drive.incFiles, negate(raw.drive.decFiles)) ) }] }; }; const fetchPerUserNotesChart = async (): Promise<typeof data> => { const raw = await os.api('charts/user/notes', { userId: props.args.user.id, limit: props.limit, span: props.span }); return { series: [...(props.args.withoutAll ? [] : [{ name: 'All', type: 'line', borderDash: [5, 5], data: format(sum(raw.inc, negate(raw.dec))), }]), { name: 'Renotes', type: 'area', data: format(raw.diffs.renote), }, { name: 'Replies', type: 'area', data: format(raw.diffs.reply), }, { name: 'Normal', type: 'area', data: format(raw.diffs.normal), }], }; }; const fetchAndRender = async () => { const fetchData = () => { switch (props.src) { case 'federation-instances': return fetchFederationInstancesChart(false); case 'federation-instances-total': return fetchFederationInstancesChart(true); case 'users': return fetchUsersChart(false); case 'users-total': return fetchUsersChart(true); case 'active-users': return fetchActiveUsersChart(); case 'notes': return fetchNotesChart('combined'); case 'local-notes': return fetchNotesChart('local'); case 'remote-notes': return fetchNotesChart('remote'); case 'notes-total': return fetchNotesTotalChart(); case 'drive': return fetchDriveChart(); case 'drive-total': return fetchDriveTotalChart(); case 'drive-files': return fetchDriveFilesChart(); case 'drive-files-total': return fetchDriveFilesTotalChart(); case 'instance-requests': return fetchInstanceRequestsChart(); case 'instance-users': return fetchInstanceUsersChart(false); case 'instance-users-total': return fetchInstanceUsersChart(true); case 'instance-notes': return fetchInstanceNotesChart(false); case 'instance-notes-total': return fetchInstanceNotesChart(true); case 'instance-ff': return fetchInstanceFfChart(false); case 'instance-ff-total': return fetchInstanceFfChart(true); case 'instance-drive-usage': return fetchInstanceDriveUsageChart(false); case 'instance-drive-usage-total': return fetchInstanceDriveUsageChart(true); case 'instance-drive-files': return fetchInstanceDriveFilesChart(false); case 'instance-drive-files-total': return fetchInstanceDriveFilesChart(true); case 'per-user-notes': return fetchPerUserNotesChart(); } }; fetching.value = true; data = await fetchData(); fetching.value = false; render(); }; watch(() => [props.src, props.span], fetchAndRender); onMounted(() => { fetchAndRender(); }); return { chartEl, fetching, }; }, }); </script> <style lang="scss" scoped> .cbbedffa { position: relative; > .fetching { position: absolute; top: 0; left: 0; width: 100%; height: 100%; -webkit-backdrop-filter: var(--blur, blur(12px)); backdrop-filter: var(--blur, blur(12px)); display: flex; justify-content: center; align-items: center; cursor: wait; } } </style>