diff --git a/src/components/timeline/BaseTimelineChart.vue b/src/components/timeline/BaseTimelineChart.vue index 3498c714f0b908672d9d8acf1aa530c5334a8bda..2323405a8899ed8fb11b7326ca61539434a5b649 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 0000000000000000000000000000000000000000..ad8999736b34a17d3ffd523be3ddde6634f90f6c --- /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 27a3d65dbd8aedc399b2590d0c712fd1153c86ed..42adc8e4be3b858ea1783196c7a8193e9af4093b 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 1f6eb395b1114b6706c55827fbfe33e2ec1ad952..75e8928d8d38bb4805a259a803fb8ab31eb26fef 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"