From 5358ed251d051ce0340596e6ad2ea076ba940552 Mon Sep 17 00:00:00 2001
From: Paul Pestov <10750176+paulpestov@users.noreply.github.com>
Date: Sun, 26 Nov 2023 21:06:14 +0100
Subject: [PATCH] feat: add axis info to detailed metric charts

---
 src/components/timeline/BaseTimelineChart.vue |   4 -
 .../timeline/BaseTimelineDetailedChart.vue    | 206 ++++++++++++++++++
 .../timeline/MetricAverageChart.vue           |   6 +-
 src/components/timeline/MetricChart.vue       |   3 +-
 4 files changed, 212 insertions(+), 7 deletions(-)
 create mode 100644 src/components/timeline/BaseTimelineDetailedChart.vue

diff --git a/src/components/timeline/BaseTimelineChart.vue b/src/components/timeline/BaseTimelineChart.vue
index 3498c71..2323405 100644
--- a/src/components/timeline/BaseTimelineChart.vue
+++ b/src/components/timeline/BaseTimelineChart.vue
@@ -47,9 +47,6 @@ function render([data, startDate, endDate]) {
 
   if (data.length === 0) return
 
-  console.log(container.value)
-
-
   // Declare the x (horizontal position) scale.
   const x = d3.scaleTime()
       .domain([startDate, endDate])
@@ -84,7 +81,6 @@ function render([data, startDate, endDate]) {
   svg.selectAll('.y-axis-group .tick text').attr('fill', colors.gray['400'])
 
 
-
   const trend = isUp(data, false)
 
   const pathGroup = svg
diff --git a/src/components/timeline/BaseTimelineDetailedChart.vue b/src/components/timeline/BaseTimelineDetailedChart.vue
new file mode 100644
index 0000000..ad89997
--- /dev/null
+++ b/src/components/timeline/BaseTimelineDetailedChart.vue
@@ -0,0 +1,206 @@
+<script setup lang="ts">
+import * as d3 from "d3"
+import { ref, watch, computed, onMounted } from "vue"
+import type { TimelineChartDataPoint } from "@/types"
+import { createTooltip, setEventListeners } from "@/helpers/d3/d3-tooltip"
+import colors from 'tailwindcss/colors'
+
+
+interface Props {
+  data: TimelineChartDataPoint[],
+  maxY?: number,
+  width?: number,
+  startDate: Date,
+  endDate: Date,
+  height?: number,
+  tooltipContent: (d: TimelineChartDataPoint) => string,
+  yAxisTitle?: string
+}
+
+const props = defineProps<Props>()
+
+const height = props.height || 60
+const marginTop = 10
+const marginRight = 10
+const marginBottom = 30
+const marginLeft = 40
+const _width = computed(() => props.width ?? 300)
+const _maxY = computed(() => props.maxY ?? 2)
+
+const container = ref<HTMLDivElement>()
+
+function isUp(data: TimelineChartDataPoint[], higherIsUp = true) {
+
+  if (data.length === 0) return 0
+
+  const last = data[data.length - 1].value
+  const secondLast = data.length > 1 ? data[data.length - 2].value : last
+
+  const diff = higherIsUp ? last - secondLast : secondLast - last
+
+  if (diff === 0) return 0
+  else if (diff > 0) return 1
+  else return -1
+}
+
+function render([data, startDate, endDate]) {
+  if (!data || !startDate || !endDate) return
+
+  if (data.length === 0) return
+
+  // Declare the x (horizontal position) scale.
+  const x = d3.scaleTime()
+      .domain([startDate, endDate])
+      .range([marginLeft, _width.value - marginRight])
+
+// Declare the y (vertical position) scale.
+  const y = d3.scaleLinear()
+      .domain([0, _maxY.value])
+      .range([height - marginBottom, marginTop])
+
+// Create the SVG container.
+  const svg = d3.create("svg")
+      .attr("width", _width.value - marginRight)
+      .attr("height", height)
+      .classed('!overflow-visible', true)
+
+// Add the x-axis.
+  svg.append("g")
+      .classed('x-axis-group', true)
+      .attr("transform", `translate(0,${height - marginBottom})`)
+      .call(d3.axisBottom(x).ticks(6).tickSize(4).tickFormat(d3.utcFormat("%d.%m.%Y")))
+
+  svg.select('.x-axis-group .domain').attr('stroke', colors.gray['400'])
+  svg.selectAll('.x-axis-group .tick text').attr('fill', colors.gray['400'])
+
+// Add the y-axis.
+  svg.append("g")
+      .classed('y-axis-group', true)
+      .attr("transform", `translate(${marginLeft},0)`)
+      .call(d3.axisLeft(y).ticks(6).tickSize(0).tickPadding(5))
+
+  svg.append("text")
+      .attr("text-anchor", "end")
+      .attr("transform", "rotate(-90)")
+      .attr("y", marginLeft - 30)
+      .attr("x", marginTop - 50)
+      .text(props.yAxisTitle ?? '')
+      .attr('fill', colors.gray['400'])
+
+
+  svg.select('.y-axis-group .domain').attr('stroke', colors.gray['400'])
+  svg.selectAll('.y-axis-group .tick text').attr('fill', colors.gray['400'])
+
+
+  const trend = isUp(data, false)
+
+  const pathGroup = svg
+      .append('g')
+      .classed('path-group', true)
+      .classed('up', trend === 1)
+      .classed('down', trend === -1)
+
+  pathGroup.append("path")
+      .datum(data)
+      .attr("stroke-width", 1.5)
+      .attr("d", d3.line()
+          .x(function(d) { return x(d.date) })
+          .y(function(d) { return y(d.value) })
+      )
+
+  const pointGroups = pathGroup.selectAll("myCircles")
+      .data(data)
+      .enter()
+      .append("g")
+
+  pointGroups
+      .append('circle')
+      .attr("r", 10)
+      .style('fill', 'transparent')
+      .style('cursor', 'pointer')
+      .attr("cx", function(d) { return x(d.date) })
+      .attr("cy", function(d) { return y(d.value) })
+      .classed('chart-point', true)
+
+  pointGroups
+      .append("circle")
+      .attr("r", 2)
+      .classed('pointer-events-none', true)
+      .attr("cx", function(d) { return x(d.date) })
+      .attr("cy", function(d) { return y(d.value) })
+
+  const oldTooltip = document.querySelector('.d3-tooltip')
+  const tooltip = oldTooltip ? d3.select(oldTooltip) : createTooltip(d3.select('body'), {
+    classes: 'border border-gray-300 bg-white p-2 rounded-md'
+  })
+
+  setEventListeners(svg.selectAll('.chart-point'), tooltip, { useData: props.tooltipContent })
+
+  // Append the SVG element.
+  if (!container.value) return
+  container.value.replaceChildren()
+  container.value.append(svg.node())
+}
+
+onMounted(() => {
+  render([props.data, props.startDate, props.endDate])
+})
+
+watch([() => props.data, () => props.startDate, () => props.endDate], render)
+
+
+</script>
+
+<template>
+  <div ref="container"></div>
+</template>
+
+<style lang="scss">
+
+
+.path-group {
+  path {
+    fill: none;
+    stroke: var(--color--medium-text);
+  }
+
+  circle {
+    fill: var(--color--medium-text);
+  }
+
+  &.up {
+    path {
+      stroke: var(--color--positive-text);
+    }
+
+    circle {
+      fill: var(--color--positive-text);
+    }
+  }
+
+  &.down {
+    path {
+      stroke: var(--color--negative-text);
+    }
+
+    circle {
+      fill: var(--color--negative-text);
+    }
+  }
+}
+
+.y-axis-group {
+  .tick {
+    &:first-of-type {
+      text {
+        transform: translateY(-3px);
+      }
+    }
+    text {
+      @apply text-[9px];
+    }
+  }
+}
+
+</style>
+
diff --git a/src/components/timeline/MetricAverageChart.vue b/src/components/timeline/MetricAverageChart.vue
index 27a3d65..42adc8e 100644
--- a/src/components/timeline/MetricAverageChart.vue
+++ b/src/components/timeline/MetricAverageChart.vue
@@ -7,6 +7,7 @@ import { getMaxValueOfMetric } from '@/helpers/metrics'
 import {useI18n} from "vue-i18n";
 import { metricChartTooltipContent } from "@/helpers/metric-chart-tooltip-content";
 import OverlayPanel from "primevue/overlaypanel";
+import BaseTimelineDetailedChart from "@/components/timeline/BaseTimelineDetailedChart.vue";
 
 const { t } = useI18n()
 
@@ -81,13 +82,14 @@ function tooltipContent(d: TimelineChartDataPoint) {
       ref="op"
       :pt="{
       root: {
-        class: 'z-[9999] bg-white border rounded-md shadow-md'
+        class: 'z-[9999] bg-white border rounded-md shadow-md p-6'
       }
     }"
   >
-    <BaseTimelineChart
+    <BaseTimelineDetailedChart
         :data="data"
         :max-y="maxY"
+        :y-axis-title="$t(metric)"
         :start-date="startDate"
         :end-date="endDate"
         :tooltip-content="tooltipContent"
diff --git a/src/components/timeline/MetricChart.vue b/src/components/timeline/MetricChart.vue
index 1f6eb39..75e8928 100644
--- a/src/components/timeline/MetricChart.vue
+++ b/src/components/timeline/MetricChart.vue
@@ -6,6 +6,7 @@ import { getMaxValueOfMetric } from '@/helpers/metrics'
 import type { EvaluationResultsDocumentWide, EvaluationRun, TimelineChartDataPoint } from "@/types"
 import { metricChartTooltipContent } from "@/helpers/metric-chart-tooltip-content"
 import OverlayPanel from 'primevue/overlaypanel'
+import BaseTimelineDetailedChart from "@/components/timeline/BaseTimelineDetailedChart.vue"
 
 const props = defineProps(['gtId', 'workflowId', 'metric', 'startDate', 'endDate'])
 const runs = ref<EvaluationRun[]>([])
@@ -64,7 +65,7 @@ function tooltipContent(d: TimelineChartDataPoint) {
       }
     }"
   >
-    <BaseTimelineChart
+    <BaseTimelineDetailedChart
       :data="data"
       :max-y="maxY"
       :start-date="startDate"
-- 
GitLab