<template> <div class="cbbedffa"> <canvas ref="chartEl"></canvas> <div v-if="fetching" class="fetching"> <MkLoading /> </div> </div> </template> <script lang="ts" setup> import type { PropType } from "vue"; import { onMounted, ref, watch } from "vue"; import { ArcElement, BarController, BarElement, CategoryScale, Chart, Filler, Legend, LineController, LineElement, LinearScale, PointElement, SubTitle, TimeScale, Title, Tooltip, } from "chart.js"; import "chartjs-adapter-date-fns"; import { enUS } from "date-fns/locale"; import zoomPlugin from "chartjs-plugin-zoom"; // https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114242002 // We can't use gradient because Vite throws a error. // import gradient from 'chartjs-plugin-gradient'; import * as os from "@/os"; import { defaultStore } from "@/store"; import { useChartTooltip } from "@/scripts/use-chart-tooltip"; const props = defineProps({ 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, }, bar: { type: Boolean, required: false, default: false, }, aspectRatio: { type: Number, required: false, default: null, }, }); Chart.register( ArcElement, LineElement, BarElement, PointElement, BarController, LineController, CategoryScale, LinearScale, TimeScale, Legend, Title, Tooltip, SubTitle, Filler, zoomPlugin, // gradient, ); 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: "#31748f", green: "#9ccfd8", yellow: "#f6c177", red: "#eb6f92", purple: "#c4a7e7", orange: "#ebbcba", lime: "#56949f", cyan: "#9ccfd8", }; const colorSets = [ colors.blue, colors.green, colors.yellow, colors.red, colors.purple, ]; const getColor = (i) => { return colorSets[i % colorSets.length]; }; const now = new Date(); let chartInstance: Chart = null, chartData: { series: { name: string; type: "line" | "area"; color?: string; dashed?: boolean; 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 { handler: externalTooltipHandler } = useChartTooltip(); 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"); // const maxes = chartData.series.map((x, i) => // Math.max(...x.data.map((d) => d.y)), // ); chartInstance = new Chart(chartEl.value, { type: props.bar ? "bar" : "line", data: { labels: new Array(props.limit) .fill(0) .map((_, i) => getDate(i).toLocaleString()) .slice() .reverse(), datasets: chartData.series.map((x, i) => ({ parsing: false, label: x.name, data: x.data.slice().reverse(), tension: 0.3, pointRadius: 0, borderWidth: props.bar ? 0 : 2, borderColor: x.color ? x.color : getColor(i), borderDash: x.dashed ? [5, 5] : [], borderJoinStyle: "round", borderRadius: props.bar ? 3 : undefined, backgroundColor: props.bar ? x.color ? x.color : getColor(i) : alpha(x.color ? x.color : getColor(i), 0.1), /* gradient: props.bar ? undefined : { backgroundColor: { axis: 'y', colors: { 0: alpha(x.color ? x.color : getColor(i), 0), [maxes[i]]: alpha(x.color ? x.color : getColor(i), 0.2), }, }, }, */ barPercentage: 0.9, categoryPercentage: 0.9, fill: x.type === "area", clip: 8, hidden: !!x.hidden, })), }, options: { aspectRatio: props.aspectRatio || 2.5, layout: { padding: { left: 0, right: 8, top: 0, bottom: 0, }, }, scales: { x: { type: "time", stacked: props.stacked, offset: false, time: { stepSize: 1, unit: props.span === "day" ? "month" : "day", }, grid: { color: gridColor, borderColor: "rgb(0, 0, 0, 0)", }, ticks: { display: props.detailed, maxRotation: 0, autoSkipPadding: 16, }, adapters: { date: { locale: enUS, }, }, min: getDate(props.limit).getTime(), }, y: { position: "left", stacked: props.stacked, suggestedMax: 50, grid: { color: gridColor, borderColor: "rgb(0, 0, 0, 0)", }, ticks: { display: props.detailed, // mirror: true, }, }, }, interaction: { intersect: false, mode: "index", }, elements: { point: { hoverRadius: 5, hoverBorderWidth: 2, }, }, animation: false, plugins: { legend: { display: props.detailed, position: "bottom", labels: { boxWidth: 16, }, }, tooltip: { enabled: false, mode: "index", animation: { duration: 0, }, external: externalTooltipHandler, }, zoom: props.detailed ? { 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", }, }, } : undefined, // gradient, }, }, plugins: [ { id: "vLine", beforeDraw(chart, args, options) { if (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 fetchFederationChart = async (): Promise<typeof chartData> => { const raw = await os.apiGet("charts/federation", { limit: props.limit, span: props.span, }); return { series: [ { name: "Received", type: "area", data: format(raw.inboxInstances), color: colors.blue, }, { name: "Delivered", type: "area", data: format(raw.deliveredInstances), color: colors.green, }, { name: "Stalled", type: "area", data: format(raw.stalled), color: colors.red, }, { name: "Pub Active", type: "line", data: format(raw.pubActive), color: colors.purple, }, { name: "Sub Active", type: "line", data: format(raw.subActive), color: colors.orange, }, { name: "Pub & Sub", type: "line", data: format(raw.pubsub), dashed: true, color: colors.cyan, }, { name: "Pub", type: "line", data: format(raw.pub), dashed: true, color: colors.purple, }, { name: "Sub", type: "line", data: format(raw.sub), dashed: true, color: colors.orange, }, ], }; }; const fetchApRequestChart = async (): Promise<typeof chartData> => { const raw = await os.apiGet("charts/ap-request", { limit: props.limit, span: props.span, }); return { series: [ { name: "In", type: "area", color: "#31748f", data: format(raw.inboxReceived), }, { name: "Out (succ)", type: "area", color: "#c4a7e7", data: format(raw.deliverSucceeded), }, { name: "Out (fail)", type: "area", color: "#f6c177", data: format(raw.deliverFailed), }, ], }; }; const fetchNotesChart = async (type: string): Promise<typeof chartData> => { const raw = await os.apiGet("charts/notes", { limit: props.limit, span: props.span, }); return { series: [ { name: "All", type: "line", 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)), ), color: "#888888", }, { name: "Renotes", type: "area", data: format( type === "combined" ? sum(raw.local.diffs.renote, raw.remote.diffs.renote) : raw[type].diffs.renote, ), color: colors.green, }, { name: "Replies", type: "area", data: format( type === "combined" ? sum(raw.local.diffs.reply, raw.remote.diffs.reply) : raw[type].diffs.reply, ), color: colors.yellow, }, { name: "Normal", type: "area", data: format( type === "combined" ? sum(raw.local.diffs.normal, raw.remote.diffs.normal) : raw[type].diffs.normal, ), color: colors.blue, }, { name: "With file", type: "area", data: format( type === "combined" ? sum( raw.local.diffs.withFile, raw.remote.diffs.withFile, ) : raw[type].diffs.withFile, ), color: colors.purple, }, ], }; }; const fetchNotesTotalChart = async (): Promise<typeof chartData> => { const raw = await os.apiGet("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 chartData> => { const raw = await os.apiGet("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 chartData> => { const raw = await os.apiGet("charts/active-users", { limit: props.limit, span: props.span, }); return { series: [ { name: "Read & Write", type: "area", data: format(raw.readWrite), color: colors.orange, }, { name: "Write", type: "area", data: format(raw.write), color: colors.lime, }, { name: "Read", type: "area", data: format(raw.read), color: colors.blue, }, { name: "< Week", type: "area", data: format(raw.registeredWithinWeek), color: colors.green, }, { name: "< Month", type: "area", data: format(raw.registeredWithinMonth), color: colors.yellow, }, { name: "< Year", type: "area", data: format(raw.registeredWithinYear), color: colors.red, }, { name: "> Week", type: "area", data: format(raw.registeredOutsideWeek), color: colors.yellow, }, { name: "> Month", type: "area", data: format(raw.registeredOutsideMonth), color: colors.red, }, { name: "> Year", type: "area", data: format(raw.registeredOutsideYear), color: colors.purple, }, ], }; }; const fetchDriveChart = async (): Promise<typeof chartData> => { const raw = await os.apiGet("charts/drive", { limit: props.limit, span: props.span, }); return { bytes: true, series: [ { name: "All", type: "line", dashed: true, 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 fetchDriveFilesChart = async (): Promise<typeof chartData> => { const raw = await os.apiGet("charts/drive", { limit: props.limit, span: props.span, }); return { series: [ { name: "All", type: "line", dashed: true, 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 fetchInstanceRequestsChart = async (): Promise<typeof chartData> => { const raw = await os.apiGet("charts/instance", { host: props.args.host, limit: props.limit, span: props.span, }); return { series: [ { name: "In", type: "area", color: "#31748f", data: format(raw.requests.received), }, { name: "Out (succ)", type: "area", color: "#c4a7e7", data: format(raw.requests.succeeded), }, { name: "Out (fail)", type: "area", color: "#f6c177", data: format(raw.requests.failed), }, ], }; }; const fetchInstanceUsersChart = async ( total: boolean, ): Promise<typeof chartData> => { const raw = await os.apiGet("charts/instance", { host: props.args.host, limit: props.limit, span: props.span, }); return { series: [ { name: "Users", type: "area", color: "#31748f", data: format( total ? raw.users.total : sum(raw.users.inc, negate(raw.users.dec)), ), }, ], }; }; const fetchInstanceNotesChart = async ( total: boolean, ): Promise<typeof chartData> => { const raw = await os.apiGet("charts/instance", { host: props.args.host, limit: props.limit, span: props.span, }); return { series: [ { name: "Posts", type: "area", color: "#31748f", data: format( total ? raw.notes.total : sum(raw.notes.inc, negate(raw.notes.dec)), ), }, ], }; }; const fetchInstanceFfChart = async ( total: boolean, ): Promise<typeof chartData> => { const raw = await os.apiGet("charts/instance", { host: props.args.host, limit: props.limit, span: props.span, }); return { series: [ { name: "Following", type: "area", color: "#31748f", data: format( total ? raw.following.total : sum(raw.following.inc, negate(raw.following.dec)), ), }, { name: "Followers", type: "area", color: "#c4a7e7", data: format( total ? raw.followers.total : sum(raw.followers.inc, negate(raw.followers.dec)), ), }, ], }; }; const fetchInstanceDriveUsageChart = async ( total: boolean, ): Promise<typeof chartData> => { const raw = await os.apiGet("charts/instance", { host: props.args.host, limit: props.limit, span: props.span, }); return { bytes: true, series: [ { name: "Drive usage", type: "area", color: "#31748f", data: format( total ? raw.drive.totalUsage : sum(raw.drive.incUsage, negate(raw.drive.decUsage)), ), }, ], }; }; const fetchInstanceDriveFilesChart = async ( total: boolean, ): Promise<typeof chartData> => { const raw = await os.apiGet("charts/instance", { host: props.args.host, limit: props.limit, span: props.span, }); return { series: [ { name: "Drive files", type: "area", color: "#31748f", data: format( total ? raw.drive.totalFiles : sum(raw.drive.incFiles, negate(raw.drive.decFiles)), ), }, ], }; }; const fetchPerUserNotesChart = async (): Promise<typeof chartData> => { const raw = await os.apiGet("charts/user/notes", { userId: props.args.user.id, limit: props.limit, span: props.span, }); return { series: [ ...(props.args.withoutAll ? [] : [ { name: "All", type: "line", data: format(sum(raw.inc, negate(raw.dec))), color: "#888888", }, ]), { name: "With file", type: "area", data: format(raw.diffs.withFile), color: colors.purple, }, { name: "Renotes", type: "area", data: format(raw.diffs.renote), color: colors.green, }, { name: "Replies", type: "area", data: format(raw.diffs.reply), color: colors.yellow, }, { name: "Normal", type: "area", data: format(raw.diffs.normal), color: colors.blue, }, ], }; }; const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => { const raw = await os.apiGet("charts/user/following", { userId: props.args.user.id, limit: props.limit, span: props.span, }); return { series: [ { name: "Local", type: "area", data: format(raw.local.followings.total), }, { name: "Remote", type: "area", data: format(raw.remote.followings.total), }, ], }; }; const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => { const raw = await os.apiGet("charts/user/following", { userId: props.args.user.id, limit: props.limit, span: props.span, }); return { series: [ { name: "Local", type: "area", data: format(raw.local.followers.total), }, { name: "Remote", type: "area", data: format(raw.remote.followers.total), }, ], }; }; const fetchPerUserDriveChart = async (): Promise<typeof chartData> => { const raw = await os.apiGet("charts/user/drive", { userId: props.args.user.id, limit: props.limit, span: props.span, }); return { series: [ { name: "Inc", type: "area", data: format(raw.incSize), }, { name: "Dec", type: "area", data: format(raw.decSize), }, ], }; }; const fetchAndRender = async () => { const fetchData = () => { switch (props.src) { case "federation": return fetchFederationChart(); case "ap-request": return fetchApRequestChart(); 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-files": return fetchDriveFilesChart(); 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(); case "per-user-following": return fetchPerUserFollowingChart(); case "per-user-followers": return fetchPerUserFollowersChart(); case "per-user-drive": return fetchPerUserDriveChart(); } }; fetching.value = true; chartData = await fetchData(); fetching.value = false; render(); }; watch(() => [props.src, props.span], fetchAndRender); onMounted(() => { fetchAndRender(); }); </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>