From f96da56ecb37e015d19bd76b9ecaa1879e3c8d5e Mon Sep 17 00:00:00 2001 From: Esther Weon Date: Mon, 20 May 2019 14:10:32 -0700 Subject: [PATCH] Update packages/webviz-core from internal repo Changelog: Update Docker image version and fix Jest bug where Jest process never exits - --- packages/webviz-core/.eslintrc.js | 5 +- packages/webviz-core/package-lock.json | 270 ++++++-- packages/webviz-core/package.json | 9 +- packages/webviz-core/shared/CachedFilelike.js | 261 ++++++++ .../webviz-core/shared/CachedFilelike.test.js | 83 +++ .../webviz-core/shared/VirtualLRUBuffer.js | 164 +++++ .../shared/VirtualLRUBuffer.test.js | 88 +++ .../webviz-core/shared/getNewConnection.js | 130 ++++ .../shared/getNewConnection.test.js | 251 ++++++++ packages/webviz-core/shared/ranges.js | 22 + packages/webviz-core/shared/ranges.test.js | 31 + .../src/components/AppMenu/index.js | 2 +- .../webviz-core/src/components/CopyText.js | 50 ++ .../src/components/DocumentDropListener.js | 4 +- .../src/components/Dropdown/index.js | 3 +- .../webviz-core/src/components/EmptyState.js | 1 + .../src/components/ErrorDisplay.js | 2 +- .../src/components/ErrorDisplay.stories.js | 2 +- packages/webviz-core/src/components/Icon.js | 17 +- .../webviz-core/src/components/JsonInput.js | 77 +++ .../webviz-core/src/components/LargeList.js | 1 + .../webviz-core/src/components/LayoutMenu.js | 2 +- .../webviz-core/src/components/Menu/Item.js | 15 +- .../src/components/Menu/index.module.scss | 8 +- .../MessageHistoryOnlyTopics.js | 45 +- .../src/components/MessageHistory/fixture.js | 19 +- .../src/components/MessageHistory/index.js | 1 + .../components/MessageHistory/index.test.js | 103 ++- .../MessageHistory/synchronizeMessages.js | 59 ++ .../synchronizeMessages.test.js | 86 +++ .../MessageHistory/topicPrefixUtils.js | 65 ++ .../MessageHistory/topicPrefixUtils.test.js | 156 +++++ .../components/MessagePipeline/NodePlayer.js | 6 +- .../src/components/MessagePipeline/index.js | 82 ++- .../components/MessagePipeline/index.test.js | 111 +++- packages/webviz-core/src/components/Panel.js | 214 +++++-- .../src/components/Panel.module.scss | 91 ++- .../webviz-core/src/components/Panel.test.js | 18 + .../src/components/PanelContext.js | 2 +- .../src/components/PanelToolbar/HelpButton.js | 4 +- .../PanelToolbar/MosaicDragHandle.js | 8 +- .../src/components/PanelToolbar/index.js | 132 ++-- .../components/PanelToolbar/index.module.scss | 38 +- .../components/PanelToolbar/index.stories.js | 63 +- .../src/components/PanelToolbar/utils.js | 21 + .../src/components/PerfMonitor/index.js | 4 +- .../src/components/PlayerManager.js | 4 +- packages/webviz-core/src/components/Root.js | 3 + .../src/components/SeekController.js | 61 ++ .../src/components/SeekController.test.js | 96 +++ .../src/components/ShareJsonModal.js | 2 +- packages/webviz-core/src/components/Slider.js | 10 + .../src/components/TimeBasedChart/index.js | 1 - .../src/components/icon.module.scss | 8 + .../webviz-core/src/components/validator.js | 72 +++ packages/webviz-core/src/globals.js.flow | 6 + packages/webviz-core/src/loadWebviz.js | 130 +++- .../src/panels/GlobalVariables/index.js | 66 +- .../src/panels/ImageView/ImageCanvas.js | 85 +-- .../webviz-core/src/panels/ImageView/index.js | 268 ++++++-- packages/webviz-core/src/panels/PanelList.js | 48 +- packages/webviz-core/src/panels/Plot/index.js | 4 +- .../webviz-core/src/panels/Rosout/index.js | 35 +- .../src/panels/Rosout/index.module.scss | 28 +- .../src/panels/Rosout/index.stories.js | 11 +- .../panels/ThreeDimensionalViz/CameraInfo.js | 207 ++++++ .../ThreeDimensionalViz/FollowTFControl.js | 274 ++++++++ .../ThreeDimensionalViz/LaserScanVert.js | 39 ++ .../src/panels/ThreeDimensionalViz/Layout.js | 606 ++++++++++++++++++ .../ThreeDimensionalViz/Layout.module.scss | 138 ++++ .../ThreeDimensionalViz/MarkerMetadata.js | 47 ++ .../ThreeDimensionalViz/MeasuringTool.js | 185 ++++++ .../ThreeDimensionalViz/PositionControl.js | 142 ++++ .../PositionControl.module.scss | 21 + .../PositionControl.test.js | 32 + .../SceneBuilder/MessageCollector.js | 148 +++++ .../SceneBuilder/MessageCollector.test.js | 146 +++++ .../SceneBuilder.occupancyMovieSet.test.js | 113 ++++ .../ThreeDimensionalViz/SceneBuilder/index.js | 517 +++++++++++++++ .../TopicSelector/index.js | 334 ++++++++++ .../TopicSelector/topicTree.js | 40 ++ .../TopicSelector/treeBuilder.js | 475 ++++++++++++++ .../ThreeDimensionalViz/TopicSelectorMenu.js | 58 ++ .../TopicSettingsEditor.js | 184 ++++++ .../TopicSettingsEditor.module.scss | 21 + .../panels/ThreeDimensionalViz/Transforms.js | 170 +++++ .../ThreeDimensionalViz/TransformsBuilder.js | 230 +++++++ .../src/panels/ThreeDimensionalViz/World.js | 296 +++++++++ .../cameraStateValidator.js | 34 + .../cameraStateValidator.test.js | 64 ++ .../src/panels/ThreeDimensionalViz/color.js | 45 ++ .../commands/LaserScans.js | 43 ++ .../commands/OccupancyGrids.js | 110 ++++ .../commands/Pointclouds/PointCloudBuilder.js | 330 ++++++++++ .../Pointclouds/PointCloudBuilder.test.js | 449 +++++++++++++ .../commands/Pointclouds/index.js | 91 +++ .../ThreeDimensionalViz/commands/index.js | 10 + .../commands/utils/index.js | 126 ++++ .../panels/ThreeDimensionalViz/index.help.md | 19 + .../src/panels/ThreeDimensionalViz/index.js | 332 ++++++++++ .../src/panels/ThreeDimensionalViz/logger.js | 22 + .../ThreeDimensionalViz/withTransforms.js | 63 ++ .../src/panels/TopicEcho/fixture.js | 27 + .../TopicEcho/getValueActionForValue.js | 39 ++ .../TopicEcho/getValueActionForValue.test.js | 62 +- .../webviz-core/src/panels/TopicEcho/index.js | 38 +- .../src/panels/TopicEcho/index.stories.js | 9 +- .../diagnostics/DiagnosticStatusPanel.js | 6 +- .../DiagnosticStatusPanel.stories.js | 176 ----- .../diagnostics/DiagnosticSummary.stories.js | 120 ---- .../src/panels/diagnostics/util.js | 7 +- .../src/panels/diagnostics/util.test.js | 11 +- .../src/players/RandomAccessPlayer.js | 3 + .../src/players/RandomAccessPlayer.test.js | 34 +- packages/webviz-core/src/reducers/panels.js | 29 +- .../src/stories/PanelSetupWithBag.js | 8 +- .../webviz-core/src/styles/colors.module.scss | 1 + packages/webviz-core/src/styles/global.scss | 5 +- .../webviz-core/src/styles/mixins.module.scss | 2 +- packages/webviz-core/src/test/setup.js | 14 + packages/webviz-core/src/types/Scene.js | 54 ++ packages/webviz-core/src/types/panels.js | 4 +- .../webviz-core/src/util/globalConstants.js | 39 ++ .../src/util/indexeddb/Database.js | 224 +++++++ .../src/util/indexeddb/Database.test.js | 177 +++++ .../src/util/indexeddb/DbWriter.js | 69 ++ .../src/util/indexeddb/MetaDatabase.js | 120 ++++ .../src/util/indexeddb/MetaDatabase.test.js | 107 ++++ .../webviz-core/src/util/indexeddb/types.js | 14 + .../videoRecordingMode.js} | 6 +- 130 files changed, 10476 insertions(+), 854 deletions(-) create mode 100644 packages/webviz-core/shared/CachedFilelike.js create mode 100644 packages/webviz-core/shared/CachedFilelike.test.js create mode 100644 packages/webviz-core/shared/VirtualLRUBuffer.js create mode 100644 packages/webviz-core/shared/VirtualLRUBuffer.test.js create mode 100644 packages/webviz-core/shared/getNewConnection.js create mode 100644 packages/webviz-core/shared/getNewConnection.test.js create mode 100644 packages/webviz-core/shared/ranges.js create mode 100644 packages/webviz-core/shared/ranges.test.js create mode 100644 packages/webviz-core/src/components/CopyText.js create mode 100644 packages/webviz-core/src/components/JsonInput.js create mode 100644 packages/webviz-core/src/components/MessageHistory/synchronizeMessages.js create mode 100644 packages/webviz-core/src/components/MessageHistory/synchronizeMessages.test.js create mode 100644 packages/webviz-core/src/components/MessageHistory/topicPrefixUtils.js create mode 100644 packages/webviz-core/src/components/MessageHistory/topicPrefixUtils.test.js create mode 100644 packages/webviz-core/src/components/PanelToolbar/utils.js create mode 100644 packages/webviz-core/src/components/SeekController.js create mode 100644 packages/webviz-core/src/components/SeekController.test.js create mode 100644 packages/webviz-core/src/components/validator.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/CameraInfo.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/FollowTFControl.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/LaserScanVert.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/Layout.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/Layout.module.scss create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/MarkerMetadata.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/MeasuringTool.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/PositionControl.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/PositionControl.module.scss create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/PositionControl.test.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/MessageCollector.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/MessageCollector.test.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/SceneBuilder.occupancyMovieSet.test.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/index.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/index.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/topicTree.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/treeBuilder.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelectorMenu.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSettingsEditor.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSettingsEditor.module.scss create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/TransformsBuilder.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/World.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/cameraStateValidator.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/cameraStateValidator.test.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/color.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/commands/LaserScans.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/commands/OccupancyGrids.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/commands/Pointclouds/PointCloudBuilder.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/commands/Pointclouds/PointCloudBuilder.test.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/commands/Pointclouds/index.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/commands/index.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/commands/utils/index.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/index.help.md create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/index.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/logger.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/withTransforms.js delete mode 100644 packages/webviz-core/src/panels/diagnostics/DiagnosticStatusPanel.stories.js delete mode 100644 packages/webviz-core/src/panels/diagnostics/DiagnosticSummary.stories.js create mode 100644 packages/webviz-core/src/types/Scene.js create mode 100644 packages/webviz-core/src/util/indexeddb/Database.js create mode 100644 packages/webviz-core/src/util/indexeddb/Database.test.js create mode 100644 packages/webviz-core/src/util/indexeddb/DbWriter.js create mode 100644 packages/webviz-core/src/util/indexeddb/MetaDatabase.js create mode 100644 packages/webviz-core/src/util/indexeddb/MetaDatabase.test.js create mode 100644 packages/webviz-core/src/util/indexeddb/types.js rename packages/webviz-core/src/{types/selection.js => util/videoRecordingMode.js} (66%) diff --git a/packages/webviz-core/.eslintrc.js b/packages/webviz-core/.eslintrc.js index 300a95dc7..b1919b3ec 100644 --- a/packages/webviz-core/.eslintrc.js +++ b/packages/webviz-core/.eslintrc.js @@ -27,7 +27,10 @@ module.exports = { [ " @flow", "", - " Copyright (c) 2018-present, GM Cruise LLC", + { + pattern: "^ Copyright \\(c\\) \\d{4}-present, GM Cruise LLC$", + template: " 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.", diff --git a/packages/webviz-core/package-lock.json b/packages/webviz-core/package-lock.json index 64f6add68..50362ae86 100644 --- a/packages/webviz-core/package-lock.json +++ b/packages/webviz-core/package-lock.json @@ -76,6 +76,12 @@ "resolved": "https://registry.npmjs.org/base16/-/base16-1.0.0.tgz", "integrity": "sha1-4pf2DX7BAUp6lxo568ipjAtoHnA=" }, + "base64-arraybuffer-es6": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer-es6/-/base64-arraybuffer-es6-0.4.2.tgz", + "integrity": "sha512-HaJx92u12By863ZXVHZs4Bp1nkKaLpbs3Ec9SI1OKzq60Hz+Ks6z7UvdD8pIx61Ck3e8F9MH/IPEu5T0xKSbkQ==", + "dev": true + }, "base64-js": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", @@ -101,9 +107,9 @@ } }, "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", "requires": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -135,18 +141,18 @@ } }, "chartjs-color": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.3.0.tgz", - "integrity": "sha512-hEvVheqczsoHD+fZ+tfPUE+1+RbV6b+eksp2LwAhwRTVXEjCSEavvk+Hg3H6SZfGlPh/UfmWKGIvZbtobOEm3g==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.2.0.tgz", + "integrity": "sha1-hKL7dVeH7YXDndbdjHsdiEKbrq4=", "requires": { - "chartjs-color-string": "^0.6.0", + "chartjs-color-string": "^0.5.0", "color-convert": "^0.5.3" } }, "chartjs-color-string": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz", - "integrity": "sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.5.0.tgz", + "integrity": "sha512-amWNvCOXlOUYxZVDSa0YOab5K/lmEhbFNKI55PWc4mlv28BDzA7zaoQTGxSBgJMHIW+hGX8YUrvw/FH4LyhwSQ==", "requires": { "color-name": "^1.0.0" } @@ -185,7 +191,7 @@ }, "color-convert": { "version": "0.5.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-0.5.3.tgz", + "resolved": "http://registry.npmjs.org/color-convert/-/color-convert-0.5.3.tgz", "integrity": "sha1-vbbGnOZg+t/+CwAHzER+G59ygr0=" }, "color-name": { @@ -195,7 +201,7 @@ }, "colors": { "version": "0.5.1", - "resolved": "https://registry.npmjs.org/colors/-/colors-0.5.1.tgz", + "resolved": "http://registry.npmjs.org/colors/-/colors-0.5.1.tgz", "integrity": "sha1-fQAj6usVTo7p/Oddy5I9DtFmd3Q=" }, "core-js": { @@ -215,7 +221,7 @@ }, "dnd-core": { "version": "2.6.0", - "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-2.6.0.tgz", + "resolved": "http://registry.npmjs.org/dnd-core/-/dnd-core-2.6.0.tgz", "integrity": "sha1-ErrWbVh0LG5ffPKUP7aFlED4CcQ=", "requires": { "asap": "^2.0.6", @@ -251,12 +257,19 @@ "integrity": "sha1-Mqu5Lw2P7KYhUWKu9D5LRJq42Zw=" }, "dom-serializer": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.1.tgz", - "integrity": "sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz", + "integrity": "sha1-BzxpdUbOB4DOI75KKOKT5AvDDII=", "requires": { - "domelementtype": "^1.3.0", - "entities": "^1.1.1" + "domelementtype": "~1.1.1", + "entities": "~1.1.1" + }, + "dependencies": { + "domelementtype": { + "version": "1.1.3", + "resolved": "http://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz", + "integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs=" + } } }, "dom-walk": { @@ -269,6 +282,15 @@ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==" }, + "domexception": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz", + "integrity": "sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==", + "dev": true, + "requires": { + "webidl-conversions": "^4.0.2" + } + }, "domhandler": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", @@ -314,6 +336,17 @@ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, + "fake-indexeddb": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-2.0.4.tgz", + "integrity": "sha1-QBcV3rf8lQGGbJ8ym953QlmeLeg=", + "dev": true, + "requires": { + "core-js": "^2.4.1", + "realistic-structured-clone": "^2.0.1", + "setimmediate": "^1.0.5" + } + }, "fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", @@ -392,27 +425,32 @@ } }, "htmlparser2": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz", - "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.0.tgz", + "integrity": "sha512-J1nEUGv+MkXS0weHNWVKJJ+UrLfePxRWpN3C9bEi9fLxL2+ggW94DQvgYVXsaT30PGwYRIZKNZXuyMhp3Di4bQ==", "requires": { - "domelementtype": "^1.3.1", + "domelementtype": "^1.3.0", "domhandler": "^2.3.0", "domutils": "^1.5.1", "entities": "^1.1.1", "inherits": "^2.0.1", - "readable-stream": "^3.1.1" + "readable-stream": "^3.0.6" } }, + "idb": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-2.1.3.tgz", + "integrity": "sha512-1He6QAuavrD38HCiJasi4lEEK87Y22ldFuM+ZHkp433n4Fd5jXjWKutClYFp8w4mgx3zgrjnWxL8dpjMzcQ+WQ==" + }, "ieee754": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" }, "immutability-helper": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/immutability-helper/-/immutability-helper-2.9.1.tgz", - "integrity": "sha512-r/RmRG8xO06s/k+PIaif2r5rGc3j4Yhc01jSBfwPCXDLYZwp/yxralI37Df1mwmuzcCsen/E/ITKcTEvc1PQmQ==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/immutability-helper/-/immutability-helper-2.9.0.tgz", + "integrity": "sha512-2LYtDuGugMLyoFV0qGvblnq39E2VVQ9m4dDktlRLVBBVV1LnUMK0rlqkbtlUjfT1UJO876OobtPlNZTEbOOYVQ==", "requires": { "invariant": "^2.2.0" } @@ -432,6 +470,21 @@ "resolved": "https://registry.npmjs.org/inter-ui/-/inter-ui-3.0.0.tgz", "integrity": "sha512-8mmzPPU2GqexJ/Pi/F90pDBfNZU6UysigjA+2jHM/X2lVNm/2/oQDgJ3NlH1GHNSmj8XMaDFEr/l/BoFUNFVWw==" }, + "intervals-fn": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/intervals-fn/-/intervals-fn-3.0.3.tgz", + "integrity": "sha512-8jHsv7lOAIWJLp1gl2PGRj6bvmEhp8XbtMe3oH1CHsaC63hGFlmiV6RsATLe9U/jtZpdLF+OXIbA0hvoyyrOzw==", + "requires": { + "ramda": "^0.25.0" + }, + "dependencies": { + "ramda": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.25.0.tgz", + "integrity": "sha512-GXpfrYVPwx3K7RQ6aYT8KPS8XViSXUVJT1ONhoKPE9VAleW42YE+U+8VEyGWt41EnEQW7gwecYJriTI0pKoecQ==" + } + } + }, "invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -583,6 +636,12 @@ "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz", "integrity": "sha512-eWw5r+PYICtEBgrBE5hhlT6aAa75f411bgDz/ZL2KZqYV03USvucsxcHUIlGTDTECs1eunpI7HOV7U+WLDvNdQ==" }, + "lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", + "dev": true + }, "loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -604,16 +663,16 @@ "unist-util-visit-parents": "1.1.2" } }, - "memoize-one": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.0.0.tgz", - "integrity": "sha512-7g0+ejkOaI9w5x6LvQwmj68kUj6rxROywPSCqmclG/HBacmFnZqhVscQ8kovkn9FBCNJmOz6SY42+jnvZzDWdw==" - }, "memoize-weak": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/memoize-weak/-/memoize-weak-1.0.2.tgz", "integrity": "sha1-0AFaTHxs/yJj27tJ2x3CBuu5SRY=" }, + "micro-memoize": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/micro-memoize/-/micro-memoize-3.0.1.tgz", + "integrity": "sha512-1ruDLuAVzgBcm2WVFC4YZnTKY8xjBgcjoCGR4K64NbRncDxkgempJyYSni06vB9+FDQzqcvuyCQr0yc+0S5ltw==" + }, "min-document": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", @@ -695,9 +754,9 @@ } }, "parse-entities": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-1.2.1.tgz", - "integrity": "sha512-NBWYLQm1KSoDKk7GAHyioLTvCZ5QjdH/ASBBQTD3iLiAWJXS5bg1jEWI8nIJ+vgVvsceBVBcDGRWSo0KVQBvvg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-1.2.0.tgz", + "integrity": "sha512-XXtDdOPLSB0sHecbEapQi6/58U/ODj/KWfIXmmMCJF/eRn8laX6LZbOyioMoETOOJoWRW8/qTSl5VQkUIfKM5g==", "requires": { "character-entities": "^1.0.0", "character-entities-legacy": "^1.0.0", @@ -716,28 +775,13 @@ } }, "postcss": { - "version": "7.0.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.14.tgz", - "integrity": "sha512-NsbD6XUUMZvBxtQAJuWDJeeC4QFsmWsfozWxCJPWf3M55K9iu2iMDaKqyoOdTJ1R4usBXuxlVFAIo8rZPQD4Bg==", + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.7.tgz", + "integrity": "sha512-HThWSJEPkupqew2fnuQMEI2YcTj/8gMV3n80cMdJsKxfIh5tHf7nM5JigNX6LxVMqo6zkgQNAI88hyFvBk41Pg==", "requires": { - "chalk": "^2.4.2", + "chalk": "^2.4.1", "source-map": "^0.6.1", - "supports-color": "^6.1.0" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" - }, - "supports-color": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", - "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", - "requires": { - "has-flag": "^3.0.0" - } - } + "supports-color": "^5.5.0" } }, "process": { @@ -754,6 +798,12 @@ "object-assign": "^4.1.1" } }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + }, "pure-color": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/pure-color/-/pure-color-1.3.0.tgz", @@ -783,6 +833,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", @@ -843,7 +904,7 @@ }, "react-dnd-html5-backend": { "version": "2.6.0", - "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-2.6.0.tgz", + "resolved": "http://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-2.6.0.tgz", "integrity": "sha1-WQzRzKeEQbsnTt1XH+9MCxbdz44=", "requires": { "lodash": "^4.2.0" @@ -897,6 +958,11 @@ "requires": { "react-is": "^16.7.0" } + }, + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==" } } }, @@ -919,9 +985,9 @@ } }, "react-is": { - "version": "16.8.6", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", - "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==" + "version": "16.7.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.7.0.tgz", + "integrity": "sha512-Z0VRQdF4NPDoI0tsXVMLkJLiwEBa+RP66g0xDHxgxysxSoCUccSten4RTF/UFvZF1dZvZ9Zu1sx+MDXwcOR34g==" }, "react-json-tree": { "version": "0.11.1", @@ -1069,15 +1135,27 @@ } }, "readable-stream": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.3.0.tgz", - "integrity": "sha512-EsI+s3k3XsW+fU8fQACLN59ky34AZ14LoeVZpYwmZvldCFo0r0gnelwF2TcMjLor/BTL5aDJVBMkss0dthToPw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.1.1.tgz", + "integrity": "sha512-DkN66hPyqDhnIQ6Jcsvx9bFjhw214O4poMBcIMgPVpQvNy9a0e0Uhg5SqySyDKAmUlwt8LonTBz1ezOnM8pUdA==", "requires": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, + "realistic-structured-clone": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/realistic-structured-clone/-/realistic-structured-clone-2.0.2.tgz", + "integrity": "sha512-5IEvyfuMJ4tjQOuKKTFNvd+H9GSbE87IcendSBannE28PTrbolgaVg5DdEApRKhtze794iXqVUFKV60GLCNKEg==", + "dev": true, + "requires": { + "core-js": "^2.5.3", + "domexception": "^1.0.1", + "typeson": "^5.8.2", + "typeson-registry": "^1.0.0-alpha.20" + } + }, "redux": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.1.tgz", @@ -1145,9 +1223,9 @@ "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==" }, "rosbag": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/rosbag/-/rosbag-2.0.1.tgz", - "integrity": "sha512-tSu+2RKoYFVh4qLti93qh4UmKFHv64ZMv0okqqEHx7UlmqdCxel5LgXZzhDvnwdurk+bVyNCI7wVgGsjxn3Wbw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/rosbag/-/rosbag-2.1.0.tgz", + "integrity": "sha512-TMbYGjB0YUQSQ3S7G4jPazXQMER6hA2AZv+MjKm0/N2uge7T0hlM+77grKYrngvGomcrzyJanMBMz6nkVet9JA==", "requires": { "buffer": "5.2.1", "heap": "0.2.6", @@ -1186,9 +1264,15 @@ } }, "semver": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", - "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==" + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz", + "integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==" + }, + "setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=", + "dev": true }, "shallowequal": { "version": "1.1.0", @@ -1196,9 +1280,9 @@ "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" }, "source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==" + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" }, "srcset": { "version": "1.0.0", @@ -1253,6 +1337,15 @@ "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.1.tgz", "integrity": "sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g=" }, + "tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, "trim": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz", @@ -1268,9 +1361,27 @@ "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.3.tgz", "integrity": "sha512-fwkLWH+DimvA4YCy+/nvJd61nWQQ2liO/nF/RjkTpiOGi+zxZzVkhb1mvbHIIW4b/8nDsYI8uTmAlc0nNkRMOw==" }, + "typeson": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/typeson/-/typeson-5.12.0.tgz", + "integrity": "sha512-iN1XWZ8ry8OUtnEnBfrQ5M249B0w3jmooeBQ0zmTkmPz2wWmAFDtLwlShCAh2g+aNIYajhza2hJyvygTwUH1PQ==", + "dev": true + }, + "typeson-registry": { + "version": "1.0.0-alpha.27", + "resolved": "https://registry.npmjs.org/typeson-registry/-/typeson-registry-1.0.0-alpha.27.tgz", + "integrity": "sha512-Le8bEDzjpkTRoym1ahojpT8cYd+Oh3tS5cPyWoKKFe/dGEKV/o7jnGBVpd+WjgZ1OrvhYRL8tVCrm+vHFqpiPg==", + "dev": true, + "requires": { + "base64-arraybuffer-es6": "0.4.2", + "typeson": "5.12.0", + "uuid": "3.3.2", + "whatwg-url": "7.0.0" + } + }, "underscore": { "version": "1.4.4", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.4.4.tgz", + "resolved": "http://registry.npmjs.org/underscore/-/underscore-1.4.4.tgz", "integrity": "sha1-YaajIBBiKvoHljvzJSA88SI51gQ=" }, "unherit": { @@ -1388,6 +1499,23 @@ "resolved": "https://registry.npmjs.org/wasm-lz4/-/wasm-lz4-0.0.2.tgz", "integrity": "sha512-cUd61ai+Q8T9g9CB6vLfgXPigw0+11QXh3bWhUgrWWwFyCZBagQQC4dYv4JJz+Ii2L1Pz4jpw+6Zr7QzJPtTIg==" }, + "webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true + }, + "whatwg-url": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.0.0.tgz", + "integrity": "sha512-37GeVSIJ3kn1JgKyjiYNmSLP1yzbpb29jdmwBSgkD9h40/hyrR/OifpVUndji3tmwGgD8qpw7iQu3RSbCrBpsQ==", + "dev": true, + "requires": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, "x-is-string": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz", diff --git a/packages/webviz-core/package.json b/packages/webviz-core/package.json index 5af6bcd09..1b73bd67e 100644 --- a/packages/webviz-core/package.json +++ b/packages/webviz-core/package.json @@ -10,11 +10,14 @@ "chartjs-plugin-datalabels": "0.4.0", "chartjs-plugin-zoom": "0.6.6", "classnames": "2.2.6", + "history": "4.7.2", "hoist-non-react-statics": "3.0.1", + "idb": "2.1.3", "inter-ui": "3.0.0", + "intervals-fn": "3.0.3", "lodash": "4.17.11", - "memoize-one": "5.0.0", "memoize-weak": "1.0.2", + "micro-memoize": "^3.0.1", "moment": "2.22.2", "moment-duration-format": "2.2.2", "moment-timezone": "0.5.23", @@ -27,7 +30,6 @@ "react-chartjs-2": "2.7.4", "react-container-dimensions": "1.4.1", "react-dnd": "2.5.4", - "react-dnd-html5-backend": "2.6.0", "react-document-events": "1.4.0", "react-dom": "16.8.6", "react-draggable": "3.0.5", @@ -46,7 +48,7 @@ "redux": "4.0.1", "redux-thunk": "2.3.0", "reselect": "4.0.0", - "rosbag": "2.0.1", + "rosbag": "2.1.0", "sanitize-html": "1.20.0", "shallowequal": "1.1.0", "string-hash": "1.1.3", @@ -56,6 +58,7 @@ "wasm-lz4": "0.0.2" }, "devDependencies": { + "fake-indexeddb": "2.0.4", "flow-bin": "0.95.1" }, "importjs": { diff --git a/packages/webviz-core/shared/CachedFilelike.js b/packages/webviz-core/shared/CachedFilelike.js new file mode 100644 index 000000000..3b1f5493b --- /dev/null +++ b/packages/webviz-core/shared/CachedFilelike.js @@ -0,0 +1,261 @@ +// @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 { round } from "lodash"; +import type { Callback, Filelike } from "rosbag"; + +import { getNewConnection } from "./getNewConnection"; +import { type Range } from "./ranges"; +import VirtualLRUBuffer from "./VirtualLRUBuffer"; + +// CachedFilelike is a `Filelike` that attempts to do as much caching of the file in memory as +// possible. It takes in 3 named arguments to its constructor: +// - fileReader: a `FileReader` instance (defined below). This essentially does the streamed +// fetching of ranges from our file. +// - cacheSizeInBytes (optional): how many bytes we're allowed to cache. Defaults to infinite +// caching (meaning that the cache will be as big as the file size). `cacheSizeInBytes` also +// becomes the largest range of data that can be requested. +// - logFn (optional): a log function. Useful for logging in a particular format. Defaults to +// `console.log`. +// +// Under the hood this uses a `VirtualLRUBuffer`, which represents the entire file in memory, even +// though only parts of it may actually be stored in memory. It also manages evicting least recently +// used blocks from memory. +// +// We keep a list of byte ranges that have been requested, and their associated callbacks. Typically +// there will be only one such requested range at the time, as usually we need to parse some data +// first before we can read more. We keep one stream from the `fileReader` open at a time, and we +// serve the requested byte ranges in order. +// +// If there are currently no requested byte ranges, we try to intelligently load as much data as +// possible into memory, with a preference given to ranges immediately following the last requested +// byte range. If the cache spans the entire file size, we try to download the entire file. + +export type FileStream = { + on: (string, any) => void, // We only use "data" and "error". + destroy: () => void, +}; +export interface FileReader { + open(): Promise<{| size: number |}>; + fetch(offset: number, length: number): FileStream; + +recordBytesPerSecond?: (number) => void; // For logging / metrics. +} + +const LOGGING_INTERVAL_IN_BYTES = 1024 * 1024 * 10; // Log every 10MiB to avoid cluttering the logs too much. +const CACHE_BLOCK_SIZE = 1024 * 1024 * 10; // 10MiB blocks. +// Don't start a new connection if we're 5MiB away from downloading the requested byte. +// TODO(JP): It would be better (but a bit more involved) to express this in seconds, and take into +// account actual download speed. +const CLOSE_ENOUGH_BYTES_TO_NOT_START_NEW_CONNECTION = 1024 * 1024 * 5; + +export default class CachedFilelike implements Filelike { + _fileReader: FileReader; + _cacheSizeInBytes: number = Infinity; + _fileSize: number; + _virtualBuffer: VirtualLRUBuffer; + _logFn: (string) => void = (msg) => console.log(msg); + _closed: boolean = false; + + // The current active connection, if there is one. `remainingRange.start` gets updated whenever + // we receive new data, so it truly is the remaining range that it is going to download. + _currentConnection: ?{| stream: FileStream, remainingRange: Range |}; + + // A list of read requests and associated ranges for all read requests, in order. + _readRequests: {| range: Range, callback: Callback, requestTime: number |}[] = []; + + // The range.end of the last read request that we resolved. Useful for reading ahead a bit. + _lastResolvedCallbackEnd: ?number; + + // The last time we've encountered an error; + _lastErrorTime: ?number; + + constructor(options: {| fileReader: FileReader, cacheSizeInBytes?: ?number, logFn?: (string) => void |}) { + this._fileReader = options.fileReader; + this._cacheSizeInBytes = options.cacheSizeInBytes || this._cacheSizeInBytes; + this._logFn = options.logFn || this._logFn; + } + + async open() { + if (this._fileSize) { + return; + } + const { size } = await this._fileReader.open(); + this._fileSize = size; + if (this._cacheSizeInBytes >= size) { + // If we have a cache limit that exceeds the file size, then we don't need to limit ourselves + // to small blocks. This way `VirtualLRUBuffer#slice` will be faster since we'll almost always + // not need to copy from multiple blocks into a new `Buffer` instance. + this._virtualBuffer = new VirtualLRUBuffer({ size }); + } else { + this._virtualBuffer = new VirtualLRUBuffer({ + size, + blockSize: CACHE_BLOCK_SIZE, + // Rather create too many blocks than too few (Math.ceil), and always add one block, + // to allow for a read range not starting or ending perfectly at a block boundary. + numberOfBlocks: Math.ceil(this._cacheSizeInBytes / CACHE_BLOCK_SIZE) + 2, + }); + } + this._logFn(`Opening file with size ${bytesToMiB(this._fileSize)}MiB`); + } + + // Get the file size. Requires a call to `open()` or `read()` first. + size() { + if (!this._fileSize) { + throw new Error("CachedFilelike has not been opened"); + } + return this._fileSize; + } + + // Read a certain byte range, and get back a `Buffer` in `callback`. + read(offset: number, length: number, callback: Callback) { + const range = { start: offset, end: offset + length }; + this._logFn(`Requested ${rangeToString(range)}`); + + if (offset < 0 || range.end > this._fileSize || length <= 0) { + throw new Error("CachedFilelike#read invalid input"); + } + if (length > this._cacheSizeInBytes) { + throw new Error(`Requested more data than cache size: ${length} > ${this._cacheSizeInBytes}`); + } + + this.open().then(() => { + this._readRequests.push({ range, callback, requestTime: Date.now() }); + this._updateState(); + }); + } + + // Gets called any time our connection or read requests change. + _updateState() { + if (this._closed) { + return; + } + + // First, see if there are any read requests that we can resolve now. + this._readRequests = this._readRequests.filter(({ range, callback, requestTime }) => { + if (!this._virtualBuffer.hasData(range.start, range.end)) { + return true; + } + + this._logFn(`Returned ${bytesToMiB(range.start)}-${bytesToMiB(range.end)}MiB in ${Date.now() - requestTime}ms`); + this._lastResolvedCallbackEnd = range.end; + const buffer = this._virtualBuffer.slice(range.start, range.end); + + // You can set READ_DELAY= on the command line when testing locally to simulate a slow connection. + let delay = 0; + if (process.env.READ_DELAY && process.env.NODE_ENV !== "production") { + delay = parseInt(process.env.READ_DELAY) || 1000; + } + setTimeout(() => callback(null, buffer), delay); + + return false; + }); + + // 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._virtualBuffer.getRangesWithData(), + lastResolvedCallbackEnd: this._lastResolvedCallbackEnd, + cacheSize: this._cacheSizeInBytes, + fileSize: this._fileSize, + continueDownloadingThreshold: CLOSE_ENOUGH_BYTES_TO_NOT_START_NEW_CONNECTION, + }); + if (newConnection) { + this._setConnection(newConnection); + } + } + + // Replace the current connection with a new one, spanning a certain range. + _setConnection(range: Range) { + this._logFn(`Setting new connection @ ${rangeToString(range)}`); + + if (this._currentConnection) { + // Destroy the current connection if there is one. + const currentConnection = this._currentConnection; + currentConnection.stream.destroy(); + this._logFn(`Destroyed current connection @ ${rangeToString(currentConnection.remainingRange)}`); + } + + // Start the stream, and update the current connection state. + const stream = this._fileReader.fetch(range.start, range.end); + this._currentConnection = { stream, remainingRange: range }; + + stream.on("error", (error: Error) => { + // If we get two errors in a short timespan (100ms) then there is probably a serious error, so + // we resolve all remaining callbacks with errors and close out. + const lastErrorTime = this._lastErrorTime; + if (lastErrorTime && Date.now() - lastErrorTime < 100) { + this._logFn(`Connection @ ${rangeToString(range)} threw another error; closing: ${error.toString()}`); + + this._closed = true; + if (this._currentConnection) { + this._currentConnection.stream.destroy(); + } + for (const request of this._readRequests) { + request.callback(error); + } + return; + } + + // When we encounter an error there is usually a bad connection or timeout or so, so just + // mark the current connection as destroyed, and try again. + this._logFn(`Connection @ ${rangeToString(range)} threw error; trying to continue: ${error.toString()}`); + this._lastErrorTime = Date.now(); + delete this._currentConnection; + this._updateState(); + }); + + // Handle the data stream. + const startTime = Date.now(); + let bytesRead = 0; + let lastReportedBytesRead = 0; + stream.on("data", (chunk: Buffer) => { + const currentConnection = this._currentConnection; + if (!currentConnection || stream !== currentConnection.stream) { + return; // Ignore data from old streams. + } + + // Copy the data into the VirtualLRUBuffer. + this._virtualBuffer.copyFrom(chunk, currentConnection.remainingRange.start); + bytesRead += chunk.byteLength; + + // Every now and then, do some logging of the current download speed. + if (bytesRead - lastReportedBytesRead > LOGGING_INTERVAL_IN_BYTES) { + lastReportedBytesRead = bytesRead; + const sec = (Date.now() - startTime) / 1000; + if (this._fileReader.recordBytesPerSecond) { + this._fileReader.recordBytesPerSecond(bytesRead / sec); + } + + const mibibytes = bytesToMiB(bytesRead); + const speed = round(mibibytes / sec, 2); + this._logFn(`Connection @ ${rangeToString(currentConnection.remainingRange)} downloading at ${speed} MiB/s`); + } + + if (this._virtualBuffer.hasData(range.start, range.end)) { + // If the requested range has been downloaded, we're done! + this._logFn(`Connection @ ${rangeToString(currentConnection.remainingRange)} finished!`); + stream.destroy(); + delete this._currentConnection; + } else { + // Otherwise, update `remainingRange`. + this._currentConnection = { stream, remainingRange: { start: range.start + bytesRead, end: range.end } }; + } + + // Always call `_updateState` so it can decide to create new connections, resolve callbacks, etc. + this._updateState(); + }); + } +} + +// Some formatting functions. +function bytesToMiB(bytes: number) { + return round(bytes / 1024 / 1024, 3); +} +function rangeToString(range: Range) { + return `${bytesToMiB(range.start)}-${bytesToMiB(range.end)}MiB`; +} diff --git a/packages/webviz-core/shared/CachedFilelike.test.js b/packages/webviz-core/shared/CachedFilelike.test.js new file mode 100644 index 000000000..f466f3bb5 --- /dev/null +++ b/packages/webviz-core/shared/CachedFilelike.test.js @@ -0,0 +1,83 @@ +// @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 buffer from "buffer"; + +import CachedFilelike, { type FileReader, type FileStream } from "./CachedFilelike"; + +class InMemoryFileReader implements FileReader { + _buffer: Buffer; + + constructor(buffer: Buffer) { + this._buffer = buffer; + } + + async open() { + return { size: this._buffer.byteLength }; + } + + fetch(offset: number, length: number): FileStream { + return { + on: (type, callback) => { + if (type === "data") { + setTimeout(() => callback(this._buffer.slice(offset, offset + length))); + } + }, + destroy() {}, + }; + } +} + +describe("CachedFilelike", () => { + describe("#size", () => { + it("returns the size from the underlying FileReader", async () => { + const fileReader = new InMemoryFileReader(buffer.Buffer.from([0, 1, 2, 3])); + const cachedFileReader = new CachedFilelike({ fileReader, logFn: () => {} }); + await cachedFileReader.open(); + expect(cachedFileReader.size()).toEqual(4); + }); + }); + + describe("#read", () => { + it("returns data from the underlying FileReader", (done) => { + const fileReader = new InMemoryFileReader(buffer.Buffer.from([0, 1, 2, 3])); + const cachedFileReader = new CachedFilelike({ fileReader, logFn: () => {} }); + cachedFileReader.read(1, 2, (error, data) => { + if (!data) { + throw new Error("Missing `data`"); + } + expect([...data]).toEqual([1, 2]); + done(); + }); + }); + + it("returns an error in the callback if the FileReader keeps returning errors", (done) => { + const fileReader = new InMemoryFileReader(buffer.Buffer.from([0, 1, 2, 3])); + let interval, destroyed; + jest.spyOn(fileReader, "fetch").mockImplementation(() => { + return { + on: (type, callback) => { + if (type === "error") { + interval = setInterval(() => callback(new Error("Dummy error")), 20); + } + }, + destroy() { + clearInterval(interval); + destroyed = true; + }, + }; + }); + const cachedFileReader = new CachedFilelike({ fileReader, logFn: () => {} }); + cachedFileReader.read(1, 2, (error, data) => { + expect(error).not.toEqual(undefined); + expect(destroyed).toEqual(true); + done(); + }); + }); + }); +}); diff --git a/packages/webviz-core/shared/VirtualLRUBuffer.js b/packages/webviz-core/shared/VirtualLRUBuffer.js new file mode 100644 index 000000000..885c1ceae --- /dev/null +++ b/packages/webviz-core/shared/VirtualLRUBuffer.js @@ -0,0 +1,164 @@ +// @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 buffer from "buffer"; +import { simplify, substract, unify } from "intervals-fn"; + +import { isRangeCoveredByRanges, type Range } from "./ranges"; + +// VirtualLRUBuffer works similarly to a regular Node.js `Buffer`, but it has some additional features: +// 1. It can span buffers larger than `buffer.kMaxLength` (typically 2GiB). +// 2. It can take up much less memory when needed by evicting its least recently used ranges from +// memory. +// +// This works by allocating multiple smaller buffers underneath, which we call "blocks". There are +// two main operations: +// - `VirtualLRUBuffer#slice`: works just like `Buffer#slice`, but stitches data together from the +// underlying blocks. It throws an error when the underlying data is not currently set, so be +// sure to check that first using `VirtualLRUBuffer#hasData`, because the underlying block might +// have been evicted. +// - `VirtualLRUBuffer#copyFrom`: similar to `Buffer#copy`. Will set `VirtualLRUBuffer#hasData` to true +// for the range that you copied in, until the data gets evicted through subsequent +// `VirtualLRUBuffer#copyFrom` calls. +// +// As said above, you can use `VirtualLRUBuffer#hasData` to see if a range can be sliced out. You can +// also use `VirtualLRUBuffer#getRangesWithData` to get the full list of ranges for which data is set, +// as an array of `Range` objects with `start` (inclusive) and `end` (exclusive) numbers. +// +// Create a new instance by calling `new VirtualLRUBuffer({ size })`. By default this will not do any +// eviction, and so it will take up `size` bytes of memory. +// +// To limit the memory usage, you can pass in a additional options to the constructor: `blockSize` +// (in bytes) and `numberOfBlocks`. The least recently used block will get evicted when writing to +// an unallocated block using `VirtualLRUBuffer.copyFrom`. + +export default class VirtualLRUBuffer { + byteLength: number; // How many bytes does this buffer represent. + _blocks: Buffer[] = []; // Actual `Buffer` for each block. + _blockSize: number = buffer.kMaxLength; // How many bytes is each block. + _numberOfBlocks: number = Infinity; // How many blocks are we allowed to have at any time. + _lastAccessedBlockIndices: number[] = []; // Indexes of blocks, from least to most recently accessed. + _rangesWithData: Range[] = []; // Ranges for which we have data copied in (and have not been evicted). + + constructor(options: {| size: number, blockSize?: number, numberOfBlocks?: number |}) { + this.byteLength = options.size; + this._blockSize = options.blockSize || this._blockSize; + this._numberOfBlocks = options.numberOfBlocks || this._numberOfBlocks; + } + + // Check if the range between `start` (inclusive) and `end` (exclusive) fully contains data + // copied in through `VirtualLRUBuffer#copyFrom`. + hasData(start: number, end: number): boolean { + return isRangeCoveredByRanges({ start, end }, this._rangesWithData); + } + + // Get the minimal number of start-end pairs for which `VirtualLRUBuffer#hasData` will return true. + // The array is sorted by `start`. + getRangesWithData(): Range[] { + return this._rangesWithData; + } + + // Copy data from the `source` buffer to the byte at `targetStart` in the VirtualLRUBuffer. + copyFrom(source: Buffer, targetStart: number): void { + if (targetStart < 0 || targetStart >= this.byteLength) { + throw new Error("VirtualLRUBuffer#copyFrom invalid input"); + } + + const range = { start: targetStart, end: targetStart + source.byteLength }; + + // Walk through the blocks and copy the data over. If the input buffer is too large we will + // currently just evict the earliest copied in data. + // TODO(JP): We could throw an error in that case if this is causing a lot of trouble. + let position = range.start; + while (position < range.end) { + const { blockIndex, positionInBlock, remainingBytesInBlock } = this._calculatePosition(position); + source.copy(this._getBlock(blockIndex), positionInBlock, position - targetStart); + position += remainingBytesInBlock; + } + + this._rangesWithData = simplify(unify([range], this._rangesWithData)); + } + + // Get a slice of data. Throws if `VirtualLRUBuffer#hasData(start, end)` is false, so be sure to check + // that first. Will use an efficient `Buffer#slice` instead of a copy if all the data happens to + // be contained in one block. + slice(start: number, end: number): Buffer { + const size = end - start; + if (start < 0 || end > this.byteLength || size <= 0 || size > buffer.kMaxLength) { + throw new Error("VirtualLRUBuffer#slice invalid input"); + } + if (!this.hasData(start, end)) { + throw new Error("VirtualLRUBuffer#slice range has no data set"); + } + + const startPositionData = this._calculatePosition(start); + if (size <= startPositionData.remainingBytesInBlock) { + // If the entire range that we care about are contained in one block, do an efficient + // `Buffer#slice` instead of copying data to a new Buffer. + const { blockIndex, positionInBlock } = startPositionData; + return this._getBlock(blockIndex).slice(positionInBlock, positionInBlock + size); + } + + const result = buffer.Buffer.allocUnsafe(size); + let position = start; + while (position < end) { + const { blockIndex, positionInBlock, remainingBytesInBlock } = this._calculatePosition(position); + // Note that these calls to `_getBlock` will never cause any eviction, since we verified using + // the `VirtualLRUBuffer#hasData` precondition that all these buffers exist already. + this._getBlock(blockIndex).copy(result, position - start, positionInBlock); + position += remainingBytesInBlock; + } + return result; + } + + // Get a reference to a block, and mark it as most recently used. Might evict older blocks. + _getBlock(index: number): Buffer { + if (!this._blocks[index]) { + // If a block is not allocated yet, do so. + let size = this._blockSize; + if ((index + 1) * this._blockSize > this.byteLength) { + size = this.byteLength % this._blockSize; // Trim the last block to match the total size. + } + // It's okay to use `allocUnsafe` because we don't allow reading data from ranges that have + // not explicitly be filled using `VirtualLRUBuffer#copyFrom`. + this._blocks[index] = buffer.Buffer.allocUnsafe(size); + } + // Put the current index to the end of the list, while avoiding duplicates. + this._lastAccessedBlockIndices = [...this._lastAccessedBlockIndices.filter((idx) => idx !== index), index]; + if (this._lastAccessedBlockIndices.length > this._numberOfBlocks) { + // If we have too many blocks, remove the least recently used one. + // Note that we don't reuse blocks, since other code might still hold a reference to it + // via the `VirtualLRUBuffer#slice` method. + // TODO(JP): It might be worth measuring if under typical use it's faster to reuse blocks and always + // copy to a new buffer in `VirtualLRUBuffer#slice` (less garbage collection), or if the current method + // is better (faster slicing). + const deleteIndex = this._lastAccessedBlockIndices.shift(); + delete this._blocks[deleteIndex]; + // Remove the range that we evicted from `_rangesWithData`, since the range doesn't have data now. + this._rangesWithData = simplify( + substract(this._rangesWithData, [ + { start: deleteIndex * this._blockSize, end: (deleteIndex + 1) * this._blockSize }, + ]) + ); + } + return this._blocks[index]; + } + + // For a given position, calculate `blockIndex` (which block is this position in); + // `positionInBlock` (byte index of `position` within that block); and `remainingBytesInBlock` + // (how many bytes are there in that block after that position). + _calculatePosition(position: number) { + if (position < 0 || position >= this.byteLength) { + throw new Error("VirtualLRUBuffer#_calculatePosition invalid input"); + } + const blockIndex = Math.floor(position / this._blockSize); + const positionInBlock = position - blockIndex * this._blockSize; + const remainingBytesInBlock = this._getBlock(blockIndex).byteLength - positionInBlock; + return { blockIndex, positionInBlock, remainingBytesInBlock }; + } +} diff --git a/packages/webviz-core/shared/VirtualLRUBuffer.test.js b/packages/webviz-core/shared/VirtualLRUBuffer.test.js new file mode 100644 index 000000000..bcf737933 --- /dev/null +++ b/packages/webviz-core/shared/VirtualLRUBuffer.test.js @@ -0,0 +1,88 @@ +// @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 VirtualLRUBuffer from "./VirtualLRUBuffer"; + +describe("VirtualLRUBuffer", () => { + describe("constructor", () => { + it("returns an instance with the requested bytes", () => { + const vb = new VirtualLRUBuffer({ size: 50, blockSize: 10 }); + expect(vb.byteLength).toEqual(50); + }); + }); + + describe("#copyFrom", () => { + it("lets you copy a buffer into a single block", () => { + const vb = new VirtualLRUBuffer({ size: 25, blockSize: 10 }); + vb.copyFrom(Buffer.from(new Array(25).fill(0)), 0); + vb.copyFrom(Buffer.from([1, 2, 3, 4]), 2); + vb.copyFrom(Buffer.from([5, 6, 7, 8]), 12); + // <--------- block 1 --------> <--------- block 2 --------> <-- block 3 -> + const expected = [0, 0, 1, 2, 3, 4, 0, 0, 0, 0, 0, 0, 5, 6, 7, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + expect([...vb.slice(0, 10), ...vb.slice(10, 20), ...vb.slice(20, 25)]).toEqual(expected); + }); + + it("lets you copy a buffer spread over two blocks", () => { + const vb = new VirtualLRUBuffer({ size: 25, blockSize: 10 }); + vb.copyFrom(Buffer.from(new Array(25).fill(0)), 0); + vb.copyFrom(Buffer.from([1, 2, 3, 4]), 8); + vb.copyFrom(Buffer.from([5, 6, 7, 8]), 18); + // <--------- block 1 --------> <--------- block 2 --------> <-- block 3 -> + const expected = [0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 0, 0, 0, 0, 0, 0, 5, 6, 7, 8, 0, 0, 0]; + expect([...vb.slice(0, 10), ...vb.slice(10, 20), ...vb.slice(20, 25)]).toEqual(expected); + }); + + it("lets you copy a buffer spread over three blocks", () => { + const vb = new VirtualLRUBuffer({ size: 25, blockSize: 10 }); + vb.copyFrom(Buffer.from(new Array(25).fill(0)), 0); + vb.copyFrom(Buffer.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5]), 8); + // <--------- block 1 --------> <--------- block 2 --------> <-- block 3 -> + const expected = [0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5, 0, 0, 0]; + expect([...vb.slice(0, 10), ...vb.slice(10, 20), ...vb.slice(20, 25)]).toEqual(expected); + }); + }); + + describe("#hasData", () => { + it("gets set when copying in data", () => { + const vb = new VirtualLRUBuffer({ size: 25, blockSize: 10 }); + vb.copyFrom(Buffer.from([1, 2, 3, 4]), 2); + expect(vb.hasData(0, 4)).toEqual(false); + expect(vb.hasData(2, 6)).toEqual(true); + }); + + it("evicts old blocks if numberOfBlocks is set", () => { + const vb = new VirtualLRUBuffer({ size: 25, blockSize: 10, numberOfBlocks: 1 }); + vb.copyFrom(Buffer.from([1, 2, 3, 4]), 2); + expect(vb.hasData(2, 6)).toEqual(true); + vb.copyFrom(Buffer.from([5, 6, 7, 8]), 12); + expect(vb.hasData(2, 6)).toEqual(false); + expect(vb.hasData(12, 16)).toEqual(true); + }); + }); + + describe("#slice", () => { + // single block case covered above in .copyFrom tests. + + it("lets you slice a buffer spread over two blocks", () => { + const vb = new VirtualLRUBuffer({ size: 25, blockSize: 10 }); + vb.copyFrom(Buffer.from([1, 2, 3, 4]), 8); + vb.copyFrom(Buffer.from([5, 6, 7, 8]), 18); + expect([...vb.slice(8, 12)]).toEqual([1, 2, 3, 4]); + expect([...vb.slice(18, 22)]).toEqual([5, 6, 7, 8]); + }); + + it("lets you slice a buffer spread over three blocks", () => { + const vb = new VirtualLRUBuffer({ size: 25, blockSize: 10 }); + vb.copyFrom(Buffer.from(new Array(25).fill(0)), 0); + vb.copyFrom(Buffer.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5]), 8); + // <--------- block 1 --------> <--------- block 2 --------> <-- block 3 -> + const expected = [0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5, 0, 0, 0]; + expect([...vb.slice(0, 25)]).toEqual(expected); + }); + }); +}); diff --git a/packages/webviz-core/shared/getNewConnection.js b/packages/webviz-core/shared/getNewConnection.js new file mode 100644 index 000000000..b76949a50 --- /dev/null +++ b/packages/webviz-core/shared/getNewConnection.js @@ -0,0 +1,130 @@ +// @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 { complement, intersect, isOverlapping } from "intervals-fn"; + +import type { Range } from "./ranges"; + +// Based on a number of properties this function determines if a new connection should be opened or +// not. It can be used for any type of ranges, be it bytes, timestamps, or something else. +export function getNewConnection(options: {| + currentRemainingRange: ?Range, // The remaining range that the current connection (if any) is going to download. + readRequestRange: ?Range, // The range of the read request that we're trying to satisfy. + downloadedRanges: Range[], // Array of ranges that have been downloaded already. + lastResolvedCallbackEnd: ?number, // The range.end of the last read request that we resolved. Useful for reading ahead a bit. + cacheSize: number, // The cache size. If equal to or larger than `fileSize` we will attempt to download the whole file. + fileSize: number, // Size of the file. + continueDownloadingThreshold: number, // Amount we're willing to wait downloading before opening a new connection. +|}): ?Range { + const { readRequestRange, currentRemainingRange, ...otherOptions } = options; + if (readRequestRange) { + return getNewConnectionWithExistingReadRequest({ readRequestRange, currentRemainingRange, ...otherOptions }); + } else if (!currentRemainingRange) { + return getNewConnectionWithoutExistingConnection(otherOptions); + } +} + +function getNewConnectionWithExistingReadRequest({ + currentRemainingRange, + readRequestRange, + downloadedRanges, + lastResolvedCallbackEnd, + cacheSize, + fileSize, + continueDownloadingThreshold, +}: {| + currentRemainingRange: ?Range, + readRequestRange: Range, + downloadedRanges: Range[], + lastResolvedCallbackEnd: ?number, + cacheSize: number, + fileSize: number, + continueDownloadingThreshold: number, +|}): ?Range { + // We have a requested range that we're trying to download. + if (readRequestRange.end - readRequestRange.start > cacheSize) { + // This should have been caught way earlier, but just as a sanity check. + throw new Error("Range exceeds cache size"); + } + + // Get the parts of the requested range that have not been downloaded yet. + const notDownloadedRanges = missingRanges(readRequestRange, downloadedRanges); + + if (!notDownloadedRanges[0]) { + // If there aren't any, then we should have never passed in `readRequestRange`. + throw new Error("Range for the first read request is fully downloaded, so it should have been deleted"); + } + + // We want to start a new connection if: + const startNewConnection = + // 1. There is no current connection. + !currentRemainingRange || + // 2. Or if there is no overlap between the current connection and the requested range. + !isOverlapping(notDownloadedRanges, [currentRemainingRange]) || + // 3. Or if we'll reach the requested range at some point, but that would take too long. + currentRemainingRange.start + continueDownloadingThreshold < notDownloadedRanges[0].start; + + if (!startNewConnection) { + return; + } + if (cacheSize >= fileSize) { + // If we're trying to download the whole file, read all the way up to the next range that we have already downloaded. + const range = { start: notDownloadedRanges[0].start, end: fileSize }; + return missingRanges(range, downloadedRanges)[0]; + } + + if (notDownloadedRanges[0].end === readRequestRange.end) { + // If we're downloading to the end of our range, do some reading ahead while we're at it. + // Note that we might have already downloaded parts of this range, but we don't know when + // they get evicted, so for now we just the entire range again. + // TODO(JP): In the future it might be good to mark the already downloaded bits as "recently + // accessed" so they don't get evicted, and then not download them again. + return { ...notDownloadedRanges[0], end: Math.min(readRequestRange.start + cacheSize, fileSize) }; + } + + // Otherwise, start reading from the first non-downloaded range. + return notDownloadedRanges[0]; +} + +function getNewConnectionWithoutExistingConnection({ + downloadedRanges, + lastResolvedCallbackEnd, + cacheSize, + fileSize, +}: { + downloadedRanges: Range[], + lastResolvedCallbackEnd: ?number, + cacheSize: number, + fileSize: number, +}): ?Range { + // If we don't have any read requests, and we also don't have an active connection, then start + // reading ahead as much data as we can! + let readAheadRange: ?Range; + if (cacheSize >= fileSize) { + // If we have an unlimited cache, we want to read the entire file. + readAheadRange = { start: 0, end: fileSize }; + } else if (lastResolvedCallbackEnd != null) { + // Otherwise, if we have a limited cache, we want to read the data right after the last + // read request, because usually read requests are sequential without gaps. + readAheadRange = { + start: lastResolvedCallbackEnd, + end: Math.min(lastResolvedCallbackEnd + cacheSize, fileSize), + }; + } + if (readAheadRange) { + // If we have a range that we want to read ahead, then create a new connection for the range + // within it that has not already been downloaded. + return missingRanges(readAheadRange, downloadedRanges)[0]; + } +} + +// Get the ranges in `bounds` that are NOT covered by `ranges`. +function missingRanges(bounds: Range, ranges: Range[]) { + // `complement` works in unexpected ways when `ranges` has a range that exceeds `bounds`, + // so we first clip `ranges` to `bounds`. + return complement(bounds, intersect([bounds], ranges)); +} diff --git a/packages/webviz-core/shared/getNewConnection.test.js b/packages/webviz-core/shared/getNewConnection.test.js new file mode 100644 index 000000000..e045e6f08 --- /dev/null +++ b/packages/webviz-core/shared/getNewConnection.test.js @@ -0,0 +1,251 @@ +// @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 { getNewConnection } from "./getNewConnection"; + +describe("getNewConnection", () => { + describe("when using a limited cache", () => { + const defaults = { + currentRemainingRange: undefined, + readRequestRange: undefined, + downloadedRanges: [], + lastResolvedCallbackEnd: undefined, + cacheSize: 10, + fileSize: 100, + continueDownloadingThreshold: 5, + }; + + describe("when there is a read request", () => { + it("throws when the range exceeds the cache size", () => { + expect(() => + getNewConnection({ + ...defaults, + readRequestRange: { start: 40, end: 60 }, + }) + ).toThrow("Range exceeds cache size"); + }); + + it("throws when the read request range has been fully downloaded already", () => { + expect(() => + getNewConnection({ + ...defaults, + readRequestRange: { start: 40, end: 50 }, + downloadedRanges: [{ start: 40, end: 50 }], + }) + ).toThrow("Range for the first read request is fully downloaded, so it should have been deleted"); + }); + + describe("when there is an existing connection", () => { + it("does not start a new connection when the current connection overlaps the read request range", () => { + const newConnection = getNewConnection({ + ...defaults, + currentRemainingRange: { start: 45, end: 55 }, + readRequestRange: { start: 40, end: 50 }, + }); + expect(newConnection).toEqual(undefined); + }); + + it("does not start a new connection when the current connection is close enough to the start of the read request range", () => { + const newConnection = getNewConnection({ + ...defaults, + currentRemainingRange: { start: 40, end: 50 }, + readRequestRange: { start: 45 /* 40 + continueDownloadingThreshold */, end: 55 }, + }); + expect(newConnection).toEqual(undefined); + }); + + it("does start a new connection when it would take too long to get to the read request range", () => { + const newConnection = getNewConnection({ + ...defaults, + currentRemainingRange: { start: 40, end: 50 }, + readRequestRange: { start: 46, end: 55 }, + }); + expect(newConnection).toEqual({ start: 46, end: 56 /* 46 + cacheSize */ }); + }); + + it("does not download already downloaded ranges", () => { + const newConnection = getNewConnection({ + ...defaults, + readRequestRange: { start: 40, end: 50 }, + downloadedRanges: [{ start: 45, end: 47 }], + }); + expect(newConnection).toEqual({ start: 40, end: 45 }); + }); + }); + + describe("when there is no existing connection", () => { + it("starts a new connection when there is no existing one", () => { + const newConnection = getNewConnection({ + ...defaults, + readRequestRange: { start: 40, end: 45 }, + }); + expect(newConnection).toEqual({ start: 40, end: 50 /* read-ahead */ }); + }); + + it("starts a new connection at the first non-downloaded ranges", () => { + const newConnection = getNewConnection({ + ...defaults, + readRequestRange: { start: 45, end: 55 }, + downloadedRanges: [{ start: 40, end: 50 }], + }); + expect(newConnection).toEqual({ start: 50, end: 55 }); + }); + + it("reads ahead a bit as long as it does not evict existing downloaded ranges that we requested", () => { + const newConnection = getNewConnection({ + ...defaults, + readRequestRange: { start: 48, end: 55 }, + downloadedRanges: [{ start: 40, end: 50 }], + }); + expect(newConnection).toEqual({ start: 50, end: 58 /* readRequestRange.start + cacheSize */ }); + }); + + it("does not exceed file size in reading ahead", () => { + const newConnection = getNewConnection({ + ...defaults, + readRequestRange: { start: 95, end: 100 }, + }); + expect(newConnection).toEqual({ start: 95, end: 100 }); + }); + }); + }); + + describe("when there is no read request", () => { + it("does not start a new connection", () => { + const newConnection = getNewConnection(defaults); + expect(newConnection).toEqual(undefined); + }); + + describe("read-ahead", () => { + it("starts a new connection based on the end position of the last resolved read request", () => { + const newConnection = getNewConnection({ ...defaults, lastResolvedCallbackEnd: 15 }); + expect(newConnection).toEqual({ start: 15, end: 25 }); + }); + + it("skips over already downloaded ranges", () => { + const newConnection = getNewConnection({ + ...defaults, + lastResolvedCallbackEnd: 15, + downloadedRanges: [{ start: 10, end: 20 }], + }); + expect(newConnection).toEqual({ start: 20, end: 25 }); + }); + + it("creates no new connection when the read-ahead range has been fully downloaded", () => { + const newConnection = getNewConnection({ + ...defaults, + lastResolvedCallbackEnd: 15, + downloadedRanges: [{ start: 10, end: 25 }], + }); + expect(newConnection).toEqual(undefined); + }); + }); + }); + }); + + describe("when using an unlimited cache", () => { + const defaults = { + currentRemainingRange: undefined, + readRequestRange: undefined, + downloadedRanges: [], + lastResolvedCallbackEnd: undefined, + cacheSize: 100, // Same or bigger than `fileSize`. + fileSize: 100, + continueDownloadingThreshold: 5, + }; + + describe("when there is a read request", () => { + it("throws when the read request range has been fully downloaded already", () => { + expect(() => + getNewConnection({ + ...defaults, + readRequestRange: { start: 40, end: 50 }, + downloadedRanges: [{ start: 40, end: 50 }], + }) + ).toThrow("Range for the first read request is fully downloaded, so it should have been deleted"); + }); + + describe("when there is an existing connection", () => { + it("does not start a new connection when the current connection overlaps the read request range", () => { + const newConnection = getNewConnection({ + ...defaults, + currentRemainingRange: { start: 40, end: 100 }, + readRequestRange: { start: 20, end: 50 }, + }); + expect(newConnection).toEqual(undefined); + }); + + it("does not start a new connection when the current connection is close enough to the start of the read request range", () => { + const newConnection = getNewConnection({ + ...defaults, + currentRemainingRange: { start: 40, end: 100 }, + readRequestRange: { start: 45 /* 40 + continueDownloadingThreshold */, end: 55 }, + }); + expect(newConnection).toEqual(undefined); + }); + + it("does start a new connection when it would take too long to get to the read request range", () => { + const newConnection = getNewConnection({ + ...defaults, + currentRemainingRange: { start: 40, end: 100 }, + readRequestRange: { start: 46, end: 55 }, + }); + expect(newConnection).toEqual({ start: 46, end: 100 }); + }); + + it("does not download already downloaded ranges", () => { + const newConnection = getNewConnection({ + ...defaults, + readRequestRange: { start: 20, end: 50 }, + downloadedRanges: [{ start: 30, end: 40 }], + }); + expect(newConnection).toEqual({ start: 20, end: 30 }); + }); + }); + + describe("when there is no existing connection", () => { + it("starts a new connection when there is no existing one", () => { + const newConnection = getNewConnection({ + ...defaults, + readRequestRange: { start: 40, end: 45 }, + }); + expect(newConnection).toEqual({ start: 40, end: 100 }); + }); + + it("starts a new connection at the first non-downloaded ranges", () => { + const newConnection = getNewConnection({ + ...defaults, + readRequestRange: { start: 45, end: 55 }, + downloadedRanges: [{ start: 40, end: 50 }], + }); + expect(newConnection).toEqual({ start: 50, end: 100 }); + }); + }); + }); + + describe("when there is no read request", () => { + it("starts downloading the entire file", () => { + const newConnection = getNewConnection(defaults); + expect(newConnection).toEqual({ start: 0, end: 100 }); + }); + + it("does not download already downloaded ranges", () => { + const newConnection = getNewConnection({ ...defaults, downloadedRanges: [{ start: 20, end: 30 }] }); + expect(newConnection).toEqual({ start: 0, end: 20 }); + }); + + it("keeps downloading linearly from start to end", () => { + const newConnection = getNewConnection({ + ...defaults, + downloadedRanges: [{ start: 0, end: 30 }, { start: 50, end: 70 }], + }); + expect(newConnection).toEqual({ start: 30, end: 50 }); + }); + }); + }); +}); diff --git a/packages/webviz-core/shared/ranges.js b/packages/webviz-core/shared/ranges.js new file mode 100644 index 000000000..5bbe20879 --- /dev/null +++ b/packages/webviz-core/shared/ranges.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 { isBefore, isDuring } from "intervals-fn"; + +export type Range = {| start: number /* inclusive */, end: number /* exclusive */ |}; + +export function isRangeCoveredByRanges(queryRange: Range, nonOverlappingMergedAndSortedRanges: Range[]) { + for (const range of nonOverlappingMergedAndSortedRanges) { + if (isBefore(queryRange, range)) { + return false; + } + if (isDuring(queryRange, range)) { + return true; + } + } + return false; +} diff --git a/packages/webviz-core/shared/ranges.test.js b/packages/webviz-core/shared/ranges.test.js new file mode 100644 index 000000000..6b05b37e6 --- /dev/null +++ b/packages/webviz-core/shared/ranges.test.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 { isRangeCoveredByRanges } from "./ranges"; + +describe("ranges", () => { + describe("isRangeCoveredByRanges", () => { + it("returns true if there is a range that fully contains the queryRange", () => { + expect( + isRangeCoveredByRanges({ start: 5, end: 7 }, [ + { start: 0, end: 1 }, + { start: 4, end: 10 }, + { start: 12, end: 20 }, + ]) + ).toEqual(true); + expect(isRangeCoveredByRanges({ start: 5, end: 7 }, [{ start: 5, end: 7 }])).toEqual(true); + }); + + it("returns false if there is no range that fully contains the queryRange", () => { + expect(isRangeCoveredByRanges({ start: 5, end: 7 }, [{ start: 0, end: 1 }])).toEqual(false); + expect(isRangeCoveredByRanges({ start: 5, end: 7 }, [{ start: 3, end: 6 }, { start: 7, end: 10 }])).toEqual( + false + ); + }); + }); +}); diff --git a/packages/webviz-core/src/components/AppMenu/index.js b/packages/webviz-core/src/components/AppMenu/index.js index 56fa7e643..300a5e13d 100644 --- a/packages/webviz-core/src/components/AppMenu/index.js +++ b/packages/webviz-core/src/components/AppMenu/index.js @@ -40,7 +40,7 @@ export default class AppMenu extends Component { const { isOpen } = this.state; return ( - + diff --git a/packages/webviz-core/src/components/CopyText.js b/packages/webviz-core/src/components/CopyText.js new file mode 100644 index 000000000..bf73e6eaa --- /dev/null +++ b/packages/webviz-core/src/components/CopyText.js @@ -0,0 +1,50 @@ +// @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 ClipboardOutlineIcon from "@mdi/svg/svg/clipboard-outline.svg"; +import * as React from "react"; +import styled from "styled-components"; + +import Icon from "webviz-core/src/components/Icon"; +import clipboard from "webviz-core/src/util/clipboard"; + +const SCopyTextWrapper = styled.div` + display: flex; + align-items: center; + cursor: pointer; + .icon { + visibility: hidden; + } + :hover { + .icon { + visibility: visible; + } + } +`; + +type Props = {| + copyText?: string, + tooltip: string, + children: React.Node, + getCopyText?: () => string, +|}; + +function CopyText({ copyText, tooltip, children, getCopyText }: Props) { + if (!copyText || !children) { + return null; + } + return ( + clipboard.copy(copyText || getCopyText)}> + {children ? children : copyText} + + + + + ); +} + +export default CopyText; diff --git a/packages/webviz-core/src/components/DocumentDropListener.js b/packages/webviz-core/src/components/DocumentDropListener.js index 3fe2f4430..96d2d30d2 100644 --- a/packages/webviz-core/src/components/DocumentDropListener.js +++ b/packages/webviz-core/src/components/DocumentDropListener.js @@ -67,7 +67,7 @@ export default class DocumentDropListener extends React.PureComponent + <> {this.state.hovering && this.props.children} - + ); } } diff --git a/packages/webviz-core/src/components/Dropdown/index.js b/packages/webviz-core/src/components/Dropdown/index.js index a07a03286..56971ad87 100644 --- a/packages/webviz-core/src/components/Dropdown/index.js +++ b/packages/webviz-core/src/components/Dropdown/index.js @@ -27,6 +27,7 @@ type Props = {| toggleComponent?: React$Element, flatEdges: boolean, tooltip?: string, + dataTest?: string, |}; type State = { @@ -88,7 +89,7 @@ export default class Dropdown extends React.Component { } const { text, value, disabled, tooltip } = this.props; const button = ( - + + + {!isOnlyPanel &&

Drag to move

} - + )} {/* $FlowFixMe - https://github.com/facebook/flow/issues/6479 */} + {canSetTopicPrefix ? ( + + ) : null} @@ -227,14 +342,16 @@ export default function Panel( function ConnectedToPipelinePanel(props: any) { return ( - {(context: MessagePipelineContext) => ( - - )} + {(context: MessagePipelineContext) => { + return ( + + ); + }} ); } @@ -244,8 +361,8 @@ export default function Panel( // all panels will rerender, which is very expensive. return { childId: ownProps.childId, - // $FlowFixMe: if nothing went wrong, `state.panels.savedProps[ownProps.childId]` should be of type `Config`. config: state.panels.savedProps[ownProps.childId] || ownProps.config, + isOnlyPanel: !isParent(state.panels.layout), }; } @@ -259,6 +376,7 @@ export default function Panel( ConnectedPanel.defaultConfig = defaultConfig; ConnectedPanel.panelType = PanelComponent.panelType; + ConnectedPanel.canSetTopicPrefix = PanelComponent.canSetTopicPrefix; return ConnectedPanel; } diff --git a/packages/webviz-core/src/components/Panel.module.scss b/packages/webviz-core/src/components/Panel.module.scss index 877b2c281..5b616bdee 100644 --- a/packages/webviz-core/src/components/Panel.module.scss +++ b/packages/webviz-core/src/components/Panel.module.scss @@ -13,18 +13,10 @@ right: 0; bottom: 0; z-index: 100; - - > .fullScreenKeyPressedOverlay { - cursor: unset; - - &:hover { - background-color: unset; - } - } + border: 4px solid rgba(110, 81, 238, 0.3); } -.fullScreenKeyPressedOverlay, -.removePanelKeyPressOverlay { +.quickActionsOverlay { cursor: pointer; position: absolute; top: 0; @@ -32,43 +24,84 @@ right: 0; bottom: 0; z-index: 100000; // highest level within panel + display: none; - > div { + .root:hover &, + // for screenshot tests + &:global(.hoverForScreenshot) { + background-color: rgba(110, 81, 238, 0.15); display: flex; align-items: center; + justify-content: center; + flex-wrap: wrap; } - p { - display: none; + flex-direction: column; + justify-content: flex-start; + align-items: flex-end; + font-size: 14px; + padding-top: 24px; + + div { + width: 100%; + padding: 6px 0; + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; } svg { margin-right: 4px; - width: 16px; - height: 16px; - fill: none; + width: 24px; + height: 24px; + fill: white; } - &:hover { - background-color: rgba(0, 0, 0, 0.5); + button { + width: 72px; + height: 72px; + margin: 4px; + flex: none; + font-size: 14px; display: flex; + flex-direction: column; + align-items: center; justify-content: center; - - p { - display: block; - margin: 0; + background: rgba(110, 81, 238, 0.5); + svg { + margin: 0 0 6px; } - svg { - fill: white; + &:not(:global(.disabled)):hover { + background: rgba(110, 81, 238, 0.8); } } -} -.fullScreenKeyPressedOverlay:hover { - box-shadow: 0 0 0 5px rgba(255, 255, 255, 0.5) inset; + p { + font-size: 12px; + color: $text-muted; + } } -.removePanelKeyPressOverlay:hover { - box-shadow: 0 0 0 5px rgba(242, 67, 102, 0.75) inset; +.topicPrefixLabel { + display: flex; + position: absolute; + bottom: 10px; + right: 10px; + fill: gray; + color: gray; + border-radius: 10px; + opacity: 1; + font-size: 12px; + background-color: rgba(255, 255, 255, 0.2); + padding: 3px; + + &.hasEmptyTopicPrefix { + display: none; + + :global(.mosaic-window):hover & { + display: block; + } + } } diff --git a/packages/webviz-core/src/components/Panel.test.js b/packages/webviz-core/src/components/Panel.test.js index 1cee30876..19b5b65ff 100644 --- a/packages/webviz-core/src/components/Panel.test.js +++ b/packages/webviz-core/src/components/Panel.test.js @@ -10,10 +10,12 @@ import { mount } from "enzyme"; import * as React from "react"; import { savePanelConfig } from "webviz-core/src/actions/panels"; +import { getFilteredFormattedTopics } from "webviz-core/src/components/MessageHistory/topicPrefixUtils"; import { MockMessagePipelineProvider } from "webviz-core/src/components/MessagePipeline"; import Panel from "webviz-core/src/components/Panel"; import rootReducer from "webviz-core/src/reducers"; import configureStore from "webviz-core/src/store/configureStore.testing"; +import { SECOND_BAG_PREFIX } from "webviz-core/src/util/globalConstants"; function getDummyPanel(renderFn) { type DummyConfig = { @@ -119,3 +121,19 @@ describe("Panel", () => { expect(renderFn.mock.calls.length).toEqual(1); }); }); + +it("filters and formats topics appropriately, according to topicPrefix", () => { + const topics = [ + { name: "/topicA", datatype: "some/datatype" }, + { name: "/other_prefix/topicA", datatype: "some/datatype" }, + { name: "/topicB", datatype: "some/datatype" }, + { name: "/other_prefix/topicB", datatype: "some/datatype" }, + { name: "/topicC", datatype: "some/datatype" }, + { name: "/webviz_bag_2/topicC", datatype: "some/datatype" }, + { name: "/topicD", datatype: "some/datatype" }, + { name: "/webviz_bag_2/topicD", datatype: "some/datatype" }, + ]; + + expect(getFilteredFormattedTopics(topics, SECOND_BAG_PREFIX).map((t) => t.name)).toEqual(["/topicC", "/topicD"]); + expect(getFilteredFormattedTopics(topics, "")).toEqual(topics); +}); diff --git a/packages/webviz-core/src/components/PanelContext.js b/packages/webviz-core/src/components/PanelContext.js index aaa472087..25113f0fb 100644 --- a/packages/webviz-core/src/components/PanelContext.js +++ b/packages/webviz-core/src/components/PanelContext.js @@ -9,6 +9,6 @@ import * as React from "react"; // Context used for components to know which panel they are inside -const PanelContext: React.Context = React.createContext(); +const PanelContext = React.createContext(); export default PanelContext; diff --git a/packages/webviz-core/src/components/PanelToolbar/HelpButton.js b/packages/webviz-core/src/components/PanelToolbar/HelpButton.js index 6b474a6ea..82bea9bbe 100644 --- a/packages/webviz-core/src/components/PanelToolbar/HelpButton.js +++ b/packages/webviz-core/src/components/PanelToolbar/HelpButton.js @@ -21,7 +21,7 @@ type Props = {| export default class HelpButton extends React.Component { render() { return ( - + <> { }}> - + ); } } diff --git a/packages/webviz-core/src/components/PanelToolbar/MosaicDragHandle.js b/packages/webviz-core/src/components/PanelToolbar/MosaicDragHandle.js index 76899117c..95ac66907 100644 --- a/packages/webviz-core/src/components/PanelToolbar/MosaicDragHandle.js +++ b/packages/webviz-core/src/components/PanelToolbar/MosaicDragHandle.js @@ -12,6 +12,9 @@ import { Component } from "react"; import { DragSource } from "react-dnd"; import { MosaicDragType, createDragToUpdates } from "react-mosaic-component"; +import { getPanelTypeFromMosiac } from "webviz-core/src/components/PanelToolbar/utils"; +import { getGlobalHooks } from "webviz-core/src/loadWebviz"; + const dragSource = { beginDrag: (props, monitor, component) => { // TODO: Actually just delete instead of hiding @@ -29,9 +32,12 @@ const dragSource = { // If the hide call hasn't happened yet, cancel it window.clearTimeout(hideTimer); + const { mosaicWindowActions, mosaicActions } = component.context; + const type = getPanelTypeFromMosiac(mosaicWindowActions, mosaicActions); + + getGlobalHooks().onPanelDrag(type); const ownPath = component.context.mosaicWindowActions.getPath(); const dropResult = monitor.getDropResult() || {}; - const { mosaicActions } = component.context; const { position, path: destinationPath } = dropResult; if (position != null && destinationPath != null && !_.isEqual(destinationPath, ownPath)) { mosaicActions.updateTree(createDragToUpdates(mosaicActions.getRoot(), ownPath, destinationPath, position)); diff --git a/packages/webviz-core/src/components/PanelToolbar/index.js b/packages/webviz-core/src/components/PanelToolbar/index.js index e13fbcbb6..785bba0d5 100644 --- a/packages/webviz-core/src/components/PanelToolbar/index.js +++ b/packages/webviz-core/src/components/PanelToolbar/index.js @@ -14,8 +14,9 @@ import JsonIcon from "@mdi/svg/svg/json.svg"; import SettingsIcon from "@mdi/svg/svg/settings.svg"; import cx from "classnames"; import PropTypes from "prop-types"; -import * as React from "react"; -import { getNodeAtPath } from "react-mosaic-component"; +import * as React from "react"; // eslint-disable-line import/no-duplicates +import { useContext } from "react"; // eslint-disable-line import/no-duplicates +import Dimensions from "react-container-dimensions"; import { connect } from "react-redux"; import HelpButton from "./HelpButton"; @@ -27,17 +28,19 @@ import Dropdown from "webviz-core/src/components/Dropdown"; import Icon from "webviz-core/src/components/Icon"; import { Item, SubMenu } from "webviz-core/src/components/Menu"; import PanelContext from "webviz-core/src/components/PanelContext"; +import { getPanelTypeFromMosiac } from "webviz-core/src/components/PanelToolbar/utils"; import renderToBody from "webviz-core/src/components/renderToBody"; import ShareJsonModal from "webviz-core/src/components/ShareJsonModal"; +import { getGlobalHooks } from "webviz-core/src/loadWebviz"; import PanelList from "webviz-core/src/panels/PanelList"; import type { PanelConfig, SaveConfigPayload } from "webviz-core/src/types/panels"; -import { getPanelTypeFromId } from "webviz-core/src/util"; type Props = {| children?: React.Node, floating?: boolean, helpContent?: React.Node, menuContent?: React.Node, + showPanelName?: boolean, |}; // separated into a sub-component so it can always skip re-rendering @@ -52,26 +55,25 @@ class StandardMenuItems extends React.PureComponent<{| savePanelConfig: (SaveCon getPanelType() { const { mosaicWindowActions, mosaicActions } = this.context; - if (!mosaicWindowActions || !mosaicActions) { - return null; - } - const node = getNodeAtPath(mosaicActions.getRoot(), mosaicWindowActions.getPath()); - const type = getPanelTypeFromId(node); - return type; + + return getPanelTypeFromMosiac(mosaicWindowActions, mosaicActions); } close = () => { const { mosaicActions, mosaicWindowActions } = this.context; + getGlobalHooks().onPanelClose(this.getPanelType()); mosaicActions.remove(mosaicWindowActions.getPath()); }; split = () => { const { mosaicWindowActions } = this.context; const type = this.getPanelType(); + getGlobalHooks().onPanelSplit(type); mosaicWindowActions.split({ type }); }; swap = (type: string, panelConfig?: PanelConfig) => { + getGlobalHooks().onPanelSwap(type); this.context.mosaicWindowActions.replaceWithNew({ type, panelConfig }); }; @@ -84,8 +86,8 @@ class StandardMenuItems extends React.PureComponent<{| savePanelConfig: (SaveCon modal.remove()} value={panelConfigById[id] || {}} - onChange={(config) => this.props.savePanelConfig({ id, config })} - noun="config" + onChange={(config) => this.props.savePanelConfig({ id, config, override: true })} + noun="panel configuration" /> ); }; @@ -100,21 +102,21 @@ class StandardMenuItems extends React.PureComponent<{| savePanelConfig: (SaveCon return ( {(panelData) => ( - - }> + <> + }> {/* $FlowFixMe - not sure why it thinks onPanelSelect is a Redux action */} - + } onClick={() => this._onImportClick(panelData && panelData.id)}> - import/export config + Import/export config } onClick={this.split}> - Split Panel + Split panel } onClick={this.close} disabled={isOnlyPanel}> - Remove Panel + Remove panel - + )} ); @@ -128,38 +130,35 @@ const ConnectedStandardMenuItems = connect( // Keep controls, which don't change often, in a pure component in order to avoid re-rendering the // whole PanelToolbar when only children change. -class PanelToolbarControls extends React.PureComponent { - render() { - const { floating, helpContent, menuContent } = this.props; - return ( -
- {helpContent && {helpContent}} - - - - }> - {menuContent && ( - - {menuContent}
{" "} -
- )} - -
- - {/* Can only nest native nodes into , so wrapping in a */} - - - - - - -
- ); - } -} +const PanelToolbarControls = React.memo(function PanelToolbarControls(props: Props) { + const panelData = useContext(PanelContext); + const { floating, helpContent, menuContent, showPanelName } = props; + return ( +
+ {showPanelName && panelData &&
{panelData.title}
} + {helpContent && {helpContent}} + + + + }> + +
+ {menuContent && <>{menuContent}} +
+ + {/* Can only nest native nodes into , so wrapping in a */} + + + + + + +
+ ); +}); // Panel toolbar should be added to any panel that's part of the // react-mosaic layout. It adds a drag handle, remove/replace controls @@ -168,19 +167,28 @@ export default class PanelToolbar extends React.PureComponent { render() { const { children, floating, helpContent, menuContent } = this.props; return ( - - {(containsOpen) => ( -
- {children} - -
+ + {({ width }) => ( + + {(containsOpen) => ( +
+ {children} + 360} + /> +
+ )} +
)} -
+ ); } } diff --git a/packages/webviz-core/src/components/PanelToolbar/index.module.scss b/packages/webviz-core/src/components/PanelToolbar/index.module.scss index 7fc60e418..7d12fe1c2 100644 --- a/packages/webviz-core/src/components/PanelToolbar/index.module.scss +++ b/packages/webviz-core/src/components/PanelToolbar/index.module.scss @@ -19,12 +19,19 @@ $spacing: 4px; padding: 2px 2px 2px 6px; } +.panelName { + font-size: 10px; + opacity: 0.5; + margin-right: 4px; +} + .panelToolbarContainer { transition: transform 80ms ease-in-out, opacity 80ms ease-in-out; display: flex; flex: 0 0 auto; flex-direction: row; - background-color: $toolbar; + justify-content: flex-end; + background-color: $toolbar-fixed; padding: 4px; &.floating { @@ -33,28 +40,47 @@ $spacing: 4px; // leave some room for possible scrollbar padding-right: 8px; top: 0; + width: 100%; z-index: 5000; background-color: transparent; transform: translateY(-10px); opacity: 0; + pointer-events: none; + + * { + pointer-events: all; + } &.hasChildren { left: 0; - background-color: $toolbar; + background-color: $toolbar-fixed; } &:not(.hasChildren) .iconContainer { background-color: $toolbar; border-radius: 4px; - box-shadow: 0 6px 40px rgba(0, 0, 0, 0.5), 0 0 0 2px rgba(0, 0, 0, 0.2); + box-shadow: 0 6px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(0, 0, 0, 0.2); + } + } + + &:not(.floating) { + min-height: 30px; + + .iconContainer { + display: none; + + :global(.mosaic-window):hover & { + display: flex; + } } - &.containsOpen { - transform: translateY(0); + &.containsOpen .iconContainer { + display: flex; opacity: 1; } } + &.floating.containsOpen, :global(.mosaic-window):hover & { transform: translateY(0); opacity: 1; @@ -64,7 +90,7 @@ $spacing: 4px; .icon, .dragIcon { font-size: 14px; - margin: 0 0.25em; + margin: 0 0.2em; } .dragIcon { diff --git a/packages/webviz-core/src/components/PanelToolbar/index.stories.js b/packages/webviz-core/src/components/PanelToolbar/index.stories.js index 208a8ede4..e9936d6e9 100644 --- a/packages/webviz-core/src/components/PanelToolbar/index.stories.js +++ b/packages/webviz-core/src/components/PanelToolbar/index.stories.js @@ -13,16 +13,19 @@ import { Provider } from "react-redux"; import { withScreenshot } from "storybook-chrome-screenshot"; import PanelToolbar from "./index"; +import ChildToggle from "webviz-core/src/components/ChildToggle"; +import { MockPanelContextProvider } from "webviz-core/src/components/Panel"; import rootReducer from "webviz-core/src/reducers"; import configureStore from "webviz-core/src/store/configureStore.testing"; -class MosaicWrapper extends React.Component<{| layout?: any, children: React.Node |}> { +class MosaicWrapper extends React.Component<{| layout?: any, children: React.Node, width?: number |}> { render() { + const { width = 300 } = this.props; return ( ( }> -
+
{id === "dummy" ? this.props.children : "Sibling Panel"}
@@ -40,9 +43,12 @@ class PanelToolbarWithOpenMenu extends React.PureComponent<{}> {
{ if (el) { - const gearIcon = el.querySelectorAll("svg")[1]; - // $FlowFixMe - gearIcon.parentElement.click(); + // wait for react-container-dimensions + setImmediate(() => { + const gearIcon = el.querySelectorAll("svg")[1]; + // $FlowFixMe + gearIcon.parentElement.click(); + }); } }}> }> @@ -53,13 +59,42 @@ class PanelToolbarWithOpenMenu extends React.PureComponent<{}> { } } +// Keep PanelToolbar visible by rendering an empty ChildToggle inside the toolbar +function KeepToolbarVisibleHack() { + return ( + {}} position="above"> + + + + ); +} + storiesOf("", module) .addDecorator(withScreenshot()) - .add("non-floating", () => { + .addDecorator((childrenRenderFcn) => { + // Provide all stories with PanelContext and redux state + return ( + + {childrenRenderFcn()} + + ); + }) + .add("non-floating (narrow)", () => { return ( }>
Some controls here
+ +
+
+ ); + }) + .add("non-floating (wide with panel name)", () => { + return ( + + }> +
Some controls here
+
); @@ -68,11 +103,9 @@ storiesOf("", module) class Story extends React.Component<{}> { render() { return ( - - - - - + + + ); } } @@ -82,11 +115,9 @@ storiesOf("", module) class Story extends React.Component<{}> { render() { return ( - - - - - + + + ); } } diff --git a/packages/webviz-core/src/components/PanelToolbar/utils.js b/packages/webviz-core/src/components/PanelToolbar/utils.js new file mode 100644 index 000000000..2ad30a14b --- /dev/null +++ b/packages/webviz-core/src/components/PanelToolbar/utils.js @@ -0,0 +1,21 @@ +// @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 { getNodeAtPath } from "react-mosaic-component"; + +import { getPanelTypeFromId } from "webviz-core/src/util"; + +export function getPanelTypeFromMosiac(mosaicWindowActions: any, mosaicActions: any) { + if (!mosaicWindowActions || !mosaicActions) { + return null; + } + const node = getNodeAtPath(mosaicActions.getRoot(), mosaicWindowActions.getPath()); + const type = getPanelTypeFromId(node); + + return type; +} diff --git a/packages/webviz-core/src/components/PerfMonitor/index.js b/packages/webviz-core/src/components/PerfMonitor/index.js index 988bdeeb5..144d3f586 100644 --- a/packages/webviz-core/src/components/PerfMonitor/index.js +++ b/packages/webviz-core/src/components/PerfMonitor/index.js @@ -60,7 +60,7 @@ export default class PerfMonitor extends React.Component<{| id: string, children } return ( - + <>
(this._top = el)}>?
{this.props.children} - + ); } } diff --git a/packages/webviz-core/src/components/PlayerManager.js b/packages/webviz-core/src/components/PlayerManager.js index 77f586740..8537984a0 100644 --- a/packages/webviz-core/src/components/PlayerManager.js +++ b/packages/webviz-core/src/components/PlayerManager.js @@ -36,7 +36,7 @@ export default function PlayerManager({ children }: {| children: React.Node |}) const [player, setPlayer] = useState(); return ( - + <> { if (shiftPressed && usedFiles.current.length === 1) { @@ -56,6 +56,6 @@ export default function PlayerManager({ children }: {| children: React.Node |}) {children} - + ); } diff --git a/packages/webviz-core/src/components/Root.js b/packages/webviz-core/src/components/Root.js index f9243d6f8..254b0a024 100644 --- a/packages/webviz-core/src/components/Root.js +++ b/packages/webviz-core/src/components/Root.js @@ -53,6 +53,9 @@ class App extends React.PureComponent { if (this.container) { this.container.focus(); } + + // Add a hook for integration tests. + window.setPanelLayout = (payload) => this.props.importPanelLayout(payload, false); } onPanelSelect = (panelType: string, panelConfig?: PanelConfig) => { diff --git a/packages/webviz-core/src/components/SeekController.js b/packages/webviz-core/src/components/SeekController.js new file mode 100644 index 000000000..603adcf9f --- /dev/null +++ b/packages/webviz-core/src/components/SeekController.js @@ -0,0 +1,61 @@ +// @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 { useEffect, useRef } from "react"; +import { TimeUtil } from "rosbag"; + +import { useMessagePipeline } from "./MessagePipeline"; +import { fromMillis } from "webviz-core/src/util/time"; + +// Accept a search string to be injected into the component. +// The default value comes from window.location.search but allowing one to be provided +// makes testing much easier as window.location is a bit hard to mock out cleanly as it's a singleton. +type Props = { + search?: string, +}; + +// seeks player based on url parameters once per page load to allow linking into a player's initial position +export default function SeekController(props: Props) { + const context = useMessagePipeline(); + const seekApplied = useRef(false); + const search = props.search || window.location.search; + // Only apply seek once per page load. + // 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 + ); + useEffect( + () => { + const { activeData, playerId } = context.playerState; + if (!playerId || !activeData) { + return; + } + if (seekApplied.current) { + return; + } + if (!activeData.isPlaying) { + return; + } + seekApplied.current = true; + const params = new URLSearchParams(search); + const seekTo = parseInt(params.get("seek-to"), 10); + if (!seekTo) { + return; + } + const { startTime, endTime } = activeData; + const seekToTime = fromMillis(seekTo); + if (TimeUtil.isGreaterThan(seekToTime, startTime) && TimeUtil.isLessThan(seekToTime, endTime)) { + context.seekPlayback(seekToTime); + } + }, + [shouldRunEffect, search] + ); + return null; +} diff --git a/packages/webviz-core/src/components/SeekController.test.js b/packages/webviz-core/src/components/SeekController.test.js new file mode 100644 index 000000000..1a85e971e --- /dev/null +++ b/packages/webviz-core/src/components/SeekController.test.js @@ -0,0 +1,96 @@ +// @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 { mount } from "enzyme"; +import React from "react"; + +import { MockMessagePipelineProvider } from "./MessagePipeline"; +import SeekController from "./SeekController"; + +describe("", () => { + it("calls seek on message pipeline context exactly once", () => { + const activeData = { + startTime: { sec: 1, nsec: 0 }, + endTime: { sec: 10, nsec: 0 }, + isPlaying: true, + }; + const onSeek = jest.fn(); + const el = mount( + + + + ); + expect(onSeek).toHaveBeenCalledTimes(1); + expect(onSeek).toHaveBeenCalledWith({ sec: 2, nsec: 1e8 }); + const newActiveData = { + ...activeData, + currentTime: { sec: 3, nsec: 0 }, + isPlaying: true, + }; + el.setProps({ activeData: newActiveData }); + expect(onSeek).toHaveBeenCalledTimes(1); + el.unmount(); + expect(onSeek).toHaveBeenCalledTimes(1); + }); + + it("does not call seek if seek-to is outside range of player", () => { + const activeData = { + startTime: { sec: 1, nsec: 0 }, + endTime: { sec: 10, nsec: 0 }, + isPlaying: true, + }; + const onSeek = jest.fn(); + const el = mount( + + + + ); + expect(onSeek).toHaveBeenCalledTimes(0); + el.unmount(); + }); + + it("does not call seek if seek-to is not a number", () => { + const activeData = { + startTime: { sec: 1, nsec: 0 }, + endTime: { sec: 10, nsec: 0 }, + isPlaying: true, + }; + const onSeek = jest.fn(); + const el = mount( + + + + ); + expect(onSeek).toHaveBeenCalledTimes(0); + el.unmount(); + }); + + it("does not call seek until player starts playing", () => { + const activeData = { + startTime: { sec: 1, nsec: 0 }, + endTime: { sec: 10, nsec: 0 }, + isPlaying: false, + }; + const onSeek = jest.fn(); + const el = mount( + + + + ); + expect(onSeek).toHaveBeenCalledTimes(0); + const newActiveData = { + ...activeData, + isPlaying: true, + }; + el.setProps({ + activeData: newActiveData, + }); + expect(onSeek).toHaveBeenCalledTimes(1); + expect(onSeek).toHaveBeenCalledWith({ sec: 3, nsec: 0 }); + el.unmount(); + }); +}); diff --git a/packages/webviz-core/src/components/ShareJsonModal.js b/packages/webviz-core/src/components/ShareJsonModal.js index 7d26a19da..f8d20ec01 100644 --- a/packages/webviz-core/src/components/ShareJsonModal.js +++ b/packages/webviz-core/src/components/ShareJsonModal.js @@ -111,7 +111,7 @@ export default class ShareJsonModal extends Component { Apply - +
diff --git a/packages/webviz-core/src/components/Slider.js b/packages/webviz-core/src/components/Slider.js index c25ca02f3..59755649a 100644 --- a/packages/webviz-core/src/components/Slider.js +++ b/packages/webviz-core/src/components/Slider.js @@ -11,6 +11,8 @@ import * as React from "react"; import DocumentEvents from "react-document-events"; import styled from "styled-components"; +import reportError from "webviz-core/src/util/reportError"; + // A low level slider component. // // Props: @@ -135,6 +137,14 @@ export default class Slider extends React.Component { render() { const { min, max, value, renderSlider, draggable } = this.props; const { mouseDown } = this; + + if (max < min) { + const msg = `Slider component given invalid range: ${min}, ${max}`; + const err = new Error(msg); + + reportError(err.message, err, "user"); + } + return ( (this.el = el)} onClick={this._onClick} onMouseDown={this._onMouseDown}> { }, })), }, - events: ["click"], onClick: this.props.onClick, pan: { enabled: true }, zoom: { enabled: this.props.zoom }, diff --git a/packages/webviz-core/src/components/icon.module.scss b/packages/webviz-core/src/components/icon.module.scss index 23c93104a..ee9dfacdf 100644 --- a/packages/webviz-core/src/components/icon.module.scss +++ b/packages/webviz-core/src/components/icon.module.scss @@ -27,6 +27,14 @@ vertical-align: middle; } +.small, +.small img { + width: 18px; + height: 18px; + font-size: 18px; + vertical-align: middle; +} + .clickable { cursor: pointer; } diff --git a/packages/webviz-core/src/components/validator.js b/packages/webviz-core/src/components/validator.js new file mode 100644 index 000000000..f60d7c7a8 --- /dev/null +++ b/packages/webviz-core/src/components/validator.js @@ -0,0 +1,72 @@ +// @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/globals.js.flow b/packages/webviz-core/src/globals.js.flow index b69c0ed7f..c64b5c5b4 100644 --- a/packages/webviz-core/src/globals.js.flow +++ b/packages/webviz-core/src/globals.js.flow @@ -55,3 +55,9 @@ declare var expect: { }; declare type StyleObj = { [key: string]: string | number }; + +declare class IDBKeyRange { + static lowerBound(any, open?: boolean): IDBKeyRange; + static upperBound(any, open?: boolean): IDBKeyRange; + static bound(start: any, end: any): IDBKeyRange; +} diff --git a/packages/webviz-core/src/loadWebviz.js b/packages/webviz-core/src/loadWebviz.js index 32a04afe8..77052a15a 100644 --- a/packages/webviz-core/src/loadWebviz.js +++ b/packages/webviz-core/src/loadWebviz.js @@ -12,7 +12,7 @@ import { routerMiddleware } from "react-router-redux"; // We put all the internal requires inside functions, so that when they load the hooks have been properly set. -let hooks = { +const defaultHooks = { nodes: () => [], migratePanels: (panels) => panels, panelList() { @@ -24,6 +24,7 @@ let hooks = { const Plot = require("webviz-core/src/panels/Plot").default; const Rosout = require("webviz-core/src/panels/Rosout").default; const StateTransitions = require("webviz-core/src/panels/StateTransitions").default; + const ThreeDimensionalViz = require("webviz-core/src/panels/ThreeDimensionalViz").default; const TopicEcho = require("webviz-core/src/panels/TopicEcho").default; const { ndash } = require("webviz-core/src/util/entities"); @@ -33,6 +34,7 @@ let hooks = { { title: "Topic Echo", component: TopicEcho }, { title: "Plot", component: Plot }, { title: "State Transition Visualizer", component: StateTransitions }, + { title: "3D", component: ThreeDimensionalViz }, { title: `Runtime Monitor ${ndash} Summary`, component: DiagnosticSummary }, { title: `Runtime Monitor ${ndash} Detail`, component: DiagnosticStatusPanel }, { title: "Webviz Internals", component: Internals }, @@ -40,17 +42,99 @@ let hooks = { ]; }, helpPageFootnote: () => null, - perPanelHooks: () => ({ - DiagnosticSummary: { defaultConfig: { pinnedIds: [] } }, - ImageView: { - defaultConfig: { cameraTopic: "", enabledMarkerNames: [], scale: 0.2, transformMarkers: false }, - imageMarkerDatatypes: ["visualization_msgs/ImageMarker"], - imageMarkerArrayDatatypes: [], - canTransformMarkersByTopic: (topic) => !topic.includes("rect"), - }, - StateTransitions: { defaultConfig: { paths: [] }, customStateTransitionColors: {} }, - TopicEcho: { docLinkFunction: () => undefined }, - }), + perPanelHooks: () => { + const World = require("webviz-core/src/panels/ThreeDimensionalViz/World").default; + const LaserScanVert = require("webviz-core/src/panels/ThreeDimensionalViz/LaserScanVert").default; + const FileMultipleIcon = require("@mdi/svg/svg/file-multiple.svg").default; + const CheckboxBlankCircleOutline = require("@mdi/svg/svg/checkbox-blank-circle-outline.svg").default; + const { defaultMapPalette } = require("webviz-core/src/panels/ThreeDimensionalViz/commands/utils"); + + const { SECOND_BAG_PREFIX } = require("webviz-core/src/util/globalConstants"); + + const getMetadata = () => {}; + getMetadata.topics = []; + return { + Panel: { + topicPrefixes: { + "": { + labelText: "Default", + icon: CheckboxBlankCircleOutline, + iconPrefix: "", + }, + [SECOND_BAG_PREFIX]: { + labelText: "Input 2", + icon: FileMultipleIcon, + iconPrefix: "2", + tooltipText: "topics prefixed with '/webviz_bag_2'", + }, + }, + }, + DiagnosticSummary: { defaultConfig: { pinnedIds: [] } }, + ImageView: { + defaultConfig: { + cameraTopic: "", + enabledMarkerNames: [], + scale: 0.2, + transformMarkers: false, + synchronize: false, + }, + imageMarkerDatatypes: ["visualization_msgs/ImageMarker"], + imageMarkerArrayDatatypes: [], + canTransformMarkersByTopic: (topic) => !topic.includes("rect"), + }, + StateTransitions: { defaultConfig: { paths: [] }, customStateTransitionColors: {} }, + ThreeDimensionalViz: { + defaultConfig: { + checkedNodes: ["name:Topics"], + expandedNodes: ["name:Topics"], + followTf: null, + cameraState: {}, + modifiedNamespaceTopics: [], + pinTopics: false, + topicSettings: {}, + }, + topics: [], + editableTopics: [], + icons: {}, + WorldComponent: World, + LaserScanVert, + getMetadata, + migrateConfig: () => false, + setGlobalDataInSceneBuilder: (globalData, selectionState, topicsToRender) => ({ + selectionState, + topicsToRender, + }), + consumeMessage: (topic, msg, consumeMethods, { errors }) => { + errors.topicsWithError.set(topic, `Unrecognized topic datatype for scene: ${msg.datatype}`); + }, + getMessagePose: (msg) => msg.message.pose, + addMarkerToCollector: () => {}, + getSyntheticArrowMarkerColor: () => ({ r: 0, g: 0, b: 1, a: 0.5 }), + getFlattenedPose: () => undefined, + getOccupancyGridValues: (topic) => [0.5, "map"], + getMapTexture(regl) { + return regl.texture({ + format: "rgba", + type: "uint8", + mipmap: false, + data: defaultMapPalette, + width: 256, + height: 1, + }); + }, + consumePose: () => {}, + getMarkerColor: (topic, markerColor) => markerColor, + getDefaultTopicTree: () => ({ name: "root" }), + hasBlacklistTopics: () => false, + renderTopicSettings: () => {}, + ungroupedNodesCategory: "Topics", + rootTransformFrame: "map", + defaultFollowTransformFrame: null, + skipTransformFrame: null, + }, + TopicEcho: { docLinkFunction: () => undefined }, + }; + }, Root({ store }) { const Root = require("webviz-core/src/components/Root").default; return ; @@ -62,23 +146,31 @@ let hooks = { "sensor_msgs/LaserScan", "nav_msgs/OccupancyGrid", ], - rootTransformFrame: "map", - defaultFollowTransformFrame: null, useRaven: () => true, load: () => {}, + onPanelClose: () => {}, + onPanelSwap: () => {}, + onPanelSplit: () => {}, + onPanelDrag: () => {}, }; +let hooks = defaultHooks; + export function getGlobalHooks() { return hooks; } -export function addGlobalHooksForStorybook(webvizHooks) { - hooks = { ...hooks, ...webvizHooks }; +export function setHooks(hooksToSet) { + hooks = { ...hooks, ...hooksToSet }; +} + +export function resetHooksToDefault() { + hooks = defaultHooks; } -export function loadWebviz(webvizHooks) { - if (webvizHooks) { - hooks = webvizHooks; +export function loadWebviz(hooksToSet) { + if (hooksToSet) { + setHooks(hooksToSet); } require("webviz-core/src/styles/global.scss"); diff --git a/packages/webviz-core/src/panels/GlobalVariables/index.js b/packages/webviz-core/src/panels/GlobalVariables/index.js index be7373531..4f1d9260a 100644 --- a/packages/webviz-core/src/panels/GlobalVariables/index.js +++ b/packages/webviz-core/src/panels/GlobalVariables/index.js @@ -8,7 +8,7 @@ import CloseIcon from "@mdi/svg/svg/close.svg"; import { pick } from "lodash"; -import * as React from "react"; +import React, { type Node, useState } from "react"; import styled from "styled-components"; import helpContent from "./index.help.md"; @@ -17,11 +17,13 @@ import GlobalVariablesAccessor from "webviz-core/src/components/GlobalVariablesA import Icon from "webviz-core/src/components/Icon"; import Panel from "webviz-core/src/components/Panel"; import PanelToolbar from "webviz-core/src/components/PanelToolbar"; +import clipboard from "webviz-core/src/util/clipboard"; type Props = {}; -const SButton = styled.button` - margin-top: 10px; +const SButtonContainer = styled.div` + padding: 10px 16px 0; + display: flex; `; const SInput = styled.input` @@ -30,6 +32,10 @@ const SInput = styled.input` color: white; `; +const SSection = styled.section` + margin: 15px; +`; + const canParseJSON = (val) => { try { JSON.parse(val); @@ -69,7 +75,7 @@ class EditableJSONInput extends React.Component { const isValid = canParseJSON(inputVal); return ( { setGlobalData({ [datumKey]: newVal === undefined ? undefined : JSON.parse(String(newVal)) }); }; -function GlobalVariables(props: Props): React.Node { +const getUpdatedURL = (globalData) => { + const queryParams = new URLSearchParams(window.location.search); + queryParams.set("global-data", JSON.stringify(globalData)); + return `${window.location.host}/?${queryParams.toString()}`; +}; + +function GlobalVariables(props: Props): Node { const input = React.createRef(); + const [btnMessage, setBtnMessage] = useState("Copy"); + + const copyURL = (text) => () => { + clipboard.copy(text); + setBtnMessage("Copied!"); + }; return ( @@ -119,6 +137,7 @@ function GlobalVariables(props: Props): React.Node { onChange={(e) => { const newKey = e.target.value.slice(1); changeGlobalKey(newKey.trim(), datumKey, globalData, idx, overwriteGlobalData); + setBtnMessage("Copy"); }} /> @@ -130,6 +149,7 @@ function GlobalVariables(props: Props): React.Node { const newVal = e.target.value; if (canParseJSON(newVal)) { changeGlobalVal(newVal, datumKey, setGlobalData); + setBtnMessage("Copy"); } }} /> @@ -138,6 +158,7 @@ function GlobalVariables(props: Props): React.Node { { changeGlobalVal(undefined, datumKey, setGlobalData); + setBtnMessage("Copy"); }}> @@ -146,18 +167,29 @@ function GlobalVariables(props: Props): React.Node { ))} - { - setGlobalData({ "": "" }); - }}> - + add variable - - { - overwriteGlobalData({}); - }}> - - clear all - + + + + + + + + + {document.queryCommandSupported("copy") && ( + + )} + + ); }} diff --git a/packages/webviz-core/src/panels/ImageView/ImageCanvas.js b/packages/webviz-core/src/panels/ImageView/ImageCanvas.js index 0d72b477c..372c71599 100644 --- a/packages/webviz-core/src/panels/ImageView/ImageCanvas.js +++ b/packages/webviz-core/src/panels/ImageView/ImageCanvas.js @@ -6,8 +6,6 @@ // 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 WavesIcon from "@mdi/svg/svg/waves.svg"; -import cx from "classnames"; import { isEqual, omit } from "lodash"; import React from "react"; @@ -15,14 +13,9 @@ import CameraModel from "./CameraModel"; import { decodeYUV, decodeBGR, decodeFloat1c, decodeRGGB } from "./decodings"; import styles from "./ImageCanvas.module.scss"; import { type ImageViewPanelHooks } from "./index"; -import type { SaveConfig } from "./index"; -import ChildToggle from "webviz-core/src/components/ChildToggle"; import ContextMenu from "webviz-core/src/components/ContextMenu"; -import Icon from "webviz-core/src/components/Icon"; import Menu, { Item } from "webviz-core/src/components/Menu"; 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 { ImageMarker, CameraInfo, Color } from "webviz-core/src/types/Messages"; import type { Message } from "webviz-core/src/types/players"; @@ -33,8 +26,6 @@ type Props = { markers: Message[], panelHooks?: ImageViewPanelHooks, transformMarkers: boolean, - canTransformMarkers?: boolean, - saveConfig: SaveConfig, }; type State = { @@ -51,6 +42,7 @@ function toRGBA(color: Color) { export default class ImageCanvas extends React.Component { _canvasRef = React.createRef(); _ready: boolean = true; + _droppedFrame: boolean = false; static defaultProps = { markers: [], @@ -348,24 +340,31 @@ export default class ImageCanvas extends React.Component { 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) - link.setAttribute("href", canvas.toDataURL()); - // name the image the same name as the topic - // note: the / characters in the file name will be replaced with _ - // by the browser - // remove the leading / so the image name doesn't start with _ - 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(); - // remove the link after triggering download - body.removeChild(link); + 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 + // remove the leading / so the image name doesn't start with _ + 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); + }); + }); }; onCanvasRightClick = (e: SyntheticMouseEvent) => { @@ -383,6 +382,7 @@ export default class ImageCanvas extends React.Component { renderCurrentImage() { if (!this._ready) { console.warn("Dropped frame on image canvas"); + this._droppedFrame = true; return; } @@ -393,11 +393,17 @@ export default class ImageCanvas extends React.Component { } 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); @@ -407,35 +413,6 @@ export default class ImageCanvas extends React.Component { } render() { - const { saveConfig, transformMarkers, canTransformMarkers } = this.props; - const { cameraModel } = this.state; - - return ( - - - {canTransformMarkers && cameraModel && cameraModel.initializedData && ( - - {(containsOpen) => ( -
- saveConfig({ transformMarkers: !transformMarkers })} - tooltip={ - transformMarkers - ? "Markers are being transformed by webviz based on the camera model. Click to turn it off." - : `Markers can be transformed by webviz based on the camera model. Click to turn it on.` - } - fade - medium> - - -
- )} -
- )} -
- ); + return ; } } diff --git a/packages/webviz-core/src/panels/ImageView/index.js b/packages/webviz-core/src/panels/ImageView/index.js index 8687dbbd9..2ac9a18a3 100644 --- a/packages/webviz-core/src/panels/ImageView/index.js +++ b/packages/webviz-core/src/panels/ImageView/index.js @@ -8,30 +8,50 @@ import CheckboxBlankOutlineIcon from "@mdi/svg/svg/checkbox-blank-outline.svg"; import CheckboxMarkedIcon from "@mdi/svg/svg/checkbox-marked.svg"; -import { sortBy } from "lodash"; -import React, { Component } from "react"; +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 * as React from "react"; import { createSelector } from "reselect"; +import styled from "styled-components"; import ImageCanvas from "./ImageCanvas"; +import imageCanvasStyles from "./ImageCanvas.module.scss"; import helpContent from "./index.help.md"; import style from "./index.module.scss"; -import { getCameraInfoTopic, getMarkerTopics, getMarkerOptions, groupTopics } from "./util"; +import { getCameraInfoTopic, getCameraNamespace, getMarkerTopics, getMarkerOptions, groupTopics } from "./util"; +import ChildToggle from "webviz-core/src/components/ChildToggle"; import Dropdown from "webviz-core/src/components/Dropdown"; +import dropDownStyles from "webviz-core/src/components/Dropdown/index.module.scss"; +import EmptyState from "webviz-core/src/components/EmptyState"; import Flex from "webviz-core/src/components/Flex"; +import Icon from "webviz-core/src/components/Icon"; import { Item, SubMenu } from "webviz-core/src/components/Menu"; -import MessageHistory, { type MessageHistoryData } from "webviz-core/src/components/MessageHistory"; +import MessageHistory, { + type MessageHistoryData, + type MessageHistoryItemsByPath, +} from "webviz-core/src/components/MessageHistory"; +import synchronizeMessages from "webviz-core/src/components/MessageHistory/synchronizeMessages"; import Panel from "webviz-core/src/components/Panel"; 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 naturalSort from "webviz-core/src/util/naturalSort"; +import { formatTimeRaw } from "webviz-core/src/util/time"; import toggle from "webviz-core/src/util/toggle"; +const IMAGE_QUEUE_SIZE = 3; +const MARKER_QUEUE_SIZE = 12; + export type ImageViewPanelHooks = { defaultConfig: { cameraTopic: string, enabledMarkerNames: string[], scale: number, + synchronize: boolean, }, imageMarkerArrayDatatypes: string[], imageMarkerDatatypes: string[], @@ -43,6 +63,7 @@ export type Config = { scale: number, panelHooks?: ImageViewPanelHooks, transformMarkers: boolean, + synchronize: boolean, }; export type SaveConfig = ($Shape) => void; @@ -53,6 +74,45 @@ type Props = { topics: Topic[], }; +const formatTimeForPath = (items: MessageHistoryItemsByPath, path: string): string => { + const stamp = get(items, [path, "0", "message", "message", "header", "stamp"]); + + if (stamp === undefined) { + return ""; + } + + return formatTimeRaw(stamp); +}; + +const TopicTimestampSpan = styled.span` + padding: 0px 0px 0px 15px; + font-size: 10px; + font-style: italic; +`; + +const TopicTimestamp = ({ text, style }: { text: string, style?: { [string]: string } }) => + text === "" ? null : {text}; + +const BottomBar = ({ children, containsOpen }: { children?: React.Node, containsOpen: boolean }) => ( +
+ {children} +
+); + +const ToggleComponent = ({ text, disabled = false }: { text: string, disabled?: boolean }) => { + return ( + + ); +}; + // Group image topics by the first component of their name const imageTopicsByNamespaceSelector = createSelector( (topics?: Topic[]) => topics || [], @@ -75,7 +135,30 @@ const markerTopicSelector = createSelector( } ); -class ImageView extends Component { +function renderEmptyState(cameraTopic: string, markerTopics: string[], shouldSynchronize: boolean) { + return ( + + Waiting for images {markerTopics.length > 0 && "and markers"} on: +
    +
  • + {cameraTopic} +
  • + {markerTopics.sort().map((m) => ( +
  • + {m} +
  • + ))} +
+ {shouldSynchronize && ( +

+ Synchronization is enabled, so all header.stamps must match exactly. +

+ )} +
+ ); +} + +class ImageView extends React.Component { static panelType = "ImageViewPanel"; static defaultConfig = getGlobalHooks().perPanelHooks().ImageView.defaultConfig; @@ -96,12 +179,13 @@ class ImageView extends Component { this.props.saveConfig({ scale }); }; - renderImageTopicDropdown() { + renderImageTopicDropdown(allItemsByPath: MessageHistoryItemsByPath) { const { cameraTopic } = this.props.config; + const cameraNamespace = getCameraNamespace(cameraTopic); const imageTopicsByNamespace = imageTopicsByNamespaceSelector(this.props.topics); if (!imageTopicsByNamespace || imageTopicsByNamespace.size === 0) { - return ; + return } />; } const items = [...imageTopicsByNamespace.keys()].sort().map((group) => { @@ -111,11 +195,9 @@ class ImageView extends Component { } // satisfy flow topics.sort(naturalSort("name")); - const isSelected = topics.some((topic) => topic.name === cameraTopic); - // place rectified topic above other topics return ( - + {topics.map((topic) => { return ( { ); }); - return {items}; + return }>{items}; } - renderMarkerDropdown() { + renderMarkerDropdown(allItemsByPath: MessageHistoryItemsByPath) { const { cameraTopic, enabledMarkerNames } = this.props.config; const imageTopicsByNamespace = imageTopicsByNamespaceSelector(this.props.topics); const markerTopics = markerTopicSelector(this.props.topics, this.props.config.panelHooks); @@ -142,6 +224,7 @@ class ImageView extends Component { const markerOptions = getMarkerOptions(cameraTopic, (markerTopics || []).map((t) => t.name), allCameraNamespaces); return ( { icon={enabledMarkerNames.includes(option.name) ? : } key={option.name} value={option.name}> - {option.name} + {option.name} + ))} ); } - renderDropdown() { - const { scale } = this.props.config; + _renderToolbar(allItemsByPath: MessageHistoryItemsByPath) { return ( - +
- {this.renderImageTopicDropdown()} - {this.renderMarkerDropdown()} - - 20% - 50% - 100% - + {this.renderImageTopicDropdown(allItemsByPath)} + {this.renderMarkerDropdown(allItemsByPath)}
); } + _toggleSynchronize = () => { + this.props.saveConfig({ synchronize: !this.props.config.synchronize }); + }; + + _renderMenuContent() { + const { scale, synchronize } = this.props.config; + return ( + <> + : } + onClick={this._toggleSynchronize} + tooltip={`Image queue size: ${IMAGE_QUEUE_SIZE}\nMarker queue size: ${MARKER_QUEUE_SIZE}`}> + Synchronize images and markers + +
+ + {[0.2, 0.5, 1].map((value) => { + return ( + this.onChangeScale(value)}> + {(value * 100).toFixed()}% + + ); + })} + + + ); + } + render() { const { saveConfig, + config, config: { cameraTopic, enabledMarkerNames, scale, panelHooks, transformMarkers }, } = this.props; const cameraInfoTopic = getCameraInfoTopic(cameraTopic); - const markerTopics = getMarkerTopics(cameraTopic, enabledMarkerNames); + const allMarkerTopics = markerTopicSelector(this.props.topics, this.props.config.panelHooks); + const markerTopics = getMarkerTopics(cameraTopic, enabledMarkerNames).filter((markerTopic) => + allMarkerTopics.some(({ name }) => markerTopic === name) + ); + + const shouldSynchronize = config.synchronize && markerTopics.length > 0; + + // When synchronizing, keep some extra historical messages so we can synchronize over + // significant time delays. + const imageHistorySize = shouldSynchronize ? IMAGE_QUEUE_SIZE : 1; + const markerHistorySize = shouldSynchronize ? MARKER_QUEUE_SIZE : 1; return ( - {this.renderDropdown()} - - {({ itemsByPath: { [cameraTopic]: cameraMessages } }: MessageHistoryData) => ( - - {({ itemsByPath }: MessageHistoryData) => ( - itemsByPath[topic][0] && itemsByPath[topic][0].message) - .filter(Boolean)} - /> - )} + + {({ itemsByPath: imageItemsByPath }: MessageHistoryData) => ( + + {({ itemsByPath: markerItemsByPath }: MessageHistoryData) => { + const allItemsByPath: ?MessageHistoryItemsByPath = (() => { + const items = { + ...imageItemsByPath, + ...pick(markerItemsByPath, markerTopics), + }; + return shouldSynchronize ? synchronizeMessages(items) : items; + })(); + + if (!allItemsByPath || allItemsByPath[cameraTopic].length === 0) { + return ( + <> + {this._renderToolbar({})} + {renderEmptyState(cameraTopic, markerTopics, shouldSynchronize)} + + ); + } + + return ( + <> + {this._renderToolbar(allItemsByPath)} + get(allItemsByPath[topic], [0, "message"])).filter(Boolean)} + /> + + {(containsOpen) => { + const canTransformMarkers = getGlobalHooks() + .perPanelHooks() + .ImageView.canTransformMarkersByTopic(cameraTopic); + + const topicTimestamp = ( + + ); + + if (!canTransformMarkers) { + return {topicTimestamp}; + } + + return ( + + {topicTimestamp} + saveConfig({ transformMarkers: !transformMarkers })} + tooltip={ + transformMarkers + ? "Markers are being transformed by webviz based on the camera model. Click to turn it off." + : `Markers can be transformed by webviz based on the camera model. Click to turn it on.` + } + fade + medium> + + + + ); + }} + + + ); + }} )} diff --git a/packages/webviz-core/src/panels/PanelList.js b/packages/webviz-core/src/panels/PanelList.js index 4c919812f..9f648cdd7 100644 --- a/packages/webviz-core/src/panels/PanelList.js +++ b/packages/webviz-core/src/panels/PanelList.js @@ -27,7 +27,30 @@ type PanelListItem = {| hideFromList?: boolean, |}; -const panelList: PanelListItem[] = getGlobalHooks().panelList(); +// getPanelList() and getPanelListItemsByType() are functions rather than top-level constants +// in order to avoid issues with circular imports, such as +// FooPanel -> PanelToolbar -> PanelList -> getGlobalHooks().panelList() -> FooPanel. +let gPanelList; +function getPanelList(): PanelListItem[] { + if (!gPanelList) { + gPanelList = getGlobalHooks().panelList(); + } + return gPanelList; +} + +let gPanelListItemsByType; +export function getPanelListItemsByType(): { [type: string]: PanelListItem } { + if (!gPanelListItemsByType) { + gPanelListItemsByType = {}; + for (const item of getPanelList()) { + // $FlowFixMe - bug prevents requiring panelType: https://stackoverflow.com/q/52508434/23649 + const panelType = item.component.panelType; + console.assert(panelType && !(panelType in gPanelListItemsByType)); + gPanelListItemsByType[panelType] = item; + } + } + return gPanelListItemsByType; +} type DropDescription = { panelType: string, @@ -41,6 +64,7 @@ type PanelItemProps = { title: string, panelConfig?: PanelConfig, |}, + checked?: boolean, // this comes from react-dnd connectDragSource: (any) => React.Node, onClick: () => void, @@ -53,10 +77,12 @@ type PanelItemProps = { class PanelItem extends React.Component { render() { - const { connectDragSource, panel, onClick } = this.props; + const { connectDragSource, panel, onClick, checked } = this.props; return connectDragSource(
- {panel.title} + + {panel.title} +
); } @@ -93,6 +119,7 @@ const DraggablePanelItem = DragSource(MosaicDragType.WINDOW, dragConfig, (connec type OwnProps = {| onPanelSelect: (panelType: string, panelConfig?: PanelConfig) => void, + selectedPanelType?: string, |}; type Props = { ...OwnProps, @@ -104,7 +131,7 @@ type Props = { class PanelList extends React.Component { static getComponentForType(type: string): any | void { // $FlowFixMe - bug prevents requiring panelType: https://stackoverflow.com/q/52508434/23649 - const panel = panelList.find((item) => item.component.panelType === type); + const panel = getPanelList().find((item) => item.component.panelType === type); return panel && panel.component; } @@ -138,7 +165,7 @@ class PanelList extends React.Component { // sanity checks to help panel authors debug issues _verifyPanels() { const panelTypes: Map> = new Map(); - for (const { component } of panelList) { + for (const { component } of getPanelList()) { // $FlowFixMe - bug prevents requiring panelType: https://stackoverflow.com/q/52508434/23649 const { name, displayName, panelType } = component; if (!panelType) { @@ -160,17 +187,17 @@ class PanelList extends React.Component { render() { this._verifyPanels(); - const { mosaicId, onPanelSelect } = this.props; + const { mosaicId, onPanelSelect, selectedPanelType } = this.props; return ( - - {panelList + <> + {getPanelList() .filter(({ hideFromList }) => !hideFromList) .sort(naturalSort("title")) .map( // $FlowFixMe - bug prevents requiring panelType: https://stackoverflow.com/q/52508434/23649 ({ presets, title, component: { panelType } }) => presets ? ( - + {presets.map((subPanelListItem) => ( { panel={{ type: panelType, title }} onDrop={this.onPanelMenuItemDrop} onClick={() => onPanelSelect(panelType)} + checked={panelType === selectedPanelType} /> ) )} - + ); } } diff --git a/packages/webviz-core/src/panels/Plot/index.js b/packages/webviz-core/src/panels/Plot/index.js index e05fb8b54..eb9818ba9 100644 --- a/packages/webviz-core/src/panels/Plot/index.js +++ b/packages/webviz-core/src/panels/Plot/index.js @@ -56,7 +56,7 @@ class Plot extends PureComponent { const { saveConfig } = this.props; return ( - + <> saveConfig({ maxYValue: maxYValue === "" ? "10" : "" })}>
Maximum
{ placeholder="auto" />
-
+ ); } diff --git a/packages/webviz-core/src/panels/Rosout/index.js b/packages/webviz-core/src/panels/Rosout/index.js index df63ff2a6..de5e1a1e3 100644 --- a/packages/webviz-core/src/panels/Rosout/index.js +++ b/packages/webviz-core/src/panels/Rosout/index.js @@ -71,21 +71,27 @@ export const getShouldDisplayMsg = (msg: Message, minLogLevel: number, searchTer // No search term filters so this message should be visible. return true; } - - for (const searchTerm of searchTerms) { - if (msg.message.name.includes(searchTerm) || msg.message.msg.includes(searchTerm)) { + const searchTermsInLowerCase = searchTerms.map((term) => term.toLowerCase()); + for (const searchTerm of searchTermsInLowerCase) { + if (msg.message.name.toLowerCase().includes(searchTerm) || msg.message.msg.toLowerCase().includes(searchTerm)) { return true; } } return false; }; +type State = { + disableAutoScroll: boolean, +}; + const DEFAULT_CONFIG = { searchTerms: [], minLogLevel: 1 }; -class RosoutPanel extends PureComponent { +class RosoutPanel extends PureComponent { static defaultConfig = DEFAULT_CONFIG; static panelType = "RosOut"; _prevConfig: Config = DEFAULT_CONFIG; + state = { disableAutoScroll: false }; + _onNodeFilterChange = (selectedOptions: Option[]) => { this.props.saveConfig({ ...this.props.config, searchTerms: selectedOptions.map((option) => option.value) }); }; @@ -161,6 +167,7 @@ class RosoutPanel extends PureComponent { render() { const seenNodeNames = new Set(); + const { disableAutoScroll } = this.state; return ( @@ -175,11 +182,21 @@ class RosoutPanel extends PureComponent { {this._renderFiltersBar(seenNodeNames)} - +
{ + const newDisableAutoScroll = target.scrollHeight - target.scrollTop > target.clientHeight; + if (newDisableAutoScroll !== disableAutoScroll) { + this.setState({ disableAutoScroll: newDisableAutoScroll }); + } + }}> + +
); }} diff --git a/packages/webviz-core/src/panels/Rosout/index.module.scss b/packages/webviz-core/src/panels/Rosout/index.module.scss index eb9bfc872..5711fec71 100644 --- a/packages/webviz-core/src/panels/Rosout/index.module.scss +++ b/packages/webviz-core/src/panels/Rosout/index.module.scss @@ -17,11 +17,15 @@ $option-font-size: $text-size; .message { line-height: 1.2; padding-top: 32px; +} + +.content { @include monospace; + flex: 1 1 auto; + display: flex; } .filtersBar { - align-items: right; background-color: $toolbar; display: flex; flex-wrap: wrap; @@ -32,7 +36,7 @@ $option-font-size: $text-size; .severityFilter { flex: 0 1 auto; - min-width: 200px; + min-width: 120px; margin-left: 10px; } @@ -93,15 +97,25 @@ $option-font-size: $text-size; .Select--multi .Select-multi-value-wrapper { width: 100%; } + .Select--multi .Select-arrow { + opacity: 0.5; + } .Select--multi .Select-value { color: $text-bright; background-color: $background-control-selected; - border: 1px solid $light-purple; + border: 1px solid $background-control; margin: 2px; } - - .Select--multi .Select-value-icon:hover { - background-color: $light-purple; - color: $background-control-selected; + .Select--multi .Select-value-icon { + display: inline-flex; + align-items: center; + padding: 0 0 0 4px; + opacity: 0.5; + border: 0; + &:hover { + color: inherit; + background-color: transparent; + opacity: 1; + } } } diff --git a/packages/webviz-core/src/panels/Rosout/index.stories.js b/packages/webviz-core/src/panels/Rosout/index.stories.js index 71eff140e..fcae30e72 100644 --- a/packages/webviz-core/src/panels/Rosout/index.stories.js +++ b/packages/webviz-core/src/panels/Rosout/index.stories.js @@ -43,7 +43,7 @@ const fixture = { header: { stamp: { sec: 123, nsec: 0 } }, level: 4, line: 242, - msg: "Couldn't find int 83757.", + msg: "Couldn't find int 2121.", name: "/other_node", }, }, @@ -92,10 +92,17 @@ storiesOf("", module) ); }) - .add("filtered", () => { + .add(`filtered terms: "multiple", "/some_topic"`, () => { return ( ); + }) + .add(`case insensitive message filtering: "could", "Ipsum"`, () => { + return ( + + + + ); }); diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/CameraInfo.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/CameraInfo.js new file mode 100644 index 000000000..8ffd2fc45 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/CameraInfo.js @@ -0,0 +1,207 @@ +// @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/FollowTFControl.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/FollowTFControl.js new file mode 100644 index 000000000..643008863 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/FollowTFControl.js @@ -0,0 +1,274 @@ +// @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 CrosshairsGpsIcon from "@mdi/svg/svg/crosshairs-gps.svg"; +import MenuDownIcon from "@mdi/svg/svg/menu-down.svg"; +import MenuLeftIcon from "@mdi/svg/svg/menu-left.svg"; +import CompassOutlineIcon from "@mdi/svg/svg/navigation.svg"; +import { sortBy, debounce } from "lodash"; +import React, { memo, createRef, useCallback, useEffect, useState } from "react"; +import shallowequal from "shallowequal"; +import styled from "styled-components"; + +import { type Transform } from "./Transforms"; +import Autocomplete from "webviz-core/src/components/Autocomplete"; +import Button from "webviz-core/src/components/Button"; +import Icon from "webviz-core/src/components/Icon"; +import { getGlobalHooks } from "webviz-core/src/loadWebviz"; +import colors from "webviz-core/src/styles/colors.module.scss"; + +type TfTreeNode = { + tf: Transform, + children: TfTreeNode[], + depth: number, +}; + +type TfTree = { + roots: TfTreeNode[], + nodes: { [string]: TfTreeNode }, +}; + +const treeNodeToTfId = (node) => node.tf.id; + +const buildTfTree = (transforms: Transform[]): TfTree => { + const tree: TfTree = { + roots: [], + nodes: {}, + }; + // Create treeNodes for all tfs. + for (const tf of transforms) { + if (tree.nodes[tf.id]) { + continue; + } + tree.nodes[tf.id] = { + tf, + children: [], + depth: 0, + }; + } + + // Now add children to their parents treenode. + for (const tf of transforms) { + if (tf.parent) { + const parentTreeNode = tree.nodes[tf.parent.id]; + parentTreeNode.children.push(tree.nodes[tf.id]); + } else { + tree.roots.push(tree.nodes[tf.id]); + } + } + + // Cast the list to satisfy flow (because Object.values returns array of mixed). + const allNodes = ((Object.values(tree.nodes): any): TfTreeNode[]); + // Do a final pass sorting all the children lists. + for (const node of allNodes) { + node.children = sortBy(node.children, treeNodeToTfId); + } + tree.roots = sortBy(tree.roots, treeNodeToTfId); + + // Calculate depths + const setDepth = (node: TfTreeNode, depth: number) => { + node.depth = depth; + node.children.forEach((child) => setDepth(child, depth + 1)); + }; + tree.roots.forEach((root) => + setDepth(root, root.tf.id === getGlobalHooks().perPanelHooks().ThreeDimensionalViz.rootTransformFrame ? -1 : 0) + ); + + return tree; +}; + +type Props = { + transforms: any, + tfToFollow?: string, + followingOrientation?: boolean, + onFollowChange: (tfId?: string | false, followOrientation?: boolean) => void, +}; + +function* getDescendants(nodes: TfTreeNode[]) { + for (const node of nodes) { + if (node.tf.id !== getGlobalHooks().perPanelHooks().ThreeDimensionalViz.rootTransformFrame) { + yield node; + } + yield* getDescendants(node.children); + } +} + +function getItemText(node: TfTreeNode | { tf: { id: string }, depth: number }) { + return "".padEnd(node.depth * 4) + node.tf.id; +} + +const Container = styled.div` + display: flex; + flex: 1 1 auto; + align-items: center; + position: relative; +`; + +const defaultFollowTfFrame = getGlobalHooks().perPanelHooks().ThreeDimensionalViz.defaultFollowTransformFrame; + +const arePropsEqual = (prevProps, nextProps) => { + if (!nextProps.tfToFollow) { + const tfTree = buildTfTree(nextProps.transforms.values()); + const allNodes = Array.from(getDescendants(tfTree.roots)); + const nodesWithoutDefaultFollowTfFrame = allNodes && allNodes.length && !defaultFollowTfFrame; + if (nodesWithoutDefaultFollowTfFrame) { + return false; + } + } + return shallowequal(prevProps, nextProps); +}; + +const FollowTFControl = memo((props: Props) => { + const { transforms, tfToFollow, followingOrientation, onFollowChange } = props; + const [forceShowFrameList, setForceShowFrameList] = useState(false); + const [hovering, setHovering] = useState(false); + const [lastSelectedFrame, setLastSelectedFrame] = useState(undefined); + + const tfTree = buildTfTree(transforms.values()); + const allNodes = Array.from(getDescendants(tfTree.roots)); + const nodesWithoutDefaultFollowTfFrame = allNodes && allNodes.length && !defaultFollowTfFrame; + const newFollowTfFrame = allNodes && allNodes[0] && allNodes[0].tf && allNodes[0].tf.id; + + const autocomplete = createRef(); + + useEffect(() => { + if (nodesWithoutDefaultFollowTfFrame && !tfToFollow) { + onFollowChange(newFollowTfFrame); + } + }); + + const getDefaultFollowTransformFrame = useCallback( + () => { + return nodesWithoutDefaultFollowTfFrame ? newFollowTfFrame : defaultFollowTfFrame; + }, + [nodesWithoutDefaultFollowTfFrame, newFollowTfFrame, defaultFollowTfFrame] + ); + + const getFollowButtonTooltip = useCallback( + () => { + if (!tfToFollow) { + if (lastSelectedFrame) { + return `Follow ${lastSelectedFrame}`; + } + return `Follow ${getDefaultFollowTransformFrame()}`; + } else if (!followingOrientation) { + return "Follow Orientation"; + } + return "Unfollow"; + }, + [tfToFollow, lastSelectedFrame, followingOrientation] + ); + + const onClickFollowButton = useCallback( + () => { + if (!tfToFollow) { + if (lastSelectedFrame) { + return onFollowChange(lastSelectedFrame); + } + return onFollowChange(getDefaultFollowTransformFrame()); + } else if (!followingOrientation) { + return onFollowChange(tfToFollow, true); + } + return onFollowChange(false); + }, + [tfToFollow, lastSelectedFrame, onFollowChange, getDefaultFollowTransformFrame, followingOrientation] + ); + + const onSelectFrame = useCallback( + (id: string, item: mixed, autocomplete: Autocomplete) => { + setLastSelectedFrame(id === getDefaultFollowTransformFrame() ? undefined : id); + onFollowChange(id, followingOrientation); + autocomplete.blur(); + }, + [setLastSelectedFrame, getDefaultFollowTransformFrame, onFollowChange, followingOrientation] + ); + + const openFrameList = useCallback( + (event: SyntheticEvent) => { + event.preventDefault(); + setForceShowFrameList(true); + if (autocomplete.current) { + autocomplete.current.focus(); + } + }, + [setForceShowFrameList, autocomplete] + ); + + // slight delay to prevent the arrow from disappearing when you're trying to click it + const onMouseLeaveDebounced = useCallback( + debounce(() => { + setHovering(false); + }, 200), + [setHovering] + ); + + const onMouseEnter = useCallback( + () => { + onMouseLeaveDebounced.cancel(); + setHovering(true); + }, + [onMouseLeaveDebounced, setHovering] + ); + + const followingCustomFrame = tfToFollow && tfToFollow !== getDefaultFollowTransformFrame(); + const showFrameList = lastSelectedFrame != null || forceShowFrameList || followingCustomFrame; + const selectedFrameId = tfToFollow || lastSelectedFrame; + const selectedItem = selectedFrameId ? { tf: { id: selectedFrameId }, depth: 0 } : undefined; + + return ( + + {showFrameList && ( + { + setForceShowFrameList(false); + setHovering(false); + }} + /> + )} + {showFrameList ? ( + + + + ) : hovering ? ( + + + + ) : null} + + + ); +}, arePropsEqual); +export default FollowTFControl; diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/LaserScanVert.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/LaserScanVert.js new file mode 100644 index 000000000..34664c3f5 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/LaserScanVert.js @@ -0,0 +1,39 @@ +// @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. +export default ` + precision mediump float; + + uniform mat4 projection, view; + + #WITH_POSE + + uniform float angle_min; + uniform float angle_increment; + uniform float range_min; + uniform float range_max; + + attribute float index; + attribute float range; + attribute float intensity; + + varying vec4 vColor; + + void main () { + float angle = angle_min + index * angle_increment; + vec3 p = applyPose(vec3(range * cos(angle), range * sin(angle), 0)); + + gl_Position = projection * view * vec4(p, 1); + gl_PointSize = 4.; + + if (range < range_min || range > range_max || intensity == 0.0) { + gl_PointSize = 0.; + } else { + vColor = vec4(0.5, 0.5, 1, 1); + } + } +`; diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/Layout.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/Layout.js new file mode 100644 index 000000000..31ba8eb5c --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/Layout.js @@ -0,0 +1,606 @@ +// @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 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 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 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 SceneBuilder, { + type TopicSettingsCollection, + type TopicSettings, +} from "webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder"; +import TopicSelector from "webviz-core/src/panels/ThreeDimensionalViz/TopicSelector"; +import type { Selections } from "webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/treeBuilder"; +import TopicSettingsEditor from "webviz-core/src/panels/ThreeDimensionalViz/TopicSettingsEditor"; +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 colors from "webviz-core/src/styles/colors.module.scss"; +import type { SaveConfig } 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"; + +type Props = { + autoTextBackgroundColor?: boolean, + selections: Selections, + frame?: Frame, + transforms: Transforms, + saveConfig: SaveConfig, + followTf?: string | false, + followOrientation: boolean, + onFollowChange: (followTf?: string | false, followOrientation?: boolean) => void, + onAlignXYAxis: () => void, + topicSettings: TopicSettingsCollection, + topics: Topic[], + checkedNodes: string[], + expandedNodes: string[], + modifiedNamespaceTopics: string[], + extensions: Extensions, + pinTopics: boolean, + cameraState: $Shape, + onCameraStateChange: (CameraState) => void, + showCameraPosition?: ?boolean, + helpContent: React.Node | string, + + children?: React.Node, + mouseClick: ({}) => void, + onMouseUp?: MouseHandler, + onMouseDown?: MouseHandler, + onMouseMove?: MouseHandler, + onDoubleClick?: MouseHandler, + + setSelections: (Selections) => void, + cleared?: boolean, + // redux values + globalData: Object, + currentTime: {| sec: number, nsec: number |}, +}; + +type State = { + sceneBuilder: SceneBuilder, + transformsBuilder: TransformsBuilder, + cachedTopicSettings: TopicSettingsCollection, + editedTopics: string[], + + debug: boolean, + showCameraPosition: boolean, + showTopics: boolean, + metadata: Object, + editTipX: ?number, + editTipY: ?number, + editTopic: ?Topic, + measureInfo: MeasureInfo, +}; + +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: [], + expandedNodes: [], + modifiedNamespaceTopics: [], + topicSettings: {}, + showTopics: false, + pinTopics: false, + }; + + state: State = { + sceneBuilder: new SceneBuilder(), + transformsBuilder: new TransformsBuilder(), + 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 }, + }, + }; + + static getDerivedStateFromProps(nextProps: Props, prevState: State) { + const { frame, cleared, transforms, followTf, selections, topicSettings, currentTime } = nextProps; + const { sceneBuilder, transformsBuilder, cachedTopicSettings } = prevState; + if (!frame) { + return null; + } + + const newState = { ...prevState }; + if (topicSettings !== cachedTopicSettings) { + const nonEmptyTopicSettingsKeys = Object.keys(topicSettings).filter( + (settingKey) => Object.keys(topicSettings[settingKey]).length + ); + newState.editedTopics = (nonEmptyTopicSettingsKeys: string[]); + newState.cachedTopicSettings = topicSettings; + } + + if (cleared) { + sceneBuilder.clear(); + } + const rootTfID = transforms.rootOfTransform( + followTf || getGlobalHooks().perPanelHooks().ThreeDimensionalViz.rootTransformFrame + ).id; + sceneBuilder.setTransforms(transforms, rootTfID); + sceneBuilder.setFlattenMarkers(selections.extensions.includes("Car.flattenMarkers")); + // toggle scene builder namespaces based on selected namespace nodes in the tree + sceneBuilder.setEnabledNamespaces(selections.namespaces); + sceneBuilder.setTopicSettings(topicSettings); + + // toggle scene builder topics based on selected topic nodes in the tree + sceneBuilder.setTopics(selections.topics); + sceneBuilder.setGlobalData(nextProps.globalData); + sceneBuilder.setFrame(frame); + sceneBuilder.setCurrentTime(currentTime); + sceneBuilder.render(); + + // Update the transforms and set the selected ones to render. + transformsBuilder.setTransforms(transforms, rootTfID); + transformsBuilder.setSelectedTransforms(selections.extensions); + + const metadata = getGlobalHooks() + .perPanelHooks() + .ThreeDimensionalViz.getMetadata(frame); + if (metadata) { + newState.metadata = metadata; + } + 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); + } + }; + + 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); + } + }; + + onMouseMove: MouseHandler = (e, args: ?ReglClickInfo) => { + const handler = this.measuringTool && this.measuringTool.canvasMouseMove; + if (handler && args) { + return handler(e, args); + } + const { onMouseMove } = this.props; + if (onMouseMove) { + onMouseMove(e, args); + } + }; + + onDoubleClick: MouseHandler = (e, args: ?ReglClickInfo) => { + const { onDoubleClick } = this.props; + if (onDoubleClick) { + onDoubleClick(e, args); + } + }; + + keyDownHandlers = { + "3": () => { + this.toggleCameraMode(); + }, + }; + + toggleCameraMode = () => { + const { cameraState, saveConfig } = this.props; + const perspective = !cameraState.perspective; + + saveConfig({ cameraState: { ...cameraState, perspective } }); + if (this.measuringTool && perspective) { + this.measuringTool.reset(); + } + }; + + toggleShowTopics = () => { + const { showTopics } = this.state; + this.setState({ showTopics: !showTopics }); + }; + + toggleDebug = () => { + this.setState({ debug: !this.state.debug }); + }; + + // clicking on the body should hide any edit tip + onEditClick = (e: SyntheticMouseEvent, topic: string) => { + const { topics } = this.props; + // if the same icon is clicked again, close the popup + const existingEditTopic = this.state.editTopic ? this.state.editTopic.name : undefined; + if (topic === existingEditTopic) { + return this.setState({ + editTipX: 0, + editTipY: 0, + editTopic: undefined, + }); + } + const { el } = this; + + // satisfy flow + if (!el) { + return; + } + + const panelRect = el.getBoundingClientRect(); + const editBtnRect = e.currentTarget.getBoundingClientRect(); + const editTopic = topics.find((t) => t.name === topic); + if (!editTopic) { + return; + } + this.setState({ + editTipX: editBtnRect.right - panelRect.left + 5, + editTipY: editBtnRect.top + editBtnRect.height / 2, + editTopic, + }); + }; + + onSettingsChange = (settings: TopicSettings) => { + const { saveConfig, topicSettings } = this.props; + const { editTopic } = this.state; + if (!editTopic) { + return; + } + saveConfig({ + topicSettings: { + ...topicSettings, + [editTopic.name]: settings, + }, + }); + }; + + onControlsOverlayClick = (e: SyntheticMouseEvent) => { + // statisfy flow + const { el } = this; + if (!el) { + return; + } + const target = ((e.target: any): HTMLElement); + // don't close if the click target is outside the panel + // e.g. don't close when dropdown menus rendered in portals are clicked + if (!el.contains(target)) { + return; + } + this.setState({ showTopics: false }); + }; + + cancelClick = (e: SyntheticMouseEvent) => { + // stop the event from bubbling up to onControlsOverlayClick + // (but don't preventDefault because checkboxes, buttons, etc. should continue to work) + e.stopPropagation(); + }; + + renderToolbars() { + const { + cameraState, + cameraState: { perspective }, + onCameraStateChange, + transforms, + followTf, + followOrientation, + onAlignXYAxis, + saveConfig, + } = this.props; + const { measureInfo, showCameraPosition } = this.state; + + return ( +
+
+ +
+ + this.setState({ showCameraPosition: expanded })} + onCameraStateChange={onCameraStateChange} + followTf={followTf} + followOrientation={followOrientation} + onAlignXYAxis={onAlignXYAxis} + saveConfig={saveConfig} + /> + {this.measuringTool && this.measuringTool.measureDistance} +
+ ); + } + + render3d() { + const { sceneBuilder, transformsBuilder, debug, metadata } = this.state; + const scene = sceneBuilder.getScene(); + const { + autoTextBackgroundColor, + extensions, + cameraState, + onCameraStateChange, + mouseClick, + children, + selections, + } = this.props; + + const WorldComponent = getGlobalHooks().perPanelHooks().ThreeDimensionalViz.WorldComponent; + + return ( + + {children} + + ); + } + + // 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) { + return; + } + const { target, targetOffset, distance, thetaOffset } = cameraState; + const targetHeading = cameraStateSelectors.targetHeading(cameraState); + + // move the crosshair to the center of the camera's viewport: the target + targetOffset rotated by heading + const crosshairPoint = [0, 0, 0]; + vec3.add(crosshairPoint, vec3.rotateZ(crosshairPoint, targetOffset, [0, 0, 0], -targetHeading), target); + + // orient and size the crosshair so it remains visually fixed in the center + const length = 0.02 * distance; + const orientation = [0, 0, 0, 1]; + const theta = targetHeading + thetaOffset; + + quat.rotateZ(orientation, orientation, -theta); + + const crosshair = (z, extraThickness) => { + const thickness = 0.004 * distance * (1 + extraThickness); + return { + header: { frame_id: getGlobalHooks().rootTransformFrame, stamp: { sec: 0, nsec: 0 } }, + type: 5, + action: 0, + id: "", + ns: "", + pose: { + position: { x: crosshairPoint[0], y: crosshairPoint[1], z }, + orientation: { x: orientation[0], y: orientation[1], z: orientation[2], w: orientation[3] }, + }, + points: [ + { x: -length * (1 + 0.1 * extraThickness), y: 0, z: 0 }, + { x: length * (1 + 0.1 * extraThickness), y: 0, z: 0 }, + { x: 0, y: -length * (1 + 0.1 * extraThickness), z: 0 }, + { x: 0, y: length * (1 + 0.1 * extraThickness), z: 0 }, + ], + scale: { x: thickness, y: thickness, z: thickness }, + }; + }; + + add.lineList({ + ...crosshair(1000, 0.6), + color: { r: 0, g: 0, b: 0, a: 1 }, + }); + + add.lineList({ + ...crosshair(1001, 0), + color: { r: 1, g: 1, b: 1, a: 1 }, + }); + } + + renderTopicSettingsEditor() { + const { topicSettings } = this.props; + const { editTopic, editTipX, editTipY, sceneBuilder } = this.state; + if (!editTopic || !editTipX || !editTipY) { + return null; + } + // satisfy flow + const collector = sceneBuilder.collectors[editTopic.name]; + const message = collector ? collector.getMessages()[0] : undefined; + + // need to place the draggable div into an absolute positioned element + const style = { + position: "absolute", + top: 0, + left: 0, + width: 0, + height: 0, + zIndex: 103, + }; + const bounds = { left: 0, top: 0 }; + // position the popup to the left and down from the topic selector + const defaultPosition = { x: editTipX + 30, y: 40 }; + return ( +
+ +
+ this.setState({ editTopic: undefined })}> + + + +
+
+
+ ); + } + + renderControlsOverlay() { + const { + autoTextBackgroundColor, + checkedNodes, + expandedNodes, + modifiedNamespaceTopics, + pinTopics, + saveConfig, + setSelections, + topics, + transforms, + } = this.props; + + const { showTopics, sceneBuilder, editedTopics } = this.state; + + return ( + + ); + } + + render() { + const { measureState, measurePoints } = this.state.measureInfo; + const cursorType = measureState === "place-start" || measureState === "place-finish" ? "crosshair" : ""; + + return ( +
(this.el = el)} + style={{ cursor: cursorType }} + onClick={this.onControlsOverlayClick}> + (this.measuringTool = el)} + measureState={measureState} + measurePoints={measurePoints} + onMeasureInfoChange={(measureInfo) => this.setState({ measureInfo })} + /> + + +
+ {this.renderToolbars()} + {this.renderControlsOverlay()} + {this.renderTopicSettingsEditor()} +
+
{this.render3d()}
+
+ ); + } +} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/Layout.module.scss b/packages/webviz-core/src/panels/ThreeDimensionalViz/Layout.module.scss new file mode 100644 index 000000000..c72ae031a --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/Layout.module.scss @@ -0,0 +1,138 @@ +$spacing: 15px; +@import "~webviz-core/src/styles/colors.module.scss"; +@import "~webviz-core/src/styles/mixins.module.scss"; + +// container for the entire panel +.container { + display: flex; + flex: 1 1 auto; + position: relative; + width: 100%; + height: 100%; +} + +.world { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; +} + +.topicsContainer { + position: absolute; + top: $spacing; + left: $spacing; + bottom: $spacing; + z-index: 102; + max-width: 60%; +} + +.topicSettingsEditor { + @extend %floating-box; + background-color: $panel-background; + width: 300px; + padding: 20px; + + .closeIcon { + @extend %floating-box-close-icon; + } +} + +.topics { + @extend %floating-box; + max-height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.toolbar { + position: absolute; + top: $spacing; + &.left { + left: $spacing; + } + &.right { + // move the right toolbar below the floating panel controls + top: $spacing + 20; + right: $spacing; + } + padding: 0px; + z-index: 101; + display: flex; + flex-direction: column; + align-items: flex-end; + + // allow mouse events to pass through the empty space in this container element + pointer-events: none; +} + +.buttons { + @extend %floating-box; + display: flex; + flex-direction: column; + padding: 0px; + margin-bottom: 10px; + + button { + background-color: transparent; + border: none; + display: block; + padding-left: 4px; + padding-right: 4px; + } + + span:global(.icon) { + width: 18px; + height: 18px; + font-size: 18px; + display: block; + } +} + +.buttonsActive { + span:global(.icon) { + color: $accent; + } +} + +.filterRow { + height: 36px; + flex-shrink: 0; + border-bottom: 1px solid $divider; + background-color: rgba(255, 255, 255, 0.1); +} + +.errorsBadge { + $size: 10px; + position: absolute; + z-index: 9999; + top: -$size/3; + right: -$size/3; + width: $size; + height: $size; + border-radius: $size; + background-color: $red; +} + +.errors { + color: $red; + padding: 8px; + line-height: 1.2; + li { + margin-left: 1em; + } +} + +.cameraWarning { + margin-top: 0.5em; + font-size: 0.9em; + font-style: italic; + color: $text-muted; + + // don't affect flex parent width + // https://stackoverflow.com/a/25045641/23649 + width: 0; + min-width: 100%; +} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/MarkerMetadata.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/MarkerMetadata.js new file mode 100644 index 000000000..c635667e5 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/MarkerMetadata.js @@ -0,0 +1,47 @@ +// @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 JSONTree from "react-json-tree"; +import styled from "styled-components"; + +import colors from "webviz-core/src/styles/colors.module.scss"; +import type { BaseMarker } from "webviz-core/src/types/Messages"; + +const MarkerWrapper = styled.div` + position: absolute; + background-color: rgb(22, 17, 35); + border: 1px solid rgba(255, 255, 255, 0.77); + bottom: 15px; + right: 15px; + padding: 15px; + border-radius: 4px; + overflow: auto; + max-width: 50%; + max-height: 50%; +`; + +type Props = { + marker: BaseMarker, +}; +function MarkerMetadata({ marker }: Props) { + if (!marker || !marker.customMetadata) { + return null; + } + return ( + + + + ); +} + +export default MarkerMetadata; diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/MeasuringTool.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/MeasuringTool.js new file mode 100644 index 000000000..3436e5b5c --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/MeasuringTool.js @@ -0,0 +1,185 @@ +// @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/PositionControl.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/PositionControl.js new file mode 100644 index 000000000..5b4fd8976 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/PositionControl.js @@ -0,0 +1,142 @@ +// @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 { cameraStateSelectors, type CameraState, type Vec3 } from "regl-worldview"; + +import styles from "webviz-core/src/panels/ThreeDimensionalViz/PositionControl.module.scss"; + +type Props = { + cameraState: ?CameraState, + onCameraStateChange: (CameraState) => void, +}; + +const TEMP_VEC3 = [0, 0, 0]; +const ZERO_VEC3 = Object.freeze([0, 0, 0]); + +// make a best-effort attempt to x and y position out of the input +export function parsePosition(input: string): ?Vec3 { + const parts = input.split(/\s*[,\n{}[\]]+\s*/).filter((part) => part !== ""); + const parseMatch = (val: string) => { + const match = val.match(/-?\d+(\.\d+)?/); + return match ? Number.parseFloat(match[0]) : null; + }; + // allow length 3 to ignore z value + if (parts.length === 2 || parts.length === 3) { + const x = parseMatch(parts[0]); + const y = parseMatch(parts[1]); + if (x != null && y != null) { + return [x, y, 0]; + } + } + return null; +} + +export default class PositionControl extends React.Component { + lastValue: ?string; + _ref = React.createRef(); + + onKeyDown = (event: SyntheticKeyboardEvent) => { + if (event.key === "Enter" || event.key === "Return") { + event.stopPropagation(); + event.preventDefault(); + event.currentTarget.blur(); + } + }; + + onInput = () => { + if (this._ref.current) { + this.lastValue = this._ref.current.innerText; + } + }; + + onFocus = () => { + const { current: el } = this._ref; + if (el) { + const range = document.createRange(); + range.selectNodeContents(el); + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + } + }; + + onBlur = () => { + window.getSelection().removeAllRanges(); + + const { cameraState } = this.props; + if (!cameraState) { + return; + } + if (!this.lastValue) { + return; + } + + const newPos = parsePosition(this.lastValue); + if (newPos) { + const { target, targetOffset } = cameraState; + const targetHeading = cameraStateSelectors.targetHeading(cameraState); + // 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)) { + this.props.onCameraStateChange({ ...cameraState, targetOffset: newTargetOffset }); + return; + } + } + + // if we didn't actually change the camera position, reset manually since we won't be getting new props + this.resetValue(); + }; + + componentDidMount() { + this.resetValue(); + } + + componentDidUpdate() { + this.resetValue(); + } + + resetValue() { + const { current: el } = this._ref; + if (!el) { + return; + } + const { cameraState } = this.props; + if (!cameraState) { + return; + } + + // show camera center position for now + // TODO(jacob): maybe UI to switch between car, camera, and mouse position? + const { target, targetOffset } = cameraState; + const targetHeading = cameraStateSelectors.targetHeading(cameraState); + + const [x, y] = vec3.add(TEMP_VEC3, target, vec3.rotateZ(TEMP_VEC3, targetOffset, ZERO_VEC3, -targetHeading)); + + this.lastValue = null; + el.innerHTML = + `x: ${x}
` + + `y: ${y}`; + } + + render() { + return ( +
+ ); + } +} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/PositionControl.module.scss b/packages/webviz-core/src/panels/ThreeDimensionalViz/PositionControl.module.scss new file mode 100644 index 000000000..2c54fa74c --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/PositionControl.module.scss @@ -0,0 +1,21 @@ +@import "~webviz-core/src/styles/mixins.module.scss"; + +.inputField { + @include monospace; + padding: $control-padding; + border-radius: 4px; + outline: none; + line-height: 1.3; + min-width: 180px; + + &:not(:focus) b { + color: $text-normal; + } + &:not(:focus) .value { + color: $highlight; + } + &:focus { + color: black; + background-color: transparentize($text-normal, 0.2); + } +} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/PositionControl.test.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/PositionControl.test.js new file mode 100644 index 000000000..f57f88947 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/PositionControl.test.js @@ -0,0 +1,32 @@ +// @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 { parsePosition } from "./PositionControl"; + +describe("parsePosition", () => { + it("parses numbers correctly", () => { + expect(parsePosition("0\n0")).toEqual([0, 0, 0]); + expect(parsePosition("0\n1")).toEqual([0, 1, 0]); + expect(parsePosition("1111.111\n2222.222")).toEqual([1111.111, 2222.222, 0]); + expect(parsePosition("-1111.111\n-2222.222")).toEqual([-1111.111, -2222.222, 0]); + }); + it("parses arrays", () => { + expect(parsePosition("[-1.1,-2.1]")).toEqual([-1.1, -2.1, 0]); + expect(parsePosition(" [ -1 , -0 ] ")).toEqual([-1, -0, 0]); + }); + it("parses objects", () => { + expect(parsePosition("{x:1,y:2}")).toEqual([1, 2, 0]); + expect( + parsePosition(`{ + x: 1, + y: 2, + z: 3, + }`) + ).toEqual([1, 2, 0]); + }); +}); diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/MessageCollector.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/MessageCollector.js new file mode 100644 index 000000000..32bfd1684 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/MessageCollector.js @@ -0,0 +1,148 @@ +// @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 { TimeUtil, type Time } from "rosbag"; + +import type { BaseMarker, StampedMessage } from "webviz-core/src/types/Messages"; + +const ZERO_TIME = { sec: 0, nsec: 0 }; + +class MessageWithLifetime { + message: StampedMessage; + receiveTime: Time; + + constructor(message: StampedMessage, receiveTime: Time) { + this.message = message; + this.receiveTime = receiveTime; + } + + // support in place update w/ mutation to avoid allocating + // a MarkerWithLifetime wrapper for every marker on every tick + // only allocate on new markers + update(message: StampedMessage, receiveTime: Time) { + this.message = message; + this.receiveTime = receiveTime; + } + + isExpired(currentTime: Time) { + // cannot tell if a marker is expired if we don't have a clock yet + if (!currentTime) { + return false; + } + const lifetime = this.getLifetime(); + + // we use the receive time (clock) instead of the header stamp + // to match the behavior of rviz + const expiresAt = TimeUtil.add(this.receiveTime, lifetime); + + return TimeUtil.isGreaterThan(currentTime, expiresAt); + } + + getLifetime() { + const marker = ((this.message: any): BaseMarker); + return marker.lifetime || ZERO_TIME; + } + + hasLifetime() { + return !TimeUtil.areSame(this.getLifetime(), ZERO_TIME); + } +} + +// used to collect marker and non-marker visualization messages +// for a given topic and ensure the lifecycle is managed properly +export default class MessageCollector { + markers: Map = new Map(); + clock: Time = { sec: 0, nsec: 0 }; + + setClock(clock: Time) { + if (!clock) { + return; + } + + const clockMovedBackwards = TimeUtil.isGreaterThan(this.clock, clock); + + if (clockMovedBackwards) { + this.markers.forEach((marker, key) => { + const markerReceivedAfterClock = TimeUtil.isGreaterThan(marker.receiveTime, clock); + if (markerReceivedAfterClock) { + this.markers.delete(key); + } + }); + } + this.clock = clock; + } + + flush() { + // clear out all 0 lifetime markers + this.markers.forEach((marker, key) => { + if (!marker.hasLifetime()) { + this.markers.delete(key); + } + }); + } + + _addItem(key: string, item: any): void { + const existing = this.markers.get(key); + if (existing) { + existing.update(item, this.clock); + } else { + this.markers.set(key, new MessageWithLifetime(item, this.clock)); + } + } + + addMarker(topic: string, marker: BaseMarker) { + const { name } = marker; + if (!name) { + return console.error("Cannot add marker, it is missing name", marker); + } + this._addItem(name, marker); + } + + deleteMarker(name: string) { + if (!name) { + return console.error("Cannot delete marker, it is missing name"); + } + this.markers.delete(name); + } + + deleteAll() { + this.markers.clear(); + } + + addMessage(topic: string, message: any) { + if (message.lifetime) { + // Assuming that all future messages will have a decay time set, + // we need to immediately expire any pre-existing message that didn't have a decay time. + this.markers.delete(topic); + + // Note: messages with same timestamp will override each other, but this is probably very uncommon + const key = `${topic}/${this.clock.sec}/${this.clock.nsec}`; + this._addItem(key, message); + } else { + // if future messages will not have a decay time set, + // we should expire any pre-existing message that have potentially longer decay times. + for (const key of this.markers.keys()) { + if (key.indexOf(`${topic}/`) === 0) { + this.markers.delete(key); + } + } + this._addItem(topic, message); + } + } + + getMessages(): any[] { + const result = []; + this.markers.forEach((marker, key) => { + if (marker.hasLifetime() && marker.isExpired(this.clock)) { + this.markers.delete(key); + } else { + result.push(marker.message); + } + }); + return result; + } +} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/MessageCollector.test.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/MessageCollector.test.js new file mode 100644 index 000000000..51c8582c4 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/MessageCollector.test.js @@ -0,0 +1,146 @@ +// @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 MessageCollector from "./MessageCollector"; +import type { Marker } from "webviz-core/src/types/Messages"; + +const makeMarker = (namespace: string, id: string): Marker => { + const result = { + header: { + stamp: { sec: 100, nsec: 100 }, + frame_id: "foo", + }, + type: 1, + id, + action: 0, + ns: namespace, + name: `${namespace}/${id}`, + lifetime: { sec: 0, nsec: 0 }, + pose: { + position: { x: 0, y: 0, z: 0 }, + orientation: { x: 0, y: 0, z: 0, w: 0 }, + }, + color: { r: 0, g: 0, b: 0, a: 0 }, + scale: { x: 0, y: 0, z: 0 }, + }; + return result; +}; + +describe("MessageCollector", () => { + it("returns an empty array on initialization", () => { + const collector = new MessageCollector(); + expect(collector.getMessages()).toHaveLength(0); + }); + + it("returns all collected messages", () => { + const collector = new MessageCollector(); + const marker = makeMarker("ns", "foo"); + collector.addMarker("/topic", marker); + expect(collector.getMessages()).toHaveLength(1); + expect(collector.getMessages()).toEqual([marker]); + collector.addMessage("/topic", { name: "baz", foo: "bar" }); + expect(collector.getMessages()).toHaveLength(2); + }); + + it("keeps reference to marker around forever if it does not have an expire time", () => { + const collector = new MessageCollector(); + const marker = makeMarker("ns", "foo"); + collector.setClock({ sec: 100, nsec: 100 }); + collector.addMarker("/topic", marker); + expect(collector.getMessages()).toHaveLength(1); + collector.setClock({ sec: 100, nsec: 200 }); + expect(collector.getMessages()).toHaveLength(1); + collector.setClock({ sec: 100000, nsec: 200 }); + expect(collector.getMessages()).toHaveLength(1); + }); + + it("flushes all messages and non-lifetime markers", () => { + const collector = new MessageCollector(); + const marker = makeMarker("ns", "foo"); + const lifetimeMarker = makeMarker("", "baz"); + lifetimeMarker.lifetime = { sec: 0, nsec: 1 }; + collector.setClock({ sec: 100, nsec: 100 }); + collector.addMarker("/topic", marker); + collector.addMarker("/topic2", lifetimeMarker); + collector.addMessage("/bar", { baz: true }); + expect(collector.getMessages()).toHaveLength(3); + collector.flush(); + expect(collector.getMessages()).toEqual([lifetimeMarker]); + }); + + it("expires marker if lifetime is exceeded", () => { + const collector = new MessageCollector(); + const marker = makeMarker("ns", "foo"); + marker.header.stamp = { sec: 100, nsec: 90 }; + const lifetimeNanos = 5000000; + marker.lifetime = { sec: 0, nsec: lifetimeNanos }; + + collector.setClock({ sec: 100, nsec: 100 }); + collector.addMarker("/topic", marker); + expect(collector.getMessages()).toHaveLength(1); + + collector.setClock({ sec: 100, nsec: 100 + lifetimeNanos }); + expect(collector.getMessages()).toHaveLength(1); + + collector.setClock({ sec: 100, nsec: 100 + lifetimeNanos + 1 }); + expect(collector.getMessages()).toHaveLength(0); + + collector.addMarker("/topic", marker); + collector.setClock({ sec: 10000000, nsec: 100 }); + expect(collector.getMessages()).toHaveLength(0); + }); + + it("flushes existing messages w/o lifetime when decayTime in Topic Settings starts coming in, expires them accordingly", () => { + const collector = new MessageCollector(); + collector.setClock({ sec: 100, nsec: 10 }); + collector.addMessage("/topic", { name: "foo", foo: "bar" }); + expect(collector.getMessages()).toHaveLength(1); + + collector.setClock({ sec: 100, nsec: 30 }); + collector.addMessage("/topic", { name: "foo", foo: "baz", lifetime: { sec: 100, nsec: 10 } }); + expect(collector.getMessages()).toHaveLength(1); + + const fooBatMsg = { name: "foo", foo: "bat", lifetime: { sec: 100, nsec: 20 } }; + collector.setClock({ sec: 100, nsec: 31 }); + collector.addMessage("/topic", fooBatMsg); + expect(collector.getMessages()).toHaveLength(2); + + collector.setClock({ sec: 200, nsec: 41 }); + expect(collector.getMessages()).toHaveLength(1); + expect(collector.getMessages()[0]).toEqual(fooBatMsg); + + collector.setClock({ sec: 200, nsec: 52 }); + expect(collector.getMessages()).toHaveLength(0); + }); + + it("expires potential existing messages with decay times when decay time is reset to 0", () => { + const collector = new MessageCollector(); + collector.setClock({ sec: 100, nsec: 10 }); + collector.addMessage("/topic", { name: "foo", foo: "bar", lifetime: { sec: 100, nsec: 15 } }); + expect(collector.getMessages()).toHaveLength(1); + + collector.setClock({ sec: 100, nsec: 20 }); + collector.addMessage("/topic", { name: "foo", foo: "baz", lifetime: { sec: 100, nsec: 20 } }); + expect(collector.getMessages()).toHaveLength(2); + + const fooBatMessage = { name: "foo", foo: "bat" }; + collector.setClock({ sec: 100, nsec: 30 }); + collector.addMessage("/topic", fooBatMessage); + expect(collector.getMessages()).toHaveLength(1); + expect(collector.getMessages()[0]).toEqual(fooBatMessage); + }); + + it("overwrites non-marker messages based on name", () => { + const collector = new MessageCollector(); + const message = { name: "foo" }; + collector.addMessage("/foo", message); + expect(collector.getMessages()).toEqual([{ name: "foo" }]); + collector.addMessage("/foo", message); + expect(collector.getMessages()).toEqual([{ name: "foo" }]); + }); +}); diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/SceneBuilder.occupancyMovieSet.test.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/SceneBuilder.occupancyMovieSet.test.js new file mode 100644 index 000000000..ab5b61cbe --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/SceneBuilder.occupancyMovieSet.test.js @@ -0,0 +1,113 @@ +// @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 SceneBuilder from "webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder"; + +describe("SceneBuilder", () => { + describe("OccupancyMovieSet", () => { + it("on setFrame, modified topics rendered", () => { + const builder = new SceneBuilder(); + builder.setTopics(["a"]); + + builder.setFrame({ a: [] }); + + expect(builder.topicsToRender).toContain("a"); + }); + + it("on setFrame, only specified topics rendered", () => { + const builder = new SceneBuilder(); + builder.setTopics(["a"]); + + builder.setFrame({ b: [] }); + + expect(builder.topicsToRender.size).toBe(0); + }); + + it("on setFrame, same instance, nothing rendered", () => { + const builder = new SceneBuilder(); + builder.setTopics(["a"]); + const frame = { a: [] }; + builder.setFrame(frame); + // check that we're set up properly with one topic rendered + expect(builder.topicsToRender.size).toBe(1); + builder.render(); + + builder.setFrame(frame); + + expect(builder.topicsToRender.size).toBe(0); + }); + + it("on setFrame, same value different instance, topics rendered", () => { + const builder = new SceneBuilder(); + builder.setTopics(["a"]); + const frame1 = { a: [] }; + const frame2 = { a: [] }; + builder.setFrame(frame1); + builder.render(); + + builder.setFrame(frame2); + + expect(builder.topicsToRender.size).toBe(1); + }); + + it("on setFrame, latest value saved", () => { + const builder = new SceneBuilder(); + builder.setTopics(["a"]); + const messages1 = []; + const messages2 = []; + builder.setFrame({ a: messages1 }); + builder.setFrame({ a: messages2 }); + + expect(builder.lastSeenMessages.a).not.toBe(messages1); + expect(builder.lastSeenMessages.a).toBe(messages2); + }); + + it("on setFrame, messages are saved", () => { + const builder = new SceneBuilder(); + const messagesValue = []; + builder.setTopics(["a"]); + + builder.setFrame({ a: messagesValue }); + + expect(builder.lastSeenMessages.a).toBe(messagesValue); + }); + + it("on setFrame, old messages not clobbered", () => { + const builder = new SceneBuilder(); + const messagesValue = []; + builder.setTopics(["a", "b"]); + builder.setFrame({ a: messagesValue }); + + builder.setFrame({ b: messagesValue }); + + // a survives even though it's only included in the first setFrame + expect(builder.lastSeenMessages.a).toBe(messagesValue); + }); + + it("on setFrame, unrendered messages saved", () => { + const builder = new SceneBuilder(); + const messagesValue = []; + builder.setTopics(["a"]); + + builder.setFrame({ b: messagesValue }); + + expect("b" in builder.lastSeenMessages).toBe(true); + }); + + it("on render, topics to render cleared", () => { + const builder = new SceneBuilder(); + builder.setTopics(["a"]); + builder.setFrame({ a: [] }); + // to make sure we're set up right, check that one topic should be rendered + expect(builder.topicsToRender.size).toBe(1); + + builder.render(); + + expect(builder.topicsToRender.size).toBe(0); + }); + }); +}); diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/index.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/index.js new file mode 100644 index 000000000..4902c602f --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/index.js @@ -0,0 +1,517 @@ +// @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 { some } from "lodash"; +import type { Time } from "rosbag"; + +import { getGlobalHooks } from "webviz-core/src/loadWebviz"; +import MessageCollector from "webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/MessageCollector"; +import Transforms from "webviz-core/src/panels/ThreeDimensionalViz/Transforms"; +import type { Marker, OccupancyGridMessage, Pose } from "webviz-core/src/types/Messages"; +import type { Frame, Namespace, Message } from "webviz-core/src/types/players"; +import type { MarkerProvider, MarkerCollector, Scene } from "webviz-core/src/types/Scene"; +import Bounds from "webviz-core/src/util/Bounds"; +import { POINT_CLOUD_DATATYPE, POSE_STAMPED_DATATYPE } from "webviz-core/src/util/globalConstants"; +import { emptyPose } from "webviz-core/src/util/Pose"; +import { fromSec } from "webviz-core/src/util/time"; + +export type TopicSettings = { + colorField?: ?string, + pointSize?: ?number, + pointShape?: ?string, + color?: ?string, + useCarModel?: boolean, + alpha?: number, + decayTime?: number, +}; + +export type TopicSettingsCollection = { + [topic: string]: TopicSettings, +}; + +// builds a syntehtic arrow marker from a geometry_msgs/PoseStamped +// these pose sizes were manually configured in rviz; for now we hard-code them here +export function buildSyntheticArrowMarker(msg: any, flattenedZHeightPose: ?Pose) { + msg.message.pose = getGlobalHooks() + .perPanelHooks() + .ThreeDimensionalViz.getMessagePose(msg, flattenedZHeightPose); + return { + type: 103, + header: msg.message.header, + pose: msg.message.pose, + size: { + shaftLength: 3.5, + shaftWidth: 2, + headLength: 1, + headWidth: 2, + }, + scale: { x: 1, y: 1, z: 1 }, + color: getGlobalHooks() + .perPanelHooks() + .ThreeDimensionalViz.getSyntheticArrowMarkerColor(msg.topic), + }; +} + +export type ErrorDetails = {| frameIds: Set, namespaces: Set |}; + +export type SceneErrors = { + topicsMissingFrameIds: Map, + topicsMissingTransforms: Map, + topicsWithError: Map, + rootTransformID: string, +}; + +// constructs a scene containing all objects to be rendered +// by consuming visualization topics from frames +export default class SceneBuilder implements MarkerProvider { + topics: string[] = []; + markers: Marker[] = []; + transforms: Transforms; + rootTransformID: string; + selectionState: Object = {}; + frame: Frame; + errors: SceneErrors = { + rootTransformID: "", + topicsMissingFrameIds: new Map(), + topicsMissingTransforms: new Map(), + topicsWithError: new Map(), + }; + maps = []; + flattenedZHeightPose: ?Pose = null; + scene = {}; + collectors: { [string]: MessageCollector } = {}; + _clock: Time; + _topicSettings: TopicSettingsCollection = {}; + + allNamespaces: Namespace[] = []; + enabledNamespaces: Namespace[] = []; + flatten: boolean = false; + bounds: Bounds = new Bounds(); + + // list of topics that need to be rerendered because the frame has new values + // or because a prop affecting its rendering was changed + topicsToRender: Set = new Set(); + + // stored message arrays allowing used to re-render topics even when the latest + // frame does not not contain that topic + lastSeenMessages: { [string]: Message[] } = {}; + + setTransforms = (transforms: Transforms, rootTransformID: string) => { + this.transforms = transforms; + this.rootTransformID = rootTransformID; + this.errors.rootTransformID = rootTransformID; + }; + + clear() { + for (const topic of this.topics) { + const collector = this.collectors[topic]; + if (collector) { + collector.flush(); + } + } + } + + setTopicSettings(settings: TopicSettingsCollection) { + this._topicSettings = settings; + } + + // set the topics the scene builder should consume from each frame + setTopics(topics: string[]) { + this.topics = topics; + // IMPORTANT: when topics change, we also need to reset the frame so that + // setFrame gets called correctly to set the topicsToRender and lastSeenMessages + this.frame = {}; + // TODO(bmc): delete message collectors we don't need anymore + } + + setFrame(frame: Frame) { + if (this.frame === frame) { + return; + } + this.frame = frame; + for (const topic of this.topics) { + if (topic in frame) { + this.topicsToRender.add(topic); + } + } + + // Note we save even topics that are not rendered since they may be used by non-rendered topics + Object.assign(this.lastSeenMessages, frame); + } + + setFlattenMarkers(flatten: boolean): void { + this.flatten = flatten; + } + + setEnabledNamespaces(namespaces: Namespace[]) { + this.enabledNamespaces = namespaces; + } + + setGlobalData = (globalData: Object = {}) => { + const { selectionState, topicsToRender } = getGlobalHooks() + .perPanelHooks() + .ThreeDimensionalViz.setGlobalDataInSceneBuilder(globalData, this.selectionState, this.topicsToRender); + this.selectionState = selectionState; + this.topicsToRender = topicsToRender; + }; + + hasErrors() { + const { topicsMissingFrameIds, topicsMissingTransforms, topicsWithError } = this.errors; + return topicsMissingFrameIds.size !== 0 || topicsMissingTransforms.size !== 0 || topicsWithError.size !== 0; + } + + _addError(map: Map, topic: string): ErrorDetails { + let values = map.get(topic); + if (!values) { + values = { namespaces: new Set(), frameIds: new Set() }; + map.set(topic, values); + } + return values; + } + + // keep a unique set of all seen namespaces + _consumeNamespace(topic: string, name: string) { + if (some(this.allNamespaces, (ns) => ns.topic === topic && ns.name === name)) { + return; + } + this.allNamespaces = this.allNamespaces.concat([{ topic, name }]); + } + + // Only public for tests. + namespaceIsEnabled(topic: string, name: string) { + return some(this.enabledNamespaces, (ns) => ns.topic === topic && ns.name === name); + } + + _transformAndCloneMarker = (topic: string, marker: Marker) => { + const { frame_id } = marker.header; + + if (!frame_id) { + const error = this._addError(this.errors.topicsMissingFrameIds, topic); + error.namespaces.add(marker.ns); + return null; + } + + const sourcePose = marker.pose; + const pose = this.transforms.apply(emptyPose(), sourcePose, frame_id, this.rootTransformID); + if (!pose) { + const error = this._addError(this.errors.topicsMissingTransforms, topic); + error.namespaces.add(marker.ns); + error.frameIds.add(frame_id); + return null; + } + + return { + ...marker, + pose, + }; + }; + + _consumeMarkerArray = (topic: string, message: any): void => { + for (let i = 0; i < message.markers.length; i++) { + this._consumeMarker(topic, message.markers[i]); + } + }; + + _consumeMarker(topic: string, message: Marker): void { + if (message.ns) { + this._consumeNamespace(topic, message.ns); + } + + // Every marker needs a name property as a unique id. In each topic, the namespace (`ns`) and + // identifier (`id`) uniquely identify the marker. + // See https://github.com/ros-visualization/rviz/blob/4b6c0f4/src/rviz/default_plugin/markers/marker_base.h#L56 + // and https://github.com/ros-visualization/rviz/blob/4b6c0f4/src/rviz/default_plugin/marker_display.cpp#L422 + const name = `${topic}/${message.ns}/${message.id}`; + switch (message.action) { + case 0: // add + break; + case 1: // deprecated in ros + this.errors.topicsWithError.set(topic, "Marker.action=1 is deprecated"); + return; + case 2: // delete + this.collectors[topic].deleteMarker(name); + return; + case 3: + this.collectors[topic].deleteAll(); + return; + default: + this.errors.topicsWithError.set(topic, `Unsupported action type: ${message.action}`); + return; + } + + const marker = this._transformAndCloneMarker(topic, message); + if (!marker) { + return; + } + + marker.name = name; + const { points } = (marker: any); + + let minZ = Number.MAX_SAFE_INTEGER; + + // if the marker has points, adjust bounds by the points + if (points && points.length) { + points.forEach((point) => { + const x = point.x + marker.pose.position.x; + const y = point.y + marker.pose.position.y; + const z = point.z + marker.pose.position.z; + minZ = Math.min(minZ, point.z); + this.bounds.update({ x, y, z }); + }); + } else { + // otherwise just adjust by the pose + minZ = Math.min(minZ, marker.pose.position.z); + this.bounds.update(marker.pose.position); + } + + // if the minimum z value of any point (or the pose) is exactly 0 + // then assume this marker can be flattened + if (minZ === 0 && this.flatten && this.flattenedZHeightPose) { + marker.pose.position.z = this.flattenedZHeightPose.position.z; + } + + // HACK(jacob): rather than hard-coding this, we should + // (a) produce this visualization dynamically from a non-marker topic + // (b) fix translucency so it looks correct (harder) + marker.color = getGlobalHooks() + .perPanelHooks() + .ThreeDimensionalViz.getMarkerColor(topic, marker.color); + this.collectors[topic].addMarker(topic, marker); + } + + _consumeOccupancyGrid = (topic: string, message: OccupancyGridMessage): void => { + const { frame_id } = message.header; + + if (!frame_id) { + this._addError(this.errors.topicsMissingFrameIds, topic); + return; + } + + let pose = emptyPose(); + pose = this.transforms.apply(pose, pose, frame_id, this.rootTransformID); + if (!pose) { + const error = this._addError(this.errors.topicsMissingTransforms, topic); + error.frameIds.add(frame_id); + return; + } + + const type = 101; + // every marker needs a name property as a unique id + const name = `${topic}/${message.type}`; + + // set ogrid texture & alpha based on current rviz settings + // in the future these will be customizable via the UI + const [alpha, map] = getGlobalHooks() + .perPanelHooks() + .ThreeDimensionalViz.getOccupancyGridValues(topic); + + const mappedMessage = { + ...message, + alpha, + map, + type, + name, + pose, + }; + + // if we neeed to flatten the ogrid clone the position and change the z to match the flattenedZHeightPose + if (mappedMessage.info.origin.position.z === 0 && this.flattenedZHeightPose && this.flatten) { + mappedMessage.info.origin.position = { + ...mappedMessage.info.origin.position, + z: this.flattenedZHeightPose.position.z, + }; + } + this.collectors[topic].addMessage(topic, mappedMessage); + }; + + _consumeNonMarkerMessage = (topic: string, message: any, type: number): void => { + const sourcePose = emptyPose(); + const pose = this.transforms.apply(sourcePose, sourcePose, message.header.frame_id, this.rootTransformID); + if (!pose) { + const error = this._addError(this.errors.topicsMissingTransforms, topic); + error.frameIds.add(message.header.frame_id); + return; + } + + const decayTimeInSec = (this._topicSettings[topic] && this._topicSettings[topic].decayTime) || 0; + const mappedMessage = { + ...message, + name: `${topic}/${message.type}`, + type, + pose, + lifetime: fromSec(decayTimeInSec), + }; + + this.collectors[topic].addMessage(topic, mappedMessage); + }; + + setCurrentTime = (currentTime: { sec: number, nsec: number }) => { + this.bounds.reset(); + + this._clock = currentTime; + // set the new clock value in all existing collectors + // including those for topics not included in this frame, + // so each can expire markers if they need to + for (const key in this.collectors) { + const collector = this.collectors[key]; + collector.setClock(this._clock); + } + }; + + // extracts renderable markers from the ros frame + render() { + this.flattenedZHeightPose = + getGlobalHooks() + .perPanelHooks() + .ThreeDimensionalViz.getFlattenedPose(this.frame) || this.flattenedZHeightPose; + + if (this.flattenedZHeightPose && this.flattenedZHeightPose.position) { + this.bounds.update(this.flattenedZHeightPose.position); + } + for (const topic of this.topicsToRender) { + try { + this._consumeTopic(topic); + } catch (error) { + this.errors.topicsWithError.set(topic, error.toString()); + } + } + this.topicsToRender.clear(); + } + + _consumeMessage = (topic: string, msg: any): void => { + const { message } = msg; + + switch (msg.datatype) { + case "visualization_msgs/Marker": + this._consumeMarker(topic, message); + break; + case "visualization_msgs/MarkerArray": + this._consumeMarkerArray(topic, message); + break; + case POSE_STAMPED_DATATYPE: + // make synthetic arrow marker from the stamped pose + this.collectors[topic].addMessage(topic, buildSyntheticArrowMarker(msg, this.flattenedZHeightPose)); + break; + case "nav_msgs/OccupancyGrid": + // flatten btn: set empty z values to be at the same level as the flattenedZHeightPose + this._consumeOccupancyGrid(topic, message); + break; + case POINT_CLOUD_DATATYPE: + this._consumeNonMarkerMessage(topic, message, 102); + break; + case "sensor_msgs/LaserScan": + this._consumeNonMarkerMessage(topic, message, 104); + break; + case "geometry_msgs/PolygonStamped": { + // convert Polygon to a line strip + const { polygon } = message; + if (polygon.points.length === 0) { + break; + } + const newMessage = { + ...message, + points: polygon.points, + closed: true, + scale: { x: 0.2 }, + color: { r: 0, g: 1, b: 0, a: 1 }, + }; + this._consumeNonMarkerMessage(topic, newMessage, 4 /* line strip */); + break; + } + default: { + const { flattenedZHeightPose, collectors, errors, lastSeenMessages, selectionState } = this; + getGlobalHooks() + .perPanelHooks() + .ThreeDimensionalViz.consumeMessage( + topic, + msg, + { + consumeMarkerArray: this._consumeMarkerArray, + consumeNonMarkerMessage: this._consumeNonMarkerMessage, + }, + { flattenedZHeightPose, collectors, errors, lastSeenMessages, selectionState } + ); + } + } + }; + + _consumeTopic = (topic: string) => { + const messages = this.frame[topic] || this.lastSeenMessages[topic]; + if (!messages) { + return; + } + + this.errors.topicsMissingFrameIds.delete(topic); + this.errors.topicsMissingTransforms.delete(topic); + this.errors.topicsWithError.delete(topic); + this.collectors[topic] = this.collectors[topic] || new MessageCollector(); + this.collectors[topic].setClock(this._clock); + this.collectors[topic].flush(); + + for (let i = 0; i < messages.length; i++) { + this._consumeMessage(topic, messages[i]); + } + }; + + getScene(): Scene { + return { + bounds: this.bounds, + flattenedZHeightPose: this.flattenedZHeightPose, + }; + } + + renderMarkers(add: MarkerCollector) { + for (const topic of this.topics) { + const collector = this.collectors[topic]; + if (!collector) { + continue; + } + const topicMarkers = collector.getMessages(); + for (const marker of topicMarkers) { + if (marker.ns) { + if (!this.namespaceIsEnabled(topic, marker.ns)) { + continue; + } + } + // TODO(bmc): once we support more topic settings + // flesh this out to be more marker type agnostic + const settings = this._topicSettings[topic]; + if (settings) { + marker.settings = settings; + } + this._addMarkerToCollector(add, topic, marker); + } + } + } + + _addMarkerToCollector(add: MarkerCollector, topic: string, marker: any) { + // prettier-ignore + switch (marker.type) { + case 0: return add.arrow(marker); + case 1: return add.cube(marker); + case 2: return add.sphere(marker); + case 3: return add.cylinder(marker); + case 4: return add.lineStrip(marker); + case 5: return add.lineList(marker); + case 6: return add.cubeList(marker); + case 7: return add.sphereList(marker); + case 8: return add.points(marker); + case 9: return add.text(marker); + // mesh resource not supported + case 11: return add.triangleList(marker); + case 101: return add.grid(marker); + case 102: return add.pointcloud(marker); + case 104: return add.laserScan(marker); + case 107: return add.filledPolygon(marker); + default: { + getGlobalHooks() + .perPanelHooks() + .ThreeDimensionalViz.addMarkerToCollector(add, topic, marker, this.errors); + } + } + } +} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/index.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/index.js new file mode 100644 index 000000000..7cf0c5c1b --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/index.js @@ -0,0 +1,334 @@ +// @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 LayersIcon from "@mdi/svg/svg/layers.svg"; +import MagnifyIcon from "@mdi/svg/svg/magnify.svg"; +import cx from "classnames"; +import { debounce } from "lodash"; +import * as React from "react"; + +import styles from "../Layout.module.scss"; +import type { SceneErrors, ErrorDetails } from "../SceneBuilder"; +import treeBuilder, { TopicTreeNode, Selections, getId } from "./treeBuilder"; +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 Tree, { type Node } from "webviz-core/src/components/Tree"; +import TopicSelectorMenu from "webviz-core/src/panels/ThreeDimensionalViz/TopicSelectorMenu"; +import type { Transform } from "webviz-core/src/panels/ThreeDimensionalViz/Transforms"; +import type { SaveConfig } from "webviz-core/src/types/panels"; +import type { Namespace, Topic } from "webviz-core/src/types/players"; +import toggle from "webviz-core/src/util/toggle"; + +import type { ThreeDimensionalVizConfig } from ".."; + +type Props = {| + autoTextBackgroundColor: boolean, + sceneErrors: SceneErrors, + namespaces: Namespace[], + + // config and props forwarded from the panel + topics: Topic[], + checkedNodes: string[], + expandedNodes: string[], + modifiedNamespaceTopics: string[], + pinTopics: boolean, + transforms: Array, + editedTopics: string[], + onToggleShowClick: () => void, + onEditClick: (e: SyntheticMouseEvent, topic: string) => void, + + setSelections: (Selections) => void, + + saveConfig: SaveConfig, + showTopics: boolean, +|}; + +type State = {| + filterText: ?string, + tree: TopicTreeNode, + cachedProps: Props, +|}; + +function renderErrorSection(description: string, values: Map) { + if (values.size === 0) { + return null; + } + const items = []; + values.forEach((value, topic) => { + const details = [ + listToString(value.frameIds.size === 1 ? "frame" : "frames", value.frameIds), + listToString(value.namespaces.size === 1 ? "namespace" : "namespaces", value.namespaces), + ].filter(Boolean); + if (details.length > 0) { + items.push(`${topic} (${details.join("; ")})`); + } else { + items.push(topic); + } + }); + return ( +
+ {`${values.size} topic${values.size === 1 ? "" : "s"} ${description}`}: +
    + {items.map((name) => ( +
  • {name}
  • + ))} +
+
+ ); +} + +function listToString(kind: string, data: Iterable) { + const items = Array.from(data).filter(Boolean); + if (items.length === 0) { + return null; + } + return `${kind}: ${items.sort().join(", ")}`; +} + +export default class TopicSelector extends React.Component { + topicList: ?Element; + filterTextField: ?HTMLInputElement; + + static defaultProps = { + editedTopics: [], + }; + + // $FlowFixMe, for sure we'll get the state from getDerivedStateFromProps + state: State = TopicSelector.getDerivedStateFromProps(this.props); + + static getDerivedStateFromProps(nextProps: Props, prevState?: State) { + const { + topics, + namespaces, + checkedNodes, + expandedNodes, + modifiedNamespaceTopics, + transforms, + editedTopics, + } = nextProps; + + // building the tree is kind of expensive + // so only do it at initialization or when the data changes + const needsNewTree = + !prevState || + prevState.cachedProps.namespaces !== namespaces || + prevState.cachedProps.topics !== topics || + prevState.cachedProps.expandedNodes !== expandedNodes || + prevState.cachedProps.checkedNodes !== checkedNodes || + prevState.cachedProps.editedTopics !== editedTopics || + prevState.cachedProps.transforms.length !== transforms.length; + + if (needsNewTree) { + const filterText = (prevState && prevState.filterText) || undefined; + const tree = treeBuilder({ + topics, + namespaces, + checkedNodes, + expandedNodes, + modifiedNamespaceTopics, + transforms, + editedTopics, + filterText, + }); + + nextProps.setSelections(tree.getSelections()); + + return { + filterText, + tree, + cachedProps: nextProps, + }; + } + return null; + } + + componentDidUpdate(prevProps: Props, prevState: State) { + const { showTopics } = this.props; + + if (prevProps.showTopics !== showTopics) { + if (showTopics && this.filterTextField) { + this.filterTextField.focus(); + } + } + } + + cancelClick = (e: SyntheticMouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + _updateTreeDebounced = debounce(() => { + const tree = treeBuilder({ + topics: this.props.topics, + filterText: this.state.filterText, + namespaces: this.props.namespaces, + checkedNodes: this.props.checkedNodes, + expandedNodes: this.props.expandedNodes, + modifiedNamespaceTopics: this.props.modifiedNamespaceTopics, + transforms: this.props.transforms, + editedTopics: this.props.editedTopics, + }); + + this.setState({ tree }); + this.props.setSelections(tree.getSelections()); + }, 150); + + onFilterTextChange = (e: SyntheticEvent) => { + this.setState({ filterText: e.currentTarget.value }); + this._updateTreeDebounced(); + }; + + toggleExpanded = (node: Node) => { + const { expandedNodes, saveConfig } = this.props; + const { legacyIds, id } = node; + // don't invalidate layout url just because a node was expanded/collapsed + saveConfig( + { expandedNodes: toggle(expandedNodes, id, (item) => legacyIds.includes(item) || item === id) }, + { keepLayoutInUrl: true } + ); + }; + + onEditClick = (e: SyntheticMouseEvent, node: Node) => { + const topicNode: TopicTreeNode = ((node: any): TopicTreeNode); + if (!topicNode.topic) { + return; + } + this.props.onEditClick(e, topicNode.topic); + }; + + togglePinTopics = () => { + const { pinTopics, saveConfig } = this.props; + saveConfig({ pinTopics: !pinTopics }); + }; + + toggleChecked = (node: Node) => { + const { checkedNodes, saveConfig, modifiedNamespaceTopics, namespaces } = this.props; + const { namespace, topic } = ((node: any): TopicTreeNode); + // if we are interacting with a namespace, mark its topic as modified + // this way when future namespaces show up for this topic, on this page load or after an app reload + // we don't check them automatically + if (namespace && topic && !modifiedNamespaceTopics.includes(topic)) { + // check all namespaces under this topic *except* the clicked one + const newCheckedNodes = checkedNodes.slice(); + namespaces.forEach((ns) => { + if (ns.topic === topic && ns.name !== namespace) { + newCheckedNodes.push(getId(ns)); + } + }); + saveConfig({ + modifiedNamespaceTopics: modifiedNamespaceTopics.concat(topic), + checkedNodes: newCheckedNodes, + }); + } else { + const { legacyIds, id } = node; + saveConfig({ checkedNodes: toggle(checkedNodes, id, (item) => legacyIds.includes(item) || item === id) }); + } + }; + + renderErrors() { + if (!this.hasErrors()) { + return null; + } + + const { sceneErrors } = this.props; + + const genericTopicErrors = []; + for (const [topic, message] of sceneErrors.topicsWithError) { + const html =
{`${topic}: ${message}`}
; + genericTopicErrors.push(html); + } + + return ( +
+ {renderErrorSection("missing frame ids", sceneErrors.topicsMissingFrameIds)} + {renderErrorSection( + `missing transforms to ${sceneErrors.rootTransformID}`, + sceneErrors.topicsMissingTransforms + )} + {genericTopicErrors} +
+ ); + } + + hasErrors() { + const { sceneErrors } = this.props; + return ( + sceneErrors.topicsMissingFrameIds.size !== 0 || + sceneErrors.topicsMissingTransforms.size !== 0 || + sceneErrors.topicsWithError.size !== 0 + ); + } + + render() { + const { tree } = this.state; + if (!tree) { + return null; + } + const { pinTopics, showTopics, onToggleShowClick, autoTextBackgroundColor, saveConfig } = this.props; + const inputStyle = { + flex: 1, + borderBottomLeftRadius: 0, + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + margin: 0, + background: "transparent", + color: "white", + paddingLeft: 5, + }; + const hide = !pinTopics && !showTopics; + + return ( + <> +
+
+ + {this.hasErrors() &&
} +
+
+
+
+ + + + + + (this.filterTextField = el)} + type="text" + style={inputStyle} + placeholder="Type to filter" + value={this.state.filterText || ""} + onChange={this.onFilterTextChange} + /> + + + + + + + {this.renderErrors()} +
+
+ + ); + } +} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/topicTree.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/topicTree.js new file mode 100644 index 000000000..15529977f --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/topicTree.js @@ -0,0 +1,40 @@ +// @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"; + +export type TopicTreeConfig = ( + | { + name: string, + topic?: void, + extension?: void, + children: TopicTreeConfig[], + } + | { + name?: string, + topic: string, + extension?: void, + children?: TopicTreeConfig[], + } + | { + name: string, + topic?: void, + extension: string, + children?: TopicTreeConfig[], + } +) & { + // Previous names or ids for this item under which it might be saved in old layouts. + // Used for automatic conversion so that old saved layouts continue to work when tree nodes are renamed. + legacyIds?: string[], + + icon?: React.Node, + + description?: string, + + // Synthetic groups don't have a checkbox, they can only expand/collapse + isSyntheticGroup?: boolean, +}; diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/treeBuilder.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/treeBuilder.js new file mode 100644 index 000000000..03be86801 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelector/treeBuilder.js @@ -0,0 +1,475 @@ +// @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 BlurIcon from "@mdi/svg/svg/blur.svg"; +import CarIcon from "@mdi/svg/svg/car.svg"; +import GridIcon from "@mdi/svg/svg/grid.svg"; +import HexagonMultipleIcon from "@mdi/svg/svg/hexagon-multiple.svg"; +import HexagonIcon from "@mdi/svg/svg/hexagon.svg"; +import PentagonOutlineIcon from "@mdi/svg/svg/pentagon-outline.svg"; +import RadarIcon from "@mdi/svg/svg/radar.svg"; +import { find } from "lodash"; +import * as React from "react"; +import styled from "styled-components"; + +import { type TopicTreeConfig } from "./topicTree"; +import { getGlobalHooks } from "webviz-core/src/loadWebviz"; +import type { Transform } from "webviz-core/src/panels/ThreeDimensionalViz/Transforms"; +import colors from "webviz-core/src/styles/colors.module.scss"; +import type { Topic, Namespace } from "webviz-core/src/types/players"; +import { POINT_CLOUD_DATATYPE, POSE_STAMPED_DATATYPE } from "webviz-core/src/util/globalConstants"; +import naturalSort from "webviz-core/src/util/naturalSort"; + +type Props = { + // list of all topics available in the current bag + topics: Topic[], + namespaces: Namespace[], + checkedNodes: string[], + expandedNodes: string[], + modifiedNamespaceTopics: string[], + filterText?: ?string, + transforms: Array, + editedTopics?: string[], +}; + +const icons = { + "visualization_msgs/Marker": , + "visualization_msgs/MarkerArray": , + "nav_msgs/OccupancyGrid": , + "sensor_msgs/LaserScan": , + "geometry_msgs/PolygonStamped": , + [POINT_CLOUD_DATATYPE]: , + [POSE_STAMPED_DATATYPE]: , +}; + +export class Selections { + topics: string[] = []; + namespaces: Namespace[] = []; + extensions: string[] = []; +} + +// ids for namespace nodes are the topic name, namespace name +// and an 'ns' string to make sure they don't collide in any way with topic names +// they're meant to be opaque and the data within them not parsed or read back out +export function getId(namespace: Namespace) { + return `ns:${namespace.topic}:${namespace.name}`; +} + +function canEdit(topic: Topic) { + return [ + POINT_CLOUD_DATATYPE, + POSE_STAMPED_DATATYPE, + ...getGlobalHooks().perPanelHooks().ThreeDimensionalViz.editableTopics, + ].includes(topic.datatype); +} + +const TooltipRow = styled.div` + margin: 4px 0; + &:first-child { + margin-top: 0; + } + &:last-child { + margin-bottom: 0; + } +`; +const TooltipDescription = styled(TooltipRow)` + line-height: 1.3; + max-width: 300px; +`; +const TooltipTable = styled.table` + th, + td { + border: none; + padding: 0; + } + td { + word-break: break-word; + } + max-width: 100%; + th { + color: ${colors.textMuted}; + } +`; + +export class TopicTreeNode { + id: string; + text: string; + icon: ?React.Node; + expanded: boolean; + missing: boolean; + tooltip: React.Node[] = []; + description: ?string; + checked: boolean; + hasCheckbox: boolean; + disabled: boolean = false; + children: TopicTreeNode[] = []; + filterMatch: boolean = false; + canEdit: boolean = false; + hasEdit: boolean = false; + + // whether this node or any descendants match the filter + descendantFilterMatch: boolean = false; + + visible: boolean = true; + + // true if this node or all descendent nodes are missing from + // the available topics in a bag + missing: boolean = false; + + topic: ?string; + namespace: ?string; + extension: ?string; + + // outdated ids under which the node might have been checked/expanded + legacyIds: string[]; + + // create a topic node from a json config file node + static fromJson(config: TopicTreeConfig) { + 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))); + } + return result; + } + + constructor(config: TopicTreeConfig, namespace: ?string) { + this.text = config.name || ""; + this.icon = config.icon; + this.legacyIds = config.legacyIds || []; + this.hasCheckbox = !config.isSyntheticGroup; + this.description = config.description; + const { topic } = config; + if (topic) { + if (namespace) { + this.namespace = namespace; + this.id = `ns:${topic}:${namespace}`; + } else { + this.legacyIds.push(topic); + this.id = `t:${topic}`; + } + this.text = config.name || topic; + this.topic = topic; + } else if (config.extension) { + this.id = `x:${config.extension}`; + this.extension = config.extension; + } else if (config.name) { + this.id = `name:${config.name}`; + } else { + throw new Error("encountered TopicTree node with no topic, extension, or name"); + } + if (!this.legacyIds.includes(this.text)) { + this.legacyIds.push(this.text); + } + } + + // collect namespaces which belong to this topic node + // and add them as child nodes - namespaces are the topic name and the namespace + // separated by a slash like "/lidar_points/cars" + consumeNamespaces(namespaces: Namespace[]): void { + const { topic } = this; + // if we're not a topic node, ignore the namespaces + if (!topic) { + return; + } + + const matching = namespaces.filter((ns) => ns.topic === topic); + matching.sort(naturalSort("name")); + matching.forEach((ns) => { + const node = new TopicTreeNode({ name: ns.name, topic }, ns.name); + this.add(node); + }); + } + + add(child: TopicTreeNode): void { + this.children.push(child); + } + + // recursively search the tree for a node matching the predicate + find(predicate: (TopicTreeNode) => boolean): ?TopicTreeNode { + if (predicate(this)) { + return this; + } + + for (let i = 0; i < this.children.length; i++) { + const match = this.children[i].find(predicate); + if (match) { + return match; + } + } + } + + // recursively update this node & its children based on the selected/expanded state + // collects all node ids along the way to check for duplicates + _updateState(props: Props, disabled: boolean, ids: string[], ancestorFilterMatch: boolean): void { + // Wrapper to help handle nodes that were checked under an old-style id. + const containsThisNode = (ids: string[]) => { + return ids.includes(this.id) || this.legacyIds.some((id) => ids.includes(id)); + }; + + this.expanded = containsThisNode(props.expandedNodes); + this.checked = !this.hasCheckbox || containsThisNode(props.checkedNodes); + + // if a topic hasn't had its namespaces modified, check its namespaces by default + if (this.namespace && !props.modifiedNamespaceTopics.includes(this.topic)) { + this.checked = true; + } + + // check for duplicate ids + if (ids.indexOf(this.id) > -1) { + throw new Error(`Two nodes in the tree share the same id: ${this.id}`); + } + ids.push(this.id); + + const topicName = this.topic; + if (topicName) { + // topic nodes are disabled if they are not in the list of active topics + const matchingTopic = find(props.topics, { name: topicName }); + this.missing = !matchingTopic; + this.disabled = disabled || this.missing; + this.canEdit = !!matchingTopic && canEdit(matchingTopic); + this.hasEdit = !!props.editedTopics && props.editedTopics.indexOf(topicName) > -1; + + if (matchingTopic) { + this.tooltip.push( + + + + + Topic: + + {matchingTopic.name} + + + + Type: + + {matchingTopic.datatype} + + + + + + ); + } else { + this.tooltip.push( + + Topic {topicName} is not currently available + + ); + } + } else { + this.disabled = disabled; + } + + if (this.description) { + this.tooltip.push({this.description}); + } + + const { filterText } = props; + this.filterMatch = filterText ? this.text.toLocaleLowerCase().includes(filterText.toLowerCase()) : false; + if (!this.filterMatch && this.topic) { + this.filterMatch = filterText ? this.topic.toLocaleLowerCase().includes(filterText.toLowerCase()) : false; + } + + let missingChildrenCount = 0; + const childDisabled = this.disabled || !this.checked; + this.children.forEach((child) => { + // eslint-disable-next-line no-underscore-dangle + child._updateState(props, childDisabled, ids, ancestorFilterMatch || this.filterMatch); + if (child.missing) { + missingChildrenCount++; + } + }); + + // if all the children are missing, mark this node as missing & disabled as well + if (!topicName && !this.extension && this.children.length === missingChildrenCount) { + this.disabled = true; + this.missing = true; + this.tooltip.push(None of the topics in this group are currently available); + } + + if (filterText) { + this.descendantFilterMatch = this.filterMatch || this.children.some((child) => child.descendantFilterMatch); + if (this.descendantFilterMatch || ancestorFilterMatch) { + this.visible = true; + this.expanded = true; + } else { + this.visible = false; + } + } else { + this.visible = true; + } + } + + _collectSelections(selections: Selections): void { + if (this.disabled || !this.checked) { + return; + } + + // namespace nodes do not have children + if (this.topic && this.namespace) { + selections.namespaces.push({ topic: this.topic, name: this.namespace }); + return; + } else if (this.topic) { + selections.topics.push(this.topic); + } else if (this.extension) { + selections.extensions.push(this.extension); + } + + if (this.children) { + // eslint-disable-next-line no-underscore-dangle + this.children.forEach((child) => child._collectSelections(selections)); + } + } + + // walks the tree and gets the selected topics and namespaces + // based on cascading checked/unchecked state of parents + getSelections(): Selections { + const selections: Selections = new Selections(); + this._collectSelections(selections); + return selections; + } +} + +export const UNGROUPED_NAME = getGlobalHooks().perPanelHooks().ThreeDimensionalViz.ungroupedNodesCategory; + +function createUngroupedNode(ungroupedNodes: TopicTreeNode[]): TopicTreeNode { + const prefixes = {}; + const getPrefix = (node: TopicTreeNode) => { + return node.text.split("/")[1]; + }; + + ungroupedNodes.forEach((node) => { + const prefix = `/${getPrefix(node)}`; + if (!prefix) { + return; + } + prefixes[prefix] = prefixes[prefix] || 0; + prefixes[prefix] += 1; + }); + + const prefixNodes = Object.keys(prefixes) + .map((prefix) => { + const count = prefixes[prefix]; + if (count < 3) { + return undefined; + } + return new TopicTreeNode({ name: prefix, isSyntheticGroup: true, children: [] }); + }) + .filter(Boolean); + + let unprefixedNodes = ungroupedNodes.filter((node) => { + const prefix = `/${getPrefix(node)}`; + const prefixNode = prefixNodes.find((pfx) => pfx.text === prefix); + if (!prefixNode) { + return true; + } + prefixNode.add(node); + return false; + }); + + unprefixedNodes = unprefixedNodes.concat(prefixNodes); + + const sortNodesByName = naturalSort("text"); + + prefixNodes.forEach((prefixNode) => { + prefixNode.children = prefixNode.children.sort(sortNodesByName); + }); + + // sort the uncategorized nodes by topic name + unprefixedNodes.sort(sortNodesByName); + + const parentNode = new TopicTreeNode({ name: UNGROUPED_NAME, children: [] }); + unprefixedNodes.forEach((node) => parentNode.add(node)); + + return parentNode; +} + +// build tree - use either the json config supplied +// or optionally a custom config used in testing +export default function buildTree( + props: Props, + jsonConfig: any = getGlobalHooks() + .perPanelHooks() + .ThreeDimensionalViz.getDefaultTopicTree() +): TopicTreeNode { + const { topics, transforms } = props; + + const rootNode = TopicTreeNode.fromJson(jsonConfig); + + rootNode.disabled = false; + rootNode.checked = true; + const ungroupedNodes = []; + + // apply the topics from the bag to the tree + topics.forEach((topic: Topic) => { + if ( + getGlobalHooks() + .perPanelHooks() + .ThreeDimensionalViz.hasBlacklistTopics(topic.name) + ) { + return; + } + + const icon = { + ...getGlobalHooks().perPanelHooks().ThreeDimensionalViz.icons, + ...icons, + }[topic.datatype]; + if (!icon) { + return; + } + let node = rootNode.find((node) => node.topic === topic.name); + // enable the existing node if it exists + if (node) { + node.disabled = false; + node.icon = icon; + } else { + node = new TopicTreeNode({ topic: topic.name }); + node.icon = icon; + ungroupedNodes.push(node); + } + node.consumeNamespaces(props.namespaces); + }); + + const tfRootNode = rootNode.find((node) => node.id === "name:TF"); + if (transforms.length > 0) { + if (tfRootNode) { + transforms.forEach((transform) => { + let node = rootNode.find((node) => node.id === `x:TF.${transform.id}`); + if (node) { + node.disabled = false; + } else { + node = new TopicTreeNode({ name: transform.id, extension: `TF.${transform.id}`, disabled: false }); + tfRootNode.add(node); + } + }); + } else { + console.warn("Couldn't find tf category node"); + } + } + + const ids: string[] = []; + + const ungroupedNode = createUngroupedNode(ungroupedNodes); + + // sort the uncategorized nodes by topic name + ungroupedNode.children = ungroupedNode.children.sort(naturalSort("text")); + + rootNode.add(ungroupedNode); + + // now that we have built the tree, update the state + rootNode.children.forEach((child) => { + // eslint-disable-next-line no-underscore-dangle + child._updateState(props, false, ids, false); + }); + + return rootNode; +} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelectorMenu.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelectorMenu.js new file mode 100644 index 000000000..2543e86d7 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSelectorMenu.js @@ -0,0 +1,58 @@ +// @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 CheckboxBlankOutlineIcon from "@mdi/svg/svg/checkbox-blank-outline.svg"; +import CheckboxMarkedIcon from "@mdi/svg/svg/checkbox-marked.svg"; +import DotsVerticalIcon from "@mdi/svg/svg/dots-vertical.svg"; +import React, { useState } from "react"; +import styled from "styled-components"; + +import ChildToggle from "webviz-core/src/components/ChildToggle"; +import Icon from "webviz-core/src/components/Icon"; +import Menu from "webviz-core/src/components/Menu"; +import Item from "webviz-core/src/components/Menu/Item"; +import type { ThreeDimensionalVizConfig } from "webviz-core/src/panels/ThreeDimensionalViz/index"; +import type { SaveConfig } from "webviz-core/src/types/panels"; + +const SIconWrapper = styled.div` + display: flex; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; +`; + +type Props = { + saveConfig: SaveConfig, + pinTopics: boolean, + autoTextBackgroundColor: boolean, +}; + +export default function TopicSelectorMenu({ saveConfig, pinTopics, autoTextBackgroundColor }: Props) { + const [isOpen, setIsOpen] = useState(false); + return ( + setIsOpen(!isOpen)} isOpen={isOpen}> + + + + + + + saveConfig({ pinTopics: !pinTopics })} + icon={pinTopics ? : }> + Pin Topics + + saveConfig({ autoTextBackgroundColor: !autoTextBackgroundColor })} + icon={autoTextBackgroundColor ? : }> + Auto Text Background + + + + ); +} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSettingsEditor.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSettingsEditor.js new file mode 100644 index 000000000..695660841 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSettingsEditor.js @@ -0,0 +1,184 @@ +// @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 { range } from "lodash"; +import React, { PureComponent } from "react"; +import styled from "styled-components"; + +import type { TopicSettings } from "./SceneBuilder"; +import styles from "./TopicSettingsEditor.module.scss"; +import Flex from "webviz-core/src/components/Flex"; +import { Select, Option } from "webviz-core/src/components/Select"; +import { getGlobalHooks } from "webviz-core/src/loadWebviz"; +import type { Topic } from "webviz-core/src/types/players"; +import { POINT_CLOUD_DATATYPE } from "webviz-core/src/util/globalConstants"; + +export type Props = { + topic: Topic, + message: any, + settings: TopicSettings, + onSettingsChange: (TopicSettings) => void, +}; + +export const SLabel = styled.label` + display: block; + margin: 5px 2px; +`; + +export const SInput = styled.input` + flex: 1 1 auto; + margin-bottom: 8px; +`; + +export const RenderPointSettings = ({ + defaultPointSize, + settings, + onFieldChange, +}: { + defaultPointSize: number, + settings: TopicSettings, + onFieldChange: (string) => Function, +}) => { + const pointSize = settings.pointSize; + + const pointSizeVal = pointSize || defaultPointSize; + const pointSizeOptions = range(1, 10).map((field) => ( + + )); + + const pointShape = settings.pointShape; + const pointShapeVal = pointShape ? pointShape : "circle"; + const pointShapeOpts = ["circle", "square"].map((field) => ( + + )); + + return ( + + Point Size + + + Point Shape + + + ); +}; + +export const renderDecaySettings = (props: Props, onFieldChange: (string) => Function) => { + const { settings } = props; + const decayTime = settings.decayTime; + const decayTimeValue = decayTime === undefined ? "" : decayTime; + + return ( + + Decay Time (# sec to display) + e.g. 0s (default) - remove when new message with same topic received + { + const isInputValid = !isNaN(parseFloat(e.target.value)); + onFieldChange("decayTime")(isInputValid ? parseFloat(e.target.value) : undefined); + }} + /> + + ); +}; + +export const RenderResetButton = ({ onSettingsChange }: { onSettingsChange: (TopicSettings) => void }) => { + return ; +}; + +export default class TopicSettingsEditor extends PureComponent { + static defaultProps = { + settings: {}, + }; + + onFieldChange = (fieldName: string) => { + return (value: string) => { + const { onSettingsChange, settings } = this.props; + const newSettings = { + ...settings, + [fieldName]: value, + }; + onSettingsChange(newSettings); + }; + }; + + _renderPointCloudSettings() { + const { message, settings, onSettingsChange } = this.props; + if (!message || !message.fields) { + return null; + } + const colorField = settings.colorField; + const colorFieldValue = colorField ? colorField : "rgb"; + + const color = settings.color; + const colorValue = color || ""; + + const colorFieldOptions = message.fields.map((field) => ( + + )); + + return ( + + {RenderPointSettings({ defaultPointSize: 2, settings, onFieldChange: this.onFieldChange })} + Color by + + Point Color in r,g,b (overrides Color Field) + { + this.onFieldChange("color")(e.target.value); + }} + /> + {renderDecaySettings(this.props, this.onFieldChange)} + {RenderResetButton({ onSettingsChange })} + + ); + } + + _renderSettings(datatype: string) { + if (datatype === POINT_CLOUD_DATATYPE) { + return this._renderPointCloudSettings(); + } + + return getGlobalHooks() + .perPanelHooks() + .ThreeDimensionalViz.renderTopicSettings(datatype, this.props, this.onFieldChange); + } + + render() { + const { topic } = this.props; + return ( +
+

{topic.name}

+

+ {topic.datatype} +

+ {this._renderSettings(topic.datatype)} +
+ ); + } +} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSettingsEditor.module.scss b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSettingsEditor.module.scss new file mode 100644 index 000000000..a0fe1e0d8 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSettingsEditor.module.scss @@ -0,0 +1,21 @@ +@import "~webviz-core/src/styles/mixins.module.scss"; +@import "~webviz-core/src/styles/colors.module.scss"; + +.dropdown { + .button { + width: 100%; + } +} + +.container { + @include sans-serif; + + .topicName { + @extend %modal-subtitle; + word-wrap: break-word; + } + + .datatype { + padding-bottom: 20px; + } +} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms.js new file mode 100644 index 000000000..17d023749 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms.js @@ -0,0 +1,170 @@ +// @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 { mat4, vec3, quat } from "gl-matrix"; +import type { Mat4 } from "gl-matrix"; + +import type { TF, Pose, Point, Orientation } from "webviz-core/src/types/Messages"; + +// allocate some temporary variables +// so we can copy/in out of them during tf application +// this reduces GC as this code gets called lot +const tempMat = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; +const tempPos = [0, 0, 0]; +const tempScale = [0, 0, 0]; +const tempOrient = [0, 0, 0, 0]; + +export class Transform { + id: string; + matrix: Mat4 = mat4.create(); + parent: ?Transform; + valid = false; + + constructor(id: string) { + this.id = id; + } + + reset() { + mat4.identity(this.matrix); + this.valid = true; + } + + set(position: Point, orientation: Orientation) { + mat4.fromRotationTranslation( + this.matrix, + quat.set(tempOrient, orientation.x, orientation.y, orientation.z, orientation.w), + vec3.set(tempPos, position.x, position.y, position.z) + ); + this.valid = true; + } + + isChildOfTransform(rootId: string): boolean { + if (!this.parent) { + return this.id === rootId; + } + return this.parent.isChildOfTransform(rootId); + } + + rootTransform(): Transform { + if (!this.parent) { + return this; + } + return this.parent.rootTransform(); + } + + apply(output: Pose, input: Pose, rootId: string): ?Pose { + if (this.id === rootId) { + output.position.x = input.position.x; + output.position.y = input.position.y; + output.position.z = input.position.z; + output.orientation.x = input.orientation.x; + output.orientation.y = input.orientation.y; + output.orientation.z = input.orientation.z; + output.orientation.w = input.orientation.w; + return output; + } + if (!this.valid) { + return null; + } + if (!this.isChildOfTransform(rootId)) { + // Can't apply if this transform doesn't map to the root transform. + return null; + } + + const { position, orientation } = input; + // set a transform matrix from the input pose + mat4.fromRotationTranslation( + tempMat, + quat.set(tempOrient, orientation.x, orientation.y, orientation.z, orientation.w), + vec3.set(tempPos, position.x, position.y, position.z) + ); + + // set transform matrix to (our matrix * pose transform matrix) + mat4.multiply(tempMat, this.matrix, tempMat); + + // copy the transform matrix components out into temp variables + mat4.getTranslation(tempPos, tempMat); + + // Normalize the values in the matrix by the scale. This ensures that we get the correct rotation + // out even if the scale isn't 1 in each axis. The logic from this comes from the threejs + // implementation and an SO answer: + // - https://github.com/mrdoob/three.js/blob/master/src/math/Matrix4.js#L790-L815 + // - https://math.stackexchange.com/a/1463487 + mat4.getScaling(tempScale, tempMat); + if (mat4.determinant(tempMat) < 0) { + tempScale[0] *= -1; + } + vec3.inverse(tempScale, tempScale); + mat4.scale(tempMat, tempMat, tempScale); + + mat4.getRotation(tempOrient, tempMat); + + // mutate the output w/ the temp values + output.position.x = tempPos[0]; + output.position.y = tempPos[1]; + output.position.z = tempPos[2]; + output.orientation.x = tempOrient[0]; + output.orientation.y = tempOrient[1]; + output.orientation.z = tempOrient[2]; + output.orientation.w = tempOrient[3]; + + if (!this.parent) { + return output; + } + return this.parent.apply(output, output, rootId); + } +} + +class TfStore { + storage = {}; + get(key: string): Transform { + let result = this.storage[key]; + if (result) { + return result; + } + result = new Transform(key); + this.storage[key] = result; + return result; + } + + values = (): Array => ((Object.values(this.storage): any): Array); +} + +export default class Transforms { + storage = new TfStore(); + + // consume a tf message + consume(tfMessage: TF) { + // child_frame_id is the id of the tf + const id = tfMessage.child_frame_id; + const parentId = tfMessage.header.frame_id; + const tf = this.storage.get(id); + const { rotation, translation } = tfMessage.transform; + tf.set(translation, rotation); + tf.parent = this.storage.get(parentId); + } + + // Apply the tf hierarchy to the original pose and update the pose supplied in the output parameter. + // This follows the same calling conventions in the gl-mat4 lib, which takes an 'out' parameter as their first argument. + // This allows the caller to decide if they want to update the pose by reference + // (by reference by supplying it as both the first and second arguments) + // or return a new one by calling with apply({ position: { }, orientation: {} }, original). + // Returns the output pose, or the input pose if no transform was needed, or null if the transform + // is not available -- the return value must not be ignored. + apply(output: Pose, original: Pose, frameId: string, rootId: string): ?Pose { + const tf = this.storage.get(frameId); + return tf.apply(output, original, rootId); + } + + rootOfTransform(transformID: string): Transform { + return this.get(transformID).rootTransform(); + } + + get = (key: string) => this.storage.get(key); + values = (): Array => this.storage.values(); +} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/TransformsBuilder.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/TransformsBuilder.js new file mode 100644 index 000000000..b1524dcb7 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/TransformsBuilder.js @@ -0,0 +1,230 @@ +// @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 { mat4, quat, vec3 } from "gl-matrix"; +import type { Vec3 } from "gl-matrix"; + +import Transforms, { Transform } from "webviz-core/src/panels/ThreeDimensionalViz/Transforms"; +import type { Marker, ArrowMarker, Color, Pose } from "webviz-core/src/types/Messages"; +import type { MarkerProvider, MarkerCollector } from "webviz-core/src/types/Scene"; + +const originPosition = { x: 0, y: 0, z: 0 }; +const originOrientation = { x: 0, y: 0, z: 0, w: 1 }; + +const defaultArrowMarker = { + id: "", + header: { + frame_id: "", + stamp: { + sec: 0, + nsec: 0, + }, + }, + ns: "tf-axes", + type: 0, + action: 0, +}; + +const defaultArrowScale = { x: 0.2, y: 0.02, z: 0.02 }; + +const getOriginPose = (): Pose => ({ + position: { ...originPosition }, + orientation: { ...originOrientation }, +}); + +const unitXVector = vec3.fromValues(1, 0, 0); + +type Axis = ArrowMarker & { + id: string, + color: Color, + unitVector: Vec3, +}; + +const originAxes: Array = [ + { + ...defaultArrowMarker, + scale: { ...defaultArrowScale }, + pose: { + position: { ...originPosition }, + orientation: { ...originOrientation }, + }, + id: "X", + color: { r: 1, g: 0, b: 0, a: 1 }, + unitVector: vec3.fromValues(1, 0, 0), + }, + { + ...defaultArrowMarker, + scale: { ...defaultArrowScale }, + pose: { + position: { ...originPosition }, + orientation: { ...originOrientation }, + }, + id: "Y", + color: { r: 0, g: 1, b: 0, a: 1 }, + unitVector: vec3.fromValues(0, 1, 0), + }, + { + ...defaultArrowMarker, + scale: { ...defaultArrowScale }, + pose: { + position: { ...originPosition }, + orientation: { ...originOrientation }, + }, + id: "Z", + color: { r: 0, g: 0, b: 1, a: 1 }, + unitVector: vec3.fromValues(0, 0, 1), + }, +]; + +const tempOrientation = [0, 0, 0, 0]; + +const getTransformedAxisArrowMarker = (id: string, transform: Transform, axis: Axis, rootTransformID: string) => { + const { unitVector, id: axisId } = axis; + quat.rotationTo(tempOrientation, unitXVector, unitVector); + const pose = { + position: { ...originPosition }, + orientation: { x: tempOrientation[0], y: tempOrientation[1], z: tempOrientation[2], w: tempOrientation[3] }, + }; + + transform.apply(pose, pose, rootTransformID); + + return { + ...axis, + id: `${id}-${axisId}axis`, + name: `${id}-${axisId}axis`, + pose, + }; +}; + +const getAxesArrowMarkers = (id: string, transform: Transform, rootTransformID: string): ArrowMarker[] => { + return originAxes.map((axis) => getTransformedAxisArrowMarker(id, transform, axis, rootTransformID)); +}; + +const getAxisTextMarker = (id: string, transform: Transform, rootTransformID: string): Marker => { + const textPose = getOriginPose(); + transform.apply(textPose, textPose, rootTransformID); + // Lower it a little in world coordinates so it appears slightly below the axis origin (like in rviz). + textPose.position.z = textPose.position.z - 0.02; + return { + header: { + frame_id: "", + stamp: { + sec: 0, + nsec: 0, + }, + }, + ns: "tf-axes", + action: 0, + scale: { x: 1, y: 1, z: 1 }, + color: { r: 1, g: 1, b: 1, a: 1 }, + id: `${id}-name`, + name: `${id}-name`, + pose: textPose, + type: 9, + text: id, + }; +}; + +const tempTranslation = [0, 0, 0]; +// So we don't create a lot of effectively unused vectors / quats. +const throwawayQuat = { ...originOrientation }; + +const getArrowToParentMarkers = (id: string, transform: Transform, rootTransformID: string): ArrowMarker[] => { + const { parent } = transform; + if (!parent || !parent.valid) { + return []; + } + + mat4.getTranslation(tempTranslation, transform.matrix); + if (vec3.length(tempTranslation) <= 0) { + // If the parent's position is on the child, we don't need to draw an arrow between them. + return []; + } + + const childPose: Pose = { position: { ...originPosition }, orientation: throwawayQuat }; + transform.apply(childPose, childPose, rootTransformID); + + const parentPose = { position: { ...originPosition }, orientation: throwawayQuat }; + parent.apply(parentPose, parentPose, rootTransformID); + + return [ + { + ...defaultArrowMarker, + pose: { + position: { ...originPosition }, + orientation: { ...originOrientation }, + }, + id: `${id}-childToParentArrow`, + color: { r: 1, g: 1, b: 0, a: 1 }, + points: [childPose.position, parentPose.position], + scale: { + // Intentionally different scale from the other arrows, to make the arrow head reasonable. + x: 0.02, + y: 0.01, + z: 0.05, + }, + }, + ]; +}; + +const isTfExtension = (extension: string) => extension.startsWith("TF"); +const removeTfPrefix = (extension: string) => extension.slice("TF.".length); + +export default class TransformsBuilder implements MarkerProvider { + transforms: Transforms; + rootTransformID: string; + selections: string[] = []; + + setTransforms = (transforms: Transforms, rootTransformID: string) => { + this.transforms = transforms; + this.rootTransformID = rootTransformID; + }; + + addMarkersForTransform(add: MarkerCollector, id: string, transform: Transform, rootTransformID: string) { + if (!transform.isChildOfTransform(rootTransformID)) { + return; + } + const markersForTransform: Marker[] = [ + ...getAxesArrowMarkers(id, transform, rootTransformID), + ...getArrowToParentMarkers(id, transform, rootTransformID), + getAxisTextMarker(id, transform, rootTransformID), + ]; + for (const marker of markersForTransform) { + switch (marker.type) { + case 0: + add.arrow(marker); + break; + case 9: + add.text(marker); + break; + default: + console.warn("Marker for transform not supported", marker); + } + } + } + + setSelectedTransforms(extensions: string[]) { + this.selections = extensions.filter(isTfExtension).map(removeTfPrefix); + } + + renderMarkers = (add: MarkerCollector) => { + const { selections, transforms } = this; + if (!transforms) { + return; + } + for (const key of selections) { + const transform = this.transforms.get(key); + if (!transform.valid) { + // If a marker doesn't exist yet, skip rendering for now, we might get the + // transform in a later message, so we still want to keep it in selections. + continue; + } + this.addMarkersForTransform(add, key, transform, this.rootTransformID); + } + }; +} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/World.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/World.js new file mode 100644 index 000000000..bca94227f --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/World.js @@ -0,0 +1,296 @@ +// @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 { + Worldview, + Arrows, + Cubes, + Cylinders, + Lines, + Points, + Spheres, + Text, + Triangles, + FilledPolygons, + type CameraState, + type ReglClickInfo, + type MouseHandler, + type Vec4, +} from "regl-worldview"; + +import { OccupancyGrids, LaserScans, PointClouds } from "webviz-core/src/panels/ThreeDimensionalViz/commands"; +import MarkerMetadata from "webviz-core/src/panels/ThreeDimensionalViz/MarkerMetadata"; +import inScreenshotTests from "webviz-core/src/stories/inScreenshotTests"; +import type { Scene, MarkerProvider } from "webviz-core/src/types/Scene"; + +type Props = {| + autoTextBackgroundColor: boolean, + cameraState: CameraState, + children?: React.Node, + cityName: ?string, + debug: boolean, + markerProviders: (?MarkerProvider)[], + mouseClick: ({}) => void, + onCameraStateChange: (CameraState) => void, + onDoubleClick?: MouseHandler, + onMouseDown?: MouseHandler, + onMouseMove?: MouseHandler, + onMouseUp?: MouseHandler, + scene: Scene, + version: ?string, + extensions: string[], +|}; + +type PerceptionAreaConfig = { + enabled: boolean, + colors: Vec4[], +}; + +type ClickedObject = { + id: number, + markerName: string, +}; + +type State = {| + zMin: number, + perceptionAreaConfig: PerceptionAreaConfig, + extensions: string[], + clickedObject?: ClickedObject, +|}; + +function getMarkers(markerProviders: (?MarkerProvider)[], clickedObject?: ClickedObject) { + const markers = { + lines: [], + grids: [], + arrows: [], + texts: [], + cubes: [], + spheres: [], + points: [], + pointclouds: [], + triangles: [], + laserScans: [], + cylinders: [], + filledPolygons: [], + }; + + const collector = { + arrow: (o) => markers.arrows.push(o), + cube: (o) => markers.cubes.push({ ...o, points: undefined }), + cubeList: (o) => markers.cubes.push(o), + sphere: (o) => markers.spheres.push({ ...o, points: undefined }), + sphereList: (o) => markers.spheres.push(o), + cylinder: (o) => markers.cylinders.push({ ...o, points: undefined }), + lineStrip: (o) => markers.lines.push({ ...o, primitive: "line strip" }), + lineList: (o) => markers.lines.push({ ...o, primitive: "lines" }), + points: (o) => markers.points.push(o), + text: (o) => markers.texts.push(o), + triangleList: (o) => markers.triangles.push(o), + + grid: (o) => markers.grids.push(o), + pointcloud: (o) => markers.pointclouds.push(o), + laserScan: (o) => markers.laserScans.push(o), + filledPolygon: (o) => + markers.filledPolygons.push({ + ...o, + color: clickedObject && clickedObject.id === o.id ? { r: 1, g: 1, b: 1, a: 0.5 } : o.color, + }), + }; + + markerProviders.forEach((provider) => { + if (provider) { + provider.renderMarkers(collector); + } + }); + + return markers; +} + +type MarkersProps = { + autoTextBackgroundColor: boolean, + markerProviders: (?MarkerProvider)[], + clickedObject?: ClickedObject, + onMarkerDoubleClick: (event: MouseEvent, clickInfo: ?ReglClickInfo, markerName: string) => void, +}; + +function Markers(props: MarkersProps) { + const { + lines, + arrows, + texts, + cubes, + spheres, + points, + triangles, + cylinders, + grids, + pointclouds, + laserScans, + filledPolygons, + } = getMarkers(props.markerProviders, props.clickedObject); + return ( + <> + + {grids} + + {lines},{arrows},{points} + {pointclouds},{triangles} + {spheres},{cylinders} + {cubes} + {laserScans} + + {texts} + + props.onMarkerDoubleClick(event, clickInfo, "filledPolygons")} + key="FilledPolygons"> + {filledPolygons} + + + ); +} + +const defaultPerceptionAreaConfig: PerceptionAreaConfig = { + enabled: false, + colors: [], +}; + +function getConfigFromExtensions(extensions: string[]): PerceptionAreaConfig { + return defaultPerceptionAreaConfig; +} + +export default class World extends React.Component { + state = { + zMin: 0, + extensions: [], + perceptionAreaConfig: { + enabled: false, + colors: [], + }, + clickedObject: undefined, + }; + + static getDerivedStateFromProps(nextProps: Props, prevState: State) { + const { extensions } = nextProps; + const zMin = nextProps.scene.bounds.z.min - 1; + + // Check if the extension array is the same reference. + // If it is not the same reference do a compare of each item (there are only a handful) and only + // update state if the extensions actually change - the topic selector emits a new list of extensions + // every time someone expands/collapses a node or types in the selection box...so if we don't check for + // item equality here we end up re-rendering more than we need to. + if (zMin >= prevState.zMin && isEqual(extensions, prevState.extensions)) { + return null; + } + + return { + // save the extensions to compare next time we update + extensions: nextProps.extensions, + perceptionAreaConfig: getConfigFromExtensions(extensions), + // set the zMin to 1 lower than the actual new zMin so it updates less frequently + // otherwise the min going very slightly down every frame causes lots of renders + zMin: zMin > prevState.zMin ? prevState.zMin : zMin - 1, + }; + } + + onMarkerDoubleClick = (event: MouseEvent, clickInfo: ?ReglClickInfo, markerName: string) => { + if (!clickInfo) { + return; + } + const { objectId } = clickInfo; + this.setState((state) => ({ + ...state, + clickedObject: objectId ? { id: objectId, markerName } : undefined, + })); + }; + + onDoubleClick = (event: MouseEvent, clickInfo: ?ReglClickInfo) => { + if (!clickInfo) { + return; + } + this.setState((state) => ({ + ...state, + clickedObject: undefined, + })); + + if (this.props.onDoubleClick) { + this.props.onDoubleClick(event, clickInfo); + } + }; + + onClick = (event: MouseEvent, clickInfo: ?ReglClickInfo) => { + if (!clickInfo) { + return; + } + const { + ray: { + point: [x, y], + }, + } = clickInfo; + + // rather than choosing an arbitrary z-plane to intersect the ray with, just restrict point to + // the top-down ortho view + if (!this.props.cameraState.perspective && this.props.mouseClick) { + const { markerProviders } = this.props; + this.props.mouseClick({ markers: getMarkers(markerProviders), position: [x, y], event }); + } + }; + + renderClickedMetadata() { + const { clickedObject } = this.state; + if (!clickedObject) { + return; + } + const { markerProviders } = this.props; + const { id, markerName } = clickedObject; + // We use currently rendered markers here for objects that have persistent + // guids throughout a run, such as labels. + const markers = getMarkers(markerProviders)[markerName]; + const clickedMarker = markers && markers.find((marker) => marker && marker.id === id); + return clickedMarker && ; + } + + render() { + const { clickedObject } = this.state; + const { + autoTextBackgroundColor, + children, + onCameraStateChange, + onMouseDown, + onMouseMove, + onMouseUp, + cameraState, + markerProviders, + } = this.props; + + return ( + + {clickedObject && this.renderClickedMetadata()} + + {children} + + ); + } +} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/cameraStateValidator.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/cameraStateValidator.js new file mode 100644 index 000000000..ca1a2e15d --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/cameraStateValidator.js @@ -0,0 +1,34 @@ +// @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/cameraStateValidator.test.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/cameraStateValidator.test.js new file mode 100644 index 000000000..404b67ae2 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/cameraStateValidator.test.js @@ -0,0 +1,64 @@ +// @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 cameraStateValidator from "./cameraStateValidator"; + +describe("cameraStateValidator", () => { + it("returns no error 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({ + targetOffset: "must contain 3 array items", + }); + + const cameraState1 = { targetOffset: [1, 1, "abc"] }; + expect(cameraStateValidator(cameraState1)).toEqual({ + targetOffset: `must contain only numbers in the array. "abc" is not a number.`, + }); + + const cameraState2 = { targetOrientation: [1, 1, 1] }; + expect(cameraStateValidator(cameraState2)).toEqual({ + 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({ + distance: "must be a number", + targetOffset: 'must contain only numbers in the array. "121" is not a number.', + targetOrientation: "must contain 4 array items", + }); + + const cameraState1 = { distance: "abc", targetOffset: [1, 12, "121"], targetOrientation: [1, 1, 1, 1] }; + + expect(cameraStateValidator(cameraState1)).toEqual({ + targetOrientation: "must be valid quaternion", + distance: "must be a number", + targetOffset: 'must contain only numbers in the array. "121" is not a number.', + }); + }); +}); diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/color.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/color.js new file mode 100644 index 000000000..10daf2786 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/color.js @@ -0,0 +1,45 @@ +// @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 type { + Color, + BaseMarker, + LineListMarker, + LineStripMarker, + SphereListMarker, + PointsMarker, +} from "webviz-core/src/types/Messages"; + +export const colors = { + white: { r: 1, g: 1, b: 1, a: 1 }, +}; + +export function fromRGBA(color?: Color) { + return color || colors.white; +} + +export function getColor(marker: BaseMarker) { + return fromRGBA(marker.color); +} + +export function getCSSColor(marker: BaseMarker) { + const { r, g, b, a } = marker.color || colors.white; + return `rgba(${(r * 255).toFixed()}, ${(g * 255).toFixed()}, ${(b * 255).toFixed()}, ${a.toFixed(3)})`; +} + +// get a color segment from a marker which may contain +// multiple color definitions corresponding to its number of segments +type MarkerType = LineStripMarker | LineListMarker | SphereListMarker | PointsMarker; +export function getSegmentColor(marker: MarkerType, segmentIndex: number) { + if (marker.colors && marker.colors.length) { + const color = marker.colors[segmentIndex]; + if (color) { + return color; + } + } + return marker.color || colors.white; +} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/LaserScans.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/LaserScans.js new file mode 100644 index 000000000..234ca5aea --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/LaserScans.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 { range } from "lodash"; +import { makeCommand, withPose, type Regl } from "regl-worldview"; + +import { getGlobalHooks } from "webviz-core/src/loadWebviz"; +import type { LaserScan } from "webviz-core/src/types/Messages"; + +const laserScan = (regl: Regl) => + withPose({ + primitive: "points", + vert: getGlobalHooks().perPanelHooks().ThreeDimensionalViz.LaserScanVert, + frag: ` + precision mediump float; + varying vec4 vColor; + void main () { + gl_FragColor = vColor; + } + `, + + uniforms: { + angle_min: regl.prop("angle_min"), + angle_increment: regl.prop("angle_increment"), + range_min: regl.prop("range_min"), + range_max: regl.prop("range_max"), + }, + + attributes: { + index: (context, props) => range(props.ranges.length), + range: regl.prop("ranges"), + intensity: regl.prop("intensities"), + }, + + count: regl.prop("ranges.length"), + }); + +export default makeCommand("LaserScans", laserScan); diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/OccupancyGrids.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/OccupancyGrids.js new file mode 100644 index 000000000..b12e1a8fa --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/OccupancyGrids.js @@ -0,0 +1,110 @@ +// @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 { makeCommand, withPose, pointToVec3, defaultBlend } from "regl-worldview"; + +import { getGlobalHooks } from "webviz-core/src/loadWebviz"; +import { TextureCache } from "webviz-core/src/panels/ThreeDimensionalViz/commands/utils"; +import type { OccupancyGridMessage } from "webviz-core/src/types/Messages"; + +const occupancyGrids = (regl: any) => { + // make a buffer holding the verticies of a 1x1 plane + // it will be resized in the shader + const positionBuffer = regl.buffer([0, 1, 0, 1, 1, 0, 0, 0, 0, 1, 0, 0]); + + const cache = new TextureCache(regl); + + return withPose({ + primitive: "triangle strip", + + vert: ` + precision lowp float; + + uniform mat4 projection, view; + uniform vec3 offset; + uniform float width, height, resolution, alpha; + + attribute vec3 point; + + #WITH_POSE + + varying vec2 uv; + varying float vAlpha; + + void main () { + // set the texture uv to the unscaled vertext point + uv = vec2(point.x, point.y); + + // compute the plane vertex dimensions + float planeWidth = width * resolution; + float planeHeight = height * resolution; + + // scale the point by the plane vertex dimensions + vec3 position = point * vec3(planeWidth, planeHeight, 1.); + + // move the vertex by the marker offset + vec3 loc = applyPose(position + offset); + vAlpha = alpha; + gl_Position = projection * view * vec4(loc, 1); + } + `, + frag: ` + precision lowp float; + + varying vec2 uv; + varying float vAlpha; + + uniform sampler2D palette; + uniform sampler2D data; + + void main () { + // look up the point in our data texture corresponding to + // the current point being shaded + vec4 point = texture2D(data, uv); + + // vec2(point.a, 0.5) is similar to textelFetch for webGL 1.0 + // it looks up a point along our 1 dimentional palette + // http://www.lighthouse3d.com/tutorials/glsl-tutorial/texture-coordinates/ + gl_FragColor = texture2D(palette, vec2(point.a, 0.5)); + gl_FragColor.a *= vAlpha; + } + `, + blend: defaultBlend, + + depth: { enable: true, mask: false }, + + attributes: { + point: positionBuffer, + }, + + uniforms: { + width: regl.prop("info.width"), + height: regl.prop("info.height"), + resolution: regl.prop("info.resolution"), + // make alpha a uniform so in the future it can be controlled by topic settings + alpha: (context: any, props: OccupancyGridMessage) => { + return props.alpha || 0.5; + }, + offset: (context: any, props: OccupancyGridMessage) => { + return pointToVec3(props.info.origin.position); + }, + palette: (context: any, props: OccupancyGridMessage) => { + return getGlobalHooks() + .perPanelHooks() + .ThreeDimensionalViz.getMapTexture(regl, props.map); + }, + data: (context: any, props: any) => { + return cache.get(props); + }, + }, + + count: 4, + }); +}; + +export default makeCommand("OccupancyGrids", occupancyGrids); diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/Pointclouds/PointCloudBuilder.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/Pointclouds/PointCloudBuilder.js new file mode 100644 index 000000000..6b094b0a2 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/Pointclouds/PointCloudBuilder.js @@ -0,0 +1,330 @@ +// @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 { find } from "lodash"; + +import log from "webviz-core/src/panels/ThreeDimensionalViz/logger"; +import type { PointCloud2, PointCloud2Field } from "webviz-core/src/types/Messages"; + +const datatype = { + uint8: 2, + uint16: 4, + int32: 5, + float32: 7, +}; + +interface FieldReader { + read(data: number[], index: number): number; +} + +class Float32Reader implements FieldReader { + offset: number; + view: DataView; + constructor(offset: number) { + this.offset = offset; + const buffer = new ArrayBuffer(4); + this.view = new DataView(buffer); + } + + read(data: number[], index: number): number { + this.view.setUint8(0, data[index + this.offset]); + this.view.setUint8(1, data[index + this.offset + 1]); + this.view.setUint8(2, data[index + this.offset + 2]); + this.view.setUint8(3, data[index + this.offset + 3]); + return this.view.getFloat32(0, true); + } +} + +class Int32Reader implements FieldReader { + offset: number; + view: DataView; + constructor(offset: number) { + this.offset = offset; + const buffer = new ArrayBuffer(4); + this.view = new DataView(buffer); + } + + read(data: number[], index: number): number { + this.view.setUint8(0, data[index + this.offset]); + this.view.setUint8(1, data[index + this.offset + 1]); + this.view.setUint8(2, data[index + this.offset + 2]); + this.view.setUint8(3, data[index + this.offset + 3]); + return this.view.getInt32(0, true); + } +} + +class Uint16Reader implements FieldReader { + offset: number; + view: DataView; + constructor(offset: number) { + this.offset = offset; + const buffer = new ArrayBuffer(2); + this.view = new DataView(buffer); + } + + read(data: number[], index: number): number { + this.view.setUint8(0, data[index + this.offset]); + this.view.setUint8(1, data[index + this.offset + 1]); + return this.view.getUint16(0, true); + } +} + +class Uint8Reader implements FieldReader { + offset: number; + constructor(offset: number) { + this.offset = offset; + } + + read(data: number[], index: number): number { + return data[index + this.offset]; + } +} + +class FieldOffsets { + x: number = 0; + y: number = 0; + z: number = 0; + rgb: number = 0; + reader: ?FieldReader; +} + +function getFieldOffsets(fields: PointCloud2Field[], colorField: ?string): ?FieldOffsets { + const result = new FieldOffsets(); + const xField = find(fields, { name: "x" }); + if (!xField) { + log.error("Unable to find x field for point cloud"); + return null; + } + if (xField.datatype !== datatype.float32) { + log.error("x value not represented as float32"); + return null; + } + result.x = xField.offset; + + const yField = find(fields, { name: "y" }); + if (!yField) { + log.error("Unable to find y field for point cloud"); + return null; + } + if (yField.datatype !== datatype.float32) { + log.error("y value not represented as float32"); + return null; + } + result.y = yField.offset; + + const zField = find(fields, { name: "z" }); + if (!zField) { + log.error("Unable to find z field for point cloud"); + return null; + } + if (zField.datatype !== datatype.float32) { + log.error("z value not represented ast float32"); + return null; + } + result.z = zField.offset; + + const rgbField = find(fields, { name: "rgb" }); + result.rgb = rgbField ? rgbField.offset : -1; + + const field = colorField ? find(fields, { name: colorField }) : false; + if (field) { + switch (field.datatype) { + case datatype.float32: + result.reader = new Float32Reader(field.offset); + break; + case datatype.uint8: + result.reader = new Uint8Reader(field.offset); + break; + case datatype.uint16: + result.reader = new Uint16Reader(field.offset); + break; + case datatype.int32: + result.reader = new Int32Reader(field.offset); + break; + default: + log.error("colorField of value other than float32, uint16, int32, or uint8 not supported", field.datatype); + } + } + + return result; +} + +const emptyArray = []; + +export function mapMarker(marker: PointCloud2) { + // http://docs.ros.org/api/sensor_msgs/html/msg/PointCloud2.html + const { fields, data, width, row_step: rowStep, height, point_step: dataStep, colorField, color } = marker; + + const offsets = getFieldOffsets(fields, colorField); + + if (!offsets) { + console.warn("missing field offsets for marker"); + return { points: [], colors: [] }; + } + + const pointStep = 12; + const colorStep = 3; + const points = new Uint8Array(width * height * 12); + const colors = new Uint8Array(width * height * 3); + let pointCount = 0; + let minColorFieldValue = Number.MAX_VALUE; + let maxColorFieldValue = Number.MIN_VALUE; + // the field "rgb" has a special parsing code path - it's packed into the first three bytes of a float32 + // so if the colorField is rgb don't use a reader - its faster to just read the value directly + // it also is the default rendering option if no custom colorField is specified + const useRGB = offsets.rgb !== -1 && (!colorField || colorField === "rgb"); + const useColorField = !useRGB && offsets.reader; + + // use empty array if not coloring with colorField to avoid unneeded allocation + const colorFieldValues = useColorField ? new Array(width * height) : emptyArray; + + // in practice we use height:1 pointCloud2 messages + // but for completeness sake, we support any height of array + for (let j = 0; j < height; j++) { + const dataOffset = j * rowStep; + for (let i = 0; i < width; i++) { + const dataStart = i * dataStep + dataOffset; + const x1 = data[dataStart + offsets.x]; + const x2 = data[dataStart + offsets.x + 1]; + const x3 = data[dataStart + offsets.x + 2]; + const x4 = data[dataStart + offsets.x + 3]; + + const pointStart = pointCount * pointStep; + + // if the value is NaN then don't count this point + // this is to support non-dense point clouds + // https://answers.ros.org/question/234455/pointcloud2-and-pointfield/ + if ((x4 & 0x7f) === 0x7f && (x3 & 0x80) === 0x80) { + continue; + } + + // add x point + points[pointStart] = x1; + points[pointStart + 1] = x2; + points[pointStart + 2] = x3; + points[pointStart + 3] = x4; + + // add y point + points[pointStart + 4] = data[dataStart + offsets.y]; + points[pointStart + 5] = data[dataStart + offsets.y + 1]; + points[pointStart + 6] = data[dataStart + offsets.y + 2]; + points[pointStart + 7] = data[dataStart + offsets.y + 3]; + + // add z point + points[pointStart + 8] = data[dataStart + offsets.z]; + points[pointStart + 9] = data[dataStart + offsets.z + 1]; + points[pointStart + 10] = data[dataStart + offsets.z + 2]; + points[pointStart + 11] = data[dataStart + offsets.z + 3]; + + // add color + const colorStart = pointCount * colorStep; + + if (useRGB) { + colors[colorStart] = data[dataStart + offsets.rgb]; + colors[colorStart + 1] = data[dataStart + offsets.rgb + 1]; + colors[colorStart + 2] = data[dataStart + offsets.rgb + 2]; + } else if (useColorField && offsets.reader) { + const colorValue = offsets.reader.read(data, dataStart); + colorFieldValues[pointCount] = colorValue; + if (!Number.isNaN(colorValue)) { + minColorFieldValue = Math.min(minColorFieldValue, colorValue); + maxColorFieldValue = Math.max(maxColorFieldValue, colorValue); + } + } else { + // rgb isn't mandatory - color white if not found in fields + colors[colorStart] = 255; + colors[colorStart + 1] = 255; + colors[colorStart + 2] = 255; + } + + // increase point count by 1 + pointCount++; + } + } + + // we need to loop through colorField values again now that we know min/max + // and assign a color to the pointCloud for each colorField value + if (useColorField || color) { + // if min and max are equal set the diff to something huge + // so when we divide by it we effectively get zero. + // taken from http://docs.ros.org/jade/api/rviz/html/c++/point__cloud__transformers_8cpp_source.html + // line 132 + const colorFieldRange = maxColorFieldValue - minColorFieldValue || 1e20; + const parsedColor = getParsedColor(color); + for (let i = 0; i < pointCount; i++) { + const idx = i * 3; + const val = colorFieldValues[i]; + const pct = (val - minColorFieldValue) / colorFieldRange; + + if (parsedColor) { + colors[idx] = parsedColor[0]; + colors[idx + 1] = parsedColor[1]; + colors[idx + 2] = parsedColor[2]; + } else { + setColorFieldColor(colors, idx, pct, color); + } + } + } + + return { + ...marker, + points: new Float32Array(points.buffer, 0, pointCount * 3), + colors, + }; +} + +function getParsedColor(color) { + if (color) { + const rgbVals = color.split(",").map((char) => parseInt(char)); + const r = rgbVals[0] || 0; + const g = rgbVals[1] || 0; + const b = rgbVals[2] || 0; + return [r, g, b]; + } + return null; +} + +// taken from http://docs.ros.org/jade/api/rviz/html/c++/point__cloud__transformers_8cpp_source.html +// line 47 +// TODO - implement 1 solid color, hue change (e.g. spread from (55, 0, 0, 1) => (30, 0, 0, 1)); +function setColorFieldColor(colors: Uint8Array, idx: number, val: number, color?: string) { + const h = (1 - val) * 5.0 + 1.0; + const i = Math.floor(h); + let f = h % 1.0; + // if i is even + if ((i & 1) === 0) { + f = 1 - f; + } + const n = 1 - f; + let r = 0; + let g = 0; + let b = 0; + if (i <= 1) { + r = n; + g = 0; + b = 1; + } else if (i === 2) { + r = 0; + g = n; + b = 1; + } else if (i === 3) { + r = 0; + g = 1; + b = n; + } else if (i === 4) { + r = n; + g = 1; + b = 0; + } else { + r = 1; + g = n; + b = 0; + } + colors[idx] = r * 255; + colors[idx + 1] = g * 255; + colors[idx + 2] = b * 255; +} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/Pointclouds/PointCloudBuilder.test.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/Pointclouds/PointCloudBuilder.test.js new file mode 100644 index 000000000..9e9940d2b --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/Pointclouds/PointCloudBuilder.test.js @@ -0,0 +1,449 @@ +// @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 { mapMarker } from "./PointCloudBuilder"; + +const fields = [ + { + name: "x", + offset: 0, + datatype: 7, + count: 1, + }, + { + name: "y", + offset: 4, + datatype: 7, + count: 1, + }, + { + name: "z", + offset: 8, + datatype: 7, + count: 1, + }, + { + name: "rgb", + offset: 16, + datatype: 7, + count: 1, + }, +]; + +const msg = { + fields, + type: 102, + name: "foo", + pose: { + position: { x: 0, y: 0, z: 0 }, + orientation: { x: 0, y: 0, z: 0, w: 0 }, + }, + header: { + frame_id: "root_frame_id", + stamp: { + sec: 10, + nsec: 10, + }, + }, + height: 1, + is_bigendian: 0, + is_dense: 1, + point_step: 32, + row_step: 32, + width: 2, + data: [ + // point 1 + 125, + 236, + 11, + 197, + 118, + 102, + 48, + 196, + 50, + 194, + 23, + 192, + 0, + 0, + 128, + 63, + 255, + 225, + 127, + 0, + 254, + 127, + 0, + 0, + 16, + 142, + 140, + 0, + 161, + 254, + 127, + 0, + // point 2 + 125, + 236, + 11, + 197, + 118, + 102, + 48, + 196, + 50, + 194, + 23, + 192, + 0, + 0, + 128, + 63, + 255, + 255, + 127, + 0, + 254, + 127, + 0, + 0, + 16, + 142, + 140, + 0, + 161, + 254, + 127, + 0, + // point 3 + 118, + 102, + 48, + 196, + 125, + 236, + 11, + 197, + 50, + 194, + 23, + 192, + 0, + 0, + 128, + 63, + 127, + 255, + 127, + 0, + 254, + 127, + 0, + 0, + 16, + 142, + 140, + 0, + 161, + 254, + 127, + 8, + ], +}; + +describe("PointCloudBuilder", () => { + it("builds point cloud out of simple PointCloud2", () => { + const result = mapMarker(msg); + const pos = result.points; + const colorCodes = result.colors; + expect(pos).toHaveLength(6); + expect(Math.floor(pos[0])).toBe(-2239); + expect(Math.floor(pos[1])).toBe(-706); + expect(Math.floor(pos[2])).toBe(-3); + expect(Math.floor(pos[3])).toBe(-2239); + expect(Math.floor(pos[4])).toBe(-706); + expect(Math.floor(pos[5])).toBe(-3); + expect(colorCodes[0]).toBe(255); + expect(colorCodes[1]).toBe(225); + expect(colorCodes[2]).toBe(127); + expect(colorCodes[3]).toBe(255); + expect(colorCodes[4]).toBe(255); + expect(colorCodes[5]).toBe(127); + }); + + it("uses rgb values when rendering by rgb colorfield", () => { + const result = mapMarker({ ...msg, colorField: "rgb" }); + const pos = result.points; + const colorCodes = result.colors; + expect(pos).toHaveLength(6); + expect(Math.floor(pos[0])).toBe(-2239); + expect(Math.floor(pos[1])).toBe(-706); + expect(Math.floor(pos[2])).toBe(-3); + expect(Math.floor(pos[3])).toBe(-2239); + expect(Math.floor(pos[4])).toBe(-706); + expect(Math.floor(pos[5])).toBe(-3); + expect(colorCodes[0]).toBe(255); + expect(colorCodes[1]).toBe(225); + expect(colorCodes[2]).toBe(127); + expect(colorCodes[3]).toBe(255); + expect(colorCodes[4]).toBe(255); + expect(colorCodes[5]).toBe(127); + }); + + it("builds point cloud with custom colors", () => { + const input = { + ...msg, + colorField: "x", + }; + const result = mapMarker(input); + const pos = result.points; + const colorCodes = result.colors; + expect(pos).toHaveLength(6); + expect(Math.floor(pos[0])).toBe(-2239); + expect(Math.floor(pos[1])).toBe(-706); + expect(Math.floor(pos[2])).toBe(-3); + expect(Math.floor(pos[3])).toBe(-2239); + expect(Math.floor(pos[4])).toBe(-706); + expect(Math.floor(pos[5])).toBe(-3); + expect(colorCodes[0]).toBe(255); + expect(colorCodes[1]).toBe(0); + expect(colorCodes[2]).toBe(0); + expect(colorCodes[3]).toBe(255); + expect(colorCodes[4]).toBe(0); + expect(colorCodes[5]).toBe(0); + }); + + it("builds a point cloud with height 3", () => { + const result = mapMarker({ + ...msg, + height: 3, + width: 1, + row_step: 32, + }); + const pos = result.points; + expect(pos).toHaveLength(9); + expect(Math.floor(pos[0])).toBe(-2239); + expect(Math.floor(pos[1])).toBe(-706); + expect(Math.floor(pos[2])).toBe(-3); + expect(Math.floor(pos[3])).toBe(-2239); + expect(Math.floor(pos[4])).toBe(-706); + expect(Math.floor(pos[5])).toBe(-3); + expect(Math.floor(pos[6])).toBe(-706); + expect(Math.floor(pos[7])).toBe(-2239); + expect(Math.floor(pos[8])).toBe(-3); + + const colorCodes = result.colors; + expect(colorCodes[0]).toBe(255); + expect(colorCodes[1]).toBe(225); + expect(colorCodes[2]).toBe(127); + expect(colorCodes[3]).toBe(255); + expect(colorCodes[4]).toBe(255); + expect(colorCodes[5]).toBe(127); + expect(colorCodes[6]).toBe(127); + expect(colorCodes[7]).toBe(255); + expect(colorCodes[8]).toBe(127); + }); + + const vel = { + fields: [ + { + name: "x", + offset: 0, + datatype: 7, + count: 1, + }, + { + name: "y", + offset: 4, + datatype: 7, + count: 1, + }, + { + name: "z", + offset: 8, + datatype: 7, + count: 1, + }, + { + name: "intensity", + offset: 16, + datatype: 2, + count: 1, + }, + ], + type: 102, + header: { + frame_id: "root_frame_id", + stamp: { + sec: 10, + nsec: 10, + }, + }, + pose: { + position: { x: 0, y: 0, z: 0 }, + orientation: { x: 0, y: 0, z: 0, w: 0 }, + }, + name: "foo", + data: [ + // point 1 + 0, + 0, + 192, + 127, + 0, + 0, + 192, + 127, + 0, + 0, + 192, + 127, + 0, + 0, + 192, + 127, + 255, + 255, + 255, + 255, + ], + height: 1, + is_bigendian: 0, + is_dense: 0, + point_step: 20, + row_step: 20, + width: 1, + }; + + it("builts point cloud based on velodyne_organized containing nan values", () => { + const result = mapMarker(vel); + expect(result.points).toHaveLength(0); + }); + + describe("color field data type reading", () => { + const marker = { + fields: [ + { + name: "x", + offset: 0, + datatype: 7, + count: 1, + }, + { + name: "y", + offset: 4, + datatype: 7, + count: 1, + }, + { + name: "z", + offset: 8, + datatype: 7, + count: 1, + }, + { + name: "foo", + offset: 12, + datatype: 2, + count: 1, + }, + { + name: "bar", + offset: 13, + datatype: 4, + count: 1, + }, + { + name: "baz", + offset: 15, + datatype: 5, + count: 1, + }, + ], + type: 102, + name: "foo", + pose: { + position: { x: 0, y: 0, z: 0 }, + orientation: { x: 0, y: 0, z: 0, w: 0 }, + }, + header: { + frame_id: "root_frame_id", + stamp: { + sec: 10, + nsec: 10, + }, + }, + height: 1, + is_bigendian: 0, + is_dense: 1, + point_step: 19, + row_step: 19, + width: 2, + data: [ + 0, // start of point 1 + 0, + 0, + 0, // x: float32 = 0 + 0, + 0, + 128, + 63, // y: float32 = 1 + 0, + 0, + 0, + 64, // z: float32 = 2 + 7, // foo: uint8 = 7 + 6, + 0, // bar: uint16 = 6 + 5, + 0, + 0, + 0, // baz: int32 = 5 + 0, // start of point 2 + 0, + 0, + 0, // x: float32 = 0 + 0, + 0, + 128, + 63, // y: float32 = 1 + 0, + 0, + 0, + 64, // z: float32 = 2 + 9, // foo: uint8 = 9 + 8, + 0, // bar: uint16 = 8 + 7, + 0, + 0, + 0, // baz: int32 = 7 + ], + }; + + it("reads uint8", () => { + const result = mapMarker({ ...marker, colorField: "foo" }); + // because we only have 2 values, the colors should be min/max of rainbow spectrum + expect(result.colors).toEqual(new Uint8Array([255, 0, 0, 255, 0, 255])); + }); + + it("reads uint16", () => { + const result = mapMarker({ ...marker, colorField: "bar" }); + // because we only have 2 values, the colors should be min/max of rainbow spectrum + expect(result.colors).toEqual(new Uint8Array([255, 0, 0, 255, 0, 255])); + }); + + it("reads int32", () => { + const result = mapMarker({ ...marker, colorField: "baz" }); + // because we only have 2 values, the colors should be min/max of rainbow spectrum + expect(result.colors).toEqual(new Uint8Array([255, 0, 0, 255, 0, 255])); + }); + }); +}); diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/Pointclouds/index.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/Pointclouds/index.js new file mode 100644 index 000000000..e0599e834 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/Pointclouds/index.js @@ -0,0 +1,91 @@ +// @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 { makeCommand, withPose, type Regl } from "regl-worldview"; + +import { mapMarker } from "webviz-core/src/panels/ThreeDimensionalViz/commands/Pointclouds/PointCloudBuilder"; +import { type PointCloud } from "webviz-core/src/types/Messages"; + +const pointCloud = (regl: Regl) => { + const pointCloudCommand = withPose({ + primitive: "points", + vert: ` + precision mediump float; + + // this comes from the camera + uniform mat4 projection, view; + + #WITH_POSE + + attribute vec3 position; + attribute vec3 color; + uniform float pointSize; + varying vec3 fragColor; + void main () { + gl_PointSize = pointSize; + vec3 p = applyPose(position); + gl_Position = projection * view * vec4(p, 1); + fragColor = color; + } + `, + frag: ` + precision mediump float; + varying vec3 fragColor; + uniform bool isCircle; + void main () { + if (isCircle) { + vec3 normal; + normal.xy = gl_PointCoord * 2.0 - 1.0; + float r2 = dot(normal.xy, normal.xy); + + if (r2 > 1.0) { + discard; + } + } + + gl_FragColor = vec4(fragColor.x / 255.0, fragColor.y / 255.0, fragColor.z / 255.0, 1); + } + `, + attributes: { + position: regl.prop("points"), + color: regl.prop("colors"), + }, + + uniforms: { + pointSize: (context, props) => { + return props.pointSize || 2; + }, + isCircle: (context, props) => { + return props.pointShape ? props.pointShape === "circle" : true; + }, + }, + + count: (context, props) => { + return props.points.length / 3; + }, + }); + + const command = regl(pointCloudCommand); + + return (props: any) => { + const arr = Array.isArray(props) ? props : [props]; + + const mapped = arr.map((props) => { + return props.settings + ? mapMarker({ + ...props, + ...props.settings, + }) + : mapMarker(props); + }); + + command(mapped); + }; +}; + +export default makeCommand("PointClouds", pointCloud); diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/index.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/index.js new file mode 100644 index 000000000..56980c9bd --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/index.js @@ -0,0 +1,10 @@ +// @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. +export { default as LaserScans } from "./LaserScans"; +export { default as OccupancyGrids } from "./OccupancyGrids"; +export { default as PointClouds } from "./Pointclouds"; diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/utils/index.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/utils/index.js new file mode 100644 index 000000000..bcbe7b5ee --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/utils/index.js @@ -0,0 +1,126 @@ +// @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 tinycolor from "tinycolor2"; + +import type { OccupancyGridMessage } from "webviz-core/src/types/Messages"; + +export function setRgba(buffer: Uint8Array, index: number, color: tinycolor) { + const rgba255 = color.toRgb(); + rgba255.a *= 255; + buffer[index] = rgba255.r; + buffer[index + 1] = rgba255.g; + buffer[index + 2] = rgba255.b; + buffer[index + 3] = rgba255.a; +} + +export const defaultMapPalette = (() => { + const buff = new Uint8Array(256 * 4); + + // standard gray map palette values + for (let i = 0; i <= 100; i++) { + const t = 1 - i / 100; + const idx = i * 4; + setRgba(buff, idx, tinycolor.fromRatio({ r: t, g: t, b: t })); + } + + // illegal positive values in green + for (let i = 101; i <= 127; i++) { + const idx = i * 4; + setRgba(buff, idx, tinycolor("lime")); + } + + // illegal negative (char) values + for (let i = 128; i <= 254; i++) { + const idx = i * 4; + const t = (i - 128) / (254 - 128); + setRgba(buff, idx, tinycolor.fromRatio({ r: t, g: 0.2, b: 0.6, a: Math.max(1 - t, 0.2) })); + } + + // legal -1 value + setRgba(buff, 255 * 4, tinycolor("#99d6b1").setAlpha(0.25)); + return buff; +})(); + +// convert a number array to a typed array +// passing a typed array to regl is orders of magnitude +// faster than passing a number[] and letting regl do the conversion +function toTypedArray(data: number[] | Int8Array): Uint8Array { + if (data instanceof Int8Array) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } + const result = new Uint8Array(data.length); + for (let i = 0; i < data.length; i++) { + result[i] = data[i]; + } + return result; +} + +class TextureCacheEntry { + marker: OccupancyGridMessage; + texture: any; + // regl context + regl: any; + + constructor(regl, marker) { + this.marker = marker; + this.regl = regl; + const { info, data } = marker; + + this.texture = regl.texture({ + format: "alpha", + mipmap: false, + data: toTypedArray(data), + width: info.width, + height: info.height, + }); + } + + // get the texture for a marker + // if the marker is not the same reference + // generate a new texture, otherwise keep the old one + // uploading new texture data to the gpu is something + // you only want to do when required - it takes several milliseconds + getTexture(marker) { + if (this.marker === marker) { + return this.texture; + } + this.marker = marker; + const { info, data } = marker; + this.texture = this.texture({ + format: "alpha", + mipmap: false, + data: toTypedArray(data), + width: info.width, + height: info.height, + }); + return this.texture; + } +} + +export class TextureCache { + store: { [string]: TextureCacheEntry } = {}; + // regl context + regl: any; + + constructor(regl: any) { + this.regl = regl; + } + + // returns a regl texture for a given marker + get(marker: OccupancyGridMessage) { + const { name } = marker; + const item = this.store[name]; + if (!item) { + // if the item is missing initialize a new entry + const entry = new TextureCacheEntry(this.regl, marker); + this.store[name] = entry; + return entry.texture; + } + return item.getTexture(marker); + } +} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/index.help.md b/packages/webviz-core/src/panels/ThreeDimensionalViz/index.help.md new file mode 100644 index 000000000..b11eae5d8 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/index.help.md @@ -0,0 +1,19 @@ +# 3D View + +The 3D view plots visualization messages and a world map in a 3D scene. Topics can be selected from the left-hand topic list panel. Selected topics will have their messages visualized within the 3D scene. Topic selection is part of the configuration and will be saved between reloads and can be shared with `Import / Export Layout`. This panel can be expanded / collapsed by clicking on the caret icon to the left of it. + +The scene will load a map tile set centered around the position of the cruise car. The map will always be the 'lowest' object in the scene, with all other objects plotted above it. By default the scene will follow the position of the cruise car and the 'follow' toggle button in the upper right of the map will be green. You can toggle this behavior by clicking on the button. + +You can toggle between a 3D perspective camera and a top-down, 2D orthographic camera of the scene by clicking on the _toggle 2D / 3D_ button in the top left of the 3D view panel. + +`Left-click + drag` on the scene to move the camera position parallel to the ground. If 'follow' mode is on this will disengage it. + +`Right-click + drag` on the scene to pan and rotate the camera. Dragging left/right will rotate the camera around the Z axis, and in 3D camera mode dragging top/bottom will pan the camera around the map's x/y axis. + +`Mouse-wheel` controls the 'zoom' of the camera. Wheeling "up" will zoom the camera closer to the map while wheeling "down" will zoom the camera farther away from the map. + +In 3D camera mode, you can also use "shooter controls" (like those found in most popular desktop first-person shooter games) of `a` `s` `d` `f` to move the camera 'left / backwards / right / forward' respective to the camera's position, and use `z` `x` to move the camera "up" and "down" respective to its position. It's easy to get lost when using these controls as there is nothing anchoring the camera to the scene. + +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._ diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/index.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/index.js new file mode 100644 index 000000000..46d64457a --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/index.js @@ -0,0 +1,332 @@ +// @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 { omit, mergeWith } from "lodash"; +import * as React from "react"; +import { connect } from "react-redux"; +import { DEFAULT_CAMERA_STATE, cameraStateSelectors, type Vec3, type Vec4, type CameraState } from "regl-worldview"; + +import { registerMarkerProvider, unregisterMarkerProvider } from "webviz-core/src/actions/extensions"; +import GlobalVariablesAccessor from "webviz-core/src/components/GlobalVariablesAccessor"; +import { FrameCompatibility } from "webviz-core/src/components/MessageHistory/FrameCompatibility"; +import { MessagePipelineConsumer, type MessagePipelineContext } from "webviz-core/src/components/MessagePipeline"; +import Panel from "webviz-core/src/components/Panel"; +import { getGlobalHooks } from "webviz-core/src/loadWebviz"; +import helpContent from "webviz-core/src/panels/ThreeDimensionalViz/index.help.md"; +import Layout from "webviz-core/src/panels/ThreeDimensionalViz/Layout"; +import type { TopicSettingsCollection } from "webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder"; +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 { 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"; +import { emptyPose } from "webviz-core/src/util/Pose"; + +export type ThreeDimensionalVizConfig = { + autoTextBackgroundColor?: boolean, + checkedNodes: string[], + expandedNodes: string[], + cameraState: $Shape, + followTf?: string | false, + followOrientation?: boolean, + topicSettings: TopicSettingsCollection, + modifiedNamespaceTopics: string[], + pinTopics: boolean, + savedPropsVersion?: ?number, // eslint-disable-line react/no-unused-prop-types + // legacy props + hideMap?: ?boolean, // eslint-disable-line react/no-unused-prop-types + useHeightMap?: ?boolean, // eslint-disable-line react/no-unused-prop-types + follow?: boolean, + flattenMarkers?: boolean, +}; + +export type Props = { + topics: Topic[], + frame: Frame, + transforms: Transforms, + // these come from savedProps + config: ThreeDimensionalVizConfig, + + // For other panels that wrap this one. + helpContent: React.Node | string, + + saveConfig: SaveConfig, + setSubscriptions: (string[]) => void, + registerMarkerProvider: (MarkerProvider) => void, + unregisterMarkerProvider: (MarkerProvider) => void, + mouseClick: ({}) => void, + cleared?: boolean, +}; + +// Hold selections in the top level panel state. +// This allows the check/uncheck logic to easily propagate out from the TopicSelector sub-component +// (by calling setSelections) while still providing the current selections in a top-down manner +// so they can be consumed elsewhere in the 3D Viz panel. +type State = {| + selections: Selections, + topics: Topic[], + checkedNodes: string[], + cameraState: CameraState, + + // Store last seen target pose because the target may become available/unavailable over time as + // the player changes, and we want to avoid moving the camera when it disappears. + lastTargetPose: ?{| + target: Vec3, + targetOrientation: Vec4, + |}, +|}; + +const ZOOM_LEVEL_URL_PARAM = "zoom"; + +const getZoomDistanceFromURLParam = (): number | void => { + const params = new URLSearchParams(location && location.search); + if (params.has(ZOOM_LEVEL_URL_PARAM)) { + return parseFloat(params.get(ZOOM_LEVEL_URL_PARAM)); + } +}; + +// Get the camera target position and orientation +function getTargetPose(followTf?: string | false, transforms: Transforms) { + if (followTf) { + let pose = emptyPose(); + pose = transforms.apply(pose, pose, followTf, transforms.rootOfTransform(followTf).id); + if (pose) { + const { x: px, y: py, z: pz } = pose.position; + const { x: ox, y: oy, z: oz, w: ow } = pose.orientation; + return { + target: [px, py, pz], + targetOrientation: [ox, oy, oz, ow], + }; + } + } + return null; +} + +// Return targetOffset and thetaOffset that would yield the same camera position as the +// given offsets if the target were (0,0,0) and targetOrientation were identity. +function getEquivalentOffsetsWithoutTarget( + offsets: { +targetOffset: Vec3, +thetaOffset: number }, + targetPose: { +target: Vec3, +targetOrientation: Vec4 }, + followingOrientation?: boolean +): { targetOffset: Vec3, thetaOffset: number } { + const heading = followingOrientation + ? cameraStateSelectors.targetHeading({ targetOrientation: targetPose.targetOrientation }) + : 0; + const targetOffset = vec3.rotateZ([0, 0, 0], offsets.targetOffset, [0, 0, 0], -heading); + vec3.add(targetOffset, targetOffset, targetPose.target); + const thetaOffset = offsets.thetaOffset + heading; + return { targetOffset, thetaOffset }; +} + +export class Renderer extends React.Component { + static displayName = "ThreeDimensionalViz"; + static panelType = "3D Panel"; + static defaultConfig = getGlobalHooks().perPanelHooks().ThreeDimensionalViz.defaultConfig; + + state = { + selections: new Selections(), + topics: [], + checkedNodes: [], + cameraState: DEFAULT_CAMERA_STATE, + lastTargetPose: undefined, + }; + + onSelectionsChanged = (selections: Selections): void => { + this.setState({ selections }); + }; + + onCameraStateChange = (cameraState: CameraState) => { + this.props.saveConfig( + { cameraState: omit(cameraState, ["target", "targetOrientation"]) }, + { keepLayoutInUrl: true } + ); + }; + + onAlignXYAxis = () => { + const { + saveConfig, + config: { cameraState }, + } = this.props; + saveConfig({ + followOrientation: false, + cameraState: { ...omit(cameraState, ["target", "targetOrientation"]), thetaOffset: 0 }, + }); + }; + + onFollowChange = (newFollowTf?: string | false, newFollowOrientation?: boolean) => { + const { config, saveConfig, transforms } = this.props; + const targetPose = getTargetPose(newFollowTf, transforms) || this.state.lastTargetPose; + + const newCameraState = { ...config.cameraState }; + const offsets = { + targetOffset: config.cameraState.targetOffset, + thetaOffset: config.cameraState.thetaOffset, + }; + + if (newFollowTf) { + // When switching to follow orientation, adjust thetaOffset to preserve camera rotation. + if (newFollowOrientation && !config.followOrientation && targetPose) { + const heading = cameraStateSelectors.targetHeading({ targetOrientation: targetPose.targetOrientation }); + newCameraState.targetOffset = vec3.rotateZ([0, 0, 0], newCameraState.targetOffset, [0, 0, 0], heading); + newCameraState.thetaOffset -= heading; + } + // When following a frame for the first time, snap to the origin. + if (!config.followTf) { + newCameraState.targetOffset = [0, 0, 0]; + } + } else if (config.followTf && targetPose) { + // When unfollowing, preserve the camera position and orientation. + Object.assign(newCameraState, getEquivalentOffsetsWithoutTarget(offsets, targetPose, config.followOrientation)); + } + + saveConfig({ + followTf: newFollowTf, + followOrientation: newFollowOrientation, + cameraState: newCameraState, + }); + }; + + static getDerivedStateFromProps(nextProps: Props, prevState: State): ?$Shape { + // don't set state if we're migrating props + if ( + getGlobalHooks() + .perPanelHooks() + .ThreeDimensionalViz.migrateConfig(nextProps) + ) { + return null; + } + + const { config, topics, setSubscriptions, transforms } = nextProps; + const { checkedNodes, followTf, followOrientation } = config; + + const newState: $Shape = {}; + + const newCameraState: $Shape = {}; + const targetPose = getTargetPose(followTf, transforms); + + if (targetPose) { + newState.lastTargetPose = targetPose; + + newCameraState.target = targetPose.target; + if (followOrientation) { + newCameraState.targetOrientation = targetPose.targetOrientation; + } + } else if (followTf && prevState.lastTargetPose) { + // If follow is enabled but no target is available (such as when seeking), keep the camera + // position the same as it would have beeen by reusing the last seen target pose. + newCameraState.target = prevState.lastTargetPose.target; + if (followOrientation) { + newCameraState.targetOrientation = prevState.lastTargetPose.targetOrientation; + } + } + + // Read the distance from URL when World is first loaded with empty cameraState + let { distance } = config.cameraState; + if (distance == null) { + distance = getZoomDistanceFromURLParam(); + } + + newState.cameraState = mergeWith( + { + ...config.cameraState, + ...newCameraState, + distance, + }, + DEFAULT_CAMERA_STATE, + (objVal, srcVal) => (objVal == null ? srcVal : objVal) + ); + + // no need to derive state if topics & checked nodes haven't changed + if (topics !== prevState.topics || checkedNodes !== prevState.checkedNodes) { + // build a copy of the tree to determine which topics are active + const root = treeBuilder({ + topics, + checkedNodes, + expandedNodes: [], + namespaces: [], + modifiedNamespaceTopics: [], + transforms: transforms.values(), + }); + + const selections = root.getSelections(); + setSubscriptions(selections.topics); + + const isOpenSource = checkedNodes.length === 1 && checkedNodes[0] === "name:Topics" && topics.length; + if (isOpenSource) { + const newCheckedNodes = isOpenSource ? checkedNodes.concat(topics.map((t) => t.name)) : checkedNodes; + nextProps.saveConfig({ checkedNodes: newCheckedNodes }, { keepLayoutInUrl: true }); + } + + newState.checkedNodes = checkedNodes; + newState.topics = topics; + newState.selections = prevState.selections; + } + + return newState; + } + + render() { + const { selections, topics, checkedNodes, cameraState } = this.state; + + return ( + + {({ playerState }: MessagePipelineContext) => { + const currentTime = playerState.activeData ? playerState.activeData.currentTime : { sec: 0, nsec: 0 }; + return ( + + {(globalData) => ( + + )} + + ); + }} + + ); + } +} + +export const frameCompatibilityOptionsThreeDimensionalViz = { + // always subscribe to critical topics + topics: [ + ...getGlobalHooks().perPanelHooks().ThreeDimensionalViz.topics, + TRANSFORM_TOPIC, + ...getGlobalHooks().perPanelHooks().ThreeDimensionalViz.getMetadata.topics, + ], + dontRemountOnSeek: true, // SceneBuilder is doing its own state management. +}; + +export default Panel( + FrameCompatibility( + withTransforms( + connect( + (state) => ({ + extensions: state.extensions, + }), + { registerMarkerProvider, unregisterMarkerProvider } + )(Renderer) + ), + frameCompatibilityOptionsThreeDimensionalViz + ) +); diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/logger.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/logger.js new file mode 100644 index 000000000..3c9233680 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/logger.js @@ -0,0 +1,22 @@ +// @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 { throttle } from "lodash"; + +const DELAY = 1000; + +const warn = throttle((...args: any[]) => console.warn(...args), DELAY); +const error = throttle((...args: any[]) => console.error(...args), DELAY); +const info = throttle((...args: any[]) => console.info(...args), DELAY); +const debug = throttle((...args: any[]) => console.log(...args), DELAY); + +export default { + debug, + info, + warn, + error, +}; diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/withTransforms.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/withTransforms.js new file mode 100644 index 000000000..ea1c18455 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/withTransforms.js @@ -0,0 +1,63 @@ +// @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 hoistNonReactStatics from "hoist-non-react-statics"; +import PropTypes from "prop-types"; +import * as React from "react"; + +import { getGlobalHooks } from "../../loadWebviz"; +import Transforms from "webviz-core/src/panels/ThreeDimensionalViz/Transforms"; +import type { Frame } from "webviz-core/src/types/players"; +import { TRANSFORM_TOPIC } from "webviz-core/src/util/globalConstants"; + +type State = {| transforms: Transforms |}; + +function withTransforms(ChildComponent: React.ComponentType) { + class Component extends React.PureComponent<$Shape<{| frame: Frame, cleared: boolean, forwardedRef: any |}>, State> { + static displayName = `withTransforms(${ChildComponent.displayName || ChildComponent.name || ""})`; + static contextTypes = { store: PropTypes.any }; + + state: State = { transforms: new Transforms() }; + + static getDerivedStateFromProps(nextProps: Props, prevState: State): ?$Shape { + const { frame, cleared } = nextProps; + let { transforms } = prevState; + if (cleared) { + transforms = new Transforms(); + } + + getGlobalHooks() + .perPanelHooks() + .ThreeDimensionalViz.consumePose(frame, transforms); + + const tfs = frame[TRANSFORM_TOPIC]; + if (tfs) { + for (const msg of tfs) { + for (const tf of msg.message.transforms) { + if (tf.child_frame_id !== getGlobalHooks().perPanelHooks().ThreeDimensionalViz.skipTranformFrame) { + transforms.consume(tf); + } + } + } + } + return { transforms }; + } + + render() { + // $FlowFixMe - can't seem to figure out how to properly type this. + return ; + } + } + return hoistNonReactStatics( + React.forwardRef((props, ref) => { + return ; + }), + ChildComponent + ); +} + +export default withTransforms; diff --git a/packages/webviz-core/src/panels/TopicEcho/fixture.js b/packages/webviz-core/src/panels/TopicEcho/fixture.js index 4893efa89..7b98ddb64 100644 --- a/packages/webviz-core/src/panels/TopicEcho/fixture.js +++ b/packages/webviz-core/src/panels/TopicEcho/fixture.js @@ -80,3 +80,30 @@ export const fixture = { ], }, }; + +// separate fixture so that we only need to define datatypes for small subset of types +export const enumFixture = { + datatypes: { + "baz/enum": [ + { type: "uint8", name: "ERROR", isConstant: true, value: 0 }, + { type: "uint8", name: "OFF", isConstant: true, value: 1 }, + { type: "uint8", name: "BOOTING", isConstant: true, value: 2 }, + { type: "uint8", name: "ACTIVE", isConstant: true, value: 3 }, + { type: "uint8", name: "value", isArray: false }, + ], + }, + topics: [{ name: "/baz/enum", datatype: "baz/enum" }], + frame: { + "/baz/enum": [ + { + op: "message", + datatype: "baz/enum", + topic: "/baz/enum", + receiveTime: { sec: 123, nsec: 456789012 }, + message: { + value: 2, + }, + }, + ], + }, +}; diff --git a/packages/webviz-core/src/panels/TopicEcho/getValueActionForValue.js b/packages/webviz-core/src/panels/TopicEcho/getValueActionForValue.js index 4067774bf..e12a92f41 100644 --- a/packages/webviz-core/src/panels/TopicEcho/getValueActionForValue.js +++ b/packages/webviz-core/src/panels/TopicEcho/getValueActionForValue.js @@ -7,6 +7,7 @@ // You may not use this file except in compliance with the License. import { last } from "lodash"; +import memoizeWeak from "memoize-weak"; import { type MessagePathStructureItem, isTypicalFilterName } from "webviz-core/src/components/MessageHistory"; @@ -89,3 +90,41 @@ export function getValueActionForValue( }; } } + +// Given root structureItem (e.g. a message definition), +// and a key path (comma-joined) to navigate down, return strutureItem for the field at that path +// Using comma-joined path to allow memoization of this function +export const getStructureItemForPath = memoizeWeak( + (rootStructureItem: ?MessagePathStructureItem, keyPathJoined: string): ?MessagePathStructureItem => { + // split the path and parse into numbers and strings + const keyPath: (number | string)[] = []; + for (const part: string of keyPathJoined.split(",")) { + if (!isNaN(part)) { + keyPath.push(parseInt(part)); + } else { + keyPath.push(part); + } + } + let structureItem: ?MessagePathStructureItem = rootStructureItem; + // Walk down the keyPath, while updating `value` and `structureItem` + for (const pathItem: number | string of keyPath) { + if (structureItem == null) { + break; + } else if (structureItem.structureType === "message" && typeof pathItem === "string") { + structureItem = structureItem.nextByName[pathItem]; + } else if (structureItem.structureType === "array" && typeof pathItem === "number") { + structureItem = structureItem.next; + if (!structureItem) { + break; + } + } else if (structureItem.structureType === "primitive") { + // ROS has some primitives that contain nested data (time+duration). We currently don't + // support looking inside them. + return structureItem; + } else { + throw new Error(`Invalid strutureType: ${structureItem.structureType} for value/pathItem.`); + } + } + return structureItem; + } +); diff --git a/packages/webviz-core/src/panels/TopicEcho/getValueActionForValue.test.js b/packages/webviz-core/src/panels/TopicEcho/getValueActionForValue.test.js index e025d22c3..cc471ddf4 100644 --- a/packages/webviz-core/src/panels/TopicEcho/getValueActionForValue.test.js +++ b/packages/webviz-core/src/panels/TopicEcho/getValueActionForValue.test.js @@ -6,7 +6,7 @@ // 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 { getValueActionForValue } from "./getValueActionForValue"; +import { getValueActionForValue, getStructureItemForPath } from "./getValueActionForValue"; describe("getValueActionForValue", () => { it("returns undefined if it is not a primitive", () => { @@ -122,3 +122,63 @@ describe("getValueActionForValue", () => { expect(getValueActionForValue({ sec: 0, nsec: 0 }, structureItem, ["sec"])).toEqual(undefined); }); }); + +describe("getStructureItemForPath", () => { + it("returns a structureItem for an array element", () => { + const structureItem = { + structureType: "array", + next: { + structureType: "primitive", + primitiveType: "uint32", + datatype: "", + }, + }; + expect(getStructureItemForPath(structureItem, "0")).toEqual({ + structureType: "primitive", + primitiveType: "uint32", + datatype: "", + }); + }); + + it("returns a structureItem for a map element", () => { + const structureItem = { + structureType: "message", + nextByName: { + some_id: { + structureType: "primitive", + primitiveType: "uint32", + datatype: "", + }, + }, + datatype: "", + }; + expect(getStructureItemForPath(structureItem, "some_id")).toEqual({ + structureType: "primitive", + primitiveType: "uint32", + datatype: "", + }); + }); + + it("returns a structureItem multi elements path", () => { + const structureItem = { + structureType: "array", + next: { + structureType: "message", + nextByName: { + some_id: { + structureType: "primitive", + primitiveType: "uint32", + datatype: "", + }, + }, + datatype: "", + }, + datatype: "", + }; + expect(getStructureItemForPath(structureItem, "0,some_id")).toEqual({ + structureType: "primitive", + primitiveType: "uint32", + datatype: "", + }); + }); +}); diff --git a/packages/webviz-core/src/panels/TopicEcho/index.js b/packages/webviz-core/src/panels/TopicEcho/index.js index 5a75eccac..aef643ecf 100644 --- a/packages/webviz-core/src/panels/TopicEcho/index.js +++ b/packages/webviz-core/src/panels/TopicEcho/index.js @@ -19,7 +19,7 @@ import ReactHoverObserver from "react-hover-observer"; import Tree from "react-json-tree"; import styled from "styled-components"; -import { type ValueAction, getValueActionForValue } from "./getValueActionForValue"; +import { type ValueAction, getValueActionForValue, getStructureItemForPath } from "./getValueActionForValue"; import helpContent from "./index.help.md"; import styles from "./index.module.scss"; import EmptyState from "webviz-core/src/components/EmptyState"; @@ -35,8 +35,10 @@ import Panel from "webviz-core/src/components/Panel"; import PanelToolbar from "webviz-core/src/components/PanelToolbar"; import { getGlobalHooks } from "webviz-core/src/loadWebviz"; import Plot, { type PlotConfig, plotableRosTypes } from "webviz-core/src/panels/Plot"; +import { constantsByDatatype } from "webviz-core/src/selectors"; import colors from "webviz-core/src/styles/colors.module.scss"; import type { PanelConfig } from "webviz-core/src/types/panels"; +import type { RosDatatypes } from "webviz-core/src/types/RosDatatypes"; import clipboard from "webviz-core/src/util/clipboard"; import { format, formatDuration } from "webviz-core/src/util/time"; @@ -107,6 +109,7 @@ type Props = { config: Config, saveConfig: ($Shape) => void, openSiblingPanel: (string, cb: (PanelConfig) => PanelConfig) => void, + datatypes: RosDatatypes, }; type State = {| @@ -231,6 +234,21 @@ class TopicEcho extends React.PureComponent { keyPath.slice(0, -1).reverse() ); } + + let constantName: ?string; + if (metadata) { + const structureItem = getStructureItemForPath( + metadata.structureItem, + keyPath + .slice(0, -1) + .reverse() + .join(",") + ); + const { datatypes } = this.props; + if (structureItem) { + constantName = constantsByDatatype(datatypes)[structureItem.datatype][itemValue]; + } + } const basePath: string = queriedData[lastKeyPath].path; let itemLabel = label; // output preview for the first x items if the data is in binary format @@ -245,16 +263,23 @@ class TopicEcho extends React.PureComponent { // $FlowFixMe itemLabel = itemValue.constructor.name; } + if (constantName) { + itemLabel = `${itemLabel} (${constantName})`; + } return ( {itemLabel} {smallNumberArrayStr && ( - + <> {smallNumberArrayStr} - console.log(itemValue)} tooltip="Log data to console"> + console.log(itemValue)} + tooltip="Log data to browser console"> - + )} {valueAction && this._renderIcons(valueAction, basePath)} @@ -302,7 +327,6 @@ class TopicEcho extends React.PureComponent { const isSingleElemArray = Array.isArray(data) && data.length === 1 && typeof data[0] !== "object"; const shouldDisplaySingleVal = typeof data !== "object" || isSingleElemArray; const singleVal = isSingleElemArray ? data[0] : data; - return ( @@ -370,10 +394,10 @@ class TopicEcho extends React.PureComponent { return ( - - + {expandAll ? : } + {this._renderTopic()} diff --git a/packages/webviz-core/src/panels/TopicEcho/index.stories.js b/packages/webviz-core/src/panels/TopicEcho/index.stories.js index 81452bea6..7303a9805 100644 --- a/packages/webviz-core/src/panels/TopicEcho/index.stories.js +++ b/packages/webviz-core/src/panels/TopicEcho/index.stories.js @@ -10,7 +10,7 @@ import { storiesOf } from "@storybook/react"; import React from "react"; import { withScreenshot } from "storybook-chrome-screenshot"; -import { fixture } from "./fixture"; +import { fixture, enumFixture } from "./fixture"; import TopicEcho from "webviz-core/src/panels/TopicEcho"; import PanelSetup from "webviz-core/src/stories/PanelSetup"; @@ -50,4 +50,11 @@ storiesOf("", module) ); + }) + .add("display enum", () => { + return ( + + + + ); }); diff --git a/packages/webviz-core/src/panels/diagnostics/DiagnosticStatusPanel.js b/packages/webviz-core/src/panels/diagnostics/DiagnosticStatusPanel.js index 8b3f34ba7..426183935 100644 --- a/packages/webviz-core/src/panels/diagnostics/DiagnosticStatusPanel.js +++ b/packages/webviz-core/src/panels/diagnostics/DiagnosticStatusPanel.js @@ -53,13 +53,13 @@ class DiagnosticStatusPanel extends Component { } return ( - + {(buffer) => { const selectedItem = selectedId ? buffer.diagnosticsById.get(selectedId) : null; return ( - + <> { ) : ( No diagnostic node selected )} - + ); }} diff --git a/packages/webviz-core/src/panels/diagnostics/DiagnosticStatusPanel.stories.js b/packages/webviz-core/src/panels/diagnostics/DiagnosticStatusPanel.stories.js deleted file mode 100644 index 79c76c67a..000000000 --- a/packages/webviz-core/src/panels/diagnostics/DiagnosticStatusPanel.stories.js +++ /dev/null @@ -1,176 +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 { withKnobs, number } from "@storybook/addon-knobs"; -import { storiesOf } from "@storybook/react"; -import React from "react"; -import { findDOMNode } from "react-dom"; -import { withScreenshot } from "storybook-chrome-screenshot"; - -import DiagnosticStatusPanel from "webviz-core/src/panels/diagnostics/DiagnosticStatusPanel"; -import PanelSetup from "webviz-core/src/stories/PanelSetup"; -import { DIAGNOSTIC_TOPIC } from "webviz-core/src/util/globalConstants"; - -const fixture = { - topics: [{ name: DIAGNOSTIC_TOPIC, datatype: "diagnostic_msgs/DiagnosticArray" }], - frame: { - [DIAGNOSTIC_TOPIC]: [ - { - op: "message", - topic: DIAGNOSTIC_TOPIC, - datatype: "diagnostic_msgs/DiagnosticArray", - receiveTime: { - sec: 1529965609, - nsec: 181214696, - }, - message: { - header: { - seq: 209903, - stamp: { - sec: 1529965609, - nsec: 181087516, - }, - frame_id: "", - }, - status: [ - { - level: 0, - name: "node: Some synthetic diagnostic with long name", - message: "The summary of the message goes here", - hardware_id: "node", - values: [ - { - key: "Distance", - value: new Array(20).fill("foo "), - }, - { - key: "--can collapse--", - value: "", - }, - { - key: "foo reallylongwordthatshouldbeforcedtobreak", - value: "bar", - }, - { - key: "baz", - value: "hearts: <3\nqux reallylongwordthatshouldbeforcedtobreak", - }, - { - key: "supports basic html in keys/values", - value: `\ -like
this!
mono\ -space\ -
↫ And ↬
eventables!
\ -no hearts: <3`, - }, - ], - }, - ], - }, - }, - { - op: "message", - topic: DIAGNOSTIC_TOPIC, - datatype: "diagnostic_msgs/DiagnosticArray", - receiveTime: { - sec: 1529965609, - nsec: 181214696, - }, - message: { - header: { - seq: 209903, - stamp: { - sec: 1529965609, - nsec: 181087516, - }, - frame_id: "", - }, - status: [ - { - level: 0, - name: "SomePlanner: Status", - message: "TODO summary", - hardware_id: "some_node_health", - values: [ - { - key: "Previous State", - value: "1", - }, - { - key: "Current State", - value: "1", - }, - { - key: "State Transition Time", - value: "4.6e-08", - }, - ], - }, - ], - }, - }, - ], - }, -}; - -const selectedHardwareId = "node"; -const selectedName = "node: Some synthetic diagnostic with long name"; - -storiesOf("", module) - .addDecorator(withKnobs) - .add( - "simple", - withScreenshot({ knobs: { splitFraction: [0.25, undefined, 0.75] } })(() => { - const splitFraction = number("splitFraction", 0.4, { range: true, min: 0, max: 1, step: 0.01 }); - return ( - { - // $FlowFixMe - const resizeHandle: Element = findDOMNode(el) //eslint-disable-line react/no-find-dom-node - .querySelector("[data-test-resizehandle]"); - resizeHandle.tabIndex = 0; - resizeHandle.focus(); - }}> - - - ); - }) - ) - .add( - "waiting for message", - withScreenshot()(() => { - return ( - - - - ); - }) - ) - .add( - "empty state", - withScreenshot()(() => { - return ( - - - - ); - }) - ); diff --git a/packages/webviz-core/src/panels/diagnostics/DiagnosticSummary.stories.js b/packages/webviz-core/src/panels/diagnostics/DiagnosticSummary.stories.js deleted file mode 100644 index ec1d7e234..000000000 --- a/packages/webviz-core/src/panels/diagnostics/DiagnosticSummary.stories.js +++ /dev/null @@ -1,120 +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 { storiesOf } from "@storybook/react"; -import React from "react"; -import { withScreenshot } from "storybook-chrome-screenshot"; - -import DiagnosticSummary from "webviz-core/src/panels/diagnostics/DiagnosticSummary"; -import PanelSetup from "webviz-core/src/stories/PanelSetup"; -import { DIAGNOSTIC_TOPIC } from "webviz-core/src/util/globalConstants"; - -const fixture = { - topics: [{ name: DIAGNOSTIC_TOPIC, datatype: "diagnostic_msgs/DiagnosticArray" }], - frame: { - [DIAGNOSTIC_TOPIC]: [ - { - op: "message", - topic: DIAGNOSTIC_TOPIC, - datatype: "diagnostic_msgs/DiagnosticArray", - receiveTime: { - sec: 1529965609, - nsec: 181214696, - }, - message: { - header: { - seq: 209903, - stamp: { - sec: 1529965609, - nsec: 181087516, - }, - frame_id: "", - }, - status: [ - { - level: 0, - name: "node: Some synthetic diagnostic with long name", - message: "The summary of the message goes here", - hardware_id: "node", - values: [ - { - key: "Distance", - value: new Array(20).fill("foo "), - }, - { - key: "--can collapse--", - value: "", - }, - { - key: "foo reallylongwordthatshouldbeforcedtobreak", - value: "bar", - }, - { - key: "baz", - value: "qux reallylongwordthatshouldbeforcedtobreak", - }, - ], - }, - ], - }, - }, - { - op: "message", - topic: DIAGNOSTIC_TOPIC, - datatype: "diagnostic_msgs/DiagnosticArray", - receiveTime: { - sec: 1529965609, - nsec: 181214696, - }, - message: { - header: { - seq: 209903, - stamp: { - sec: 1529965609, - nsec: 181087516, - }, - frame_id: "", - }, - status: [ - { - level: 0, - name: "SomePlanner: Status", - message: "TODO summary", - hardware_id: "some_node_health", - values: [ - { - key: "Previous State", - value: "1", - }, - { - key: "Current State", - value: "1", - }, - { - key: "State Transition Time", - value: "4.6e-08", - }, - ], - }, - ], - }, - }, - ], - }, -}; - -storiesOf("", module).add( - "simple", - withScreenshot()(() => { - return ( - - - - ); - }) -); diff --git a/packages/webviz-core/src/panels/diagnostics/util.js b/packages/webviz-core/src/panels/diagnostics/util.js index 5a739d296..0af61fd97 100644 --- a/packages/webviz-core/src/panels/diagnostics/util.js +++ b/packages/webviz-core/src/panels/diagnostics/util.js @@ -54,7 +54,12 @@ export type DiagnosticsById = Map; export type DiagnosticsByLevel = {| [Level]: DiagnosticsById |}; export function getDiagnosticId(status: DiagnosticStatusMessage): DiagnosticId { - return `|${status.hardware_id}|${status.name}|`; + // Remove leading slash from hardware_id if present. + let hardware_id = status.hardware_id; + if (hardware_id.startsWith("/")) { + hardware_id = hardware_id.substring(1); + } + return `|${hardware_id}|${status.name}|`; } export function getDisplayName(hardwareId: string, name: string) { diff --git a/packages/webviz-core/src/panels/diagnostics/util.test.js b/packages/webviz-core/src/panels/diagnostics/util.test.js index 4d9c75479..ce2d0e894 100644 --- a/packages/webviz-core/src/panels/diagnostics/util.test.js +++ b/packages/webviz-core/src/panels/diagnostics/util.test.js @@ -6,9 +6,18 @@ // 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 { getDisplayName } from "./util"; +import { getDiagnosticId, getDisplayName } from "./util"; describe("diagnostics", () => { + describe("getDiagnosticId", () => { + it("removes leading slash from hardware_id if present", () => { + expect(getDiagnosticId({ hardware_id: "foo", name: "bar", level: 0 })).toBe("|foo|bar|"); + expect(getDiagnosticId({ hardware_id: "/foo", name: "bar", level: 0 })).toBe("|foo|bar|"); + expect(getDiagnosticId({ hardware_id: "//foo", name: "bar", level: 0 })).toBe("|/foo|bar|"); + expect(getDiagnosticId({ hardware_id: "foo", name: "/bar", level: 0 })).toBe("|foo|/bar|"); + }); + }); + describe("getDisplayName", () => { it("leaves old formatted diagnostic messages alone", () => { expect(getDisplayName("my_hardware_id", "my_hardware_id: foo")).toBe("my_hardware_id: foo"); diff --git a/packages/webviz-core/src/players/RandomAccessPlayer.js b/packages/webviz-core/src/players/RandomAccessPlayer.js index 61cae3563..827b73415 100644 --- a/packages/webviz-core/src/players/RandomAccessPlayer.js +++ b/packages/webviz-core/src/players/RandomAccessPlayer.js @@ -83,6 +83,9 @@ export default class RandomAccessPlayer implements Player { 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); }, }) diff --git a/packages/webviz-core/src/players/RandomAccessPlayer.test.js b/packages/webviz-core/src/players/RandomAccessPlayer.test.js index 23ce2b5a2..88fa7987e 100644 --- a/packages/webviz-core/src/players/RandomAccessPlayer.test.js +++ b/packages/webviz-core/src/players/RandomAccessPlayer.test.js @@ -129,6 +129,20 @@ 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(); + }, + }; + const source = new RandomAccessPlayer((provider: any)); + source.setSubscriptions([{ topic: "/foo/bar" }, { topic: "/foo/baz" }]); + source.setListener(() => Promise.resolve()); + }); it("calls extension point topics callbacks when topics change", async () => { const provider = new TestProvider(); const source = new RandomAccessPlayer(provider); @@ -143,15 +157,15 @@ describe("RandomAccessPlayer", () => { extPoint.addTopicsCallback((topics: string[]) => { topicCalls2.push(topics); }); - expect(topicCalls1).toEqual([]); + expect(topicCalls1).toEqual([[]]); source.setSubscriptions([{ topic: "/foo/bar" }]); - expect(topicCalls1).toEqual([["/foo/bar"]]); + expect(topicCalls1).toEqual([[], ["/foo/bar"]]); source.setSubscriptions([{ topic: "/foo/bar" }, { topic: "/foo/bar" }]); - expect(topicCalls1).toEqual([["/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"]]); + expect(topicCalls1).toEqual([[], ["/foo/bar"], ["/foo/bar", "/baz"]]); source.setSubscriptions([{ topic: "/baz" }]); - expect(topicCalls1).toEqual([["/foo/bar"], ["/foo/bar", "/baz"], ["/baz"]]); + expect(topicCalls1).toEqual([[], ["/foo/bar"], ["/foo/bar", "/baz"], ["/baz"]]); expect(topicCalls2).toEqual(topicCalls1); }); @@ -375,11 +389,11 @@ describe("RandomAccessPlayer", () => { it("seek during reading discards messages before seek", async () => { const provider = new TestProvider(); const source = new RandomAccessPlayer(provider); - const store = new MessageStore(3); + const store = new MessageStore(4); await source.setListener(store.add); source.setSubscriptions([{ topic: "/foo/bar" }]); let callCount = 0; - const getMessages: GetMessages = (start: Time, end: Time, topics: string[]): Promise => { + const getMessages: GetMessages = async (start: Time, end: Time, topics: string[]): Promise => { expect(topics).toContainOnly(["/foo/bar"]); callCount++; if (callCount > 1) { @@ -403,20 +417,22 @@ describe("RandomAccessPlayer", () => { message: { payload: "foo bar" }, }, ]; + await new Promise((resolve) => setTimeout(resolve, 10)); source.seekPlayback({ sec: 0, nsec: 0 }); return Promise.resolve(result); }; provider.getMessages = getMessages; source.startPlayback(); const messages = await store.done; - expect(messages).toHaveLength(3); + expect(messages).toHaveLength(4); 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 }, ]); - expect(activeDatas.map((d) => d.messages)).toEqual([undefined, [], []]); + expect(activeDatas.map((d) => d.messages)).toEqual([undefined, [], [], []]); }); it("backfills previous messages on seek", async () => { diff --git a/packages/webviz-core/src/reducers/panels.js b/packages/webviz-core/src/reducers/panels.js index 744a55a53..6268d1403 100644 --- a/packages/webviz-core/src/reducers/panels.js +++ b/packages/webviz-core/src/reducers/panels.js @@ -6,7 +6,7 @@ // 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 { pick } from "lodash"; +import { isEmpty, pick } from "lodash"; import { getLeaves } from "react-mosaic-component"; import type { ActionTypes } from "webviz-core/src/actions"; @@ -64,15 +64,17 @@ function savePanelConfig(state: PanelsState, payload: SaveConfigPayload): Panels const { id, config } = payload; // imutable update of key/value pairs - const newProps = { - ...state.savedProps, - [id]: { - // merge new config with old one - // similar to how this.setState merges props - ...state.savedProps[id], - ...config, - }, - }; + const newProps = payload.override + ? { ...state.savedProps, [id]: config } + : { + ...state.savedProps, + [id]: { + // merge new config with old one + // similar to how this.setState merges props + ...state.savedProps[id], + ...config, + }, + }; // save the new saved panel props in storage storage.set(PANEL_PROPS_KEY, newProps); @@ -84,7 +86,12 @@ function savePanelConfig(state: PanelsState, payload: SaveConfigPayload): Panels } function importPanelLayout(state: PanelsState, payload: ImportPanelLayoutPayload) { - const migratedPayload = getGlobalHooks().migratePanels(payload); + let migratedPayload = getGlobalHooks().migratePanels(payload); + if (isEmpty(migratedPayload)) { + storage.set(LAYOUT_KEY, migratedPayload); + migratedPayload = getDefaultState(); + } + storage.set(LAYOUT_KEY, migratedPayload.layout); storage.set(PANEL_PROPS_KEY, migratedPayload.savedProps); storage.set(GLOBAL_DATA_KEY, migratedPayload.globalData || {}); diff --git a/packages/webviz-core/src/stories/PanelSetupWithBag.js b/packages/webviz-core/src/stories/PanelSetupWithBag.js index 37ab60634..4d09df424 100644 --- a/packages/webviz-core/src/stories/PanelSetupWithBag.js +++ b/packages/webviz-core/src/stories/PanelSetupWithBag.js @@ -23,6 +23,7 @@ type Props = { getMergedFixture?: (bagFixture: Fixture) => Fixture, mapTopicToDatatype?: (topic: string) => string, hasNestedMessageHistory?: ?boolean, + onMount?: (HTMLDivElement) => void, }; // A util component for testing panels that need to load the raw ROS bags. @@ -36,6 +37,7 @@ export default function PanelSetupWithBag({ getMergedFixture = (bagFixture) => bagFixture, mapTopicToDatatype = () => "dummyType", topics = [], + onMount, }: Props) { const [fixture, setFixture] = useState(); @@ -96,5 +98,9 @@ export default function PanelSetupWithBag({ // load the bag when component is mounted or updated useEffect(() => void loadBag(), [bagFileUrl, topics]); - return fixture ? {children} : null; + return fixture ? ( + + {children} + + ) : null; } diff --git a/packages/webviz-core/src/styles/colors.module.scss b/packages/webviz-core/src/styles/colors.module.scss index d359517ed..d48510fe5 100644 --- a/packages/webviz-core/src/styles/colors.module.scss +++ b/packages/webviz-core/src/styles/colors.module.scss @@ -34,6 +34,7 @@ $yellow: #f7df71; $light-purple: #c1c5d6; $grey: #e7e9ef; $panel-background: #121217; +$toolbar-fixed: #1f1e27; $toolbar: #2d2c33; $accent: #22b5ff; $orange: #ccb862; diff --git a/packages/webviz-core/src/styles/global.scss b/packages/webviz-core/src/styles/global.scss index ec143e56d..6efa9f4aa 100644 --- a/packages/webviz-core/src/styles/global.scss +++ b/packages/webviz-core/src/styles/global.scss @@ -90,7 +90,6 @@ textarea { border: none; padding: $control-padding; margin: $control-margin; - appearance: none; &:focus { outline: none; @@ -180,7 +179,7 @@ button { background-color: $background-control; } - &:hover { + &:not(.disabled):hover { color: $text-control-hover; } @@ -193,7 +192,7 @@ button { color: $text-normal; } &.disabled { - color: $text-disabled; + opacity: 0.5; cursor: not-allowed; } } diff --git a/packages/webviz-core/src/styles/mixins.module.scss b/packages/webviz-core/src/styles/mixins.module.scss index 07570aac7..e4b079e89 100644 --- a/packages/webviz-core/src/styles/mixins.module.scss +++ b/packages/webviz-core/src/styles/mixins.module.scss @@ -28,7 +28,7 @@ $control-margin: 0 0.2em; background-color: $toolbar; border-radius: 4px; padding: 0; - box-shadow: 0 6px 40px rgba(0, 0, 0, 0.5), 0 0 0 2px rgba(0, 0, 0, 0.25); + box-shadow: 0 6px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(0, 0, 0, 0.25); overflow: hidden; pointer-events: auto; flex-shrink: 0; diff --git a/packages/webviz-core/src/test/setup.js b/packages/webviz-core/src/test/setup.js index 73e80b590..0ba2b8fe7 100644 --- a/packages/webviz-core/src/test/setup.js +++ b/packages/webviz-core/src/test/setup.js @@ -9,6 +9,7 @@ import "babel-polyfill"; import { TextDecoder } from "text-encoding"; import UrlSearchParams from "url-search-params"; +import ws from "ws"; import MemoryStorage from "./MemoryStorage"; @@ -36,3 +37,16 @@ if (typeof window !== "undefined") { // polyfill URLSearchParams in jsdom window.URLSearchParams = UrlSearchParams; } + +// you can import fakes from fake-indexeddb and attach them to the jsdom global +// https://github.com/dumbmatter/fakeIndexedDB#use +global.indexedDB = require("fake-indexeddb"); +global.IDBIndex = require("fake-indexeddb/lib/FDBIndex"); +global.IDBCursor = require("fake-indexeddb/lib/FDBCursor"); +global.IDBObjectStore = require("fake-indexeddb/lib/FDBObjectStore"); +global.IDBTransaction = require("fake-indexeddb/lib/FDBTransaction"); +global.IDBDatabase = require("fake-indexeddb/lib/FDBDatabase"); +global.IDBKeyRange = require("fake-indexeddb/lib/FDBKeyRange"); + +// monkey-patch global websocket +global.WebSocket = global.WebSocket || ws; diff --git a/packages/webviz-core/src/types/Scene.js b/packages/webviz-core/src/types/Scene.js new file mode 100644 index 000000000..349cf2f0e --- /dev/null +++ b/packages/webviz-core/src/types/Scene.js @@ -0,0 +1,54 @@ +// @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 type { + Pose, + ArrowMarker, + CubeMarker, + SphereMarker, + CylinderMarker, + LineStripMarker, + LineListMarker, + CubeListMarker, + SphereListMarker, + PointsMarker, + TextMarker, + TriangleListMarker, + FilledPolygonMarker, + // non-default types + OccupancyGridMessage, + PointCloud, + LaserScan, +} from "webviz-core/src/types/Messages"; +import Bounds from "webviz-core/src/util/Bounds"; + +export type Scene = {| + flattenedZHeightPose: ?Pose, + bounds: Bounds, +|}; + +export interface MarkerCollector { + arrow(ArrowMarker): any; + cube(CubeMarker): any; + cubeList(CubeListMarker): any; + sphere(SphereMarker): any; + sphereList(SphereListMarker): any; + cylinder(CylinderMarker): any; + lineStrip(LineStripMarker): any; + lineList(LineListMarker): any; + points(PointsMarker): any; + text(TextMarker): any; + triangleList(TriangleListMarker): any; + grid(OccupancyGridMessage): any; + pointcloud(PointCloud): any; + laserScan(LaserScan): any; + filledPolygon(FilledPolygonMarker): any; +} + +export interface MarkerProvider { + renderMarkers(add: MarkerCollector): void; +} diff --git a/packages/webviz-core/src/types/panels.js b/packages/webviz-core/src/types/panels.js index 55a3933ab..305c62b93 100644 --- a/packages/webviz-core/src/types/panels.js +++ b/packages/webviz-core/src/types/panels.js @@ -10,9 +10,11 @@ export type PanelConfig = { [key: string]: any }; export type SaveConfigPayload = { id: string, - // if you set silent to true the url will not be stripped of a layout id + // if you set silent to true, the url will not be stripped of a layout id // after the props are saved - useful for minor or background UI operations modifying insignificant panel props silent?: boolean, + // if you set override to true, existing config will be completely overriden by new passed in config + override?: boolean, config: PanelConfig, }; diff --git a/packages/webviz-core/src/util/globalConstants.js b/packages/webviz-core/src/util/globalConstants.js index 03b1f49a2..af7b6b2e5 100644 --- a/packages/webviz-core/src/util/globalConstants.js +++ b/packages/webviz-core/src/util/globalConstants.js @@ -10,3 +10,42 @@ export const TRANSFORM_TOPIC = "/tf"; export const DIAGNOSTIC_TOPIC = "/diagnostics"; export const SOCKET_KEY = "dataSource.websocket"; export const SECOND_BAG_PREFIX = "/webviz_bag_2"; + +export const POINT_CLOUD_DATATYPE = "sensor_msgs/PointCloud2"; +export const POSE_STAMPED_DATATYPE = "geometry_msgs/PoseStamped"; +export const LASER_SCAN_DATATYPE = "sensor_msgs/LaserScan"; + +export const COLORS = { + RED: { r: 1.0, g: 0.2, b: 0.2, a: 1.0 }, + BLUE: { r: 0.4, g: 0.4, b: 1.0, a: 1.0 }, + YELLOW: { r: 0.9, g: 1.0, b: 0.1, a: 1.0 }, + ORANGE: { r: 1.0, g: 0.6, b: 0.2, a: 1.0 }, + GREEN: { r: 0.1, g: 0.9, b: 0.3, a: 1.0 }, + GRAY: { r: 0.4, g: 0.4, b: 0.4, a: 1.0 }, + PURPLE: { r: 1.0, g: 0.2, b: 1.0, a: 1.0 }, + WHITE: { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, + PINK: { r: 1.0, g: 0.4, b: 0.6, a: 1.0 }, + LIGHT_RED: { r: 0.9, g: 0.1, b: 0.1, a: 1.0 }, + LIGHT_GREEN: { r: 0.4, g: 0.9, b: 0.4, a: 1.0 }, + LIGHT_BLUE: { r: 0.4, g: 0.4, b: 1, a: 1.0 }, +}; + +// http://docs.ros.org/melodic/api/visualization_msgs/html/msg/Marker.html +export const MARKER_MSG_TYPES = { + ARROW: 0, + CUBE: 1, + SPHERE: 2, + CYLINDER: 3, + LINE_STRIP: 4, + LINE_LIST: 5, + CUBE_LIST: 6, + SPHERE_LIST: 7, + POINTS: 8, + TEXT_VIEW_FACING: 9, + MESH_RESOURCE: 10, + TRIANGLE_LIST: 11, +}; + +// Planning +export const MILES_PER_HOUR_TO_METERS_PER_SECOND = 0.44703; +export const METERS_PER_SECOND_TO_MILES_PER_HOUR = 2.23694; diff --git a/packages/webviz-core/src/util/indexeddb/Database.js b/packages/webviz-core/src/util/indexeddb/Database.js new file mode 100644 index 000000000..0febd6b4e --- /dev/null +++ b/packages/webviz-core/src/util/indexeddb/Database.js @@ -0,0 +1,224 @@ +// @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 idb, { type DB, type UpgradeDB, type Transaction } from "idb"; + +import DbWriter from "./DbWriter"; +import type { WritableStreamOptions } from "./types"; + +type IDBValidKey = number | string | Date | Array; + +type UpgradeDBOptions = { + keyPath?: string, + autoIncrement?: boolean, +}; + +type UpgradeCallback = (db: UpgradeDB) => void; + +type IDBTransactionMode = "readonly" | "readwrite"; + +export type Key = string | string[]; + +type Cursor = { + key: any, + value: any, + continue: () => void, +}; + +type CursorCallback = (cursor: ?Cursor) => void; + +type ObjectStore = { + +put: (value: any, key?: IDBValidKey) => Promise, + +get: (key: IDBValidKey) => Promise, + +delete: (key: IDBValidKey) => Promise, + +iterateCursor: (rangeOrCallback: IDBKeyRange | CursorCallback, callback?: CursorCallback) => void, + +iterateKeyCursor: (callback: CursorCallback) => void, + +index: (name: string) => ObjectStore, + +count: () => Promise, + +getAll: () => Promise, +}; + +type WritableStream = stream$Writable & { + write: (any) => boolean, + total: number, +}; + +export type Record = { + key: IDBKeyRange | IDBValidKey, + value: any, +}; + +type IndexDefinition = { + name: string, + keyPath: Key, +}; + +type ObjectStoreDefinition = { + name: string, + options?: UpgradeDBOptions, + indexes?: IndexDefinition[], +}; + +export type DatabaseDefinition = { + name: string, + version: number, + objectStores: ObjectStoreDefinition[], +}; + +// a wrapper around indexeddb database with flowtype definitions +// and a few helper methods to make doing common things easier +export default class Database { + db: DB; + + // open a database - low level call if you want to migrate the database manually + // mostly used in tests or when the structure of the database is created elsewhere + static async open(name: string, version: number, onChange: UpgradeCallback): Promise { + const db = await idb.open(name, version, onChange); + return new Database(db); + } + + // gets a database at the specified version and handles creating object stores + // and indexes based on the supplied DatabaseDefinition object + static async get(definition: DatabaseDefinition): Promise { + const { name, version, objectStores } = definition; + const db = await idb.open(name, version, (change) => { + objectStores.forEach((storeDefinition) => { + const { indexes = [] } = storeDefinition; + const store = change.createObjectStore(storeDefinition.name, storeDefinition.options); + indexes.forEach((index) => { + store.createIndex(index.name, index.keyPath); + }); + }); + }); + return new Database(db); + } + + constructor(db: DB) { + this.db = db; + } + + close() { + return this.db.close(); + } + + // get a single value by key - returns undefined if object cannot be found + async get(objectStore: string, key: IDBValidKey): Promise { + const tx = this.transaction(objectStore); + const value = await tx.objectStore(objectStore).get(key); + await tx.complete; + return value; + } + + // Get all values + async getAll(objectStore: string): Promise { + const tx = this.transaction(objectStore); + const values = await tx.objectStore(objectStore).getAll(); + await tx.complete; + return values; + } + + // Get all values and keys + async getAllKeyValues(objectStore: string): Promise<{ key: any, value: any }[]> { + const tx = this.transaction(objectStore); + const store = tx.objectStore(objectStore); + const items = []; + store.iterateCursor((cursor) => { + if (!cursor) { + return; + } + const { key, value } = cursor; + items.push({ key, value }); + cursor.continue(); + }); + await tx.complete; + return items; + } + + // put a single value with an optional key - uses autoIncrement if no key provided - returns the key + async put(objectStore: string, value: any, key?: IDBValidKey): Promise { + const tx = this.transaction(objectStore, "readwrite"); + const result = await tx.objectStore(objectStore).put(value, key); + await tx.complete; + return result; + } + + // deletes an object by key + async delete(objectStore: string, key: IDBValidKey): Promise { + const tx = this.transaction(objectStore, "readwrite"); + await tx.objectStore(objectStore).delete(key); + await tx.complete; + } + + // merges new data with existing object in store at key + // if object is not found, throws error + async merge(objectStore: string, value: any, key: IDBValidKey): Promise { + const tx = this.transaction(objectStore, "readwrite"); + const existing = await tx.objectStore(objectStore).get(key); + if (!existing) { + throw new Error(`Could not merge with ${JSON.stringify(key)}: key not found`); + } + const result = { ...existing, ...value }; + await tx.objectStore(objectStore).put(result, key); + await tx.complete; + return result; + } + + // returns the count of objects in the object store + async count(objectStore: string): Promise { + const tx = this.transaction(objectStore); + const store = tx.objectStore(objectStore); + const count = await store.count(); + await tx.complete; + return count; + } + + async keys(objectStore: string): Promise<(IDBKeyRange | IDBValidKey)[]> { + const tx = this.transaction(objectStore); + const store = tx.objectStore(objectStore); + const items = []; + store.iterateKeyCursor((cursor) => { + if (!cursor) { + return; + } + items.push(cursor.key); + cursor.continue(); + }); + await tx.complete; + return items; + } + + // gets a range of objects by key inclusive of start and end + async getRange(objectStore: string, index: ?string, start: IDBValidKey, end: IDBValidKey): Promise { + const tx = this.transaction(objectStore); + let store = tx.objectStore(objectStore); + if (index) { + store = store.index(index); + } + const range = IDBKeyRange.bound(start, end); + const items = []; + store.iterateCursor(range, (cursor) => { + if (!cursor) { + return; + } + const { key, value } = cursor; + items.push({ key, value }); + cursor.continue(); + }); + await tx.complete; + return items; + } + + transaction(objectStore: string, transactionMode?: IDBTransactionMode = "readonly"): Transaction { + return this.db.transaction(objectStore, transactionMode); + } + + // returns a writable (object mode) stream which does batching writes of records to the database + createWriteStream(objectStore: string, options?: WritableStreamOptions = {}): WritableStream { + return ((new DbWriter(this.db, objectStore, options): any): WritableStream); + } +} diff --git a/packages/webviz-core/src/util/indexeddb/Database.test.js b/packages/webviz-core/src/util/indexeddb/Database.test.js new file mode 100644 index 000000000..79db6b991 --- /dev/null +++ b/packages/webviz-core/src/util/indexeddb/Database.test.js @@ -0,0 +1,177 @@ +// @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 Database from "./Database"; + +async function put(db: Database, objectStore: string, values: any[]) { + const tx = db.transaction(objectStore, "readwrite"); + for (const value of values) { + await tx.objectStore(objectStore).put(value); + } + await tx.complete; +} +// NOTE: Each Database name needs to be unique if you do not want to pollute other tests. +describe("Database", () => { + it("read/write", async () => { + const db = await Database.open("foo", 1, (db) => { + db.createObjectStore("bar", { autoIncrement: true }); + }); + const tx1 = db.transaction("bar", "readwrite"); + tx1.objectStore("bar").put("one"); + tx1.objectStore("bar").put("two"); + await tx1.complete; + + const tx2 = db.transaction("bar", "readonly"); + expect(await tx2.objectStore("bar").get(1)).toEqual("one"); + expect(await tx2.objectStore("bar").get(2)).toEqual("two"); + expect(await tx2.objectStore("bar").get(3)).toEqual(undefined); + await tx2.complete; + await db.close(); + }); + + it("can do crud operations", async () => { + const db = await Database.open("crud", 1, (db) => { + db.createObjectStore("bar", { autoIncrement: true }); + }); + const result = await db.put("bar", { val: "something" }, "key-1"); + expect(result).toEqual("key-1"); + expect(await db.count("bar")).toEqual(1); + expect(await db.get("bar", "key-1")).toEqual({ val: "something" }); + expect(await db.merge("bar", { foo: true }, "key-1")).toEqual({ val: "something", foo: true }); + expect(await db.merge("bar", { val: "something-else" }, "key-1")).toEqual({ val: "something-else", foo: true }); + expect(await db.count("bar")).toEqual(1); + await db.delete("bar", "key-1"); + expect(await db.get("bar", "key-1")).toBeUndefined(); + expect(await db.count("bar")).toEqual(0); + }); + + it("can get single item", async () => { + const def = { + name: "foo", + version: 1, + objectStores: [ + { + name: "bar", + options: { autoIncrement: true }, + }, + ], + }; + const db = await Database.get(def); + const tx1 = db.transaction("bar", "readwrite"); + tx1.objectStore("bar").put("one"); + await tx1.complete; + + expect(await db.get("bar", 1)).toEqual("one"); + return db.close(); + }); + + it("can read a range", async () => { + const db = await Database.open("range", 1, (db) => { + db.createObjectStore("bar", { autoIncrement: true }); + }); + await put(db, "bar", [{ one: 1 }, { two: 2 }, { three: 3 }]); + const range = await db.getRange("bar", undefined, 1, 2); + expect(range).toEqual([{ key: 1, value: { one: 1 } }, { key: 2, value: { two: 2 } }]); + expect(await db.getRange("bar", undefined, 1, 100)).toEqual([ + { key: 1, value: { one: 1 } }, + { key: 2, value: { two: 2 } }, + { key: 3, value: { three: 3 } }, + ]); + return db.close(); + }); + + it("can read a range by index", async () => { + const db = await Database.open("range-with-index", 1, (db) => { + const store = db.createObjectStore("bar", { autoIncrement: true }); + store.createIndex("stamp", "stamp"); + }); + await put(db, "bar", [{ one: 1, stamp: 100 }, { two: 2, stamp: 50 }, { three: 3, stamp: 10 }]); + const range = await db.getRange("bar", "stamp", 1, 20); + expect(range).toEqual([{ key: 10, value: { three: 3, stamp: 10 } }]); + const range2 = await db.getRange("bar", "stamp", 20, 50); + expect(range2).toEqual([{ key: 50, value: { two: 2, stamp: 50 } }]); + return db.close(); + }); + + it("can create writable stream", async () => { + const db = await Database.open("stream", 1, (db) => { + db.createObjectStore("bar", { autoIncrement: true }); + }); + + const writer = db.createWriteStream("bar"); + writer.write({ one: 1 }); + writer.write({ two: 2 }); + setImmediate(() => { + writer.write({ three: 3 }); + writer.end(); + }); + + writer.on("finish", async () => { + const tx = db.transaction("bar"); + const store = tx.objectStore("bar"); + expect(await store.get(1)).toEqual({ one: 1 }); + expect(await store.get(2)).toEqual({ two: 2 }); + expect(await store.get(3)).toEqual({ three: 3 }); + expect(await store.get(4)).toEqual(undefined); + expect(writer.total).toEqual(3); + }); + }); + + it("can create writable stream with extra appended", async () => { + const db = await Database.open("stream-with-extra", 1, (db) => { + db.createObjectStore("bar", { autoIncrement: true }); + }); + + const writer = db.createWriteStream("bar", { extra: { topic: "/foo" } }); + writer.write({ one: 1 }); + writer.write({ two: 2 }); + setImmediate(() => { + writer.write({ three: 3 }); + writer.end(); + }); + + writer.on("finish", async () => { + const tx = db.transaction("bar"); + const store = tx.objectStore("bar"); + expect(await store.get(1)).toEqual({ one: 1, topic: "/foo" }); + expect(await store.get(2)).toEqual({ two: 2, topic: "/foo" }); + expect(await store.get(3)).toEqual({ three: 3, topic: "/foo" }); + expect(await store.get(4)).toEqual(undefined); + expect(writer.total).toEqual(3); + }); + }); + + it("can get all the keys in a store", async () => { + const db = await Database.open("keys", 1, (db) => { + db.createObjectStore("bar", { keyPath: "key" }); + }); + await put(db, "bar", [ + { key: "a", one: 1, stamp: 100 }, + { key: "b", two: 2, stamp: 50 }, + { key: "c", three: 3, stamp: 10 }, + { key: "d", four: 4, stamp: 10 }, + ]); + expect(await db.keys("bar")).toContainOnly(["a", "b", "c", "d"]); + }); + + it("can get all keys and values in a store", async () => { + const db = await Database.open("kvp", 1, (db) => { + db.createObjectStore("bar", { keyPath: "key" }); + }); + const values = [ + { key: "a", one: 1, stamp: 100 }, + { key: "b", two: 2, stamp: 50 }, + { key: "c", three: 3, stamp: 10 }, + { key: "d", four: 4, stamp: 10 }, + ]; + await put(db, "bar", values); + expect(await db.getAllKeyValues("bar")).toEqual( + values.map(({ key, ...rest }) => ({ key, value: { key, ...rest } })) + ); + }); +}); diff --git a/packages/webviz-core/src/util/indexeddb/DbWriter.js b/packages/webviz-core/src/util/indexeddb/DbWriter.js new file mode 100644 index 000000000..704fe506e --- /dev/null +++ b/packages/webviz-core/src/util/indexeddb/DbWriter.js @@ -0,0 +1,69 @@ +// @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 DB } from "idb"; +import { Writable } from "stream"; + +import type { WritableStreamOptions } from "./types"; + +const DEFAULT_BATCH_SIZE = 5000; + +type WriteCallback = (err: ?Error) => void; + +// a node.js writable stream interface for writing records to indexeddb in batch +// this isn't meant to be created alone, but rather via database.createWriteStream() +export default class DbWriter extends Writable { + db: DB; + objectStore: string; + batch: any[]; + options: WritableStreamOptions; + total: number = 0; + + constructor(db: DB, objectStore: string, options: WritableStreamOptions) { + super({ objectMode: true }); + this.db = db; + this.objectStore = objectStore; + this.options = options; + this.batch = []; + } + + // write a batch of records - in my experimenting its much faster than doing transactional write per item + writeBatch(callback: WriteCallback): void { + const batch = this.batch; + // reset the instance batch + this.batch = []; + const tx = this.db.transaction(this.objectStore, "readwrite"); + const store = tx.objectStore(this.objectStore); + for (const item of batch) { + const toInsert = this.options.extra ? { ...item, ...this.options.extra } : item; + store.put(toInsert); + } + // use setTimeout to yield the thread a bit - even with their quasi-asyncness + // node streams can sometimes cause a bit too much throughput pressure on writes + tx.complete.then(() => setTimeout(callback, 1)).catch(callback); + } + + // node.js stream api implementation + _write(chunk: any, encoding: string, callback: WriteCallback) { + this.batch.push(chunk); + this.total++; + if (this.batch.length < (this.options.batchSize || DEFAULT_BATCH_SIZE)) { + callback(); + // can handle more data + return true; + } + this.writeBatch(callback); + // cannot handle more data until transaction completes + return false; + } + + // node.js stream api implementation + _final(callback: WriteCallback) { + this.writeBatch(callback); + } +} diff --git a/packages/webviz-core/src/util/indexeddb/MetaDatabase.js b/packages/webviz-core/src/util/indexeddb/MetaDatabase.js new file mode 100644 index 000000000..4b42454bf --- /dev/null +++ b/packages/webviz-core/src/util/indexeddb/MetaDatabase.js @@ -0,0 +1,120 @@ +// @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 Database from "webviz-core/src/util/indexeddb/Database"; +import Logger from "webviz-core/src/util/Logger"; + +const log = new Logger(__filename); + +/* + +MetaDatabase + +This IndexedDB instance is a hardcoded reference to the dynamically named +databases from different players, such that we can figure out whether we +need to create a new instance or not. In terms of data integrity, things only +get evicted if a global limit, which is correlated with origin of the saved +resource. Attempting to exceed global limit (which is based on available disk +space) does trigger eviction, but does so based on origin, so they won't try to +delete just parts of your data. The other type of limit we could potentially +violate is group limit, but it's just a hard limit based on origin and does not +trigger any eviction. + +More info here: +https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Browser_storage_limits_and_eviction_criteria + +*/ + +const metadataObjectStoreName = "databases"; +const getConfig = (metadataDatabaseName: string) => ({ + version: 1, + name: metadataDatabaseName, + objectStores: [ + { + name: metadataObjectStoreName, + }, + ], +}); + +// Tries to delete an indexeddb database. +// We return true if we were successfully able to delete the database. +// If the database is currently open in another tab it will fire the onblocked callback. +// If multiple tabs all try to delete only the first will raise an onblocked call: +// https://github.com/w3c/IndexedDB/issues/223 +// so also use a timeout of 500 milliseconds & continue as if it was blocked. +// Note: since this runs each time we make a new database when user finally closes all opened tabs +// the next creation of a database will clean up all the old ones. +function tryDelete(databaseName: string): Promise { + return new Promise((resolve, reject) => { + log.info("Deleting old database", databaseName); + let resolved = false; + const done = (success: boolean) => { + if (resolved) { + return; + } + resolved = true; + resolve(success); + }; + const deleteRequest = global.indexedDB.deleteDatabase(databaseName); + // if we don't hear anything after 500 milliseconds assume the request is blocked + setTimeout(() => done(false), 500); + deleteRequest.onsuccess = () => done(true); + deleteRequest.onerror = (err) => { + log.error(`Unable to delete indexeddb database ${databaseName}`, err); + done(false); + }; + deleteRequest.onblocked = () => { + log.info(`Could not delete ${databaseName} - request blocked. This database is likey open in another tab.`); + done(false); + }; + }); +} + +export async function updateMetaDatabases( + newDatabaseName: string, + maxDatabases: number, + metadataDatabaseName: string +): Promise { + const metadataDatabase = await Database.get(getConfig(metadataDatabaseName)); + try { + // see if we're opening a database we've already opened recently + const existing = await metadataDatabase.get(metadataObjectStoreName, newDatabaseName); + if (existing) { + // if we have, update the last access date and do nothing else + await metadataDatabase.merge(metadataObjectStoreName, { lastAccess: Date.now() }, newDatabaseName); + return; + } + + // store the database name we're about to create + await metadataDatabase.put( + metadataObjectStoreName, + { name: newDatabaseName, lastAccess: Date.now() }, + newDatabaseName + ); + + // get all existing databases...if there are fewer than max, do nothing + const all = await metadataDatabase.getAll(metadataObjectStoreName); + const old = all.sort((a, b) => b.lastAccess - a.lastAccess).slice(maxDatabases); + // go through the list of old databases and try to delete each of them + const promises = old.map(async (lastUsed) => { + if (await tryDelete(lastUsed.name)) { + await metadataDatabase.delete(metadataObjectStoreName, lastUsed.name); + } + }); + await Promise.all(promises); + } finally { + await metadataDatabase.close(); + } +} + +export async function doesDatabaseExist(databaseName: string, metadataDatabaseName: string): Promise { + const metadataDatabase = await Database.get(getConfig(metadataDatabaseName)); + const entry = await metadataDatabase.get(metadataObjectStoreName, databaseName); + await metadataDatabase.close(); + return !!entry; +} diff --git a/packages/webviz-core/src/util/indexeddb/MetaDatabase.test.js b/packages/webviz-core/src/util/indexeddb/MetaDatabase.test.js new file mode 100644 index 000000000..e34dfd9b6 --- /dev/null +++ b/packages/webviz-core/src/util/indexeddb/MetaDatabase.test.js @@ -0,0 +1,107 @@ +// @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 idb from "idb"; + +import Database from "./Database"; +import { updateMetaDatabases, doesDatabaseExist } from "./MetaDatabase"; + +export function getDatabases(): Map { + // until indexedDB.databases() lands in the spec, get the databases on the fake by reaching into it + return global.indexedDB._databases; // eslint-disable-line no-underscore-dangle +} + +describe("MetaDatabase", () => { + const MAX = 3; + const METADATABASE_NAME = "meta"; + + beforeEach(() => { + getDatabases().clear(); + }); + + afterEach(async () => { + // Clear up metadata. + await idb.delete(METADATABASE_NAME); + }); + + describe("updateMetadatabases", () => { + it("deletes databases if over max", async () => { + async function createAndClose(name: string) { + const db = await Database.get({ name, version: 1, objectStores: [{ name: "foo" }] }); + await db.close(); + await updateMetaDatabases(name, 3, METADATABASE_NAME); + } + await createAndClose("foo"); + await createAndClose("bar"); + await createAndClose("baz"); + await createAndClose("biz"); + await createAndClose("boz"); + expect(getDatabases().size).toEqual(4); + }); + + it("does not delete databases which are still open", async () => { + const dbs = []; + async function createAndClose(name: string) { + const db = await Database.get({ name, version: 1, objectStores: [{ name: "foo" }] }); + dbs.push(db); + await updateMetaDatabases(name, 3, METADATABASE_NAME); + } + await createAndClose("foo2"); + await createAndClose("bar2"); + await createAndClose("baz2"); + await createAndClose("biz2"); + await createAndClose("boz2"); + expect(getDatabases().size).toEqual(6); + await Promise.all(dbs.map((db) => db.close())); + await createAndClose("boz3"); + expect(getDatabases().size).toEqual(4); + await updateMetaDatabases("baz3", 1, METADATABASE_NAME); + expect(getDatabases().size).toEqual(2); + await Promise.all(dbs.map((db) => db.close())); + }); + + it("does not throw when database deletion throws an error", async () => { + const spy = jest.spyOn(global.indexedDB, "deleteDatabase").mockImplementation(() => { + const result = { + // This gets overridden by caller. Only coded for throwing error to satisfy flow & lint. + onerror: (err: Error) => { + throw err; + }, + }; + setTimeout(() => { + result.onerror(new Error("failed to delete")); + }, 10); + return result; + }); + await updateMetaDatabases("foo", 1, METADATABASE_NAME); + await updateMetaDatabases("bar", 1, METADATABASE_NAME); + spy.mockRestore(); + }); + + it("does not delete databases which never fire onblocked calls", async () => { + const spy = jest.spyOn(global.indexedDB, "deleteDatabase").mockImplementation(() => { + return {}; + }); + await updateMetaDatabases("foo", 1, METADATABASE_NAME); + await updateMetaDatabases("bar", 1, METADATABASE_NAME); + spy.mockRestore(); + }); + }); + + describe("doesDatabaseExist", () => { + it("returns false for entries that do not yet exist", async () => { + const isSaved = await doesDatabaseExist("a", METADATABASE_NAME); + expect(isSaved).toBeFalsy(); + }); + it("returns true for names that already exist", async () => { + await updateMetaDatabases("a", MAX, METADATABASE_NAME); + const isSaved = await doesDatabaseExist("a", METADATABASE_NAME); + expect(isSaved).toBeTruthy(); + }); + }); +}); diff --git a/packages/webviz-core/src/util/indexeddb/types.js b/packages/webviz-core/src/util/indexeddb/types.js new file mode 100644 index 000000000..92dce9d61 --- /dev/null +++ b/packages/webviz-core/src/util/indexeddb/types.js @@ -0,0 +1,14 @@ +// @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. + +export type WritableStreamOptions = { + batchSize?: number, + // extra static data to be copied to the value of every record inserted + // used to copy topic name into airavata records + extra?: { [key: any]: any }, +}; diff --git a/packages/webviz-core/src/types/selection.js b/packages/webviz-core/src/util/videoRecordingMode.js similarity index 66% rename from packages/webviz-core/src/types/selection.js rename to packages/webviz-core/src/util/videoRecordingMode.js index 11639ef0d..67b13cdc0 100644 --- a/packages/webviz-core/src/types/selection.js +++ b/packages/webviz-core/src/util/videoRecordingMode.js @@ -6,4 +6,8 @@ // 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. -export type VisualizedPlanner = number | "active"; +function videoRecordingMode() { + return window.location.search.includes("video-recording-mode"); +} + +export default videoRecordingMode;