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]

Reply via email to