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 c8eb7adeb9b Improved web-console's time-chart brush and added 
auto-granularity (#14990)
c8eb7adeb9b is described below

commit c8eb7adeb9bb170e2bfe7879c23f42b72d263876
Author: Sébastien <[email protected]>
AuthorDate: Mon Nov 27 21:15:47 2023 +0100

    Improved web-console's time-chart brush and added auto-granularity (#14990)
    
    * Improved time-chart brush and added auto-granularity
    
    * prettier
    
    * added highlight bubble to explore visualizations
    
    * Added licenses and fixes from PR review
    
    * added missing files...
---
 licenses.yaml                                      |  38 ++++
 licenses/bin/moment-timezone.MIT                   |  20 ++
 licenses/bin/moment.MIT                            |  22 ++
 web-console/package-lock.json                      |  71 ++++++
 web-console/package.json                           |   1 +
 web-console/src/hooks/index.ts                     |   1 +
 web-console/src/hooks/use-resize-observer.ts       |  80 +++++++
 .../droppable-container/droppable-container.scss   |   1 +
 .../src/views/explore-view/explore-view.tsx        | 251 +++++++++++----------
 .../explore-view/filter-pane/pattern-helpers.ts    |  17 +-
 .../highlight-bubble.scss}                         |  38 ++--
 .../highlight-bubble/highlight-bubble.tsx          |  80 +++++++
 .../highlight-store/highlight-store.ts             | 105 +++++++++
 .../modules/bar-chart-echarts-module.ts            |  61 +++--
 .../modules/multi-axis-chart-echarts-module.ts     | 135 ++++++++++-
 .../modules/pie-chart-echarts-module.ts            | 107 +++++++--
 .../modules/time-chart-echarts-module.ts           | 148 ++++++++++--
 .../explore-view/utils/date-format.ts}             |  17 +-
 .../explore-view/utils/get-auto-granularity.ts     |  82 +++++++
 .../{hooks => views/explore-view/utils}/index.ts   |  12 +-
 .../views/explore-view/{utils.ts => utils/misc.ts} |   2 +-
 .../explore-view/utils/snap-to-granularity.ts      |  57 +++++
 22 files changed, 1103 insertions(+), 243 deletions(-)

diff --git a/licenses.yaml b/licenses.yaml
index e4869198a5d..5ad05d80c89 100644
--- a/licenses.yaml
+++ b/licenses.yaml
@@ -5375,6 +5375,15 @@ license_file_path: licenses/bin/change-case.MIT
 
 ---
 
+name: "chronoshift"
+license_category: binary
+module: web-console
+license_name: Apache License version 2.0
+copyright: Vadim Ogievetsky
+version: 0.10.0
+
+---
+
 name: "classnames"
 license_category: binary
 module: web-console
@@ -5903,6 +5912,15 @@ license_file_path: licenses/bin/iconv-lite.MIT
 
 ---
 
+name: "immutable-class"
+license_category: binary
+module: web-console
+license_name: Apache License version 2.0
+copyright: Vadim Ogievetsky
+version: 0.11.2
+
+---
+
 name: "import-fresh"
 license_category: binary
 module: web-console
@@ -6103,6 +6121,26 @@ license_file_path: licenses/bin/memoize-one.MIT
 
 ---
 
+name: "moment-timezone"
+license_category: binary
+module: web-console
+license_name: MIT License
+copyright: Tim Wood
+version: 0.5.43
+license_file_path: licenses/bin/moment-timezone.MIT
+
+---
+
+name: "moment"
+license_category: binary
+module: web-console
+license_name: MIT License
+copyright: Iskren Ivov Chernev
+version: 2.29.4
+license_file_path: licenses/bin/moment.MIT
+
+---
+
 name: "no-case"
 license_category: binary
 module: web-console
diff --git a/licenses/bin/moment-timezone.MIT b/licenses/bin/moment-timezone.MIT
new file mode 100644
index 00000000000..f0bb3b4bb46
--- /dev/null
+++ b/licenses/bin/moment-timezone.MIT
@@ -0,0 +1,20 @@
+The MIT License (MIT)
+
+Copyright (c) JS Foundation and other contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 
of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 
FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
diff --git a/licenses/bin/moment.MIT b/licenses/bin/moment.MIT
new file mode 100644
index 00000000000..8618b7333d6
--- /dev/null
+++ b/licenses/bin/moment.MIT
@@ -0,0 +1,22 @@
+Copyright (c) JS Foundation and other contributors
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
diff --git a/web-console/package-lock.json b/web-console/package-lock.json
index 42228155346..e3fba2cb11f 100644
--- a/web-console/package-lock.json
+++ b/web-console/package-lock.json
@@ -19,6 +19,7 @@
         "@druid-toolkit/visuals-react": "^0.3.3",
         "ace-builds": "~1.4.14",
         "axios": "^0.26.1",
+        "chronoshift": "^0.10.0",
         "classnames": "^2.2.6",
         "copy-to-clipboard": "^3.2.0",
         "core-js": "^3.10.1",
@@ -6647,6 +6648,16 @@
         "node": ">=6.0"
       }
     },
+    "node_modules/chronoshift": {
+      "version": "0.10.0",
+      "resolved": 
"https://registry.npmjs.org/chronoshift/-/chronoshift-0.10.0.tgz";,
+      "integrity": 
"sha512-dNvumPg7R6ACUOKbGo1zH6DtmTo5ut9/LNbzqaKGnpC9VdArIos8+kApHOVIZH4FCpm9M9XYh++jwlRHhc1PyA==",
+      "dependencies": {
+        "immutable-class": "^0.11.0",
+        "moment-timezone": "^0.5.26",
+        "tslib": "^2.3.1"
+      }
+    },
     "node_modules/ci-info": {
       "version": "3.3.0",
       "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.0.tgz";,
@@ -10985,6 +10996,15 @@
       "integrity": 
"sha512-0AOCmOip+xgJwEVTQj1EfiDDOkPmuyllDuTuEX+DDXUgapLAsBIfkg3sxCYyCEA8mQqZrrxPUGjcOQ2JS3WLkg==",
       "dev": true
     },
+    "node_modules/immutable-class": {
+      "version": "0.11.2",
+      "resolved": 
"https://registry.npmjs.org/immutable-class/-/immutable-class-0.11.2.tgz";,
+      "integrity": 
"sha512-CzkVPkJXzkspt6RX+ipNgtvt16+rzEBUlA3yNPLkK5/S042c9wvuyfE4F5TfMfPJ6XF86Fp+OCwu6eeAnMICuw==",
+      "dependencies": {
+        "has-own-prop": "^2.0.0",
+        "tslib": "^2.3.1"
+      }
+    },
     "node_modules/import-fresh": {
       "version": "3.3.0",
       "resolved": 
"https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz";,
@@ -16156,6 +16176,25 @@
         "mkdirp": "bin/cmd.js"
       }
     },
+    "node_modules/moment": {
+      "version": "2.29.4",
+      "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz";,
+      "integrity": 
"sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/moment-timezone": {
+      "version": "0.5.43",
+      "resolved": 
"https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.43.tgz";,
+      "integrity": 
"sha512-72j3aNyuIsDxdF1i7CEgV2FfxM1r6aaqJyLB2vwb33mXYyoyLly+F1zbWqhA3/bVIoJ4szlUoMbUnVdid32NUQ==",
+      "dependencies": {
+        "moment": "^2.29.4"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
     "node_modules/ms": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz";,
@@ -29504,6 +29543,16 @@
       "integrity": 
"sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==",
       "dev": true
     },
+    "chronoshift": {
+      "version": "0.10.0",
+      "resolved": 
"https://registry.npmjs.org/chronoshift/-/chronoshift-0.10.0.tgz";,
+      "integrity": 
"sha512-dNvumPg7R6ACUOKbGo1zH6DtmTo5ut9/LNbzqaKGnpC9VdArIos8+kApHOVIZH4FCpm9M9XYh++jwlRHhc1PyA==",
+      "requires": {
+        "immutable-class": "^0.11.0",
+        "moment-timezone": "^0.5.26",
+        "tslib": "^2.3.1"
+      }
+    },
     "ci-info": {
       "version": "3.3.0",
       "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.0.tgz";,
@@ -32887,6 +32936,15 @@
       "integrity": 
"sha512-0AOCmOip+xgJwEVTQj1EfiDDOkPmuyllDuTuEX+DDXUgapLAsBIfkg3sxCYyCEA8mQqZrrxPUGjcOQ2JS3WLkg==",
       "dev": true
     },
+    "immutable-class": {
+      "version": "0.11.2",
+      "resolved": 
"https://registry.npmjs.org/immutable-class/-/immutable-class-0.11.2.tgz";,
+      "integrity": 
"sha512-CzkVPkJXzkspt6RX+ipNgtvt16+rzEBUlA3yNPLkK5/S042c9wvuyfE4F5TfMfPJ6XF86Fp+OCwu6eeAnMICuw==",
+      "requires": {
+        "has-own-prop": "^2.0.0",
+        "tslib": "^2.3.1"
+      }
+    },
     "import-fresh": {
       "version": "3.3.0",
       "resolved": 
"https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz";,
@@ -36826,6 +36884,19 @@
         "minimist": "^1.2.5"
       }
     },
+    "moment": {
+      "version": "2.29.4",
+      "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz";,
+      "integrity": 
"sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w=="
+    },
+    "moment-timezone": {
+      "version": "0.5.43",
+      "resolved": 
"https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.43.tgz";,
+      "integrity": 
"sha512-72j3aNyuIsDxdF1i7CEgV2FfxM1r6aaqJyLB2vwb33mXYyoyLly+F1zbWqhA3/bVIoJ4szlUoMbUnVdid32NUQ==",
+      "requires": {
+        "moment": "^2.29.4"
+      }
+    },
     "ms": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz";,
diff --git a/web-console/package.json b/web-console/package.json
index 1cd3fd894ad..32fb9ea68c1 100644
--- a/web-console/package.json
+++ b/web-console/package.json
@@ -73,6 +73,7 @@
     "@druid-toolkit/visuals-react": "^0.3.3",
     "ace-builds": "~1.4.14",
     "axios": "^0.26.1",
+    "chronoshift": "^0.10.0",
     "classnames": "^2.2.6",
     "copy-to-clipboard": "^3.2.0",
     "core-js": "^3.10.1",
diff --git a/web-console/src/hooks/index.ts b/web-console/src/hooks/index.ts
index 9ffb3762551..62b22443f3b 100644
--- a/web-console/src/hooks/index.ts
+++ b/web-console/src/hooks/index.ts
@@ -24,3 +24,4 @@ export * from './use-last-defined';
 export * from './use-local-storage-state';
 export * from './use-permanent-callback';
 export * from './use-query-manager';
+export * from './use-resize-observer';
diff --git a/web-console/src/hooks/use-resize-observer.ts 
b/web-console/src/hooks/use-resize-observer.ts
new file mode 100644
index 00000000000..921edd0b8ac
--- /dev/null
+++ b/web-console/src/hooks/use-resize-observer.ts
@@ -0,0 +1,80 @@
+/*
+ * 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 { useCallback, useEffect, useState } from 'react';
+
+const emptyDOMRect = { height: 0, width: 0, x: 0, y: 0, bottom: 0, left: 0, 
right: 0, top: 0 };
+
+function createEmptyDOMRect(): DOMRect {
+  // DOMRect is not defined in JSDOM
+  return typeof DOMRect !== 'undefined'
+    ? new DOMRect()
+    : { ...emptyDOMRect, toJSON: () => emptyDOMRect };
+}
+
+export function useResizeObserver(element: HTMLElement | null | undefined) {
+  const [rect, setRect] = useState(() => createEmptyDOMRect());
+
+  const maybeUpdate = useCallback(
+    (e: Element) => {
+      const newRect: DOMRect = e.getBoundingClientRect();
+      if (
+        !rect ||
+        rect.bottom !== newRect.bottom ||
+        rect.top !== newRect.top ||
+        rect.left !== newRect.left ||
+        rect.right !== newRect.right ||
+        rect.width !== newRect.width ||
+        rect.height !== newRect.height
+      ) {
+        setRect(newRect);
+      }
+    },
+    [rect],
+  );
+
+  const onScroll = useCallback(() => {
+    if (element) {
+      maybeUpdate(element);
+    }
+  }, [element, maybeUpdate]);
+
+  useEffect(() => {
+    const resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) 
=> {
+      if (!Array.isArray(entries) || !entries.length) {
+        return;
+      }
+
+      maybeUpdate(entries[0].target);
+    });
+
+    if (element) {
+      resizeObserver.observe(element);
+      window.addEventListener('scroll', onScroll, true);
+
+      return () => {
+        resizeObserver.unobserve(element);
+        window.removeEventListener('scroll', onScroll, true);
+      };
+    }
+
+    return;
+  }, [element, maybeUpdate, onScroll]);
+
+  return rect;
+}
diff --git 
a/web-console/src/views/explore-view/droppable-container/droppable-container.scss
 
b/web-console/src/views/explore-view/droppable-container/droppable-container.scss
index a9470e7e1da..74b8a397384 100644
--- 
a/web-console/src/views/explore-view/droppable-container/droppable-container.scss
+++ 
b/web-console/src/views/explore-view/droppable-container/droppable-container.scss
@@ -20,6 +20,7 @@
 
 .droppable-container {
   position: relative;
+  overflow: hidden;
 
   &.drop-hover {
     &::after {
diff --git a/web-console/src/views/explore-view/explore-view.tsx 
b/web-console/src/views/explore-view/explore-view.tsx
index bb64492e1d6..cdc19d96d3e 100644
--- a/web-console/src/views/explore-view/explore-view.tsx
+++ b/web-console/src/views/explore-view/explore-view.tsx
@@ -29,6 +29,7 @@ import {
   useSingleHost,
 } from '@druid-toolkit/visuals-react';
 import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { useStore } from 'zustand';
 
 import { ShowValueDialog } from 
'../../dialogs/show-value-dialog/show-value-dialog';
 import { useLocalStorageState, useQueryManager } from '../../hooks';
@@ -37,6 +38,8 @@ import { deepGet, filterMap, findMap, LocalStorageKeys, 
oneOf, queryDruidSql } f
 import { ControlPane } from './control-pane/control-pane';
 import { DroppableContainer } from './droppable-container/droppable-container';
 import { FilterPane } from './filter-pane/filter-pane';
+import { HighlightBubble } from './highlight-bubble/highlight-bubble';
+import { highlightStore } from './highlight-store/highlight-store';
 import BarChartEcharts from './modules/bar-chart-echarts-module';
 import MultiAxisChartEcharts from './modules/multi-axis-chart-echarts-module';
 import PieChartEcharts from './modules/pie-chart-echarts-module';
@@ -195,6 +198,10 @@ export const ExploreView = React.memo(function 
ExploreView() {
     VISUAL_MODULES[0].moduleName,
   );
 
+  const { dropHighlight } = useStore(highlightStore);
+
+  const [timezone] = useState('Etc/UTC');
+
   const [columns, setColumns] = useState<ExpressionMeta[]>([]);
 
   const { host, where, table, visualModule, updateWhere, updateTable } = 
useSingleHost({
@@ -209,6 +216,10 @@ export const ExploreView = React.memo(function 
ExploreView() {
     },
   });
 
+  useEffect(() => {
+    host.store.setState({ context: { timezone } });
+  }, [timezone, host.store]);
+
   const { parameterValues, updateParameterValues, resetParameterValues } = 
useParameterValues({
     host,
     selectedModule: moduleName,
@@ -245,135 +256,139 @@ export const ExploreView = React.memo(function 
ExploreView() {
   const [containerRef] = useModuleContainer({ host, selectedModule: 
moduleName, columns });
 
   return (
-    <div className="explore-view">
-      <SourcePane
-        selectedTableName={table ? (table as SqlTable).getName() : '-'}
-        onSelectedTableNameChange={t => updateTable(T(t))}
-        disabled={Boolean(dataset && datasetState.loading)}
-      />
-      <FilterPane
-        ref={filterPane}
-        dataset={dataset}
-        filter={where}
-        onFilterChange={updateWhere}
-        queryDruidSql={extendedQueryDruidSql}
-      />
-      <TilePicker<ModuleType>
-        modules={VISUAL_MODULES}
-        selectedTileName={moduleName}
-        onSelectedTileNameChange={m => {
-          const currentParameterDefinitions = 
visualModule?.parameterDefinitions || {};
-          const valuesToTransfer: TransferValue[] = filterMap(
-            VISUAL_MODULES.find(vm => vm.moduleName === 
visualModule?.moduleName)?.transfer || [],
-            paramName => {
-              const parameterDefinition = 
currentParameterDefinitions[paramName];
-              if (!parameterDefinition) return;
-              const parameterValue = parameterValues[paramName];
-              if (typeof parameterValue === 'undefined') return;
-              return [parameterDefinition.type, parameterValue];
-            },
-          );
-
-          setModuleName(m);
-          resetParameterValues();
-
-          const newModuleDef = VISUAL_MODULES.find(vm => vm.moduleName === m);
-          if (newModuleDef) {
-            const newParameters: any = newModuleDef.module?.parameters || {};
-            const transferParameterValues: [name: string, value: any][] = 
filterMap(
-              newModuleDef.transfer || [],
-              t => {
-                const p = newParameters[t];
-                if (!p) return;
-                const normalizedTargetType = normalizeType(p.type);
-                const transferSource = valuesToTransfer.find(
-                  ([t]) => normalizeType(t) === normalizedTargetType,
-                );
-                if (!transferSource) return;
-                const targetValue = adjustTransferValue(
-                  transferSource[1],
-                  transferSource[0],
-                  p.type,
-                );
-                if (typeof targetValue === 'undefined') return;
-                return [t, targetValue];
+    <>
+      <div className="explore-view">
+        <SourcePane
+          selectedTableName={table ? (table as SqlTable).getName() : '-'}
+          onSelectedTableNameChange={t => updateTable(T(t))}
+          disabled={Boolean(dataset && datasetState.loading)}
+        />
+        <FilterPane
+          ref={filterPane}
+          dataset={dataset}
+          filter={where}
+          onFilterChange={updateWhere}
+          queryDruidSql={extendedQueryDruidSql}
+        />
+        <TilePicker<ModuleType>
+          modules={VISUAL_MODULES}
+          selectedTileName={moduleName}
+          onSelectedTileNameChange={m => {
+            const currentParameterDefinitions = 
visualModule?.parameterDefinitions || {};
+            const valuesToTransfer: TransferValue[] = filterMap(
+              VISUAL_MODULES.find(vm => vm.moduleName === 
visualModule?.moduleName)?.transfer || [],
+              paramName => {
+                const parameterDefinition = 
currentParameterDefinitions[paramName];
+                if (!parameterDefinition) return;
+                const parameterValue = parameterValues[paramName];
+                if (typeof parameterValue === 'undefined') return;
+                return [parameterDefinition.type, parameterValue];
               },
             );
 
-            if (transferParameterValues.length) {
-              
updateParameterValues(Object.fromEntries(transferParameterValues));
+            dropHighlight();
+            setModuleName(m);
+            resetParameterValues();
+
+            const newModuleDef = VISUAL_MODULES.find(vm => vm.moduleName === 
m);
+            if (newModuleDef) {
+              const newParameters: any = newModuleDef.module?.parameters || {};
+              const transferParameterValues: [name: string, value: any][] = 
filterMap(
+                newModuleDef.transfer || [],
+                t => {
+                  const p = newParameters[t];
+                  if (!p) return;
+                  const normalizedTargetType = normalizeType(p.type);
+                  const transferSource = valuesToTransfer.find(
+                    ([t]) => normalizeType(t) === normalizedTargetType,
+                  );
+                  if (!transferSource) return;
+                  const targetValue = adjustTransferValue(
+                    transferSource[1],
+                    transferSource[0],
+                    p.type,
+                  );
+                  if (typeof targetValue === 'undefined') return;
+                  return [t, targetValue];
+                },
+              );
+
+              if (transferParameterValues.length) {
+                
updateParameterValues(Object.fromEntries(transferParameterValues));
+              }
             }
+          }}
+          moreMenu={
+            <Menu>
+              <MenuItem
+                icon={IconNames.HISTORY}
+                text="Show query history"
+                onClick={() => {
+                  setShownText(getFormattedQueryHistory());
+                }}
+              />
+              <MenuItem
+                icon={IconNames.RESET}
+                text="Reset visualization state"
+                onClick={() => {
+                  resetParameterValues();
+                }}
+              />
+            </Menu>
           }
-        }}
-        moreMenu={
-          <Menu>
-            <MenuItem
-              icon={IconNames.HISTORY}
-              text="Show query history"
-              onClick={() => {
-                setShownText(getFormattedQueryHistory());
-              }}
-            />
-            <MenuItem
-              icon={IconNames.RESET}
-              text="Reset visualization state"
-              onClick={() => {
-                resetParameterValues();
+        />
+        <div className="resource-pane-cnt">
+          {!dataset && datasetState.loading && 'Loading...'}
+          {dataset && (
+            <ResourcePane
+              dataset={dataset}
+              onFilter={c => {
+                filterPane.current?.filterOn(c);
               }}
+              onShow={onShow}
             />
-          </Menu>
-        }
-      />
-      <div className="resource-pane-cnt">
-        {!dataset && datasetState.loading && 'Loading...'}
-        {dataset && (
-          <ResourcePane
-            dataset={dataset}
-            onFilter={c => {
-              filterPane.current?.filterOn(c);
-            }}
-            onShow={onShow}
-          />
-        )}
-      </div>
-      <DroppableContainer
-        ref={containerRef}
-        onDropColumn={column => {
-          let nextModuleName: ModuleType;
-          if (column.sqlType === 'TIMESTAMP') {
-            nextModuleName = 'time_chart_echarts';
-          } else {
-            nextModuleName = 'table_react';
-          }
+          )}
+        </div>
+        <DroppableContainer
+          ref={containerRef}
+          onDropColumn={column => {
+            let nextModuleName: ModuleType;
+            if (column.sqlType === 'TIMESTAMP') {
+              nextModuleName = 'time_chart_echarts';
+            } else {
+              nextModuleName = 'table_react';
+            }
 
-          setModuleName(nextModuleName);
+            setModuleName(nextModuleName);
 
-          if (column.sqlType === 'TIMESTAMP') {
-            resetParameterValues();
-          } else {
-            updateParameterValues({ splitColumns: [column] });
-          }
-        }}
-      />
-      <div className="control-pane-cnt">
-        {dataset && visualModule?.parameterDefinitions && (
-          <ControlPane
-            columns={dataset.columns}
-            onUpdateParameterValues={updateParameterValues}
-            parameterValues={parameterValues}
-            visualModule={visualModule}
+            if (column.sqlType === 'TIMESTAMP') {
+              resetParameterValues();
+            } else {
+              updateParameterValues({ splitColumns: [column] });
+            }
+          }}
+        />
+        <div className="control-pane-cnt">
+          {dataset && visualModule?.parameterDefinitions && (
+            <ControlPane
+              columns={dataset.columns}
+              onUpdateParameterValues={updateParameterValues}
+              parameterValues={parameterValues}
+              visualModule={visualModule}
+            />
+          )}
+        </div>
+        {shownText && (
+          <ShowValueDialog
+            title="Query history"
+            str={shownText}
+            onClose={() => {
+              setShownText(undefined);
+            }}
           />
         )}
       </div>
-      {shownText && (
-        <ShowValueDialog
-          title="Query history"
-          str={shownText}
-          onClose={() => {
-            setShownText(undefined);
-          }}
-        />
-      )}
-    </div>
+      <HighlightBubble referenceContainer={containerRef.current} />
+    </>
   );
 });
diff --git a/web-console/src/views/explore-view/filter-pane/pattern-helpers.ts 
b/web-console/src/views/explore-view/filter-pane/pattern-helpers.ts
index 00434d497a6..b927095ce81 100644
--- a/web-console/src/views/explore-view/filter-pane/pattern-helpers.ts
+++ b/web-console/src/views/explore-view/filter-pane/pattern-helpers.ts
@@ -19,6 +19,8 @@
 import type { FilterPattern } from '@druid-toolkit/query';
 import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
 
+import { DATE_FORMAT } from '../utils';
+
 export function initPatternForColumn(column: ExpressionMeta): FilterPattern {
   switch (column.sqlType) {
     case 'TIMESTAMP':
@@ -54,20 +56,7 @@ export function formatPatternWithoutNegation(pattern: 
FilterPattern): string {
       return `${pattern.column} ~ /${pattern.regexp}/`;
 
     case 'timeInterval': {
-      let startString = pattern.start.toISOString().replace(/Z$/, '');
-      let endString = pattern.end.toISOString().replace(/Z$/, '');
-
-      if (startString.endsWith('.000') && endString.endsWith('.000')) {
-        startString = startString.replace(/\.000$/, '');
-        endString = endString.replace(/\.000$/, '');
-      }
-
-      if (startString.endsWith(':00') && endString.endsWith(':00')) {
-        startString = startString.replace(/:00$/, '');
-        endString = endString.replace(/:00$/, '');
-      }
-
-      return `${startString}/${endString}`;
+      return DATE_FORMAT.formatRange(pattern.start, pattern.end);
     }
 
     case 'timeRelative':
diff --git 
a/web-console/src/views/explore-view/droppable-container/droppable-container.scss
 b/web-console/src/views/explore-view/highlight-bubble/highlight-bubble.scss
similarity index 60%
copy from 
web-console/src/views/explore-view/droppable-container/droppable-container.scss
copy to 
web-console/src/views/explore-view/highlight-bubble/highlight-bubble.scss
index a9470e7e1da..479ad15f056 100644
--- 
a/web-console/src/views/explore-view/droppable-container/droppable-container.scss
+++ b/web-console/src/views/explore-view/highlight-bubble/highlight-bubble.scss
@@ -16,22 +16,32 @@
  * limitations under the License.
  */
 
-@import '../../../variables';
+.highlight-bubble {
+  position: absolute;
+  transform: translate(-50%, -100%);
+  z-index: 10;
+  min-width: 200px;
+  max-width: 500px;
 
-.droppable-container {
-  position: relative;
+  // shpitz
+  &:after {
+    content: ' ';
+    position: absolute;
+    left: 50%;
+    transform: translate(-50%, 0);
+    bottom: -15px;
+    border-top: 15px solid #2f344e;
+    border-right: 15px solid transparent;
+    border-left: 15px solid transparent;
+    border-bottom: none;
+  }
+
+  .button-group {
+    margin-top: 20px;
+    text-align: right;
 
-  &.drop-hover {
-    &::after {
-      position: absolute;
-      top: 0;
-      left: 0;
-      right: 0;
-      bottom: 0;
-      pointer-events: none;
-      border: 1px solid $druid-brand;
-      border-radius: 3px;
-      content: '';
+    > *:not(:last-child) {
+      margin-right: 10px;
     }
   }
 }
diff --git 
a/web-console/src/views/explore-view/highlight-bubble/highlight-bubble.tsx 
b/web-console/src/views/explore-view/highlight-bubble/highlight-bubble.tsx
new file mode 100644
index 00000000000..cf26029957c
--- /dev/null
+++ b/web-console/src/views/explore-view/highlight-bubble/highlight-bubble.tsx
@@ -0,0 +1,80 @@
+/*
+ * 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 { Button, Card, Elevation } from '@blueprintjs/core';
+import React, { useCallback, useEffect, useMemo } from 'react';
+import { createPortal } from 'react-dom';
+import { useStore } from 'zustand';
+
+import { useResizeObserver } from '../../../hooks';
+import { highlightStore } from '../highlight-store/highlight-store';
+
+import './highlight-bubble.scss';
+
+export const HighlightBubble = (props: { referenceContainer: HTMLDivElement | 
null }) => {
+  const { referenceContainer } = props;
+
+  const { highlight } = useStore(highlightStore);
+
+  const { left, top } = useResizeObserver(referenceContainer);
+
+  // drop highlight on ESC and save it on ENTER
+  useEffect(() => {
+    const handleKeyDown = (event: KeyboardEvent) => {
+      if (event.key === 'Escape') {
+        highlight?.onDrop();
+      }
+
+      if (event.key === 'Enter') {
+        highlight?.onSave?.(highlight);
+      }
+    };
+
+    document.addEventListener('keydown', handleKeyDown);
+
+    return () => {
+      document.removeEventListener('keydown', handleKeyDown);
+    };
+  }, [highlight]);
+
+  const style = useMemo(() => {
+    if (!highlight) return {};
+    return { left: left + highlight.x, top: top + highlight.y };
+  }, [left, top, highlight]);
+
+  const onSave = useCallback(() => {
+    highlight?.onSave?.(highlight);
+  }, [highlight]);
+
+  if (!highlight) return null;
+
+  return createPortal(
+    <Card elevation={Elevation.TWO} className="highlight-bubble" style={style}>
+      <div className="label">{highlight.label}</div>
+      <div className="button-group">
+        <Button
+          intent="none"
+          text={highlight.onSave ? 'Cancel' : 'Close'}
+          onClick={highlight.onDrop}
+        />
+        {highlight.onSave && <Button intent="primary" text="Filter" 
onClick={onSave} />}
+      </div>
+    </Card>,
+    document.body,
+  );
+};
diff --git 
a/web-console/src/views/explore-view/highlight-store/highlight-store.ts 
b/web-console/src/views/explore-view/highlight-store/highlight-store.ts
new file mode 100644
index 00000000000..4de8c4bd7b5
--- /dev/null
+++ b/web-console/src/views/explore-view/highlight-store/highlight-store.ts
@@ -0,0 +1,105 @@
+/*
+ * 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 { createStore } from 'zustand';
+
+interface Highlight {
+  /**
+   * The label of the highlight.
+   */
+  label: string;
+
+  /**
+   * The x coordinate of the highlight.
+   */
+  x: number;
+
+  /**
+   * The y coordinate of the highlight.
+   */
+  y: number;
+
+  /**
+   * Optional x offset for the highlight (useful for scrolling offset)
+   */
+  offsetX?: number;
+
+  /**
+   * Optional y offset for the highlight (useful for scrolling offset)
+   */
+  offsetY?: number;
+
+  /**
+   * Called when the highlight is dropped (when the "cancel" button is clicked)
+   */
+  onDrop: () => void;
+
+  /**
+   * Called when the highlight is saved (when the "save" button is clicked)
+   * @param highlight The highlight to save
+   */
+  onSave?: (highlight: Highlight) => void;
+
+  /**
+   * Optional data attached to the highlight.
+   */
+  data?: any;
+}
+
+interface HighlightState {
+  /**
+   * The current highlight.
+   */
+  highlight: Highlight | undefined;
+
+  /**
+   * Sets the highlight.
+   * @param highlight the highlight to set
+   */
+  setHighlight: (highlight: Highlight) => void;
+
+  /**
+   * Drops the highlight.
+   */
+  dropHighlight: () => void;
+
+  /**
+   * Updates the highlight.
+   * @param highlight the highlight to update (only the properties to update)
+   * @returns the updated highlight, or undefined if there's no highlight in 
the store
+   */
+  updateHighlight: (highlight: Partial<Highlight>) => Highlight | undefined;
+}
+
+/**
+ * A lightweight store for the highlight.
+ */
+export const highlightStore = createStore<HighlightState>((set, get) => ({
+  highlight: undefined,
+  setHighlight: highlight => set({ highlight }),
+  dropHighlight: () => set({ highlight: undefined }),
+  updateHighlight: highlight => {
+    set(state => {
+      if (!state.highlight) return state;
+
+      return { highlight: { ...state.highlight, ...highlight } };
+    });
+
+    return get().highlight;
+  },
+}));
diff --git 
a/web-console/src/views/explore-view/modules/bar-chart-echarts-module.ts 
b/web-console/src/views/explore-view/modules/bar-chart-echarts-module.ts
index 27fc28d6ca0..2e3bf8681d6 100644
--- a/web-console/src/views/explore-view/modules/bar-chart-echarts-module.ts
+++ b/web-console/src/views/explore-view/modules/bar-chart-echarts-module.ts
@@ -20,6 +20,7 @@ import { C, SqlExpression } from '@druid-toolkit/query';
 import { typedVisualModule } from '@druid-toolkit/visuals-core';
 import * as echarts from 'echarts';
 
+import { highlightStore } from '../highlight-store/highlight-store';
 import { getInitQuery } from '../utils';
 
 export default typedVisualModule({
@@ -56,7 +57,7 @@ export default typedVisualModule({
       },
     },
   },
-  module: ({ container, host, getLastUpdateEvent, updateWhere }) => {
+  module: ({ container, host, updateWhere }) => {
     const { sqlQuery } = host;
     const myChart = echarts.init(container, 'dark');
 
@@ -83,26 +84,12 @@ export default typedVisualModule({
       ],
     });
 
-    const resizeHandler = () => {
-      myChart.resize();
-    };
-
-    window.addEventListener('resize', resizeHandler);
-
-    myChart.on('click', 'series', p => {
-      const lastUpdateEvent = getLastUpdateEvent();
-      if (!lastUpdateEvent?.parameterValues.splitColumn) return;
-
-      updateWhere(
-        lastUpdateEvent.where.toggleClauseInWhere(
-          C(lastUpdateEvent.parameterValues.splitColumn.name).equal(p.name),
-        ),
-      );
-    });
-
     return {
       async update({ table, where, parameterValues }) {
         const { splitColumn, metric, metricToSort, limit } = parameterValues;
+
+        myChart.off('click');
+
         if (!splitColumn) return;
 
         const v = await sqlQuery(
@@ -122,9 +109,45 @@ export default typedVisualModule({
             source: v.toObjectArray(),
           },
         });
+
+        myChart.on('click', 'series', p => {
+          const { dim, met } = p.data as any;
+
+          const [x, y] = myChart.convertToPixel({ seriesIndex: 0 }, [dim, 
met]);
+
+          highlightStore.getState().setHighlight({
+            label: p.name,
+            x,
+            y: y - 20,
+            data: [dim, met],
+            onDrop: () => {
+              highlightStore.getState().dropHighlight();
+            },
+            onSave: () => {
+              
updateWhere(where.toggleClauseInWhere(C(splitColumn.name).equal(p.name)));
+              highlightStore.getState().dropHighlight();
+            },
+          });
+        });
+      },
+
+      resize() {
+        myChart.resize();
+
+        // if there is a highlight, update its x position
+        // by calculating new pixel position from the highlight's data
+        const highlight = highlightStore.getState().highlight;
+        if (highlight) {
+          const [x, y] = myChart.convertToPixel({ seriesIndex: 0 }, 
highlight.data as number[]);
+
+          highlightStore.getState().updateHighlight({
+            x,
+            y: y - 20,
+          });
+        }
       },
+
       destroy() {
-        window.removeEventListener('resize', resizeHandler);
         myChart.dispose();
       },
     };
diff --git 
a/web-console/src/views/explore-view/modules/multi-axis-chart-echarts-module.ts 
b/web-console/src/views/explore-view/modules/multi-axis-chart-echarts-module.ts
index 13ca24d3992..c315b4c9444 100644
--- 
a/web-console/src/views/explore-view/modules/multi-axis-chart-echarts-module.ts
+++ 
b/web-console/src/views/explore-view/modules/multi-axis-chart-echarts-module.ts
@@ -20,16 +20,18 @@ import { C, F, L, SqlExpression } from 
'@druid-toolkit/query';
 import { typedVisualModule } from '@druid-toolkit/visuals-core';
 import * as echarts from 'echarts';
 
-import { getInitQuery } from '../utils';
+import { highlightStore } from '../highlight-store/highlight-store';
+import { DATE_FORMAT, getAutoGranularity, getInitQuery, snapToGranularity } 
from '../utils';
 
 export default typedVisualModule({
   parameters: {
     timeGranularity: {
       type: 'option',
-      options: ['PT1M', 'PT5M', 'PT30M', 'PT1H', 'P1D'],
-      default: 'PT1H',
+      options: ['auto', 'PT1M', 'PT5M', 'PT30M', 'PT1H', 'P1D'],
+      default: 'auto',
       control: {
         optionLabels: {
+          auto: 'Auto',
           PT1M: 'Minute',
           PT5M: '5 minutes',
           PT30M: '30 minutes',
@@ -49,7 +51,7 @@ export default typedVisualModule({
       },
     },
   },
-  module: ({ container, host }) => {
+  module: ({ container, host, updateWhere }) => {
     const myChart = echarts.init(container, 'dark');
 
     myChart.setOption({
@@ -70,6 +72,10 @@ export default typedVisualModule({
           saveAsImage: {},
         },
       },
+      brush: {
+        toolbox: ['lineX'],
+        xAxisIndex: 0,
+      },
       grid: {
         left: '3%',
         right: '4%',
@@ -89,15 +95,23 @@ export default typedVisualModule({
       ],
     });
 
-    const resizeHandler = () => {
-      myChart.resize();
-    };
-
-    window.addEventListener('resize', resizeHandler);
+    // auto-enables the brush tool on load
+    myChart.dispatchAction({
+      type: 'takeGlobalCursor',
+      key: 'brush',
+      brushOption: {
+        brushType: 'lineX',
+      },
+    });
 
     return {
-      async update({ table, where, parameterValues }) {
-        const { timeGranularity, metrics } = parameterValues;
+      async update({ table, where, parameterValues, context }) {
+        const { metrics } = parameterValues;
+
+        const timeGranularity =
+          parameterValues.timeGranularity === 'auto'
+            ? getAutoGranularity(where, '__time')
+            : parameterValues.timeGranularity;
 
         const dataset = (
           await host.sqlQuery(
@@ -145,9 +159,106 @@ export default typedVisualModule({
             replaceMerge: ['yAxis', 'series'],
           },
         );
+
+        myChart.on('brush', (params: any) => {
+          if (!params.areas.length) return;
+
+          // this is only used for the label and the data saved in the 
highlight
+          // the positioning is done with the true coordinates until the user
+          // releases the mouse button (in the `brushend` event)
+          const { start, end } = snapToGranularity(
+            params.areas[0].coordRange[0],
+            params.areas[0].coordRange[1],
+            timeGranularity,
+            context.timezone,
+          );
+
+          const x0 = myChart.convertToPixel({ xAxisIndex: 0 }, 
params.areas[0].coordRange[0]);
+          const x1 = myChart.convertToPixel({ xAxisIndex: 0 }, 
params.areas[0].coordRange[1]);
+
+          highlightStore.getState().setHighlight({
+            label: DATE_FORMAT.formatRange(start, end),
+            x: x0 + (x1 - x0) / 2,
+            y: 40,
+            data: { start, end },
+            onDrop: () => {
+              highlightStore.getState().dropHighlight();
+              myChart.dispatchAction({
+                type: 'brush',
+                command: 'clear',
+                areas: [],
+              });
+            },
+            onSave: () => {
+              updateWhere(
+                where.changeClauseInWhere(
+                  SqlExpression.parse(
+                    `TIME_IN_INTERVAL(${C(
+                      '__time',
+                    )}, '${start.toISOString()}/${end.toISOString()}')`,
+                  ),
+                ) as SqlExpression,
+              );
+              highlightStore.getState().dropHighlight();
+              myChart.dispatchAction({
+                type: 'brush',
+                command: 'clear',
+                areas: [],
+              });
+            },
+          });
+        });
+
+        // once the user is done selecting a range, this will snap the start 
and end
+        myChart.on('brushend', () => {
+          const highlight = highlightStore.getState().highlight;
+          if (!highlight) return;
+
+          // this is already snapped
+          const { start, end } = highlight.data;
+
+          const x0 = myChart.convertToPixel({ xAxisIndex: 0 }, start);
+          const x1 = myChart.convertToPixel({ xAxisIndex: 0 }, end);
+
+          // positions the bubble on the snapped start and end
+          highlightStore.getState().updateHighlight({
+            x: x0 + (x1 - x0) / 2,
+          });
+
+          // gives the chart the snapped range to highlight
+          // (will replace the area the user just selected)
+          myChart.dispatchAction({
+            type: 'brush',
+            areas: [
+              {
+                brushType: 'lineX',
+                coordRange: [start, end],
+                xAxisIndex: 0,
+              },
+            ],
+          });
+        });
       },
+
+      resize() {
+        myChart.resize();
+
+        // if there is a highlight, update its x position
+        // by calculating new pixel position from the highlight's data
+        const highlight = highlightStore.getState().highlight;
+        if (highlight) {
+          const { start, end } = highlight.data;
+
+          const x0 = myChart.convertToPixel({ xAxisIndex: 0 }, start);
+          const x1 = myChart.convertToPixel({ xAxisIndex: 0 }, end);
+
+          highlightStore.getState().updateHighlight({
+            x: x0 + (x1 - x0) / 2,
+          });
+        }
+      },
+
       destroy() {
-        window.removeEventListener('resize', resizeHandler);
         myChart.dispose();
       },
     };
diff --git 
a/web-console/src/views/explore-view/modules/pie-chart-echarts-module.ts 
b/web-console/src/views/explore-view/modules/pie-chart-echarts-module.ts
index 825be93bb99..d9aedb16082 100644
--- a/web-console/src/views/explore-view/modules/pie-chart-echarts-module.ts
+++ b/web-console/src/views/explore-view/modules/pie-chart-echarts-module.ts
@@ -20,8 +20,29 @@ import { C, SqlExpression } from '@druid-toolkit/query';
 import { typedVisualModule } from '@druid-toolkit/visuals-core';
 import * as echarts from 'echarts';
 
+import { highlightStore } from '../highlight-store/highlight-store';
 import { getInitQuery } from '../utils';
 
+/**
+ * Returns the cartesian coordinates of a pie slice external centroid
+ */
+function getCentroid(chart: echarts.ECharts, dataIndex: number) {
+  // see these underscores everywhere? that's because those are private 
properties
+  // I have no real choice but to use them, because there is no public API for 
this (on pie charts)
+  // #no_ragrets
+  const layout = (chart as 
any)._chartsViews?.[0]?._data?._itemLayouts?.[dataIndex];
+
+  if (!layout) return;
+
+  const { cx, cy, startAngle, endAngle, r } = layout;
+  const angle = (startAngle + endAngle) / 2;
+
+  const x = cx + Math.cos(angle) * r;
+  const y = cy + Math.sin(angle) * r;
+
+  return { x, y };
+}
+
 export default typedVisualModule({
   parameters: {
     splitColumn: {
@@ -54,7 +75,7 @@ export default typedVisualModule({
       control: { label: 'Show others' },
     },
   },
-  module: ({ container, host, getLastUpdateEvent, updateWhere }) => {
+  module: ({ container, host, updateWhere }) => {
     const myChart = echarts.init(container, 'dark');
 
     myChart.setOption({
@@ -68,6 +89,7 @@ export default typedVisualModule({
       series: [
         {
           type: 'pie',
+          id: 'hello',
           radius: '50%',
           data: [],
           emphasis: {
@@ -81,29 +103,14 @@ export default typedVisualModule({
       ],
     });
 
-    const resizeHandler = () => {
-      myChart.resize();
-    };
-
-    window.addEventListener('resize', resizeHandler);
-
-    myChart.on('click', 'series', p => {
-      const lastUpdateEvent = getLastUpdateEvent();
-      if (!lastUpdateEvent?.parameterValues.splitColumn) return;
-
-      updateWhere(
-        lastUpdateEvent.where.toggleClauseInWhere(
-          C(lastUpdateEvent.parameterValues.splitColumn.name).equal(p.name),
-        ),
-      );
-    });
-
     return {
       async update({ table, where, parameterValues }) {
         const { splitColumn, metric, limit } = parameterValues;
 
         if (!splitColumn) return;
 
+        myChart.off('click');
+
         const result = await host.sqlQuery(
           getInitQuery(table, where)
             .addSelect(splitColumn.expression.as('name'), { addToGroupBy: 
'end' })
@@ -118,25 +125,77 @@ export default typedVisualModule({
 
         if (parameterValues.showOthers) {
           const others = await host.sqlQuery(
-            getInitQuery(table, where)
-              .addSelect(metric.expression.as('value'))
-              
.addWhere(C(splitColumn.name).notIn(result.getColumnByIndex(0)!)),
+            getInitQuery(
+              table,
+              where.changeClauseInWhere(
+                C(splitColumn.name).notIn(result.getColumnByIndex(0)!),
+              ) as SqlExpression,
+            ).addSelect(metric.expression.as('value')),
           );
 
-          data.push({ name: 'Others', value: others.rows[0][0] });
+          data.push({ name: 'Others', value: others.rows[0][0], __isOthers: 
true });
         }
 
         myChart.setOption({
           series: [
             {
-              name: metric.name,
+              id: 'hello',
               data,
             },
           ],
         });
+
+        myChart.on('click', 'series', p => {
+          if (highlightStore.getState().highlight?.data.name === p.name) {
+            highlightStore.getState().dropHighlight();
+            return;
+          }
+
+          const centroid = getCentroid(myChart, p.dataIndex);
+
+          if (!centroid) return;
+
+          const { name, value, __isOthers } = p.data as any;
+
+          highlightStore.getState().setHighlight({
+            label: name + ': ' + value,
+            x: centroid.x,
+            y: centroid.y - 20,
+            data: { name, value, dataIndex: p.dataIndex },
+            onDrop: () => {
+              highlightStore.getState().dropHighlight();
+            },
+            onSave: __isOthers
+              ? undefined
+              : () => {
+                  
updateWhere(where.toggleClauseInWhere(C(splitColumn.name).equal(name)));
+                  highlightStore.getState().dropHighlight();
+                },
+          });
+        });
+      },
+
+      resize() {
+        myChart.resize();
+
+        // if there is a highlight, update its x position
+        // by calculating new pixel position from the highlight's data
+        const highlight = highlightStore.getState().highlight;
+        if (highlight) {
+          const { dataIndex } = highlight.data;
+
+          const centroid = getCentroid(myChart, dataIndex);
+
+          if (!centroid) return;
+
+          highlightStore.getState().updateHighlight({
+            x: centroid.x,
+            y: centroid.y - 20,
+          });
+        }
       },
+
       destroy() {
-        window.removeEventListener('resize', resizeHandler);
         myChart.dispose();
       },
     };
diff --git 
a/web-console/src/views/explore-view/modules/time-chart-echarts-module.ts 
b/web-console/src/views/explore-view/modules/time-chart-echarts-module.ts
index d0f7ade6ba4..4f1322bf002 100644
--- a/web-console/src/views/explore-view/modules/time-chart-echarts-module.ts
+++ b/web-console/src/views/explore-view/modules/time-chart-echarts-module.ts
@@ -20,7 +20,8 @@ import { C, F, L, SqlCase, SqlExpression } from 
'@druid-toolkit/query';
 import { typedVisualModule } from '@druid-toolkit/visuals-core';
 import * as echarts from 'echarts';
 
-import { getInitQuery } from '../utils';
+import { highlightStore } from '../highlight-store/highlight-store';
+import { DATE_FORMAT, getAutoGranularity, getInitQuery, snapToGranularity } 
from '../utils';
 
 const TIME_NAME = '__t__';
 const METRIC_NAME = '__met__';
@@ -50,10 +51,11 @@ export default typedVisualModule({
   parameters: {
     timeGranularity: {
       type: 'option',
-      options: ['PT1M', 'PT5M', 'PT30M', 'PT1H', 'P1D'],
-      default: 'PT1H',
+      options: ['auto', 'PT1M', 'PT5M', 'PT30M', 'PT1H', 'P1D'],
+      default: 'auto',
       control: {
         optionLabels: {
+          auto: 'Auto',
           PT1M: 'Minute',
           PT5M: '5 minutes',
           PT30M: '30 minutes',
@@ -96,6 +98,13 @@ export default typedVisualModule({
         // transferGroup: 'show-agg',
       },
     },
+    snappyHighlight: {
+      type: 'boolean',
+      default: true,
+      control: {
+        label: 'Snap highlight to nearest dates',
+      },
+    },
   },
   module: ({ container, host, updateWhere }) => {
     const myChart = echarts.init(container, 'dark');
@@ -147,16 +156,28 @@ export default typedVisualModule({
       series: [],
     });
 
-    const resizeHandler = () => {
-      myChart.resize();
-    };
-
-    window.addEventListener('resize', resizeHandler);
+    // auto-enables the brush tool on load
+    myChart.dispatchAction({
+      type: 'takeGlobalCursor',
+      key: 'brush',
+      brushOption: {
+        brushType: 'lineX',
+      },
+    });
 
     return {
-      async update({ table, where, parameterValues }) {
-        const { splitColumn, metric, numberToStack, showOthers, 
timeGranularity } = parameterValues;
+      async update({ table, where, parameterValues, context }) {
+        const { splitColumn, metric, numberToStack, showOthers, 
snappyHighlight } = parameterValues;
+
+        // this should probably be a parameter
+        const timeColumnName = '__time';
 
+        const timeGranularity =
+          parameterValues.timeGranularity === 'auto'
+            ? getAutoGranularity(where, timeColumnName)
+            : parameterValues.timeGranularity;
+
+        myChart.off('brush');
         myChart.off('brushend');
 
         const vs = splitColumn
@@ -176,7 +197,7 @@ export default typedVisualModule({
               table,
               splitColumn && vs && !showOthers ? 
where.and(splitColumn.expression.in(vs)) : where,
             )
-              .addSelect(F.timeFloor(C('__time'), 
L(timeGranularity)).as(TIME_NAME), {
+              .addSelect(F.timeFloor(C(timeColumnName), 
L(timeGranularity)).as(TIME_NAME), {
                 addToGroupBy: 'end',
                 addToOrderBy: 'end',
                 direction: 'ASC',
@@ -198,25 +219,84 @@ export default typedVisualModule({
         const effectiveVs = vs && showOthers ? vs.concat(OTHERS_VALUE) : vs;
         const sourceData = effectiveVs ? transformData(dataset, effectiveVs) : 
dataset;
 
-        myChart.on('brushend', (params: any) => {
+        myChart.on('brush', (params: any) => {
           if (!params.areas.length) return;
 
-          const [start, end] = params.areas[0].coordRange;
+          // this is only used for the label and the data saved in the 
highlight
+          // the positioning is done with the true coordinates until the user
+          // releases the mouse button (in the `brushend` event)
+          const { start, end } = snappyHighlight
+            ? snapToGranularity(
+                params.areas[0].coordRange[0],
+                params.areas[0].coordRange[1],
+                timeGranularity,
+                context.timezone,
+              )
+            : { start: params.areas[0].coordRange[0], end: 
params.areas[0].coordRange[1] };
 
-          updateWhere(
-            where.changeClauseInWhere(
-              SqlExpression.parse(
-                `TIME_IN_INTERVAL(${C('__time')}, '${new 
Date(start).toISOString()}/${new Date(
-                  end,
-                ).toISOString()}')`,
-              ),
-            ),
-          );
+          const x0 = myChart.convertToPixel({ xAxisIndex: 0 }, 
params.areas[0].coordRange[0]);
+          const x1 = myChart.convertToPixel({ xAxisIndex: 0 }, 
params.areas[0].coordRange[1]);
 
+          highlightStore.getState().setHighlight({
+            label: DATE_FORMAT.formatRange(start, end),
+            x: x0 + (x1 - x0) / 2,
+            y: 40,
+            data: { start, end },
+            onDrop: () => {
+              highlightStore.getState().dropHighlight();
+              myChart.dispatchAction({
+                type: 'brush',
+                command: 'clear',
+                areas: [],
+              });
+            },
+            onSave: () => {
+              updateWhere(
+                where.changeClauseInWhere(
+                  SqlExpression.parse(
+                    `TIME_IN_INTERVAL(${C(
+                      timeColumnName,
+                    )}, '${start.toISOString()}/${end.toISOString()}')`,
+                  ),
+                ) as SqlExpression,
+              );
+              highlightStore.getState().dropHighlight();
+              myChart.dispatchAction({
+                type: 'brush',
+                command: 'clear',
+                areas: [],
+              });
+            },
+          });
+        });
+
+        // once the user is done selecting a range, this will snap the start 
and end
+        myChart.on('brushend', () => {
+          const highlight = highlightStore.getState().highlight;
+          if (!highlight) return;
+
+          // this is already snapped
+          const { start, end } = highlight.data;
+
+          const x0 = myChart.convertToPixel({ xAxisIndex: 0 }, start);
+          const x1 = myChart.convertToPixel({ xAxisIndex: 0 }, end);
+
+          // positions the bubble on the snapped start and end
+          highlightStore.getState().updateHighlight({
+            x: x0 + (x1 - x0) / 2,
+          });
+
+          // gives the chart the snapped range to highlight
+          // (will replace the area the user just selected)
           myChart.dispatchAction({
             type: 'brush',
-            command: 'clear',
-            areas: [],
+            areas: [
+              {
+                brushType: 'lineX',
+                coordRange: [start, end],
+                xAxisIndex: 0,
+              },
+            ],
           });
         });
 
@@ -258,8 +338,26 @@ export default typedVisualModule({
           },
         );
       },
+
+      resize() {
+        myChart.resize();
+
+        // if there is a highlight, update its x position
+        // by calculating new pixel position from the highlight's data
+        const highlight = highlightStore.getState().highlight;
+        if (highlight) {
+          const { start, end } = highlight.data;
+
+          const x0 = myChart.convertToPixel({ xAxisIndex: 0 }, start);
+          const x1 = myChart.convertToPixel({ xAxisIndex: 0 }, end);
+
+          highlightStore.getState().updateHighlight({
+            x: x0 + (x1 - x0) / 2,
+          });
+        }
+      },
+
       destroy() {
-        window.removeEventListener('resize', resizeHandler);
         myChart.dispose();
       },
     };
diff --git a/web-console/src/hooks/index.ts 
b/web-console/src/views/explore-view/utils/date-format.ts
similarity index 73%
copy from web-console/src/hooks/index.ts
copy to web-console/src/views/explore-view/utils/date-format.ts
index 9ffb3762551..17d13c58952 100644
--- a/web-console/src/hooks/index.ts
+++ b/web-console/src/views/explore-view/utils/date-format.ts
@@ -16,11 +16,12 @@
  * limitations under the License.
  */
 
-export * from './use-clock';
-export * from './use-constant';
-export * from './use-global-event-listener';
-export * from './use-interval';
-export * from './use-last-defined';
-export * from './use-local-storage-state';
-export * from './use-permanent-callback';
-export * from './use-query-manager';
+export const DATE_FORMAT = new Intl.DateTimeFormat('default', {
+  year: 'numeric',
+  month: '2-digit',
+  day: '2-digit',
+  hour: '2-digit',
+  minute: '2-digit',
+  second: '2-digit',
+  hour12: false,
+});
diff --git a/web-console/src/views/explore-view/utils/get-auto-granularity.ts 
b/web-console/src/views/explore-view/utils/get-auto-granularity.ts
new file mode 100644
index 00000000000..f8928f3b86b
--- /dev/null
+++ b/web-console/src/views/explore-view/utils/get-auto-granularity.ts
@@ -0,0 +1,82 @@
+/*
+ * 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 type { SqlExpression } from '@druid-toolkit/query';
+import { fitFilterPattern, SqlMulti } from '@druid-toolkit/query';
+import { day, Duration, hour } from 'chronoshift';
+
+function getCanonicalDuration(
+  expression: SqlExpression,
+  timeColumnName: string,
+): number | undefined {
+  const pattern = fitFilterPattern(expression);
+  if ('column' in pattern && pattern.column !== timeColumnName) return 
undefined;
+
+  switch (pattern.type) {
+    case 'timeInterval':
+      return pattern.end.valueOf() - pattern.start.valueOf();
+
+    case 'timeRelative':
+      return Duration.fromJS(pattern.rangeDuration).getCanonicalLength();
+
+    case 'custom':
+      if (pattern.expression instanceof SqlMulti) {
+        for (const value of pattern.expression.args.values) {
+          const canonicalDuration = getCanonicalDuration(value, 
timeColumnName);
+          if (canonicalDuration !== undefined) return canonicalDuration;
+        }
+      }
+
+      break;
+
+    default:
+      break;
+  }
+
+  return undefined;
+}
+
+const DEFAULT_GRANULARITY = 'PT1H';
+
+/**
+ * Computes the granularity string from a where clause. If the where clause is 
TRUE, the default
+ * granularity is returned (PT1H). Otherwise, the granularity is computed from 
the time column and the
+ * duration of the where clause.
+ *
+ * @param where the where SQLExpression to read from
+ * @param timeColumnName the name of the time column (any other time column 
will be ignored)
+ * @returns the granularity string (default is PT1H)
+ */
+export function getAutoGranularity(where: SqlExpression, timeColumnName: 
string): string {
+  if (where.toString() === 'TRUE') return DEFAULT_GRANULARITY;
+
+  const canonicalDuration = getCanonicalDuration(where, timeColumnName);
+
+  if (canonicalDuration) {
+    if (canonicalDuration > day.canonicalLength * 95) return 'P1W';
+    if (canonicalDuration > day.canonicalLength * 8) return 'P1D';
+    if (canonicalDuration > hour.canonicalLength * 8) return 'PT1H';
+    if (canonicalDuration > hour.canonicalLength * 3) return 'PT5M';
+
+    return 'PT1M';
+  }
+
+  console.debug('Unable to determine granularity from where clause', 
where.toString());
+
+  return DEFAULT_GRANULARITY;
+}
diff --git a/web-console/src/hooks/index.ts 
b/web-console/src/views/explore-view/utils/index.ts
similarity index 73%
copy from web-console/src/hooks/index.ts
copy to web-console/src/views/explore-view/utils/index.ts
index 9ffb3762551..4e1338a8f76 100644
--- a/web-console/src/hooks/index.ts
+++ b/web-console/src/views/explore-view/utils/index.ts
@@ -16,11 +16,7 @@
  * limitations under the License.
  */
 
-export * from './use-clock';
-export * from './use-constant';
-export * from './use-global-event-listener';
-export * from './use-interval';
-export * from './use-last-defined';
-export * from './use-local-storage-state';
-export * from './use-permanent-callback';
-export * from './use-query-manager';
+export * from './date-format';
+export * from './get-auto-granularity';
+export * from './misc';
+export * from './snap-to-granularity';
diff --git a/web-console/src/views/explore-view/utils.ts 
b/web-console/src/views/explore-view/utils/misc.ts
similarity index 98%
rename from web-console/src/views/explore-view/utils.ts
rename to web-console/src/views/explore-view/utils/misc.ts
index 45d05749159..69946dd99d5 100644
--- a/web-console/src/views/explore-view/utils.ts
+++ b/web-console/src/views/explore-view/utils/misc.ts
@@ -21,7 +21,7 @@ import { SqlQuery } from '@druid-toolkit/query';
 import type { ExpressionMeta } from '@druid-toolkit/visuals-core';
 import type { ParameterDefinition } from 
'@druid-toolkit/visuals-core/src/models/parameter';
 
-import { nonEmptyArray } from '../../utils';
+import { nonEmptyArray } from '../../../utils';
 
 export interface Dataset {
   table: SqlTable;
diff --git a/web-console/src/views/explore-view/utils/snap-to-granularity.ts 
b/web-console/src/views/explore-view/utils/snap-to-granularity.ts
new file mode 100644
index 00000000000..15b0cce0351
--- /dev/null
+++ b/web-console/src/views/explore-view/utils/snap-to-granularity.ts
@@ -0,0 +1,57 @@
+/*
+ * 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 { Duration, Timezone } from 'chronoshift';
+
+/**
+ * Will try to snap start and end to the closest available dates, given the 
granularity.
+ *
+ * @param start the start date
+ * @param end the end date
+ * @param granularity the granularity
+ * @param timezone the timezone
+ * @returns an object with the start and end dates snapped to the given 
granularity
+ */
+export function snapToGranularity(
+  start: Date,
+  end: Date,
+  granularity: string,
+  timezone?: string,
+): { start: Date; end: Date } {
+  const tz = Timezone.fromJS(timezone || 'Etc/UTC');
+  const duration = Duration.fromJS(granularity);
+
+  // get closest to start
+  const flooredStart = duration.floor(start, tz);
+  const ceiledStart = duration.shift(flooredStart, tz, 1);
+  const distanceToFlooredStart = Math.abs(start.valueOf() - 
flooredStart.valueOf());
+  const distanceToCeiledStart = Math.abs(start.valueOf() - 
ceiledStart.valueOf());
+  const closestStart = distanceToFlooredStart < distanceToCeiledStart ? 
flooredStart : ceiledStart;
+
+  // get closest to end
+  const flooredEnd = duration.floor(end, tz);
+  const ceiledEnd = duration.shift(flooredEnd, tz, 1);
+  const distanceToFlooredEnd = Math.abs(end.valueOf() - flooredEnd.valueOf());
+  const distanceToCeiledEnd = Math.abs(end.valueOf() - ceiledEnd.valueOf());
+  const closestEnd = distanceToFlooredEnd < distanceToCeiledEnd ? flooredEnd : 
ceiledEnd;
+
+  return {
+    start: closestStart,
+    end: closestEnd,
+  };
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to