Skip to content
Snippets Groups Projects
WorkflowStatisticsChart.vue 8.57 KiB
<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>