Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions timeserieschart/src/TimeSeriesChartBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { forwardRef, MouseEvent, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { forwardRef, MouseEvent, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { Box } from '@mui/material';
import merge from 'lodash/merge';
import isEqual from 'lodash/isEqual';
import { toZonedTime } from 'date-fns-tz';
import { getCommonTimeScale, TimeScale, FormatOptions, TimeSeries } from '@perses-dev/core';
import type {
EChartsCoreOption,
Expand Down Expand Up @@ -49,7 +48,6 @@ import {
enableDataZoom,
getClosestTimestamp,
getFormattedAxis,
getFormattedAxisLabel,
getPointInGrid,
OnEventsType,
restoreChart,
Expand All @@ -61,6 +59,7 @@ import {
ZoomEventData,
} from '@perses-dev/components';
import { DatasetOption } from 'echarts/types/dist/shared';
import { createTimezoneAwareAxisFormatter } from './utils/timezone-formatter';

use([
EChartsLineChart,
Expand Down Expand Up @@ -128,6 +127,12 @@ export const TimeSeriesChartBase = forwardRef<ChartInstance, TimeChartProps>(fun
const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0);
const { timeZone } = useTimeZone();

const getTimezoneAwareAxisFormatter = useCallback(
(rangeMs: number): ((value: number) => string) => createTimezoneAwareAxisFormatter(rangeMs, timeZone),
[timeZone]
);

let timeScale: TimeScale;
if (timeScaleProp === undefined) {
const commonTimeScale = getCommonTimeScale(data);
Expand Down Expand Up @@ -204,11 +209,10 @@ export const TimeSeriesChartBase = forwardRef<ChartInstance, TimeChartProps>(fun
// Utilizes ECharts dataset so raw data is separate from series option style properties
// https://apache.github.io/echarts-handbook/en/concepts/dataset/
const dataset: DatasetOption[] = [];
const isLocalTimeZone = timeZone === 'local';
data.map((d, index) => {
const values = d.values.map(([timestamp, value]) => {
const val: string | number = value === null ? '-' : value; // echarts use '-' to represent null data
return [isLocalTimeZone ? timestamp : toZonedTime(timestamp, timeZone), val];
return [timestamp, val];
});
dataset.push({ id: index, source: [...values], dimensions: ['time', 'value'] });
});
Expand All @@ -221,11 +225,11 @@ export const TimeSeriesChartBase = forwardRef<ChartInstance, TimeChartProps>(fun
series: updatedSeriesMapping,
xAxis: {
type: 'time',
min: isLocalTimeZone ? timeScale.startMs : toZonedTime(timeScale.startMs, timeZone),
max: isLocalTimeZone ? timeScale.endMs : toZonedTime(timeScale.endMs, timeZone),
min: timeScale.startMs,
max: timeScale.endMs,
axisLabel: {
hideOverlap: true,
formatter: getFormattedAxisLabel(timeScale.rangeMs ?? 0),
formatter: getTimezoneAwareAxisFormatter(timeScale.rangeMs ?? 0),
},
axisPointer: {
snap: false, // important so shared crosshair does not lag
Expand Down Expand Up @@ -276,10 +280,10 @@ export const TimeSeriesChartBase = forwardRef<ChartInstance, TimeChartProps>(fun
noDataOption,
__experimentalEChartsOptionsOverride,
noDataVariant,
timeZone,
isStackedBar,
enablePinning,
pinnedCrosshair,
getTimezoneAwareAxisFormatter,
]);

// Update adjacent charts so tooltip is unpinned when current chart is clicked.
Expand Down
79 changes: 79 additions & 0 deletions timeserieschart/src/utils/timezone-formatter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright The Perses Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { createTimezoneAwareAxisFormatter } from './timezone-formatter';

// Mock formatWithTimeZone since it's from @perses-dev/components
jest.mock('@perses-dev/components', () => ({
formatWithTimeZone: jest.fn((date: Date, format: string, timeZone: string) => {
// Simple mock that returns format pattern with timezone
return `${format}[${timeZone}]`;
}),
}));

describe('createTimezoneAwareAxisFormatter', () => {
const testTimestamp = 1640995200000; // 2022-01-01 00:00:00 UTC
const timeZone = 'America/New_York';

it('should format for ranges > 5 years with year format', () => {
const formatter = createTimezoneAwareAxisFormatter(6 * 365 * 24 * 60 * 60 * 1000, timeZone);
const result = formatter(testTimestamp);
expect(result).toBe('yyyy[America/New_York]');
});

it('should format for ranges > 2 years with month-year format', () => {
const formatter = createTimezoneAwareAxisFormatter(3 * 365 * 24 * 60 * 60 * 1000, timeZone);
const result = formatter(testTimestamp);
expect(result).toBe('MMM yyyy[America/New_York]');
});

it('should format for ranges between 10 days and 6 months with day-month format', () => {
const formatter = createTimezoneAwareAxisFormatter(30 * 24 * 60 * 60 * 1000, timeZone); // 30 days
const result = formatter(testTimestamp);
expect(result).toBe('dd.MM[America/New_York]');
});

it('should format for ranges between 2-10 days with day-month-time format', () => {
const formatter = createTimezoneAwareAxisFormatter(5 * 24 * 60 * 60 * 1000, timeZone); // 5 days
const result = formatter(testTimestamp);
expect(result).toBe('dd.MM HH:mm[America/New_York]');
});

it('should format for ranges <= 2 days with time format', () => {
const formatter = createTimezoneAwareAxisFormatter(6 * 60 * 60 * 1000, timeZone); // 6 hours
const result = formatter(testTimestamp);
expect(result).toBe('HH:mm[America/New_York]');
});

it('should handle different timezones', () => {
const formatter = createTimezoneAwareAxisFormatter(6 * 60 * 60 * 1000, 'Europe/Prague');
const result = formatter(testTimestamp);
expect(result).toBe('HH:mm[Europe/Prague]');
});

it('should handle edge case at exactly 5 years', () => {
const fiveYears = 5 * 365 * 24 * 60 * 60 * 1000;
const formatter = createTimezoneAwareAxisFormatter(fiveYears, timeZone);
const result = formatter(testTimestamp);
// Should use MMM yyyy format (not > 5 years)
expect(result).toBe('MMM yyyy[America/New_York]');
});

it('should handle edge case at exactly 2 days', () => {
const twoDays = 2 * 24 * 60 * 60 * 1000;
const formatter = createTimezoneAwareAxisFormatter(twoDays, timeZone);
const result = formatter(testTimestamp);
// Should use HH:mm format (not > 2 days)
expect(result).toBe('HH:mm[America/New_York]');
});
});
49 changes: 49 additions & 0 deletions timeserieschart/src/utils/timezone-formatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright The Perses Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { formatWithTimeZone } from '@perses-dev/components';

const DAY_MS = 86400000;
const MONTH_MS = 2629440000;
const YEAR_MS = 31536000000;

/**
* Creates a timezone-aware axis formatter function for different time ranges
*/
export function createTimezoneAwareAxisFormatter(rangeMs: number, timeZone: string) {
return function (value: number): string {
const timeStamp = new Date(Number(value));

// more than 5 years
if (rangeMs > YEAR_MS * 5) {
return formatWithTimeZone(timeStamp, 'yyyy', timeZone);
}

// more than 2 years
if (rangeMs > YEAR_MS * 2) {
return formatWithTimeZone(timeStamp, 'MMM yyyy', timeZone);
}

// between 10 days to 6 months
if (rangeMs > DAY_MS * 10 && rangeMs < MONTH_MS * 6) {
return formatWithTimeZone(timeStamp, 'dd.MM', timeZone);
}

// between 2 and 10 days
if (rangeMs > DAY_MS * 2 && rangeMs <= DAY_MS * 10) {
return formatWithTimeZone(timeStamp, 'dd.MM HH:mm', timeZone);
}

return formatWithTimeZone(timeStamp, 'HH:mm', timeZone);
};
}
Loading