From 432f2a558ae86f7ed77857794131667b0e67095b Mon Sep 17 00:00:00 2001 From: Audrey Li Date: Mon, 15 Jul 2019 17:09:42 -0700 Subject: [PATCH 1/2] Update packages/webviz-core from internal repo Changelog: ImagePanel - Image panel now supports mono8/8UC1 and mono16/16UC1 grayscale images. - Image markers can now be used without CameraInfo, as long as image scale is set to 100%. - Added support for more Bayer encodings: BGGR, GBRG, and GRBG. - Image panel now displays an error message when the image encoding is unsupported. Plot Panel - The Plot panel now supports filtering out whole messages, such as /abc{node_name=="foo"}.cpu_elapsed_ns. See the Topic path syntax help page for more info. (Support for this feature in Raw Messages is still a work in progress, coming soon.) - The Plot panel now allows you to add a number to show a horizontal reference line. - The Plot panel settings now has an option to lock y-axis so that the graph won't grow to fit the data when min and max changes. Other - Control+O or Command+O keyboard shortcut can be used to open bag files. - Webviz now displays dragged-in bags in Connection Picker. - Added more options for playback speed. - Added negative integers in topic filtering. - Added copy button to Rosout. --- packages/webviz-core/package-lock.json | 53 +++ packages/webviz-core/package.json | 3 + packages/webviz-core/src/actions/panels.js | 18 +- packages/webviz-core/src/components/Button.js | 2 +- .../webviz-core/src/components/CopyText.js | 7 +- .../src/components/DocumentDropListener.js | 2 +- .../src/components/ErrorDisplay.js | 23 +- .../src/components/ErrorDisplay.stories.js | 14 + .../src/components/ExpandingToolbar.js | 109 ++--- .../components/ExpandingToolbar.module.scss | 2 +- .../webviz-core/src/components/IconButton.js | 29 ++ .../webviz-core/src/components/JsonInput.js | 77 --- .../webviz-core/src/components/LargeList.js | 28 +- .../MessageHistory/MessageHistoryInput.js | 71 ++- .../addValuesWithPathsToItems.js | 209 +++++---- .../addValuesWithPathsToItems.test.js | 209 ++++++++- .../src/components/MessageHistory/index.js | 6 +- .../MessageHistory/index.stories.js | 8 +- .../MessageHistory/internalCommon.js | 53 ++- .../MessageHistory/messagePathsForDatatype.js | 21 +- .../messagePathsForDatatype.test.js | 28 +- .../MessageHistory/parseRosPath.test.js | 106 ++++- .../MessageHistory/rosPathGrammar.ne | 59 ++- .../MessageHistory/synchronizeMessages.js | 27 +- .../MessageHistory/topicPathSyntax.help.md | 1 + .../warnOnOutOfSyncMessages.js | 4 +- packages/webviz-core/src/components/Panel.js | 24 +- .../webviz-core/src/components/Panel.test.js | 2 + .../src/components/PlaybackControls/index.js | 6 +- .../PlaybackControls/index.stories.js | 17 +- .../src/components/PlayerManager.js | 57 +-- .../src/components/SeekController.js | 7 +- .../src/components/SeekController.test.js | 4 +- .../src/components/ShareJsonModal.js | 12 +- .../src/components/ShareJsonModal.stories.js | 26 +- .../src/components/ShareJsonModal.test.js | 25 + .../src/components/ValidatedInput.js | 213 +++++++++ .../src/components/ValidatedInput.stories.js | 81 ++++ .../webviz-core/src/components/validator.js | 72 --- .../webviz-core/src/components/validators.js | 143 ++++++ .../validators.test.js} | 51 +- packages/webviz-core/src/loadWebviz.js | 11 +- .../src/panels/ImageView/CameraModel.js | 54 +-- .../src/panels/ImageView/ImageCanvas.js | 268 +++++------ .../panels/ImageView/ImageCanvas.stories.js | 121 ++++- .../src/panels/ImageView/decodings.js | 89 +++- .../src/panels/ImageView/decodings.test.js | 23 + .../src/panels/ImageView/index.help.md | 2 + .../webviz-core/src/panels/ImageView/index.js | 100 +++- .../src/panels/ImageView/index.test.js | 67 +++ .../webviz-core/src/panels/Note/index.help.md | 3 + packages/webviz-core/src/panels/Note/index.js | 119 +++++ .../src/panels/Note/index.stories.js | 31 ++ .../webviz-core/src/panels/Plot/PlotChart.js | 195 +++++--- .../webviz-core/src/panels/Plot/PlotLegend.js | 13 +- .../webviz-core/src/panels/Plot/PlotMenu.js | 74 +++ ...index.module.scss => PlotMenu.module.scss} | 10 + .../src/panels/Plot/PlotMenu.stories.js | 36 ++ .../webviz-core/src/panels/Plot/index.help.md | 6 +- packages/webviz-core/src/panels/Plot/index.js | 67 +-- .../src/panels/Plot/index.stories.js | 165 ++++++- .../src/panels/Plot/internalTypes.js | 11 +- .../webviz-core/src/panels/Rosout/index.js | 2 + .../panels/ThreeDimensionalViz/CameraInfo.js | 207 -------- .../DrawingTools/CameraInfo.js | 192 ++++++++ .../DrawingTools/Polygons.js | 86 ++++ .../ThreeDimensionalViz/DrawingTools/index.js | 131 ++++++ .../DrawingTools/index.stories.js | 89 ++++ .../src/panels/ThreeDimensionalViz/Layout.js | 255 +++++----- .../panels/ThreeDimensionalViz/MainToolbar.js | 43 ++ .../ThreeDimensionalViz/MeasuringTool.js | 185 -------- .../ThreeDimensionalViz/SceneBuilder/index.js | 12 +- .../TopicSelector/topicTree.js | 8 +- .../TopicSelector/treeBuilder.js | 9 +- .../TopicSettingsEditor.js | 11 +- .../panels/ThreeDimensionalViz/Transforms.js | 9 +- .../cameraStateValidator.js | 34 -- .../commands/LaserScans.js | 5 +- .../panels/ThreeDimensionalViz/index.help.md | 11 + .../src/panels/ThreeDimensionalViz/index.js | 5 +- .../utils/drawToolUtils.js | 55 +++ .../utils/drawToolUtils.test.js | 74 +++ .../src/players/BagDataProvider.js | 10 +- .../src/players/BagDataProvider.test.js | 15 +- .../src/players/BrowserHttpReader.js | 2 +- .../src/players/CombinedDataProvider.js | 148 +++--- .../src/players/CombinedDataProvider.test.js | 338 +++++++++----- .../webviz-core/src/players/FetchReader.js | 4 +- .../src/players/IdbCacheReaderDataProvider.js | 18 +- .../src/players/IdbCacheWriterDataProvider.js | 70 ++- .../IdbCacheWriterDataProvider.test.js | 40 +- .../src/players/MeasureDataProvider.js | 24 +- .../src/players/MemoryDataProvider.js | 11 + .../src/players/ParseMessagesDataProvider.js | 15 +- .../players/ParseMessagesDataProvider.test.js | 1 - .../src/players/RandomAccessPlayer.js | 219 +++++---- .../src/players/RandomAccessPlayer.test.js | 440 ++++++++++-------- .../src/players/ReadAheadDataProvider.js | 58 +-- .../src/players/ReadAheadDataProvider.test.js | 150 +++++- .../src/players/RpcDataProvider.js | 18 +- .../src/players/RpcDataProvider.test.js | 8 +- .../src/players/RpcDataProviderRemote.js | 15 +- .../src/players/WorkerDataProvider.js | 16 +- .../src/players/WorkerDataProvider.worker.js | 2 + .../src/players/createGetDataProvider.js | 12 +- .../src/players/mockExtensionPoint.js | 5 - .../src/players/rootGetDataProvider.js | 36 ++ .../standardDataProviderDescriptors.js | 4 +- packages/webviz-core/src/players/types.js | 30 +- packages/webviz-core/src/players/util.js | 32 ++ packages/webviz-core/src/reducers/panels.js | 38 +- .../webviz-core/src/types/RosDatatypes.js | 1 + packages/webviz-core/src/types/panels.js | 8 + packages/webviz-core/src/types/players.js | 2 +- packages/webviz-core/src/util.js | 27 ++ .../webviz-core/src/util/debouncePromise.js | 39 ++ .../src/util/debouncePromise.test.js | 101 ++++ .../src/util/indexeddb/Database.js | 1 + .../src/util/installDevtoolsFormatters.js | 66 +++ .../src/util/multicolorLineChart.js | 1 - packages/webviz-core/src/util/reportError.js | 41 +- packages/webviz-core/src/util/time.js | 14 +- packages/webviz-core/src/util/time.test.js | 16 + packages/webviz-core/src/util/yaml.js | 22 + 124 files changed, 4872 insertions(+), 2043 deletions(-) create mode 100644 packages/webviz-core/src/components/IconButton.js delete mode 100644 packages/webviz-core/src/components/JsonInput.js create mode 100644 packages/webviz-core/src/components/ValidatedInput.js create mode 100644 packages/webviz-core/src/components/ValidatedInput.stories.js delete mode 100644 packages/webviz-core/src/components/validator.js create mode 100644 packages/webviz-core/src/components/validators.js rename packages/webviz-core/src/{panels/ThreeDimensionalViz/cameraStateValidator.test.js => components/validators.test.js} (50%) create mode 100644 packages/webviz-core/src/panels/ImageView/decodings.test.js create mode 100644 packages/webviz-core/src/panels/ImageView/index.test.js create mode 100644 packages/webviz-core/src/panels/Note/index.help.md create mode 100644 packages/webviz-core/src/panels/Note/index.js create mode 100644 packages/webviz-core/src/panels/Note/index.stories.js create mode 100644 packages/webviz-core/src/panels/Plot/PlotMenu.js rename packages/webviz-core/src/panels/Plot/{index.module.scss => PlotMenu.module.scss} (77%) create mode 100644 packages/webviz-core/src/panels/Plot/PlotMenu.stories.js delete mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/CameraInfo.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/DrawingTools/CameraInfo.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/DrawingTools/Polygons.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/DrawingTools/index.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/DrawingTools/index.stories.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/MainToolbar.js delete mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/MeasuringTool.js delete mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/cameraStateValidator.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/utils/drawToolUtils.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/utils/drawToolUtils.test.js create mode 100644 packages/webviz-core/src/players/rootGetDataProvider.js create mode 100644 packages/webviz-core/src/players/util.js create mode 100644 packages/webviz-core/src/util/debouncePromise.js create mode 100644 packages/webviz-core/src/util/debouncePromise.test.js create mode 100644 packages/webviz-core/src/util/installDevtoolsFormatters.js create mode 100644 packages/webviz-core/src/util/yaml.js diff --git a/packages/webviz-core/package-lock.json b/packages/webviz-core/package-lock.json index 15a78665c..8f3f714d4 100644 --- a/packages/webviz-core/package-lock.json +++ b/packages/webviz-core/package-lock.json @@ -19,6 +19,11 @@ } } }, + "@cruise-automation/hooks": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@cruise-automation/hooks/-/hooks-0.0.1.tgz", + "integrity": "sha512-sUwYShqRJAyg2XS7+uc9ZwdNkRArLdyjPTF6Ebl1GphXHX5QYU14aMt3SO3QtVg0fd/KSWM4FinRosxZDq5uZg==" + }, "@mdi/svg": { "version": "3.2.89", "resolved": "https://registry.npmjs.org/@mdi/svg/-/svg-3.2.89.tgz", @@ -47,6 +52,14 @@ } } }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "requires": { + "sprintf-js": "~1.0.2" + } + }, "array-uniq": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", @@ -356,6 +369,11 @@ "integrity": "sha512-iwDuWR2ReRgvJsNm8fXPtTKdg78IVQF8I4+am3ntztPf/+nPnWZfArFu6aXpaC75/iCYRrkqI8nPCYkxJstmpA==", "dev": true }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" + }, "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -630,6 +648,15 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, + "js-yaml": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.12.0.tgz", + "integrity": "sha512-PIt2cnwmPfL4hKNwqeiuz4bKfnzHTBv6HyVgjahA6mPLwPDzjDWrplJBMjHUFxku/N3FlmrbyPclad+I+4mJ3A==", + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, "json5": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", @@ -725,6 +752,11 @@ "unist-util-visit-parents": "1.1.2" } }, + "memoize-one": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.0.4.tgz", + "integrity": "sha512-P0z5IeAH6qHHGkJIXWw0xC2HNEgkx/9uWWBQw64FJj3/ol14VYdfVGWWr0fXfjhhv3TKVIqUq65os6O4GUNksA==" + }, "memoize-weak": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/memoize-weak/-/memoize-weak-1.0.2.tgz", @@ -851,6 +883,11 @@ "resolved": "https://registry.npmjs.org/process/-/process-0.5.2.tgz", "integrity": "sha1-FjjYqONML0QKkduVq5rrZ3/Bhc8=" }, + "promise-queue": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/promise-queue/-/promise-queue-2.2.5.tgz", + "integrity": "sha1-L29ffA9tCBCelnZZx5uIqe1ek7Q=" + }, "prop-types": { "version": "15.6.2", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", @@ -895,6 +932,17 @@ "resolved": "https://registry.npmjs.org/raven-js/-/raven-js-3.27.0.tgz", "integrity": "sha512-vChdOL+yzecfnGA+B5EhEZkJ3kY3KlMzxEhShKh6Vdtooyl0yZfYNFQfYzgMf2v4pyQa+OTZ5esTxxgOOZDHqw==" }, + "react": { + "version": "16.8.6", + "resolved": "https://registry.npmjs.org/react/-/react-16.8.6.tgz", + "integrity": "sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.13.6" + } + }, "react-autocomplete": { "version": "github:janpaul123/react-autocomplete#bc8737070b5744069719c8fcd4e0a197192b0d48", "from": "github:janpaul123/react-autocomplete#bc8737070b5744069719c8fcd4e0a197192b0d48", @@ -1335,6 +1383,11 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" + }, "srcset": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/srcset/-/srcset-1.0.0.tgz", diff --git a/packages/webviz-core/package.json b/packages/webviz-core/package.json index 368c6c338..8e21ce1b9 100644 --- a/packages/webviz-core/package.json +++ b/packages/webviz-core/package.json @@ -16,7 +16,9 @@ "idb": "2.1.3", "inter-ui": "3.0.0", "intervals-fn": "3.0.3", + "js-yaml": "3.12.0", "lodash": "4.17.11", + "memoize-one": "5.0.4", "memoize-weak": "1.0.2", "micro-memoize": "^3.0.1", "moment": "2.22.2", @@ -24,6 +26,7 @@ "moment-timezone": "0.5.23", "natsort": "2.0.0", "nearley": "2.15.1", + "promise-queue": "2.2.5", "prop-types": "15.6.2", "raven-js": "3.27.0", "react": "16.8.6", diff --git a/packages/webviz-core/src/actions/panels.js b/packages/webviz-core/src/actions/panels.js index ac7acc63d..18155809d 100644 --- a/packages/webviz-core/src/actions/panels.js +++ b/packages/webviz-core/src/actions/panels.js @@ -8,9 +8,8 @@ import { push } from "react-router-redux"; -import type { ImportPanelLayoutPayload, SaveConfigPayload } from "webviz-core/src/types/panels"; +import type { ImportPanelLayoutPayload, SaveConfigPayload, SaveFullConfigPayload } from "webviz-core/src/types/panels"; import type { Dispatch, GetState } from "webviz-core/src/types/Store"; - // DANGER: if you change this you break existing layout urls export const URL_KEY = "layout"; @@ -18,6 +17,10 @@ export type SAVE_PANEL_CONFIG = { type: "SAVE_PANEL_CONFIG", payload: SaveConfigPayload, }; +export type SAVE_FULL_PANEL_CONFIG = { + type: "SAVE_FULL_PANEL_CONFIG", + payload: SaveFullConfigPayload, +}; export type Dispatcher = (dispatch: Dispatch, getState: GetState) => T; @@ -47,6 +50,16 @@ export const savePanelConfig = (payload: SaveConfigPayload): Dispatcher => ( + dispatch, + getState +) => { + return dispatch({ + type: "SAVE_FULL_PANEL_CONFIG", + payload, + }); +}; + type IMPORT_PANEL_LAYOUT = { type: "IMPORT_PANEL_LAYOUT", payload: ImportPanelLayoutPayload, @@ -103,5 +116,6 @@ export type PanelsActions = | CHANGE_PANEL_LAYOUT | IMPORT_PANEL_LAYOUT | SAVE_PANEL_CONFIG + | SAVE_FULL_PANEL_CONFIG | OVERWRITE_GLOBAL_DATA | SET_GLOBAL_DATA; diff --git a/packages/webviz-core/src/components/Button.js b/packages/webviz-core/src/components/Button.js index ffd79ed5a..23c855280 100644 --- a/packages/webviz-core/src/components/Button.js +++ b/packages/webviz-core/src/components/Button.js @@ -48,7 +48,7 @@ export default class Button extends React.Component { return ( {/* Extra div allows Tooltip to insert the necessary event listeners */} -
+
diff --git a/packages/webviz-core/src/components/CopyText.js b/packages/webviz-core/src/components/CopyText.js index bf73e6eaa..5143e5441 100644 --- a/packages/webviz-core/src/components/CopyText.js +++ b/packages/webviz-core/src/components/CopyText.js @@ -27,18 +27,17 @@ const SCopyTextWrapper = styled.div` `; type Props = {| - copyText?: string, + copyText: string, tooltip: string, children: React.Node, - getCopyText?: () => string, |}; -function CopyText({ copyText, tooltip, children, getCopyText }: Props) { +function CopyText({ copyText, tooltip, children }: Props) { if (!copyText || !children) { return null; } return ( - clipboard.copy(copyText || getCopyText)}> + clipboard.copy(copyText)}> {children ? children : copyText} diff --git a/packages/webviz-core/src/components/DocumentDropListener.js b/packages/webviz-core/src/components/DocumentDropListener.js index 96d2d30d2..64b246005 100644 --- a/packages/webviz-core/src/components/DocumentDropListener.js +++ b/packages/webviz-core/src/components/DocumentDropListener.js @@ -10,7 +10,7 @@ import * as React from "react"; type Props = { children: React.Node, // Shown when dragging in a file. - filesSelected: ({ files: FileList, shiftPressed: boolean }) => any, + filesSelected: ({ files: FileList | File[], shiftPressed: boolean }) => any, }; type State = { diff --git a/packages/webviz-core/src/components/ErrorDisplay.js b/packages/webviz-core/src/components/ErrorDisplay.js index 9df6569cf..ef8cfe9dd 100644 --- a/packages/webviz-core/src/components/ErrorDisplay.js +++ b/packages/webviz-core/src/components/ErrorDisplay.js @@ -18,12 +18,12 @@ import Menu from "webviz-core/src/components/Menu"; import Modal, { Title } from "webviz-core/src/components/Modal"; import renderToBody from "webviz-core/src/components/renderToBody"; import colors from "webviz-core/src/styles/colors.module.scss"; -import { setErrorHandler } from "webviz-core/src/util/reportError"; +import { setErrorHandler, unsetErrorHandler, type DetailsType } from "webviz-core/src/util/reportError"; type ErrorMessage = { +id: string, +message: string, - +details: string, + +details: DetailsType, +read: boolean, +created: Date, }; @@ -139,12 +139,16 @@ const ModalBody = styled.div` `; // Exporting for tests. -export function showErrorModal(errorMessage: ErrorMessage): void { +export function showErrorModal({ details, message }: ErrorMessage): void { + const detailsNode = React.isValidElement(details) ? details : null; + const detailsStr = + details instanceof Error ? details.stack : typeof details === "string" ? details : "No details provided"; + const modal = renderToBody( modal.remove()}> - {errorMessage.message} -
{errorMessage.details}
+ {message} + {detailsNode ? detailsNode :
{detailsStr}
}
); @@ -167,10 +171,9 @@ export default class ErrorDisplay extends React.PureComponent<{}, State> { componentDidMount() { setErrorHandler( - (message: string, details: string | Error): void => { + (message: string, details: DetailsType): void => { this.setState((state: State) => { - const detailsAsError: string = typeof details !== "string" ? details.stack : details || "No details provided"; - const newErrors = [{ id: uuid(), created: new Date(), message, details: detailsAsError, read: false }]; + const newErrors = [{ id: uuid(), created: new Date(), message, details, read: false }]; // shift errors in to the front of the array and keep a max of 100 const errors = newErrors.concat(state.errors).slice(0, 100); return { @@ -189,6 +192,10 @@ export default class ErrorDisplay extends React.PureComponent<{}, State> { ); } + componentWillUnmount() { + unsetErrorHandler(); + } + toggleErrorList = () => { this.setState((state) => { const { showErrorList } = state; diff --git a/packages/webviz-core/src/components/ErrorDisplay.stories.js b/packages/webviz-core/src/components/ErrorDisplay.stories.js index 136be1afa..b3cd442c5 100644 --- a/packages/webviz-core/src/components/ErrorDisplay.stories.js +++ b/packages/webviz-core/src/components/ErrorDisplay.stories.js @@ -152,4 +152,18 @@ storiesOf("", module) created: new Date(), }); return
; + }) + .add("Error Modal with details in React.Node type", () => { + showErrorModal({ + id: "1", + message: "Error 1", + details: ( +

+ This is customized error detail. +

+ ), + read: false, + created: new Date(), + }); + return
; }); diff --git a/packages/webviz-core/src/components/ExpandingToolbar.js b/packages/webviz-core/src/components/ExpandingToolbar.js index 5177e219f..51cd8c8aa 100644 --- a/packages/webviz-core/src/components/ExpandingToolbar.js +++ b/packages/webviz-core/src/components/ExpandingToolbar.js @@ -23,74 +23,61 @@ export class ToolGroup extends React.Component<{ name: string, children: React.N } type Props = {| - icon: React.Node, children: React.ChildrenArray>, - onExpand?: ?(expanded: boolean) => void, className?: ?string, - tooltip: string, - selectedTab?: string, expanded?: boolean, + icon: React.Node, + onSelectTab: (name: string) => void, + onSetExpanded: (expanded: boolean) => void, + selectedTab: string, + tooltip: string, |}; -type State = { - expanded: boolean, - selectedTab?: string, -}; - -export default class ExpandingToolbar extends React.Component { - state = { - expanded: !!this.props.expanded, - selectedTab: this.props.selectedTab, - }; - - toggleExpanded = () => { - const { expanded } = this.state; - const { onExpand } = this.props; - if (onExpand) { - onExpand(!expanded); - } - this.setState({ expanded: !expanded }); - }; - - render() { - const { expanded, selectedTab } = this.state; - const { icon, children, className, tooltip } = this.props; - if (!expanded) { - return ( -
- -
- ); - } - let selectedChild; - React.Children.forEach(children, (child) => { - if (!selectedChild || child.props.name === selectedTab) { - selectedChild = child; - } - }); +export default function ExpandingToolbar({ + children, + className, + expanded, + icon, + onSelectTab, + onSetExpanded, + selectedTab, + tooltip, +}: Props) { + if (!expanded) { return ( -
- - {React.Children.map(children, (child) => { - return ( - - ); - })} -
- - -
{selectedChild}
+
+
); } + let selectedChild; + React.Children.forEach(children, (child) => { + if (!selectedChild || child.props.name === selectedTab) { + selectedChild = child; + } + }); + return ( +
+ + {React.Children.map(children, (child) => { + return ( + + ); + })} +
+ + +
{selectedChild}
+
+ ); } diff --git a/packages/webviz-core/src/components/ExpandingToolbar.module.scss b/packages/webviz-core/src/components/ExpandingToolbar.module.scss index 613e4f46b..493b0ce0e 100644 --- a/packages/webviz-core/src/components/ExpandingToolbar.module.scss +++ b/packages/webviz-core/src/components/ExpandingToolbar.module.scss @@ -27,5 +27,5 @@ .tabBody { background-color: $panel-background; - padding: 12px; + padding: 4px 12px 12px 12px; } diff --git a/packages/webviz-core/src/components/IconButton.js b/packages/webviz-core/src/components/IconButton.js new file mode 100644 index 000000000..433f45c54 --- /dev/null +++ b/packages/webviz-core/src/components/IconButton.js @@ -0,0 +1,29 @@ +// @flow +// +// Copyright (c) 2018-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import React, { type Node } from "react"; + +import Button from "webviz-core/src/components/Button"; +import Icon from "webviz-core/src/components/Icon"; + +type Props = { + tooltip: string, + onClick: () => void, + icon: Node, + id?: string, +}; + +// $FlowFixMe - flow doesn't have a definition for React.memo +export default React.memo(function IconButton(props: Props) { + const { tooltip, onClick, id, icon } = props; + return ( + + ); +}); diff --git a/packages/webviz-core/src/components/JsonInput.js b/packages/webviz-core/src/components/JsonInput.js deleted file mode 100644 index b515dada0..000000000 --- a/packages/webviz-core/src/components/JsonInput.js +++ /dev/null @@ -1,77 +0,0 @@ -// @flow -// -// Copyright (c) 2019-present, GM Cruise LLC -// -// This source code is licensed under the Apache License, Version 2.0, -// found in the LICENSE file in the root directory of this source tree. -// You may not use this file except in compliance with the License. - -import * as React from "react"; -import styled from "styled-components"; - -import { validationErrorToString, type ValidationResult } from "./validator"; -import colors from "webviz-core/src/styles/colors.module.scss"; - -const StyledTextarea = styled.textarea` - flex: 1 1 auto; - resize: none; -`; - -const SError = styled.div` - color: ${colors.red}; - padding: 8px 4px; -`; - -type Props = { - value: Object | string, - onChange: (jsonObj: Object) => void, - dataValidator: (jsonObj: Object) => ?ValidationResult, -}; - -export default function JsonInput({ value, onChange, dataValidator }: Props) { - let defaultVal = (typeof value !== "string" && value) || ""; - const [error, setError] = React.useState(""); - - if (typeof value === "object") { - try { - defaultVal = JSON.stringify(value, null, 2); - } catch (e) { - setError(`Error parsing JSON value, using "" as default. ${e.message}`); - } - } - - const [inputVal, setInputValue] = React.useState(defaultVal); - - // update consumer value if the input value is not valid json - React.useEffect( - () => { - if (error) { - onChange(null); - } - }, - [error, onChange] - ); - - function handleChange(e) { - setInputValue(e.target.value); - - try { - const newVal = JSON.parse(e.target.value); - const validationResult = dataValidator(newVal); - if (validationResult) { - setError(validationErrorToString(validationResult)); - return; - } - setError(""); - onChange(newVal); - } catch (e) { - setError(e.message); - } - } - return ( - <> - - {error && {error}} - - ); -} diff --git a/packages/webviz-core/src/components/LargeList.js b/packages/webviz-core/src/components/LargeList.js index 46d95acc1..37f5a960e 100644 --- a/packages/webviz-core/src/components/LargeList.js +++ b/packages/webviz-core/src/components/LargeList.js @@ -6,11 +6,16 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. +import ClipboardOutlineIcon from "@mdi/svg/svg/clipboard-outline.svg"; import _ from "lodash"; import * as React from "react"; import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from "react-virtualized"; import styled from "styled-components"; +import Icon from "webviz-core/src/components/Icon"; +import clipboard from "webviz-core/src/util/clipboard"; +import { toolsColorScheme } from "webviz-core/src/util/toolsColorScheme"; + const RenderRowContainer = styled.div` display: flex; padding: 4px; @@ -25,6 +30,11 @@ const LogMsg = styled.div` top: 32px; right: 12px; z-index: 1; + display: flex; + border-radius: 4px; + background: ${toolsColorScheme.base.dark}; + color: rgba(255, 255, 255, 0.5); + padding: 0px 8px; `; const DEFAULT_ROW_HEIGHT = 16; @@ -56,6 +66,8 @@ type Props = { renderRow: RenderRow, disableScrollToBottom: boolean, cleared?: boolean, + enableCopying?: boolean, + copyButtonTooltip?: string, }; const defaultRenderRow: RenderRow = ({ index, key, style, items }) => { @@ -68,6 +80,17 @@ const defaultRenderRow: RenderRow = ({ index, key, style, items }) => { ); }; +function CopyIcon({ tooltip, getCopyText }: { tooltip: string, getCopyText: () => string }) { + return ( + clipboard.copy(getCopyText())} + tooltip={tooltip}> + + + ); +} + /** * is a reusable responsive component for displaying large amount of logs or similar data. * We can use renderRow to create custom UI for each data row. @@ -104,7 +127,7 @@ class LargeList extends React.Component, {}> { } render() { - const { items, renderRow, disableScrollToBottom } = this.props; + const { items, renderRow, disableScrollToBottom, enableCopying, copyButtonTooltip } = this.props; const addedProps = disableScrollToBottom ? {} : { scrollToIndex: items.length - 1 }; const logCounts = items.length; @@ -113,6 +136,9 @@ class LargeList extends React.Component, {}> { {logCounts > 0 && ( {logCounts} {logCounts > 1 ? "items" : "item"} + {enableCopying && ( + JSON.stringify(items, null, 2)} tooltip={copyButtonTooltip || "Copy"} /> + )} )} diff --git a/packages/webviz-core/src/components/MessageHistory/MessageHistoryInput.js b/packages/webviz-core/src/components/MessageHistory/MessageHistoryInput.js index 1667c2af7..d2f86cc4d 100644 --- a/packages/webviz-core/src/components/MessageHistory/MessageHistoryInput.js +++ b/packages/webviz-core/src/components/MessageHistory/MessageHistoryInput.js @@ -11,7 +11,7 @@ import cx from "classnames"; import * as React from "react"; import type { MessageHistoryTimestampMethod } from "."; -import type { RosPath } from "./internalCommon"; +import type { RosPath, RosPrimitive } from "./internalCommon"; import styles from "./MessageHistoryInput.module.scss"; import { type StructureTraversalResult, @@ -61,6 +61,32 @@ function getFirstInvalidVariableFromRosPath( .find(Boolean); } +function getExamplePrimitive(primitiveType: RosPrimitive) { + switch (primitiveType) { + case "string": + return '""'; + case "bool": + return "true"; + case "float32": + case "float64": + case "uint8": + case "uint16": + case "uint32": + case "uint64": + case "int8": + case "int16": + case "int32": + case "int64": + return "0"; + case "duration": + case "time": + return ""; + default: + (primitiveType: empty); + return ""; + } +} + type MessageHistoryInputBaseProps = { path: string, // A path of the form `/topic.some_field[:]{id==42}.x` index?: number, // Optional index field which gets passed to `onChange` (so you don't have to create anonymous functions) @@ -70,6 +96,7 @@ type MessageHistoryInputBaseProps = { autoSize?: boolean, placeholder?: string, inputStyle?: Object, + disableAutocomplete?: boolean, // Treat this as a normal input, with no autocomplete. timestampMethod?: MessageHistoryTimestampMethod, onTimestampMethodChange?: (MessageHistoryTimestampMethod, index: ?number) => void, @@ -157,6 +184,7 @@ class MessageHistoryInput extends React.PureComponent msgPath !== "" && !msgPath.endsWith(".header.seq") ); - autocompleteRange = { start: topic.name.length, end: Infinity }; + + // Exclude any initial filters ("/topic{foo=='bar'}") from the range that will be replaced + // when the user chooses a new message path. + let initialFilterLength = 0; + for (const item of rosPath.messagePath) { + if (item.type === "filter") { + initialFilterLength += item.repr.length + 2; // add { and } + } else { + break; + } + } + + autocompleteRange = { start: topic.name.length + initialFilterLength, end: Infinity }; // Filter out filters (hah!) in a pretty crude way, so autocomplete still works // when already having specified a filter and you want to see what other object // names you can complete it with. Kind of an edge case, and this doesn't work @@ -248,7 +301,7 @@ class MessageHistoryInput extends React.PureComponent this._onSelect(value, autocomplete, autocompleteType, autocompleteRange) } - hasError={!!autocompleteType} + hasError={!!autocompleteType && !disableAutocomplete} autocompleteKey={autocompleteType} placeholder={placeholder || "/some/topic.msgs[0].field"} autoSize={autoSize} diff --git a/packages/webviz-core/src/components/MessageHistory/addValuesWithPathsToItems.js b/packages/webviz-core/src/components/MessageHistory/addValuesWithPathsToItems.js index cb519ebbb..7f28a4919 100644 --- a/packages/webviz-core/src/components/MessageHistory/addValuesWithPathsToItems.js +++ b/packages/webviz-core/src/components/MessageHistory/addValuesWithPathsToItems.js @@ -6,8 +6,13 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. -import { isTypicalFilterName, type MessageHistoryItem, type MessagePathStructureItem } from "."; -import { type RosPath } from "./internalCommon"; +import { + isTypicalFilterName, + type MessageHistoryItem, + type MessagePathStructureItem, + type MessagePathStructureItemMessage, +} from "."; +import { type RosPath, type MessagePathFilter } from "./internalCommon"; import { messagePathStructures } from "webviz-core/src/components/MessageHistory/messagePathsForDatatype"; import { enumValuesByDatatypeAndField, topicsByTopicName } from "webviz-core/src/selectors"; import type { Message, Topic } from "webviz-core/src/types/players"; @@ -24,94 +29,128 @@ export default function addValuesWithPathsToItems( ): MessageHistoryItem[] { const structures = messagePathStructures(datatypes); - // Iterate over the individual messages. - return messages - .map((message: Message) => { - const topic = topicsByTopicName(topics)[rosPath.topicName]; - // We don't care about messages that don't match the topic we're looking for. - if (!topic || message.topic !== rosPath.topicName) { + const results = []; + for (const message of messages) { + const item = getItem(message, rosPath, topics, datatypes, globalData, structures); + if (item) { + results.push(item); + } + } + + return results; +} + +function filterMatches(filter: MessagePathFilter, value: any, globalData: any) { + let filterValue = filter.value; + if (typeof filterValue === "object") { + filterValue = globalData[filterValue.variableName]; + } + + let currentValue = value; + for (const name of filter.path) { + currentValue = currentValue[name]; + if (currentValue == null) { + return false; + } + } + + // Test equality using `==` so we can be forgiving for comparing booleans with integers, + // comparing numbers with strings, and so on. + // eslint-disable-next-line eqeqeq + return currentValue == filterValue; +} + +function getItem( + message: Message, + rosPath: RosPath, + topics: Topic[], + datatypes: RosDatatypes, + globalData: Object = {}, + structures: { [string]: MessagePathStructureItemMessage } +): ?MessageHistoryItem { + const topic = topicsByTopicName(topics)[rosPath.topicName]; + // We don't care about messages that don't match the topic we're looking for. + if (!topic || message.topic !== rosPath.topicName) { + return undefined; + } + + // Apply top-level filters first. If a message matches all top-level filters, then this function + // will *always* return a history item, so this is our only chance to return nothing. + for (const item of rosPath.messagePath) { + if (item.type === "filter") { + if (!filterMatches(item, message.message, globalData)) { return undefined; } + } else { + break; + } + } - const queriedData = []; - // Traverse the message (via `value`) and the `messagePath` at the same time. Also keep track - // of a `path` string that we should show in the tooltip of the point. - function traverse(value: any, pathIndex: number, path: string, structureItem: MessagePathStructureItem) { - if (value === undefined || structureItem === undefined) { - return; + const queriedData = []; + // Traverse the message (via `value`) and the `messagePath` at the same time. Also keep track + // of a `path` string that we should show in the tooltip of the point. + function traverse(value: any, pathIndex: number, path: string, structureItem: MessagePathStructureItem) { + if (value === undefined || structureItem === undefined) { + return; + } + const pathItem = rosPath.messagePath[pathIndex]; + const nextPathItem = rosPath.messagePath[pathIndex + 1]; + if (!pathItem) { + // If we're at the end of the `messagePath`, we're done! Just store the point. + let constantName: ?string; + const prevPathItem = rosPath.messagePath[pathIndex - 1]; + if (prevPathItem && prevPathItem.type === "name") { + const fieldName = prevPathItem.name; + const enumMap = enumValuesByDatatypeAndField(datatypes)[structureItem.datatype]; + if (enumMap && enumMap[fieldName]) { + constantName = enumMap[fieldName][value]; } - const pathItem = rosPath.messagePath[pathIndex]; - const nextPathItem = rosPath.messagePath[pathIndex + 1]; - if (!pathItem) { - // If we're at the end of the `messagePath`, we're done! Just store the point. - let constantName: ?string; - const prevPathItem = rosPath.messagePath[pathIndex - 1]; - if (prevPathItem && prevPathItem.type === "name") { - const fieldName = prevPathItem.name; - const enumMap = enumValuesByDatatypeAndField(datatypes)[structureItem.datatype]; - if (enumMap && enumMap[fieldName]) { - constantName = enumMap[fieldName][value]; - } - } - queriedData.push({ - value, - path, - constantName, - }); - } else if (pathItem.type === "name" && structureItem.structureType === "message") { - // If the `pathItem` is a name, we're traversing down using that name. - traverse( - value[pathItem.name], - pathIndex + 1, - `${path}.${pathItem.name}`, - structureItem.nextByName[pathItem.name] - ); - } else if (pathItem.type === "slice" && structureItem.structureType === "array") { - // If the `pathItem` is a slice, iterate over all the relevant elements in the array. - for (let i = pathItem.start; i <= Math.min(pathItem.end, value.length - 1); i++) { - if (value[i] === undefined) { - continue; - } - // Ideally show something like `/topic.object[:]{some_id=123}` for the path, but fall - // back to `/topic.object[10]` if necessary. In any case, make sure that the user can - // actually identify where the value came from. - let typicalFilterName; - if (nextPathItem && nextPathItem.type === "filter" && value[i][nextPathItem.name] !== undefined) { - // If we have a filter set after this, use the name that we're actually - // filtering on. - typicalFilterName = nextPathItem.name; - } else { - // See if `value[i]` has a property that we typically filter on. If so, show that. - const name = Object.keys(value[i]).find((key) => isTypicalFilterName(key)); - if (name) { - typicalFilterName = name; - } - } - traverse( - value[i], - pathIndex + 1, - typicalFilterName ? `${path}[:]{${typicalFilterName}==${value[i][typicalFilterName]}}` : `${path}[${i}]`, - structureItem.next - ); - } - } else if (pathItem.type === "filter") { - let filterValue = pathItem.value; - if (typeof filterValue === "object") { - filterValue = globalData[filterValue.variableName]; - } - // If the `pathItem` is a filter, then we might not traverse any further. Test equality - // using `==` so we can be forgiving for comparing booleans with integers, comparing numbers - // with strings, and so on. - // eslint-disable-next-line eqeqeq - if (value[pathItem.name] == filterValue) { - traverse(value, pathIndex + 1, path, structureItem); - } + } + queriedData.push({ + value, + path, + constantName, + }); + } else if (pathItem.type === "name" && structureItem.structureType === "message") { + // If the `pathItem` is a name, we're traversing down using that name. + traverse( + value[pathItem.name], + pathIndex + 1, + `${path}.${pathItem.name}`, + structureItem.nextByName[pathItem.name] + ); + } else if (pathItem.type === "slice" && structureItem.structureType === "array") { + // If the `pathItem` is a slice, iterate over all the relevant elements in the array. + for (let i = pathItem.start; i <= Math.min(pathItem.end, value.length - 1); i++) { + if (value[i] === undefined) { + continue; + } + // Ideally show something like `/topic.object[:]{some_id=123}` for the path, but fall + // back to `/topic.object[10]` if necessary. In any case, make sure that the user can + // actually identify where the value came from. + let newPath; + if (nextPathItem && nextPathItem.type === "filter") { + // If we have a filter set after this, it will update the path appropriately. + newPath = `${path}[:]`; } else { - console.warn(`Unknown pathItem.type ${pathItem.type} for structureType: ${structureItem.structureType}`); + // See if `value[i]` has a property that we typically filter on. If so, show that. + const name = Object.keys(value[i]).find((key) => isTypicalFilterName(key)); + if (name) { + newPath = `${path}[:]{${name}==${value[i][name]}}`; + } else { + newPath = `${path}[${i}]`; + } } + traverse(value[i], pathIndex + 1, newPath, structureItem.next); + } + } else if (pathItem.type === "filter") { + if (filterMatches(pathItem, value, globalData)) { + traverse(value, pathIndex + 1, `${path}{${pathItem.repr}}`, structureItem); } - traverse(message.message, 0, rosPath.topicName, structures[topic.datatype]); - return { message, queriedData }; - }) - .filter(Boolean); + } else { + console.warn(`Unknown pathItem.type ${pathItem.type} for structureType: ${structureItem.structureType}`); + } + } + traverse(message.message, 0, rosPath.topicName, structures[topic.datatype]); + return { message, queriedData }; } diff --git a/packages/webviz-core/src/components/MessageHistory/addValuesWithPathsToItems.test.js b/packages/webviz-core/src/components/MessageHistory/addValuesWithPathsToItems.test.js index aec2e8fab..01ea4ce17 100644 --- a/packages/webviz-core/src/components/MessageHistory/addValuesWithPathsToItems.test.js +++ b/packages/webviz-core/src/components/MessageHistory/addValuesWithPathsToItems.test.js @@ -197,10 +197,11 @@ describe("addValuesWithPathsToItems", () => { { type: "slice", start: 0, end: Infinity }, { type: "filter", - name: "some_filter_value", + path: ["some_filter_value"], value: "0", nameLoc: 0, valueLoc: "/some/topic.some_array[:]{some_filter_value==".length, + repr: "some_filter_value==0", }, // Test with a string value, should still work! { type: "name", name: "some_id" }, ], @@ -269,10 +270,11 @@ describe("addValuesWithPathsToItems", () => { { type: "slice", start: 0, end: Infinity }, { type: "filter", - name: "some_filter_value", + path: ["some_filter_value"], value: { variableName: "some_global_data_key" }, nameLoc: 0, valueLoc: "/some/topic.some_array[:]{some_filter_value==".length, + repr: "some_filter_value==$some_global_data_key", }, // Test with a string value, should still work! { type: "name", name: "some_id" }, ], @@ -285,9 +287,210 @@ describe("addValuesWithPathsToItems", () => { ).toEqual([ { message: messages[0], - queriedData: [{ value: 10, path: "/some/topic.some_array[:]{some_filter_value==5}.some_id" }], + queriedData: [ + { value: 10, path: "/some/topic.some_array[:]{some_filter_value==$some_global_data_key}.some_id" }, + ], + }, + ]); + }); + + it("filters entire messages", () => { + const messages: Message[] = [ + { + op: "message", + topic: "/some/topic", + datatype: "some_datatype", + receiveTime: { sec: 0, nsec: 0 }, + message: { + str_field: "A", + num_field: 1, + }, + }, + { + op: "message", + topic: "/some/topic", + datatype: "some_datatype", + receiveTime: { sec: 0, nsec: 0 }, + message: { + str_field: "A", + num_field: 2, + }, + }, + { + op: "message", + topic: "/some/topic", + datatype: "some_datatype", + receiveTime: { sec: 0, nsec: 0 }, + message: { + str_field: "B", + num_field: 2, + }, + }, + ]; + const topics: Topic[] = [{ name: "/some/topic", datatype: "some_datatype" }]; + const datatypes: RosDatatypes = { + some_datatype: [ + { + name: "str_field", + type: "string", + }, + { + name: "num_field", + type: "uint32", + }, + ], + }; + + expect( + addValuesWithPathsToItems( + messages, + { + topicName: "/some/topic", + messagePath: [ + { + type: "filter", + path: ["str_field"], + value: "A", + nameLoc: 0, + valueLoc: 0, + repr: "str_field=='A'", + }, + { type: "name", name: "num_field" }, + ], + modifier: undefined, + }, + topics, + datatypes + ) + ).toEqual([ + { + message: messages[0], + queriedData: [{ value: 1, path: "/some/topic{str_field=='A'}.num_field" }], + }, + { + message: messages[1], + queriedData: [{ value: 2, path: "/some/topic{str_field=='A'}.num_field" }], + }, + ]); + + expect( + addValuesWithPathsToItems( + messages, + { + topicName: "/some/topic", + messagePath: [ + { + type: "filter", + path: ["str_field"], + value: "B", + nameLoc: 0, + valueLoc: 0, + repr: "str_field=='B'", + }, + { type: "name", name: "num_field" }, + ], + modifier: undefined, + }, + topics, + datatypes + ) + ).toEqual([ + { + message: messages[2], + queriedData: [{ value: 2, path: "/some/topic{str_field=='B'}.num_field" }], }, ]); + + expect( + addValuesWithPathsToItems( + messages, + { + topicName: "/some/topic", + messagePath: [ + { + type: "filter", + path: ["num_field"], + value: 2, + nameLoc: 0, + valueLoc: 0, + repr: "num_field==2", + }, + { type: "name", name: "num_field" }, + ], + modifier: undefined, + }, + topics, + datatypes + ) + ).toEqual([ + { + message: messages[1], + queriedData: [{ value: 2, path: "/some/topic{num_field==2}.num_field" }], + }, + { + message: messages[2], + queriedData: [{ value: 2, path: "/some/topic{num_field==2}.num_field" }], + }, + ]); + + expect( + addValuesWithPathsToItems( + messages, + { + topicName: "/some/topic", + messagePath: [ + { + type: "filter", + path: ["str_field"], + value: "A", + nameLoc: 0, + valueLoc: 0, + repr: "str_field=='A'", + }, + { + type: "filter", + path: ["num_field"], + value: 2, + nameLoc: 0, + valueLoc: 0, + repr: "num_field==2", + }, + { type: "name", name: "num_field" }, + ], + modifier: undefined, + }, + topics, + datatypes + ) + ).toEqual([ + { + message: messages[1], + queriedData: [{ value: 2, path: "/some/topic{str_field=='A'}{num_field==2}.num_field" }], + }, + ]); + + expect( + addValuesWithPathsToItems( + messages, + { + topicName: "/some/topic", + messagePath: [ + { + type: "filter", + path: ["str_field"], + value: "C", + nameLoc: 0, + valueLoc: 0, + repr: "str_field=='C'", + }, + { type: "name", name: "num_field" }, + ], + modifier: undefined, + }, + topics, + datatypes + ) + ).toEqual([]); }); it("returns matching constants", () => { diff --git a/packages/webviz-core/src/components/MessageHistory/index.js b/packages/webviz-core/src/components/MessageHistory/index.js index f17902c0a..6ccf3a62f 100644 --- a/packages/webviz-core/src/components/MessageHistory/index.js +++ b/packages/webviz-core/src/components/MessageHistory/index.js @@ -12,7 +12,7 @@ import { createSelector } from "reselect"; import type { Time } from "rosbag"; import addValuesWithPathsToItems from "./addValuesWithPathsToItems"; -import { TOPICS_WITH_INCORRECT_HEADERS } from "./internalCommon"; +import { TOPICS_WITH_INCORRECT_HEADERS, type RosPrimitive } from "./internalCommon"; import MessageHistoryInput from "./MessageHistoryInput"; import MessageHistoryOnlyTopics from "./MessageHistoryOnlyTopics"; import { messagePathStructures, traverseStructure } from "./messagePathsForDatatype"; @@ -26,7 +26,7 @@ import { topicsByTopicName, shallowEqualSelector } from "webviz-core/src/selecto import type { Topic, Message } from "webviz-core/src/types/players"; import type { RosDatatypes } from "webviz-core/src/types/RosDatatypes"; -// Use `` to get data from the player, typically a bag or ROS websocked bridge. +// Use `` to get data from the player, typically a bag or ROS websocket bridge. // Typical usage looks like this: // // const path = "/some/topic.some.field"; @@ -105,7 +105,7 @@ type MessagePathStructureItemArray = {| |}; type MessagePathStructureItemPrimitive = {| structureType: "primitive", - primitiveType: string, + primitiveType: RosPrimitive, datatype: string, |}; export type MessagePathStructureItem = diff --git a/packages/webviz-core/src/components/MessageHistory/index.stories.js b/packages/webviz-core/src/components/MessageHistory/index.stories.js index e25b78112..9da8a844c 100644 --- a/packages/webviz-core/src/components/MessageHistory/index.stories.js +++ b/packages/webviz-core/src/components/MessageHistory/index.stories.js @@ -32,10 +32,13 @@ const fixture = { "msgs/State": [ { name: "header", type: "std_msgs/Header", isArray: false }, { name: "items", type: "msgs/OtherState", isArray: true }, + { name: "foo_id", type: "uint32", isArray: false }, ], "msgs/OtherState": [ { name: "id", type: "int32", isArray: false }, { name: "speed", type: "float32", isArray: false }, + { name: "name", type: "string", isArray: false }, + { name: "valid", type: "bool", isArray: false }, ], "std_msgs/Header": [ { name: "seq", type: "uint32", isArray: false }, @@ -92,8 +95,11 @@ storiesOf("", module) .add("autocomplete filter", () => { return ; }) + .add("autocomplete top level filter", () => { + return ; + }) .add("autocomplete for globalData variables", () => { - return ; + return ; }) .add("path with valid globalData variable", () => { return ; diff --git a/packages/webviz-core/src/components/MessageHistory/internalCommon.js b/packages/webviz-core/src/components/MessageHistory/internalCommon.js index 60cf7a051..88a2835ff 100644 --- a/packages/webviz-core/src/components/MessageHistory/internalCommon.js +++ b/packages/webviz-core/src/components/MessageHistory/internalCommon.js @@ -8,22 +8,25 @@ import { getGlobalHooks } from "webviz-core/src/loadWebviz"; -export const rosPrimitives = [ - "bool", - "int8", - "uint8", - "int16", - "uint16", - "int32", - "uint32", - "int64", - "uint64", - "float32", - "float64", - "string", - "time", - "duration", -]; +const RosPrimitives = { + bool: null, + int8: null, + uint8: null, + int16: null, + uint16: null, + int32: null, + uint32: null, + int64: null, + uint64: null, + float32: null, + float64: null, + string: null, + time: null, + duration: null, +}; + +export type RosPrimitive = $Keys; +export const rosPrimitives: RosPrimitive[] = Object.keys(RosPrimitives); // It sometimes happens that topics have headers, but those headers don't have // useful timestamps in them. This is done for internal reasons, as some APIs @@ -31,17 +34,21 @@ export const rosPrimitives = [ // these topics. export const TOPICS_WITH_INCORRECT_HEADERS = getGlobalHooks().topicsWithIncorrectHeaders(); +export type MessagePathFilter = {| + type: "filter", + path: string[], + value: void | number | string | {| variableName: string |}, + nameLoc: number, + valueLoc: number, + repr: string, // the original string representation of the filter +|}; + // A parsed version of paths. export type MessagePathPart = | {| type: "name", name: string |} | {| type: "slice", start: number, end: number |} - | {| - type: "filter", - name: string, - value: number | string | {| variableName: string |}, - nameLoc: number, - valueLoc: number, - |}; + | MessagePathFilter; + export type RosPath = {| topicName: string, messagePath: MessagePathPart[], diff --git a/packages/webviz-core/src/components/MessageHistory/messagePathsForDatatype.js b/packages/webviz-core/src/components/MessageHistory/messagePathsForDatatype.js index fd73e587a..f5ebfd339 100644 --- a/packages/webviz-core/src/components/MessageHistory/messagePathsForDatatype.js +++ b/packages/webviz-core/src/components/MessageHistory/messagePathsForDatatype.js @@ -10,7 +10,7 @@ import { memoize } from "lodash"; import memoizeWeak from "memoize-weak"; import { type MessagePathStructureItem, type MessagePathStructureItemMessage, isTypicalFilterName } from "."; -import { type MessagePathPart, rosPrimitives } from "./internalCommon"; +import { type MessagePathPart, rosPrimitives, type RosPrimitive } from "./internalCommon"; import type { RosDatatypes } from "webviz-core/src/types/RosDatatypes"; import naturalSort from "webviz-core/src/util/naturalSort"; @@ -54,7 +54,11 @@ export function messagePathStructures(datatypes: RosDatatypes): { [string]: Mess } const next = rosPrimitives.includes(msgField.type) - ? { structureType: "primitive", primitiveType: msgField.type, datatype } + ? { + structureType: "primitive", + primitiveType: ((msgField.type: any): RosPrimitive), // Flow doesn't understand includes() + datatype, + } : structureFor(msgField.type); if (msgField.isArray) { @@ -167,11 +171,18 @@ export const traverseStructure = memoizeWeak( } structureItem = structureItem.next; } else if (msgPathPart.type === "filter") { - if (structureItem.structureType !== "message") { + if (structureItem.structureType !== "message" || msgPathPart.path.length === 0 || msgPathPart.value == null) { return { valid: false, msgPathPart, structureItem }; } - if (!structureItem.nextByName[msgPathPart.name]) { - return { valid: false, msgPathPart, structureItem }; + let currentItem = structureItem; + for (const name of msgPathPart.path) { + if (currentItem.structureType !== "message") { + return { valid: false, msgPathPart, structureItem }; + } + currentItem = currentItem.nextByName[name]; + if (currentItem == null) { + return { valid: false, msgPathPart, structureItem }; + } } } else { (msgPathPart.type: empty); diff --git a/packages/webviz-core/src/components/MessageHistory/messagePathsForDatatype.test.js b/packages/webviz-core/src/components/MessageHistory/messagePathsForDatatype.test.js index 709cf9852..71e154b39 100644 --- a/packages/webviz-core/src/components/MessageHistory/messagePathsForDatatype.test.js +++ b/packages/webviz-core/src/components/MessageHistory/messagePathsForDatatype.test.js @@ -150,7 +150,7 @@ describe("validTerminatingStructureItem", () => { expect(validTerminatingStructureItem()).toEqual(false); }); - it("works for strutureType", () => { + it("works for structureType", () => { expect( validTerminatingStructureItem({ structureType: "message", nextByName: {}, datatype: "" }, ["message"]) ).toEqual(true); @@ -193,7 +193,7 @@ describe("traverseStructure", () => { expect( traverseStructure(structure, [ { type: "name", name: "some_pose" }, - { type: "filter", name: "x", value: 10, nameLoc: 123 }, + { type: "filter", path: ["x"], value: 10, nameLoc: 123 }, { type: "name", name: "dummy_array" }, { type: "slice", start: 50, end: 100 }, ]) @@ -203,6 +203,16 @@ describe("traverseStructure", () => { // $FlowFixMe structureItem: structure.nextByName.some_pose.nextByName.dummy_array.next, }); + expect( + traverseStructure(structure, [ + { type: "name", name: "some_pose" }, + { type: "filter", path: ["header", "seq"], value: 10, nameLoc: 123 }, + ]) + ).toEqual({ + valid: true, + msgPathPart: undefined, + structureItem: structure.nextByName.some_pose, + }); expect(traverseStructure(structure, [{ type: "name", name: "some_pose" }])).toEqual({ valid: true, msgPathPart: undefined, @@ -213,11 +223,21 @@ describe("traverseStructure", () => { expect( traverseStructure(structure, [ { type: "name", name: "some_pose" }, - { type: "filter", name: "y", value: 10, nameLoc: 123 }, + { type: "filter", path: ["y"], value: 10, nameLoc: 123 }, + ]) + ).toEqual({ + valid: false, + msgPathPart: { type: "filter", path: ["y"], value: 10, nameLoc: 123 }, + structureItem: messagePathStructures(datatypes)["pose_msgs/SomePose"], + }); + expect( + traverseStructure(structure, [ + { type: "name", name: "some_pose" }, + { type: "filter", path: ["header", "y"], value: 10, nameLoc: 123 }, ]) ).toEqual({ valid: false, - msgPathPart: { name: "y", nameLoc: 123, type: "filter", value: 10 }, + msgPathPart: { type: "filter", path: ["header", "y"], value: 10, nameLoc: 123 }, structureItem: messagePathStructures(datatypes)["pose_msgs/SomePose"], }); }); diff --git a/packages/webviz-core/src/components/MessageHistory/parseRosPath.test.js b/packages/webviz-core/src/components/MessageHistory/parseRosPath.test.js index 585b93d78..9c610d780 100644 --- a/packages/webviz-core/src/components/MessageHistory/parseRosPath.test.js +++ b/packages/webviz-core/src/components/MessageHistory/parseRosPath.test.js @@ -69,49 +69,101 @@ describe("parseRosPath", () => { }); it("parses filters", () => { - expect(parseRosPath("/topic.foo{bar=='baz'}.a{bar==\"baz\"}.b{bar==3}.c{bar==false}.d[:]{bar==true}")).toEqual({ + expect( + parseRosPath("/topic.foo{bar=='baz'}.a{bar==\"baz\"}.b{bar==3}.c{bar==-1}.d{bar==false}.e[:]{bar.baz==true}") + ).toEqual({ topicName: "/topic", messagePath: [ { type: "name", name: "foo" }, { type: "filter", - name: "bar", + path: ["bar"], value: "baz", nameLoc: "/topic.foo{".length, valueLoc: "/topic.foo{bar==".length, + repr: "bar=='baz'", }, { type: "name", name: "a" }, { type: "filter", - name: "bar", + path: ["bar"], value: "baz", nameLoc: "/topic.foo{bar=='baz'}.a{".length, valueLoc: "/topic.foo{bar=='baz'}.a{bar==".length, + repr: 'bar=="baz"', }, { type: "name", name: "b" }, { type: "filter", - name: "bar", + path: ["bar"], value: 3, nameLoc: "/topic.foo{bar=='baz'}.a{bar==\"baz\"}.b{".length, valueLoc: "/topic.foo{bar=='baz'}.a{bar==\"baz\"}.b{bar==".length, + repr: "bar==3", }, { type: "name", name: "c" }, { type: "filter", - name: "bar", - value: false, + path: ["bar"], + value: -1, nameLoc: "/topic.foo{bar=='baz'}.a{bar==\"baz\"}.b{bar==3}.c{".length, valueLoc: "/topic.foo{bar=='baz'}.a{bar==\"baz\"}.b{bar==3}.c{bar==".length, + repr: "bar==-1", }, { type: "name", name: "d" }, + { + type: "filter", + path: ["bar"], + value: false, + nameLoc: "/topic.foo{bar=='baz'}.a{bar==\"baz\"}.b{bar==3}.c{bar==-1}.d{".length, + valueLoc: "/topic.foo{bar=='baz'}.a{bar==\"baz\"}.b{bar==3}.c{bar==-1}.d{bar==".length, + repr: "bar==false", + }, + { type: "name", name: "e" }, { type: "slice", start: 0, end: Infinity }, { type: "filter", - name: "bar", + path: ["bar", "baz"], value: true, - nameLoc: "/topic.foo{bar=='baz'}.a{bar==\"baz\"}.b{bar==3}.c{bar==false}.d[:]{".length, - valueLoc: "/topic.foo{bar=='baz'}.a{bar==\"baz\"}.b{bar==3}.c{bar==false}.d[:]{bar==".length, + nameLoc: "/topic.foo{bar=='baz'}.a{bar==\"baz\"}.b{bar==3}.c{bar==-1}.d{bar==false}.e[:]{".length, + valueLoc: "/topic.foo{bar=='baz'}.a{bar==\"baz\"}.b{bar==3}.c{bar==-1}.d{bar==false}.e[:]{bar.baz==".length, + repr: "bar.baz==true", + }, + ], + modifier: null, + }); + }); + + it("parses filters on top level topic", () => { + expect(parseRosPath("/topic{foo=='bar'}{baz==2}.a[3].b{x=='y'}")).toEqual({ + topicName: "/topic", + messagePath: [ + { + type: "filter", + path: ["foo"], + value: "bar", + nameLoc: "/topic{".length, + valueLoc: "/topic{foo==".length, + repr: "foo=='bar'", + }, + { + type: "filter", + path: ["baz"], + value: 2, + nameLoc: "/topic{foo=='bar'}{".length, + valueLoc: "/topic{foo=='bar'}{baz==".length, + repr: "baz==2", + }, + { type: "name", name: "a" }, + { type: "slice", start: 3, end: 3 }, + { type: "name", name: "b" }, + { + type: "filter", + path: ["x"], + value: "y", + nameLoc: "/topic{foo=='bar'}{baz==2}.a[3].b{".length, + valueLoc: "/topic{foo=='bar'}{baz==2}.a[3].b{x==".length, + repr: "x=='y'", }, ], modifier: null, @@ -125,20 +177,22 @@ describe("parseRosPath", () => { { type: "name", name: "foo" }, { type: "filter", - name: "bar", + path: ["bar"], value: { variableName: "" }, nameLoc: "/topic.foo{".length, valueLoc: "/topic.foo{bar==".length, + repr: "bar==$", }, { type: "name", name: "a" }, { type: "filter", - name: "bar", + path: ["bar"], value: { variableName: "my_var_1", }, nameLoc: "/topic.foo{bar==$}.a{".length, valueLoc: "/topic.foo{bar==$}.a{bar==".length, + repr: "bar==$my_var_1", }, ], modifier: null, @@ -168,10 +222,11 @@ describe("parseRosPath", () => { { type: "name", name: "foo" }, { type: "filter", - name: "", - value: "", + path: [], + value: undefined, nameLoc: "/topic.foo{".length, valueLoc: "/topic.foo{".length, + repr: "", }, ], modifier: null, @@ -182,10 +237,11 @@ describe("parseRosPath", () => { { type: "name", name: "foo" }, { type: "filter", - name: "bar", - value: "", + path: ["bar"], + value: undefined, nameLoc: "/topic.foo{".length, valueLoc: "/topic.foo{".length, + repr: "bar", }, ], modifier: null, @@ -196,10 +252,26 @@ describe("parseRosPath", () => { { type: "name", name: "foo" }, { type: "filter", - name: "", + path: [], value: 1, nameLoc: "/topic.foo{".length, valueLoc: "/topic.foo{==".length, + repr: "==1", + }, + ], + modifier: null, + }); + expect(parseRosPath("/topic.foo{==-3}")).toEqual({ + topicName: "/topic", + messagePath: [ + { type: "name", name: "foo" }, + { + type: "filter", + path: [], + value: -3, + nameLoc: "/topic.foo{".length, + valueLoc: "/topic.foo{==".length, + repr: "==-3", }, ], modifier: null, @@ -209,7 +281,9 @@ describe("parseRosPath", () => { it("returns undefined for invalid strings", () => { expect(parseRosPath("blah")).toBeUndefined(); expect(parseRosPath("100")).toBeUndefined(); + expect(parseRosPath("-100")).toBeUndefined(); expect(parseRosPath("[100]")).toBeUndefined(); + expect(parseRosPath("[-100]")).toBeUndefined(); expect(parseRosPath("blah.blah")).toBeUndefined(); expect(parseRosPath("/topic.no.2d.arrays[0][1]")).toBeUndefined(); expect(parseRosPath("/topic.foo[].bar")).toBeUndefined(); diff --git a/packages/webviz-core/src/components/MessageHistory/rosPathGrammar.ne b/packages/webviz-core/src/components/MessageHistory/rosPathGrammar.ne index 6d5130c3b..33058927e 100644 --- a/packages/webviz-core/src/components/MessageHistory/rosPathGrammar.ne +++ b/packages/webviz-core/src/components/MessageHistory/rosPathGrammar.ne @@ -22,22 +22,24 @@ main -> topicName messagePath:? modifier:? id -> [a-zA-Z0-9_]:+ {% (d) => d[0].join("") %} -# Positive integer. -integer -> [0-9]:+ - {% (d) => parseInt(d[0].join("")) %} +# Integer. +integer + -> [0-9]:+ {% (d) => ({ value: parseInt(d[0].join("")), repr: d[0].join("") }) %} + | "-" [0-9]:+ {% (d) => ({ value: -parseInt(d[1].join("")), repr: `-${d[1].join("")}` }) %} # String of the form 'hi' or "hi". No escaping supported. -string -> "'" [^']:* "'" {% (d) => d[1].join("") %} - | "\"" [^"]:* "\"" {% (d) => d[1].join("") %} +string + -> "'" [^']:* "'" {% (d) => ({ value: d[1].join(""), repr: `'${d[1].join("")}'` }) %} + | "\"" [^"]:* "\"" {% (d) => ({ value: d[1].join(""), repr: `"${d[1].join("")}"` }) %} -variable -> "$" id:? {% (d) => (d[1] || "") %} +variable -> "$" id:? {% (d) => ({ value: {variableName: d[1] || ""}, repr: `$${d[1] || ""}` }) %} # An integer, string, or boolean. value -> integer {% (d) => d[0] %} | string {% (d) => d[0] %} - | "true" {% (d) => true %} - | "false" {% (d) => false %} - | variable {% (d) => ({variableName: d[0]}) %} + | "true" {% (d) => ({ value: true, repr: "true" }) %} + | "false" {% (d) => ({ value: false, repr: "false" }) %} + | variable {% (d) => d[0] %} ## Topic part. Basically an id but with slashes. topicName -> slashID:+ @@ -57,8 +59,9 @@ messagePath -> messagePathElement:* ".":? # An element of the `messagePart`, of the form `field[10:20]{some_id==10}`. # Multiple slices are not allowed (no 2d arrays in ROS). # Return type: `MessagePathPart`. -messagePathElement -> "." name slice:? filter:? - {% (d) => [d[1], d[2], d[3]].filter(x => x !== null) %} +messagePathElement -> + "." name slice:? filter:? {% (d) => [d[1], d[2], d[3]].filter(x => x !== null) %} + | filter {% id %} # Name part is just an id, e.g. `field`. name -> id @@ -66,16 +69,38 @@ name -> id # Slice part; can be a single array index `[0]` or multiple `[0:10]`, or even infinite `[:]`. slice -> "[" integer "]" - {% (d) => ({ type: "slice", start: d[1], end: d[1] }) %} + {% (d) => ({ type: "slice", start: d[1].value, end: d[1].value }) %} | "[" integer:? ":" integer:? "]" - {% (d) => ({ type: "slice", start: d[1] || 0, end: d[3] === null ? Infinity : d[3] }) %} + {% (d) => ({ type: "slice", start: d[1] === null ? 0 : d[1].value, end: d[3] === null ? Infinity : d[3].value }) %} + +# For now, filters only support simple "foo.bar.baz" paths, so we need a separate rule for this. +# TODO: it would be nice if filters supported arbitrary sub-paths, such as "/diagnostics{status[0].hardware_id=='bar'}". +simplePath -> id ("." id):* {% (d) => [d[0]].concat(d[1].map((d) => d[1])) %} # Filter part; can be empty `{}` to allow for autocomplete. Can also be half-empty, # like `{==0}`, also to allow for autocomplete. -filter -> "{" id:? "}" - {% (d, loc) => ({ type: "filter", name: d[1] || "", value: "", nameLoc: loc+1, valueLoc: loc+1 }) %} - | "{" id:? "==" value "}" - {% (d, loc) => ({ type: "filter", name: d[1] || "", value: d[3], nameLoc: loc+1, valueLoc: loc+1+(d[1] || '').length+d[2].length }) %} +filter -> "{" simplePath:? "}" + {% + (d, loc) => ({ + type: "filter", + path: d[1] || [], + value: undefined, + nameLoc: loc+1, + valueLoc: loc+1, + repr: (d[1] || []).join("."), + }) + %} + | "{" simplePath:? "==" value "}" + {% + (d, loc) => ({ + type: "filter", + path: d[1] || [], + value: d[3].value, + nameLoc: loc+1, + valueLoc: loc+1+(d[1] || []).join(".").length+d[2].length, + repr: `${(d[1] || []).join(".")}==${d[3].repr}`, + }) + %} ## Modifier. # Optional modifier at the end of a path, e.g. `.@derivative`. Currently only used by the Plot diff --git a/packages/webviz-core/src/components/MessageHistory/synchronizeMessages.js b/packages/webviz-core/src/components/MessageHistory/synchronizeMessages.js index 41045a0e7..7c2e45d63 100644 --- a/packages/webviz-core/src/components/MessageHistory/synchronizeMessages.js +++ b/packages/webviz-core/src/components/MessageHistory/synchronizeMessages.js @@ -10,13 +10,17 @@ import get from "lodash/get"; import { TimeUtil, type Time } from "rosbag"; import type { MessageHistoryItemsByPath } from "."; +import type { Message } from "webviz-core/src/types/players"; // Get all timestamps of all messages, newest first -function allStampsNewestFirst(itemsByPath: MessageHistoryItemsByPath): Time[] { +function allStampsNewestFirst( + itemsByPath: MessageHistoryItemsByPath, + getHeaderStamp?: (itemMessage: Message) => Time +): Time[] { const stamps = []; for (const path in itemsByPath) { for (const item of itemsByPath[path]) { - const stamp = get(item.message, ["message", "header", "stamp"]); + const stamp = getHeaderStamp ? getHeaderStamp(item.message) : get(item.message, ["message", "header", "stamp"]); if (!stamp) { return []; } @@ -27,12 +31,18 @@ function allStampsNewestFirst(itemsByPath: MessageHistoryItemsByPath): Time[] { } // Get a subset of items matching a particular timestamp -function messagesMatchingStamp(stamp: Time, itemsByPath: MessageHistoryItemsByPath): ?MessageHistoryItemsByPath { +function messagesMatchingStamp( + stamp: Time, + itemsByPath: MessageHistoryItemsByPath, + getHeaderStamp?: (itemMessage: Message) => Time +): ?MessageHistoryItemsByPath { const synchronizedItemsByPath = {}; for (const path in itemsByPath) { let found = false; for (const item of itemsByPath[path]) { - const thisStamp = item.message.message.header.stamp; + const thisStamp = getHeaderStamp + ? getHeaderStamp(item.message) + : get(item.message, ["message", "header", "stamp"]); if (thisStamp && TimeUtil.areSame(stamp, thisStamp)) { found = true; synchronizedItemsByPath[path] = [item]; @@ -48,9 +58,12 @@ function messagesMatchingStamp(stamp: Time, itemsByPath: MessageHistoryItemsByPa // Return a synchronized subset of the messages in `itemsByPath` with exactly matching header.stamps. // If multiple sets of synchronized messages are included, the one with the later header.stamp is returned. -export default function synchronizeMessages(itemsByPath: MessageHistoryItemsByPath): ?MessageHistoryItemsByPath { - for (const stamp of allStampsNewestFirst(itemsByPath)) { - const synchronizedItemsByPath = messagesMatchingStamp(stamp, itemsByPath); +export default function synchronizeMessages( + itemsByPath: MessageHistoryItemsByPath, + getHeaderStamp?: (itemMessage: Message) => Time +): ?MessageHistoryItemsByPath { + for (const stamp of allStampsNewestFirst(itemsByPath, getHeaderStamp)) { + const synchronizedItemsByPath = messagesMatchingStamp(stamp, itemsByPath, getHeaderStamp); if (synchronizedItemsByPath) { return synchronizedItemsByPath; } diff --git a/packages/webviz-core/src/components/MessageHistory/topicPathSyntax.help.md b/packages/webviz-core/src/components/MessageHistory/topicPathSyntax.help.md index 3ddb01372..539181942 100644 --- a/packages/webviz-core/src/components/MessageHistory/topicPathSyntax.help.md +++ b/packages/webviz-core/src/components/MessageHistory/topicPathSyntax.help.md @@ -8,6 +8,7 @@ The topic path syntax can be used in several panels to find the exact messages i - Slices are also allowed, and will return an array of values: `/some/topic.many.values[1:3].x` or even `/some/topic.many.values[:].x` to get all values. - Filter on particular values, usually in combination with slices: `/some/topic.many.values[:]{some_id==123}.x` — for now only equality is supported. - You can also filter on a global variable, which you can set in the Global Variables Panel: `/some/topic.many.values[:]{some_id==$my_custom_id}` +- Filters can be applied to fields in the top-level message, in which case entire messages that don't match the filter will be skipped: `/some/topic{foo.bar==123}` When filtering, you can use booleans: `{value==true}`; numbers: `{value==123}`; and strings `{value="foo"}`. diff --git a/packages/webviz-core/src/components/MessagePipeline/warnOnOutOfSyncMessages.js b/packages/webviz-core/src/components/MessagePipeline/warnOnOutOfSyncMessages.js index 6c2ed57fe..da3a5a531 100644 --- a/packages/webviz-core/src/components/MessagePipeline/warnOnOutOfSyncMessages.js +++ b/packages/webviz-core/src/components/MessagePipeline/warnOnOutOfSyncMessages.js @@ -57,7 +57,7 @@ export default function warnOnOutOfSyncMessages(playerState: PlayerState) { currentTime, lastSeekTime, incorrectMessages: incorrectMessages.map((msg) => ({ - receiveTime: message.receiveTime, + receiveTime: msg.receiveTime, topic: msg.topic, })), }); @@ -71,7 +71,7 @@ export default function warnOnOutOfSyncMessages(playerState: PlayerState) { lastReceiveTime, lastSeekTime, incorrectMessages: incorrectMessages.map((msg) => ({ - receiveTime: message.receiveTime, + receiveTime: msg.receiveTime, topic: msg.topic, })), }); diff --git a/packages/webviz-core/src/components/Panel.js b/packages/webviz-core/src/components/Panel.js index 3ffb1fcbb..6e2dae897 100644 --- a/packages/webviz-core/src/components/Panel.js +++ b/packages/webviz-core/src/components/Panel.js @@ -32,7 +32,13 @@ import MosaicDragHandle from "webviz-core/src/components/PanelToolbar/MosaicDrag import { getGlobalHooks } from "webviz-core/src/loadWebviz"; import PanelList, { getPanelListItemsByType } from "webviz-core/src/panels/PanelList"; import type { State as ReduxState } from "webviz-core/src/reducers"; -import type { SaveConfigPayload, PanelConfig, SaveConfig } from "webviz-core/src/types/panels"; +import type { + SaveConfigPayload, + SaveFullConfigPayload, + PanelConfig, + SaveConfig, + PerPanelFunc, +} from "webviz-core/src/types/panels"; import type { Topic } from "webviz-core/src/types/players"; import type { RosDatatypes } from "webviz-core/src/types/RosDatatypes"; import { getPanelTypeFromId } from "webviz-core/src/util"; @@ -144,6 +150,7 @@ export default function Panel( config: Config, saveConfig: SaveConfig, openSiblingPanel: (string, cb: (PanelConfig) => PanelConfig) => void, + updatePanelConfig: (panelType: string, perPanelFunc: PerPanelFunc) => void, topics: Topic[], capabilities: string[], datatypes: RosDatatypes, @@ -170,7 +177,11 @@ export default function Panel( const canSetTopicPrefix: boolean = PanelComponent.canSetTopicPrefix || false; class UnconnectedPanel extends React.PureComponent< - ReduxMappedProps & PipelineProps & {| savePanelConfig: (SaveConfigPayload) => void |}, + ReduxMappedProps & + PipelineProps & {| + savePanelConfig: (SaveConfigPayload) => void, + saveFullPanelConfig: (SaveFullConfigPayload) => PanelConfig, + |}, State > { static displayName = `Panel(${PanelComponent.displayName || PanelComponent.name || ""})`; @@ -198,6 +209,10 @@ export default function Panel( } }; + _updatePanelConfig = (panelType: string, perPanelFunc: (PanelConfig) => PanelConfig) => { + this.props.saveFullPanelConfig({ panelType, perPanelFunc }); + }; + // this is same as above _saveConfig, but is internal to this file / allows you to save the TOPIC_PREFIX_CONFIG_KEY _saveTopicPrefix = (newTopicPrefix: string) => { return () => { @@ -331,6 +346,7 @@ export default function Panel( ( } // There seems to be a circular dependency here, so defer loading a bit. - const { savePanelConfig } = require("webviz-core/src/actions/panels"); + const { savePanelConfig, saveFullPanelConfig } = require("webviz-core/src/actions/panels"); const ConnectedPanel = connect( mapStateToProps, - { savePanelConfig } + { savePanelConfig, saveFullPanelConfig } )(ConnectedToPipelinePanel); ConnectedPanel.defaultConfig = defaultConfig; diff --git a/packages/webviz-core/src/components/Panel.test.js b/packages/webviz-core/src/components/Panel.test.js index 19b5b65ff..80ee309b9 100644 --- a/packages/webviz-core/src/components/Panel.test.js +++ b/packages/webviz-core/src/components/Panel.test.js @@ -72,6 +72,7 @@ describe("Panel", () => { datatypes: { some_datatype: [] }, openSiblingPanel: expect.any(Function), saveConfig: expect.any(Function), + updatePanelConfig: expect.any(Function), topics: [{ datatype: "some_datatype", name: "/some/topic" }], }, ]); @@ -100,6 +101,7 @@ describe("Panel", () => { datatypes: { some_datatype: [] }, openSiblingPanel: expect.any(Function), saveConfig: expect.any(Function), + updatePanelConfig: expect.any(Function), topics: [{ datatype: "some_datatype", name: "/some/topic" }], }, ]); diff --git a/packages/webviz-core/src/components/PlaybackControls/index.js b/packages/webviz-core/src/components/PlaybackControls/index.js index 4240e1196..c8bf937ce 100644 --- a/packages/webviz-core/src/components/PlaybackControls/index.js +++ b/packages/webviz-core/src/components/PlaybackControls/index.js @@ -26,7 +26,7 @@ import { MessagePipelineConsumer } from "webviz-core/src/components/MessagePipel import Slider from "webviz-core/src/components/Slider"; import tooltipStyles from "webviz-core/src/components/Tooltip.module.scss"; import colors from "webviz-core/src/styles/colors.module.scss"; -import type { PlayerState } from "webviz-core/src/types/players"; +import { type PlayerState, PlayerCapabilities } from "webviz-core/src/types/players"; import { times } from "webviz-core/src/util/entities"; import { formatTime, formatTimeRaw, subtractTimes, toSec, fromSec } from "webviz-core/src/util/time"; @@ -126,7 +126,7 @@ export class UnconnectedPlaybackControls extends React.PureComponent { render() { const { pause, play, setSpeed, player } = this.props; - const { activeData, showInitializing, progress } = player; + const { activeData, showInitializing, progress, capabilities } = player; if (!activeData) { const message = showInitializing ? ( @@ -162,7 +162,7 @@ export class UnconnectedPlaybackControls extends React.PureComponent { {isPlaying ? : }
- {speed != null && speed !== 0 && ( + {capabilities.includes(PlayerCapabilities.setSpeed) && speed != null && speed !== 0 && ( 0.1× 0.2× diff --git a/packages/webviz-core/src/components/PlaybackControls/index.stories.js b/packages/webviz-core/src/components/PlaybackControls/index.stories.js index 971db3787..e07e4ec9b 100644 --- a/packages/webviz-core/src/components/PlaybackControls/index.stories.js +++ b/packages/webviz-core/src/components/PlaybackControls/index.stories.js @@ -12,7 +12,7 @@ import React from "react"; import { withScreenshot } from "storybook-chrome-screenshot"; import { UnconnectedPlaybackControls } from "."; -import { type PlayerState } from "webviz-core/src/types/players"; +import { PlayerCapabilities, type PlayerState } from "webviz-core/src/types/players"; const START_TIME = 1531761690; @@ -22,7 +22,7 @@ function getPlayerState(): PlayerState { showSpinner: false, showInitializing: false, progress: {}, - capabilities: [], + capabilities: [PlayerCapabilities.setSpeed], playerId: "1", activeData: { messages: [], @@ -53,6 +53,19 @@ storiesOf("", module)
); }) + .add("without speed control", () => { + const pause = action("pause"); + const play = action("play"); + const setSpeed = action("setSpeed"); + const seek = action("seek"); + const player = getPlayerState(); + player.capabilities = []; + return ( +
+ +
+ ); + }) .add("paused", () => { const pause = action("pause"); const play = action("play"); diff --git a/packages/webviz-core/src/components/PlayerManager.js b/packages/webviz-core/src/components/PlayerManager.js index 745766379..0b706ac89 100644 --- a/packages/webviz-core/src/components/PlayerManager.js +++ b/packages/webviz-core/src/components/PlayerManager.js @@ -14,54 +14,36 @@ import DocumentDropListener from "webviz-core/src/components/DocumentDropListene import DropOverlay from "webviz-core/src/components/DropOverlay"; import { MessagePipelineProvider } from "webviz-core/src/components/MessagePipeline"; import NodePlayer from "webviz-core/src/components/MessagePipeline/NodePlayer"; -import BagDataProvider from "webviz-core/src/players/BagDataProvider"; -import CombinedDataProvider from "webviz-core/src/players/CombinedDataProvider"; -import createGetDataProvider from "webviz-core/src/players/createGetDataProvider"; import { getRemoteBagGuid } from "webviz-core/src/players/getRemoteBagGuid"; -import IdbCacheReaderDataProvider from "webviz-core/src/players/IdbCacheReaderDataProvider"; -import IdbCacheWriterDataProvider from "webviz-core/src/players/IdbCacheWriterDataProvider"; -import { instrumentDataProviderTree } from "webviz-core/src/players/MeasureDataProvider"; -import ParseMessagesDataProvider from "webviz-core/src/players/ParseMessagesDataProvider"; import RandomAccessPlayer from "webviz-core/src/players/RandomAccessPlayer"; -import ReadAheadDataProvider from "webviz-core/src/players/ReadAheadDataProvider"; import { getLocalBagDescriptor, getRemoteBagDescriptor } from "webviz-core/src/players/standardDataProviderDescriptors"; -import type { ChainableDataProvider, ChainableDataProviderDescriptor } from "webviz-core/src/players/types"; -import WorkerDataProvider from "webviz-core/src/players/WorkerDataProvider"; import type { ImportPanelLayoutPayload } from "webviz-core/src/types/panels"; import type { Player } from "webviz-core/src/types/players"; import demoLayoutJson from "webviz-core/src/util/demoLayout.json"; import { LOAD_ENTIRE_BAG_QUERY_KEY, - MEASURE_DATA_PROVIDERS_QUERY_KEY, REMOTE_BAG_URL_QUERY_KEY, DEMO_QUERY_KEY, SECOND_BAG_PREFIX, } from "webviz-core/src/util/globalConstants"; -const getDataProviderBase = createGetDataProvider({ - BagDataProvider, - ParseMessagesDataProvider, - ReadAheadDataProvider, - WorkerDataProvider, - IdbCacheReaderDataProvider, - IdbCacheWriterDataProvider, -}); -function getDataProvider(tree: ChainableDataProviderDescriptor): ChainableDataProvider { - if (new URLSearchParams(location.search).has(MEASURE_DATA_PROVIDERS_QUERY_KEY)) { - tree = instrumentDataProviderTree(tree); +function buildPlayer(files: File[]): ?Player { + if (files.length === 0) { + return undefined; + } else if (files.length === 1) { + return new RandomAccessPlayer(getLocalBagDescriptor(files[0]), undefined, true); + } else if (files.length === 2) { + return new RandomAccessPlayer( + { + name: "CombinedDataProvider", + args: { providerInfos: [{}, { prefix: SECOND_BAG_PREFIX }] }, + children: [getLocalBagDescriptor(files[0]), getLocalBagDescriptor(files[1])], + }, + undefined, + true + ); } - return getDataProviderBase(tree); -} - -function buildPlayer(files: File[]): Player { - let bagProvider = getDataProvider(getLocalBagDescriptor(files[0])); - if (files.length === 2) { - bagProvider = new CombinedDataProvider([ - { provider: getDataProvider(getLocalBagDescriptor(files[0])) }, - { provider: getDataProvider(getLocalBagDescriptor(files[1])), prefix: SECOND_BAG_PREFIX }, - ]); - } - return new RandomAccessPlayer(bagProvider, undefined, true); + throw new Error(`Unsupported number of files: ${files.length}`); } type OwnProps = { children: React.Node }; @@ -86,7 +68,7 @@ function PlayerManager({ importPanelLayout, children }: Props) { setPlayer( new NodePlayer( new RandomAccessPlayer( - getDataProvider(getRemoteBagDescriptor(url, guid, params.has(LOAD_ENTIRE_BAG_QUERY_KEY))), + getRemoteBagDescriptor(url, guid, params.has(LOAD_ENTIRE_BAG_QUERY_KEY)), undefined, true ) @@ -104,7 +86,7 @@ function PlayerManager({ importPanelLayout, children }: Props) { return ( <> { + filesSelected={({ files, shiftPressed }: { files: FileList | File[], shiftPressed: boolean }) => { if (shiftPressed && usedFiles.current.length === 1) { usedFiles.current = [usedFiles.current[0], files[0]]; } else if (files.length === 2) { @@ -112,7 +94,8 @@ function PlayerManager({ importPanelLayout, children }: Props) { } else { usedFiles.current = [files[0]]; } - setPlayer(new NodePlayer(buildPlayer(usedFiles.current))); + const player = buildPlayer(usedFiles.current); + setPlayer(player ? new NodePlayer(player) : undefined); }}>
Drop a bag file to load it!
diff --git a/packages/webviz-core/src/components/SeekController.js b/packages/webviz-core/src/components/SeekController.js index 45e83d62d..f6ce756f3 100644 --- a/packages/webviz-core/src/components/SeekController.js +++ b/packages/webviz-core/src/components/SeekController.js @@ -28,9 +28,7 @@ export default function SeekController(props: Props) { // It doesn't make sense to apply a seek to a different instance of a player. // e.g. a user links to ?segment=foo&seek-to=4814814710 and then drops a bag - we don't // want to seek into the bag later. - const shouldRunEffect = Boolean( - context && context.playerState.activeData && context.playerState.activeData.isPlaying && !seekApplied.current - ); + const shouldRunEffect = Boolean(context && context.playerState.activeData && !seekApplied.current); useEffect( () => { const { activeData, playerId } = context.playerState; @@ -40,9 +38,6 @@ export default function SeekController(props: Props) { if (seekApplied.current) { return; } - if (!activeData.isPlaying) { - return; - } seekApplied.current = true; const params = new URLSearchParams(search); const seekTo = parseInt(params.get("seek-to"), 10); diff --git a/packages/webviz-core/src/components/SeekController.test.js b/packages/webviz-core/src/components/SeekController.test.js index 1a85e971e..56ce53eef 100644 --- a/packages/webviz-core/src/components/SeekController.test.js +++ b/packages/webviz-core/src/components/SeekController.test.js @@ -69,7 +69,7 @@ describe("", () => { el.unmount(); }); - it("does not call seek until player starts playing", () => { + it("calls seek regardless of whether the player is playing", () => { const activeData = { startTime: { sec: 1, nsec: 0 }, endTime: { sec: 10, nsec: 0 }, @@ -81,7 +81,7 @@ describe("", () => { ); - expect(onSeek).toHaveBeenCalledTimes(0); + expect(onSeek).toHaveBeenCalledTimes(1); const newActiveData = { ...activeData, isPlaying: true, diff --git a/packages/webviz-core/src/components/ShareJsonModal.js b/packages/webviz-core/src/components/ShareJsonModal.js index f8d20ec01..c4d2ba70e 100644 --- a/packages/webviz-core/src/components/ShareJsonModal.js +++ b/packages/webviz-core/src/components/ShareJsonModal.js @@ -57,10 +57,14 @@ export default class ShareJsonModal extends Component { }; onChange = () => { - const { onChange } = this.props; - const { value } = this.state; + const { onChange, onRequestClose } = this.props; + let { value } = this.state; + if (value.length === 0) { + value = JSON.stringify({}); + } try { - return onChange(decode(value)); + onChange(decode(value)); + onRequestClose(); } catch (e) { if (process.env.NODE_ENV !== "test") { console.error("Error parsing value from base64 json", e); @@ -107,7 +111,7 @@ export default class ShareJsonModal extends Component { /> {this.renderError()}
- diff --git a/packages/webviz-core/src/components/ShareJsonModal.stories.js b/packages/webviz-core/src/components/ShareJsonModal.stories.js index fe9139ffc..7520bc7e8 100644 --- a/packages/webviz-core/src/components/ShareJsonModal.stories.js +++ b/packages/webviz-core/src/components/ShareJsonModal.stories.js @@ -8,10 +8,34 @@ import { storiesOf } from "@storybook/react"; import React from "react"; +import TestUtils from "react-dom/test-utils"; import { withScreenshot } from "storybook-chrome-screenshot"; +import { importPanelLayout } from "webviz-core/src/actions/panels"; import ShareJsonModal from "webviz-core/src/components/ShareJsonModal"; +const onLayoutChange = (layout: any, isFromUrl: boolean = false) => { + importPanelLayout(layout, isFromUrl); +}; + storiesOf("", module) .addDecorator(withScreenshot()) - .add("standard", () => {}} value="" onChange={() => {}} noun="layout" />); + .add("standard", () => {}} value="" onChange={() => {}} noun="layout" />) + .add("submitting invalid layout", () => ( +
{ + if (el) { + // $FlowFixMe + const textarea: HTMLTextAreaElement = el.querySelector("textarea"); + textarea.value = "{"; + TestUtils.Simulate.change(textarea); + setTimeout(() => { + // $FlowFixMe + el.querySelector(".test-apply").click(); + }, 10); + } + }}> + {}} value={""} onChange={onLayoutChange} noun="layout" /> +
+ )); diff --git a/packages/webviz-core/src/components/ShareJsonModal.test.js b/packages/webviz-core/src/components/ShareJsonModal.test.js index efaf6e0c2..31fb7a333 100644 --- a/packages/webviz-core/src/components/ShareJsonModal.test.js +++ b/packages/webviz-core/src/components/ShareJsonModal.test.js @@ -50,4 +50,29 @@ describe("", () => { expect(wrapper.find(".is-danger").exists()).toBe(true); done(); }); + + it("fires no error when resetting an actual layout to default", (done) => { + const pass = (value) => { + expect(value.layout).toEqual("RosOut!cuuf9u"); + done(); + }; + const wrapper = mount( +
+ {}} value={{}} onChange={pass} noun="layout" /> +
+ ); + const newValue = btoa( + JSON.stringify({ + layout: "RosOut!cuuf9u", + savedProps: {}, + globalData: {}, + }) + ); + wrapper.find(".textarea").simulate("change", { target: { value: newValue } }); + wrapper + .find("Button[children='Apply']") + .first() + .simulate("click"); + expect(wrapper.find(".is-danger").exists()).toBe(false); + }); }); diff --git a/packages/webviz-core/src/components/ValidatedInput.js b/packages/webviz-core/src/components/ValidatedInput.js new file mode 100644 index 000000000..2d9118133 --- /dev/null +++ b/packages/webviz-core/src/components/ValidatedInput.js @@ -0,0 +1,213 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import * as React from "react"; +import styled from "styled-components"; + +import Dropdown from "webviz-core/src/components/Dropdown"; +import Flex from "webviz-core/src/components/Flex"; +import { validationErrorToString, type ValidationResult } from "webviz-core/src/components/validators"; +import colors from "webviz-core/src/styles/colors.module.scss"; +import YAML from "webviz-core/src/util/yaml"; + +export const EDIT_FORMAT = { JSON: "json", YAML: "yaml" }; + +const SEditBox = styled.div` + display: flex; + flex-direction: column; + min-height: 200px; + max-height: 800px; +`; +// TODO(Audrey): work with design to update Dropdown UI +const STransparentDropdownButton = styled.div` + padding-top: 6px; + display: inline-flex; + button { + background: transparent; + display: inline-flex; + } +`; +const StyledTextarea = styled.textarea` + flex: 1 1 auto; + resize: none; +`; +const SError = styled.div` + color: ${colors.red}; + padding: 8px 4px; +`; + +type Value = Object; +type OnChange = (obj: Object) => void; +type ParseAndStringifyFn = { + stringify: (obj: Object) => string, + parse: (val: string) => any, +}; +export type EditFormat = $Values; +export type BaseProps = { + dataValidator?: (data: any) => ?ValidationResult, + inputStyle?: { [attr: string]: string | number }, + onChange?: OnChange, + onError?: (err: string) => void, + value: Value, +}; +type Props = BaseProps & { + format: EditFormat, + children?: React.Node, // addition UI next to the format select + onSelectFormat?: (format: EditFormat) => void, +}; + +/** + * The base component for ValidatedInput which handles the value change, data validation + * and data stringifying/parsing. Any external value change will cause the input string to change + * and trigger new validations. Only valid internal value change will call onChange. Any data processing + * and validation error will trigger onError. + */ +export function ValidatedInputBase({ + dataValidator = (data) => {}, + inputStyle = {}, + onChange, + onError, + parse, + stringify, + value, +}: BaseProps & ParseAndStringifyFn) { + const [error, setError] = React.useState(""); + const [inputStr, setInputStr] = React.useState(""); + const prevIncomingVal = React.useRef(""); + const inputRef = React.useRef(null); + + // validate the input string, and setError or call onChange if needed + const memorizedInputValidation = React.useCallback( + (newInputVal: string, onChange?: OnChange) => { + let newVal; + let newError; + // parse the empty string directly as empty array or object for validation and onChange callback + if (newInputVal.trim() === "") { + newVal = Array.isArray(value) ? [] : {}; + } else { + try { + newVal = parse(newInputVal); + } catch (e) { + newError = e.message; + } + } + + if (newError) { + setError(newError); + return; + } + setError(""); // clear the previous error + const validationResult = dataValidator(newVal); + if (validationResult) { + setError(validationErrorToString(validationResult)); + return; + } + if (onChange) { + onChange(newVal); + } + }, + [dataValidator, parse, value] + ); + + // whenever the incoming value changes, we'll compare the new value with prevIncomingVal, and reset local state values if they are different + React.useEffect( + () => { + if (value !== prevIncomingVal.current) { + let newVal = ""; + let newError; + try { + newVal = stringify(value); + } catch (e) { + newError = `Error stringifying the new value, using "" as default. ${e.message}`; + } + setInputStr(newVal); + prevIncomingVal.current = value; + if (newError) { + setError(newError); + return; + } + // try to validate if successfully stringified the new value + memorizedInputValidation(newVal); + } + }, + [value, prevIncomingVal, stringify, memorizedInputValidation] + ); + + React.useEffect( + () => { + if (onError && error) { + onError(error); + } + }, + [error, onError] + ); + + function handleChange(e) { + setInputStr(e.target.value); + memorizedInputValidation(e.target.value, onChange); + } + + // scroll to the bottom when the text gets too long + React.useEffect( + () => { + const inputElem = inputRef.current; + if (inputElem) { + inputElem.scrollTop = inputElem.scrollHeight; + } + }, + [inputStr] + ); + + return ( + <> + + {error && {error}} + + ); +} + +function JsonInput(props: BaseProps) { + function stringify(val) { + return JSON.stringify(val, null, 2); + } + return ; +} + +export function YamlInput(props: BaseProps) { + return ; +} + +// An enhanced input component that allows editing values in json or yaml format with custom validations +export default function ValidatedInput({ format = EDIT_FORMAT.JSON, onSelectFormat, children, ...rest }: Props) { + const InputComponent = format === EDIT_FORMAT.JSON ? JsonInput : YamlInput; + return ( + + + {children} + + + {Object.keys(EDIT_FORMAT).map((key) => ( + + ))} + + + + + + + + ); +} + +// For component consumers that don't care about maintaining the editFormat state, use this instead +export function UncontrolledValidatedInput({ format = EDIT_FORMAT.YAML, ...rest }: Props) { + const [editFormat, setEditFormat] = React.useState(format); + return setEditFormat(newFormat)} />; +} diff --git a/packages/webviz-core/src/components/ValidatedInput.stories.js b/packages/webviz-core/src/components/ValidatedInput.stories.js new file mode 100644 index 000000000..31ae859fa --- /dev/null +++ b/packages/webviz-core/src/components/ValidatedInput.stories.js @@ -0,0 +1,81 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import { storiesOf } from "@storybook/react"; +import * as React from "react"; +import { withScreenshot } from "storybook-chrome-screenshot"; + +import ValidatedInput, { EDIT_FORMAT, type EditFormat } from "./ValidatedInput"; +import Flex from "webviz-core/src/components/Flex"; +import { createValidator, isNumber, type ValidationResult } from "webviz-core/src/components/validators"; + +const INPUT_OBJ = { id: 1, name: "foo" }; +const json = EDIT_FORMAT.JSON; +const yaml = EDIT_FORMAT.YAML; + +function myValidator(data: Object = {}): ?ValidationResult { + const rules = { id: [isNumber] }; + const validator = createValidator(rules); + const result = validator(data); + return Object.keys(result).length === 0 ? undefined : result; +} + +function Box({ children }) { + return
{children}
; +} + +function ControlExample({ format = EDIT_FORMAT.JSON }: { format?: EditFormat }) { + const [value, setValue] = React.useState(INPUT_OBJ); + React.useEffect(() => { + setTimeout(() => { + setValue({ id: 2, name: "bar" }); + }, 10); + }, []); + + return ( + + + + ); +} + +storiesOf("", module) + .addDecorator(withScreenshot()) + .add("default", () => { + return ( + + + + + + + + + ); + }) + .add("with dataValidator (show validation error after mount)", () => { + const invalidValue = { id: "not number", name: "foo" }; + return ( + + + + + + + + + ); + }) + .add("value change affects the input value", () => { + return ( + + ; + ; + + ); + }); diff --git a/packages/webviz-core/src/components/validator.js b/packages/webviz-core/src/components/validator.js deleted file mode 100644 index f60d7c7a8..000000000 --- a/packages/webviz-core/src/components/validator.js +++ /dev/null @@ -1,72 +0,0 @@ -// @flow -// -// Copyright (c) 2019-present, GM Cruise LLC -// -// This source code is licensed under the Apache License, Version 2.0, -// found in the LICENSE file in the root directory of this source tree. -// You may not use this file except in compliance with the License. - -type Rules = { - [name: string]: Function[], -}; - -function isEmpty(value: any) { - return value == null; -} - -export const isNumber = (value: any) => (!isEmpty(value) && typeof value !== "number" ? "must be a number" : undefined); -export const isBoolean = (value: any) => - !isEmpty(value) && typeof value !== "boolean" ? `must be "true" or "false"` : undefined; - -export const isNumberArray = (expectArrLen: number = 0) => (value: any) => { - if (Array.isArray(value)) { - if (value.length !== expectArrLen) { - return `must contain ${expectArrLen} array items`; - } - for (const item of value) { - if (typeof item !== "number") { - return `must contain only numbers in the array. "${item}" is not a number.`; - } - } - } -}; - -export const isOrientation = (value: any) => { - const isNumberArrayErr = isNumberArray(4)(value); - if (isNumberArrayErr) { - return isNumberArrayErr; - } - if (value) { - const isValidQuaternion = value.reduce((memo, item) => memo + item * item, 0) === 1; - if (!isValidQuaternion) { - return "must be valid quaternion"; - } - } -}; - -// return the first error -const join = (rules) => (value, data) => rules.map((rule) => rule(value, data)).filter((error) => !!error)[0]; - -export const createValidator = (rules: Rules) => { - return (data: Object = {}) => { - const errors = {}; - Object.keys(rules).forEach((key) => { - // concat enables both functions and arrays of functions - const rule = join([].concat(rules[key])); - const error = rule(data[key], data); - if (error) { - errors[key] = error; - } - }); - return errors; - }; -}; - -export type ValidationResult = { - [fieldName: string]: string, -}; - -export const validationErrorToString = (validationResult: ValidationResult): string => - Object.keys(validationResult) - .map((key) => `${key}: ${validationResult[key]}`) - .join(", "); diff --git a/packages/webviz-core/src/components/validators.js b/packages/webviz-core/src/components/validators.js new file mode 100644 index 000000000..21e9c155a --- /dev/null +++ b/packages/webviz-core/src/components/validators.js @@ -0,0 +1,143 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. +import { isEqual } from "lodash"; + +type Rules = { + [name: string]: Function[], +}; + +function isEmpty(value: any) { + return value == null; +} +export const isRequired = (value: any) => (value == null ? "is required" : undefined); + +export const isNumber = (value: any) => (!isEmpty(value) && typeof value !== "number" ? "must be a number" : undefined); +export const isBoolean = (value: any) => + !isEmpty(value) && typeof value !== "boolean" ? `must be "true" or "false"` : undefined; + +export const isNumberArray = (expectArrLen: number = 0) => (value: any) => { + if (Array.isArray(value)) { + if (value.length !== expectArrLen) { + return `must contain ${expectArrLen} array items`; + } + for (const item of value) { + if (typeof item !== "number") { + return `must contain only numbers in the array. "${item}" is not a number.`; + } + } + } +}; + +export const isOrientation = (value: any) => { + const isNumberArrayErr = isNumberArray(4)(value); + if (isNumberArrayErr) { + return isNumberArrayErr; + } + if (value) { + const isValidQuaternion = value.reduce((memo, item) => memo + item * item, 0) === 1; + if (!isValidQuaternion) { + return "must be valid quaternion"; + } + } +}; + +// return the first error +const join = (rules) => (value, data) => rules.map((rule) => rule(value, data)).filter((error) => !!error)[0]; + +export const createValidator = (rules: Rules) => { + return (data: Object = {}) => { + const errors = {}; + Object.keys(rules).forEach((key) => { + // concat enables both functions and arrays of functions + const rule = join([].concat(rules[key])); + const error = rule(data[key], data); + if (error) { + errors[key] = error; + } + }); + return errors; + }; +}; + +export type ValidationResult = + | string + | { + [fieldName: string]: string, + }; + +export const validationErrorToString = (validationResult: ValidationResult): string => + typeof validationResult === "string" + ? validationResult + : Object.keys(validationResult) + // $FlowFixMe + .map((key) => `${key}: ${validationResult[key]}`) + .join(", "); + +export const cameraStateValidator = (jsonData: any): ?ValidationResult => { + const data = typeof jsonData !== "object" ? {} : jsonData; + const rules = { + distance: [isNumber], + perspective: [isBoolean], + phi: [isNumber], + thetaOffset: [isNumber], + target: [isNumberArray(3)], + targetOffset: [isNumberArray(3)], + targetOrientation: [isOrientation], + }; + const validator = createValidator(rules); + const result = validator(data); + + return Object.keys(result).length === 0 ? undefined : result; +}; + +const isXYPointArray = (value: any) => { + if (Array.isArray(value)) { + for (const item of value) { + if (!item || !item.x || !item.y) { + return `must contain x and y points`; + } + if (typeof item.x !== "number" || typeof item.y !== "number") { + return `x and y points must be numbers`; + } + } + } else { + return "must be an array of x and y points"; + } +}; + +const isPolygons = (value: any) => { + if (Array.isArray(value)) { + for (const item of value) { + const error = isXYPointArray(item); + if (error) { + return error; + } + } + } else { + return "must be an array of nested x and y points"; + } +}; + +// validate the polygons must be a nested array of xy points +export const polygonPointsValidator = (jsonData: any): ?ValidationResult => { + if (!jsonData || isEqual(jsonData, []) || isEqual(jsonData, {})) { + return undefined; + } + const rules = { polygons: [isPolygons] }; + const validator = createValidator(rules); + const result = validator({ polygons: jsonData }); + return Object.keys(result).length === 0 ? undefined : result.polygons; +}; + +export const point2DValidator = (jsonData: any): ?ValidationResult => { + const data = typeof jsonData !== "object" ? {} : jsonData; + const rules = { x: [isRequired, isNumber], y: [isRequired, isNumber] }; + const validator = createValidator(rules); + const result = validator(data); + return Object.keys(result).length === 0 ? undefined : result; +}; diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/cameraStateValidator.test.js b/packages/webviz-core/src/components/validators.test.js similarity index 50% rename from packages/webviz-core/src/panels/ThreeDimensionalViz/cameraStateValidator.test.js rename to packages/webviz-core/src/components/validators.test.js index 404b67ae2..9a90d0d0f 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/cameraStateValidator.test.js +++ b/packages/webviz-core/src/components/validators.test.js @@ -6,28 +6,25 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. -import cameraStateValidator from "./cameraStateValidator"; +import { cameraStateValidator, polygonPointsValidator, point2DValidator } from "./validators"; describe("cameraStateValidator", () => { - it("returns no error for empty object input", () => { + it("returns undefined for empty object input", () => { const cameraState = {}; expect(cameraStateValidator(cameraState)).toBe(undefined); }); - it("returns error if one field is invalid", () => { const cameraState = { distance: "abc" }; expect(cameraStateValidator(cameraState)).toEqual({ distance: "must be a number", }); }); - it("returns the first error if one field has multiple errors", () => { const cameraState = { targetOrientation: [1, 1, 1] }; expect(cameraStateValidator(cameraState)).toEqual({ targetOrientation: "must contain 4 array items", }); }); - it("returns error if the vec3/vec4 values are set but are invalid", () => { const cameraState = { targetOffset: ["invalid"] }; expect(cameraStateValidator(cameraState)).toEqual({ @@ -44,7 +41,6 @@ describe("cameraStateValidator", () => { targetOrientation: "must contain 4 array items", }); }); - it("combines errors from different fields", () => { const cameraState = { distance: "abc", targetOffset: [1, 12, "121"], targetOrientation: [1, 1, 1] }; expect(cameraStateValidator(cameraState)).toEqual({ @@ -62,3 +58,46 @@ describe("cameraStateValidator", () => { }); }); }); + +describe("polygonPointsValidator", () => { + it("returns undefined for valid input", async () => { + expect(polygonPointsValidator([[{ x: 1, y: 2 }]])).toEqual(undefined); + }); + it("returns undefined for null or empty input", async () => { + expect(polygonPointsValidator([])).toEqual(undefined); + expect(polygonPointsValidator()).toEqual(undefined); + expect(polygonPointsValidator("")).toEqual(undefined); + expect(polygonPointsValidator({})).toEqual(undefined); + }); + it("returns error for non-array input", async () => { + expect(polygonPointsValidator(123)).toEqual("must be an array of nested x and y points"); + expect(polygonPointsValidator([{}])).toEqual("must be an array of x and y points"); + }); + it("returns error for non-number input", async () => { + expect(polygonPointsValidator([[{ x: "1", y: 1 }, { x: 2, y: 2 }, { x: 3, y: 3 }]])).toEqual( + "x and y points must be numbers" + ); + }); + it("returns error when missing input for x/y point", async () => { + expect(polygonPointsValidator([[{ y: 1 }, { x: 2, y: 2 }, { x: 3, y: 3 }]])).toEqual("must contain x and y points"); + }); +}); + +describe("point2DValidator", () => { + it("returns undefined for valid input", async () => { + expect(point2DValidator({ x: 1, y: 2 })).toEqual(undefined); + }); + it("returns error for non-array input", async () => { + expect(point2DValidator({})).toEqual({ x: "is required", y: "is required" }); + expect(point2DValidator()).toEqual({ x: "is required", y: "is required" }); + expect(point2DValidator("")).toEqual({ x: "is required", y: "is required" }); + expect(point2DValidator([])).toEqual({ x: "is required", y: "is required" }); + expect(point2DValidator(123)).toEqual({ x: "is required", y: "is required" }); + }); + + it("returns error when x/y field validation fails", async () => { + expect(point2DValidator({ x: 1 })).toEqual({ y: "is required" }); + expect(point2DValidator({ x: "1" })).toEqual({ x: "must be a number", y: "is required" }); + expect(point2DValidator({ x: 1, y: "2" })).toEqual({ y: "must be a number" }); + }); +}); diff --git a/packages/webviz-core/src/loadWebviz.js b/packages/webviz-core/src/loadWebviz.js index e53f2cc18..081e4b9a9 100644 --- a/packages/webviz-core/src/loadWebviz.js +++ b/packages/webviz-core/src/loadWebviz.js @@ -27,6 +27,7 @@ const defaultHooks = { const ThreeDimensionalViz = require("webviz-core/src/panels/ThreeDimensionalViz").default; const RawMessages = require("webviz-core/src/panels/RawMessages").default; const { ndash } = require("webviz-core/src/util/entities"); + const Note = require("webviz-core/src/panels/Note").default; return [ { title: "rosout", component: Rosout }, @@ -39,6 +40,7 @@ const defaultHooks = { { title: `Diagnostics ${ndash} Detail`, component: DiagnosticStatusPanel }, { title: "Webviz Internals", component: Internals }, { title: "Number of Renders", component: NumberOfRenders, hideFromList: true }, + { title: "Notes", component: Note }, ]; }, helpPageFootnote: () => null, @@ -132,7 +134,6 @@ const defaultHooks = { const Root = require("webviz-core/src/components/Root").default; return ; }, - skipBrowserConfirmation: () => false, topicsWithIncorrectHeaders: () => [], heavyDatatypesWithNoTimeDependency: () => [ "sensor_msgs/PointCloud2", @@ -145,6 +146,10 @@ const defaultHooks = { onPanelSwap: () => {}, onPanelSplit: () => {}, onPanelDrag: () => {}, + getWorkerDataProviderWorker: () => { + return require("webviz-core/src/players/WorkerDataProvider.worker"); + }, + getAdditionalDataProviders: () => {}, }; let hooks = defaultHooks; @@ -169,9 +174,11 @@ export function loadWebviz(hooksToSet) { require("webviz-core/src/styles/global.scss"); const prepareForScreenshots = require("webviz-core/src/stories/prepareForScreenshots").default; const installChartjs = require("webviz-core/src/util/installChartjs").default; + const installDevtoolsFormatters = require("webviz-core/src/util/installDevtoolsFormatters").default; installChartjs(); prepareForScreenshots(); // For integration screenshot tests. + installDevtoolsFormatters(); hooks.load(); @@ -199,7 +206,7 @@ export function loadWebviz(hooksToSet) { // From https://stackoverflow.com/a/4900484 const chromeMatch = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./); const chromeVersion = chromeMatch ? parseInt(chromeMatch[2], 10) : 0; - if (chromeVersion < MINIMUM_CHROME_VERSION && !hooks.skipBrowserConfirmation()) { + if (chromeVersion < MINIMUM_CHROME_VERSION) { Confirm({ title: "Update your browser", prompt: diff --git a/packages/webviz-core/src/panels/ImageView/CameraModel.js b/packages/webviz-core/src/panels/ImageView/CameraModel.js index 45a8f0b7d..7873db9fd 100644 --- a/packages/webviz-core/src/panels/ImageView/CameraModel.js +++ b/packages/webviz-core/src/panels/ImageView/CameraModel.js @@ -15,27 +15,28 @@ const DISTORTION_STATE = { type DistortionState = $Values; -type InitializedData = { - D: number[], - K: number[], - P: number[], - R: number[], -}; - // Essentially a copy of ROSPinholeCameraModel // but only the relevant methods, i.e. // fromCameraInfo() and unrectifyPoint() // http://docs.ros.org/diamondback/api/image_geometry/html/c++/pinhole__camera__model_8cpp_source.html export default class PinholeCameraModel { _distortionState: DistortionState = DISTORTION_STATE.NONE; - _transformMarkers: boolean = false; - initializedData: ?InitializedData = null; + D: number[] = []; + K: number[] = []; + P: number[] = []; + R: number[] = []; // Mostly copied from `fromCameraInfo` // http://docs.ros.org/diamondback/api/image_geometry/html/c++/pinhole__camera__model_8cpp_source.html#l00062 - constructor(info: CameraInfo, transformMarkers: boolean) { + constructor(info: CameraInfo) { const { binning_x, binning_y, roi, distortion_model, D, K, P, R } = info; + if (distortion_model === "") { + // Allow CameraInfo with no model to indicate no distortion + this._distortionState = DISTORTION_STATE.NONE; + return; + } + // Binning = 0 is considered the same as binning = 1 (no binning). const binningX = binning_x ? binning_x : 1; const binningY = binning_y ? binning_y : 1; @@ -44,52 +45,39 @@ export default class PinholeCameraModel { const adjustRoi = roi.x_offset !== 0 || roi.y_offset !== 0; if (adjustBinning || adjustRoi) { - console.warn( + throw new Error( "Failed to initialize camera model: unable to handle adjusted binning and adjusted roi camera models." ); - return; } // See comments about Tx = 0, Ty = 0 in // http://docs.ros.org/melodic/api/sensor_msgs/html/msg/CameraInfo.html if (P[3] !== 0 || P[7] !== 0) { - console.warn( + throw new Error( "Failed to initialize camera model: projection matrix implies non monocular camera - cannot handle at this time." ); - return; } // Figure out how to handle the distortion if (distortion_model === "plumb_bob" || distortion_model === "rational_polynomial") { - this._distortionState = info.D[0] === 0.0 ? DISTORTION_STATE.NONE : DISTORTION_STATE.CALIBRATED; + this._distortionState = D[0] === 0.0 ? DISTORTION_STATE.NONE : DISTORTION_STATE.CALIBRATED; } else { - console.warn( + throw new Error( "Failed to initialize camera model: distortion_model is unknown, only plumb_bob and rational_polynomial are supported." ); - return; } - this.initializedData = { D, K, P, R }; - this._transformMarkers = transformMarkers; + this.D = D; + this.P = P; + this.R = R; + this.K = K; } - maybeUnrectifyPoint({ x: rectX, y: rectY }: Point): { x: number, y: number } { - if (!this._transformMarkers) { - return { x: rectX, y: rectY }; - } - - const initializedData = this.initializedData; - - if (!initializedData) { - throw new Error( - "Camera model is not initialized, check cameraModel.initializedData before calling cameraModel.maybeUnrectifyPoint." - ); - } - + unrectifyPoint({ x: rectX, y: rectY }: Point): { x: number, y: number } { if (this._distortionState === DISTORTION_STATE.NONE) { return { x: rectX, y: rectY }; } - const { P, R, D, K } = initializedData; + const { P, R, D, K } = this; const fx = P[0]; const fy = P[5]; const cx = P[2]; diff --git a/packages/webviz-core/src/panels/ImageView/ImageCanvas.js b/packages/webviz-core/src/panels/ImageView/ImageCanvas.js index 372c71599..5f13d07a7 100644 --- a/packages/webviz-core/src/panels/ImageView/ImageCanvas.js +++ b/packages/webviz-core/src/panels/ImageView/ImageCanvas.js @@ -6,78 +6,83 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. -import { isEqual, omit } from "lodash"; import React from "react"; +import shallowequal from "shallowequal"; +import styled from "styled-components"; import CameraModel from "./CameraModel"; -import { decodeYUV, decodeBGR, decodeFloat1c, decodeRGGB } from "./decodings"; +import { + decodeYUV, + decodeBGR, + decodeFloat1c, + decodeBayerRGGB8, + decodeBayerBGGR8, + decodeBayerGBRG8, + decodeBayerGRBG8, + decodeMono8, + decodeMono16, +} from "./decodings"; import styles from "./ImageCanvas.module.scss"; import { type ImageViewPanelHooks } from "./index"; import ContextMenu from "webviz-core/src/components/ContextMenu"; import Menu, { Item } from "webviz-core/src/components/Menu"; import { getGlobalHooks } from "webviz-core/src/loadWebviz"; -import type { ImageMarker, CameraInfo, Color } from "webviz-core/src/types/Messages"; +import colors from "webviz-core/src/styles/colors.module.scss"; +import type { ImageMarker, Color, Point } from "webviz-core/src/types/Messages"; import type { Message } from "webviz-core/src/types/players"; +import { downloadFiles } from "webviz-core/src/util"; +import debouncePromise from "webviz-core/src/util/debouncePromise"; -type Props = { +type Props = {| topic: string, image: ?Message, - cameraInfo: ?CameraInfo, - markers: Message[], + markerData: ?{| + markers: Message[], + originalWidth: ?number, // null means no scaling is needed (use the image's size) + originalHeight: ?number, // null means no scaling is needed (use the image's size) + cameraModel: ?CameraModel, // null means no transformation is needed + |}, panelHooks?: ImageViewPanelHooks, - transformMarkers: boolean, -}; +|}; -type State = { - cameraModel: ?CameraModel, - prevCameraInfo: ?CameraInfo, - prevTransformMarkers: boolean, -}; +type State = {| + error: ?Error, +|}; function toRGBA(color: Color) { const { r, g, b, a } = color; return `rgba(${r}, ${g}, ${b}, ${a || 1})`; } +function maybeUnrectifyPoint(cameraModel: ?CameraModel, point: Point): { x: number, y: number } { + if (cameraModel) { + return cameraModel.unrectifyPoint(point); + } + return point; +} + +const SErrorMessage = styled.div` + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + position: absolute; + align-items: center; + justify-content: center; + color: ${colors.red}; +`; + export default class ImageCanvas extends React.Component { _canvasRef = React.createRef(); - _ready: boolean = true; - _droppedFrame: boolean = false; - static defaultProps = { - markers: [], + state = { + error: undefined, }; - state = { cameraModel: null, prevCameraInfo: null, prevTransformMarkers: false }; - - static getDerivedStateFromProps({ cameraInfo, topic, transformMarkers }: Props, prevState: State) { - if (!cameraInfo && prevState.prevCameraInfo) { - return { - prevCameraInfo: cameraInfo, - cameraModel: null, - transformMarkers: false, - }; - } - // only reset the cameraModel when cameraInfo or transformMarkers change - if ( - cameraInfo && - (transformMarkers !== prevState.prevTransformMarkers || - cameraInfo !== prevState.prevCameraInfo || - (!prevState.prevCameraInfo || !isEqual(omit(cameraInfo, "header"), omit(prevState.prevCameraInfo, "header")))) - ) { - return { - prevTransformMarkers: transformMarkers, - prevCameraInfo: cameraInfo, - cameraModel: new CameraModel(cameraInfo, transformMarkers), - }; - } - - return null; - } - decodeMessageToBitmap = async (msg: any): Promise => { let image: ImageData | Image | Blob | void; - const { data: rawData } = msg.message; + const { data: rawData, is_bigendian } = msg.message; if (rawData instanceof Uint8Array) { // Binary message processing if (msg.datatype === "sensor_msgs/Image") { @@ -87,9 +92,19 @@ export default class ImageCanvas extends React.Component { switch (encoding) { case "yuv422": decodeYUV(rawData, width, height, image.data); break; case "bgr8": decodeBGR(rawData, width, height, image.data); break; - case "32FC1": decodeFloat1c(rawData, width, height, image.data); break; - case "bayer_rggb8": decodeRGGB(rawData, width, height, image.data); break; - default: break; + case "32FC1": decodeFloat1c(rawData, width, height, is_bigendian, image.data); break; + case "bayer_rggb8": decodeBayerRGGB8(rawData, width, height, image.data); break; + case "bayer_bggr8": decodeBayerBGGR8(rawData, width, height, image.data); break; + case "bayer_gbrg8": decodeBayerGBRG8(rawData, width, height, image.data); break; + case "bayer_grbg8": decodeBayerGRBG8(rawData, width, height, image.data); break; + case "mono8": + case "8UC1": + decodeMono8(rawData, width, height, image.data); break; + case "mono16": + case "16UC1": + decodeMono16(rawData, width, height, is_bigendian, image.data); break; + default: + throw new Error(`Unsupported encoding ${encoding}`); } } else if (msg.datatype === "sensor_msgs/CompressedImage") { image = new Blob([rawData], { type: `image/${msg.message.format}` }); @@ -126,10 +141,8 @@ export default class ImageCanvas extends React.Component { }; paintBitmap = (bitmap: ?ImageBitmap) => { - const { cameraInfo: info } = this.props; - const { cameraModel } = this.state; + const { markerData } = this.props; const canvas = this._canvasRef.current; - const cameraModelWithInitializedData = cameraModel && cameraModel.initializedData ? cameraModel : null; if (!canvas) { return; @@ -139,26 +152,38 @@ export default class ImageCanvas extends React.Component { return; } const ctx = canvas.getContext("2d"); - if (info && info.width && info.height) { - this.resizeCanvas(info.width, info.height); - ctx.save(); - ctx.scale(info.width / bitmap.width, info.height / bitmap.height); - ctx.drawImage(bitmap, 0, 0); - ctx.restore(); - ctx.save(); - if (cameraModelWithInitializedData) { - this.paintMarkers(ctx, cameraModelWithInitializedData); - ctx.restore(); - } - } else { + + if (!markerData) { this.resizeCanvas(bitmap.width, bitmap.height); ctx.drawImage(bitmap, 0, 0); + return; + } + + const { markers, cameraModel } = markerData; + let { originalWidth, originalHeight } = markerData; + if (originalWidth == null) { + originalWidth = bitmap.width; + } + if (originalHeight == null) { + originalHeight = bitmap.height; + } + + this.resizeCanvas(originalWidth, originalHeight); + ctx.save(); + ctx.scale(originalWidth / bitmap.width, originalHeight / bitmap.height); + ctx.drawImage(bitmap, 0, 0); + ctx.restore(); + ctx.save(); + try { + this.paintMarkers(ctx, markers, cameraModel); + } catch (err) { + console.warn("error painting markers:", err); + } finally { + ctx.restore(); } - bitmap.close(); }; - paintMarkers(ctx: CanvasRenderingContext2D, cameraModel: CameraModel) { - const { markers } = this.props; + paintMarkers(ctx: CanvasRenderingContext2D, markers: Message[], cameraModel: ?CameraModel) { const imageViewHooks = this.props.panelHooks || getGlobalHooks().perPanelHooks().ImageView; for (const msg of markers) { @@ -176,12 +201,12 @@ export default class ImageCanvas extends React.Component { } } - paintMarker(ctx: CanvasRenderingContext2D, marker: ImageMarker, cameraModel: CameraModel) { + paintMarker(ctx: CanvasRenderingContext2D, marker: ImageMarker, cameraModel: ?CameraModel) { switch (marker.type) { case 0: { // CIRCLE ctx.beginPath(); - const { x, y } = cameraModel.maybeUnrectifyPoint(marker.position); + const { x, y } = maybeUnrectifyPoint(cameraModel, marker.position); ctx.arc(x, y, marker.scale, 0, 2 * Math.PI); if (marker.thickness <= 0) { ctx.fillStyle = toRGBA(marker.outline_color); @@ -202,8 +227,8 @@ export default class ImageCanvas extends React.Component { ctx.strokeStyle = toRGBA(marker.outline_color); ctx.lineWidth = marker.thickness; for (let i = 0; i < marker.points.length; i += 2) { - const { x: x1, y: y1 } = cameraModel.maybeUnrectifyPoint(marker.points[i]); - const { x: x2, y: y2 } = cameraModel.maybeUnrectifyPoint(marker.points[i + 1]); + const { x: x1, y: y1 } = maybeUnrectifyPoint(cameraModel, marker.points[i]); + const { x: x2, y: y2 } = maybeUnrectifyPoint(cameraModel, marker.points[i + 1]); ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); @@ -218,10 +243,10 @@ export default class ImageCanvas extends React.Component { break; } ctx.beginPath(); - const { x, y } = cameraModel.maybeUnrectifyPoint(marker.points[0]); + const { x, y } = maybeUnrectifyPoint(cameraModel, marker.points[0]); ctx.moveTo(x, y); for (let i = 1; i < marker.points.length; i++) { - const { x, y } = cameraModel.maybeUnrectifyPoint(marker.points[i]); + const { x, y } = maybeUnrectifyPoint(cameraModel, marker.points[i]); ctx.lineTo(x, y); } if (marker.type === 3) { @@ -247,7 +272,7 @@ export default class ImageCanvas extends React.Component { const size = marker.scale || 4; if (marker.outline_colors && marker.outline_colors.length === marker.points.length) { for (let i = 0; i < marker.points.length; i++) { - const { x, y } = cameraModel.maybeUnrectifyPoint(marker.points[i]); + const { x, y } = maybeUnrectifyPoint(cameraModel, marker.points[i]); ctx.fillStyle = toRGBA(marker.outline_colors[i]); ctx.beginPath(); ctx.arc(x, y, size, 0, 2 * Math.PI); @@ -256,7 +281,7 @@ export default class ImageCanvas extends React.Component { } else { ctx.beginPath(); for (let i = 0; i < marker.points.length; i++) { - const { x, y } = cameraModel.maybeUnrectifyPoint(marker.points[i]); + const { x, y } = maybeUnrectifyPoint(cameraModel, marker.points[i]); ctx.arc(x, y, size, 0, 2 * Math.PI); ctx.closePath(); } @@ -268,7 +293,7 @@ export default class ImageCanvas extends React.Component { case 5: { // TEXT (our own extension on visualization_msgs/Marker) - const { x, y } = cameraModel.maybeUnrectifyPoint(marker.position); + const { x, y } = maybeUnrectifyPoint(cameraModel, marker.position); const fontSize = marker.scale * 12; const padding = 4 * marker.scale; @@ -313,40 +338,33 @@ export default class ImageCanvas extends React.Component { document.removeEventListener("visibilitychange", this._onVisibilityChange); } - shouldComponentUpdate(nextProps: Props) { - return ( - nextProps.transformMarkers !== this.props.transformMarkers || - // shallow equality to avoid comparing image data - nextProps.image !== this.props.image || - // deep equality since camera info is re-published but never actually changes - !isEqual(nextProps.cameraInfo, this.props.cameraInfo) || - // shallow equality because marker list may be rebuilt with the same markers - nextProps.markers.length !== this.props.markers.length || - nextProps.markers.some((marker, i) => marker !== this.props.markers[i]) - ); - } - componentDidUpdate(prevProps: Props) { - this.renderCurrentImage(); + const imageChanged = !shallowequal(prevProps, this.props, (a, b, key) => { + if (key === "markerData") { + return shallowequal(a, b, (a, b, key) => { + if (key === "markers") { + return shallowequal(a, b); + } + }); + } + }); + if (imageChanged) { + this.renderCurrentImage(); + } } downloadImage = () => { const { topic, image } = this.props; const canvas = this._canvasRef.current; - const { body } = document; // satisfy flow - if (!body || !canvas || !image) { + if (!canvas || !image) { return; } // context: https://stackoverflow.com/questions/37135417/download-canvas-as-png-in-fabric-js-giving-network-error - // create a link element to download data - const link = document.createElement("a"); // read the canvas data as an image (png) canvas.toBlob((blob) => { - const url = URL.createObjectURL(blob); - link.setAttribute("href", url); // name the image the same name as the topic // note: the / characters in the file name will be replaced with _ // by the browser @@ -354,16 +372,7 @@ export default class ImageCanvas extends React.Component { const topicName = topic.slice(1); const stamp = image.message.header ? image.message.header.stamp : { sec: 0, nsec: 0 }; const filename = `${topicName}-${stamp.sec}-${stamp.nsec}`; - link.setAttribute("download", filename); - link.style.display = "none"; - body.appendChild(link); - // click the link to trigger a download - link.click(); - window.requestAnimationFrame(() => { - // remove the link after triggering download - body.removeChild(link); - URL.revokeObjectURL(url); - }); + downloadFiles([blob], filename); }); }; @@ -379,40 +388,35 @@ export default class ImageCanvas extends React.Component { ); }; - renderCurrentImage() { - if (!this._ready) { - console.warn("Dropped frame on image canvas"); - this._droppedFrame = true; - return; - } - + renderCurrentImage = debouncePromise(async () => { const { image } = this.props; if (!image) { this.clearCanvas(); return; } - this._ready = false; - this._droppedFrame = false; - - this.decodeMessageToBitmap(image) - .then((bitmap) => { - this.paintBitmap(bitmap); - this._ready = true; - if (this._droppedFrame) { - console.warn("Retrying render of dropped frame"); - this.renderCurrentImage(); - this._droppedFrame = false; - } - }) - .catch((err) => { - console.warn(`failed to decode image on ${image.topic}:`, err); - this.clearCanvas(); - this._ready = true; - }); - } + try { + const bitmap = await this.decodeMessageToBitmap(image); + this.paintBitmap(bitmap); + if (bitmap) { + bitmap.close(); + } + if (this.state.error) { + this.setState({ error: undefined }); + } + } catch (error) { + console.warn(`failed to decode image on ${image.topic}:`, error); + this.clearCanvas(); + this.setState({ error }); + } + }); render() { - return ; + return ( +
+ {this.state.error && Error: {this.state.error.message}} + +
+ ); } } diff --git a/packages/webviz-core/src/panels/ImageView/ImageCanvas.stories.js b/packages/webviz-core/src/panels/ImageView/ImageCanvas.stories.js index 0286c8202..f9ab1776e 100644 --- a/packages/webviz-core/src/panels/ImageView/ImageCanvas.stories.js +++ b/packages/webviz-core/src/panels/ImageView/ImageCanvas.stories.js @@ -11,6 +11,7 @@ import { range } from "lodash"; import * as React from "react"; import { withScreenshot } from "storybook-chrome-screenshot"; +import CameraModel from "webviz-core/src/panels/ImageView/CameraModel"; import ImageCanvas from "webviz-core/src/panels/ImageView/ImageCanvas"; const cameraInfo = { @@ -190,28 +191,130 @@ const markers = [ const topics = ["/camera_front_medium/image_rect_color_compressed", "/storybook_image"]; +function BayerStory({ encoding }: { encoding: string }) { + const width = 256; + const height = 200; + const data = new Uint8Array(height * width); + for (let row = 0; row < height; row++) { + for (let col = 0; col < width; col++) { + const r = Math.max(0, 1 - Math.hypot(1 - row / height, col / width)) * 256; + const g = Math.max(0, 1 - Math.hypot(row / height, 1 - col / width)) * 256; + const b = Math.max(0, 1 - Math.hypot(1 - row / height, 1 - col / width)) * 256; + switch (encoding) { + case "bayer_rggb8": + data[row * width + col] = row % 2 === 0 ? (col % 2 === 0 ? r : g) : col % 2 === 0 ? g : b; + break; + case "bayer_bggr8": + data[row * width + col] = row % 2 === 0 ? (col % 2 === 0 ? b : g) : col % 2 === 0 ? g : r; + break; + case "bayer_gbrg8": + data[row * width + col] = row % 2 === 0 ? (col % 2 === 0 ? g : b) : col % 2 === 0 ? r : g; + break; + case "bayer_grbg8": + data[row * width + col] = row % 2 === 0 ? (col % 2 === 0 ? g : r) : col % 2 === 0 ? b : g; + break; + } + } + } + return ( + + ); +} + +function Mono16Story({ bigEndian }: { bigEndian: boolean }) { + const width = 200; + const height = 100; + const data = new Uint8Array(width * height * 2); + const view = new DataView(data.buffer); + for (let r = 0; r < height; r++) { + for (let c = 0; c < width; c++) { + const val = Math.round(Math.min(1, Math.hypot(c / width, r / height)) * 10000); + view.setUint16(2 * (r * width + c), val, true); + } + } + return ( + + ); +} + storiesOf("", module) .addDecorator(withScreenshot()) .add("markers", () => (

original markers

{}} topic={topics[0]} image={imageMessage} - cameraInfo={cameraInfo} - markers={markers} + markerData={{ + markers, + cameraModel: null, + originalWidth: null, + originalHeight: null, + }} />

transformed markers

{}} topic={topics[1]} image={imageMessage} - cameraInfo={cameraInfo} - markers={markers} + markerData={{ + markers, + cameraModel: new CameraModel(cameraInfo), + originalWidth: null, + originalHeight: null, + }} + /> +

markers with different original image size

+
- )); + )) + .add("error state", () => { + return ( + + ); + }) + .add("mono16 big endian", () => ) + .add("mono16 little endian", () => ) + .add("bayer_rggb8", () => ) + .add("bayer_bggr8", () => ) + .add("bayer_gbrg8", () => ) + .add("bayer_grbg8", () => ); diff --git a/packages/webviz-core/src/panels/ImageView/decodings.js b/packages/webviz-core/src/panels/ImageView/decodings.js index 09bfde295..a2016adc8 100644 --- a/packages/webviz-core/src/panels/ImageView/decodings.js +++ b/packages/webviz-core/src/panels/ImageView/decodings.js @@ -51,12 +51,18 @@ export function decodeBGR(bgr: Uint8Array, width: number, height: number, output } } -export function decodeFloat1c(gray: Uint8Array, width: number, height: number, output: Uint8ClampedArray) { +export function decodeFloat1c( + gray: Uint8Array, + width: number, + height: number, + is_bigendian: boolean, + output: Uint8ClampedArray +) { const view = new DataView(gray.buffer, gray.byteOffset); let outIdx = 0; for (let i = 0; i < width * height * 4; i += 4) { - const val = view.getFloat32(i, true) * 255; + const val = view.getFloat32(i, !is_bigendian) * 255; output[outIdx++] = val; output[outIdx++] = val; output[outIdx++] = val; @@ -64,7 +70,55 @@ export function decodeFloat1c(gray: Uint8Array, width: number, height: number, o } } -export function decodeRGGB(rggb: Uint8Array, width: number, height: number, output: Uint8ClampedArray) { +export function decodeMono8(mono8: Uint8Array, width: number, height: number, output: Uint8ClampedArray) { + let inIdx = 0; + let outIdx = 0; + + for (let i = 0; i < width * height; i++) { + const ch = mono8[inIdx++]; + output[outIdx++] = ch; + output[outIdx++] = ch; + output[outIdx++] = ch; + output[outIdx++] = 255; + } +} + +export function decodeMono16( + mono16: Uint8Array, + width: number, + height: number, + is_bigendian: boolean, + output: Uint8ClampedArray +) { + const view = new DataView(mono16.buffer, mono16.byteOffset); + + let outIdx = 0; + for (let i = 0; i < width * height * 2; i += 2) { + let val = view.getUint16(i, !is_bigendian); + + // For now, just assume values are in the range 0-10000, consistent with image_view's default. + // TODO: support dynamic range adjustment and/or user-selectable range + // References: + // https://github.com/ros-perception/image_pipeline/blob/42266892502427eb566a4dffa61b009346491ce7/image_view/src/nodes/image_view.cpp#L80-L88 + // https://github.com/ros-visualization/rqt_image_view/blob/fe076acd265a05c11c04f9d04392fda951878f54/src/rqt_image_view/image_view.cpp#L582 + // https://github.com/ros-visualization/rviz/blob/68b464fb6571b8760f91e8eca6fb933ba31190bf/src/rviz/image/ros_image_texture.cpp#L114 + val = (val / 10000) * 255; + + output[outIdx++] = val; + output[outIdx++] = val; + output[outIdx++] = val; + output[outIdx++] = 255; + } +} + +// Specialize the Bayer decode function to a certain encoding. For performance reasons, we use +// new Function() -- this is about 20% faster than a switch statement and .bind(). +function makeSpecializedDecodeBayer( + tl, + tr, + bl, + br +): (data: Uint8Array, width: number, height: number, output: Uint8ClampedArray) => void { // We probably can't afford real debayering/demosaicking, so do something simpler // The input array look like a single-plane array of pixels. However, each pixel represents a one particular color // for a group of pixels in the 2x2 region. For 'rggb', there color representatio for the 2x2 region looks like: @@ -79,16 +133,27 @@ export function decodeRGGB(rggb: Uint8Array, width: number, height: number, outp // // We'll do something much simpler. For each group of 2x2, we're replicate the R and B values for all pixels. // For the two row, we'll replicate G0 for the green channels, and replicate G1 for the bottom row. - + // eslint-disable-next-line no-new-func + return (new Function( + "data", + "width", + "height", + "output", + ` for (let i = 0; i < height / 2; i++) { let inIdx = i * 2 * width; let outTopIdx = i * 2 * width * 4; // Addresses top row let outBottomIdx = (i * 2 + 1) * width * 4; // Addresses bottom row for (let j = 0; j < width / 2; j++) { - const r = rggb[inIdx++]; - const g0 = rggb[inIdx++]; - const g1 = rggb[inIdx + width - 2]; - const b = rggb[inIdx + width - 1]; + const tl = data[inIdx++]; + const tr = data[inIdx++]; + const bl = data[inIdx + width - 2]; + const br = data[inIdx + width - 1]; + + const ${tl} = tl; + const ${tr} = tr; + const ${bl} = bl; + const ${br} = br; // Top row output[outTopIdx++] = r; @@ -112,5 +177,11 @@ export function decodeRGGB(rggb: Uint8Array, width: number, height: number, outp output[outBottomIdx++] = b; output[outBottomIdx++] = 255; } - } + }` + ): any); } + +export const decodeBayerRGGB8 = makeSpecializedDecodeBayer("r", "g0", "g1", "b"); +export const decodeBayerBGGR8 = makeSpecializedDecodeBayer("b", "g0", "g1", "r"); +export const decodeBayerGBRG8 = makeSpecializedDecodeBayer("g0", "b", "r", "g1"); +export const decodeBayerGRBG8 = makeSpecializedDecodeBayer("g0", "r", "b", "g1"); diff --git a/packages/webviz-core/src/panels/ImageView/decodings.test.js b/packages/webviz-core/src/panels/ImageView/decodings.test.js new file mode 100644 index 000000000..796975355 --- /dev/null +++ b/packages/webviz-core/src/panels/ImageView/decodings.test.js @@ -0,0 +1,23 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import { decodeBayerRGGB8 } from "./decodings"; + +describe("decodeBayer*()", () => { + it("works for simple data", () => { + const output = new Uint8ClampedArray(2 * 2 * 4); + decodeBayerRGGB8(new Uint8Array([10, 20, 30, 40]), 2, 2, output); + expect(output).toStrictEqual( + // prettier-ignore + new Uint8ClampedArray([ + 10, 20, 40, 255, 10, 20, 40, 255, + 10, 30, 40, 255, 10, 30, 40, 255, + ]) + ); + }); +}); diff --git a/packages/webviz-core/src/panels/ImageView/index.help.md b/packages/webviz-core/src/panels/ImageView/index.help.md index 5f2feab0b..af394b871 100644 --- a/packages/webviz-core/src/panels/ImageView/index.help.md +++ b/packages/webviz-core/src/panels/ImageView/index.help.md @@ -2,6 +2,8 @@ The Image View panel displays images from `sensor_msgs/Image` or `sensor_msgs/CompressedImage` topics. +16-bit images (`16UC1`) are currently displayed assuming the values fall into the 0–10000 range, consistent with the defaults of the ROS `image_view` tool. + The **markers** dropdown can be used to toggle on and off topics with `visualization_msgs/ImageMarkers`, which will be overlayed on top of the selected image topic. Note that markers are only available if the `CameraInfo` for the selected camera is being published. If the image is unrectified, the markers will be transformed by webviz based on `CameraInfo`. The **scale** dropdown allows you to adjust the scale of the image. Since the images are fairly large, it is recommended to use the smallest scale you need to conserve bandwidth. Currently using `scale 1.0` can negatively impact rendering speed, particularly if you have a limited bandwidth connection. (Note: only applies to streaming connections, which is not part of the open source version yet.) diff --git a/packages/webviz-core/src/panels/ImageView/index.js b/packages/webviz-core/src/panels/ImageView/index.js index 2ac9a18a3..8d2729605 100644 --- a/packages/webviz-core/src/panels/ImageView/index.js +++ b/packages/webviz-core/src/panels/ImageView/index.js @@ -11,11 +11,13 @@ import CheckboxMarkedIcon from "@mdi/svg/svg/checkbox-marked.svg"; import MenuDownIcon from "@mdi/svg/svg/menu-down.svg"; import WavesIcon from "@mdi/svg/svg/waves.svg"; import cx from "classnames"; -import { sortBy, pick, get } from "lodash"; +import { sortBy, pick, get, isEqual, omit } from "lodash"; +import memoizeOne from "memoize-one"; import * as React from "react"; import { createSelector } from "reselect"; import styled from "styled-components"; +import CameraModel from "./CameraModel"; import ImageCanvas from "./ImageCanvas"; import imageCanvasStyles from "./ImageCanvas.module.scss"; import helpContent from "./index.help.md"; @@ -38,8 +40,10 @@ import PanelToolbar from "webviz-core/src/components/PanelToolbar"; import { getGlobalHooks } from "webviz-core/src/loadWebviz"; import inScreenshotTests from "webviz-core/src/stories/inScreenshotTests"; import colors from "webviz-core/src/styles/colors.module.scss"; -import type { Topic } from "webviz-core/src/types/players"; +import type { CameraInfo } from "webviz-core/src/types/Messages"; +import type { Topic, Message } from "webviz-core/src/types/players"; import naturalSort from "webviz-core/src/util/naturalSort"; +import reportError from "webviz-core/src/util/reportError"; import { formatTimeRaw } from "webviz-core/src/util/time"; import toggle from "webviz-core/src/util/toggle"; @@ -158,6 +162,63 @@ function renderEmptyState(cameraTopic: string, markerTopics: string[], shouldSyn ); } +const getCameraModel = memoizeOne( + function getCameraModel(cameraInfo: ?CameraInfo): ?CameraModel { + if (!cameraInfo) { + return null; + } + try { + return new CameraModel(cameraInfo); + } catch (err) { + reportError(`Failed to initialize camera model from CameraInfo`, err, "user"); + return null; + } + }, + ([cameraInfo]: mixed[], [prevCameraInfo]: mixed[]) => { + return isEqual(omit(cameraInfo, "header"), omit(prevCameraInfo, "header")); + } +); + +export function buildMarkerData(markers: Message[], scale: number, transformMarkers: boolean, cameraInfo: ?CameraInfo) { + if (markers.length === 0) { + return { + markers, + cameraModel: null, + originalHeight: undefined, + originalWidth: undefined, + }; + } + let cameraModel; + if (transformMarkers) { + cameraModel = getCameraModel(cameraInfo); + if (!cameraModel) { + return null; + } + } + + // Markers can only be rendered if we know the original size of the image. + let originalWidth; + let originalHeight; + if (cameraInfo && cameraInfo.width && cameraInfo.height) { + // Prefer using CameraInfo can be used to determine the image size. + originalWidth = cameraInfo.width; + originalHeight = cameraInfo.height; + } else if (scale === 1) { + // Otherwise, if scale === 1, the image was not downsampled, so the size of the bitmap is accurate. + originalWidth = undefined; + originalHeight = undefined; + } else { + return null; + } + + return { + markers, + cameraModel, + originalWidth, + originalHeight, + }; +} + class ImageView extends React.Component { static panelType = "ImageViewPanel"; static defaultConfig = getGlobalHooks().perPanelHooks().ImageView.defaultConfig; @@ -216,12 +277,17 @@ class ImageView extends React.Component { } renderMarkerDropdown(allItemsByPath: MessageHistoryItemsByPath) { - const { cameraTopic, enabledMarkerNames } = this.props.config; + const { cameraTopic, enabledMarkerNames, scale } = this.props.config; const imageTopicsByNamespace = imageTopicsByNamespaceSelector(this.props.topics); const markerTopics = markerTopicSelector(this.props.topics, this.props.config.panelHooks); const allCameraNamespaces = imageTopicsByNamespace ? [...imageTopicsByNamespace.keys()] : []; const markerOptions = getMarkerOptions(cameraTopic, (markerTopics || []).map((t) => t.name), allCameraNamespaces); + + const cameraInfoTopic = getCameraInfoTopic(cameraTopic); + const hasCameraInfo = cameraInfoTopic && get(allItemsByPath, [cameraInfoTopic, "length"]) > 0; + const missingRequiredCameraInfo = scale !== 1 && !hasCameraInfo; + return ( { onChange={this.onToggleMarkerName} value={enabledMarkerNames} text={markerOptions.length > 0 ? "markers" : "no markers"} - tooltip={markerOptions.length === 0 ? "camera_info must be available to render markers" : undefined} - disabled={markerOptions.length === 0}> + tooltip={ + missingRequiredCameraInfo + ? "camera_info is required when image resolution is set to less than 100%.\nResolution can be changed in the panel settings." + : undefined + } + disabled={markerOptions.length === 0 || missingRequiredCameraInfo}> {markerOptions.map((option) => ( : } @@ -304,7 +374,7 @@ class ImageView extends React.Component { const markerHistorySize = shouldSynchronize ? MARKER_QUEUE_SIZE : 1; return ( - + {({ itemsByPath: imageItemsByPath }: MessageHistoryData) => ( { ); } + if (cameraInfoTopic) { + // _renderToolbar needs access to camera info + allItemsByPath[cameraInfoTopic] = markerItemsByPath[cameraInfoTopic]; + } + + const markerData = buildMarkerData( + markerTopics.map((topic) => get(allItemsByPath[topic], [0, "message"])).filter(Boolean), + scale, + transformMarkers, + cameraInfoTopic ? get(markerItemsByPath, [cameraInfoTopic, 0, "message", "message"]) : null + ); return ( <> {this._renderToolbar(allItemsByPath)} get(allItemsByPath[topic], [0, "message"])).filter(Boolean)} + markerData={markerData} /> {(containsOpen) => { diff --git a/packages/webviz-core/src/panels/ImageView/index.test.js b/packages/webviz-core/src/panels/ImageView/index.test.js new file mode 100644 index 000000000..7a24ac88f --- /dev/null +++ b/packages/webviz-core/src/panels/ImageView/index.test.js @@ -0,0 +1,67 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import { buildMarkerData } from "./index"; + +describe("buildMarkerData", () => { + const cameraInfo = { + width: 10, + height: 5, + binning_x: 0, + binning_y: 0, + roi: { + x_offset: 0, + y_offset: 0, + height: 0, + width: 0, + do_rectify: false, + }, + distortion_model: ("": any), + D: [], + K: [], + P: [], + R: [], + }; + + const marker = { + topic: "foo", + datatype: "bar", + op: "message", + receiveTime: { sec: 0, nsec: 0 }, + message: {}, + }; + + it("returns nothing if markers are empty", () => { + expect(buildMarkerData([], 1, true, cameraInfo)).toEqual({ + markers: [], + originalHeight: undefined, + originalWidth: undefined, + cameraModel: null, + }); + }); + + it("requires cameraInfo if transformMarkers is true", () => { + expect(buildMarkerData([marker], 1, false, null)).toEqual({ + markers: [marker], + cameraModel: undefined, + originalWidth: undefined, + originalHeight: undefined, + }); + expect(buildMarkerData([marker], 1, true, null)).toEqual(null); + }); + + it("requires either cameraInfo or scale==1", () => { + expect(buildMarkerData([marker], 1, false, cameraInfo)).toEqual({ + markers: [marker], + cameraModel: undefined, + originalWidth: 10, + originalHeight: 5, + }); + expect(buildMarkerData([marker], 0.5, false, null)).toEqual(null); + }); +}); diff --git a/packages/webviz-core/src/panels/Note/index.help.md b/packages/webviz-core/src/panels/Note/index.help.md new file mode 100644 index 000000000..78b0436e8 --- /dev/null +++ b/packages/webviz-core/src/panels/Note/index.help.md @@ -0,0 +1,3 @@ +# Note Panel + +Provides a place for you to write and save your notes. diff --git a/packages/webviz-core/src/panels/Note/index.js b/packages/webviz-core/src/panels/Note/index.js new file mode 100644 index 000000000..6bcbb45d1 --- /dev/null +++ b/packages/webviz-core/src/panels/Note/index.js @@ -0,0 +1,119 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import LeadPencilIcon from "@mdi/svg/svg/lead-pencil.svg"; +import _ from "lodash"; +import React, { PureComponent } from "react"; +import styled from "styled-components"; + +import helpContent from "./index.help.md"; +import Button from "webviz-core/src/components/Button"; +import Flex from "webviz-core/src/components/Flex"; +import Icon from "webviz-core/src/components/Icon"; +import Panel from "webviz-core/src/components/Panel"; +import PanelToolbar from "webviz-core/src/components/PanelToolbar"; + +type Config = { + noteText: string, +}; + +type Props = { + config: Config, + saveConfig: ($Shape) => void, +}; + +type State = { + isEditing: boolean, + currentNoteText: string, +}; + +const STextAreaContainer = styled.div` + flex-grow: 1; + padding: 12px 0; +`; + +const STextArea = styled.textarea` + width: 100%; + height: 100%; + resize: none; +`; + +class Note extends PureComponent { + static panelType = "Note"; + static defaultConfig = { + noteText: "", + }; + + state = { + isEditing: false, + currentNoteText: this.props.config.noteText, + }; + + changeNoteText = (e) => { + this.setState({ + currentNoteText: e.target.value, + }); + }; + + saveNoteText = () => { + this.props.saveConfig({ + noteText: this.state.currentNoteText, + }); + this.setState({ + isEditing: false, + }); + }; + + startEditing = () => { + this.setState({ isEditing: true }); + }; + + clearNote = (e) => { + this.setState({ + currentNoteText: "", + }); + }; + + render() { + return ( + + + {this.state.isEditing ? ( +
+ + + + + + + +
+ ) : ( + +

{this.state.currentNoteText}

+ + + +
+ )} +
+ ); + } +} + +export default Panel(Note); diff --git a/packages/webviz-core/src/panels/Note/index.stories.js b/packages/webviz-core/src/panels/Note/index.stories.js new file mode 100644 index 000000000..8beef74e0 --- /dev/null +++ b/packages/webviz-core/src/panels/Note/index.stories.js @@ -0,0 +1,31 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import { storiesOf } from "@storybook/react"; +import React from "react"; +import { withScreenshot } from "storybook-chrome-screenshot"; + +import Note from "./index"; +import PanelSetup from "webviz-core/src/stories/PanelSetup"; + +storiesOf("", module) + .addDecorator(withScreenshot()) + .add("default", () => { + const fixture = { + topics: [], + datatypes: { + "std_msgs/String": [{ name: "noteText", type: "string" }], + }, + frame: {}, + }; + return ( + + + + ); + }); diff --git a/packages/webviz-core/src/panels/Plot/PlotChart.js b/packages/webviz-core/src/panels/Plot/PlotChart.js index 63713e522..23a0fb370 100644 --- a/packages/webviz-core/src/panels/Plot/PlotChart.js +++ b/packages/webviz-core/src/panels/Plot/PlotChart.js @@ -17,10 +17,9 @@ import MessageHistory, { type MessageHistoryData, type MessageHistoryItemsByPath, } from "webviz-core/src/components/MessageHistory"; -import TimeBasedChart from "webviz-core/src/components/TimeBasedChart"; -import type { TimeBasedChartTooltipData } from "webviz-core/src/components/TimeBasedChart"; +import TimeBasedChart, { type TimeBasedChartTooltipData } from "webviz-core/src/components/TimeBasedChart"; import derivative from "webviz-core/src/panels/Plot/derivative"; -import type { PlotPath } from "webviz-core/src/panels/Plot/internalTypes"; +import { type PlotPath, isReferenceLinePlotPathType } from "webviz-core/src/panels/Plot/internalTypes"; import { lightColor, lineColors } from "webviz-core/src/util/plotColors"; import { subtractTimes, toSec } from "webviz-core/src/util/time"; @@ -30,93 +29,152 @@ export type PlotChartPoint = {| tooltip: TimeBasedChartTooltipData, |}; -const getDatasets = (paths: PlotPath[], itemsByPath: MessageHistoryItemsByPath, startTime: Time) => { - return paths - .map((path: PlotPath, index: number) => { - if (!path.enabled) { - return null; - } +const Y_AXIS_ID = "Y_AXIS_ID"; - let points: PlotChartPoint[] = []; - let showLine = true; +function getDatasetFromMessagePlotPath( + path: PlotPath, + itemsByPath: MessageHistoryItemsByPath, + index: number, + startTime: Time +) { + let points: PlotChartPoint[] = []; + let showLine = true; - for (const item of itemsByPath[path.value]) { - const timestamp = getTimestampForMessage(item.message, path.timestampMethod); - if (timestamp === null) { - continue; - } + for (const item of itemsByPath[path.value]) { + const timestamp = getTimestampForMessage(item.message, path.timestampMethod); + if (timestamp === null) { + continue; + } - for (const { value, path, constantName } of item.queriedData) { - if (typeof value === "number" || typeof value === "boolean") { - points.push({ - x: toSec(subtractTimes(timestamp, startTime)), - y: Number(value), - tooltip: { item, path, value, constantName, startTime }, - }); - } - } - // If we have added more than one point for this message, make it a scatter plot. - if (item.queriedData.length > 1) { - showLine = false; - } + for (const { value, path, constantName } of item.queriedData) { + if (typeof value === "number" || typeof value === "boolean") { + points.push({ + x: toSec(subtractTimes(timestamp, startTime)), + y: Number(value), + tooltip: { item, path, value, constantName, startTime }, + }); } + } + // If we have added more than one point for this message, make it a scatter plot. + if (item.queriedData.length > 1) { + showLine = false; + } + } + + if (path.value.includes(".@derivative")) { + if (showLine) { + points = derivative(points); + } else { + // If we have a scatter plot, we can't take the derivative, so instead show nothing + // (nothing is better than incorrect data). + points = []; + } + } + + return { + borderColor: lineColors[index % lineColors.length], + label: path.value, + key: index.toString(), + showLine, + fill: false, + borderWidth: 1, + pointRadius: 1.5, + pointHoverRadius: 3, + pointBackgroundColor: lightColor(lineColors[index % lineColors.length]), + pointBorderColor: "transparent", + data: points, + }; +} - if (path.value.includes(".@derivative")) { - if (showLine) { - points = derivative(points); - } else { - // If we have a scatter plot, we can't take the derivative, so instead show nothing - // (nothing is better than incorrect data). - points = []; - } +// A "reference line" plot path is a numeric value. It creates a horizontal line on the plot at the specified value. +function getAnnotationFromReferenceLine(path: PlotPath, index: number) { + return { + type: "line", + drawTime: "beforeDatasetsDraw", + scaleID: Y_AXIS_ID, + label: path.value, + borderColor: lineColors[index % lineColors.length], + borderDash: [5, 5], + borderWidth: 1, + mode: "horizontal", + value: Number.parseFloat(path.value), + }; +} + +function getDatasets(paths: PlotPath[], itemsByPath: MessageHistoryItemsByPath, startTime: Time) { + return paths + .map((path: PlotPath, index: number) => { + if (!path.enabled) { + return null; + } else if (!isReferenceLinePlotPathType(path)) { + return getDatasetFromMessagePlotPath(path, itemsByPath, index, startTime); } + return null; + }) + .filter(Boolean); +} - return { - borderColor: lineColors[index % lineColors.length], - label: path.value, - key: index.toString(), - showLine, - fill: false, - borderWidth: 1, - pointRadius: 1.5, - pointHoverRadius: 3, - pointBackgroundColor: lightColor(lineColors[index % lineColors.length]), - pointBorderColor: "transparent", - data: points, - }; +function getAnnotations(paths: PlotPath[]) { + return paths + .map((path: PlotPath, index: number) => { + if (!path.enabled) { + return null; + } else if (isReferenceLinePlotPathType(path)) { + return getAnnotationFromReferenceLine(path, index); + } + return null; }) .filter(Boolean); -}; +} // min/maxYValue is NaN when it's unset, and an actual number otherwise. const yAxes = createSelector( - (minMax): { minYValue: number, maxYValue: number } => minMax, - ({ minYValue, maxYValue }: { minYValue: number, maxYValue: number }) => [ - { - ticks: { - suggestedMin: isNaN(minYValue) ? undefined : minYValue, - suggestedMax: isNaN(maxYValue) ? undefined : maxYValue, - precision: 3, - callback: (val, idx, vals) => (idx === 0 || idx === vals.length - 1 ? "" : `${Math.round(val * 1000) / 1000}`), - }, - gridLines: { - color: "rgba(255, 255, 255, 0.2)", - zeroLineColor: "rgba(255, 255, 255, 0.2)", + (params): { minYValue: number, maxYValue: number, isYAxisLocked: boolean, scaleId: string } => params, + ({ + minYValue, + maxYValue, + isYAxisLocked, + scaleId, + }: { + minYValue: number, + maxYValue: number, + isYAxisLocked: boolean, + scaleId: string, + }) => { + const min = isNaN(minYValue) ? undefined : minYValue; + const max = isNaN(maxYValue) ? undefined : maxYValue; + return [ + { + id: scaleId, + ticks: { + min: isYAxisLocked ? min : undefined, + max: isYAxisLocked ? max : undefined, + suggestedMin: isYAxisLocked ? undefined : min, + suggestedMax: isYAxisLocked ? undefined : max, + precision: 3, + callback: (val, idx, vals) => + idx === 0 || idx === vals.length - 1 ? "" : `${Math.round(val * 1000) / 1000}`, + }, + gridLines: { + color: "rgba(255, 255, 255, 0.2)", + zeroLineColor: "rgba(255, 255, 255, 0.2)", + }, }, - }, - ] + ]; + } ); -type PlotChartProps = {| paths: PlotPath[], minYValue: number, maxYValue: number |}; +type PlotChartProps = {| paths: PlotPath[], minYValue: number, maxYValue: number, isYAxisLocked: boolean |}; export default class PlotChart extends PureComponent { render() { - const { paths, minYValue, maxYValue } = this.props; + const { paths, minYValue, maxYValue, isYAxisLocked } = this.props; return ( // Don't filter out disabled paths when passing into , because we still want // easy access to the history when turning the disabled paths back on. path.value)}> {({ itemsByPath, startTime }: MessageHistoryData) => { const datasets = getDatasets(paths, itemsByPath, startTime); + const annotations = getAnnotations(paths); return (
@@ -128,8 +186,9 @@ export default class PlotChart extends PureComponent { width={width} height={height} data={{ datasets }} + annotations={annotations} type="scatter" - yAxes={yAxes({ minYValue, maxYValue })} + yAxes={yAxes({ minYValue, maxYValue, isYAxisLocked, scaleId: Y_AXIS_ID })} /> )} diff --git a/packages/webviz-core/src/panels/Plot/PlotLegend.js b/packages/webviz-core/src/panels/Plot/PlotLegend.js index 4a11e2f7d..61b16bf27 100644 --- a/packages/webviz-core/src/panels/Plot/PlotLegend.js +++ b/packages/webviz-core/src/panels/Plot/PlotLegend.js @@ -12,7 +12,7 @@ import React, { PureComponent } from "react"; import { plotableRosTypes } from "./index"; import styles from "./PlotLegend.module.scss"; import MessageHistory, { type MessageHistoryTimestampMethod } from "webviz-core/src/components/MessageHistory"; -import type { PlotPath } from "webviz-core/src/panels/Plot/internalTypes"; +import { type PlotPath, isReferenceLinePlotPathType } from "webviz-core/src/panels/Plot/internalTypes"; import { lineColors } from "webviz-core/src/util/plotColors"; type PlotLegendProps = {| @@ -44,6 +44,13 @@ export default class PlotLegend extends PureComponent { return (
{paths.map((path: PlotPath, index: number) => { + const isReferenceLinePlotPath = isReferenceLinePlotPathType(path); + let timestampMethod; + // Only allow chosing the timestamp method if it is applicable (not a reference line) and there is at least + // one character typed. + if (!isReferenceLinePlotPath && path.value.length > 0) { + timestampMethod = path.timestampMethod; + } return (
@@ -78,9 +85,11 @@ export default class PlotLegend extends PureComponent { onChange={this._onInputChange} onTimestampMethodChange={this._onInputTimestampMethodChange} validTypes={plotableRosTypes} + placeholder="Enter a topic name or a number" index={index} autoSize - timestampMethod={path.timestampMethod} + disableAutocomplete={isReferenceLinePlotPath} + timestampMethod={timestampMethod} />
diff --git a/packages/webviz-core/src/panels/Plot/PlotMenu.js b/packages/webviz-core/src/panels/Plot/PlotMenu.js new file mode 100644 index 000000000..e65353676 --- /dev/null +++ b/packages/webviz-core/src/panels/Plot/PlotMenu.js @@ -0,0 +1,74 @@ +// @flow +// +// Copyright (c) 2018-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import UnlockIcon from "@mdi/svg/svg/lock-open-outline.svg"; +import LockIcon from "@mdi/svg/svg/lock-outline.svg"; +import cx from "classnames"; +import React from "react"; + +import styles from "./PlotMenu.module.scss"; +import Item from "webviz-core/src/components/Menu/Item"; +import type { PlotConfig } from "webviz-core/src/panels/Plot"; + +function isValidInput(value: string) { + return value === "" || !isNaN(parseFloat(value)); +} + +export default function PlotMenu({ + minYValue, + maxYValue, + isYAxisLocked, + saveConfig, +}: { + minYValue: string, + maxYValue: string, + isYAxisLocked: boolean, + saveConfig: ($Shape) => void, +}) { + const lockIconProps = { + width: 16, + height: 16, + }; + + return ( + <> + saveConfig({ maxYValue: maxYValue === "" ? "10" : "" })}> +
Maximum
+ { + saveConfig({ maxYValue: event.target.value }); + }} + onClick={(event) => event.stopPropagation()} + placeholder="auto" + /> +
+ saveConfig({ minYValue: minYValue === "" ? "-10" : "" })}> +
Minimum
+ { + saveConfig({ minYValue: event.target.value }); + }} + onClick={(event) => event.stopPropagation()} + placeholder="auto" + /> +
+ saveConfig({ isYAxisLocked: !isYAxisLocked })}> +
+ {isYAxisLocked ? "Unlock Y-axis" : "Lock Y-axis"} + + {isYAxisLocked ? : } + +
+
+ + ); +} diff --git a/packages/webviz-core/src/panels/Plot/index.module.scss b/packages/webviz-core/src/panels/Plot/PlotMenu.module.scss similarity index 77% rename from packages/webviz-core/src/panels/Plot/index.module.scss rename to packages/webviz-core/src/panels/Plot/PlotMenu.module.scss index 559b5b5f7..1d88ef6ab 100644 --- a/packages/webviz-core/src/panels/Plot/index.module.scss +++ b/packages/webviz-core/src/panels/Plot/PlotMenu.module.scss @@ -19,3 +19,13 @@ .inputError { color: $red; } + +.lockItem { + display: flex; + align-items: center; + justify-content: space-between; +} + +.lockIcon { + fill: currentColor; +} diff --git a/packages/webviz-core/src/panels/Plot/PlotMenu.stories.js b/packages/webviz-core/src/panels/Plot/PlotMenu.stories.js new file mode 100644 index 000000000..75f77653b --- /dev/null +++ b/packages/webviz-core/src/panels/Plot/PlotMenu.stories.js @@ -0,0 +1,36 @@ +// @flow +// +// Copyright (c) 2018-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import { storiesOf } from "@storybook/react"; +import { noop } from "lodash"; +import * as React from "react"; +import { withScreenshot } from "storybook-chrome-screenshot"; + +import PlotMenu from "webviz-core/src/panels/Plot/PlotMenu"; + +function Wrapper({ children }) { + return
{children}
; +} + +storiesOf("", module) + .addDecorator(withScreenshot()) + .add("With min and max y set", () => ( + + + + )) + .add("With min and max y not set", () => ( + + + + )) + .add("With y axis locked", () => ( + + + + )); diff --git a/packages/webviz-core/src/panels/Plot/index.help.md b/packages/webviz-core/src/panels/Plot/index.help.md index 3a7c8427f..42fd11769 100644 --- a/packages/webviz-core/src/panels/Plot/index.help.md +++ b/packages/webviz-core/src/panels/Plot/index.help.md @@ -6,6 +6,10 @@ The values plotted are specified through Webviz's [topic path syntax](/help/topi The timestamp is taken from either the time the message was received, or the `message.header.stamp` field of the topic, depending on the dropdown. -You can zoom by scrolling, and pan by dragging. Double-click to reset. +You can also enter an arbitrary number, which will add a horizontal line at that y-value. + +You can zoom by scrolling, and pan by dragging. Double-click to reset. To scroll in only the vertical direction (no horizontal scrolling), you can hold the `v` key while scrolling, and to scroll in only the horizontal direction you can hold the `h` key while scrolling. To take the derivative of a value (change per second), use the special `.@derivative` modifier. This does not work with scatter plots (when using slices). + +In the options menu, you can set a minimum and maximum Y-value. By default, the plot will use those bounds unless the data you're looking at extends above the max Y-value or below the min Y-value; in those cases it will automatically expand. If you want to disable this behavior and force the plot to use the exact minimum and maximum Y-values entered, you can "lock" the y-axis in the options menu as well. diff --git a/packages/webviz-core/src/panels/Plot/index.js b/packages/webviz-core/src/panels/Plot/index.js index eb9818ba9..ec67a3564 100644 --- a/packages/webviz-core/src/panels/Plot/index.js +++ b/packages/webviz-core/src/panels/Plot/index.js @@ -6,18 +6,16 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. -import cx from "classnames"; import React, { PureComponent } from "react"; import helpContent from "./index.help.md"; -import styles from "./index.module.scss"; import Flex from "webviz-core/src/components/Flex"; -import Item from "webviz-core/src/components/Menu/Item"; import Panel from "webviz-core/src/components/Panel"; import PanelToolbar from "webviz-core/src/components/PanelToolbar"; import type { PlotPath } from "webviz-core/src/panels/Plot/internalTypes"; import PlotChart from "webviz-core/src/panels/Plot/PlotChart"; import PlotLegend from "webviz-core/src/panels/Plot/PlotLegend"; +import PlotMenu from "webviz-core/src/panels/Plot/PlotMenu"; export const plotableRosTypes = [ "bool", @@ -37,6 +35,7 @@ export type PlotConfig = { paths: PlotPath[], minYValue: string, maxYValue: string, + isYAxisLocked: boolean, }; type Props = { @@ -44,49 +43,13 @@ type Props = { saveConfig: ($Shape) => void, }; -function isValidInput(value: string) { - return value === "" || !isNaN(parseFloat(value)); -} - class Plot extends PureComponent { static panelType = "Plot"; - static defaultConfig = { paths: [], minYValue: "", maxYValue: "" }; - - _renderMenuContent(minYValue: string, maxYValue: string) { - const { saveConfig } = this.props; - - return ( - <> - saveConfig({ maxYValue: maxYValue === "" ? "10" : "" })}> -
Maximum
- { - saveConfig({ maxYValue: event.target.value }); - }} - onClick={(event) => event.stopPropagation()} - placeholder="auto" - /> -
- saveConfig({ minYValue: minYValue === "" ? "-10" : "" })}> -
Minimum
- { - saveConfig({ minYValue: event.target.value }); - }} - onClick={(event) => event.stopPropagation()} - placeholder="auto" - /> -
- - ); - } + static defaultConfig = { paths: [], minYValue: "", maxYValue: "", isYAxisLocked: false }; render() { - const { minYValue, maxYValue } = this.props.config; + const { saveConfig } = this.props; + const { minYValue, maxYValue, isYAxisLocked } = this.props.config; let { paths } = this.props.config; if (!paths.length) { paths = [{ value: "", enabled: true, timestampMethod: "receiveTime" }]; @@ -94,8 +57,24 @@ class Plot extends PureComponent { return ( - - + + } + /> + ); diff --git a/packages/webviz-core/src/panels/Plot/index.stories.js b/packages/webviz-core/src/panels/Plot/index.stories.js index c45722bd8..32095088b 100644 --- a/packages/webviz-core/src/panels/Plot/index.stories.js +++ b/packages/webviz-core/src/panels/Plot/index.stories.js @@ -219,18 +219,35 @@ storiesOf("", module) @@ -242,11 +259,20 @@ storiesOf("", module) @@ -257,9 +283,16 @@ storiesOf("", module) @@ -271,27 +304,126 @@ storiesOf("", module) ); }) - .add("scatter plot plus line graph", () => { + .add("reference line", () => { return ( + + ); + }) + .add("with min and max Y values and YAxis NOT locked", () => { + return ( + + + + ); + }) + .add("with min and max Y values and YAxis locked", () => { + return ( + + + + ); + }) + .add("scatter plot plus line graph plus reference line", () => { + return ( + + @@ -325,9 +457,16 @@ storiesOf("", module) }}> diff --git a/packages/webviz-core/src/panels/Plot/internalTypes.js b/packages/webviz-core/src/panels/Plot/internalTypes.js index 489d4f9c6..eec7579b9 100644 --- a/packages/webviz-core/src/panels/Plot/internalTypes.js +++ b/packages/webviz-core/src/panels/Plot/internalTypes.js @@ -8,4 +8,13 @@ import type { MessageHistoryTimestampMethod } from "webviz-core/src/components/MessageHistory"; -export type PlotPath = {| value: string, enabled: boolean, timestampMethod: MessageHistoryTimestampMethod |}; +export type PlotPath = {| + value: string, + enabled: boolean, + timestampMethod: MessageHistoryTimestampMethod, +|}; + +// A "reference line" plot path is a numeric value. It creates a horizontal line on the plot at the specified value. +export function isReferenceLinePlotPathType(path: PlotPath): boolean { + return !isNaN(Number.parseFloat(path.value)); +} diff --git a/packages/webviz-core/src/panels/Rosout/index.js b/packages/webviz-core/src/panels/Rosout/index.js index de5e1a1e3..be156a214 100644 --- a/packages/webviz-core/src/panels/Rosout/index.js +++ b/packages/webviz-core/src/panels/Rosout/index.js @@ -195,6 +195,8 @@ class RosoutPanel extends PureComponent { cleared={cleared || configChanged} items={this._getFilteredMessages(msgs)} renderRow={this._renderRow} + copyButtonTooltip="Copy rosout to clipboard" + enableCopying />
diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/CameraInfo.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/CameraInfo.js deleted file mode 100644 index 8ffd2fc45..000000000 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/CameraInfo.js +++ /dev/null @@ -1,207 +0,0 @@ -// @flow -// -// Copyright (c) 2018-present, GM Cruise LLC -// -// This source code is licensed under the Apache License, Version 2.0, -// found in the LICENSE file in the root directory of this source tree. -// You may not use this file except in compliance with the License. - -import CodeBracesIcon from "@mdi/svg/svg/code-braces.svg"; -import * as React from "react"; -import { type CameraState } from "regl-worldview"; -import styled from "styled-components"; - -import cameraStateValidator from "./cameraStateValidator"; -import type { ThreeDimensionalVizConfig } from "./index"; -import Button from "webviz-core/src/components/Button"; -import ExpandingToolbar, { ToolGroup } from "webviz-core/src/components/ExpandingToolbar"; -import Flex from "webviz-core/src/components/Flex"; -import JsonInput from "webviz-core/src/components/JsonInput"; -import { getGlobalHooks } from "webviz-core/src/loadWebviz"; -import styles from "webviz-core/src/panels/ThreeDimensionalViz/Layout.module.scss"; -import PositionControl from "webviz-core/src/panels/ThreeDimensionalViz/PositionControl"; -import colors from "webviz-core/src/styles/colors.module.scss"; -import type { SaveConfig } from "webviz-core/src/types/panels"; -import clipboard from "webviz-core/src/util/clipboard"; - -const SRow = styled.div` - display: flex; - align-items: center; -`; - -const SLabel = styled.label` - width: 112px; - margin: 4px 0; -`; -const SValue = styled.span` - color: ${colors.highlight}; -`; - -const SPasteBox = styled.div` - display: flex; - flex-direction: column; - width: 240px; - height: 200px; -`; - -type CamaeraStateInfoProps = { - onAlignXYAxis: () => void, - cameraState: $Shape, -}; - -type CameraPositionInfoProps = { - cameraState: $Shape, - followOrientation: boolean, - followTf?: string | false, - onCameraStateChange: (CameraState) => void, -}; - -type Props = { - expanded?: boolean, - followOrientation: boolean, - followTf?: string | false, - onCameraStateChange: (CameraState) => void, - onExpand: (expanded: boolean) => void, - saveConfig: SaveConfig, - selectedTab: "Position" | "Camera State", - showPasteBox?: boolean, -} & CamaeraStateInfoProps; - -function CameraPositionInfo({ - cameraState, - onCameraStateChange, - followOrientation, - followTf, -}: CameraPositionInfoProps) { - return ( - - - {cameraState.perspective && ( - Disable 3D mode to show the camera center point - )} - {followTf ? ( -

- Following frame {followTf} - {followOrientation && " with orientation"} -

- ) : ( -

Locked to map ({getGlobalHooks().perPanelHooks().ThreeDimensionalViz.rootTransformFrame})

- )} -
- ); -} - -function CamaeraStateInfo({ cameraState, onAlignXYAxis }: CamaeraStateInfoProps) { - return ( - <> - {Object.keys(cameraState) - .sort() - .map((key) => { - let val = cameraState[key]; - if (key === "perspective") { - val = cameraState[key] ? "true" : "false"; - } else if (Array.isArray(cameraState[key])) { - val = cameraState[key].map((x) => x.toFixed(1)).join(", "); - } else if (typeof cameraState[key] === "number") { - val = cameraState[key].toFixed(2); - } - return [key, val]; - }) - .map(([key, val]) => ( - - {key}: {val} - {key === "thetaOffset" && ( - - )} - - ))} - - ); -} - -export default function CameraInfo({ - cameraState, - expanded, - followOrientation, - followTf, - onAlignXYAxis, - onCameraStateChange, - onExpand, - saveConfig, - selectedTab, - showPasteBox: showPasteBoxAlt, -}: Props) { - const [showPasteBox, setShowPasteBox] = React.useState(!!showPasteBoxAlt); - // pasteValue is the actual cameraState object that can be saved to panel config. It's null if the validation fails - const [pasteValue, setPasteValue] = React.useState>(cameraState); - - return ( - } - className={styles.buttons} - expanded={expanded} - selectedTab={selectedTab} - onExpand={onExpand}> - - - - - - {showPasteBox ? ( - - - - ) : ( - - )} - - - - - - - - - ); -} - -CameraInfo.defaultProps = { - selectedTab: "Position", -}; diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/DrawingTools/CameraInfo.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/DrawingTools/CameraInfo.js new file mode 100644 index 000000000..c8b41b855 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/DrawingTools/CameraInfo.js @@ -0,0 +1,192 @@ +// @flow +// +// Copyright (c) 2018-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import { vec3 } from "gl-matrix"; +import { isEqual } from "lodash"; +import * as React from "react"; +import { type CameraState, cameraStateSelectors } from "regl-worldview"; +import styled from "styled-components"; + +import type { ThreeDimensionalVizConfig } from "../index"; +import Button from "webviz-core/src/components/Button"; +import Flex from "webviz-core/src/components/Flex"; +import { UncontrolledValidatedInput, YamlInput } from "webviz-core/src/components/ValidatedInput"; +import { point2DValidator, cameraStateValidator } from "webviz-core/src/components/validators"; +import { getGlobalHooks } from "webviz-core/src/loadWebviz"; +import { Renderer } from "webviz-core/src/panels/ThreeDimensionalViz/index"; +import colors from "webviz-core/src/styles/colors.module.scss"; +import type { SaveConfig, UpdatePanelConfig } from "webviz-core/src/types/panels"; +import clipboard from "webviz-core/src/util/clipboard"; + +const TEMP_VEC3 = [0, 0, 0]; +const ZERO_VEC3 = Object.freeze([0, 0, 0]); + +const LABEL_WIDTH = 112; +const SRow = styled.div` + display: flex; + align-items: center; +`; + +const SLabel = styled.label` + width: ${LABEL_WIDTH}px; + margin: 4px 0; +`; +export const SValue = styled.span` + color: ${colors.highlight}; +`; + +type CameraStateInfoProps = { + cameraState: $Shape, + onAlignXYAxis: () => void, +}; + +export type CameraInfoProps = { + cameraState: $Shape, + followOrientation: boolean, + followOrientation: boolean, + followTf?: string | false, + onAlignXYAxis: () => void, + onCameraStateChange: (CameraState) => void, + saveConfig: SaveConfig, + showCrosshair?: boolean, + updatePanelConfig: UpdatePanelConfig, +}; + +function CameraStateInfo({ cameraState, onAlignXYAxis }: CameraStateInfoProps) { + return ( + <> + {Object.keys(cameraState) + .sort() + .map((key) => { + let val = cameraState[key]; + if (key === "perspective") { + val = cameraState[key] ? "true" : "false"; + } else if (Array.isArray(cameraState[key])) { + val = cameraState[key].map((x) => x.toFixed(1)).join(", "); + } else if (typeof cameraState[key] === "number") { + val = cameraState[key].toFixed(2); + } + return [key, val]; + }) + .map(([key, val]) => ( + + {key}: {val} + {key === "thetaOffset" && ( + + )} + + ))} + + ); +} + +export default function CameraInfo({ + cameraState, + onAlignXYAxis, + onCameraStateChange, + saveConfig, + showPasteBox: showPasteBoxAlt, + followTf, + followOrientation, + showCrosshair, + updatePanelConfig, +}: CameraInfoProps) { + const [edit, setEdit] = React.useState(false); + + const { target, targetOffset } = cameraState; + const targetHeading = cameraStateSelectors.targetHeading(cameraState); + const camPos2D = vec3.add(TEMP_VEC3, target, vec3.rotateZ(TEMP_VEC3, targetOffset, ZERO_VEC3, -targetHeading)); + const camPos2DTrimmed = camPos2D.map((num) => +num.toFixed(2)); + + return ( + + + + + + + {edit ? ( + saveConfig({ cameraState })} + dataValidator={cameraStateValidator} + /> + ) : ( + + + + + Show crosshair: + + saveConfig({ showCrosshair: !showCrosshair })} + /> + + + {showCrosshair && !cameraState.perspective && ( + + + { + const { target, targetOffset } = cameraState; + const targetHeading = cameraStateSelectors.targetHeading(cameraState); + const newPos = [data.x, data.y, 0]; + // extract the targetOffset by subtracting from the target and un-rotating by heading + const newTargetOffset = vec3.rotateZ( + [0, 0, 0], + vec3.sub(TEMP_VEC3, newPos, target), + ZERO_VEC3, + targetHeading + ); + if (!isEqual(targetOffset, newTargetOffset)) { + onCameraStateChange({ + ...cameraState, + targetOffset: newTargetOffset, + }); + } + }} + dataValidator={point2DValidator} + /> + + + )} + + {followTf ? ( + + Following frame: + + {followTf} + {followOrientation && " with orientation"} + + + ) : ( +

Locked to map ({getGlobalHooks().perPanelHooks().ThreeDimensionalViz.rootTransformFrame})

+ )} +
+ )} +
+ ); +} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/DrawingTools/Polygons.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/DrawingTools/Polygons.js new file mode 100644 index 000000000..091ce2167 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/DrawingTools/Polygons.js @@ -0,0 +1,86 @@ +// @flow +// +// Copyright (c) 2018-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import * as React from "react"; +import { PolygonBuilder, Polygon } from "regl-worldview"; +import styled from "styled-components"; + +import type { ThreeDimensionalVizConfig } from "../index"; +import Button from "webviz-core/src/components/Button"; +import ValidatedInput, { type EditFormat } from "webviz-core/src/components/ValidatedInput"; +import { polygonPointsValidator } from "webviz-core/src/components/validators"; +import { SValue } from "webviz-core/src/panels/ThreeDimensionalViz/DrawingTools/CameraInfo"; +import { + polygonsToPoints, + getFormattedString, + pointsToPolygons, + getPolygonLineDistances, +} from "webviz-core/src/panels/ThreeDimensionalViz/utils/drawToolUtils"; +import type { SaveConfig } from "webviz-core/src/types/panels"; +import clipboard from "webviz-core/src/util/clipboard"; + +export type Point2D = {| x: number, y: number |}; + +export const SRow = styled.div` + display: flex; + align-items: center; + padding: 8px 0; +`; + +export const SLabel = styled.label` + width: 80px; + margin: 4px 0; +`; + +type Props = { + saveConfig: SaveConfig, + onSetPolygons: (polygons: Polygon[]) => void, + polygonBuilder: PolygonBuilder, + selectedPolygonEditFormat: EditFormat, +}; + +export default function Polygons({ saveConfig, onSetPolygons, polygonBuilder, selectedPolygonEditFormat }: Props) { + const polygons: Polygon[] = polygonBuilder.polygons; + const [polygonPoints, setPolygonPoints] = React.useState(() => polygonsToPoints(polygons)); + function polygonBuilderOnChange() { + setPolygonPoints(polygonsToPoints(polygons)); + } + polygonBuilder.onChange = polygonBuilderOnChange; + + return ( +
+ saveConfig({ selectedPolygonEditFormat: selectedFormat })} + onChange={(polygonPoints) => { + if (polygonPoints) { + setPolygonPoints(polygonPoints); + onSetPolygons(pointsToPolygons(polygonPoints)); + } + }} + dataValidator={polygonPointsValidator}> + + + + Total length: + {getPolygonLineDistances(polygonPoints).toFixed(2)} m + +

+ + Start drawing by holding ctrl and clicking on the 3D panel. + +

+
+ ); +} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/DrawingTools/index.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/DrawingTools/index.js new file mode 100644 index 000000000..6b5d76860 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/DrawingTools/index.js @@ -0,0 +1,131 @@ +// @flow +// +// Copyright (c) 2018-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import CameraControlIcon from "@mdi/svg/svg/camera-control.svg"; +import PencilIcon from "@mdi/svg/svg/pencil.svg"; +import { keyBy } from "lodash"; +import * as React from "react"; +import { PolygonBuilder, Polygon, type CameraState } from "regl-worldview"; + +import type { ThreeDimensionalVizConfig } from "../index"; +import Polygons from "./Polygons"; +import ExpandingToolbar, { ToolGroup } from "webviz-core/src/components/ExpandingToolbar"; +import Icon from "webviz-core/src/components/Icon"; +import { EDIT_FORMAT, type EditFormat } from "webviz-core/src/components/ValidatedInput"; +import CameraInfo, { type CameraInfoProps } from "webviz-core/src/panels/ThreeDimensionalViz/DrawingTools/CameraInfo"; +import styles from "webviz-core/src/panels/ThreeDimensionalViz/Layout.module.scss"; +import colors from "webviz-core/src/styles/colors.module.scss"; +import type { SaveConfig, UpdatePanelConfig } from "webviz-core/src/types/panels"; + +export type DrawingType = "Polygons" | "Camera"; + +type Config = { + key: string, + type: DrawingType, + icon: string, +}; +// create an config object based on type key so we can access fields easily using DRAWING_CONFIG.Polygon.type +export const DRAWING_CONFIG: { [type: DrawingType]: Config } = keyBy( + [ + { + key: "p", + type: "Polygons", + icon: PencilIcon, + }, + { + key: "c", + type: "Camera", + icon: CameraControlIcon, + }, + ], + (config: Config) => config.type +); + +export type onSetType = (?DrawingType) => void; +export type Point2D = {| x: number, y: number |}; +type Props = { + onCameraStateChange: (CameraState) => void, + onSetPolygons: (polygons: Polygon[]) => void, + polygonBuilder: PolygonBuilder, + saveConfig: SaveConfig, + selectedPolygonEditFormat: EditFormat, + type: ?DrawingType, + updatePanelConfig: UpdatePanelConfig, +} & CameraInfoProps; + +const DEFAULT_SELECTED_TAB = DRAWING_CONFIG.Polygons.type; +// add more drawing shapes later, e.g. Grid, Axes, Crosshairs +export default function DrawingTools({ + cameraState, + followOrientation, + followTf, + onAlignXYAxis, + onCameraStateChange, + onSetPolygons, + polygonBuilder, + saveConfig, + selectedPolygonEditFormat, + showCrosshair, + type, + updatePanelConfig, +}: Props) { + const [selectedTab, setSelectedTab] = React.useState(type || DEFAULT_SELECTED_TAB); + const [expanded, setExpanded] = React.useState(!!type); + + // reset local state when drawing type changes + React.useEffect( + () => { + setExpanded(!!type); + setSelectedTab(type || DEFAULT_SELECTED_TAB); + }, + [type] + ); + + const config = (type && DRAWING_CONFIG[type]) || DRAWING_CONFIG.Polygons; + const IconName = type ? config.icon : PencilIcon; + + return ( + + + + } + className={styles.buttons} + expanded={expanded} + selectedTab={selectedTab} + onSelectTab={(newSelectedTab) => setSelectedTab(newSelectedTab)} + onSetExpanded={(newExpanded) => setExpanded(newExpanded)}> + + + + + + + + ); +} + +DrawingTools.defaultProps = { + selectedPolygonEditFormat: EDIT_FORMAT.YAML, +}; diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/DrawingTools/index.stories.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/DrawingTools/index.stories.js new file mode 100644 index 000000000..3d71fd7dd --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/DrawingTools/index.stories.js @@ -0,0 +1,89 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import { storiesOf } from "@storybook/react"; +import React from "react"; +import { PolygonBuilder, DEFAULT_CAMERA_STATE } from "regl-worldview"; +import { withScreenshot } from "storybook-chrome-screenshot"; + +import DrawingTools, { DRAWING_CONFIG } from "./index"; +import { pointsToPolygons } from "webviz-core/src/panels/ThreeDimensionalViz/utils/drawToolUtils"; + +const polygons = pointsToPolygons([ + [{ x: 1, y: 1 }, { x: 2, y: 2 }, { x: 3, y: 3 }], + [{ x: 4, y: 4 }, { x: 5, y: 5 }, { x: 6, y: 6 }], +]); + +const containerStyle = { + width: 272, + margin: 8, + display: "inline-block", +}; + +const POLYGON_TYPE = DRAWING_CONFIG.Polygons.type; +const CAMERA_TYPE = DRAWING_CONFIG.Camera.type; + +const DEFAULT_PROPS = { + cameraState: DEFAULT_CAMERA_STATE, + expanded: true, + followOrientation: false, + followTf: "some_frame", + onAlignXYAxis: () => {}, + onCameraStateChange: () => {}, + onExpand: () => {}, + onSetPolygons: () => {}, + onSetType: () => {}, + polygonBuilder: new PolygonBuilder(polygons), + saveConfig: () => {}, + selectedPolygonEditFormat: "yaml", + showCrosshair: false, + type: POLYGON_TYPE, + updatePanelConfig: () => {}, +}; + +storiesOf("", module) + .addDecorator(withScreenshot()) + .add("Polygon", () => { + return ( +
+
+ +
+
+ +
+
+ ); + }) + .add("Camera", () => { + return ( +
+
+

Default

+ +
+
+

Follow orientation

+ +
+
+

3D and showCrosshair

+ +
+
+

2D and showCrosshair

+ +
+
+ ); + }); diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/Layout.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/Layout.js index 5a1dab6a6..eabb8067c 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/Layout.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/Layout.js @@ -6,27 +6,33 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. -import BugIcon from "@mdi/svg/svg/bug.svg"; import CloseIcon from "@mdi/svg/svg/close.svg"; -import RulerIcon from "@mdi/svg/svg/ruler.svg"; -import Video3dIcon from "@mdi/svg/svg/video-3d.svg"; import cx from "classnames"; import { vec3, quat } from "gl-matrix"; import * as React from "react"; import Draggable from "react-draggable"; import KeyListener from "react-key-listener"; -import { cameraStateSelectors, type CameraState, type ReglClickInfo, type MouseHandler } from "regl-worldview"; +import { + cameraStateSelectors, + PolygonBuilder, + DrawPolygons, + type CameraState, + type ReglClickInfo, + type MouseHandler, +} from "regl-worldview"; import type { ThreeDimensionalVizConfig } from "."; -import Button from "webviz-core/src/components/Button"; import Icon from "webviz-core/src/components/Icon"; import PanelToolbar from "webviz-core/src/components/PanelToolbar"; import { getGlobalHooks } from "webviz-core/src/loadWebviz"; -import CameraInfo from "webviz-core/src/panels/ThreeDimensionalViz/CameraInfo"; import DebugStats from "webviz-core/src/panels/ThreeDimensionalViz/DebugStats"; +import DrawingTools, { + DRAWING_CONFIG, + type DrawingType, +} from "webviz-core/src/panels/ThreeDimensionalViz/DrawingTools"; import FollowTFControl from "webviz-core/src/panels/ThreeDimensionalViz/FollowTFControl"; import styles from "webviz-core/src/panels/ThreeDimensionalViz/Layout.module.scss"; -import MeasuringTool, { type MeasureInfo } from "webviz-core/src/panels/ThreeDimensionalViz/MeasuringTool"; +import MainToolbar from "webviz-core/src/panels/ThreeDimensionalViz/MainToolbar"; import SceneBuilder, { type TopicSettingsCollection, type TopicSettings, @@ -38,20 +44,27 @@ import Transforms from "webviz-core/src/panels/ThreeDimensionalViz/Transforms"; import TransformsBuilder from "webviz-core/src/panels/ThreeDimensionalViz/TransformsBuilder"; import type { Extensions } from "webviz-core/src/reducers/extensions"; import inScreenshotTests from "webviz-core/src/stories/inScreenshotTests"; -import colors from "webviz-core/src/styles/colors.module.scss"; -import type { SaveConfig } from "webviz-core/src/types/panels"; +import type { SaveConfig, UpdatePanelConfig } from "webviz-core/src/types/panels"; import type { Frame, Topic } from "webviz-core/src/types/players"; import type { MarkerCollector, MarkerProvider } from "webviz-core/src/types/Scene"; import videoRecordingMode from "webviz-core/src/util/videoRecordingMode"; +const POLYGON_TYPE = DRAWING_CONFIG.Polygons.type; +const CAMERA_TYPE = DRAWING_CONFIG.Camera.type; + +type EventName = "onDoubleClick" | "onMouseMove" | "onMouseDown" | "onMouseUp"; + type Props = { autoTextBackgroundColor?: boolean, selections: Selections, frame?: Frame, transforms: Transforms, saveConfig: SaveConfig, + updatePanelConfig: UpdatePanelConfig, + selectedPolygonEditFormat: "json" | "yaml", followTf?: string | false, followOrientation: boolean, + showCrosshair: ?boolean, onFollowChange: (followTf?: string | false, followOrientation?: boolean) => void, onAlignXYAxis: () => void, topicSettings: TopicSettingsCollection, @@ -63,7 +76,6 @@ type Props = { pinTopics: boolean, cameraState: $Shape, onCameraStateChange: (CameraState) => void, - showCameraPosition?: ?boolean, helpContent: React.Node | string, children?: React.Node, @@ -87,75 +99,18 @@ type State = { editedTopics: string[], debug: boolean, - showCameraPosition: boolean, showTopics: boolean, metadata: Object, editTipX: ?number, editTipY: ?number, editTopic: ?Topic, - measureInfo: MeasureInfo, + drawingType: ?DrawingType, + polygonBuilder: PolygonBuilder, }; -class MainToolbar extends React.PureComponent<{| - perspective: boolean, - measuringTool: ?MeasuringTool, - measureInfo: MeasureInfo, - debug: boolean, - onToggleCameraMode: () => void, - onToggleDebug: () => void, -|}> { - render() { - const { - measuringTool, - measureInfo: { measureState }, - debug, - onToggleCameraMode, - onToggleDebug, - perspective = false, - } = this.props; - const cameraModeTip = perspective ? "Switch to 2D camera" : "Switch to 3D camera"; - const measureActive = measureState === "place-start" || measureState === "place-finish"; - - return ( -
- - - {process.env.NODE_ENV === "development" && ( - - )} -
- ); - } -} - export default class Layout extends React.Component implements MarkerProvider { // overall element containing everything in this component el: ?HTMLDivElement; - measuringTool: ?MeasuringTool; static defaultProps = { checkedNodes: [], @@ -172,16 +127,13 @@ export default class Layout extends React.Component implements Mar cachedTopicSettings: {}, editedTopics: [], debug: false, - showCameraPosition: !!this.props.showCameraPosition, showTopics: false, metadata: {}, editTipX: undefined, editTipY: undefined, editTopic: undefined, - measureInfo: { - measureState: "idle", - measurePoints: { start: null, end: null }, - }, + drawingType: null, + polygonBuilder: new PolygonBuilder(), }; static getDerivedStateFromProps(nextProps: Props, prevState: State) { @@ -232,43 +184,35 @@ export default class Layout extends React.Component implements Mar return newState; } - onMouseDown: MouseHandler = (e, args: ?ReglClickInfo) => { - const handler = this.measuringTool && this.measuringTool.canvasMouseDown; - if (handler && args) { - return handler(e, args); - } - const { onMouseDown } = this.props; - if (onMouseDown) { - onMouseDown(e, args); - } + onDoubleClick = (ev: MouseEvent, args: ?ReglClickInfo) => { + this._handleEvent("onDoubleClick", ev, args); + }; + onMouseDown = (ev: MouseEvent, args: ?ReglClickInfo) => { + this._handleEvent("onMouseDown", ev, args); + }; + onMouseMove = (ev: MouseEvent, args: ?ReglClickInfo) => { + this._handleEvent("onMouseMove", ev, args); + }; + onMouseUp = (ev: MouseEvent, args: ?ReglClickInfo) => { + this._handleEvent("onMouseUp", ev, args); }; - onMouseUp: MouseHandler = (e, args: ?ReglClickInfo) => { - const handler = this.measuringTool && this.measuringTool.canvasMouseUp; - if (handler && args) { - return handler(e, args); - } - const { onMouseUp } = this.props; - if (onMouseUp) { - onMouseUp(e, args); - } + _handleDrawPolygons = (eventName: EventName, ev: MouseEvent, args: ?ReglClickInfo) => { + this.state.polygonBuilder[eventName](ev, args); + this.forceUpdate(); }; - onMouseMove: MouseHandler = (e, args: ?ReglClickInfo) => { - const handler = this.measuringTool && this.measuringTool.canvasMouseMove; - if (handler && args) { - return handler(e, args); + _handleEvent = (eventName: EventName, ev: MouseEvent, args: ?ReglClickInfo) => { + const propsHandler = this.props[eventName]; + const { drawingType } = this.state; + if (!args) { + return; } - const { onMouseMove } = this.props; - if (onMouseMove) { - onMouseMove(e, args); + if (drawingType === POLYGON_TYPE) { + this._handleDrawPolygons(eventName, ev, args); } - }; - - onDoubleClick: MouseHandler = (e, args: ?ReglClickInfo) => { - const { onDoubleClick } = this.props; - if (onDoubleClick) { - onDoubleClick(e, args); + if (propsHandler) { + propsHandler(ev, args); } }; @@ -276,18 +220,50 @@ export default class Layout extends React.Component implements Mar "3": () => { this.toggleCameraMode(); }, + [DRAWING_CONFIG.Polygons.key]: () => { + this._toggleDrawing(POLYGON_TYPE); + }, + [DRAWING_CONFIG.Camera.key]: () => { + this._toggleDrawing(CAMERA_TYPE); + }, + Control: () => { + // support default DrawPolygon key + this._toggleDrawing(POLYGON_TYPE); + }, + Escape: () => { + this._exitDrawing(); + }, }; - toggleCameraMode = () => { - const { cameraState, saveConfig } = this.props; - const perspective = !cameraState.perspective; + _toggleDrawing = (drawingType: DrawingType) => { + // can enter into drawing from null or non-new-drawing-type to the new drawingType + const enterDrawing = this.state.drawingType !== drawingType; + this.setState({ drawingType: enterDrawing ? drawingType : null }); + if (drawingType !== CAMERA_TYPE) { + this.switchTo2DCameraIfNeeded(); + } + }; - saveConfig({ cameraState: { ...cameraState, perspective } }); - if (this.measuringTool && perspective) { - this.measuringTool.reset(); + _exitDrawing = () => { + this.setState({ drawingType: null }); + }; + + switchTo2DCameraIfNeeded = () => { + const { + cameraState, + cameraState: { perspective }, + saveConfig, + } = this.props; + if (this.state.drawingType && perspective) { + saveConfig({ cameraState: { ...cameraState, perspective: false } }); } }; + toggleCameraMode = () => { + const { cameraState, saveConfig } = this.props; + saveConfig({ cameraState: { ...cameraState, perspective: !cameraState.perspective } }); + }; + toggleShowTopics = () => { const { showTopics } = this.state; this.setState({ showTopics: !showTopics }); @@ -368,14 +344,18 @@ export default class Layout extends React.Component implements Mar const { cameraState, cameraState: { perspective }, - onCameraStateChange, - transforms, - followTf, followOrientation, + followTf, onAlignXYAxis, + onCameraStateChange, + onFollowChange, saveConfig, + selectedPolygonEditFormat, + showCrosshair, + transforms, + updatePanelConfig, } = this.props; - const { measureInfo, showCameraPosition } = this.state; + const { debug, polygonBuilder, drawingType } = this.state; return (
@@ -384,34 +364,35 @@ export default class Layout extends React.Component implements Mar transforms={transforms} tfToFollow={followTf ? followTf : undefined} followingOrientation={followOrientation} - onFollowChange={this.props.onFollowChange} + onFollowChange={onFollowChange} />
- this.setState({ showCameraPosition: expanded })} - onCameraStateChange={onCameraStateChange} - followTf={followTf} followOrientation={followOrientation} + followTf={followTf} onAlignXYAxis={onAlignXYAxis} + onCameraStateChange={onCameraStateChange} + onSetPolygons={(polygons) => this.setState({ polygonBuilder: new PolygonBuilder(polygons) })} + polygonBuilder={polygonBuilder} saveConfig={saveConfig} + selectedPolygonEditFormat={selectedPolygonEditFormat} + showCrosshair={!!showCrosshair} + type={drawingType} + updatePanelConfig={updatePanelConfig} /> - {this.measuringTool && this.measuringTool.measureDistance} ); } render3d() { - const { sceneBuilder, transformsBuilder, debug, metadata } = this.state; + const { sceneBuilder, transformsBuilder, debug, metadata, polygonBuilder } = this.state; const scene = sceneBuilder.getScene(); const { autoTextBackgroundColor, @@ -424,13 +405,14 @@ export default class Layout extends React.Component implements Mar } = this.props; const WorldComponent = getGlobalHooks().perPanelHooks().ThreeDimensionalViz.WorldComponent; + // TODO(Audrey): update DrawPolygons to support custom key so the users don't have to press ctrl key all the time return ( implements Mar extensions={selections.extensions} metadata={metadata}> {children} + {polygonBuilder.polygons} {process.env.NODE_ENV !== "production" && !inScreenshotTests() && } ); @@ -448,13 +431,11 @@ export default class Layout extends React.Component implements Mar // draw a crosshair to show the center of the viewport renderMarkers(add: MarkerCollector) { - const { cameraState } = this.props; - if (!this.state.showCameraPosition || cameraState.perspective) { - return; - } - if (!cameraState) { + const { cameraState, showCrosshair } = this.props; + if (!cameraState || cameraState.perspective || !showCrosshair) { return; } + const { target, targetOffset, distance, thetaOffset } = cameraState; const targetHeading = cameraStateSelectors.targetHeading(cameraState); @@ -580,8 +561,8 @@ export default class Layout extends React.Component implements Mar } render() { - const { measureState, measurePoints } = this.state.measureInfo; - const cursorType = measureState === "place-start" || measureState === "place-finish" ? "crosshair" : ""; + const { drawingType } = this.state; + const cursorType = drawingType && drawingType !== CAMERA_TYPE ? "crosshair" : ""; return (
implements Mar ref={(el) => (this.el = el)} style={{ cursor: cursorType }} onClick={this.onControlsOverlayClick}> - (this.measuringTool = el)} - measureState={measureState} - measurePoints={measurePoints} - onMeasureInfoChange={(measureInfo) => this.setState({ measureInfo })} - />
diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/MainToolbar.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/MainToolbar.js new file mode 100644 index 000000000..690463566 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/MainToolbar.js @@ -0,0 +1,43 @@ +// @flow +// +// Copyright (c) 2018-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import BugIcon from "@mdi/svg/svg/bug.svg"; +import Video3dIcon from "@mdi/svg/svg/video-3d.svg"; +import * as React from "react"; + +import Button from "webviz-core/src/components/Button"; +import Icon from "webviz-core/src/components/Icon"; +import styles from "webviz-core/src/panels/ThreeDimensionalViz/Layout.module.scss"; +import colors from "webviz-core/src/styles/colors.module.scss"; + +type Props = { + perspective: boolean, + debug: boolean, + onToggleCameraMode: () => void, + onToggleDebug: () => void, +}; + +export default function MainToolbar({ debug, onToggleCameraMode, onToggleDebug, perspective = false }: Props) { + const cameraModeTip = perspective ? "Switch to 2D camera" : "Switch to 3D camera"; + return ( +
+ + {process.env.NODE_ENV === "development" && ( + + )} +
+ ); +} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/MeasuringTool.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/MeasuringTool.js deleted file mode 100644 index 3436e5b5c..000000000 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/MeasuringTool.js +++ /dev/null @@ -1,185 +0,0 @@ -// @flow -// -// Copyright (c) 2018-present, GM Cruise LLC -// -// This source code is licensed under the Apache License, Version 2.0, -// found in the LICENSE file in the root directory of this source tree. -// You may not use this file except in compliance with the License. - -import { isEqual } from "lodash"; -import * as React from "react"; -import { type ReglClickInfo } from "regl-worldview"; - -import type { Point } from "webviz-core/src/types/Messages"; -import type { MarkerProvider, MarkerCollector } from "webviz-core/src/types/Scene"; -import { arrayToPoint } from "webviz-core/src/util"; - -export type MeasureState = "idle" | "place-start" | "place-finish" | "done"; - -export type MeasureInfo = {| - measureState: MeasureState, - measurePoints: { start: ?Point, end: ?Point }, -|}; - -type Props = {| - onMeasureInfoChange: (MeasureInfo) => void, - ...MeasureInfo, -|}; - -const sphereSize: number = 0.3; -const lineSize: number = 0.1; - -const defaultSphere: any = Object.freeze({ - type: 2, - action: 0, - scale: { x: sphereSize, y: sphereSize, z: 0.1 }, - color: { r: 1, g: 0.2, b: 0, a: 1 }, -}); -const defaultPose: any = Object.freeze({ orientation: { x: 0, y: 0, z: 0, w: 1 } }); - -export default class MeasuringTool extends React.Component implements MarkerProvider { - mouseDownCoords: number[] = [-1, -1]; - - toggleMeasureState = () => { - const newMeasureState = - this.props.measureState === "idle" || this.props.measureState === "done" ? "place-start" : "idle"; - this.props.onMeasureInfoChange({ - measureState: newMeasureState, - measurePoints: { start: undefined, end: undefined }, - }); - }; - - reset = () => { - this.props.onMeasureInfoChange({ - measureState: "idle", - measurePoints: { start: undefined, end: undefined }, - }); - }; - - _canvasMouseDownHandler = (e: MouseEvent, clickInfo: ReglClickInfo) => { - this.mouseDownCoords = [e.clientX, e.clientY]; - }; - - _canvasMouseUpHandler = (e: MouseEvent, clickInfo: ReglClickInfo) => { - const mouseUpCoords = [e.clientX, e.clientY]; - const { measureState, measurePoints, onMeasureInfoChange } = this.props; - - if (!isEqual(mouseUpCoords, this.mouseDownCoords)) { - return; - } - - let newMeasureState = measureState; - if (measureState === "place-start") { - newMeasureState = "place-finish"; - } else if (measureState === "place-finish") { - newMeasureState = "done"; - } - - onMeasureInfoChange({ - measureState: newMeasureState, - measurePoints, - }); - }; - - _canvasMouseMoveHandler = (e: MouseEvent, clickInfo: ReglClickInfo) => { - const { measureState, measurePoints, onMeasureInfoChange } = this.props; - switch (measureState) { - case "place-start": - onMeasureInfoChange({ - measureState, - measurePoints: { - start: arrayToPoint(clickInfo.ray.planeIntersection([0, 0, 0], [0, 0, 1])), - end: undefined, - }, - }); - break; - - case "place-finish": - onMeasureInfoChange({ - measureState, - measurePoints: { - ...measurePoints, - end: arrayToPoint(clickInfo.ray.planeIntersection([0, 0, 0], [0, 0, 1])), - }, - }); - break; - } - }; - - get canvasMouseMove(): ?(MouseEvent, ReglClickInfo) => void { - if (!this.measureActive) { - return null; - } - - return this._canvasMouseMoveHandler; - } - - get canvasMouseUp(): ?(MouseEvent, ReglClickInfo) => void { - if (!this.measureActive) { - return null; - } - - return this._canvasMouseUpHandler; - } - - get canvasMouseDown(): ?(MouseEvent, ReglClickInfo) => void { - if (!this.measureActive) { - return null; - } - - return this._canvasMouseDownHandler; - } - - get measureActive(): boolean { - const { measureState } = this.props; - return measureState === "place-start" || measureState === "place-finish"; - } - - get measureDistance(): string { - const { start, end } = this.props.measurePoints; - let dist_string = ""; - if (start && end) { - const dist = Math.hypot(end.x - start.x, end.y - start.y, end.z - start.z); - dist_string = `${dist.toFixed(2)}m`; - } - - return dist_string; - } - - renderMarkers(add: MarkerCollector) { - const { start, end } = this.props.measurePoints; - - if (start) { - const startPoint = { ...start }; - - add.sphere({ - ...defaultSphere, - id: "_measure_start", - pose: { position: startPoint, ...defaultPose }, - }); - - if (end) { - const endPoint = { ...end }; - - add.lineStrip({ - ...defaultSphere, - id: "_measure_line", - points: [start, end], - pose: { ...defaultPose, position: { x: 0, y: 0, z: 0 } }, - scale: { x: lineSize, y: 1, z: 1 }, - type: 4, - }); - - add.sphere({ - ...defaultSphere, - id: "_measure_end", - pose: { position: endPoint, ...defaultPose }, - }); - } - } - } - - render() { - return null; - } -} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/index.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/index.js index cb1364671..82e35b485 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/index.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/index.js @@ -20,10 +20,13 @@ import { emptyPose } from "webviz-core/src/util/Pose"; import { fromSec } from "webviz-core/src/util/time"; export type TopicSettings = { + channel?: ?string, + color?: ?string, colorField?: ?string, + minPoint?: ?number, + maxPoint?: ?number, pointSize?: ?number, pointShape?: ?string, - color?: ?string, useCarModel?: boolean, alpha?: number, decayTime?: number, @@ -153,7 +156,12 @@ export default class SceneBuilder implements MarkerProvider { setGlobalData = (globalData: Object = {}) => { const { selectionState, topicsToRender } = getGlobalHooks() .perPanelHooks() - .ThreeDimensionalViz.setGlobalDataInSceneBuilder(globalData, this.selectionState, this.topicsToRender); + .ThreeDimensionalViz.setGlobalDataInSceneBuilder( + globalData, + this.selectionState, + this.topicsToRender, + this.topics + ); this.selectionState = selectionState; this.topicsToRender = topicsToRender; }; diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/topicTree.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/topicTree.js index 15529977f..43e89287f 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/topicTree.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/topicTree.js @@ -7,24 +7,26 @@ // You may not use this file except in compliance with the License. import * as React from "react"; +import { type Topic } from "webviz-core/src/types/players"; + export type TopicTreeConfig = ( | { name: string, topic?: void, extension?: void, - children: TopicTreeConfig[], + children: TopicTreeConfig[] | ((topic: Topic) => boolean), } | { name?: string, topic: string, extension?: void, - children?: TopicTreeConfig[], + children?: TopicTreeConfig[] | ((topic: Topic) => boolean), } | { name: string, topic?: void, extension: string, - children?: TopicTreeConfig[], + children?: TopicTreeConfig[] | ((topic: Topic) => boolean), } ) & { // Previous names or ids for this item under which it might be saved in old layouts. diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/treeBuilder.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/treeBuilder.js index 03be86801..626c46c88 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/treeBuilder.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/treeBuilder.js @@ -128,14 +128,17 @@ export class TopicTreeNode { legacyIds: string[]; // create a topic node from a json config file node - static fromJson(config: TopicTreeConfig) { + static fromJson(config: TopicTreeConfig, topics: Topic[]) { const result = new TopicTreeNode(config); // extension nodes start as enabled-by-default for now if (!config.extension) { result.disabled = true; } if (config.children) { - config.children.forEach((child) => result.add(TopicTreeNode.fromJson(child))); + const childTopics = Array.isArray(config.children) + ? config.children + : topics.filter(config.children).map((topic) => ({ topic: topic.name })); + childTopics.forEach((child) => result.add(TopicTreeNode.fromJson(child, topics))); } return result; } @@ -403,7 +406,7 @@ export default function buildTree( ): TopicTreeNode { const { topics, transforms } = props; - const rootNode = TopicTreeNode.fromJson(jsonConfig); + const rootNode = TopicTreeNode.fromJson(jsonConfig, props.topics); rootNode.disabled = false; rootNode.checked = true; diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSettingsEditor.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSettingsEditor.js index f8e9b49e7..10711ddde 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSettingsEditor.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSettingsEditor.js @@ -182,8 +182,13 @@ export const RenderPointSettings = ({ ); }; -export const renderDecaySettings = (props: Props, onFieldChange: (string) => Function) => { - const { settings } = props; +export const renderDecaySettings = ({ + settings, + onFieldChange, +}: { + settings: TopicSettings, + onFieldChange: (string) => Function, +}) => { const decayTime = settings.decayTime; const decayTimeValue = decayTime === undefined ? "" : decayTime; @@ -269,7 +274,7 @@ export default class TopicSettingsEditor extends PureComponent { this._onFieldChange("color")(e.target.value); }} /> - {renderDecaySettings(this.props, this._onFieldChange)} + {renderDecaySettings({ settings: this.props.settings, onFieldChange: this._onFieldChange })} {RenderResetButton({ onSettingsChange })} ); diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms.js index 17d023749..142773759 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms.js @@ -19,6 +19,10 @@ const tempPos = [0, 0, 0]; const tempScale = [0, 0, 0]; const tempOrient = [0, 0, 0, 0]; +function stripLeadingSlash(name: string) { + return name.startsWith("/") ? name.slice(1) : name; +} + export class Transform { id: string; matrix: Mat4 = mat4.create(); @@ -26,7 +30,7 @@ export class Transform { valid = false; constructor(id: string) { - this.id = id; + this.id = stripLeadingSlash(id); } reset() { @@ -44,6 +48,7 @@ export class Transform { } isChildOfTransform(rootId: string): boolean { + rootId = stripLeadingSlash(rootId); if (!this.parent) { return this.id === rootId; } @@ -58,6 +63,7 @@ export class Transform { } apply(output: Pose, input: Pose, rootId: string): ?Pose { + rootId = stripLeadingSlash(rootId); if (this.id === rootId) { output.position.x = input.position.x; output.position.y = input.position.y; @@ -123,6 +129,7 @@ export class Transform { class TfStore { storage = {}; get(key: string): Transform { + key = stripLeadingSlash(key); let result = this.storage[key]; if (result) { return result; diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/cameraStateValidator.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/cameraStateValidator.js deleted file mode 100644 index ca1a2e15d..000000000 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/cameraStateValidator.js +++ /dev/null @@ -1,34 +0,0 @@ -// @flow -// -// Copyright (c) 2019-present, GM Cruise LLC -// -// This source code is licensed under the Apache License, Version 2.0, -// found in the LICENSE file in the root directory of this source tree. -// You may not use this file except in compliance with the License. - -import { - createValidator, - isNumber, - isBoolean, - isNumberArray, - isOrientation, - type ValidationResult, -} from "webviz-core/src/components/validator"; - -const cameraStateValidator = (jsonData: Object = {}): ?ValidationResult => { - const rules = { - distance: [isNumber], - perspective: [isBoolean], - phi: [isNumber], - thetaOffset: [isNumber], - target: [isNumberArray(3)], - targetOffset: [isNumberArray(3)], - targetOrientation: [isOrientation], - }; - const validator = createValidator(rules); - const result = validator(jsonData); - - return Object.keys(result).length === 0 ? undefined : result; -}; - -export default cameraStateValidator; diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/LaserScans.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/LaserScans.js index 234ca5aea..edd3a7bd6 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/LaserScans.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/LaserScans.js @@ -34,7 +34,10 @@ const laserScan = (regl: Regl) => attributes: { index: (context, props) => range(props.ranges.length), range: regl.prop("ranges"), - intensity: regl.prop("intensities"), + intensity: (context, props) => + props.intensities.length === props.ranges.length + ? props.intensities + : new Float32Array(props.ranges.length).fill(1), }, count: regl.prop("ranges.length"), diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/index.help.md b/packages/webviz-core/src/panels/ThreeDimensionalViz/index.help.md index edf98760a..2e56b88cb 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/index.help.md +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/index.help.md @@ -17,3 +17,14 @@ In 3D camera mode, you can also use "shooter controls" (like those found in most Holding down `shift` in while performing any interaction with the camera will adjust values by 1/10th of their normal adjustments. This allows precision movements and adjustments to the camera. _tip: If you get 'lost' in the scene and end up looking into infinite blank space and can't find your way back try clicking on 'follow' to snap the camera back to the default position._ + +## Drawing Polygons + +- To start a drawing, hold `ctrl` and click on the canvas. This will place the first point of the polygon. Continue holding ctrl and click as may times as you want to create a `string` of points connected by lines. To terminate your drawing release `ctrl` and click a final time. This will place one final point at the mouse location and 'close' the polygon. The polygon will still be selected until you click "off" of the polygon to anywhere else on the canvas. +- To select a polygon, click it once. +- To select a point within a polygon click it once. +- To move a polygon you have drawn you can click + drag on the polygon. +- To resize a polygon you can click + drag on an individual point within the polygon to move it. This will resize the polygon: all the points will remain connected to one another. +- To place a new point within an existing polygon, double click on a line within the polygon. This will bisect the line at the current double-click position, inserting a new point you may drag around to move. +- To delete the entire polygon, press the `delete` key when the polygon is selected. +- To delete an existing point in a polygon, double-click it, or press `delete` with a point selected. diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/index.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/index.js index 46d64457a..51cc6d4a4 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/index.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/index.js @@ -24,7 +24,7 @@ import type { TopicSettingsCollection } from "webviz-core/src/panels/ThreeDimens import treeBuilder, { Selections } from "webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/treeBuilder"; import Transforms from "webviz-core/src/panels/ThreeDimensionalViz/Transforms"; import withTransforms from "webviz-core/src/panels/ThreeDimensionalViz/withTransforms"; -import type { SaveConfig } from "webviz-core/src/types/panels"; +import type { SaveConfig, UpdatePanelConfig } from "webviz-core/src/types/panels"; import type { Frame, Topic } from "webviz-core/src/types/players"; import type { MarkerProvider } from "webviz-core/src/types/Scene"; import { TRANSFORM_TOPIC } from "webviz-core/src/util/globalConstants"; @@ -46,6 +46,8 @@ export type ThreeDimensionalVizConfig = { useHeightMap?: ?boolean, // eslint-disable-line react/no-unused-prop-types follow?: boolean, flattenMarkers?: boolean, + selectedPolygonEditFormat?: "json" | "yaml", + showCrosshair?: boolean, }; export type Props = { @@ -59,6 +61,7 @@ export type Props = { helpContent: React.Node | string, saveConfig: SaveConfig, + updatePanelConfig: UpdatePanelConfig, setSubscriptions: (string[]) => void, registerMarkerProvider: (MarkerProvider) => void, unregisterMarkerProvider: (MarkerProvider) => void, diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/utils/drawToolUtils.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/utils/drawToolUtils.js new file mode 100644 index 000000000..c6fd7b9f0 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/utils/drawToolUtils.js @@ -0,0 +1,55 @@ +// @flow +// +// Copyright (c) 2018-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import { Polygon, PolygonPoint } from "regl-worldview"; + +import { EDIT_FORMAT, type EditFormat } from "webviz-core/src/components/ValidatedInput"; +import { type Point2D } from "webviz-core/src/panels/ThreeDimensionalViz/DrawingTools"; +import YAML from "webviz-core/src/util/yaml"; + +export function polygonsToPoints(polygons: Polygon[]): Point2D[][] { + return polygons.map((poly) => { + return poly.points.map((point) => ({ x: point.point[0], y: point.point[1] })); + }); +} + +export function pointsToPolygons(polygonPoints: Point2D[][]): Polygon[] { + // map the points back to polygons + return polygonPoints.map((pointsPerPolygon, idx) => { + const polygon = new Polygon(`${idx}`); + polygon.points = pointsPerPolygon.map(({ x, y }) => new PolygonPoint([x, y, 0])); + return polygon; + }); +} + +function pointsToYaml(polygonPoints: Point2D[][]): string { + if (!polygonPoints.length || !polygonPoints[0].length) { + return ""; + } + return YAML.stringify(polygonPoints); +} + +function pointsToJson(polygonPoints: Point2D[][]): string { + return JSON.stringify(polygonPoints, null, 2); +} + +export function getFormattedString(polygonPoints: Point2D[][], selectedPolygonEditFormat: EditFormat) { + return selectedPolygonEditFormat === EDIT_FORMAT.JSON ? pointsToJson(polygonPoints) : pointsToYaml(polygonPoints); +} + +// calculate the sum of the line distances +export function getPolygonLineDistances(polygonPoints: Point2D[][]): number { + return polygonPoints.reduce((memo, polyPoints, idx) => { + if (polyPoints.length > 1) { + for (let i = 0; i < polyPoints.length - 1; i++) { + memo += Math.hypot(polyPoints[i + 1].x - polyPoints[i].x, polyPoints[i + 1].y - polyPoints[i].y); + } + } + return memo; + }, 0); +} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/utils/drawToolUtils.test.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/utils/drawToolUtils.test.js new file mode 100644 index 000000000..0ddbf4056 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/utils/drawToolUtils.test.js @@ -0,0 +1,74 @@ +// @flow +// +// Copyright (c) 2018-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import { polygonsToPoints, pointsToPolygons, getFormattedString } from "./drawToolUtils"; +import { EDIT_FORMAT } from "webviz-core/src/components/ValidatedInput"; + +const points = [[{ x: 1, y: 1 }, { x: 2, y: 2 }, { x: 3, y: 3 }], [{ x: 4, y: 4 }, { x: 5, y: 5 }, { x: 6, y: 6 }]]; +const polygons = [ + { + active: false, + id: 1, + name: "0", + points: [ + { active: false, id: 2, point: [1, 1, 0] }, + { active: false, id: 3, point: [2, 2, 0] }, + { active: false, id: 4, point: [3, 3, 0] }, + ], + }, + { + active: false, + id: 5, + name: "1", + points: [ + { active: false, id: 6, point: [4, 4, 0] }, + { active: false, id: 7, point: [5, 5, 0] }, + { active: false, id: 8, point: [6, 6, 0] }, + ], + }, +]; + +describe("drawToolUtils", () => { + describe("polygonsToPoints", () => { + it("converts polygons to points", async () => { + expect(polygonsToPoints(polygons)).toEqual(points); + }); + }); + describe("pointsToPolygons", () => { + it("converts polygon points to polygons", () => { + expect(pointsToPolygons(points)).toEqual(polygons); + }); + }); + describe("getFormattedString", () => { + it("returns json format", async () => { + expect(JSON.parse(getFormattedString(points, EDIT_FORMAT.JSON))).toEqual(points); + }); + it("handles empty input for json format", async () => { + expect(getFormattedString([], EDIT_FORMAT.JSON)).toEqual("[]"); + }); + it("returns yaml format", () => { + expect(getFormattedString(points, EDIT_FORMAT.YAML)).toEqual(`- - x: 1 + y: 1 + - x: 2 + y: 2 + - x: 3 + y: 3 + +- - x: 4 + y: 4 + - x: 5 + y: 5 + - x: 6 + y: 6`); + }); + it("handles empty input for yaml format", async () => { + expect(getFormattedString([], EDIT_FORMAT.YAML)).toEqual(""); + expect(getFormattedString([[]], EDIT_FORMAT.YAML)).toEqual(""); + }); + }); +}); diff --git a/packages/webviz-core/src/players/BagDataProvider.js b/packages/webviz-core/src/players/BagDataProvider.js index 9c03380b9..7097e3629 100644 --- a/packages/webviz-core/src/players/BagDataProvider.js +++ b/packages/webviz-core/src/players/BagDataProvider.js @@ -12,8 +12,8 @@ import decompress from "wasm-lz4"; import BrowserHttpReader from "webviz-core/src/players/BrowserHttpReader"; import type { - ChainableDataProvider, - ChainableDataProviderDescriptor, + RandomAccessDataProvider, + DataProviderDescriptor, Connection, ExtensionPoint, InitializationResult, @@ -30,12 +30,12 @@ type Options = {| bagPath: BagPath, cacheSizeInBytes?: ?number |}; const log = new Logger(__filename); -export default class BagDataProvider implements ChainableDataProvider { +export default class BagDataProvider implements RandomAccessDataProvider { _options: Options; _bag: Bag; _connectionsByTopic: { [topic: string]: Connection } = {}; - constructor(options: Options, children: ChainableDataProviderDescriptor[]) { + constructor(options: Options, children: DataProviderDescriptor[]) { if (children.length > 0) { throw new Error("BagDataProvider cannot have children"); } @@ -47,6 +47,7 @@ export default class BagDataProvider implements ChainableDataProvider { await decompress.isLoaded; if (bagPath.type === "remoteBagUrl") { + extensionPoint.progressCallback({ fullyLoadedFractionRanges: [] }); let approximateSize = 0; const fileReader = new BrowserHttpReader(bagPath.url); const remoteReader = new CachedFilelike({ @@ -79,6 +80,7 @@ export default class BagDataProvider implements ChainableDataProvider { await this._bag.open(); } else { this._bag = await open(bagPath.file); + extensionPoint.progressCallback({ fullyLoadedFractionRanges: [{ start: 0, end: 1 }] }); } const { startTime, endTime } = this._bag; diff --git a/packages/webviz-core/src/players/BagDataProvider.test.js b/packages/webviz-core/src/players/BagDataProvider.test.js index 000f81ba7..78ebc63b2 100644 --- a/packages/webviz-core/src/players/BagDataProvider.test.js +++ b/packages/webviz-core/src/players/BagDataProvider.test.js @@ -6,11 +6,11 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. -import BagDataProvider from "./BagDataProvider"; +import BagDataProvider from "webviz-core/src/players/BagDataProvider"; +import { mockExtensionPoint } from "webviz-core/src/players/mockExtensionPoint"; const dummyExtensionPoint = { progressCallback() {}, - addTopicsCallback() {}, reportMetadataCallback() {}, }; @@ -49,6 +49,17 @@ describe("BagDataProvider", () => { ]); }); + it("calls progress callback while initializing with a local bag", async () => { + const provider = new BagDataProvider( + { bagPath: { type: "file", file: `${__dirname}/../../public/fixtures/example.bag` } }, + [] + ); + const extensionPoint = mockExtensionPoint().extensionPoint; + jest.spyOn(extensionPoint, "progressCallback"); + await provider.initialize(extensionPoint); + expect(extensionPoint.progressCallback).toHaveBeenCalledWith({ fullyLoadedFractionRanges: [{ start: 0, end: 1 }] }); + }); + it("gets messages", async () => { const provider = new BagDataProvider( { bagPath: { type: "file", file: `${__dirname}/../../public/fixtures/example.bag` } }, diff --git a/packages/webviz-core/src/players/BrowserHttpReader.js b/packages/webviz-core/src/players/BrowserHttpReader.js index f873429a3..6a4cf4c58 100644 --- a/packages/webviz-core/src/players/BrowserHttpReader.js +++ b/packages/webviz-core/src/players/BrowserHttpReader.js @@ -57,7 +57,7 @@ export default class BrowserHttpReader implements FileReader { fetch(offset: number, length: number): FileStream { const headers = new Headers({ range: `bytes=${offset}-${offset + (length - 1)}` }); // $FlowFixMe - Flow doesn't understand that this *does* have the right type. - return new FetchReader(this._url, headers); + return new FetchReader(this._url, { headers }); } recordBytesPerSecond(bytesPerSecond: number): void {} diff --git a/packages/webviz-core/src/players/CombinedDataProvider.js b/packages/webviz-core/src/players/CombinedDataProvider.js index 958c7e41e..128730bfc 100644 --- a/packages/webviz-core/src/players/CombinedDataProvider.js +++ b/packages/webviz-core/src/players/CombinedDataProvider.js @@ -6,40 +6,38 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. -import { flatten, uniq, isEqual, intersection } from "lodash"; +import { flatten, uniq, isEqual } from "lodash"; import { TimeUtil, type Time } from "rosbag"; import type { - RandomAccessDataProvider, - MessageLike, - InitializationResult, + DataProviderDescriptor, DataProviderMetadata, ExtensionPoint, + GetDataProvider, + InitializationResult, + MessageLike, + RandomAccessDataProvider, } from "webviz-core/src/players/types"; +import { intersectProgress, emptyProgress, fullyLoadedProgress } from "webviz-core/src/players/util"; import type { Progress, Topic } from "webviz-core/src/types/players"; import type { RosMsgField } from "webviz-core/src/types/RosDatatypes"; import naturalSort from "webviz-core/src/util/naturalSort"; +import { clampTime } from "webviz-core/src/util/time"; -type ProviderInfo = { provider: RandomAccessDataProvider, prefix?: string, deleteTopics?: string[] }; +export type ProviderInfo = { prefix?: string, deleteTopics?: string[] }; +type InternalProviderInfo = { provider: RandomAccessDataProvider, prefix?: string, deleteTopics?: string[] }; const sortTimes = (times: Time[]) => times.sort(TimeUtil.compare); -const mapTopics = ( - initializationResult: InitializationResult, - { provider, prefix, deleteTopics = [] }: ProviderInfo -): Topic[] => { - let resTopics: Topic[] = []; +const mapTopics = (topics: Topic[], { provider, prefix }: InternalProviderInfo): Topic[] => { if (!prefix) { - resTopics = initializationResult.topics; - } else { - resTopics = initializationResult.topics.map((topic) => ({ - ...topic, - name: `${prefix}${topic.name}`, - originalTopic: topic.name, - })); + return topics; } - - return deleteTopics.length ? resTopics.filter(({ name }) => !deleteTopics.includes(name)) : resTopics; + return topics.map((topic) => ({ + ...topic, + name: `${prefix}${topic.name}`, + originalTopic: topic.name, + })); }; const merge = (messages1: MessageLike[], messages2: MessageLike[]) => { @@ -91,11 +89,21 @@ const throwOnUnequalDatatypes = (datatypes: [string, RosMsgField[]][]) => { // a caching adapter for a DataProvider which does eager, non-blocking read ahead of time ranges // based on a readAheadRange (default to 100 milliseconds) export default class CombinedDataProvider implements RandomAccessDataProvider { - _providers: ProviderInfo[]; - _availableTopicsForAllProviders: string[][] = []; - - constructor(providers: ProviderInfo[]) { - const prefixes = providers.filter(({ prefix }) => prefix).map(({ prefix }) => prefix); + _providers: InternalProviderInfo[]; + _initializationResultsPerProvider: { start: Time, end: Time, topicSet: Set }[] = []; + _progressPerProvider: (Progress | null)[]; + + constructor( + { providerInfos }: {| providerInfos: ProviderInfo[] |}, + children: DataProviderDescriptor[], + getDataProvider: GetDataProvider + ) { + if (providerInfos.length !== children.length) { + throw new Error( + `Number of providerInfos (${providerInfos.length}) does not match number of children (${children.length})` + ); + } + const prefixes = providerInfos.filter(({ prefix }) => prefix).map(({ prefix }) => prefix); if (uniq(prefixes).length !== prefixes.length) { throw new Error(`Duplicate prefixes are not allowed: ${JSON.stringify(prefixes)}`); } @@ -103,29 +111,27 @@ export default class CombinedDataProvider implements RandomAccessDataProvider { throw new Error(`Each prefix must have a leading forward slash: ${JSON.stringify(prefixes)}`); } - this._providers = providers; + this._providers = providerInfos.map((providerInfo, index) => ({ + ...providerInfo, + provider: + process.env.NODE_ENV === "test" && children[index].name === "TestProvider" + ? children[index].args.provider + : getDataProvider(children[index]), + })); + // initialize progress to an empty range for each provider + this._progressPerProvider = providerInfos.map((_) => null); } async initialize(extensionPoint: ExtensionPoint): Promise { - const results = await Promise.all( - this._providers.map(({ provider, prefix, deleteTopics }: ProviderInfo, idx) => { + const results: InitializationResult[] = await Promise.all( + this._providers.map(({ provider, prefix, deleteTopics }: InternalProviderInfo, idx) => { const childExtensionPoint = { progressCallback: (progress: Progress) => { - // For now just pass through progress from all underlying providers, without combining - // them in a meaningful way, because that's all we need right now. - // TODO(JP): Do some smarter combining of progress when we need that, e.g. when allowing - // playing multiple remote bags. - extensionPoint.progressCallback(progress); - }, - addTopicsCallback: (fn: (string[]) => void) => { - extensionPoint.addTopicsCallback((topics) => { - // filter out the topics that are not in the provider's availableTopics list - const filteredTopics = intersection(topics, this._availableTopicsForAllProviders[idx]); - const topicsWithoutPrefix = filteredTopics - .map((topic) => (topic.startsWith(prefix || "") ? topic.slice((prefix || "").length) : undefined)) - .filter(Boolean); - fn(topicsWithoutPrefix); - }); + this._progressPerProvider[idx] = progress; + // Assume empty for unreported progress + const cleanProgresses = this._progressPerProvider.map((p) => p || emptyProgress()); + const intersected = intersectProgress(cleanProgresses); + extensionPoint.progressCallback(intersected); }, reportMetadataCallback: (data: DataProviderMetadata) => { extensionPoint.reportMetadataCallback(data); @@ -134,13 +140,28 @@ export default class CombinedDataProvider implements RandomAccessDataProvider { return provider.initialize(childExtensionPoint); }) ); + + // Any providers that didn't report progress in `initialize` are assumed fully loaded + this._progressPerProvider.forEach((p, i) => { + this._progressPerProvider[i] = p || fullyLoadedProgress(); + }); + const start = sortTimes(results.map(({ start }) => start)).shift(); const end = sortTimes(results.map(({ end }) => end)).pop(); - const topicsPerProvider = results.map((initializationResult, i) => - mapTopics(initializationResult, this._providers[i]) - ); - this._availableTopicsForAllProviders = topicsPerProvider.map((pTopics) => pTopics.map((t) => t.name)); - const topics = flatten(topicsPerProvider); + + this._initializationResultsPerProvider = []; + let topics: Topic[] = []; + results.forEach((result, i) => { + const deleteTopics: string[] = this._providers[i].deleteTopics || []; + const filteredTopics: Topic[] = result.topics.filter(({ name }) => !deleteTopics.includes(name)); + topics = [...topics, ...mapTopics(filteredTopics, this._providers[i])]; + + this._initializationResultsPerProvider.push({ + start: result.start, + end: result.end, + topicSet: new Set(filteredTopics.map((t) => t.name)), + }); + }); // Error handling throwOnDuplicateTopics([...topics]); @@ -161,13 +182,32 @@ export default class CombinedDataProvider implements RandomAccessDataProvider { async getMessages(start: Time, end: Time, topics: string[]): Promise { const messagesPerProvider = await Promise.all( - this._providers.map(async ({ provider, prefix: suppliedPrefix }) => { - const prefix = suppliedPrefix || ""; - const filteredTopics = (prefix ? topics.filter((topic) => topic.startsWith(prefix)) : topics).map((topic) => - topic.slice(prefix.length) - ); - const messages = await provider.getMessages(start, end, filteredTopics); - return Promise.resolve(messages.map((message) => ({ ...message, topic: `${prefix}${message.topic}` }))); + this._providers.map(async ({ provider, prefix }, index) => { + const initializationResult = this._initializationResultsPerProvider[index]; + const availableTopics = initializationResult.topicSet; + const filteredTopics = topics + .map((topic) => topic.slice((prefix || "").length)) + .filter((topic) => availableTopics.has(topic)); + if (!filteredTopics.length) { + // If we don't need any topics from this provider, we shouldn't call getMessages at all. + return Promise.resolve([]); + } + if ( + TimeUtil.isLessThan(end, initializationResult.start) || + TimeUtil.isLessThan(initializationResult.end, start) + ) { + // If we're totally out of bounds for this provider, we shouldn't call getMessages at all. + return Promise.resolve([]); + } + const clampedStart = clampTime(start, initializationResult.start, initializationResult.end); + const clampedEnd = clampTime(end, initializationResult.start, initializationResult.end); + const messages = await provider.getMessages(clampedStart, clampedEnd, filteredTopics); + for (const message of messages) { + if (!availableTopics.has(message.topic)) { + throw new Error(`Saw unexpected topic from provider ${index}: ${message.topic}`); + } + } + return Promise.resolve(messages.map((message) => ({ ...message, topic: `${prefix || ""}${message.topic}` }))); }) ); diff --git a/packages/webviz-core/src/players/CombinedDataProvider.test.js b/packages/webviz-core/src/players/CombinedDataProvider.test.js index 28fceea9b..44636da29 100644 --- a/packages/webviz-core/src/players/CombinedDataProvider.test.js +++ b/packages/webviz-core/src/players/CombinedDataProvider.test.js @@ -18,89 +18,105 @@ import { mockExtensionPoint } from "webviz-core/src/players/mockExtensionPoint"; import { SECOND_BAG_PREFIX } from "webviz-core/src/util/globalConstants"; // reusable providers -const provider1 = new MemoryDataProvider({ - messages: [ - { topic: "/some_topic1", receiveTime: { sec: 101, nsec: 0 }, message: { value: 1 } }, - { topic: "/some_topic1", receiveTime: { sec: 103, nsec: 0 }, message: { value: 3 } }, - ], - topics: [{ name: "/some_topic1", datatype: "some_datatype" }], - datatypes: {}, -}); +function provider1(initiallyLoaded = false) { + return new MemoryDataProvider({ + messages: [ + { topic: "/some_topic1", receiveTime: { sec: 101, nsec: 0 }, message: { value: 1 } }, + { topic: "/some_topic1", receiveTime: { sec: 103, nsec: 0 }, message: { value: 3 } }, + ], + topics: [{ name: "/some_topic1", datatype: "some_datatype" }], + datatypes: {}, + initiallyLoaded, + }); +} -const provider1Duplicate = new MemoryDataProvider({ - messages: [ - { topic: "/some_topic1", receiveTime: { sec: 101, nsec: 0 }, message: { value: 1 } }, - { topic: "/some_topic1", receiveTime: { sec: 103, nsec: 0 }, message: { value: 3 } }, - ], - topics: [{ name: "/some_topic1", datatype: "some_datatype" }], - datatypes: {}, -}); +function provider1Duplicate() { + return new MemoryDataProvider({ + messages: [ + { topic: "/some_topic1", receiveTime: { sec: 101, nsec: 0 }, message: { value: 1 } }, + { topic: "/some_topic1", receiveTime: { sec: 103, nsec: 0 }, message: { value: 3 } }, + ], + topics: [{ name: "/some_topic1", datatype: "some_datatype" }], + datatypes: {}, + }); +} -const provider2 = new MemoryDataProvider({ - messages: [{ topic: "/some_topic2", receiveTime: { sec: 102, nsec: 0 }, message: { value: 2 } }], - topics: [{ name: "/some_topic2", datatype: "some_datatype" }], - datatypes: {}, -}); +function provider2() { + return new MemoryDataProvider({ + messages: [{ topic: "/some_topic2", receiveTime: { sec: 102, nsec: 0 }, message: { value: 2 } }], + topics: [{ name: "/some_topic2", datatype: "some_datatype" }], + datatypes: {}, + }); +} -const provider3 = new MemoryDataProvider({ - messages: [ - { topic: "/some_topic3", receiveTime: { sec: 100, nsec: 0 }, message: { value: 3 } }, - { topic: "/some_topic3", receiveTime: { sec: 102, nsec: 0 }, message: { value: 3 } }, - { topic: "/some_topic3", receiveTime: { sec: 104, nsec: 0 }, message: { value: 3 } }, - ], - topics: [{ name: "/some_topic3", datatype: "some_datatype" }], - datatypes: {}, -}); +function provider3() { + return new MemoryDataProvider({ + messages: [ + { topic: "/some_topic3", receiveTime: { sec: 100, nsec: 0 }, message: { value: 3 } }, + { topic: "/some_topic3", receiveTime: { sec: 102, nsec: 0 }, message: { value: 3 } }, + { topic: "/some_topic3", receiveTime: { sec: 104, nsec: 0 }, message: { value: 3 } }, + ], + topics: [{ name: "/some_topic3", datatype: "some_datatype" }], + datatypes: {}, + }); +} + +function getCombinedDataProvider(data: any[]) { + const providerInfos = []; + const children = []; + for (const item of data) { + const { provider, deleteTopics, prefix } = item; + providerInfos.push({ deleteTopics, prefix }); + children.push({ name: "TestProvider", args: { provider }, children: [] }); + } + return new CombinedDataProvider({ providerInfos }, children, () => { + throw new Error("Should never be called"); + }); +} describe("CombinedDataProvider", () => { describe("error handling", () => { it("throws if a prefix does not have a leading forward slash", () => { - expect( - () => new CombinedDataProvider([{ provider: provider1, prefix: "foo" }, { provider: provider2 }]) + expect(() => + getCombinedDataProvider([{ provider: provider1(), prefix: "foo" }, { provider: provider2() }]) ).toThrow(); }); it("throws if two providers have the same topics without a prefix", async () => { - const combinedProvider = new CombinedDataProvider([{ provider: provider1 }, { provider: provider1Duplicate }]); + const combinedProvider = getCombinedDataProvider([{ provider: provider1() }, { provider: provider1Duplicate() }]); await expect(combinedProvider.initialize(mockExtensionPoint().extensionPoint)).rejects.toThrow(); }); it("throws if duplicate prefixes are provided", () => { - expect( - () => - new CombinedDataProvider([{ provider: provider1, prefix: "/foo" }, { provider: provider2, prefix: "/foo" }]) + expect(() => + getCombinedDataProvider([{ provider: provider1(), prefix: "/foo" }, { provider: provider2(), prefix: "/foo" }]) ).toThrow(); - expect( - () => - new CombinedDataProvider([ - { provider: provider1, prefix: "/foo" }, - { provider: provider2 }, - { provider: provider3, prefix: "/foo" }, - ]) + expect(() => + getCombinedDataProvider([ + { provider: provider1(), prefix: "/foo" }, + { provider: provider2() }, + { provider: provider3(), prefix: "/foo" }, + ]) ).toThrow(); }); it("should not allow duplicate topics", async () => { - const provider1 = new MemoryDataProvider({ + const p1 = new MemoryDataProvider({ messages: [{ topic: "/some_topic", receiveTime: { sec: 101, nsec: 0 }, message: { value: 1 } }], topics: [{ name: "/some_topic", datatype: "some_datatype" }], datatypes: {}, }); - - const provider2 = new MemoryDataProvider({ + const p2 = new MemoryDataProvider({ messages: [{ topic: "/some_topic", receiveTime: { sec: 101, nsec: 0 }, message: { value: 1 } }], topics: [{ name: "/generic_topic/some_topic", datatype: "some_datatype" }], datatypes: {}, }); - const combinedProvider = new CombinedDataProvider([ - { provider: provider1, prefix: "/generic_topic" }, - { provider: provider2 }, - ]); + const combinedProvider = getCombinedDataProvider([{ provider: p1, prefix: "/generic_topic" }, { provider: p2 }]); await expect(combinedProvider.initialize(mockExtensionPoint().extensionPoint)).rejects.toThrow(); }); it("should not allow conflicting datatypes", async () => { - const provider1 = new MemoryDataProvider({ + const p1 = new MemoryDataProvider({ messages: [{ topic: "/some_topic", receiveTime: { sec: 101, nsec: 0 }, message: { value: 1 } }], topics: [{ name: "/some_topic", datatype: "some_datatype" }], datatypes: { @@ -113,7 +129,7 @@ describe("CombinedDataProvider", () => { }, }); - const provider2 = new MemoryDataProvider({ + const p2 = new MemoryDataProvider({ messages: [{ topic: "/some_topic", receiveTime: { sec: 101, nsec: 0 }, message: { value: 1 } }], topics: [{ name: "/some_topic", datatype: "some_datatype" }], datatypes: { @@ -125,88 +141,107 @@ describe("CombinedDataProvider", () => { ], }, }); - const combinedProvider = new CombinedDataProvider([ - { provider: provider1, prefix: "/some_prefix" }, - { provider: provider2 }, - ]); + const combinedProvider = getCombinedDataProvider([{ provider: p1, prefix: "/some_prefix" }, { provider: p2 }]); await expect(combinedProvider.initialize(mockExtensionPoint().extensionPoint)).rejects.toThrow(); }); }); + describe("features", () => { it("combines initialization data", async () => { - const combinedProvider = new CombinedDataProvider([ - { provider: provider1 }, - { provider: provider3, prefix: SECOND_BAG_PREFIX }, - { provider: provider2, prefix: "/table_1" }, + const combinedProvider = getCombinedDataProvider([ + { provider: provider1() }, + { provider: provider3(), prefix: SECOND_BAG_PREFIX }, + { provider: provider2(), prefix: "/table_1" }, ]); expect(await combinedProvider.initialize(mockExtensionPoint().extensionPoint)).toEqual({ - datatypes: {}, - end: { nsec: 0, sec: 104 }, start: { nsec: 0, sec: 100 }, + end: { nsec: 0, sec: 104 }, topics: [ { datatype: "some_datatype", name: "/some_topic1" }, { datatype: "some_datatype", name: `${SECOND_BAG_PREFIX}/some_topic3`, originalTopic: "/some_topic3" }, { datatype: "some_datatype", name: "/table_1/some_topic2", originalTopic: "/some_topic2" }, ], + datatypes: {}, }); }); - describe("delete topics", () => { - const providerWithTopicToDelete = new MemoryDataProvider({ - messages: [ - { topic: "/some_topic", receiveTime: { sec: 101, nsec: 0 }, message: { value: 1 } }, - { topic: "/some_topic_to_delete", receiveTime: { sec: 103, nsec: 0 }, message: { value: 3 } }, - ], - topics: [ - { name: "/some_topic", datatype: "some_datatype" }, - { name: "/some_topic_to_delete", datatype: "some_datatype" }, - ], - datatypes: {}, - }); - it("delete topics from providers", async () => { - const combinedProvider = new CombinedDataProvider([ - { provider: provider1 }, + describe("deleting topics", () => { + function providerWithTopicToDelete() { + return new MemoryDataProvider({ + messages: [ + { topic: "/some_topic1", receiveTime: { sec: 101, nsec: 0 }, message: { value: 1 } }, + { topic: "/some_topic2", receiveTime: { sec: 103, nsec: 0 }, message: { value: 3 } }, + ], + topics: [ + { name: "/some_topic1", datatype: "some_datatype" }, + { name: "/some_topic2", datatype: "some_datatype" }, + ], + datatypes: {}, + }); + } + + it("deletes topics from providers without prefixes", async () => { + const combinedProvider = getCombinedDataProvider([ + { provider: provider1() }, { - provider: providerWithTopicToDelete, - deleteTopics: ["/some_topic_to_delete"], + provider: providerWithTopicToDelete(), + deleteTopics: ["/some_topic1"], }, ]); expect(await combinedProvider.initialize(mockExtensionPoint().extensionPoint)).toEqual({ - datatypes: {}, - end: { nsec: 0, sec: 103 }, start: { nsec: 0, sec: 101 }, + end: { nsec: 0, sec: 103 }, topics: [ { datatype: "some_datatype", name: "/some_topic1" }, - { datatype: "some_datatype", name: "/some_topic" }, + { datatype: "some_datatype", name: "/some_topic2" }, ], + datatypes: {}, }); }); - it("delete topics from providers with prefix", async () => { - const combinedProvider = new CombinedDataProvider([ - { provider: provider1 }, + it("deletes topics from providers with prefixes", async () => { + const combinedProvider = getCombinedDataProvider([ + { provider: provider1() }, { - provider: providerWithTopicToDelete, + provider: providerWithTopicToDelete(), prefix: "/table_1", - deleteTopics: ["/table_1/some_topic_to_delete"], + deleteTopics: ["/some_topic1"], }, ]); expect(await combinedProvider.initialize(mockExtensionPoint().extensionPoint)).toEqual({ - datatypes: {}, - end: { nsec: 0, sec: 103 }, start: { nsec: 0, sec: 101 }, + end: { nsec: 0, sec: 103 }, topics: [ { datatype: "some_datatype", name: "/some_topic1" }, - { datatype: "some_datatype", name: "/table_1/some_topic", originalTopic: "/some_topic" }, + { datatype: "some_datatype", name: "/table_1/some_topic2", originalTopic: "/some_topic2" }, ], + datatypes: {}, }); }); + + it("removes deleted topics from getMessages calls", async () => { + const p1 = provider1(); + const p2 = providerWithTopicToDelete(); + const combinedProvider = getCombinedDataProvider([ + { provider: p1 }, + { provider: p2, deleteTopics: ["/some_topic1"] }, + ]); + await combinedProvider.initialize(mockExtensionPoint().extensionPoint); + jest.spyOn(p1, "getMessages"); + jest.spyOn(p2, "getMessages"); + await combinedProvider.getMessages({ nsec: 0, sec: 101 }, { nsec: 0, sec: 103 }, [ + "/some_topic1", + "/some_topic2", + ]); + expect(p1.getMessages.mock.calls[0][2]).toEqual(["/some_topic1"]); + expect(p2.getMessages.mock.calls[0][2]).toEqual(["/some_topic2"]); + }); }); it("combines messages", async () => { - const combinedProvider = new CombinedDataProvider([ - { provider: provider1 }, - { provider: provider1Duplicate, prefix: SECOND_BAG_PREFIX }, + const combinedProvider = getCombinedDataProvider([ + { provider: provider1() }, + { provider: provider1Duplicate(), prefix: SECOND_BAG_PREFIX }, ]); await combinedProvider.initialize(mockExtensionPoint().extensionPoint); expect( @@ -221,10 +256,11 @@ describe("CombinedDataProvider", () => { { message: { value: 3 }, receiveTime: { nsec: 0, sec: 103 }, topic: `${SECOND_BAG_PREFIX}/some_topic1` }, ]); }); + it("allows customization of prefixes", async () => { - const combinedProvider = new CombinedDataProvider([ - { provider: provider1, prefix: "/table_1" }, - { provider: provider2, prefix: "/table_2" }, + const combinedProvider = getCombinedDataProvider([ + { provider: provider1(), prefix: "/table_1" }, + { provider: provider2(), prefix: "/table_2" }, ]); expect(await combinedProvider.initialize(mockExtensionPoint().extensionPoint)).toEqual({ datatypes: {}, @@ -236,58 +272,104 @@ describe("CombinedDataProvider", () => { ], }); }); + + it("does not call getMessages with out of bound times", async () => { + const p1 = new MemoryDataProvider({ + messages: [ + { topic: "/some_topic", receiveTime: { sec: 100, nsec: 0 }, message: undefined }, + { topic: "/some_topic", receiveTime: { sec: 130, nsec: 0 }, message: undefined }, + ], + topics: [{ name: "/some_topic", datatype: "some_datatype" }], + datatypes: {}, + }); + jest.spyOn(p1, "getMessages"); + const p2 = new MemoryDataProvider({ + messages: [ + { topic: "/some_topic2", receiveTime: { sec: 170, nsec: 0 }, message: undefined }, + { topic: "/some_topic2", receiveTime: { sec: 200, nsec: 0 }, message: undefined }, + ], + topics: [{ name: "/some_topic2", datatype: "some_datatype" }], + datatypes: {}, + }); + jest.spyOn(p2, "getMessages"); + const combinedProvider = getCombinedDataProvider([{ provider: p1 }, { provider: p2 }]); + const result = await combinedProvider.initialize(mockExtensionPoint().extensionPoint); + + // Sanity check: + expect(result.start).toEqual({ sec: 100, nsec: 0 }); + expect(result.end).toEqual({ sec: 200, nsec: 0 }); + + const messages = await combinedProvider.getMessages({ sec: 100, nsec: 0 }, { sec: 150, nsec: 0 }, [ + "/some_topic", + "/some_topic2", + ]); + expect(messages.length).toEqual(2); + expect(p1.getMessages.mock.calls[0]).toEqual([{ sec: 100, nsec: 0 }, { sec: 130, nsec: 0 }, ["/some_topic"]]); + expect(p2.getMessages.mock.calls.length).toEqual(0); + }); }); describe("extensionPoint", () => { describe("progressCallback", () => { it("calls progressCallback with the progress data passed from child provider", async () => { - const combinedProvider = new CombinedDataProvider([{ provider: provider1, prefix: "/generic_topic" }]); + const p1 = provider1(); + const combinedProvider = getCombinedDataProvider([{ provider: p1, prefix: "/generic_topic" }]); const extensionPoint = mockExtensionPoint().extensionPoint; jest.spyOn(extensionPoint, "progressCallback"); await combinedProvider.initialize(extensionPoint); - provider1.extensionPoint.progressCallback({ fullyLoadedFractionRanges: [{ start: 0, end: 0.5 }] }); - expect(extensionPoint.progressCallback.mock.calls).toEqual([ - [{ fullyLoadedFractionRanges: [{ end: 0.5, start: 0 }] }], + p1.extensionPoint.progressCallback({ fullyLoadedFractionRanges: [{ start: 0, end: 0.5 }] }); + const calls = extensionPoint.progressCallback.mock.calls; + expect(calls[calls.length - 1]).toEqual([{ fullyLoadedFractionRanges: [{ start: 0, end: 0.5 }] }]); + }); + + it("intersects progress from multiple child providers", async () => { + const p1 = provider1(); + const p2 = provider2(); + const combinedProvider = getCombinedDataProvider([ + { provider: p1, prefix: "/generic_topic" }, + { provider: p2 }, + ]); + const extensionPoint = mockExtensionPoint().extensionPoint; + jest.spyOn(extensionPoint, "progressCallback"); + await combinedProvider.initialize(extensionPoint); + p1.extensionPoint.progressCallback({ fullyLoadedFractionRanges: [{ start: 0.1, end: 0.5 }] }); + let calls = extensionPoint.progressCallback.mock.calls; + // Assume that p2 has no progress yet since it has not reported, so intersected range is empty + expect(calls[calls.length - 1]).toEqual([{ fullyLoadedFractionRanges: [] }]); + p2.extensionPoint.progressCallback({ fullyLoadedFractionRanges: [{ start: 0, end: 0.3 }] }); + calls = extensionPoint.progressCallback.mock.calls; + expect(calls[calls.length - 1]).toEqual([{ fullyLoadedFractionRanges: [{ end: 0.3, start: 0.1 }] }]); + }); + + it("assumes providers that don't report progress in initialize are fully loaded", async () => { + const p1 = provider1(true); + const p2 = provider2(); + const combinedProvider = getCombinedDataProvider([ + { provider: p1, prefix: "/generic_topic" }, + { provider: p2 }, ]); + const extensionPoint = mockExtensionPoint().extensionPoint; + jest.spyOn(extensionPoint, "progressCallback"); + await combinedProvider.initialize(extensionPoint); + p2.extensionPoint.progressCallback({ fullyLoadedFractionRanges: [{ start: 0.1, end: 0.5 }] }); + const calls = extensionPoint.progressCallback.mock.calls; + // Assume that p1 is fully loaded since it did not report during initialize, so intersected range is the one from p2 + expect(calls[calls.length - 1]).toEqual([{ fullyLoadedFractionRanges: [{ start: 0.1, end: 0.5 }] }]); }); }); + describe("reportMetadataCallback", () => { it("calls reportMetadataCallback with the progress data passed from child provider", async () => { - const combinedProvider = new CombinedDataProvider([{ provider: provider1, prefix: "/generic_topic" }]); + const p1 = provider1(); + const combinedProvider = getCombinedDataProvider([{ provider: p1, prefix: "/generic_topic" }]); const extensionPoint = mockExtensionPoint().extensionPoint; jest.spyOn(extensionPoint, "reportMetadataCallback"); await combinedProvider.initialize(extensionPoint); - provider1.extensionPoint.reportMetadataCallback({ type: "updateReconnecting", reconnecting: true }); + p1.extensionPoint.reportMetadataCallback({ type: "updateReconnecting", reconnecting: true }); expect(extensionPoint.reportMetadataCallback.mock.calls).toEqual([ [{ reconnecting: true, type: "updateReconnecting" }], ]); }); }); - - describe("addTopicsCallback", () => { - it("filters out topics that doesn't belong to the child provider", async () => { - const combinedProvider = new CombinedDataProvider([ - { provider: provider1 }, - { provider: provider2, prefix: "/table_1" }, - { provider: provider3, prefix: SECOND_BAG_PREFIX }, - ]); - - const { extensionPoint, topicCallbacks } = mockExtensionPoint(); - const cbMockProvider2 = jest.fn(); - const cbMockProvider3 = jest.fn(); - jest.spyOn(extensionPoint, "addTopicsCallback"); - await combinedProvider.initialize(extensionPoint); - provider2.extensionPoint.addTopicsCallback(cbMockProvider2); - provider3.extensionPoint.addTopicsCallback(cbMockProvider3); - expect(extensionPoint.addTopicsCallback.mock.calls).toEqual([[expect.any(Function)], [expect.any(Function)]]); - expect(topicCallbacks).toEqual([expect.any(Function), expect.any(Function)]); - - topicCallbacks.forEach((cb) => { - cb(["/table_1/some_topic2", `${SECOND_BAG_PREFIX}/some_topic3`]); - }); - expect(cbMockProvider2).toHaveBeenNthCalledWith(1, ["/some_topic2"]); - expect(cbMockProvider3).toHaveBeenNthCalledWith(1, ["/some_topic3"]); - }); - }); }); }); diff --git a/packages/webviz-core/src/players/FetchReader.js b/packages/webviz-core/src/players/FetchReader.js index bad59e2af..278f42e5f 100644 --- a/packages/webviz-core/src/players/FetchReader.js +++ b/packages/webviz-core/src/players/FetchReader.js @@ -16,11 +16,11 @@ export default class FetchReader extends Readable { _aborted: boolean = false; _url: string; - constructor(url: string, headers?: Headers) { + constructor(url: string, options: ?Object) { super(); this._url = url; this._controller = new AbortController(); - this._response = fetch(url, { headers, signal: this._controller.signal }); + this._response = fetch(url, { ...options, signal: this._controller.signal }); } // you can only call getReader once on a response body diff --git a/packages/webviz-core/src/players/IdbCacheReaderDataProvider.js b/packages/webviz-core/src/players/IdbCacheReaderDataProvider.js index 86c7bb514..9978814ab 100644 --- a/packages/webviz-core/src/players/IdbCacheReaderDataProvider.js +++ b/packages/webviz-core/src/players/IdbCacheReaderDataProvider.js @@ -6,16 +6,22 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. +import microMemoize from "micro-memoize"; import { Time } from "rosbag"; import { MESSAGES_STORE_NAME, getIdbCacheDataProviderDatabase, TIMESTAMP_INDEX } from "./IdbCacheDataProviderDatabase"; -import { type ChainableDataProvider, type InitializationResult, type MessageLike } from "./types"; -import type { ChainableDataProviderDescriptor, ExtensionPoint, GetDataProvider } from "webviz-core/src/players/types"; +import { type RandomAccessDataProvider, type InitializationResult, type MessageLike } from "./types"; +import type { DataProviderDescriptor, ExtensionPoint, GetDataProvider } from "webviz-core/src/players/types"; import type { Progress } from "webviz-core/src/types/players"; import Database from "webviz-core/src/util/indexeddb/Database"; import { type Range, deepIntersect, isRangeCoveredByRanges } from "webviz-core/src/util/ranges"; import { subtractTimes, toNanoSec } from "webviz-core/src/util/time"; +// Can take a few ms, so worth memoizing. +const deeplyIntersectedTopics = microMemoize((topics, rangesByTopic) => { + return deepIntersect(topics.map((topic) => rangesByTopic[topic] || [])); +}); + // This reads from an IndexedDB (Idb) database, which gets populated by an // `IdbCacheWriterDataProvider` (which has to be used below this provider). The writer communicates // with the reader in two ways: @@ -28,14 +34,14 @@ import { subtractTimes, toNanoSec } from "webviz-core/src/util/time"; // all the downloading, processing, and writing without blocking the main thread. For more details // on how stuff is stored in IndexedDB, see IdbCacheWriterDataProvider.js and // IdbCacheDataProviderDatabase.js. -export default class IdbCacheReaderDataProvider implements ChainableDataProvider { +export default class IdbCacheReaderDataProvider implements RandomAccessDataProvider { _id: string; - _provider: ChainableDataProvider; + _provider: RandomAccessDataProvider; _db: Database; _startTime: Time; _rangesByTopic: { [string]: Range[] } = {}; - constructor({ id }: {| id: string |}, children: ChainableDataProviderDescriptor[], getDataProvider: GetDataProvider) { + constructor({ id }: {| id: string |}, children: DataProviderDescriptor[], getDataProvider: GetDataProvider) { this._id = id; if (children.length !== 1) { throw new Error(`Incorrect number of children to IdbCacheReaderDataProvider: ${children.length}`); @@ -66,7 +72,7 @@ export default class IdbCacheReaderDataProvider implements ChainableDataProvider start: toNanoSec(subtractTimes(startTime, this._startTime)), end: toNanoSec(subtractTimes(endTime, this._startTime)) + 1, // `Range` is defined with `end` being exclusive. }; - if (!isRangeCoveredByRanges(range, deepIntersect(topics.map((topic) => this._rangesByTopic[topic] || [])))) { + if (!isRangeCoveredByRanges(range, deeplyIntersectedTopics(topics, this._rangesByTopic))) { // We use the child's `getMessages` promise to signal that the data is available in the database, // but we don't expect it to return actual messages. const getMessagesResult = await this._provider.getMessages(startTime, endTime, topics); diff --git a/packages/webviz-core/src/players/IdbCacheWriterDataProvider.js b/packages/webviz-core/src/players/IdbCacheWriterDataProvider.js index 27fdc1fa6..983d74c33 100644 --- a/packages/webviz-core/src/players/IdbCacheWriterDataProvider.js +++ b/packages/webviz-core/src/players/IdbCacheWriterDataProvider.js @@ -19,8 +19,8 @@ import { getIdbCacheDataProviderDatabase, } from "./IdbCacheDataProviderDatabase"; import type { - ChainableDataProvider, - ChainableDataProviderDescriptor, + RandomAccessDataProvider, + DataProviderDescriptor, ExtensionPoint, GetDataProvider, InitializationResult, @@ -34,9 +34,14 @@ import { fromNanoSec, subtractTimes, toNanoSec } from "webviz-core/src/util/time const log = new Logger(__filename); -export const BLOCK_SIZE_NS = 0.1 * 1e9; // 0.1 seconds. +const BLOCK_SIZE_MILLISECONDS = 100; +export const BLOCK_SIZE_NS = BLOCK_SIZE_MILLISECONDS * 1e6; const CONTINUE_DOWNLOADING_THRESHOLD = 3 * BLOCK_SIZE_NS; +function getNormalizedTopics(topics: string[]): string[] { + return uniq(topics).sort(); +} + // This writer is a companion to the `IdbCacheReaderDataProvider`. The writer fills up // IndexedDB (Idb) with messages, and the reader reads them. The writer gets signals from the reader // in order to download the relevant ranges, in the form of `getMessages` calls. We track ranges @@ -44,16 +49,17 @@ const CONTINUE_DOWNLOADING_THRESHOLD = 3 * BLOCK_SIZE_NS; // support `Time`). This also lets us use `getNewConnection`, which contains logic to determine // which range to download next. // For more details on how stuff is stored in IndexedDB, see IdbCacheDataProviderDatabase.js. -export default class IdbCacheWriterDataProvider implements ChainableDataProvider { +export default class IdbCacheWriterDataProvider implements RandomAccessDataProvider { _id: string; - _provider: ChainableDataProvider; + _provider: RandomAccessDataProvider; _db: Database; // The start time of the bag. Used for computing from and to nanoseconds since the start. _startTime: Time; - // The topics that we care about. This is always set by the last `getMessages` or topic callback. - _topics: string[] = []; + // The topics that we were most recently asked to load. + // This is always set by the last `getMessages` call. + _preloadTopics: string[] = []; // Total length of the data in nanoseconds. Used to compute progress with. _totalNs: number; @@ -74,7 +80,7 @@ export default class IdbCacheWriterDataProvider implements ChainableDataProvider _extensionPoint: ExtensionPoint; - constructor({ id }: {| id: string |}, children: ChainableDataProviderDescriptor[], getDataProvider: GetDataProvider) { + constructor({ id }: {| id: string |}, children: DataProviderDescriptor[], getDataProvider: GetDataProvider) { this._id = id; if (children.length !== 1) { throw new Error(`Incorrect number of children to IdbCacheReaderDataProvider: ${children.length}`); @@ -91,13 +97,17 @@ export default class IdbCacheWriterDataProvider implements ChainableDataProvider const result = await this._provider.initialize({ ...extensionPoint, progressCallback: () => {} }); this._startTime = result.start; this._totalNs = toNanoSec(subtractTimes(result.end, result.start)) + 1; // +1 since times are inclusive. - extensionPoint.addTopicsCallback((topics: string[]) => this._updateTopics(topics)); + if (this._totalNs > Number.MAX_SAFE_INTEGER * 0.9) { + throw new Error("Time range is too long to be supported"); + } + return result; } async getMessages(startTime: Time, endTime: Time, topics: string[]): Promise { // We might have a new set of topics. - this._updateTopics(topics); + topics = getNormalizedTopics(topics); + this._preloadTopics = topics; // Push a new entry to `this._readRequests`, and call `this._updateState()`. const range = { @@ -116,25 +126,23 @@ export default class IdbCacheWriterDataProvider implements ChainableDataProvider return this._provider.close(); } - _updateTopics(topics: string[]) { - const newTopics = uniq(topics).sort(); - if (!isEqual(this._topics, newTopics)) { - // If we have a different set of topics, stop the current "connection", and refresh everything. - delete this._currentConnection; - this._topics = newTopics; - this._updateProgress(); - this._updateState(); + // We're primarily interested in the topics for the first outstanding read request, and after that + // we're interested in preloading topics (based on the *last* read request). + _getCurrentTopics(): string[] { + if (this._readRequests[0]) { + return this._readRequests[0].topics; } + return this._preloadTopics; } // Gets called any time our "connection", read requests, or topics change. _updateState() { - if (this._topics.length === 0) { - return; - } - // First, see if there are any read requests that we can resolve now. this._readRequests = this._readRequests.filter(({ range, topics, resolve }) => { + if (topics.length === 0) { + resolve([]); + return false; + } const downloadedRanges: Range[] = this._getDownloadedRanges(topics); if (!isRangeCoveredByRanges(range, downloadedRanges)) { return true; @@ -144,11 +152,16 @@ export default class IdbCacheWriterDataProvider implements ChainableDataProvider return false; }); + if (this._currentConnection && !isEqual(this._currentConnection.topics, this._getCurrentTopics())) { + // If we have a different set of topics, stop the current "connection", and refresh everything. + delete this._currentConnection; + } + // Then see if we need to set a new connection based on the new connection and read requests state. const newConnection = getNewConnection({ currentRemainingRange: this._currentConnection ? this._currentConnection.remainingRange : undefined, readRequestRange: this._readRequests[0] ? this._readRequests[0].range : undefined, - downloadedRanges: this._getDownloadedRanges(this._topics), + downloadedRanges: this._getDownloadedRanges(this._getCurrentTopics()), lastResolvedCallbackEnd: this._lastResolvedCallbackEnd, cacheSize: Infinity, fileSize: this._totalNs, @@ -164,12 +177,19 @@ export default class IdbCacheWriterDataProvider implements ChainableDataProvider }); }); } + + this._updateProgress(); } // Replace the current connection with a new one, spanning a certain range. async _setConnection(range: Range) { + if (!this._getCurrentTopics().length) { + delete this._currentConnection; + return; + } + const id = uuid.v4(); - this._currentConnection = { id, topics: this._topics, remainingRange: range }; + this._currentConnection = { id, topics: this._getCurrentTopics(), remainingRange: range }; const reportTransactionError = (err) => { this._extensionPoint.reportMetadataCallback({ @@ -281,7 +301,7 @@ export default class IdbCacheWriterDataProvider implements ChainableDataProvider _updateProgress() { this._extensionPoint.progressCallback({ - fullyLoadedFractionRanges: this._getDownloadedRanges(this._topics).map((range) => ({ + fullyLoadedFractionRanges: this._getDownloadedRanges(this._getCurrentTopics()).map((range) => ({ start: range.start / this._totalNs, end: range.end / this._totalNs, })), diff --git a/packages/webviz-core/src/players/IdbCacheWriterDataProvider.test.js b/packages/webviz-core/src/players/IdbCacheWriterDataProvider.test.js index ab27aae8b..704ca5d54 100644 --- a/packages/webviz-core/src/players/IdbCacheWriterDataProvider.test.js +++ b/packages/webviz-core/src/players/IdbCacheWriterDataProvider.test.js @@ -11,7 +11,6 @@ import { TimeUtil } from "rosbag"; import { getIdbCacheDataProviderDatabase, MESSAGES_STORE_NAME, TIMESTAMP_INDEX } from "./IdbCacheDataProviderDatabase"; import IdbCacheWriterDataProvider, { BLOCK_SIZE_NS } from "./IdbCacheWriterDataProvider"; -import delay from "webviz-core/shared/delay"; import MemoryDataProvider from "webviz-core/src/players/MemoryDataProvider"; import { mockExtensionPoint } from "webviz-core/src/players/mockExtensionPoint"; import type { MessageLike } from "webviz-core/src/players/types"; @@ -72,22 +71,6 @@ describe("IdbCacheWriterDataProvider", () => { ]); }); - it("loads when topics are selected", async () => { - const { provider } = getProvider(); - const { extensionPoint, topicCallbacks } = mockExtensionPoint(); - jest.spyOn(extensionPoint, "progressCallback"); - - await provider.initialize(extensionPoint); - topicCallbacks[0](["/foo"]); - await delay(1000); // Wait until fully loaded and written to local db. - - expect(extensionPoint.progressCallback.mock.calls.length).toEqual(3 + 2e9 / BLOCK_SIZE_NS); - - const db = await getIdbCacheDataProviderDatabase("some-id"); - const messages = await db.getRange(MESSAGES_STORE_NAME, TIMESTAMP_INDEX, 0, 2e9); - expect(messages.map(({ value }) => value.message)).toEqual(generateMessages(["/foo"])); - }); - it("loads when calling getMessages", async () => { const { provider } = getProvider(); const { extensionPoint } = mockExtensionPoint(); @@ -96,7 +79,8 @@ describe("IdbCacheWriterDataProvider", () => { await provider.initialize(extensionPoint); const emptyArray = await provider.getMessages({ sec: 100, nsec: 0 }, { sec: 102, nsec: 0 }, ["/foo"]); - expect(extensionPoint.progressCallback.mock.calls.length).toEqual(3 + 2e9 / BLOCK_SIZE_NS); + // See comment in previous test for why we make this many calls. + expect(extensionPoint.progressCallback.mock.calls.length).toEqual(4 + 4e9 / BLOCK_SIZE_NS); expect(emptyArray).toEqual([]); const db = await getIdbCacheDataProviderDatabase("some-id"); @@ -119,4 +103,24 @@ describe("IdbCacheWriterDataProvider", () => { generateMessages(["/foo", "/bar", "/baz"]) ); }); + + // When this happens, we still have a promise to resolve, and we can't keep it unresolved because + // then the part of the application that is waiting for that promise might lock up, and we cannot + // resolve it with the newer topics because that would violate the API. + it("still loads old topics when there is a getMessages call pending while getMessages gets called", async () => { + const { provider } = getProvider(); + const { extensionPoint } = mockExtensionPoint(); + jest.spyOn(extensionPoint, "progressCallback"); + + await provider.initialize(extensionPoint); + const getMessagesPromise1 = provider.getMessages({ sec: 100, nsec: 0 }, { sec: 102, nsec: 0 }, ["/foo"]); + const getMessagesPromise2 = provider.getMessages({ sec: 100, nsec: 0 }, { sec: 102, nsec: 0 }, ["/foo", "/bar"]); + + expect(await getMessagesPromise1).toEqual([]); + expect(await getMessagesPromise2).toEqual([]); + + const db = await getIdbCacheDataProviderDatabase("some-id"); + const messages = await db.getRange(MESSAGES_STORE_NAME, TIMESTAMP_INDEX, 0, 6e9); + expect(sortMessages(messages.map(({ value }) => value.message))).toEqual(generateMessages(["/foo", "/bar"])); + }); }); diff --git a/packages/webviz-core/src/players/MeasureDataProvider.js b/packages/webviz-core/src/players/MeasureDataProvider.js index ae1ad6004..894346614 100644 --- a/packages/webviz-core/src/players/MeasureDataProvider.js +++ b/packages/webviz-core/src/players/MeasureDataProvider.js @@ -9,8 +9,8 @@ import type { Time } from "rosbag"; import type { - ChainableDataProvider, - ChainableDataProviderDescriptor, + RandomAccessDataProvider, + DataProviderDescriptor, DataProviderMetadata, ExtensionPoint, GetDataProvider, @@ -22,9 +22,9 @@ import Logger from "webviz-core/src/util/Logger"; const log = new Logger(__filename); export function instrumentDataProviderTree( - treeRoot: ChainableDataProviderDescriptor, + treeRoot: DataProviderDescriptor, depth: number = 1 -): ChainableDataProviderDescriptor { +): DataProviderDescriptor { return { name: "MeasureDataProvider", args: { name: `${new Array(depth * 2 + 1).join("-")}> ${treeRoot.name}` }, @@ -37,16 +37,12 @@ export function instrumentDataProviderTree( }; } -export default class MeasureDataProvider implements ChainableDataProvider { +export default class MeasureDataProvider implements RandomAccessDataProvider { _name: string; - _provider: ChainableDataProvider; + _provider: RandomAccessDataProvider; _reportMetadataCallback: (DataProviderMetadata) => void = () => {}; - constructor( - { name }: { name: string }, - children: ChainableDataProviderDescriptor[], - getDataProvider: GetDataProvider - ) { + constructor({ name }: { name: string }, children: DataProviderDescriptor[], getDataProvider: GetDataProvider) { if (children.length !== 1) { throw new Error(`Incorrect number of children to MeasureDataProvider: ${children.length}`); } @@ -63,7 +59,11 @@ export default class MeasureDataProvider implements ChainableDataProvider { const startMs = Date.now(); const argsString = `${start.sec}.${start.nsec}, ${end.sec}.${end.nsec}`; const result = await this._provider.getMessages(start, end, topics); - log.info(`MeasureDataProvider(${this._name}): ${Date.now() - startMs}ms for getMessages(${argsString})`); + log.info( + `MeasureDataProvider(${this._name}): ${Date.now() - startMs}ms for ${ + result.length + } messages from getMessages(${argsString})` + ); return result; } diff --git a/packages/webviz-core/src/players/MemoryDataProvider.js b/packages/webviz-core/src/players/MemoryDataProvider.js index 56d6adf1f..2963e8e01 100644 --- a/packages/webviz-core/src/players/MemoryDataProvider.js +++ b/packages/webviz-core/src/players/MemoryDataProvider.js @@ -24,24 +24,35 @@ export default class MemoryDataProvider implements RandomAccessDataProvider { topics: ?(Topic[]); datatypes: ?RosDatatypes; extensionPoint: ExtensionPoint; + initiallyLoaded: boolean; constructor({ messages, topics, datatypes, + initiallyLoaded, }: { messages: MessageLike[], topics?: Topic[], datatypes?: RosDatatypes, + initiallyLoaded?: boolean, }) { this.messages = messages; this.topics = topics; this.datatypes = datatypes; + this.initiallyLoaded = !!initiallyLoaded; } async initialize(extensionPoint: ExtensionPoint): Promise { this.extensionPoint = extensionPoint; + if (!this.initiallyLoaded) { + // Report progress during `initialize` to state intention to provide progress (for testing) + this.extensionPoint.progressCallback({ + fullyLoadedFractionRanges: [{ start: 0, end: 0 }], + }); + } + return { start: this.messages[0].receiveTime, end: last(this.messages).receiveTime, diff --git a/packages/webviz-core/src/players/ParseMessagesDataProvider.js b/packages/webviz-core/src/players/ParseMessagesDataProvider.js index 2db8763f8..33ee753e0 100644 --- a/packages/webviz-core/src/players/ParseMessagesDataProvider.js +++ b/packages/webviz-core/src/players/ParseMessagesDataProvider.js @@ -8,17 +8,22 @@ import { type Time } from "rosbag"; -import { type ChainableDataProvider, type MessageLike, type InitializationResult, type ExtensionPoint } from "./types"; -import type { ChainableDataProviderDescriptor, Connection, GetDataProvider } from "webviz-core/src/players/types"; +import { + type RandomAccessDataProvider, + type MessageLike, + type InitializationResult, + type ExtensionPoint, +} from "./types"; +import type { DataProviderDescriptor, Connection, GetDataProvider } from "webviz-core/src/players/types"; import MessageReaderStore from "webviz-core/src/util/MessageReaderStore"; const readers = new MessageReaderStore(); -export default class ParseMessagesDataProvider implements ChainableDataProvider { - _provider: ChainableDataProvider; +export default class ParseMessagesDataProvider implements RandomAccessDataProvider { + _provider: RandomAccessDataProvider; _connectionsByTopic: { [topic: string]: Connection } = {}; - constructor(_: {}, children: ChainableDataProviderDescriptor[], getDataProvider: GetDataProvider) { + constructor(_: {}, children: DataProviderDescriptor[], getDataProvider: GetDataProvider) { if (children.length !== 1) { throw new Error(`Incorrect number of children to ParseMessagesDataProvider: ${children.length}`); } diff --git a/packages/webviz-core/src/players/ParseMessagesDataProvider.test.js b/packages/webviz-core/src/players/ParseMessagesDataProvider.test.js index 5bb8e2067..c2b0ab88e 100644 --- a/packages/webviz-core/src/players/ParseMessagesDataProvider.test.js +++ b/packages/webviz-core/src/players/ParseMessagesDataProvider.test.js @@ -26,7 +26,6 @@ function getProvider() { const dummyExtensionPoint = { progressCallback() {}, - addTopicsCallback() {}, reportMetadataCallback() {}, }; diff --git a/packages/webviz-core/src/players/RandomAccessPlayer.js b/packages/webviz-core/src/players/RandomAccessPlayer.js index d3f62be08..6e19d7faa 100644 --- a/packages/webviz-core/src/players/RandomAccessPlayer.js +++ b/packages/webviz-core/src/players/RandomAccessPlayer.js @@ -5,13 +5,15 @@ // This source code is licensed under the Apache License, Version 2.0, // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. -import { isEqual } from "lodash"; +import { intersection, isEqual } from "lodash"; +import microMemoize from "micro-memoize"; import { TimeUtil, type Time } from "rosbag"; import uuid from "uuid"; import NoopMetricsCollector from "./NoopMetricsCollector"; import { type RandomAccessDataProvider } from "./types"; -import type { DataProviderMetadata } from "webviz-core/src/players/types"; +import { rootGetDataProvider } from "webviz-core/src/players/rootGetDataProvider"; +import type { DataProviderDescriptor, DataProviderMetadata } from "webviz-core/src/players/types"; import inScreenshotTests from "webviz-core/src/stories/inScreenshotTests"; import { type AdvertisePayload, @@ -26,6 +28,7 @@ import { type Topic, } from "webviz-core/src/types/players"; import type { RosDatatypes } from "webviz-core/src/types/RosDatatypes"; +import debouncePromise from "webviz-core/src/util/debouncePromise"; import reportError, { type ErrorType } from "webviz-core/src/util/reportError"; import { clampTime, fromMillis, subtractTimes, toSec } from "webviz-core/src/util/time"; @@ -33,11 +36,20 @@ const LOOP_MIN_BAG_TIME_IN_SEC = 1; const delay = (time) => new Promise((resolve) => setTimeout(resolve, time)); -// the number of nanoseconds to seek backwards to build context during a seek operation -// larger values mean more oportunity to capture context before the seek event, but are slower operations -export const SEEK_BACK_NANOSECONDS = 150 /* ms */ * 1000 * 1000; +// The number of nanoseconds to seek backwards to build context during a seek +// operation larger values mean more oportunity to capture context before the +// seek event, but are slower operations. We've chosen 99ms since our internal tool (Tableflow) +// publishes at 10hz, and we do NOT want to pull in a range of messages that +// exceeds that frequency. +export const SEEK_BACK_NANOSECONDS = 99 /* ms */ * 1000 * 1000; -const capabilities = [PlayerCapabilities.initialization]; +const capabilities = [PlayerCapabilities.setSpeed]; + +const getTopics = microMemoize( + (topics: Set, availableTopics: Topic[]): string[] => { + return intersection(Array.from(topics), availableTopics.map(({ name }) => name)); + } +); export default class RandomAccessPlayer implements Player { _provider: RandomAccessDataProvider; @@ -56,24 +68,25 @@ export default class RandomAccessPlayer implements Player { _providerDatatypes: RosDatatypes = {}; _metricsCollector: PlayerMetricsCollectorInterface; _autoplay: boolean; - _topicsCallbacks: ((string[]) => void)[] = []; _initializing: boolean = true; _reconnecting: boolean = false; _progress: Progress = {}; _id: string = uuid.v4(); _messages: Message[] = []; - _lastEmitPromise: Promise = Promise.resolve(); - _hasSetEmitStateCallback: boolean = false; _hasError = false; constructor( - provider: RandomAccessDataProvider, + providerDescriptor: DataProviderDescriptor, metricsCollector: PlayerMetricsCollectorInterface = new NoopMetricsCollector(), autoplay: boolean = false ) { - this._provider = provider; + if (process.env.NODE_ENV === "test" && providerDescriptor.name === "TestProvider") { + this._provider = providerDescriptor.args.provider; + } else { + this._provider = rootGetDataProvider(providerDescriptor); + } this._metricsCollector = metricsCollector; - this._autoplay = autoplay && !inScreenshotTests(); + this._autoplay = autoplay; } _setError(message: string, details: string | Error, errorType: ErrorType) { @@ -94,12 +107,6 @@ export default class RandomAccessPlayer implements Player { this._progress = progress; this._emitState(); }, - addTopicsCallback: (topicsCallback: (string[]) => void) => { - // Register any currently set subscriptions; otherwise, we wont - // alert the provider of new topics until the next time our subscriptions change. - topicsCallback(Array.from(this._subscribedTopics)); - this._topicsCallbacks.push(topicsCallback); - }, reportMetadataCallback: (metadata: DataProviderMetadata) => { switch (metadata.type) { case "error": @@ -120,15 +127,18 @@ export default class RandomAccessPlayer implements Player { } this._start = start; - // Since _currentTime is defined as the end of the last range that we emitted messages for - // (inclusive), we have to subtract 1 nanosecond at the start, otherwise we might - // double-emit messages with a receiveTime that is exactly equal to _currentTime. - this._setCurrentTime(TimeUtil.add(start, { sec: 0, nsec: -1 })); + this._currentTime = start; this._end = end; this._providerTopics = topics; this._providerDatatypes = datatypes; this._initializing = false; - this._emitState(); + + // If subscriptions came in while we were initializing, trigger an initial getMessages() call to kick off loading data. + if (this._subscribedTopics.size !== 0) { + this.seekPlayback(this._start); + } else { + this._emitState(); + } if (this._autoplay) { // Wait a bit until panels have had the chance to subscribe to topics before we start @@ -144,59 +154,48 @@ export default class RandomAccessPlayer implements Player { }); } - _emitState(): void { + _emitState = debouncePromise(() => { if (!this._listener) { - return; - } - if (this._hasSetEmitStateCallback) { - return; + return Promise.resolve(); } this._reportInitialized(); - this._hasSetEmitStateCallback = true; - this._lastEmitPromise.then(() => { - this._hasSetEmitStateCallback = false; - if (this._hasError) { - this._lastEmitPromise = this._listener({ - isPresent: false, - showSpinner: false, - showInitializing: false, - progress: {}, - capabilities: [], - playerId: this._id, - activeData: undefined, - }); - return; - } - - this._lastEmitPromise = this._listener({ - isPresent: true, - showSpinner: this._initializing || this._reconnecting, - showInitializing: this._initializing, - progress: this._progress, - capabilities, + if (this._hasError) { + return this._listener({ + isPresent: false, + showSpinner: false, + showInitializing: false, + progress: {}, + capabilities: [], playerId: this._id, - activeData: this._initializing - ? undefined - : { - messages: this._messages, - currentTime: TimeUtil.isLessThan(this._currentTime, this._start) - ? this._start - : TimeUtil.isLessThan(this._end, this._currentTime) - ? this._end - : this._currentTime, - startTime: this._start, - endTime: this._end, - isPlaying: this._isPlaying, - speed: this._speed, - lastSeekTime: this._lastSeekTime, - topics: this._providerTopics, - datatypes: this._providerDatatypes, - }, + activeData: undefined, }); - this._messages = []; + } + + const messages = this._messages; + this._messages = []; + return this._listener({ + isPresent: true, + showSpinner: this._initializing || this._reconnecting, + showInitializing: this._initializing, + progress: this._progress, + capabilities, + playerId: this._id, + activeData: this._initializing + ? undefined + : { + messages, + currentTime: clampTime(this._currentTime, this._start, this._end), + startTime: this._start, + endTime: this._end, + isPlaying: this._isPlaying, + speed: this._speed, + lastSeekTime: this._lastSeekTime, + topics: this._providerTopics, + datatypes: this._providerDatatypes, + }, }); - } + }); async _tick(): Promise { if (this._initializing || !this._isPlaying || this._hasError) { @@ -218,10 +217,14 @@ export default class RandomAccessPlayer implements Player { // loop to the beginning if we pass the end of the playback range if (isEqual(this._currentTime, this._end)) { if (inScreenshotTests()) { - return; // Just don't loop at all in screenshot / integration tests. + // Just don't loop at all in screenshot / integration tests. + this.pausePlayback(); + return; } if (toSec(subtractTimes(this._end, this._start)) < LOOP_MIN_BAG_TIME_IN_SEC) { - return; // Don't loop for short bags. + // Don't loop for short bags. + this.pausePlayback(); + return; } // Wait a little bit before we loop back. This helps with extremely small bags; otherwise // it looks like it's stuck at the beginning of the bag. @@ -236,7 +239,7 @@ export default class RandomAccessPlayer implements Player { const start: Time = clampTime(TimeUtil.add(this._currentTime, { sec: 0, nsec: 1 }), this._start, this._end); const end: Time = clampTime(TimeUtil.add(this._currentTime, fromMillis(rangeMillis)), this._start, this._end); const messages = await this._getMessages(start, end); - await this._lastEmitPromise; + await this._emitState.currentPromise; // if we seeked while reading the do not emit messages // just start reading again from the new seek position @@ -271,25 +274,45 @@ export default class RandomAccessPlayer implements Player { } async _getMessages(start: Time, end: Time): Promise { - const messages = await this._provider.getMessages(start, end, Array.from(this._subscribedTopics)); - return messages.map((message) => { - const topic: ?Topic = this._providerTopics.find((t) => t.name === message.topic); - if (!topic) { - throw new Error(`Could not find topic for message ${message.topic}`); - } - - if (!topic.datatype) { - throw new Error(`Missing datatype for topic: ${message.topic}`); - } + const topics = getTopics(this._subscribedTopics, this._providerTopics); + const messages = await this._provider.getMessages(start, end, topics); + return messages + .map((message) => { + if (!topics.includes(message.topic)) { + reportError( + `Unexpected topic encountered: ${message.topic}; skipped message`, + `Full message details: ${JSON.stringify(message)}`, + "app" + ); + return undefined; + } + const topic: ?Topic = this._providerTopics.find((t) => t.name === message.topic); + if (!topic) { + reportError( + `Could not find topic for message ${message.topic}; skipped message`, + `Full message details: ${JSON.stringify(message)}`, + "app" + ); + return undefined; + } + if (!topic.datatype) { + reportError( + `Missing datatype for topic: ${message.topic}; skipped message`, + `Full message details: ${JSON.stringify(message)}`, + "app" + ); + return undefined; + } - return { - op: "message", - topic: message.topic, - datatype: topic.datatype, - receiveTime: message.receiveTime, - message: message.message, - }; - }); + return { + op: "message", + topic: message.topic, + datatype: topic.datatype, + receiveTime: message.receiveTime, + message: message.message, + }; + }) + .filter(Boolean); } startPlayback(): void { @@ -298,7 +321,14 @@ export default class RandomAccessPlayer implements Player { } this._metricsCollector.play(this._speed); this._isPlaying = true; - this._emitState(); + + // If we had paused at the end, pressing play should loop back to the beginning. + if (isEqual(this._currentTime, this._end)) { + this.seekPlayback(this._start); + } else { + this._emitState(); + } + this._read().catch((e: Error) => { this._setError(e.message, e, "app"); }); @@ -355,6 +385,7 @@ export default class RandomAccessPlayer implements Player { }), time ).then((messages) => { + // Only emit the messages if we haven't seeked again since we started loading them. if (seekTime === this._lastSeekTime) { this._messages = messages; this._emitState(); @@ -367,9 +398,9 @@ export default class RandomAccessPlayer implements Player { const oldSubscribedTopics = this._subscribedTopics; const subscribedTopics = new Set(subscriptions.map(({ topic }) => topic)); this._subscribedTopics = subscribedTopics; - if (!isEqual(oldSubscribedTopics, subscribedTopics)) { - const topics = Array.from(this._subscribedTopics); - this._topicsCallbacks.forEach((callback) => callback(topics)); + if (!isEqual(oldSubscribedTopics, subscribedTopics) && !this._isPlaying && !this._initializing) { + // Trigger a seek so that we backfill recent messages on the newly subscribed topics. + this.seekPlayback(this._currentTime); } } diff --git a/packages/webviz-core/src/players/RandomAccessPlayer.test.js b/packages/webviz-core/src/players/RandomAccessPlayer.test.js index 5cd0582b9..44d63dc78 100644 --- a/packages/webviz-core/src/players/RandomAccessPlayer.test.js +++ b/packages/webviz-core/src/players/RandomAccessPlayer.test.js @@ -22,17 +22,19 @@ import { type MessageLike, type RandomAccessDataProvider, } from "./types"; -import { type PlayerMetricsCollectorInterface, type Topic, type PlayerState } from "webviz-core/src/types/players"; +import { + type PlayerMetricsCollectorInterface, + type Topic, + type PlayerState, + PlayerCapabilities, +} from "webviz-core/src/types/players"; import type { RosDatatypes } from "webviz-core/src/types/RosDatatypes"; -import { fromMillis } from "webviz-core/src/util/time"; jest.mock("webviz-core/src/util/reportError"); type GetMessages = (start: Time, end: Time, topics: string[]) => Promise; -// start at 1 nanosecond because RandomAccesPlayer rewinds by 1 nano when before starting playback -// and subtracting 1 nanosecond from {0, 0} throws an error -const start = { sec: 0, nsec: 1 }; +const start = { sec: 0, nsec: 0 }; const end = { sec: 100, nsec: 0 }; const datatypes: RosDatatypes = { fooBar: [ @@ -128,60 +130,24 @@ class MessageStore { } describe("RandomAccessPlayer", () => { - it("sets initial topics when extension point registers topics callback", (done) => { - const provider = { - initialize: (extensionPoint) => { - extensionPoint.addTopicsCallback((topics) => { - expect(topics).toEqual(["/foo/bar", "/foo/baz"]); - done(); - }); - return Promise.resolve(); - }, - async close() {}, - }; - const source = new RandomAccessPlayer((provider: any)); - source.setSubscriptions([{ topic: "/foo/bar" }, { topic: "/foo/baz" }]); - source.setListener(() => Promise.resolve()); + let mockDateNow; + beforeEach(() => { + mockDateNow = jest.spyOn(Date, "now").mockReturnValue(0); }); - it("calls extension point topics callbacks when topics change", async () => { - const provider = new TestProvider(); - const source = new RandomAccessPlayer(provider); - const listener = (msg) => Promise.resolve(); - await source.setListener(listener); - const extPoint = provider.extensionPoint; - const topicCalls1 = []; - const topicCalls2 = []; - extPoint.addTopicsCallback((topics: string[]) => { - topicCalls1.push(topics); - }); - extPoint.addTopicsCallback((topics: string[]) => { - topicCalls2.push(topics); - }); - expect(topicCalls1).toEqual([[]]); - source.setSubscriptions([{ topic: "/foo/bar" }]); - expect(topicCalls1).toEqual([[], ["/foo/bar"]]); - source.setSubscriptions([{ topic: "/foo/bar" }, { topic: "/foo/bar" }]); - expect(topicCalls1).toEqual([[], ["/foo/bar"]]); - source.setSubscriptions([{ topic: "/foo/bar" }, { topic: "/foo/bar" }, { topic: "/baz" }]); - expect(topicCalls1).toEqual([[], ["/foo/bar"], ["/foo/bar", "/baz"]]); - source.setSubscriptions([{ topic: "/baz" }]); - expect(topicCalls1).toEqual([[], ["/foo/bar"], ["/foo/bar", "/baz"], ["/baz"]]); - expect(topicCalls2).toEqual(topicCalls1); + afterEach(() => { + mockDateNow.mockRestore(); }); it("calls listener with player initial player state and data types", async () => { const provider = new TestProvider(); - // player gets its lastSeekTime initially from Date.now() - const mockDateNow = jest.spyOn(Date, "now").mockReturnValue(0); - const source = new RandomAccessPlayer(provider); + const source = new RandomAccessPlayer({ name: "TestProvider", args: { provider }, children: [] }); const store = new MessageStore(2); await source.setListener(store.add); const messages = await store.done; - mockDateNow.mockRestore(); expect(messages).toEqual([ { activeData: undefined, - capabilities: ["initialization"], + capabilities: [PlayerCapabilities.setSpeed], isPresent: true, progress: {}, showInitializing: true, @@ -189,17 +155,17 @@ describe("RandomAccessPlayer", () => { }, { activeData: { - currentTime: { sec: 0, nsec: 1 }, + currentTime: { sec: 0, nsec: 0 }, datatypes: { baz: [{ name: "val", type: "number" }], fooBar: [{ name: "val", type: "number" }] }, endTime: { sec: 100, nsec: 0 }, isPlaying: false, lastSeekTime: 0, messages: [], speed: 0.2, - startTime: { sec: 0, nsec: 1 }, + startTime: { sec: 0, nsec: 0 }, topics: [{ datatype: "fooBar", name: "/foo/bar" }, { datatype: "baz", name: "/baz" }], }, - capabilities: ["initialization"], + capabilities: ["setSpeed"], isPresent: true, progress: {}, showInitializing: false, @@ -212,7 +178,7 @@ describe("RandomAccessPlayer", () => { it("calls listener with player state changes on play/pause", async () => { const provider = new TestProvider(); - const source = new RandomAccessPlayer(provider); + const source = new RandomAccessPlayer({ name: "TestProvider", args: { provider }, children: [] }); const store = new MessageStore(2); await source.setListener(store.add); // make getMessages do nothing since we're going to start reading @@ -232,7 +198,7 @@ describe("RandomAccessPlayer", () => { it("calls listener with speed changes", async () => { const provider = new TestProvider(); - const source = new RandomAccessPlayer(provider); + const source = new RandomAccessPlayer({ name: "TestProvider", args: { provider }, children: [] }); const store = new MessageStore(2); await source.setListener(store.add); // allow initialization messages to come in @@ -250,41 +216,51 @@ describe("RandomAccessPlayer", () => { }); it("reads messages when playing back", async () => { + expect.assertions(6); const provider = new TestProvider(); - const source = new RandomAccessPlayer(provider); - const store = new MessageStore(4); - await source.setListener(store.add); - source.setSubscriptions([{ topic: "/foo/bar" }]); let callCount = 0; - let firstEnd; - const getMessages: GetMessages = (start: Time, end: Time, topics: string[]): Promise => { + provider.getMessages = (start: Time, end: Time, topics: string[]): Promise => { callCount++; - if (callCount > 1) { - // our second start should be exactly 1 nano after our first end - expect(TimeUtil.compare(start, TimeUtil.add(firstEnd, { sec: 0, nsec: 1 }))).toBe(0); - expect(TimeUtil.isGreaterThan(end, start)).toBeTruthy(); - return Promise.resolve([]); + switch (callCount) { + case 1: + // initial getMessages from player initialization + expect(start).toEqual({ sec: 0, nsec: 0 }); + expect(end).toEqual({ sec: 0, nsec: 0 }); + return Promise.resolve([]); + + case 2: { + expect(start).toEqual({ sec: 0, nsec: 1 }); + expect(end).toEqual({ sec: 0, nsec: 4000000 }); + expect(topics).toEqual(["/foo/bar"]); + const result: MessageLike[] = [ + { + topic: "/foo/bar", + receiveTime: { sec: 0, nsec: 2 }, + message: { payload: "foo bar" }, + }, + ]; + return Promise.resolve(result); + } + + default: + throw new Error("getMessages called too many times"); } - firstEnd = end; - expect(start).toEqual({ sec: 0, nsec: 1 }); - expect(end).toEqual(fromMillis(20 * 0.2)); - expect(topics).toContainOnly(["/foo/bar"]); - const result: MessageLike[] = [ - { - topic: "/foo/bar", - receiveTime: { sec: 0, nsec: 0 }, - message: { payload: "foo bar" }, - }, - ]; - return Promise.resolve(result); }; - provider.getMessages = getMessages; + + const source = new RandomAccessPlayer({ name: "TestProvider", args: { provider }, children: [] }); + const store = new MessageStore(6); + await source.setListener(store.add); + + source.setSubscriptions([{ topic: "/foo/bar" }]); source.startPlayback(); const messages = await store.done; // close the player to stop more reads source.close(); + const messagePayloads = messages.map((msg) => (msg.activeData || {}).messages || []); expect(messagePayloads).toEqual([ + [], + [], [], [], [ @@ -292,7 +268,7 @@ describe("RandomAccessPlayer", () => { op: "message", topic: "/foo/bar", datatype: "fooBar", - receiveTime: { sec: 0, nsec: 0 }, + receiveTime: { sec: 0, nsec: 2 }, message: { payload: "foo bar" }, }, ], @@ -302,75 +278,96 @@ describe("RandomAccessPlayer", () => { it("still moves forward time if there are no messages", async () => { const provider = new TestProvider(); - const source = new RandomAccessPlayer(provider); - const store = new MessageStore(4); - await source.setListener(store.add); - source.setSubscriptions([{ topic: "/foo/bar" }]); + const source = new RandomAccessPlayer({ name: "TestProvider", args: { provider }, children: [] }); + let callCount = 0; - let firstEnd; - const getMessages: GetMessages = (start: Time, end: Time, topics: string[]): Promise => { + provider.getMessages = (start: Time, end: Time, topics: string[]): Promise => { callCount++; - if (callCount > 2) { - throw new Error("should not be called again"); - } - if (callCount > 1) { - // our second start should be exactly 1 nano after our first end - expect(TimeUtil.compare(start, TimeUtil.add(firstEnd, { sec: 0, nsec: 1 }))).toBe(0); - expect(TimeUtil.isGreaterThan(end, start)).toBeTruthy(); - source.pausePlayback(); - return Promise.resolve([]); + switch (callCount) { + case 1: + // initial getMessages from player initialization + expect(start).toEqual({ sec: 0, nsec: 0 }); + expect(end).toEqual({ sec: 0, nsec: 0 }); + expect(topics).toContainOnly(["/foo/bar"]); + return Promise.resolve([]); + + case 2: + expect(start).toEqual({ sec: 0, nsec: 2 }); + expect(end).toEqual({ sec: 0, nsec: 4000001 }); + expect(topics).toEqual(["/foo/bar"]); + source.pausePlayback(); + return Promise.resolve([]); + + default: + throw new Error("getMessages called too many times"); } - firstEnd = end; - expect(start).toEqual({ sec: 0, nsec: 1 }); - expect(end).toEqual(fromMillis(20 * 0.2)); - expect(topics).toContainOnly(["/foo/bar"]); - return Promise.resolve([]); }; - provider.getMessages = getMessages; + + const store = new MessageStore(5); + await source.setListener(store.add); + source.setSubscriptions([{ topic: "/foo/bar" }]); source.startPlayback(); const messages = await store.done; // close the player to stop more reads source.close(); const messagePayloads = messages.map((msg) => (msg.activeData || {}).messages || []); - expect(messagePayloads).toEqual([[], [], [], []]); + expect(messagePayloads).toEqual([[], [], [], [], []]); }); it("pauses and does not emit messages after pause", async () => { const provider = new TestProvider(); - const source = new RandomAccessPlayer(provider); - const store = new MessageStore(4); - await source.setListener(store.add); - source.setSubscriptions([{ topic: "/foo/bar" }]); + const source = new RandomAccessPlayer({ name: "TestProvider", args: { provider }, children: [] }); + let callCount = 0; - const getMessages: GetMessages = (start: Time, end: Time, topics: string[]): Promise => { + provider.getMessages = (start: Time, end: Time, topics: string[]): Promise => { callCount++; - if (callCount > 1) { - source.pausePlayback(); - return Promise.resolve([ - { - topic: "/foo/bar", - receiveTime: start, - message: "this message should not be emitted", - }, - ]); + switch (callCount) { + case 1: + // initial getMessages from player initialization + expect(start).toEqual({ sec: 0, nsec: 0 }); + expect(end).toEqual({ sec: 0, nsec: 0 }); + expect(topics).toContainOnly(["/foo/bar"]); + return Promise.resolve([]); + + case 2: { + expect(start).toEqual({ sec: 0, nsec: 1 }); + expect(end).toEqual({ sec: 0, nsec: 4000000 }); + expect(topics).toContainOnly(["/foo/bar"]); + const result: MessageLike[] = [ + { + topic: "/foo/bar", + receiveTime: { sec: 0, nsec: 0 }, + message: { payload: "foo bar" }, + }, + ]; + return Promise.resolve(result); + } + + case 3: + source.pausePlayback(); + return Promise.resolve([ + { + topic: "/foo/bar", + receiveTime: start, + message: "this message should not be emitted", + }, + ]); + + default: + throw new Error("getMessages called too many times"); } - expect(start).toEqual({ sec: 0, nsec: 1 }); - expect(end).toEqual(fromMillis(20 * 0.2)); - expect(topics).toContainOnly(["/foo/bar"]); - const result: MessageLike[] = [ - { - topic: "/foo/bar", - receiveTime: { sec: 0, nsec: 0 }, - message: { payload: "foo bar" }, - }, - ]; - return Promise.resolve(result); }; - provider.getMessages = getMessages; + + const store = new MessageStore(6); + await source.setListener(store.add); + source.setSubscriptions([{ topic: "/foo/bar" }]); + source.startPlayback(); const messages = await store.done; const messagePayloads = messages.map((msg) => (msg.activeData || {}).messages || []); expect(messagePayloads).toEqual([ + [], + [], [], [], [ @@ -388,87 +385,124 @@ describe("RandomAccessPlayer", () => { it("seek during reading discards messages before seek", async () => { const provider = new TestProvider(); - const source = new RandomAccessPlayer(provider); - const store = new MessageStore(4); - await source.setListener(store.add); - source.setSubscriptions([{ topic: "/foo/bar" }]); + const source = new RandomAccessPlayer({ name: "TestProvider", args: { provider }, children: [] }); let callCount = 0; - const getMessages: GetMessages = async (start: Time, end: Time, topics: string[]): Promise => { + provider.getMessages = async (start: Time, end: Time, topics: string[]): Promise => { expect(topics).toContainOnly(["/foo/bar"]); callCount++; - if (callCount > 1) { - source.pausePlayback(); - return Promise.resolve([ - { - topic: "/foo/bar", - receiveTime: start, - message: "this message should not be emitted", - }, - ]); - } + switch (callCount) { + case 1: + // initial getMessages from player initialization + expect(start).toEqual({ sec: 0, nsec: 0 }); + expect(end).toEqual({ sec: 0, nsec: 0 }); + expect(topics).toContainOnly(["/foo/bar"]); + return Promise.resolve([]); + case 2: { + expect(start).toEqual({ sec: 0, nsec: 1 }); + expect(end).toEqual({ sec: 0, nsec: 4000000 }); + expect(topics).toContainOnly(["/foo/bar"]); + const result: MessageLike[] = [ + { + topic: "/foo/bar", + receiveTime: { sec: 0, nsec: 0 }, + message: { payload: "foo bar" }, + }, + ]; + await new Promise((resolve) => setTimeout(resolve, 10)); + mockDateNow.mockReturnValue(Date.now() + 1); + source.seekPlayback({ sec: 0, nsec: 0 }); + return Promise.resolve(result); + } - expect(start).toEqual({ sec: 0, nsec: 1 }); - expect(end).toEqual(fromMillis(20 * 0.2)); - expect(topics).toContainOnly(["/foo/bar"]); - const result: MessageLike[] = [ - { - topic: "/foo/bar", - receiveTime: { sec: 0, nsec: 0 }, - message: { payload: "foo bar" }, - }, - ]; - await new Promise((resolve) => setTimeout(resolve, 10)); - source.seekPlayback({ sec: 0, nsec: 0 }); - return Promise.resolve(result); + case 3: + source.pausePlayback(); + return Promise.resolve([ + { + topic: "/foo/bar", + receiveTime: start, + message: "this message should not be emitted", + }, + ]); + + default: + throw new Error("getMessages called too many times"); + } }; - provider.getMessages = getMessages; + + const store = new MessageStore(6); + await source.setListener(store.add); + source.setSubscriptions([{ topic: "/foo/bar" }]); source.startPlayback(); + const messages = await store.done; - expect(messages).toHaveLength(4); + expect(messages).toHaveLength(6); const activeDatas = messages.map((msg) => msg.activeData || {}); expect(activeDatas.map((d) => d.currentTime)).toEqual([ undefined, // "start up" message - { sec: 0, nsec: 1 }, - { sec: 0, nsec: 1 }, - { sec: 0, nsec: 1 }, + { sec: 0, nsec: 0 }, + { sec: 0, nsec: 0 }, + { sec: 0, nsec: 0 }, + { sec: 0, nsec: 0 }, + { sec: 0, nsec: 0 }, ]); - expect(activeDatas.map((d) => d.messages)).toEqual([undefined, [], [], []]); + expect(activeDatas.map((d) => d.messages)).toEqual([undefined, [], [], [], [], []]); }); it("backfills previous messages on seek", async () => { const provider = new TestProvider(); - const source = new RandomAccessPlayer(provider); - const store = new MessageStore(3); - await source.setListener(store.add); - source.setSubscriptions([{ topic: "/foo/bar" }]); + const source = new RandomAccessPlayer({ name: "TestProvider", args: { provider }, children: [] }); let callCount = 0; - const getMessages: GetMessages = (start: Time, end: Time, topics: string[]): Promise => { + provider.getMessages = (start: Time, end: Time, topics: string[]): Promise => { callCount++; - if (callCount > 2) { - source.pausePlayback(); - return Promise.resolve([]); - } - if (callCount > 1) { - // make sure after we seek & read again we read exactly from the right nanosecond - expect(start).toEqual({ sec: 20, nsec: 51 }); - return Promise.resolve([ - { topic: "/foo/bar", receiveTime: { sec: 0, nsec: 101 }, message: { payload: "baz" } }, - ]); + switch (callCount) { + case 1: + // initial getMessages from player initialization + expect(start).toEqual({ sec: 0, nsec: 0 }); + expect(end).toEqual({ sec: 0, nsec: 0 }); + expect(topics).toContainOnly(["/foo/bar"]); + return Promise.resolve([]); + + case 2: { + expect(start).toEqual({ sec: 19, nsec: 1e9 + 50 - SEEK_BACK_NANOSECONDS }); + expect(end).toEqual({ sec: 20, nsec: 50 }); + expect(topics).toContainOnly(["/foo/bar"]); + const result: MessageLike[] = [ + { + topic: "/foo/bar", + receiveTime: { sec: 0, nsec: 5 }, + message: { payload: "foo bar" }, + }, + ]; + return Promise.resolve(result); + } + case 3: + // make sure after we seek & read again we read exactly from the right nanosecond + expect(start).toEqual({ sec: 20, nsec: 51 }); + return Promise.resolve([ + { topic: "/foo/bar", receiveTime: { sec: 0, nsec: 101 }, message: { payload: "baz" } }, + ]); + case 4: + source.pausePlayback(); + return Promise.resolve([]); + default: + throw new Error("getMessages called too many times"); } - expect(start).toEqual({ sec: 19, nsec: 1e9 + 50 - SEEK_BACK_NANOSECONDS }); - expect(end).toEqual({ sec: 20, nsec: 50 }); - expect(topics).toContainOnly(["/foo/bar"]); - const result: MessageLike[] = [ - { - topic: "/foo/bar", - receiveTime: { sec: 0, nsec: 5 }, - message: { payload: "foo bar" }, - }, - ]; - return Promise.resolve(result); }; - provider.getMessages = getMessages; + + const store = new MessageStore(2); + source.setListener(store.add); + expect(await store.done).toEqual([ + expect.objectContaining({ activeData: undefined }), + expect.objectContaining({ activeData: expect.any(Object) }), + ]); + + store.reset(3); + source.setSubscriptions([{ topic: "/foo/bar" }]); + // ensure results from the automatic backfill during setSubscriptions are always thrown away + // after the new seek, by making the lastSeekTime change + mockDateNow.mockReturnValue(Date.now() + 1); source.seekPlayback({ sec: 20, nsec: 50 }); + const messages = await store.done; expect(messages.map((msg) => (msg.activeData ? msg.activeData.messages : []))).toEqual([ [], @@ -503,7 +537,7 @@ describe("RandomAccessPlayer", () => { it("clamps times passed to the DataProvider", async () => { const provider = new TestProvider(); - const source = new RandomAccessPlayer(provider); + const source = new RandomAccessPlayer({ name: "TestProvider", args: { provider }, children: [] }); source.setSubscriptions([{ topic: "/foo/bar" }]); let lastGetMessagesCall; const getMessages: GetMessages = (start: Time, end: Time, topics: string[]): Promise => { @@ -522,8 +556,8 @@ describe("RandomAccessPlayer", () => { } lastGetMessagesCall.resolve([]); expect(lastGetMessagesCall).toEqual({ - start: { nsec: 1, sec: 0 }, - end: { nsec: 100, sec: 0 }, + start: { sec: 0, nsec: 0 }, + end: { sec: 0, nsec: 100 }, topics: ["/foo/bar"], resolve: expect.any(Function), }); @@ -543,13 +577,6 @@ describe("RandomAccessPlayer", () => { it("reads a bunch of messages", async () => { const provider = new TestProvider(); - const source = new RandomAccessPlayer(provider); - const received = []; - await source.setListener((msg) => { - received.push(...((msg.activeData || {}).messages || [])); - return Promise.resolve(); - }); - source.setSubscriptions([{ topic: "/foo/bar" }, { topic: "/baz" }]); const items: MessageLike[] = [ { topic: "/foo/bar", @@ -574,7 +601,7 @@ describe("RandomAccessPlayer", () => { ]; let resolve; const done = new Promise((_resolve) => (resolve = _resolve)); - const getMessages: GetMessages = (start: Time, end: Time, topics: string[]): Promise => { + provider.getMessages = (start: Time, end: Time, topics: string[]): Promise => { expect(topics).toContainOnly(["/foo/bar", "/baz"]); const next = items.shift(); if (!next) { @@ -583,7 +610,14 @@ describe("RandomAccessPlayer", () => { } return Promise.resolve([next]); }; - provider.getMessages = getMessages; + + const source = new RandomAccessPlayer({ name: "TestProvider", args: { provider }, children: [] }); + const received = []; + await source.setListener((msg) => { + received.push(...((msg.activeData || {}).messages || [])); + return Promise.resolve(); + }); + source.setSubscriptions([{ topic: "/foo/bar" }, { topic: "/baz" }]); source.startPlayback(); await done; source.pausePlayback(); @@ -621,7 +655,7 @@ describe("RandomAccessPlayer", () => { it("closes provider when closed", async () => { const provider = new TestProvider(); - const source = new RandomAccessPlayer(provider); + const source = new RandomAccessPlayer({ name: "TestProvider", args: { provider }, children: [] }); await source.close(); expect(provider.closed).toBe(true); }); @@ -642,7 +676,7 @@ describe("RandomAccessPlayer", () => { closed = true; }, }; - const source = new RandomAccessPlayer((provider: any)); + const source = new RandomAccessPlayer({ name: "TestProvider", args: { provider }, children: [] }); source.setListener((state) => { if (!state.isPresent) { expect(closed).toBe(true); @@ -654,7 +688,7 @@ describe("RandomAccessPlayer", () => { it("shows a spinner when a provider is reconnecting", (done) => { const provider = new TestProvider(); - const source = new RandomAccessPlayer((provider: any)); + const source = new RandomAccessPlayer({ name: "TestProvider", args: { provider }, children: [] }); source.setListener((state) => { if (!state.showInitializing) { if (!state.showSpinner) { @@ -711,7 +745,7 @@ describe("RandomAccessPlayer", () => { provider.getMessages = () => Promise.resolve([]); const collector = new TestMetricsCollector(); - const source = new RandomAccessPlayer(provider, collector); + const source = new RandomAccessPlayer({ name: "TestProvider", args: { provider }, children: [] }, collector); expect(collector.stats()).toEqual({ initialized: 0, played: 0, diff --git a/packages/webviz-core/src/players/ReadAheadDataProvider.js b/packages/webviz-core/src/players/ReadAheadDataProvider.js index 3ada0bf6c..6df660420 100644 --- a/packages/webviz-core/src/players/ReadAheadDataProvider.js +++ b/packages/webviz-core/src/players/ReadAheadDataProvider.js @@ -7,11 +7,12 @@ // You may not use this file except in compliance with the License. import { intersection } from "lodash"; +import PromiseQueue from "promise-queue"; import { TimeUtil, type Time } from "rosbag"; import type { - ChainableDataProvider, - ChainableDataProviderDescriptor, + RandomAccessDataProvider, + DataProviderDescriptor, ExtensionPoint, GetDataProvider, InitializationResult, @@ -24,14 +25,12 @@ const log = new Logger(__filename); export class ReadResult { start: Time; end: Time; - _isEmpty: boolean = false; _promise: Promise; _isResolved: boolean = false; constructor(start: Time, end: Time, promise: Promise) { this.start = start; this.end = end; - this._isEmpty = TimeUtil.compare(start, end) === 0; this._promise = promise; promise.then(() => (this._isResolved = true)); } @@ -41,10 +40,7 @@ export class ReadResult { } overlaps(start: Time, end: Time) { - if (this._isEmpty) { - return false; - } - return TimeUtil.compare(this.start, end) < 1 && TimeUtil.compare(this.end, start) > -1; + return !TimeUtil.isLessThan(end, this.start) && !TimeUtil.isLessThan(this.end, start); } async getMessages(start: Time, end: Time): Promise { @@ -63,17 +59,18 @@ export class ReadResult { const oneNanoSecond = { sec: 0, nsec: 1 }; // a caching adapter for a DataProvider which does eager, non-blocking read ahead of time ranges // based on a readAheadRange (default to 100 milliseconds) -export default class ReadAheadDataProvider implements ChainableDataProvider { - _provider: ChainableDataProvider; +export default class ReadAheadDataProvider implements RandomAccessDataProvider { + _provider: RandomAccessDataProvider; _topics: string[] = []; - _current: ReadResult = ReadResult.empty(); - _next: ReadResult = ReadResult.empty(); + _current: ?ReadResult; + _next: ?ReadResult; _topics: string[] = []; _readAheadRange: Time; + _taskQueue = new PromiseQueue(1); constructor( { readAheadRange }: { readAheadRange?: Time }, - children: ChainableDataProviderDescriptor[], + children: DataProviderDescriptor[], getDataProvider: GetDataProvider ) { if (children.length !== 1) { @@ -95,44 +92,49 @@ export default class ReadAheadDataProvider implements ChainableDataProvider { return new ReadResult(start, end, this._provider.getMessages(start, end, topics)); } - async getMessages(start: Time, end: Time, topics: string[]): Promise { + getMessages(start: Time, end: Time, topics: string[]): Promise { + // The implementation of _getMessages() is not reentrant, so wait for all previous calls to return before starting a new one. + return this._taskQueue.add(() => this._getMessages(start, end, topics)); + } + + async _getMessages(start: Time, end: Time, topics: string[]): Promise { // if our topics change we need to clear out the cached ranges, or if we're // reading from before the first range. if ( intersection(this._topics, topics).length !== topics.length || - TimeUtil.compare(start, this._current.start) < 0 + !this._current || + TimeUtil.isLessThan(start, this._current.start) ) { this._topics = topics; - this._current = ReadResult.empty(); - this._next = ReadResult.empty(); + this._current = undefined; + this._next = undefined; } let messages = []; - const currentMatches = this._current.overlaps(start, end); - const nextMatches = this._next.overlaps(start, end); - if (currentMatches) { + const currentMatches = this._current && this._current.overlaps(start, end); + const nextMatches = this._next && this._next.overlaps(start, end); + if (/*:: this._current && */ currentMatches) { messages = messages.concat(await this._current.getMessages(start, end)); } - if (nextMatches) { + if (/*:: this._next && */ nextMatches) { messages = messages.concat(await this._next.getMessages(start, end)); } - if ((!currentMatches && !nextMatches) || TimeUtil.isGreaterThan(end, this._next.end)) { + if ((!currentMatches && !nextMatches) || (this._next && TimeUtil.isGreaterThan(end, this._next.end))) { let startTime = start; - if (nextMatches) { - log.info("readahead cache overrun - consider expanding readAheadRange"); + if (/*:: this._next && */ nextMatches) { startTime = TimeUtil.add(this._next.end, oneNanoSecond); + log.info("readahead cache overrun - consider expanding readAheadRange"); } this._current = this._makeReadResult(startTime, end, topics); await this._current.getMessages(startTime, end); const nextStart = TimeUtil.add(end, oneNanoSecond); this._next = this._makeReadResult(nextStart, TimeUtil.add(nextStart, this._readAheadRange), topics); - return messages.concat(await this.getMessages(startTime, end, topics)); - } - if (nextMatches) { + messages = messages.concat(await this._getMessages(startTime, end, topics)); + } else if (/*:: this._next && */ nextMatches) { this._current = this._next; const nextStart = TimeUtil.add(this._current.end, oneNanoSecond); const nextEnd = TimeUtil.add(nextStart, this._readAheadRange); this._next = this._makeReadResult(nextStart, nextEnd, topics); } - return messages; + return messages.filter((message) => topics.includes(message.topic)); } } diff --git a/packages/webviz-core/src/players/ReadAheadDataProvider.test.js b/packages/webviz-core/src/players/ReadAheadDataProvider.test.js index 8dd526559..2deb26861 100644 --- a/packages/webviz-core/src/players/ReadAheadDataProvider.test.js +++ b/packages/webviz-core/src/players/ReadAheadDataProvider.test.js @@ -34,11 +34,15 @@ function generateMessages() { } function getProvider() { - return new ReadAheadDataProvider( - { readAheadRange: { sec: 0, nsec: 10 * 1e6 } }, - [{ name: "MemoryDataProvider", args: {}, children: [] }], - () => new MemoryDataProvider({ messages: generateMessages() }) - ); + const memoryDataProvider = new MemoryDataProvider({ messages: generateMessages() }); + return { + memoryDataProvider, + provider: new ReadAheadDataProvider( + { readAheadRange: { sec: 0, nsec: 10 * 1e6 } }, + [{ name: "MemoryDataProvider", args: {}, children: [] }], + () => memoryDataProvider + ), + }; } describe("ReadResult", () => { @@ -51,11 +55,20 @@ describe("ReadResult", () => { expect(result.overlaps({ sec: 1, nsec: 0 }, { sec: 2, nsec: 0 })).toBe(true); expect(result.overlaps({ sec: 1, nsec: 1 }, { sec: 2, nsec: 0 })).toBe(false); }); + it("handles equal start & end", () => { + const result = new ReadResult({ sec: 0, nsec: 2 }, { sec: 0, nsec: 2 }, Promise.resolve([])); + expect(result.overlaps({ sec: 0, nsec: 0 }, { sec: 0, nsec: 1 })).toBe(false); + expect(result.overlaps({ sec: 0, nsec: 0 }, { sec: 0, nsec: 2 })).toBe(true); + expect(result.overlaps({ sec: 0, nsec: 1 }, { sec: 0, nsec: 2 })).toBe(true); + expect(result.overlaps({ sec: 0, nsec: 2 }, { sec: 0, nsec: 2 })).toBe(true); + expect(result.overlaps({ sec: 0, nsec: 2 }, { sec: 0, nsec: 3 })).toBe(true); + expect(result.overlaps({ sec: 0, nsec: 3 }, { sec: 0, nsec: 3 })).toBe(false); + }); }); describe("ReadAheadDataProvider", () => { it("can get messages", async () => { - const provider = getProvider(); + const { provider } = getProvider(); const messages = await provider.getMessages(fromMillis(0), fromMillis(10), ["/foo"]); expect(messages).toEqual([ { @@ -72,7 +85,7 @@ describe("ReadAheadDataProvider", () => { }); it("can get messages spanning two read ranges", async () => { - const provider = getProvider(); + const { provider } = getProvider(); const messages = await provider.getMessages(fromMillis(0), fromMillis(20), ["/foo"]); expect(messages).toEqual([ { @@ -94,7 +107,7 @@ describe("ReadAheadDataProvider", () => { }); it("can get messages spanning many read ranges", async () => { - const provider = getProvider(); + const { provider } = getProvider(); const messages = await provider.getMessages(fromMillis(0), fromMillis(40), ["/foo"]); expect(messages).toEqual([ { @@ -126,7 +139,7 @@ describe("ReadAheadDataProvider", () => { }); it("clears cache on topic change", async () => { - const provider = getProvider(); + const { provider } = getProvider(); const messages = await provider.getMessages(fromMillis(0), fromMillis(10), ["/foo"]); expect(messages).toEqual([ { @@ -165,8 +178,52 @@ describe("ReadAheadDataProvider", () => { ]); }); + it("reuses cache when looking at fewer topics (but does not return the old topics)", async () => { + const { provider, memoryDataProvider } = getProvider(); + jest.spyOn(memoryDataProvider, "getMessages"); + const messages1 = await provider.getMessages(fromMillis(0), fromMillis(10), ["/foo", "/bar"]); + expect(messages1).toEqual([ + { + receiveTime: fromMillis(0), + topic: "/foo", + message: "message: 0", + }, + { + receiveTime: fromMillis(0), + topic: "/bar", + message: "message: 0", + }, + { + receiveTime: fromMillis(10), + topic: "/foo", + message: "message: 1", + }, + { + receiveTime: fromMillis(10), + topic: "/bar", + message: "message: 1", + }, + ]); + const originalCalls = memoryDataProvider.getMessages.mock.calls.length; + expect(memoryDataProvider.getMessages).toHaveBeenCalledTimes(2); + const messages2 = await provider.getMessages(fromMillis(0), fromMillis(10), ["/foo"]); + expect(messages2).toEqual([ + { + receiveTime: fromMillis(0), + topic: "/foo", + message: "message: 0", + }, + { + receiveTime: fromMillis(10), + topic: "/foo", + message: "message: 1", + }, + ]); + expect(memoryDataProvider.getMessages.mock.calls.length).toEqual(originalCalls); + }); + it("clears cache when going back in time", async () => { - const provider = getProvider(); + const { provider } = getProvider(); // Get messages from 10-20ms. const messages = await provider.getMessages(fromMillis(10), fromMillis(20), ["/foo"]); expect(messages).toEqual([ @@ -200,4 +257,77 @@ describe("ReadAheadDataProvider", () => { }, ]); }); + + it("works when multiple seeks happen in rapid succession", async () => { + const { provider } = getProvider(); + + // Start 2 non-overlapping requests in quick succession. + const messages = provider.getMessages(fromMillis(30), fromMillis(40), ["/foo"]); + const messages2 = provider.getMessages(fromMillis(0), fromMillis(10), ["/foo"]); + + // Ensure both requests are eventually resolved with the right data. + expect(await messages).toEqual([ + { + receiveTime: fromMillis(30), + topic: "/foo", + message: "message: 3", + }, + { + receiveTime: fromMillis(40), + topic: "/foo", + message: "message: 4", + }, + ]); + + expect(await messages2).toEqual([ + { + receiveTime: fromMillis(0), + topic: "/foo", + message: "message: 0", + }, + { + receiveTime: fromMillis(10), + topic: "/foo", + message: "message: 1", + }, + ]); + }); + + it("works with 3 requests with small ranges", async () => { + const { provider } = getProvider(); + + // Start 2 non-overlapping requests in quick succession. + const messages1 = provider.getMessages(fromMillis(30), fromMillis(30), ["/foo"]); + const messages2 = provider.getMessages(fromMillis(30), fromMillis(30), ["/foo"]); + const messages3 = provider.getMessages(fromMillis(40), fromMillis(50), ["/foo"]); + + expect(await messages1).toEqual([ + { + receiveTime: fromMillis(30), + topic: "/foo", + message: "message: 3", + }, + ]); + + expect(await messages2).toEqual([ + { + receiveTime: fromMillis(30), + topic: "/foo", + message: "message: 3", + }, + ]); + + expect(await messages3).toEqual([ + { + receiveTime: fromMillis(40), + topic: "/foo", + message: "message: 4", + }, + { + receiveTime: fromMillis(50), + topic: "/foo", + message: "message: 5", + }, + ]); + }); }); diff --git a/packages/webviz-core/src/players/RpcDataProvider.js b/packages/webviz-core/src/players/RpcDataProvider.js index 7d1cbd2c2..e786a0d4c 100644 --- a/packages/webviz-core/src/players/RpcDataProvider.js +++ b/packages/webviz-core/src/players/RpcDataProvider.js @@ -9,7 +9,7 @@ import { Time } from "rosbag"; import { - type ChainableDataProviderDescriptor, + type DataProviderDescriptor, type ExtensionPoint, type InitializationResult, type MessageLike, @@ -19,27 +19,25 @@ import Rpc from "webviz-core/src/util/Rpc"; export default class RpcDataProvider implements RandomAccessDataProvider { _rpc: Rpc; - _childDescriptor: ChainableDataProviderDescriptor; + _childDescriptor: DataProviderDescriptor; - constructor(rpc: Rpc, childDescriptor: ChainableDataProviderDescriptor) { + constructor(rpc: Rpc, children: DataProviderDescriptor[]) { this._rpc = rpc; - this._childDescriptor = childDescriptor; + if (children.length !== 1) { + throw new Error(`RpcDataProvider requires exactly 1 child, but received ${children.length}`); + } + this._childDescriptor = children[0]; } initialize(extensionPoint: ExtensionPoint): Promise { if (extensionPoint) { - const { progressCallback, addTopicsCallback, reportMetadataCallback } = extensionPoint; + const { progressCallback, reportMetadataCallback } = extensionPoint; this._rpc.receive("extensionPointCallback", ({ type, data }) => { switch (type) { case "progressCallback": progressCallback(data); break; - case "addTopicsCallback": - addTopicsCallback((topics: string[]) => { - this._rpc.send(data.rpcCommand, topics); - }); - break; case "reportMetadataCallback": reportMetadataCallback(data); break; diff --git a/packages/webviz-core/src/players/RpcDataProvider.test.js b/packages/webviz-core/src/players/RpcDataProvider.test.js index f78b93212..e7c8ba2be 100644 --- a/packages/webviz-core/src/players/RpcDataProvider.test.js +++ b/packages/webviz-core/src/players/RpcDataProvider.test.js @@ -21,11 +21,12 @@ const data = { topics: [{ name: "/some_topic", datatype: "some_datatype" }], datatypes: { some_datatype: [{ name: "data", type: "string" }] }, }; +const dummyChildren = [{ name: "MemoryDataProvider", args: {}, children: [] }]; describe("RpcDataProvider", () => { it("passes the initialization result through the Rpc channel", async () => { const { local: mainChannel, remote: workerChannel } = createLinkedChannels(); - const provider = new RpcDataProvider(new Rpc(mainChannel), { name: "MemoryDataProvider", args: {}, children: [] }); + const provider = new RpcDataProvider(new Rpc(mainChannel), dummyChildren); const memoryDataProvider = new MemoryDataProvider(data); new RpcDataProviderRemote(new Rpc(workerChannel), () => memoryDataProvider); @@ -39,7 +40,7 @@ describe("RpcDataProvider", () => { it("passes messages with ArrayBuffers through the Rpc channel", async () => { const { local: mainChannel, remote: workerChannel } = createLinkedChannels(); - const provider = new RpcDataProvider(new Rpc(mainChannel), { name: "MemoryDataProvider", args: {}, children: [] }); + const provider = new RpcDataProvider(new Rpc(mainChannel), dummyChildren); const memoryDataProvider = new MemoryDataProvider(data); new RpcDataProviderRemote(new Rpc(workerChannel), () => memoryDataProvider); @@ -51,11 +52,10 @@ describe("RpcDataProvider", () => { it("passes calls to extensionPoint.reportMetadataCallback through the Rpc channel", async () => { const extensionPoint = { progressCallback() {}, - addTopicsCallback() {}, reportMetadataCallback: jest.fn(), }; const { local: mainChannel, remote: workerChannel } = createLinkedChannels(); - const provider = new RpcDataProvider(new Rpc(mainChannel), { name: "MemoryDataProvider", args: {}, children: [] }); + const provider = new RpcDataProvider(new Rpc(mainChannel), dummyChildren); const memoryDataProvider = new MemoryDataProvider(data); new RpcDataProviderRemote(new Rpc(workerChannel), () => memoryDataProvider); const error = { type: "error", source: "test", errorType: "user", message: "test error" }; diff --git a/packages/webviz-core/src/players/RpcDataProviderRemote.js b/packages/webviz-core/src/players/RpcDataProviderRemote.js index 1e661d7d0..a2005defe 100644 --- a/packages/webviz-core/src/players/RpcDataProviderRemote.js +++ b/packages/webviz-core/src/players/RpcDataProviderRemote.js @@ -6,29 +6,22 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. -import uuid from "uuid"; - import type { - ChainableDataProvider, - ChainableDataProviderDescriptor, + RandomAccessDataProvider, + DataProviderDescriptor, DataProviderMetadata, } from "webviz-core/src/players/types"; import Rpc from "webviz-core/src/util/Rpc"; export default class RpcDataProviderRemote { - constructor(rpc: Rpc, getDataProvider: (ChainableDataProviderDescriptor) => ChainableDataProvider) { - let provider: ChainableDataProvider; + constructor(rpc: Rpc, getDataProvider: (DataProviderDescriptor) => RandomAccessDataProvider) { + let provider: RandomAccessDataProvider; rpc.receive("initialize", async ({ childDescriptor, hasExtensionPoint }) => { provider = getDataProvider(childDescriptor); return provider.initialize({ progressCallback: (data) => { rpc.send("extensionPointCallback", { type: "progressCallback", data }); }, - addTopicsCallback: (fn: (string[]) => void) => { - const rpcCommand = uuid.v4(); - rpc.receive(rpcCommand, fn); - rpc.send("extensionPointCallback", { type: "addTopicsCallback", data: { rpcCommand } }); - }, reportMetadataCallback: (data: DataProviderMetadata) => { rpc.send("extensionPointCallback", { type: "reportMetadataCallback", data }); }, diff --git a/packages/webviz-core/src/players/WorkerDataProvider.js b/packages/webviz-core/src/players/WorkerDataProvider.js index d52c2cef1..6366ce2ec 100644 --- a/packages/webviz-core/src/players/WorkerDataProvider.js +++ b/packages/webviz-core/src/players/WorkerDataProvider.js @@ -8,18 +8,18 @@ import { Time } from "rosbag"; -import { type ChainableDataProvider, type InitializationResult, type MessageLike } from "./types"; -import WorkerDataProviderWorker from "./WorkerDataProvider.worker"; // eslint-disable-line +import { type RandomAccessDataProvider, type InitializationResult, type MessageLike } from "./types"; +import { getGlobalHooks } from "webviz-core/src/loadWebviz"; import RpcDataProvider from "webviz-core/src/players/RpcDataProvider"; -import type { ChainableDataProviderDescriptor, ExtensionPoint } from "webviz-core/src/players/types"; +import type { DataProviderDescriptor, ExtensionPoint } from "webviz-core/src/players/types"; import Rpc from "webviz-core/src/util/Rpc"; -export default class WorkerDataProvider implements ChainableDataProvider { +export default class WorkerDataProvider implements RandomAccessDataProvider { _worker: Worker; _provider: RpcDataProvider; - _child: ChainableDataProviderDescriptor; + _child: DataProviderDescriptor; - constructor(args: Object, children: ChainableDataProviderDescriptor[]) { + constructor(args: Object, children: DataProviderDescriptor[]) { if (children.length !== 1) { throw new Error(`Incorrect number of children to WorkerDataProvider: ${children.length}`); } @@ -27,9 +27,9 @@ export default class WorkerDataProvider implements ChainableDataProvider { } initialize(extensionPoint: ExtensionPoint): Promise { - // $FlowFixMe - flow doesn't understand webpack imports this as a WebWorker constructor + const WorkerDataProviderWorker = getGlobalHooks().getWorkerDataProviderWorker(); this._worker = new WorkerDataProviderWorker(); - this._provider = new RpcDataProvider(new Rpc(this._worker), this._child); + this._provider = new RpcDataProvider(new Rpc(this._worker), [this._child]); return this._provider.initialize(extensionPoint); } diff --git a/packages/webviz-core/src/players/WorkerDataProvider.worker.js b/packages/webviz-core/src/players/WorkerDataProvider.worker.js index 2a0b3f382..dd5690723 100644 --- a/packages/webviz-core/src/players/WorkerDataProvider.worker.js +++ b/packages/webviz-core/src/players/WorkerDataProvider.worker.js @@ -14,6 +14,8 @@ import ReadAheadDataProvider from "webviz-core/src/players/ReadAheadDataProvider import RpcDataProviderRemote from "webviz-core/src/players/RpcDataProviderRemote"; import Rpc from "webviz-core/src/util/Rpc"; +// This is the open source version. There is also an internal variant. + const getDataProvider = createGetDataProvider({ BagDataProvider, MeasureDataProvider, diff --git a/packages/webviz-core/src/players/createGetDataProvider.js b/packages/webviz-core/src/players/createGetDataProvider.js index 895ed325b..18c38c038 100644 --- a/packages/webviz-core/src/players/createGetDataProvider.js +++ b/packages/webviz-core/src/players/createGetDataProvider.js @@ -6,19 +6,15 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. -import type { - ChainableDataProvider, - ChainableDataProviderDescriptor, - GetDataProvider, -} from "webviz-core/src/players/types"; +import type { RandomAccessDataProvider, DataProviderDescriptor, GetDataProvider } from "webviz-core/src/players/types"; export default function createGetDataProvider(descriptorMap: { - [name: string]: Class, + [name: string]: Class, }): GetDataProvider { - return function getDataProvider(descriptor: ChainableDataProviderDescriptor): ChainableDataProvider { + return function getDataProvider(descriptor: DataProviderDescriptor): RandomAccessDataProvider { const Provider = descriptorMap[descriptor.name]; if (!Provider) { - throw new Error(`Unknown ChainableDataProviderDescriptor#name: ${descriptor.name}`); + throw new Error(`Unknown DataProviderDescriptor#name: ${descriptor.name}`); } return new Provider(descriptor.args, descriptor.children, getDataProvider); }; diff --git a/packages/webviz-core/src/players/mockExtensionPoint.js b/packages/webviz-core/src/players/mockExtensionPoint.js index 45f04d585..6818ff980 100644 --- a/packages/webviz-core/src/players/mockExtensionPoint.js +++ b/packages/webviz-core/src/players/mockExtensionPoint.js @@ -8,14 +8,9 @@ import type { ExtensionPoint } from "webviz-core/src/players/types"; export function mockExtensionPoint() { - const topicCallbacks: ((string[]) => void)[] = []; return { - topicCallbacks, extensionPoint: ({ progressCallback() {}, - addTopicsCallback(callback: (string[]) => void) { - topicCallbacks.push(callback); - }, reportMetadataCallback() {}, }: ExtensionPoint), }; diff --git a/packages/webviz-core/src/players/rootGetDataProvider.js b/packages/webviz-core/src/players/rootGetDataProvider.js new file mode 100644 index 000000000..301d10325 --- /dev/null +++ b/packages/webviz-core/src/players/rootGetDataProvider.js @@ -0,0 +1,36 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. +import { getGlobalHooks } from "webviz-core/src/loadWebviz"; +import BagDataProvider from "webviz-core/src/players/BagDataProvider"; +import CombinedDataProvider from "webviz-core/src/players/CombinedDataProvider"; +import createGetDataProvider from "webviz-core/src/players/createGetDataProvider"; +import IdbCacheReaderDataProvider from "webviz-core/src/players/IdbCacheReaderDataProvider"; +import MeasureDataProvider, { instrumentDataProviderTree } from "webviz-core/src/players/MeasureDataProvider"; +import ParseMessagesDataProvider from "webviz-core/src/players/ParseMessagesDataProvider"; +import ReadAheadDataProvider from "webviz-core/src/players/ReadAheadDataProvider"; +import type { DataProviderDescriptor, RandomAccessDataProvider } from "webviz-core/src/players/types"; +import WorkerDataProvider from "webviz-core/src/players/WorkerDataProvider"; +import { MEASURE_DATA_PROVIDERS_QUERY_KEY } from "webviz-core/src/util/globalConstants"; + +const getDataProviderBase = createGetDataProvider({ + BagDataProvider, + MeasureDataProvider, + ParseMessagesDataProvider, + ReadAheadDataProvider, + WorkerDataProvider, + IdbCacheReaderDataProvider, + CombinedDataProvider, + ...getGlobalHooks().getAdditionalDataProviders(), +}); + +export function rootGetDataProvider(tree: DataProviderDescriptor): RandomAccessDataProvider { + if (new URLSearchParams(location.search).has(MEASURE_DATA_PROVIDERS_QUERY_KEY)) { + tree = instrumentDataProviderTree(tree); + } + return getDataProviderBase(tree); +} diff --git a/packages/webviz-core/src/players/standardDataProviderDescriptors.js b/packages/webviz-core/src/players/standardDataProviderDescriptors.js index d60c17be6..cfdc02b82 100644 --- a/packages/webviz-core/src/players/standardDataProviderDescriptors.js +++ b/packages/webviz-core/src/players/standardDataProviderDescriptors.js @@ -6,9 +6,9 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. -import type { ChainableDataProviderDescriptor } from "webviz-core/src/players/types"; +import type { DataProviderDescriptor } from "webviz-core/src/players/types"; -export function getLocalBagDescriptor(file: File): ChainableDataProviderDescriptor { +export function getLocalBagDescriptor(file: File): DataProviderDescriptor { return { name: "ReadAheadDataProvider", args: {}, diff --git a/packages/webviz-core/src/players/types.js b/packages/webviz-core/src/players/types.js index e2026f478..a5053fc65 100644 --- a/packages/webviz-core/src/players/types.js +++ b/packages/webviz-core/src/players/types.js @@ -25,6 +25,15 @@ export type MessageLike = { message: any, }; +// DataProviders can be instantiated using a DataProviderDescriptor and a GetDataProvider function. +// Because the descriptor is a plain JavaScript object, it can be sent over an Rpc Channel, which +// means that you can describe a chain of data providers that includes a Worker or a WebSocket. +export type DataProviderDescriptor = {| + name: string, + args: any, + children: DataProviderDescriptor[], +|}; + export type InitializationResult = { start: Time, end: Time, @@ -41,11 +50,15 @@ export type DataProviderMetadata = | {| type: "updateReconnecting", reconnecting: boolean |}; export type ExtensionPoint = {| progressCallback: (Progress) => void, - addTopicsCallback: ((string[]) => void) => void, reportMetadataCallback: (DataProviderMetadata) => void, |}; +// eslint-disable-next-line no-use-before-define +export type GetDataProvider = (DataProviderDescriptor) => RandomAccessDataProvider; + export interface RandomAccessDataProvider { + constructor(args: any, children: DataProviderDescriptor[], getDataProvider: GetDataProvider): void; + // Do any up-front initializing of the provider, and takes an optional extension point for // callbacks that only some implementations care about. initialize(extensionPoint: ExtensionPoint): Promise; @@ -56,18 +69,3 @@ export interface RandomAccessDataProvider { // Close the provider. close(): Promise; } - -// ChainableDataProviders are RandomAccessDataProviders that can be nested. They can be instantiated -// using a ChainableDataProviderDescriptor and a GetDataProvider function. Because the descriptor -// is a plain Javascript object, it can be sent over an Rpc Channel, which means that you can -// describe a chain of data providers that includes a WebWorker or a Websocket. -export type ChainableDataProviderDescriptor = {| - name: string, - args: Object, - children: ChainableDataProviderDescriptor[], -|}; -export interface ChainableDataProvider extends RandomAccessDataProvider { - // eslint-disable-next-line no-use-before-define - constructor(args: Object, children: ChainableDataProviderDescriptor[], getDataProvider: GetDataProvider): void; -} -export type GetDataProvider = (ChainableDataProviderDescriptor) => ChainableDataProvider; diff --git a/packages/webviz-core/src/players/util.js b/packages/webviz-core/src/players/util.js new file mode 100644 index 000000000..8b6173cc5 --- /dev/null +++ b/packages/webviz-core/src/players/util.js @@ -0,0 +1,32 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import type { Progress } from "webviz-core/src/types/players"; +import { deepIntersect } from "webviz-core/src/util/ranges"; + +export const intersectProgress = (progresses: Progress[]): Progress => { + if (progresses.length === 0) { + return { fullyLoadedFractionRanges: [] }; + } + + return { + fullyLoadedFractionRanges: deepIntersect(progresses.map((p) => p.fullyLoadedFractionRanges).filter(Boolean)), + }; +}; + +export const emptyProgress = () => { + return { + fullyLoadedFractionRanges: [{ start: 0, end: 0 }], + }; +}; + +export const fullyLoadedProgress = () => { + return { + fullyLoadedFractionRanges: [{ start: 0, end: 1 }], + }; +}; diff --git a/packages/webviz-core/src/reducers/panels.js b/packages/webviz-core/src/reducers/panels.js index f4e2deffb..f33e1916f 100644 --- a/packages/webviz-core/src/reducers/panels.js +++ b/packages/webviz-core/src/reducers/panels.js @@ -11,8 +11,13 @@ import { getLeaves } from "react-mosaic-component"; import type { ActionTypes } from "webviz-core/src/actions"; import { getGlobalHooks } from "webviz-core/src/loadWebviz"; -import type { SaveConfigPayload, ImportPanelLayoutPayload, PanelConfig } from "webviz-core/src/types/panels"; -import { getPanelIdForType } from "webviz-core/src/util"; +import type { + SaveConfigPayload, + SaveFullConfigPayload, + ImportPanelLayoutPayload, + PanelConfig, +} from "webviz-core/src/types/panels"; +import { getPanelIdForType, getPanelTypeFromId } from "webviz-core/src/util"; import Storage from "webviz-core/src/util/Storage"; const storage = new Storage(); @@ -63,7 +68,6 @@ function changePanelLayout(state: PanelsState, layout: any): PanelsState { function savePanelConfig(state: PanelsState, payload: SaveConfigPayload): PanelsState { const { id, config } = payload; - // imutable update of key/value pairs const newProps = payload.override ? { ...state.savedProps, [id]: config } @@ -86,6 +90,31 @@ function savePanelConfig(state: PanelsState, payload: SaveConfigPayload): Panels }; } +// eslint-disable-next-line no-unused-vars +function saveFullPanelConfig(state: PanelsState, payload: SaveFullConfigPayload): PanelsState { + const { panelType, perPanelFunc } = payload; + const newProps = { ...state.savedProps }; + if (panelType && perPanelFunc) { + const fullConfig = state.savedProps; + Object.keys(fullConfig).forEach((panelId) => { + if (getPanelTypeFromId(panelId) === panelType) { + const newPanelConfig = perPanelFunc(fullConfig[panelId]); + if (newPanelConfig) { + newProps[panelId] = newPanelConfig; + } + } + }); + } + + // save the new saved panel props in storage + storage.set(PANEL_PROPS_KEY, newProps); + + return { + ...state, + savedProps: newProps, + }; +} + function importPanelLayout(state: PanelsState, payload: ImportPanelLayoutPayload) { let migratedPayload = getGlobalHooks().migratePanels(payload); @@ -114,6 +143,9 @@ export default function panelsReducer(state: PanelsState = getDefaultState(), ac case "SAVE_PANEL_CONFIG": return savePanelConfig(state, action.payload); + case "SAVE_FULL_PANEL_CONFIG": + return saveFullPanelConfig(state, action.payload); + case "IMPORT_PANEL_LAYOUT": return importPanelLayout(state, action.payload); diff --git a/packages/webviz-core/src/types/RosDatatypes.js b/packages/webviz-core/src/types/RosDatatypes.js index ded45a7fa..01b776296 100644 --- a/packages/webviz-core/src/types/RosDatatypes.js +++ b/packages/webviz-core/src/types/RosDatatypes.js @@ -9,6 +9,7 @@ export type RosMsgField = {| type: string, name: string, + isComplex?: boolean, // For arrays isArray?: boolean, diff --git a/packages/webviz-core/src/types/panels.js b/packages/webviz-core/src/types/panels.js index 8e8200257..5aa4806d8 100644 --- a/packages/webviz-core/src/types/panels.js +++ b/packages/webviz-core/src/types/panels.js @@ -7,6 +7,7 @@ // You may not use this file except in compliance with the License. export type PanelConfig = { [key: string]: any }; +export type PerPanelFunc = (Config) => Config; export type SaveConfigPayload = { id: string, @@ -18,6 +19,11 @@ export type SaveConfigPayload = { config: PanelConfig, }; +export type SaveFullConfigPayload = { + panelType: string, + perPanelFunc: PerPanelFunc, +}; + export type ImportPanelLayoutPayload = { // layout is the object passed to react-mosaic layout?: any, @@ -27,3 +33,5 @@ export type ImportPanelLayoutPayload = { }; export type SaveConfig = ($Shape, ?{ keepLayoutInUrl?: boolean }) => void; + +export type UpdatePanelConfig = (panelType: string, perPanelFunc: (PanelConfig) => PanelConfig) => void; diff --git a/packages/webviz-core/src/types/players.js b/packages/webviz-core/src/types/players.js index 9d236a3d7..fc7c2f992 100644 --- a/packages/webviz-core/src/types/players.js +++ b/packages/webviz-core/src/types/players.js @@ -111,7 +111,7 @@ export type PublishPayload = {| export const PlayerCapabilities = { advertise: "advertise", - initialization: "initialization", + setSpeed: "setSpeed", }; export type PlayerStateActiveData = {| diff --git a/packages/webviz-core/src/util.js b/packages/webviz-core/src/util.js index 2525ef7dd..7a3bef1ce 100644 --- a/packages/webviz-core/src/util.js +++ b/packages/webviz-core/src/util.js @@ -67,3 +67,30 @@ export function encodeURLQueryParamValue(value: string): string { return allowedChar || encodeURIComponent(char); }); } + +export function downloadFiles(blobs: Blob[], filePrefixOrName: string) { + const { body } = document; + if (!body) { + return; + } + + const link = document.createElement("a"); + link.style.display = "none"; + body.appendChild(link); + + const urls = blobs.map((blob) => window.URL.createObjectURL(blob)); + urls.forEach((url, idx) => { + const fileName = blobs.length === 1 ? filePrefixOrName : `${filePrefixOrName}_${idx}`; + link.setAttribute("download", fileName); + link.setAttribute("href", url); + link.click(); + }); + + // remove the link after triggering download + window.requestAnimationFrame(() => { + body.removeChild(link); + urls.forEach((url) => { + URL.revokeObjectURL(url); + }); + }); +} diff --git a/packages/webviz-core/src/util/debouncePromise.js b/packages/webviz-core/src/util/debouncePromise.js new file mode 100644 index 000000000..22ba44f7b --- /dev/null +++ b/packages/webviz-core/src/util/debouncePromise.js @@ -0,0 +1,39 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +type DebouncedFn = (() => void) & { currentPromise: ?Promise }; + +export default function debouncePromise(fn: () => Promise): DebouncedFn { + // Whether we are currently waiting for a promise returned by `fn` to resolve. + let calling = false; + // Whether another call to the debounced function was made while a call was in progress. + let callPending = false; + + function debouncedFn() { + if (calling) { + callPending = true; + } else { + start(); + } + } + + function start() { + calling = true; + callPending = false; + + debouncedFn.currentPromise = fn().finally(() => { + calling = false; + debouncedFn.currentPromise = undefined; + if (callPending) { + start(); + } + }); + } + + return debouncedFn; +} diff --git a/packages/webviz-core/src/util/debouncePromise.test.js b/packages/webviz-core/src/util/debouncePromise.test.js new file mode 100644 index 000000000..681a2c707 --- /dev/null +++ b/packages/webviz-core/src/util/debouncePromise.test.js @@ -0,0 +1,101 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import debouncePromise from "./debouncePromise"; +import signal from "./signal"; + +/* eslint-disable jest/valid-expect-in-promise */ + +describe("debouncePromise", () => { + it("debounces with resolved and rejected promises", async () => { + const promises = [Promise.resolve(), Promise.reject(), Promise.reject(), Promise.resolve()]; + + let calls = 0; + const debouncedFn = debouncePromise(() => { + ++calls; + return promises.shift(); + }); + + expect(calls).toBe(0); + + debouncedFn(); + debouncedFn(); + debouncedFn(); + debouncedFn(); + expect(calls).toBe(1); + + await Promise.resolve(); + expect(calls).toBe(2); + expect(debouncedFn.currentPromise).toBeUndefined(); + + debouncedFn(); + expect(calls).toBe(3); + expect(debouncedFn.currentPromise).toBeDefined(); + + debouncedFn(); + expect(calls).toBe(3); + await Promise.resolve(); + expect(calls).toBe(4); + expect(debouncedFn.currentPromise).toBeUndefined(); + expect(promises).toHaveLength(0); + }); + + it("provides currentPromise to wait on the current call", async () => { + expect.assertions(6); + + const sig = signal(); + let calls = 0; + const debouncedFn = debouncePromise(() => { + ++calls; + return sig; + }); + + expect(calls).toBe(0); + + debouncedFn(); + expect(calls).toBe(1); + + // the original function should not be called until the signal is resolved + debouncedFn(); + debouncedFn(); + await Promise.resolve(); + expect(calls).toBe(1); + + // once the first promise is resolved, the second call should start + let promise = debouncedFn.currentPromise; + expect(promise).toBeDefined(); + promise = promise.then(() => { + expect(calls).toBe(2); + }); + + sig.resolve(); + + await promise; + + // after pending calls are finished, there is no more currentPromise + expect(debouncedFn.currentPromise).toBeUndefined(); + }); + + it("handles nested calls", async () => { + expect.assertions(3); + + let calls = 0; + const debouncedFn = debouncePromise(async () => { + ++calls; + if (calls === 1) { + debouncedFn(); + expect(calls).toBe(1); + } + }); + + debouncedFn(); + expect(calls).toBe(1); + await Promise.resolve(); + expect(calls).toBe(2); + }); +}); diff --git a/packages/webviz-core/src/util/indexeddb/Database.js b/packages/webviz-core/src/util/indexeddb/Database.js index fe82f6d0f..df0c11b48 100644 --- a/packages/webviz-core/src/util/indexeddb/Database.js +++ b/packages/webviz-core/src/util/indexeddb/Database.js @@ -87,6 +87,7 @@ export default class Database { static async get(definition: DatabaseDefinition): Promise { const { name, version, objectStores } = definition; const db = await idb.open(name, version, (change) => { + [...change.objectStoreNames].forEach((name) => change.deleteObjectStore(name)); objectStores.forEach((storeDefinition) => { const { indexes = [] } = storeDefinition; const store = change.createObjectStore(storeDefinition.name, storeDefinition.options); diff --git a/packages/webviz-core/src/util/installDevtoolsFormatters.js b/packages/webviz-core/src/util/installDevtoolsFormatters.js new file mode 100644 index 000000000..7f11ed02f --- /dev/null +++ b/packages/webviz-core/src/util/installDevtoolsFormatters.js @@ -0,0 +1,66 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +// Custom formatters for Chrome dev tools. See documentation: http://bit.ly/object-formatters +// Note that the "Enable custom formatters" setting must be turned on in order to use these formatters. + +import { isEqual } from "lodash"; +import seedrandom from "seedrandom"; + +const timeFormatter = (() => { + const timeFields = new Set(["sec", "nsec"]); + function isTime(obj) { + return isEqual(new Set(Object.getOwnPropertyNames(obj)), timeFields); + } + + function groupDigits(str) { + const result = ["span", {}]; + let start = 0; + let end = str.length % 3 || 3; + while (start < str.length) { + result.push(["span", { style: end < str.length ? "margin-right: 2px;" : "" }, str.substring(start, end)]); + start = end; + end += 3; + } + return result; + } + + return { + header(o) { + if ( + !isTime(o) || + o.sec < 0 || + o.nsec < 0 || + o.nsec >= 1e9 || + !Number.isInteger(o.sec) || + !Number.isInteger(o.nsec) + ) { + return null; + } + const nsec = o.nsec.toFixed().padStart(9, "0"); + const rng = seedrandom(`${o.sec}.${nsec}`); + return [ + "span", + { style: `color: hsl(${rng() * 360}deg, ${40 + rng() * 60}%, ${20 + rng() * 40}%);` }, + groupDigits(String(o.sec)), + ".", + groupDigits(nsec), + ]; + }, + hasBody(o) { + if (isTime(o)) { + return false; + } + }, + }; +})(); + +export default function installDevtoolsFormatters() { + window.devtoolsFormatters = window.devtoolsFormatters || []; + window.devtoolsFormatters.push(timeFormatter); +} diff --git a/packages/webviz-core/src/util/multicolorLineChart.js b/packages/webviz-core/src/util/multicolorLineChart.js index e8d9ef479..a1009c653 100644 --- a/packages/webviz-core/src/util/multicolorLineChart.js +++ b/packages/webviz-core/src/util/multicolorLineChart.js @@ -7,7 +7,6 @@ // You may not use this file except in compliance with the License. import { Chart } from "react-chartjs-2"; - /* eslint-disable no-underscore-dangle */ // From https://github.com/chartjs/Chart.js/issues/4895#issuecomment-341874938 Chart.defaults.multicolorLine = Chart.defaults.scatter; diff --git a/packages/webviz-core/src/util/reportError.js b/packages/webviz-core/src/util/reportError.js index b998bb7ba..fe8881e3e 100644 --- a/packages/webviz-core/src/util/reportError.js +++ b/packages/webviz-core/src/util/reportError.js @@ -11,12 +11,25 @@ // etc). We should generally prevent users from making mistakes in the first place, but sometimes // its unavoidable to bail out with a generic error message (e.g. when dragging in a malformed // ROS bag). +import * as React from "react"; + export type ErrorType = "app" | "user"; +export type DetailsType = string | Error | React.Node; -type ErrorHandler = (message: string, details: string | Error, type: ErrorType) => void; +type ErrorHandler = (message: string, details: DetailsType, type: ErrorType) => void; -const defaultErrorHandler: ErrorHandler = (message: string, details: string | Error, type: ErrorType): void => { - if (process.env.NODE_ENV === "test") { +const defaultErrorHandler: ErrorHandler = (message: string, details: DetailsType, type: ErrorType): void => { + // eslint-disable-next-line no-undef + if (typeof WorkerGlobalScope !== "undefined" && self instanceof WorkerGlobalScope) { + const webWorkerError = + "Web Worker has uninitialized reportError function; this means this error message cannot show up in the UI (so we show it here in the console instead)."; + if (process.env.NODE_ENV === "test") { + throw new Error(webWorkerError); + } else { + console.error(webWorkerError, message, details, type); + } + return; + } else if (process.env.NODE_ENV === "test") { return; } console.error("Error before error display is mounted", message, details, type); @@ -30,14 +43,30 @@ export function setErrorHandler(handler: ErrorHandler): void { } addError = handler; // attach to window in dev mode for testing errors - if (process.env.NODE_ENV !== "production") { + if (process.env.NODE_ENV !== "production" && typeof window !== "undefined") { window.addError = handler; } } +export function unsetErrorHandler() { + if (addError === defaultErrorHandler) { + throw new Error("Tried to unset ErrorHandler but it was already the default"); + } + addError = defaultErrorHandler; +} + +export function detailsToString(details: DetailsType): string { + if (typeof details === "string") { + return details; + } + if (details instanceof Error) { + return details.toString(); + } + return "unable to convert details to string type"; +} + // Call this to add an error to the application nav bar error component if mounted // if the component is not mounted, console.error is used as a fallback. -// -export default function reportError(message: string, details: string | Error, type: ErrorType): void { +export default function reportError(message: string, details: DetailsType, type: ErrorType): void { addError(message, details, type); } diff --git a/packages/webviz-core/src/util/time.js b/packages/webviz-core/src/util/time.js index 59fa9fc06..378bb9eaf 100644 --- a/packages/webviz-core/src/util/time.js +++ b/packages/webviz-core/src/util/time.js @@ -112,7 +112,19 @@ export function fromNanoSec(nsec: number): Time { } export function fromMillis(value: number): Time { - return fromSec(value / 1000); + let sec = Math.trunc(value / 1000); + let nsec = Math.round((value - sec * 1000) * 1e6); + sec += Math.trunc(nsec / 1e9); + nsec %= 1e9; + return { sec, nsec }; +} + +export function fromMicros(value: number): Time { + let sec = Math.trunc(value / 1e6); + let nsec = Math.round((value - sec * 1e6) * 1e3); + sec += Math.trunc(nsec / 1e9); + nsec %= 1e9; + return { sec, nsec }; } export function findClosestTimestampIndex(currentTime: Time, frameTimestamps: string[] = []): number { diff --git a/packages/webviz-core/src/util/time.test.js b/packages/webviz-core/src/util/time.test.js index 534fd1efa..75521509d 100644 --- a/packages/webviz-core/src/util/time.test.js +++ b/packages/webviz-core/src/util/time.test.js @@ -128,6 +128,7 @@ describe("time.fromMillis", () => { it("handles positive values", () => { expect(time.fromMillis(1)).toEqual({ sec: 0, nsec: 1000000 }); expect(time.fromMillis(1000)).toEqual({ sec: 1, nsec: 0 }); + expect(time.fromMillis(2000000000005)).toEqual({ sec: 2000000000, nsec: 5000000 }); }); it("handles negative values", () => { @@ -136,6 +137,21 @@ describe("time.fromMillis", () => { }); }); +describe("time.fromMicros", () => { + it("handles positive values", () => { + expect(time.fromMicros(1)).toEqual({ sec: 0, nsec: 1000 }); + expect(time.fromMicros(1000)).toEqual({ sec: 0, nsec: 1000000 }); + expect(time.fromMicros(1000000)).toEqual({ sec: 1, nsec: 0 }); + expect(time.fromMicros(2000000000000005)).toEqual({ sec: 2000000000, nsec: 5000 }); + }); + + it("handles negative values", () => { + expect(time.fromMicros(-1)).toEqual({ sec: -0, nsec: -1000 }); + expect(time.fromMicros(-1000)).toEqual({ sec: -0, nsec: -1000000 }); + expect(time.fromMicros(-1000000)).toEqual({ sec: -1, nsec: 0 }); + }); +}); + describe("time.subtractTimes", () => { expect(time.subtractTimes({ sec: 1, nsec: 1 }, { sec: 1, nsec: 1 })).toEqual({ sec: 0, nsec: 0 }); expect(time.subtractTimes({ sec: 1, nsec: 2 }, { sec: 2, nsec: 1 })).toEqual({ sec: -1, nsec: 1 }); diff --git a/packages/webviz-core/src/util/yaml.js b/packages/webviz-core/src/util/yaml.js new file mode 100644 index 000000000..d5b1412e1 --- /dev/null +++ b/packages/webviz-core/src/util/yaml.js @@ -0,0 +1,22 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import yaml from "js-yaml/dist/js-yaml"; + +export default { + parse(str: string): T { + return yaml.safeLoad(str); + }, + stringify(obj: any, options: Object = {}): string { + // do not quote 'y' and 'yes' for older yaml versions + return yaml + .safeDump(obj, { noCompatMode: true, ...options }) + .replace(/^- - /gm, "\n- - ") + .trim(); + }, +}; From 4780d5faf008caff86abac7b2605e7abd95c085f Mon Sep 17 00:00:00 2001 From: Audrey Li Date: Mon, 15 Jul 2019 18:29:42 -0700 Subject: [PATCH 2/2] Fix CI problem --- .../src/panels/ThreeDimensionalViz/Layout.js | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/Layout.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/Layout.js index eabb8067c..20a0a0e65 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/Layout.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/Layout.js @@ -49,9 +49,6 @@ import type { Frame, Topic } from "webviz-core/src/types/players"; import type { MarkerCollector, MarkerProvider } from "webviz-core/src/types/Scene"; import videoRecordingMode from "webviz-core/src/util/videoRecordingMode"; -const POLYGON_TYPE = DRAWING_CONFIG.Polygons.type; -const CAMERA_TYPE = DRAWING_CONFIG.Camera.type; - type EventName = "onDoubleClick" | "onMouseMove" | "onMouseDown" | "onMouseUp"; type Props = { @@ -208,7 +205,7 @@ export default class Layout extends React.Component implements Mar if (!args) { return; } - if (drawingType === POLYGON_TYPE) { + if (drawingType === DRAWING_CONFIG.Polygons.type) { this._handleDrawPolygons(eventName, ev, args); } if (propsHandler) { @@ -221,14 +218,14 @@ export default class Layout extends React.Component implements Mar this.toggleCameraMode(); }, [DRAWING_CONFIG.Polygons.key]: () => { - this._toggleDrawing(POLYGON_TYPE); + this._toggleDrawing(DRAWING_CONFIG.Polygons.type); }, [DRAWING_CONFIG.Camera.key]: () => { - this._toggleDrawing(CAMERA_TYPE); + this._toggleDrawing(DRAWING_CONFIG.Camera.type); }, Control: () => { // support default DrawPolygon key - this._toggleDrawing(POLYGON_TYPE); + this._toggleDrawing(DRAWING_CONFIG.Polygons.type); }, Escape: () => { this._exitDrawing(); @@ -239,7 +236,7 @@ export default class Layout extends React.Component implements Mar // can enter into drawing from null or non-new-drawing-type to the new drawingType const enterDrawing = this.state.drawingType !== drawingType; this.setState({ drawingType: enterDrawing ? drawingType : null }); - if (drawingType !== CAMERA_TYPE) { + if (drawingType !== DRAWING_CONFIG.Camera.type) { this.switchTo2DCameraIfNeeded(); } }; @@ -562,7 +559,7 @@ export default class Layout extends React.Component implements Mar render() { const { drawingType } = this.state; - const cursorType = drawingType && drawingType !== CAMERA_TYPE ? "crosshair" : ""; + const cursorType = drawingType && drawingType !== DRAWING_CONFIG.Camera.type ? "crosshair" : ""; return (