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]