<script setup lang="ts"> import { Chart, BarElement, BarController, LinearScale, TimeSeriesScale, Tooltip, LineController, LineElement, PointElement, } from "chart.js"; import "@/utils/DayjsAdapter"; import type { Element as ChartElement, Point, ChartItem } from "chart.js"; import { onMounted, ref, watch, computed } from "vue"; import type { WorkflowStatistic } from "@/client/workflow"; import dayjs from "dayjs"; const canvas = ref<ChartItem | undefined>(undefined); let chart: Chart | undefined; const props = defineProps<{ stats: WorkflowStatistic[]; }>(); const disableZoomReset = ref<boolean>(true); // register all relevant elements to enable tree shaking Chart.register( BarElement, BarController, LinearScale, Tooltip, TimeSeriesScale, LineController, LineElement, PointElement, ); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore Tooltip.positioners.top = (elements: ChartElement[], eventPosition: Point) => { return { x: eventPosition.x, y: 0, }; }; function updateData(chart: Chart, newStats: WorkflowStatistic[]) { // override old labels in the chart chart.data.labels = newStats.map((stat) => stat.day); // override old count per day in the chart chart.data.datasets[0].data = newStats.map((stat) => stat.count); // override old cumulative sum in the chart chart.data.datasets[1].data = newStats.map( ( (sum) => (value) => (sum += value.count) )(0), ); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore chart.options.scales.x.time.unit = newStats.length > 0 ? xAxisLabelUnit(newStats[0].day, dayjs().toISOString()) : "week"; // render new chart chart.update(); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore chart.options.animation = true; // enable animations for zoom } function resetZoom() { if (chart) { chart.resetZoom(); } } function xAxisLabelUnit(max: number | string, min: number | string): string { return dayjs(min).diff(dayjs(max), "week") < 12 ? "week" : "month"; } // pad or trim stats to one year const paddedStats = computed<WorkflowStatistic[]>(() => { // select first day in statistics: last year, first day of statistics or last month let statsIndex = 0; let firstDay = dayjs().subtract(1, "month"); if (props.stats.length > 0) { const firstStatDay = dayjs(props.stats[0].day); if (firstStatDay.isSameOrBefore(firstDay, "day")) { firstDay = firstStatDay; } const lastYear = dayjs().subtract(1, "year"); if (firstDay.isSameOrBefore(lastYear, "day")) { firstDay = lastYear; // Find index in stats list where the stat is from a day after the same day last year statsIndex = props.stats.findIndex((stat) => lastYear.isSameOrBefore(dayjs(stat.day), "day"), ); } } else { statsIndex = 1; } const timespan = dayjs().diff(firstDay, "day") + 1; // Pad or trim workflow statistics return Array.from({ length: timespan }, (e, i) => { let count = 0; const day = dayjs() .subtract(timespan - i, "day") .format("YYYY-MM-DD"); if ( statsIndex < props.stats.length && props.stats[statsIndex].day === day ) { count = props.stats[statsIndex].count; statsIndex++; } return { day: day, count: count, }; }); }); watch(paddedStats, (newStats) => { if (chart) { updateData(chart, newStats); } }); onMounted(() => { if (canvas.value) { chart = new Chart(canvas.value, { type: "bar", data: { labels: [], // Days as lables datasets: [ { // Workflow count per day label: "Workflow Count", data: [], type: "bar", yAxisID: "y1", }, { // Cumulative Workflow count over the year label: "Cumulative Workflow Usage", data: [], type: "line", yAxisID: "y2", cubicInterpolationMode: "monotone", tension: 0.4, pointStyle: false, }, ], }, options: { animation: false, // disable animations maintainAspectRatio: false, // fill all given space responsive: true, // fill all given space scales: { // left scale for count per day y1: { position: "left", grid: { display: false, // disable grid }, ticks: { precision: 0, // round ticks to zero decimal places }, // title options title: { color: "gray", display: true, text: "per Day", }, }, y2: { // right scale for cumulative count position: "right", grid: { display: false, // disable grid }, ticks: { precision: 0, // round ticks to zero decimal places }, // title options title: { color: "gray", display: true, text: "Cumulative", }, }, x: { // x scale as time-series type: "timeseries", time: { // options for timescale unit: "month", // display unit parser: "YYYY-MM-DD", // format of the data to parse from tooltipFormat: "MMM DD, YYYY", // format of date in tooltip }, ticks: { align: "start", // align ticks labels }, grid: { display: false, // disable grid }, }, }, plugins: { zoom: { pan: { enabled: true, // enable panning modifierKey: "ctrl", // press ctrl key to enable panning mode: "x", // pan only in x direction }, zoom: { drag: { enabled: true, // enable zoom }, mode: "x", // zoom only in x direction onZoomComplete: (tempChart: { chart: Chart }) => { // update x axis label scale depending on zoom level if (tempChart.chart.options.scales?.x) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore tempChart.chart.options.scales.x.time.unit = xAxisLabelUnit( tempChart.chart.scales.x.min, tempChart.chart.scales.x.max, ); tempChart.chart.update(); disableZoomReset.value = !tempChart.chart.isZoomedOrPanned(); } }, }, limits: { // limit for zooming in x direction x: { minRange: 2419200000, // minimum range one month in milliseconds 1000*60*60*24*7*4 min: "original", // min x value from original scale max: "original", // max x value from original scale }, y2: { min: "original", // min y value from original scale max: "original", // max y value from original scale }, }, }, tooltip: { enabled: true, // enable tooltips // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore position: "top", // position tooltip on top of canvas mode: "index", intersect: false, backgroundColor: "rgba(108, 117, 125, 0.9)", // greyish background color yAlign: "bottom", // Position of the tooltip caret in the Y direction }, }, }, }); if (paddedStats.value.length > 0) { updateData(chart, paddedStats.value); } } }); </script> <template> <div class="w-100 row"> <h5 class="mx-auto w-fit">Workflow Usage Statistics</h5> </div> <div class="d-flex align-items-center mb-4"> <div class="d-flex flex-column justify-content-evenly align-items-center me-2 pb-2" style="height: 150px" > <div> {{ dayjs(paddedStats[0].day).format("MMM YYYY") }} - {{ dayjs().format("MMM YYYY") }} </div> <button type="button" class="btn btn-outline-secondary me-2" @click="resetZoom" :disabled="disableZoomReset" > Reset Zoom </button> </div> <div style="max-height: 200px" class="flex-fill"> <canvas ref="canvas"></canvas> </div> </div> </template> <style scoped></style>