From a4d46eeb68f9f4e670c799717742929f07138d4d Mon Sep 17 00:00:00 2001 From: Filip Date: Thu, 12 Dec 2024 10:49:16 -0800 Subject: [PATCH] feat: support new granularities when determining x-axis timestamp format (#1848) --- .../chart-types/TimeSeriesChart.vue | 4 +- .../src/composables/useLineChartOptions.ts | 64 ++++++------------- .../src/utils/commonOptions.ts | 18 ++---- .../src/utils/format-timestamps.spec.ts | 43 +++++++++++++ .../src/utils/format-timestamps.ts | 31 +++++++++ .../analytics-chart/src/utils/index.ts | 1 + 6 files changed, 105 insertions(+), 56 deletions(-) create mode 100644 packages/analytics/analytics-chart/src/utils/format-timestamps.spec.ts create mode 100644 packages/analytics/analytics-chart/src/utils/format-timestamps.ts diff --git a/packages/analytics/analytics-chart/src/components/chart-types/TimeSeriesChart.vue b/packages/analytics/analytics-chart/src/components/chart-types/TimeSeriesChart.vue index 8a6bc4b34c..f898ea1b3e 100644 --- a/packages/analytics/analytics-chart/src/components/chart-types/TimeSeriesChart.vue +++ b/packages/analytics/analytics-chart/src/components/chart-types/TimeSeriesChart.vue @@ -72,9 +72,9 @@ import { Line, Bar } from 'vue-chartjs' import composables from '../../composables' import type { ChartLegendSortFn, ChartTooltipSortFn, EnhancedLegendItem, KChartData, LegendValues, TooltipEntry } from '../../types' import type { GranularityValues, AbsoluteTimeRangeV4 } from '@kong-ui-public/analytics-utilities' -import { formatTime } from '@kong-ui-public/analytics-utilities' import type { Chart, LegendItem } from 'chart.js' import { ChartLegendPosition } from '../../enums' +import { formatByGranularity } from '../../utils' const props = defineProps({ chartData: { @@ -216,7 +216,7 @@ const { options } = composables.useLinechartOptions({ composables.useReportChartDataForSynthetics(toRef(props, 'chartData'), toRef(props, 'syntheticsDataKey')) const formatTimestamp = (ts: number): string | number => { - return formatTime(ts, { short: ['daily', 'weekly'].includes(props.granularity) }) + return formatByGranularity(new Date(ts), props.granularity, false) } /** diff --git a/packages/analytics/analytics-chart/src/composables/useLineChartOptions.ts b/packages/analytics/analytics-chart/src/composables/useLineChartOptions.ts index f68a15dd96..68bbb07293 100644 --- a/packages/analytics/analytics-chart/src/composables/useLineChartOptions.ts +++ b/packages/analytics/analytics-chart/src/composables/useLineChartOptions.ts @@ -8,10 +8,10 @@ import type { import { Tooltip, } from 'chart.js' -import { horizontalTooltipPositioning, tooltipBehavior, verticalTooltipPositioning } from '../utils' -import { millisecondsToHours } from 'date-fns' +import { formatByGranularity, horizontalTooltipPositioning, tooltipBehavior, verticalTooltipPositioning } from '../utils' import { isNullOrUndef } from 'chart.js/helpers' import type { ExternalTooltipContext, LineChartOptions } from '../types' +import { millisecondsToHours } from 'date-fns' export default function useLinechartOptions(chartOptions: LineChartOptions) { @@ -29,6 +29,10 @@ export default function useLinechartOptions(chartOptions: LineChartOptions) { autoSkipPadding: 100, source: 'auto', maxRotation: 0, + callback: (value: number) => { + const tickValue = new Date(value) + return formatByGranularity(tickValue, chartOptions.granularity.value, dayBoundaryCrossed.value) + }, }, title: { display: !isNullOrUndef(chartOptions.dimensionAxesTitle?.value), @@ -38,6 +42,10 @@ export default function useLinechartOptions(chartOptions: LineChartOptions) { weight: 'bold', }, }, + border: { + display: false, + }, + stacked: chartOptions.stacked.value, })) const yAxesOptions = computed(() => ({ title: { @@ -56,6 +64,10 @@ export default function useLinechartOptions(chartOptions: LineChartOptions) { }, id: 'main-y-axis', beginAtZero: true, + border: { + display: false, + }, + stacked: chartOptions.stacked.value, })) const tooltipOptions = { @@ -96,25 +108,12 @@ export default function useLinechartOptions(chartOptions: LineChartOptions) { } } - const xAxisGranularityUnit = computed(() => { - switch (chartOptions.granularity.value) { - case 'minutely': - return 'minute' - case 'hourly': - return 'hour' - case 'daily': - return 'day' - default: - return 'day' - } - }) - - const hourDisplayFormat = computed(() => { - return millisecondsToHours(Number(chartOptions.timeRangeMs.value)) >= 24 ? 'yyyy-MM-dd h:mm' : 'h:mm' - }) - const dayDisplayFormat = computed(() => { - return ['daily', 'weekly'].includes(chartOptions.granularity.value) ? 'yyyy-MM-dd' : 'yyyy-MM-dd h:mm' + const dayBoundaryCrossed = computed(() => { + const timeRange = Number(chartOptions.timeRangeMs.value) + const now = new Date() + const start = new Date(now.getTime() - timeRange) + return millisecondsToHours(timeRange) > 24 || start.getDate() !== now.getDate() }) const options = computed(() => { @@ -135,29 +134,8 @@ export default function useLinechartOptions(chartOptions: LineChartOptions) { easing: 'linear', }, scales: { - x: { - border: { - display: false, - }, - ...xAxesOptions.value, - stacked: chartOptions.stacked.value, - time: { - tooltipFormat: 'h:mm:ss a', - unit: xAxisGranularityUnit.value, - displayFormats: { - minute: 'h:mm:ss a', - hour: hourDisplayFormat.value, - day: dayDisplayFormat.value, - }, - }, - }, - y: { - border: { - display: false, - }, - ...yAxesOptions.value, - stacked: chartOptions.stacked.value, - }, + x: xAxesOptions.value, + y: yAxesOptions.value, }, responsive: true, maintainAspectRatio: false, diff --git a/packages/analytics/analytics-chart/src/utils/commonOptions.ts b/packages/analytics/analytics-chart/src/utils/commonOptions.ts index 3f380ce378..0ae592f713 100644 --- a/packages/analytics/analytics-chart/src/utils/commonOptions.ts +++ b/packages/analytics/analytics-chart/src/utils/commonOptions.ts @@ -81,18 +81,14 @@ export const hasMillisecondTimestamps = (chartData: KChartData) => (ds) => ds.data[0] && (ds.data[0] as ScatterDataPoint).x.toString().length >= 13, ) +/** + * Adjust the tooltip's horizontal position based on its width and cursor location relative to the chart center. + * This logic ensures consistent visual placement of a custom tooltip, as ChartJS offers limited direct control. + */ export const horizontalTooltipPositioning = (position: Point, tooltipWidth: number, chartCenterX: number) => { - // We are manipulating an initial positioning logic that appears to be quite arbitrary. - // ChartJS offers limited documentation on this. The logic that follows has been tested across multiple scenarios - // and provides the most consistent visual output. - // The goal is to shift the tooltip to either the left or right in proportion to the tooltip's width, - // depending on the cursor's location relative to the chart's center. - // Additionally, we need to scale by the ratio of the tooltip width to chart width in order to - // adjust for any changes in the tooltip width. - // The original tooltip position tends to lean towards the center of the tooltip — this is one of the arbitrary aspects we are dealing with. - // It appears that the default position.x and position.y values don't consistently align with the tooltip. - // It's likely that these initial position.x and position.y values refer to the position of ChartJS' default tooltip, - // which is not visible as we're using a custom tooltip. + // Scaling factor that prevents the tooltip from shifting too far when it's wide, or too little when + // it's narrow. Ensuring that as the tooltip width changes, the horizontal offset is proportionally + // adjusted to maintain a visually balanced placement. const withRatioScalingBase = 1150 // Found through trial and error. const widthRatio = Math.min(tooltipWidth / withRatioScalingBase, 1) // Limit the ratio to a maximum of 1 // Define a scaling factor for when the tooltip is positioned to the right of the cursor. diff --git a/packages/analytics/analytics-chart/src/utils/format-timestamps.spec.ts b/packages/analytics/analytics-chart/src/utils/format-timestamps.spec.ts new file mode 100644 index 0000000000..4c064d212b --- /dev/null +++ b/packages/analytics/analytics-chart/src/utils/format-timestamps.spec.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { formatByGranularity } from './format-timestamps' + +describe('formatByGranularity', () => { + const testDate = new Date('2024-12-10T15:30:45Z') + + it('formats correctly for second-based granularities in UTC', () => { + expect(formatByGranularity(testDate, 'secondly', false, 'UTC')).toBe('3:30:45 PM') + expect(formatByGranularity(testDate, 'secondly', true, 'UTC')).toBe('2024-12-10 3:30:45 PM') + }) + + it('formats correctly for minute-based granularities in UTC', () => { + expect(formatByGranularity(testDate, 'minutely', false, 'UTC')).toBe('3:30 PM') + expect(formatByGranularity(testDate, 'hourly', true, 'UTC')).toBe('2024-12-10 3:30 PM') + }) + + it('formats correctly for twelveHourly granularity in UTC', () => { + expect(formatByGranularity(testDate, 'twelveHourly', false, 'UTC')).toBe('2024-12-10 3:30 PM') + }) + + it('formats correctly for daily granularity in UTC', () => { + expect(formatByGranularity(testDate, 'daily', false, 'UTC')).toBe('2024-12-10') + }) + + it('formats correctly for weekly granularity in UTC', () => { + expect(formatByGranularity(testDate, 'weekly', false, 'UTC')).toBe('2024 W50') + }) + + it('formats with default format for unknown granularities in UTC', () => { + // @ts-ignore - testing unknown granularity + expect(formatByGranularity(testDate, 'unknownGranularity', false, 'UTC')).toBe('2024-12-10 3:30:45 PM') + }) + + it('formats correctly for second-based granularities in America/New_York', () => { + expect(formatByGranularity(testDate, 'secondly', false, 'America/New_York')).toBe('10:30:45 AM') + expect(formatByGranularity(testDate, 'secondly', true, 'America/New_York')).toBe('2024-12-10 10:30:45 AM') + }) + + it('formats correctly for minute-based granularities in America/New_York', () => { + expect(formatByGranularity(testDate, 'minutely', false, 'America/New_York')).toBe('10:30 AM') + expect(formatByGranularity(testDate, 'hourly', true, 'America/New_York')).toBe('2024-12-10 10:30 AM') + }) +}) diff --git a/packages/analytics/analytics-chart/src/utils/format-timestamps.ts b/packages/analytics/analytics-chart/src/utils/format-timestamps.ts new file mode 100644 index 0000000000..c6637cb53c --- /dev/null +++ b/packages/analytics/analytics-chart/src/utils/format-timestamps.ts @@ -0,0 +1,31 @@ +import type { GranularityValues } from '@kong-ui-public/analytics-utilities' +import { formatInTimeZone } from 'date-fns-tz' + +const tz = Intl.DateTimeFormat().resolvedOptions().timeZone + +export const formatByGranularity = ( + tickValue: Date, + granularity: GranularityValues, + dayBoundaryCrossed: boolean, + timezone: string = tz, +) => { + if (['secondly', 'tenSecondly', 'thirtySecondly'].includes(granularity)) { + return formatInTimeZone(tickValue, timezone, dayBoundaryCrossed ? 'yyyy-MM-dd h:mm:ss a' : 'h:mm:ss a') + } + if (['minutely', 'fiveMinutely', 'tenMinutely', 'thirtyMinutely', 'hourly', 'twoHourly'].includes(granularity)) { + return formatInTimeZone(tickValue, timezone, dayBoundaryCrossed ? 'yyyy-MM-dd h:mm a' : 'h:mm a') + } + if (granularity === 'twelveHourly') { + return formatInTimeZone(tickValue, timezone, 'yyyy-MM-dd h:mm a') + } + + if (granularity === 'daily') { + return formatInTimeZone(tickValue, timezone, 'yyyy-MM-dd') + } + + if (granularity === 'weekly') { + return `${formatInTimeZone(tickValue, timezone, 'yyyy')} W${formatInTimeZone(tickValue, timezone, 'II')}` + } + + return formatInTimeZone(tickValue, timezone, 'yyyy-MM-dd h:mm:ss a') +} diff --git a/packages/analytics/analytics-chart/src/utils/index.ts b/packages/analytics/analytics-chart/src/utils/index.ts index d17c78b8d7..f63dda8d11 100644 --- a/packages/analytics/analytics-chart/src/utils/index.ts +++ b/packages/analytics/analytics-chart/src/utils/index.ts @@ -4,3 +4,4 @@ export * from './colors' export * from './defaultLineOptions' export * from './stackedBarUtil' export * from './customColors' +export * from './format-timestamps'