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
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 { buildQueryContext, QueryFormData, TimeGranularity } from '@superset-ui/core';

const SUBDOMAIN_TO_TIME_GRAIN: Record<string, TimeGranularity> = {
min: TimeGranularity.MINUTE,
hour: TimeGranularity.HOUR,
day: TimeGranularity.DAY,
week: TimeGranularity.WEEK,
month: TimeGranularity.MONTH,
year: TimeGranularity.YEAR,
};

/**
* Mirrors the legacy CalHeatmapViz.query_obj: a timeseries query whose
* time grain is forced from the subdomain granularity control.
*/
export default function buildQuery(formData: QueryFormData) {
const { subdomain_granularity } = formData;
const timeGrain =
SUBDOMAIN_TO_TIME_GRAIN[(subdomain_granularity as string) ?? 'min'] ??
TimeGranularity.MINUTE;
return buildQueryContext(formData, baseQueryObject => [
{
...baseQueryObject,
is_timeseries: true,
extras: {
...baseQueryObject.extras,
time_grain_sqla: timeGrain,
},
},
]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@ const metadata = new ChartMetadata({
],
thumbnail,
thumbnailDark,
useLegacyApi: true,
});

export default class CalendarChartPlugin extends ChartPlugin {
constructor() {
super({
loadChart: () => import('./ReactCalendar'),
loadBuildQuery: () => import('./buildQuery'),
metadata,
transformProps,
controlPanel,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 { t } from '@apache-superset/core/translation';
import { DTTM_ALIAS } from '@superset-ui/core';

export interface CalHeatmapPayload {
data: Record<string, Record<string, unknown>>;
start: number;
domain?: string;
subdomain?: string;
range: number;
}

const daysInMonth = (year: number, month: number) =>
new Date(Date.UTC(year, month + 1, 0)).getUTCDate();

/** Adds calendar months, clamping the day like dateutil.relativedelta. */
const addMonths = (date: Date, months: number): Date => {
const totalMonths = date.getUTCFullYear() * 12 + date.getUTCMonth() + months;
const year = Math.floor(totalMonths / 12);
const month = totalMonths % 12;
const day = Math.min(date.getUTCDate(), daysInMonth(year, month));
return new Date(
Date.UTC(
year,
month,
day,
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
date.getUTCMilliseconds(),
),
);
};

/**
* Calendar delta between two dates with dateutil.relativedelta semantics:
* whole months first, then remaining days (weeks = days // 7).
*/
export const calendarDelta = (start: Date, end: Date) => {
let months =
(end.getUTCFullYear() - start.getUTCFullYear()) * 12 +
(end.getUTCMonth() - start.getUTCMonth());
if (addMonths(start, months) > end) {
months -= 1;
}
const anchor = addMonths(start, months);
const days = Math.floor(
(end.getTime() - anchor.getTime()) / (24 * 60 * 60 * 1000),
);
return {
years: Math.trunc(months / 12),
months: months % 12,
weeks: Math.floor(days / 7),
};
};

/**
* Ports the legacy CalHeatmapViz.get_data reshape: per-metric value maps
* keyed by unix seconds, plus the domain range computed from the query's
* time bounds exactly like the backend's relativedelta arithmetic.
*/
export default function transformData(
records: Record<string, unknown>[],
metricLabels: string[],
fromDttm: number | null | undefined,
toDttm: number | null | undefined,
domain: string,
subdomain: string,
): CalHeatmapPayload {
if (fromDttm == null || toDttm == null) {
throw new Error(t('Please provide both time bounds (Since and Until)'));
}
const data: Record<string, Record<string, unknown>> = {};
metricLabels.forEach(metric => {
const values: Record<string, unknown> = {};
records.forEach(record => {
const timestamp = record[DTTM_ALIAS];
if (timestamp != null) {
values[String((timestamp as number) / 1000)] = record[metric];
}
});
data[metric] = values;
});

const start = new Date(fromDttm);
const end = new Date(toDttm);
const delta = calendarDelta(start, end);
const diffSecs = (toDttm - fromDttm) / 1000;

let range: number;
if (domain === 'year') {
range = end.getUTCFullYear() - start.getUTCFullYear() + 1;
} else if (domain === 'month') {
range = delta.years * 12 + delta.months + 1;
} else if (domain === 'week') {
range = delta.years * 53 + delta.weeks + 1;
} else if (domain === 'day') {
range = Math.floor(diffSecs / (24 * 60 * 60)) + 1;
} else {
range = Math.floor(diffSecs / (60 * 60)) + 1;
}

return { data, start: fromDttm, domain, subdomain, range };
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,15 @@
* under the License.
*/

import { ChartProps, getNumberFormatter } from '@superset-ui/core';
import {
ChartProps,
ensureIsArray,
getMetricLabel,
getNumberFormatter,
QueryFormMetric,
} from '@superset-ui/core';
import { getFormattedUTCTime } from './utils';
import transformData from './transformData';

export default function transformProps(chartProps: ChartProps) {
const { height, formData, queriesData, datasource } = chartProps;
Expand All @@ -42,9 +49,26 @@ export default function transformProps(chartProps: ChartProps) {
getFormattedUTCTime(ts, xAxisTimeFormat);
const valueFormatter = getNumberFormatter(yAxisFormat);

// The legacy explore_json endpoint computed the per-metric value maps
// and domain range server-side; v1 responses arrive as flat records.
const { data: rawData, from_dttm: fromDttm, to_dttm: toDttm } =
queriesData[0];
const data = Array.isArray(rawData)
? transformData(
rawData,
ensureIsArray(formData.metrics as QueryFormMetric[]).map(
getMetricLabel,
),
fromDttm,
toDttm,
domainGranularity,
subdomainGranularity,
)
: rawData;

return {
height,
data: queriesData[0].data,
data,
cellPadding,
cellRadius,
cellSize,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 { QueryFormData } from '@superset-ui/core';
import buildQuery from '../src/buildQuery';

const formData: QueryFormData = {
datasource: '5__table',
granularity_sqla: 'ds',
time_range: 'Last year',
viz_type: 'cal_heatmap',
metrics: ['sum__num'],
domain_granularity: 'month',
subdomain_granularity: 'day',
};

test('forces the time grain from the subdomain granularity', () => {
const [query] = buildQuery(formData).queries;
expect(query.extras?.time_grain_sqla).toEqual('P1D');
expect(query.is_timeseries).toBe(true);
expect(query.metrics).toEqual(['sum__num']);
});

test('defaults the time grain to minutes like the legacy backend', () => {
const [query] = buildQuery({
...formData,
subdomain_granularity: undefined,
}).queries;
expect(query.extras?.time_grain_sqla).toEqual('PT1M');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 transformData, { calendarDelta } from '../src/transformData';

const jan1 = Date.UTC(2024, 0, 1); // 1704067200000
const jan2 = Date.UTC(2024, 0, 2);

test('keys per-metric values by unix seconds like the legacy backend', () => {
const { data, start } = transformData(
[
{ __timestamp: jan1, sum__num: 10 },
{ __timestamp: jan2, sum__num: 20 },
],
['sum__num'],
jan1,
Date.UTC(2024, 11, 31),
'month',
'day',
);
expect(data.sum__num).toEqual({
'1704067200': 10,
'1704153600': 20,
});
expect(start).toEqual(jan1);
});

test('throws when time bounds are missing', () => {
expect(() =>
transformData([], ['sum__num'], null, jan1, 'month', 'day'),
).toThrow('time bounds');
});

test.each([
['year', Date.UTC(2022, 5, 15), Date.UTC(2024, 1, 1), 3],
// Jan 15 -> Apr 10 is 2 whole months + 26 days => 2 + 1
['month', Date.UTC(2024, 0, 15), Date.UTC(2024, 3, 10), 3],
['day', jan1, Date.UTC(2024, 0, 8, 12), 8],
['hour', jan1, Date.UTC(2024, 0, 1, 5, 30), 6],
])('computes the %s domain range', (domain, start, end, expected) => {
const { range } = transformData(
[],
['m'],
start as number,
end as number,
domain as string,
'day',
);
expect(range).toEqual(expected);
});

test('computes week ranges with relativedelta semantics', () => {
// 2024-01-01 -> 2024-03-20: 2 full months + 19 days => weeks = 2
const { range } = transformData(
[],
['m'],
Date.UTC(2024, 0, 1),
Date.UTC(2024, 2, 20),
'week',
'day',
);
expect(range).toEqual(0 * 53 + 2 + 1);
});

test('calendarDelta matches dateutil for month-end clamping', () => {
// Jan 31 -> Feb 29 is a full month in dateutil terms (clamped day)
expect(
calendarDelta(
new Date(Date.UTC(2024, 0, 31)),
new Date(Date.UTC(2024, 1, 29)),
),
).toEqual({
years: 0,
months: 1,
weeks: 0,
});
});
Loading