This is an automated email from the ASF dual-hosted git repository.
vogievetsky pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git
The following commit(s) were added to refs/heads/master by this push:
new b5a6e506809 Explore feedback pass (#18701)
b5a6e506809 is described below
commit b5a6e506809e8cc1ff2cbd91631e5a3db4494a60
Author: Vadim Ogievetsky <[email protected]>
AuthorDate: Tue Nov 4 16:28:58 2025 +0000
Explore feedback pass (#18701)
* Have a default span for grans
* Better representation of init state
* multi measure
* smart time floor
---
web-console/src/utils/sql.spec.ts | 92 ++++-
web-console/src/utils/sql.ts | 28 ++
.../src/views/explore-view/explore-view.tsx | 4 +-
.../src/views/explore-view/models/explore-state.ts | 17 +-
.../time-chart-module/continuous-chart-render.scss | 44 --
.../time-chart-module/continuous-chart-render.tsx | 447 +++++++--------------
.../continuous-chart-single-render.scss | 75 ++++
.../continuous-chart-single-render.tsx | 349 ++++++++++++++++
.../time-chart-module/time-chart-module.tsx | 119 +++---
9 files changed, 767 insertions(+), 408 deletions(-)
diff --git a/web-console/src/utils/sql.spec.ts
b/web-console/src/utils/sql.spec.ts
index 85d01816409..ebcd5f9e972 100644
--- a/web-console/src/utils/sql.spec.ts
+++ b/web-console/src/utils/sql.spec.ts
@@ -16,9 +16,9 @@
* limitations under the License.
*/
-import { sane } from 'druid-query-toolkit';
+import { C, sane } from 'druid-query-toolkit';
-import { findAllSqlQueriesInText, findSqlQueryPrefix } from './sql';
+import { findAllSqlQueriesInText, findSqlQueryPrefix, smartTimeFloor } from
'./sql';
describe('sql', () => {
describe('getSqlQueryPrefix', () => {
@@ -840,4 +840,92 @@ describe('sql', () => {
`);
});
});
+
+ describe('smartTimeFloor', () => {
+ const timestampColumn = C('__time');
+
+ it('works with PT1H granularity in UTC', () => {
+ const result = smartTimeFloor(timestampColumn, 'PT1H', true);
+ expect(result.toString()).toEqual(`TIME_FLOOR("__time", 'PT1H')`);
+ });
+
+ it('works with PT1H granularity not in UTC', () => {
+ const result = smartTimeFloor(timestampColumn, 'PT1H', false);
+ expect(result.toString()).toEqual(`TIME_FLOOR("__time", 'PT1H')`);
+ });
+
+ it('aligns PT2H to day boundary when not in UTC', () => {
+ const result = smartTimeFloor(timestampColumn, 'PT2H', false);
+ expect(result.toString()).toEqual(
+ `TIME_FLOOR("__time", 'PT2H', TIME_FLOOR("__time", 'P1D'))`,
+ );
+ });
+
+ it('does not align PT2H to day boundary when in UTC', () => {
+ const result = smartTimeFloor(timestampColumn, 'PT2H', true);
+ expect(result.toString()).toEqual(`TIME_FLOOR("__time", 'PT2H')`);
+ });
+
+ it('aligns PT3H to day boundary when not in UTC', () => {
+ const result = smartTimeFloor(timestampColumn, 'PT3H', false);
+ expect(result.toString()).toEqual(
+ `TIME_FLOOR("__time", 'PT3H', TIME_FLOOR("__time", 'P1D'))`,
+ );
+ });
+
+ it('aligns PT4H to day boundary when not in UTC', () => {
+ const result = smartTimeFloor(timestampColumn, 'PT4H', false);
+ expect(result.toString()).toEqual(
+ `TIME_FLOOR("__time", 'PT4H', TIME_FLOOR("__time", 'P1D'))`,
+ );
+ });
+
+ it('aligns PT6H to day boundary when not in UTC', () => {
+ const result = smartTimeFloor(timestampColumn, 'PT6H', false);
+ expect(result.toString()).toEqual(
+ `TIME_FLOOR("__time", 'PT6H', TIME_FLOOR("__time", 'P1D'))`,
+ );
+ });
+
+ it('aligns PT8H to day boundary when not in UTC', () => {
+ const result = smartTimeFloor(timestampColumn, 'PT8H', false);
+ expect(result.toString()).toEqual(
+ `TIME_FLOOR("__time", 'PT8H', TIME_FLOOR("__time", 'P1D'))`,
+ );
+ });
+
+ it('aligns PT12H to day boundary when not in UTC', () => {
+ const result = smartTimeFloor(timestampColumn, 'PT12H', false);
+ expect(result.toString()).toEqual(
+ `TIME_FLOOR("__time", 'PT12H', TIME_FLOOR("__time", 'P1D'))`,
+ );
+ });
+
+ it('aligns PT24H to day boundary when not in UTC', () => {
+ const result = smartTimeFloor(timestampColumn, 'PT24H', false);
+ expect(result.toString()).toEqual(
+ `TIME_FLOOR("__time", 'PT24H', TIME_FLOOR("__time", 'P1D'))`,
+ );
+ });
+
+ it('does not align PT5H (non-divisor) to day boundary', () => {
+ const result = smartTimeFloor(timestampColumn, 'PT5H', false);
+ expect(result.toString()).toEqual(`TIME_FLOOR("__time", 'PT5H')`);
+ });
+
+ it('works with P1D granularity', () => {
+ const result = smartTimeFloor(timestampColumn, 'P1D', false);
+ expect(result.toString()).toEqual(`TIME_FLOOR("__time", 'P1D')`);
+ });
+
+ it('works with P1W granularity', () => {
+ const result = smartTimeFloor(timestampColumn, 'P1W', false);
+ expect(result.toString()).toEqual(`TIME_FLOOR("__time", 'P1W')`);
+ });
+
+ it('works with P1M granularity', () => {
+ const result = smartTimeFloor(timestampColumn, 'P1M', true);
+ expect(result.toString()).toEqual(`TIME_FLOOR("__time", 'P1M')`);
+ });
+ });
});
diff --git a/web-console/src/utils/sql.ts b/web-console/src/utils/sql.ts
index c7768dc62a9..cb7b5fd8cd8 100644
--- a/web-console/src/utils/sql.ts
+++ b/web-console/src/utils/sql.ts
@@ -18,6 +18,8 @@
import type { SqlBase } from 'druid-query-toolkit';
import {
+ F,
+ L,
SqlColumn,
SqlExpression,
SqlFunction,
@@ -178,3 +180,29 @@ export function findAllSqlQueriesInText(text: string):
QuerySlice[] {
return found;
}
+
+const GRANULARITY_TO_ALIGN_TO_DAY: Record<string, boolean> = {
+ PT2H: true,
+ PT3H: true,
+ PT4H: true,
+ PT6H: true,
+ PT8H: true,
+ PT12H: true,
+ PT24H: true,
+};
+
+/**
+ * A smart time floor that adjusts the origin for the hour granularities that
divide evenly into days to make them align with days
+ */
+export function smartTimeFloor(
+ ex: SqlExpression,
+ granularity: string,
+ isUTC: boolean,
+): SqlExpression {
+ return F(
+ 'TIME_FLOOR',
+ ex,
+ L(granularity),
+ !isUTC && GRANULARITY_TO_ALIGN_TO_DAY[granularity] ? F.timeFloor(ex,
'P1D') : undefined,
+ );
+}
diff --git a/web-console/src/views/explore-view/explore-view.tsx
b/web-console/src/views/explore-view/explore-view.tsx
index 9339adf5a2f..9176421263c 100644
--- a/web-console/src/views/explore-view/explore-view.tsx
+++ b/web-console/src/views/explore-view/explore-view.tsx
@@ -175,9 +175,9 @@ export const ExploreView = React.memo(function
ExploreView({ capabilities }: Exp
[exploreState, querySourceState.data],
);
- const { source, parseError, where, showSourceQuery, hideResources,
hideHelpers } =
- effectiveExploreState;
+ const { source, parseError, showSourceQuery, hideResources, hideHelpers } =
effectiveExploreState;
const timezone = effectiveExploreState.getEffectiveTimezone();
+ const where = effectiveExploreState.getEffectiveWhere();
function setModuleState(index: number, moduleState: ModuleState) {
setExploreState(effectiveExploreState.changeModuleState(index,
moduleState));
diff --git a/web-console/src/views/explore-view/models/explore-state.ts
b/web-console/src/views/explore-view/models/explore-state.ts
index 38f531bad9f..9b13aa0adcf 100644
--- a/web-console/src/views/explore-view/models/explore-state.ts
+++ b/web-console/src/views/explore-view/models/explore-state.ts
@@ -80,7 +80,7 @@ interface ExploreStateValue {
source: string;
showSourceQuery?: boolean;
timezone?: Timezone;
- where: SqlExpression;
+ where?: SqlExpression;
moduleStates: Readonly<Record<string, ModuleState>>;
layout?: ExploreModuleLayout;
hideResources?: boolean;
@@ -143,7 +143,7 @@ export class ExploreState {
public readonly source: string;
public readonly showSourceQuery: boolean;
public readonly timezone?: Timezone;
- public readonly where: SqlExpression;
+ public readonly where?: SqlExpression;
public readonly moduleStates: Readonly<Record<string, ModuleState>>;
public readonly layout?: ExploreModuleLayout;
public readonly hideResources: boolean;
@@ -203,7 +203,7 @@ export class ExploreState {
};
if (rename) {
- toChange.where = renameColumnsInExpression(this.where, rename);
+ toChange.where = this.where ? renameColumnsInExpression(this.where,
rename) : undefined;
toChange.moduleStates = mapRecordOrReturn(this.moduleStates, moduleState
=>
moduleState.applyRename(rename),
);
@@ -232,7 +232,7 @@ export class ExploreState {
public addInitTimeFilterIfNeeded(columns: readonly Column[]): ExploreState {
if (!this.parsedSource) return this;
if (!QuerySource.isSingleStarQuery(this.parsedSource)) return this; //
Only trigger for `SELECT * FROM ...` queries
- if (!this.where.equals(SqlLiteral.TRUE)) return this;
+ if (this.where) return this;
// Either find the `__time::TIMESTAMP` column or use the first column if
it is a TIMESTAMP
const timeColumn =
@@ -255,9 +255,9 @@ export class ExploreState {
public restrictToQuerySource(querySource: QuerySource): ExploreState {
const { where, moduleStates, helpers } = this;
- const newWhere = querySource.restrictWhere(where);
+ const newWhere = where ? querySource.restrictWhere(where) : undefined;
const newModuleStates = mapRecordOrReturn(moduleStates, moduleState =>
- moduleState.restrictToQuerySource(querySource, newWhere),
+ moduleState.restrictToQuerySource(querySource, newWhere ||
SqlLiteral.TRUE),
);
const newHelpers = filterOrReturn(helpers, helper =>
querySource.validateExpressionMeta(helper),
@@ -292,6 +292,10 @@ export class ExploreState {
return this.timezone || Timezone.UTC;
}
+ public getEffectiveWhere(): SqlExpression {
+ return this.where || SqlLiteral.TRUE;
+ }
+
public applyShowColumn(column: Column, k = 0): ExploreState {
const { moduleStates } = this;
return this.change({
@@ -345,6 +349,5 @@ export class ExploreState {
ExploreState.DEFAULT_STATE = new ExploreState({
source: '',
- where: SqlLiteral.TRUE,
moduleStates: {},
});
diff --git
a/web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-render.scss
b/web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-render.scss
index 63f23758091..351f9860460 100644
---
a/web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-render.scss
+++
b/web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-render.scss
@@ -41,17 +41,6 @@ $default-chart-color: $druid-brand;
}
}
- .selected-bar {
- fill: none;
- stroke: #ffffff;
- stroke-width: 1px;
- opacity: 0.8;
-
- &.finalized {
- opacity: 1;
- }
- }
-
.shifter {
fill: white;
fill-opacity: 0.2;
@@ -73,44 +62,11 @@ $default-chart-color: $druid-brand;
}
}
- .h-gridline {
- line {
- stroke: $white;
- stroke-dasharray: 5, 5;
- opacity: 0.5;
- }
- }
-
.now-line {
stroke: $orange4;
stroke-dasharray: 2, 2;
opacity: 0.7;
}
-
- .mark-bar {
- fill: #00b6c3;
- }
-
- .mark-line {
- stroke-width: 1.5px;
- stroke: $default-chart-color;
- fill: none;
- }
-
- .mark-area {
- fill: $default-chart-color;
- opacity: 0.5;
- }
-
- .single-point {
- stroke: $default-chart-color;
- opacity: 0.7;
- stroke-width: 1.5px;
- }
-
- .selected-point {
- fill: $default-chart-color;
- }
}
.zoom-out-button {
diff --git
a/web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-render.tsx
b/web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-render.tsx
index b45a162dc72..637a941296d 100644
---
a/web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-render.tsx
+++
b/web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-render.tsx
@@ -22,38 +22,39 @@ import type { Timezone } from 'chronoshift';
import { day, Duration, minute, second } from 'chronoshift';
import classNames from 'classnames';
import { max, sort, sum } from 'd3-array';
-import { axisBottom, axisLeft, axisRight } from 'd3-axis';
+import { axisBottom } from 'd3-axis';
import { scaleLinear, scaleUtc } from 'd3-scale';
import { select } from 'd3-selection';
-import type { Area, Line } from 'd3-shape';
-import { area, curveLinear, curveMonotoneX, curveStep, line } from 'd3-shape';
import type { MouseEvent as ReactMouseEvent } from 'react';
import { useMemo, useRef, useState } from 'react';
import type { PortalBubbleOpenOn } from '../../../../components';
import { PortalBubble } from '../../../../components';
import { useClock, useGlobalEventListener } from '../../../../hooks';
-import type { Margin, Stage } from '../../../../utils';
+import type { Margin } from '../../../../utils';
import {
clamp,
- filterMap,
formatIsoDateRange,
formatNumber,
formatStartDuration,
- groupBy,
lookupBy,
minBy,
+ Stage,
tickFormatWithTimezone,
timezoneAwareTicks,
} from '../../../../utils';
+import type { ExpressionMeta } from '../../models';
+
+import { ContinuousChartSingleRender } from './continuous-chart-single-render';
import './continuous-chart-render.scss';
const Y_AXIS_WIDTH = 60;
+const MEASURE_GAP = 6;
function getDefaultChartMargin(yAxis: undefined | 'left' | 'right') {
return {
- top: 20,
+ top: 10,
right: 10 + (yAxis === 'right' ? Y_AXIS_WIDTH : 0),
bottom: 25,
left: 10 + (yAxis === 'left' ? Y_AXIS_WIDTH : 0),
@@ -69,7 +70,7 @@ export type Range = [number, number];
export interface RangeDatum {
start: number;
end: number;
- measure: number;
+ measures: number[];
facet: string | undefined;
}
@@ -98,25 +99,12 @@ interface SelectionRange {
end: number;
finalized?: boolean;
selectedDatum?: StackedRangeDatum;
+ measureIndex?: number;
}
export type ContinuousChartMarkType = 'bar' | 'area' | 'line';
export type ContinuousChartCurveType = 'smooth' | 'linear' | 'step';
-function getCurveFactory(curveType: ContinuousChartCurveType | undefined) {
- switch (curveType) {
- case 'linear':
- return curveLinear;
-
- case 'step':
- return curveStep;
-
- case 'smooth':
- default:
- return curveMonotoneX;
- }
-}
-
export interface ContinuousChartRenderProps {
/**
* The data to be rendered it has to be ordered in reverse chronological
order (latest first)
@@ -137,6 +125,11 @@ export interface ContinuousChartRenderProps {
*/
curveType?: ContinuousChartCurveType;
+ /**
+ * The measures being displayed
+ */
+ measures: readonly ExpressionMeta[];
+
/**
* The width x height to render
*/
@@ -165,6 +158,7 @@ export const ContinuousChartRender = function
ContinuousChartRender(
markType,
curveType,
+ measures,
stage,
margin,
@@ -174,6 +168,8 @@ export const ContinuousChartRender = function
ContinuousChartRender(
domainRange,
onChangeRange,
} = props;
+
+ const numMeasures = measures.length;
const [mouseDownAt, setMouseDownAt] = useState<
{ time: number; action: 'select' | 'shift' } | undefined
>();
@@ -197,7 +193,7 @@ export const ContinuousChartRender = function
ContinuousChartRender(
const now = useClock(minute.canonicalLength);
const svgRef = useRef<SVGSVGElement | null>(null);
- const stackedData: StackedRangeDatum[] = useMemo(() => {
+ const stackedDataByMeasure: StackedRangeDatum[][] = useMemo(() => {
const effectiveFacet = facets || ['undefined'];
const facetToIndex = lookupBy(
effectiveFacet,
@@ -213,48 +209,69 @@ export const ContinuousChartRender = function
ContinuousChartRender(
return facetToIndex[String(a.facet)] - facetToIndex[String(b.facet)];
});
- if (markType === 'line') {
- // No need to stack
- return sortedData.map(d => ({ ...d, offset: 0 }));
- } else {
- let lastStart: number | undefined;
- let offset: number;
- return sortedData.map(d => {
- if (lastStart !== d.start) {
- offset = 0;
- lastStart = d.start;
- }
- const withOffset = { ...d, offset };
- offset += d.measure;
- return withOffset;
- });
- }
- }, [data, facets, markType]);
+ // Create stacked data for each measure
+ return Array.from({ length: numMeasures }, (_, measureIndex) => {
+ if (markType === 'line') {
+ // No need to stack
+ return sortedData.map(d => ({ ...d, offset: 0 }));
+ } else {
+ let lastStart: number | undefined;
+ let offset: number;
+ return sortedData.map(d => {
+ if (lastStart !== d.start) {
+ offset = 0;
+ lastStart = d.start;
+ }
+ const withOffset = { ...d, offset };
+ offset += d.measures[measureIndex];
+ return withOffset;
+ });
+ }
+ });
+ }, [data, facets, markType, numMeasures]);
function findStackedDatum(
time: number,
measure: number,
+ measureIndex: number,
isStacked: boolean,
): StackedRangeDatum | undefined {
+ const stackedData = stackedDataByMeasure[measureIndex];
+ if (!stackedData) return undefined;
+
const dataInRange = stackedData.filter(d => d.start <= time && time <
d.end);
if (!dataInRange.length) return;
if (isStacked) {
return (
- dataInRange.find(r => r.offset <= measure && measure < r.measure +
r.offset) ||
- dataInRange[dataInRange.length - 1]
+ dataInRange.find(
+ r => r.offset <= measure && measure < r.measures[measureIndex] +
r.offset,
+ ) || dataInRange[dataInRange.length - 1]
);
} else {
- return minBy(dataInRange, r => Math.abs(r.measure - measure));
+ return minBy(dataInRange, r => Math.abs(r.measures[measureIndex] -
measure));
}
}
const chartMargin = { ...margin, ...getDefaultChartMargin(yAxisPosition) };
- const innerStage = stage.applyMargin(chartMargin);
+ // Calculate height for each chart based on number of measures
+ const totalGapHeight = (numMeasures - 1) * MEASURE_GAP;
+ const chartHeight = Math.floor(
+ (stage.height - chartMargin.top - chartMargin.bottom - totalGapHeight) /
numMeasures,
+ );
+ const singleChartStage = new Stage(stage.width, chartHeight);
+ const innerStage = singleChartStage.applyMargin({
+ top: 0,
+ right: chartMargin.right,
+ bottom: 0,
+ left: chartMargin.left,
+ });
+
+ const allStackedData = stackedDataByMeasure.flat();
const effectiveDomainRange =
domainRange ||
- (stackedData.length
- ? [stackedData[stackedData.length - 1].start, stackedData[0].end]
+ (allStackedData.length
+ ? [allStackedData[allStackedData.length - 1].start,
allStackedData[0].end]
: getTodayRange(timezone));
const baseTimeScale = scaleUtc()
@@ -264,10 +281,24 @@ export const ContinuousChartRender = function
ContinuousChartRender(
? baseTimeScale.copy().domain(offsetRange(effectiveDomainRange,
shiftOffset))
: baseTimeScale;
- const maxMeasure = max(stackedData, d => d.measure + d.offset);
- const measureScale = scaleLinear()
- .rangeRound([innerStage.height, 0])
- .domain([0, (maxMeasure ?? 100) * 1.05]);
+ // Create a scale for each measure
+ const measureScales = useMemo(
+ () =>
+ stackedDataByMeasure.map((stackedData, measureIndex) => {
+ const maxMeasure = max(stackedData, d => d.measures[measureIndex] +
d.offset);
+ return scaleLinear()
+ .rangeRound([innerStage.height, 0])
+ .domain([0, (maxMeasure ?? 100) * 1.05]);
+ }),
+ [stackedDataByMeasure, innerStage.height],
+ );
+
+ function getMeasureIndexFromY(y: number): number {
+ const totalChartsHeight = chartHeight * numMeasures + totalGapHeight;
+ if (y < 0 || y > totalChartsHeight) return 0;
+ const index = Math.floor(y / (chartHeight + MEASURE_GAP));
+ return Math.min(index, numMeasures - 1);
+ }
function handleMouseDown(e: ReactMouseEvent) {
const svg = svgRef.current;
@@ -282,7 +313,8 @@ export const ContinuousChartRender = function
ContinuousChartRender(
);
const y = e.clientY - rect.y - chartMargin.top;
const time = baseTimeScale.invert(x).valueOf();
- const action = y > innerStage.height || e.shiftKey ? 'shift' : 'select';
+ const totalChartsHeight = chartHeight * numMeasures + totalGapHeight;
+ const action = y > totalChartsHeight || e.shiftKey ? 'shift' : 'select';
setMouseDownAt({
time,
action,
@@ -292,6 +324,7 @@ export const ContinuousChartRender = function
ContinuousChartRender(
setSelectionIfNeeded({
start: start.valueOf(),
end: granularity.shift(start, timezone, 1).valueOf(),
+ measureIndex: getMeasureIndexFromY(y),
});
} else {
setSelection(undefined);
@@ -315,28 +348,41 @@ export const ContinuousChartRender = function
ContinuousChartRender(
const b = baseTimeScale
.invert(clamp(x, EXTEND_X_SCALE_DOMAIN_BY, innerStage.width -
EXTEND_X_SCALE_DOMAIN_BY))
.valueOf();
+ const measureIndex = getMeasureIndexFromY(y);
if (mouseDownAt.time < b) {
setSelectionIfNeeded({
start: granularity.floor(new Date(mouseDownAt.time),
timezone).valueOf(),
end: granularity.ceil(new Date(b), timezone).valueOf(),
+ measureIndex,
});
} else {
setSelectionIfNeeded({
start: granularity.floor(new Date(b), timezone).valueOf(),
end: granularity.ceil(new Date(mouseDownAt.time),
timezone).valueOf(),
+ measureIndex,
});
}
}
} else if (!selection?.finalized) {
+ const totalChartsHeight = chartHeight * numMeasures + totalGapHeight;
if (
0 <= x &&
x <= innerStage.width &&
0 <= y &&
- y <= innerStage.height &&
+ y <= totalChartsHeight &&
svg.contains(e.target as any)
) {
const time = baseTimeScale.invert(x).valueOf();
- const measure = measureScale.invert(y);
+ const measureIndex = getMeasureIndexFromY(y);
+ const measureScale = measureScales[measureIndex];
+
+ if (!measureScale) {
+ setSelection(undefined);
+ return;
+ }
+
+ const yInChart = y - measureIndex * (chartHeight + MEASURE_GAP);
+ const measure = measureScale.invert(yInChart);
const start = granularity.floor(new Date(time), timezone);
const end = granularity.shift(start, timezone, 1);
@@ -344,7 +390,8 @@ export const ContinuousChartRender = function
ContinuousChartRender(
setSelectionIfNeeded({
start: start.valueOf(),
end: end.valueOf(),
- selectedDatum: findStackedDatum(time, measure, markType !== 'line'),
+ selectedDatum: findStackedDatum(time, measure, measureIndex,
markType !== 'line'),
+ measureIndex,
});
} else {
setSelection(undefined);
@@ -391,64 +438,6 @@ export const ContinuousChartRender = function
ContinuousChartRender(
}
});
- const byFacet = useMemo(() => {
- if (markType === 'bar' || !stackedData.length) return [];
- const isStacked = markType !== 'line';
-
- const effectiveFacets = facets || ['undefined'];
- const numFacets = effectiveFacets.length;
-
- // Fill in 0s and make sure that the facets are in the same order
- const fullTimeIntervals = groupBy(
- stackedData,
- d => String(d.start),
- dataForStart => {
- if (numFacets === 1) return [dataForStart[0]];
- const facetToDatum = lookupBy(dataForStart, d => d.facet!);
- return effectiveFacets.map(
- (facet, facetIndex) =>
- facetToDatum[facet] || {
- ...dataForStart[0],
- facet,
- measure: 0,
- offset: isStacked
- ? Math.max(
- 0,
- ...filterMap(effectiveFacets.slice(0, facetIndex), s =>
facetToDatum[s]).map(
- d => d.offset + d.measure,
- ),
- )
- : 0,
- },
- );
- },
- );
-
- // Add nulls to mark gaps in data
- const seriesForFacet: Record<string, (StackedRangeDatum | null)[]> = {};
- for (const stack of effectiveFacets) {
- seriesForFacet[stack] = [];
- }
-
- let lastDatum: StackedRangeDatum | undefined;
- for (const fullTimeInterval of fullTimeIntervals) {
- const datum = fullTimeInterval[0];
-
- if (lastDatum && lastDatum.start !== datum.end) {
- for (const facet of effectiveFacets) {
- seriesForFacet[facet].push(null);
- }
- }
-
- for (let i = 0; i < numFacets; i++) {
- seriesForFacet[effectiveFacets[i]].push(fullTimeInterval[i]);
- }
- lastDatum = datum;
- }
-
- return Object.values(seriesForFacet);
- }, [markType, stackedData, facets]);
-
if (innerStage.isInvalid()) return;
function startEndToXWidth({ start, end }: { start: number; end: number }) {
@@ -462,59 +451,15 @@ export const ContinuousChartRender = function
ContinuousChartRender(
};
}
- function datumToYHeight({ measure, offset }: StackedRangeDatum) {
- const y0 = measureScale(offset);
- const y = measureScale(measure + offset);
-
- return {
- y: y,
- height: y0 - y,
- };
- }
-
- function datumToRect(d: StackedRangeDatum) {
- const xWidth = startEndToXWidth(d);
- if (!xWidth) return;
- return {
- ...xWidth,
- ...datumToYHeight(d),
- };
- }
-
- function datumToCxCy(d: StackedRangeDatum) {
- const cx = timeScale((d.start + d.end) / 2);
- if (cx < 0 || innerStage.width < cx) return;
-
- return {
- cx,
- cy: measureScale(d.measure + d.offset),
- };
- }
-
- const curve = getCurveFactory(curveType);
-
- const areaFn = area<StackedRangeDatum>()
- .curve(curve)
- .defined(Boolean)
- .x(d => timeScale((d.start + d.end) / 2))
- .y0(d => measureScale(d.offset))
- .y1(d => measureScale(d.measure + d.offset)) as Area<StackedRangeDatum |
null>;
-
- const lineFn = line<StackedRangeDatum>()
- .curve(curve)
- .defined(Boolean)
- .x(d => timeScale((d.start + d.end) / 2))
- .y(d => measureScale(d.measure + d.offset)) as Line<StackedRangeDatum |
null>;
-
let hoveredOpenOn: PortalBubbleOpenOn | undefined;
if (selection) {
- const { start, end, selectedDatum } = selection;
+ const { start, end, selectedDatum, measureIndex = 0 } = selection;
let title: string;
let info: string;
if (selectedDatum) {
title = formatStartDuration(new Date(selectedDatum.start), granularity);
- info = formatNumber(selectedDatum.measure);
+ info = formatNumber(selectedDatum.measures[measureIndex]);
} else {
if (granularity.shift(new Date(start), timezone).valueOf() === end) {
title = formatStartDuration(new Date(start), granularity);
@@ -522,9 +467,11 @@ export const ContinuousChartRender = function
ContinuousChartRender(
title = formatIsoDateRange(new Date(start), new Date(end), timezone);
}
- const selectedData = stackedData.filter(d => start <= d.start && d.start
< end);
+ const selectedData = stackedDataByMeasure[measureIndex].filter(
+ d => start <= d.start && d.start < end,
+ );
if (selectedData.length) {
- info = formatNumber(sum(selectedData, b => b.measure));
+ info = formatNumber(sum(selectedData, b => b.measures[measureIndex]));
} else {
info = 'No data';
}
@@ -532,7 +479,7 @@ export const ContinuousChartRender = function
ContinuousChartRender(
hoveredOpenOn = {
x: chartMargin.left + timeScale((selection.start + selection.end) / 2),
- y: chartMargin.top,
+ y: chartMargin.top + measureIndex * (chartHeight + MEASURE_GAP),
title,
text: (
<>
@@ -572,6 +519,9 @@ export const ContinuousChartRender = function
ContinuousChartRender(
];
const nowX = timeScale(now);
+ const totalChartsHeight = chartHeight * numMeasures + totalGapHeight;
+ const xAxisHeight = 25;
+
return (
<div className="continuous-chart-render">
<svg
@@ -583,132 +533,49 @@ export const ContinuousChartRender = function
ContinuousChartRender(
onMouseDown={handleMouseDown}
>
<g transform={`translate(${chartMargin.left},${chartMargin.top})`}>
- {gridlinesVisible && (
- <g className="h-gridline" transform="translate(0,0)">
- {filterMap(measureScale.ticks(3), (v, i) => {
- if (v === 0) return;
- const y = measureScale(v);
- return <line key={i} x1={0} y1={y} x2={innerStage.width}
y2={y} />;
- })}
- </g>
- )}
- <g clipPath={`xywh(0px 0px ${innerStage.width}px
${innerStage.height}px) view-box`}>
+ {/* Render each measure's chart */}
+ {stackedDataByMeasure.map((stackedData, measureIndex) => (
+ <ContinuousChartSingleRender
+ key={measureIndex}
+ data={stackedData}
+ measureIndex={measureIndex}
+ facets={facets}
+ facetColorizer={facetColorizer}
+ markType={markType}
+ curveType={curveType}
+ timeScale={timeScale}
+ measureScale={measureScales[measureIndex]}
+ innerStage={innerStage}
+ yAxisPosition={yAxisPosition}
+ showHorizontalGridlines={gridlinesVisible}
+ selectedDatum={
+ selection?.measureIndex === measureIndex ?
selection.selectedDatum : undefined
+ }
+ selectionFinalized={selection?.finalized}
+ transform={`translate(0,${measureIndex * (chartHeight +
MEASURE_GAP)})`}
+ title={numMeasures > 1 ? measures[measureIndex].name : undefined}
+ />
+ ))}
+
+ {/* Selection overlay across all charts */}
+ <g clipPath={`xywh(0px 0px ${innerStage.width}px
${totalChartsHeight}px) view-box`}>
{selection && (
<rect
className={classNames('selection', { finalized:
selection.finalized })}
{...startEndToXWidth(selection)}
y={0}
- height={innerStage.height}
+ height={totalChartsHeight}
/>
)}
{0 < nowX && nowX < innerStage.width && (
- <line className="now-line" x1={nowX} x2={nowX} y1={0}
y2={innerStage.height + 8} />
- )}
- {markType === 'bar' &&
- filterMap(stackedData, stackedRow => {
- const r = datumToRect(stackedRow);
- if (!r) return;
- return (
- <rect
-
key={`${stackedRow.start}/${stackedRow.end}/${stackedRow.facet}`}
- className="mark-bar"
- {...r}
- style={
- typeof stackedRow.facet !== 'undefined'
- ? {
- fill: facetColorizer(stackedRow.facet),
- }
- : undefined
- }
- />
- );
- })}
- {markType === 'bar' && selection?.selectedDatum && (
- <rect
- className={classNames('selected-bar', { finalized:
selection.finalized })}
- {...datumToRect(selection.selectedDatum)}
- />
- )}
- {markType === 'area' &&
- byFacet.map(ds => {
- const facet = ds[0]!.facet;
- return (
- <path
- key={String(facet)}
- className="mark-area"
- d={areaFn(ds)!}
- style={
- typeof facet !== 'undefined'
- ? {
- fill: facetColorizer(facet),
- }
- : undefined
- }
- />
- );
- })}
- {(markType === 'area' || markType === 'line') &&
- byFacet.map(ds => {
- const facet = ds[0]!.facet;
- return (
- <path
- key={String(facet)}
- className="mark-line"
- d={lineFn(ds)!}
- style={
- typeof facet !== 'undefined'
- ? {
- stroke: facetColorizer(facet),
- }
- : undefined
- }
- />
- );
- })}
- {(markType === 'area' || markType === 'line') &&
- byFacet.flatMap(ds =>
- filterMap(ds, (d, i) => {
- if (!d || ds[i - 1] || ds[i + 1]) return; // Not a single
point
- const x = timeScale((d.start + d.end) / 2);
- return (
- <line
- key={`single_${i}_${d.facet}`}
- className="single-point"
- x1={x}
- x2={x}
- y1={measureScale(d.measure + d.offset)}
- y2={measureScale(d.offset)}
- style={
- typeof d.facet !== 'undefined'
- ? {
- stroke: facetColorizer(d.facet),
- }
- : undefined
- }
- />
- );
- }),
- )}
- {(markType === 'area' || markType === 'line') &&
selection?.selectedDatum && (
- <circle
- className={classNames('selected-point', { finalized:
selection.finalized })}
- {...datumToCxCy(selection.selectedDatum)}
- r={3}
- style={
- typeof selection.selectedDatum.facet !== 'undefined'
- ? {
- fill: facetColorizer(selection.selectedDatum.facet),
- }
- : undefined
- }
- />
+ <line className="now-line" x1={nowX} x2={nowX} y1={0}
y2={totalChartsHeight + 8} />
)}
{!!shiftOffset && (
<rect
className="shifter"
x={shiftOffset > 0 ? timeScale(effectiveDomainRange[1]) : 0}
y={0}
- height={innerStage.height}
+ height={totalChartsHeight}
width={
shiftOffset > 0
? innerStage.width - timeScale(effectiveDomainRange[1])
@@ -717,9 +584,11 @@ export const ContinuousChartRender = function
ContinuousChartRender(
/>
)}
</g>
+
+ {/* X-axis at the bottom */}
<g
className="axis-x"
- transform={`translate(0,${innerStage.height + 1})`}
+ transform={`translate(0,${totalChartsHeight + 1})`}
ref={(node: any) =>
select(node).call(
axisBottom(timeScale)
@@ -740,36 +609,10 @@ export const ContinuousChartRender = function
ContinuousChartRender(
shifting: typeof shiftOffset === 'number',
})}
x={0}
- y={innerStage.height}
+ y={totalChartsHeight}
width={innerStage.width}
- height={chartMargin.bottom}
+ height={xAxisHeight}
/>
- {yAxisPosition === 'left' && (
- <g
- className="axis-y"
- ref={(node: any) =>
- select(node).call(
- axisLeft(measureScale)
- .ticks(3)
- .tickSizeOuter(0)
- .tickFormat(e => formatNumber(e.valueOf())),
- )
- }
- />
- )}
- {yAxisPosition === 'right' && (
- <g
- className="axis-y"
- transform={`translate(${innerStage.width},0)`}
- ref={(node: any) =>
- select(node).call(
- axisRight(measureScale)
- .ticks(3)
- .tickFormat(e => formatNumber(e.valueOf())),
- )
- }
- />
- )}
</g>
</svg>
{!data.length && (
diff --git
a/web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-single-render.scss
b/web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-single-render.scss
new file mode 100644
index 00000000000..ce5b060e71a
--- /dev/null
+++
b/web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-single-render.scss
@@ -0,0 +1,75 @@
+/*
+ * 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 '../../../../variables';
+
+$default-chart-color: $druid-brand;
+
+.continuous-chart-single-render {
+ .chart-title {
+ fill: $white;
+ font-size: 12px;
+ font-weight: 500;
+ user-select: none;
+ pointer-events: none;
+ }
+
+ .selected-bar {
+ fill: none;
+ stroke: #ffffff;
+ stroke-width: 1px;
+ opacity: 0.8;
+
+ &.finalized {
+ opacity: 1;
+ }
+ }
+
+ .h-gridline {
+ line {
+ stroke: $white;
+ stroke-dasharray: 5, 5;
+ opacity: 0.5;
+ }
+ }
+
+ .mark-bar {
+ fill: #00b6c3;
+ }
+
+ .mark-line {
+ stroke-width: 1.5px;
+ stroke: $default-chart-color;
+ fill: none;
+ }
+
+ .mark-area {
+ fill: $default-chart-color;
+ opacity: 0.5;
+ }
+
+ .single-point {
+ stroke: $default-chart-color;
+ opacity: 0.7;
+ stroke-width: 1.5px;
+ }
+
+ .selected-point {
+ fill: $default-chart-color;
+ }
+}
diff --git
a/web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-single-render.tsx
b/web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-single-render.tsx
new file mode 100644
index 00000000000..e3dea1bc4d2
--- /dev/null
+++
b/web-console/src/views/explore-view/modules/time-chart-module/continuous-chart-single-render.tsx
@@ -0,0 +1,349 @@
+/*
+ * 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 classNames from 'classnames';
+import { axisLeft, axisRight } from 'd3-axis';
+import type { ScaleLinear, ScaleTime } from 'd3-scale';
+import { select } from 'd3-selection';
+import type { Area, Line } from 'd3-shape';
+import { area, curveLinear, curveMonotoneX, curveStep, line } from 'd3-shape';
+import { useMemo } from 'react';
+
+import type { Stage } from '../../../../utils';
+import { filterMap, formatNumber, groupBy, lookupBy } from '../../../../utils';
+
+import type {
+ ContinuousChartCurveType,
+ ContinuousChartMarkType,
+ StackedRangeDatum,
+} from './continuous-chart-render';
+
+import './continuous-chart-single-render.scss';
+
+function getCurveFactory(curveType: ContinuousChartCurveType | undefined) {
+ switch (curveType) {
+ case 'linear':
+ return curveLinear;
+
+ case 'step':
+ return curveStep;
+
+ case 'smooth':
+ default:
+ return curveMonotoneX;
+ }
+}
+
+export interface ContinuousChartSingleRenderProps {
+ data: StackedRangeDatum[];
+ measureIndex: number;
+ facets: string[] | undefined;
+ facetColorizer: (facet: string) => string;
+ markType: ContinuousChartMarkType;
+ curveType?: ContinuousChartCurveType;
+ timeScale: ScaleTime<number, number>;
+ measureScale: ScaleLinear<number, number>;
+ innerStage: Stage;
+ yAxisPosition?: 'left' | 'right';
+ showHorizontalGridlines?: boolean;
+ selectedDatum?: StackedRangeDatum;
+ selectionFinalized?: boolean;
+ transform?: string;
+ title?: string;
+}
+
+export const ContinuousChartSingleRender = function
ContinuousChartSingleRender(
+ props: ContinuousChartSingleRenderProps,
+) {
+ const {
+ data,
+ measureIndex,
+ facets,
+ facetColorizer,
+ markType,
+ curveType,
+ timeScale,
+ measureScale,
+ innerStage,
+ yAxisPosition,
+ showHorizontalGridlines,
+ selectedDatum,
+ selectionFinalized,
+ transform,
+ title,
+ } = props;
+
+ const byFacet = useMemo(() => {
+ if (markType === 'bar' || !data.length) return [];
+ const isStacked = markType !== 'line';
+
+ const effectiveFacets = facets || ['undefined'];
+ const numFacets = effectiveFacets.length;
+
+ // Fill in 0s and make sure that the facets are in the same order
+ const fullTimeIntervals = groupBy(
+ data,
+ d => String(d.start),
+ dataForStart => {
+ if (numFacets === 1) return [dataForStart[0]];
+ const facetToDatum = lookupBy(dataForStart, d => d.facet!);
+ return effectiveFacets.map(
+ (facet, facetIndex) =>
+ facetToDatum[facet] || {
+ ...dataForStart[0],
+ facet,
+ measures: [0],
+ offset: isStacked
+ ? Math.max(
+ 0,
+ ...filterMap(effectiveFacets.slice(0, facetIndex), s =>
facetToDatum[s]).map(
+ d => d.offset + d.measures[measureIndex],
+ ),
+ )
+ : 0,
+ },
+ );
+ },
+ );
+
+ // Add nulls to mark gaps in data
+ const seriesForFacet: Record<string, (StackedRangeDatum | null)[]> = {};
+ for (const stack of effectiveFacets) {
+ seriesForFacet[stack] = [];
+ }
+
+ let lastDatum: StackedRangeDatum | undefined;
+ for (const fullTimeInterval of fullTimeIntervals) {
+ const datum = fullTimeInterval[0];
+
+ if (lastDatum && lastDatum.start !== datum.end) {
+ for (const facet of effectiveFacets) {
+ seriesForFacet[facet].push(null);
+ }
+ }
+
+ for (let i = 0; i < numFacets; i++) {
+ seriesForFacet[effectiveFacets[i]].push(fullTimeInterval[i]);
+ }
+ lastDatum = datum;
+ }
+
+ return Object.values(seriesForFacet);
+ }, [markType, data, facets, measureIndex]);
+
+ function startEndToXWidth({ start, end }: { start: number; end: number }) {
+ const xStart = timeScale(start);
+ const xEnd = timeScale(end);
+ if (xEnd < 0 || innerStage.width < xStart) return;
+
+ return {
+ x: xStart,
+ width: Math.max(xEnd - xStart - 1, 1),
+ };
+ }
+
+ function datumToYHeight({ measures, offset }: StackedRangeDatum) {
+ const y0 = measureScale(offset);
+ const y = measureScale(measures[measureIndex] + offset);
+
+ return {
+ y: y,
+ height: y0 - y,
+ };
+ }
+
+ function datumToRect(d: StackedRangeDatum) {
+ const xWidth = startEndToXWidth(d);
+ if (!xWidth) return;
+ return {
+ ...xWidth,
+ ...datumToYHeight(d),
+ };
+ }
+
+ function datumToCxCy(d: StackedRangeDatum) {
+ const cx = timeScale((d.start + d.end) / 2);
+ if (cx < 0 || innerStage.width < cx) return;
+
+ return {
+ cx,
+ cy: measureScale(d.measures[measureIndex] + d.offset),
+ };
+ }
+
+ const curve = getCurveFactory(curveType);
+
+ const areaFn = area<StackedRangeDatum>()
+ .curve(curve)
+ .defined(Boolean)
+ .x(d => timeScale((d.start + d.end) / 2))
+ .y0(d => measureScale(d.offset))
+ .y1(d => measureScale(d.measures[measureIndex] + d.offset)) as
Area<StackedRangeDatum | null>;
+
+ const lineFn = line<StackedRangeDatum>()
+ .curve(curve)
+ .defined(Boolean)
+ .x(d => timeScale((d.start + d.end) / 2))
+ .y(d => measureScale(d.measures[measureIndex] + d.offset)) as
Line<StackedRangeDatum | null>;
+
+ return (
+ <g className="continuous-chart-single-render" transform={transform}>
+ {title && (
+ <text className="chart-title" x={5} y={15}>
+ {title}
+ </text>
+ )}
+ {showHorizontalGridlines && (
+ <g className="h-gridline" transform="translate(0,0)">
+ {filterMap(measureScale.ticks(3), (v, i) => {
+ if (v === 0) return;
+ const y = measureScale(v);
+ return <line key={i} x1={0} y1={y} x2={innerStage.width} y2={y} />;
+ })}
+ </g>
+ )}
+ <g clipPath={`xywh(0px 0px ${innerStage.width}px ${innerStage.height}px)
view-box`}>
+ {markType === 'bar' &&
+ filterMap(data, stackedRow => {
+ const r = datumToRect(stackedRow);
+ if (!r) return;
+ return (
+ <rect
+
key={`${stackedRow.start}/${stackedRow.end}/${stackedRow.facet}`}
+ className="mark-bar"
+ {...r}
+ style={
+ typeof stackedRow.facet !== 'undefined'
+ ? {
+ fill: facetColorizer(stackedRow.facet),
+ }
+ : undefined
+ }
+ />
+ );
+ })}
+ {markType === 'bar' && selectedDatum && (
+ <rect
+ className={classNames('selected-bar', { finalized:
selectionFinalized })}
+ {...datumToRect(selectedDatum)}
+ />
+ )}
+ {markType === 'area' &&
+ byFacet.map(ds => {
+ const facet = ds[0]!.facet;
+ return (
+ <path
+ key={String(facet)}
+ className="mark-area"
+ d={areaFn(ds)!}
+ style={
+ typeof facet !== 'undefined'
+ ? {
+ fill: facetColorizer(facet),
+ }
+ : undefined
+ }
+ />
+ );
+ })}
+ {(markType === 'area' || markType === 'line') &&
+ byFacet.map(ds => {
+ const facet = ds[0]!.facet;
+ return (
+ <path
+ key={String(facet)}
+ className="mark-line"
+ d={lineFn(ds)!}
+ style={
+ typeof facet !== 'undefined'
+ ? {
+ stroke: facetColorizer(facet),
+ }
+ : undefined
+ }
+ />
+ );
+ })}
+ {(markType === 'area' || markType === 'line') &&
+ byFacet.flatMap(ds =>
+ filterMap(ds, (d, i) => {
+ if (!d || ds[i - 1] || ds[i + 1]) return; // Not a single point
+ const x = timeScale((d.start + d.end) / 2);
+ return (
+ <line
+ key={`single_${i}_${d.facet}`}
+ className="single-point"
+ x1={x}
+ x2={x}
+ y1={measureScale(d.measures[measureIndex] + d.offset)}
+ y2={measureScale(d.offset)}
+ style={
+ typeof d.facet !== 'undefined'
+ ? {
+ stroke: facetColorizer(d.facet),
+ }
+ : undefined
+ }
+ />
+ );
+ }),
+ )}
+ {(markType === 'area' || markType === 'line') && selectedDatum && (
+ <circle
+ className={classNames('selected-point', { finalized:
selectionFinalized })}
+ {...datumToCxCy(selectedDatum)}
+ r={3}
+ style={
+ typeof selectedDatum.facet !== 'undefined'
+ ? {
+ fill: facetColorizer(selectedDatum.facet),
+ }
+ : undefined
+ }
+ />
+ )}
+ </g>
+ {yAxisPosition === 'left' && (
+ <g
+ className="axis-y"
+ ref={(node: any) =>
+ select(node).call(
+ axisLeft(measureScale)
+ .ticks(3)
+ .tickSizeOuter(0)
+ .tickFormat(e => formatNumber(e.valueOf())),
+ )
+ }
+ />
+ )}
+ {yAxisPosition === 'right' && (
+ <g
+ className="axis-y"
+ transform={`translate(${innerStage.width},0)`}
+ ref={(node: any) =>
+ select(node).call(
+ axisRight(measureScale)
+ .ticks(3)
+ .tickFormat(e => formatNumber(e.valueOf())),
+ )
+ }
+ />
+ )}
+ </g>
+ );
+};
diff --git
a/web-console/src/views/explore-view/modules/time-chart-module/time-chart-module.tsx
b/web-console/src/views/explore-view/modules/time-chart-module/time-chart-module.tsx
index a8d46d02b57..c6c70dccf15 100644
---
a/web-console/src/views/explore-view/modules/time-chart-module/time-chart-module.tsx
+++
b/web-console/src/views/explore-view/modules/time-chart-module/time-chart-module.tsx
@@ -30,6 +30,7 @@ import {
FINE_GRANULARITY_OPTIONS,
getAutoGranularity,
getTimeSpanInExpression,
+ smartTimeFloor,
} from '../../../../utils';
import { Issue } from '../../components';
import type { ExpressionMeta } from '../../models';
@@ -45,10 +46,14 @@ import type {
import { ContinuousChartRender } from './continuous-chart-render';
const TIME_NAME = 't';
-const MEASURE_NAME = 'm';
+const MEASURE_NAME_PREFIX = 'm';
const FACET_NAME = 'f';
const MIN_SLICE_WIDTH = 8;
+function getMeasureName(index: number): string {
+ return `${MEASURE_NAME_PREFIX}${index}`;
+}
+
const OTHER_VALUE = 'Other';
const OTHER_COLOR = '#666666';
@@ -101,7 +106,7 @@ interface TimeChartParameterValues {
facetColumn?: ExpressionMeta;
maxFacets: number;
showOthers: boolean;
- measure: ExpressionMeta;
+ measures: ExpressionMeta[];
curveType: ContinuousChartCurveType;
}
@@ -128,14 +133,16 @@
ModuleRepository.registerModule<TimeChartParameterValues>({
filterSpan = getTimeSpanInExpression(where, timeColumnName);
}
}
+ if (typeof filterSpan !== 'number') {
+ // If we have no span apply a default span
+ filterSpan = new Duration('P1M').getCanonicalLength();
+ }
return [
'auto',
- ...(typeof filterSpan === 'number'
- ? FINE_GRANULARITY_OPTIONS.filter(g => {
- const len = new Duration(g).getCanonicalLength();
- return filterSpan < len * 1000 && len <= filterSpan;
- })
- : FINE_GRANULARITY_OPTIONS),
+ ...FINE_GRANULARITY_OPTIONS.filter(g => {
+ const len = new Duration(g).getCanonicalLength();
+ return filterSpan < len * 1000 && len <= filterSpan;
+ }),
];
},
defaultValue: 'auto',
@@ -162,13 +169,12 @@
ModuleRepository.registerModule<TimeChartParameterValues>({
defaultValue: true,
visible: ({ parameterValues }) => Boolean(parameterValues.facetColumn),
},
- measure: {
- type: 'measure',
- label: 'Measure to show',
+ measures: {
+ type: 'measures',
transferGroup: 'show-agg',
+ defaultValue: ({ querySource }) =>
querySource?.getFirstAggregateMeasureArray(),
+ nonEmpty: true,
important: true,
- defaultValue: ({ querySource }) =>
querySource?.getFirstAggregateMeasure(),
- required: true,
},
curveType: {
type: 'option',
@@ -200,7 +206,7 @@ ModuleRepository.registerModule<TimeChartParameterValues>({
)
: parameterValues.granularity;
- const { facetColumn, maxFacets, showOthers, measure, markType } =
parameterValues;
+ const { facetColumn, maxFacets, showOthers, measures, markType } =
parameterValues;
const dataQuery = useMemo(() => {
return {
@@ -209,7 +215,7 @@ ModuleRepository.registerModule<TimeChartParameterValues>({
where,
moduleWhere,
timeGranularity,
- measure,
+ measures,
facetExpression: facetColumn?.expression,
maxFacets,
showOthers,
@@ -221,7 +227,7 @@ ModuleRepository.registerModule<TimeChartParameterValues>({
where,
moduleWhere,
timeGranularity,
- measure,
+ measures,
facetColumn,
maxFacets,
showOthers,
@@ -237,7 +243,7 @@ ModuleRepository.registerModule<TimeChartParameterValues>({
where,
moduleWhere,
timeGranularity,
- measure,
+ measures,
facetExpression,
maxFacets,
showOthers,
@@ -261,7 +267,7 @@ ModuleRepository.registerModule<TimeChartParameterValues>({
.addSelect(facetExpression.cast('VARCHAR').as(FACET_NAME),
{
addToGroupBy: 'end',
})
-
.changeOrderByExpression(measure.expression.toOrderByExpression('DESC'))
+
.changeOrderByExpression(measures[0].expression.toOrderByExpression('DESC'))
.changeLimitValue(maxFacets + (showOthers ? 1 : 0)), // If
we want to show others add 1 to check if we need to query for them
timezone,
},
@@ -277,7 +283,7 @@ ModuleRepository.registerModule<TimeChartParameterValues>({
return {
effectiveFacets: [],
sourceData: [],
- measure,
+ measures,
granularity,
};
}
@@ -288,38 +294,48 @@
ModuleRepository.registerModule<TimeChartParameterValues>({
: undefined;
const effectiveFacets = facetsToQuery ?
facetsToQuery.concat(OTHER_VALUE) : detectedFacets;
+ let query = querySource
+ .getInitQuery(overqueryWhere(effectiveWhere, timeColumnName,
granularity, oneExtra))
+ .applyIf(facetExpression && detectedFacets && !facetsToQuery, q =>
+ q.addWhere(facetExpression!.cast('VARCHAR').in(detectedFacets!)),
+ )
+ .addSelect(
+ smartTimeFloor(C(timeColumnName), timeGranularity,
timezone.isUTC()).as(TIME_NAME),
+ {
+ addToGroupBy: 'end',
+ addToOrderBy: 'end',
+ direction: 'DESC',
+ },
+ )
+ .applyIf(facetExpression, q => {
+ if (!facetExpression) return q; // Should never get here, doing
this to make peace between eslint and TS
+ return q.addSelect(
+ (facetsToQuery
+ ? SqlCase.ifThenElse(
+ facetExpression.in(facetsToQuery),
+ facetExpression,
+ L(OTHER_VALUE),
+ )
+ : facetExpression
+ )
+ .cast('VARCHAR')
+ .as(FACET_NAME),
+ { addToGroupBy: 'end' },
+ );
+ });
+
+ // Add all measures to the query
+ for (let i = 0; i < measures.length; i++) {
+ query =
query.addSelect(measures[i].expression.as(getMeasureName(i)));
+ }
+
+ query = query.changeLimitValue(
+ 10000 * (effectiveFacets ? Math.min(effectiveFacets.length, 10) : 1),
+ );
+
const result = await runSqlQuery(
{
- query: querySource
- .getInitQuery(overqueryWhere(effectiveWhere, timeColumnName,
granularity, oneExtra))
- .applyIf(facetExpression && detectedFacets && !facetsToQuery, q
=>
-
q.addWhere(facetExpression!.cast('VARCHAR').in(detectedFacets!)),
- )
- .addSelect(F.timeFloor(C(timeColumnName),
L(timeGranularity)).as(TIME_NAME), {
- addToGroupBy: 'end',
- addToOrderBy: 'end',
- direction: 'DESC',
- })
- .applyIf(facetExpression, q => {
- if (!facetExpression) return q; // Should never get here,
doing this to make peace between eslint and TS
- return q.addSelect(
- (facetsToQuery
- ? SqlCase.ifThenElse(
- facetExpression.in(facetsToQuery),
- facetExpression,
- L(OTHER_VALUE),
- )
- : facetExpression
- )
- .cast('VARCHAR')
- .as(FACET_NAME),
- { addToGroupBy: 'end' },
- );
- })
- .addSelect(measure.expression.as(MEASURE_NAME))
- .changeLimitValue(
- 10000 * (effectiveFacets ? Math.min(effectiveFacets.length,
10) : 1),
- ),
+ query,
timezone,
},
signal,
@@ -329,7 +345,7 @@ ModuleRepository.registerModule<TimeChartParameterValues>({
(b): RangeDatum => ({
start: b[TIME_NAME].valueOf(),
end: granularity.shift(b[TIME_NAME], Timezone.UTC, 1).valueOf(),
- measure: b[MEASURE_NAME],
+ measures: measures.map((_, i) => b[getMeasureName(i)]),
facet: b[FACET_NAME],
}),
);
@@ -337,7 +353,7 @@ ModuleRepository.registerModule<TimeChartParameterValues>({
return {
effectiveFacets,
sourceData: dataset,
- measure,
+ measures,
granularity,
maxTime: result.resultContext?.maxTime,
};
@@ -365,6 +381,7 @@ ModuleRepository.registerModule<TimeChartParameterValues>({
granularity={sourceData.granularity}
markType={parameterValues.markType}
curveType={parameterValues.curveType}
+ measures={measures}
stage={stage}
timezone={timezone}
yAxisPosition="right"
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]