From 0efdd2b7f9665e9e76e27cfd5da9773c2db578dc Mon Sep 17 00:00:00 2001 From: Chris Hasson Date: Tue, 13 Jul 2021 11:53:02 -0700 Subject: [PATCH] Update packages/webviz-core from internal repo (#658) Changelog: - Co-authored-by: Chris Hasson --- packages/webviz-core/package-lock.json | 605 ++++++--- packages/webviz-core/package.json | 4 +- .../script/record-local-bag-video.js | 9 +- packages/webviz-core/shared/fileUtils.js | 30 + packages/webviz-core/shared/recordVideo.js | 94 +- packages/webviz-core/src/actions/panels.js | 6 +- .../webviz-core/src/actions/panels.test.js | 37 + .../src/components/ChildToggle/index.js | 9 +- .../src/components/GLChart/gridRenderer.js | 109 ++ .../src/components/GLChart/index.js | 161 +++ .../src/components/GLChart/linesRenderer.js | 108 ++ .../src/components/GLChart/pointRenderer.js | 115 ++ .../src/components/GLChart/types.js | 20 + .../src/components/GLChart/utils.js | 114 ++ .../webviz-core/src/components/Menu/Menu.js | 58 +- .../components/ReactChartjs/ChartJSManager.js | 24 +- .../ReactChartjs/ChartJSManager.test.js | 48 + .../ReactChartjs/__mocks__/index.js | 8 +- .../src/components/ReactChartjs/index.js | 490 +++---- .../components/ReactChartjs/index.stories.js | 33 +- .../webviz-core/src/components/Resizable.js | 161 +++ packages/webviz-core/src/components/Switch.js | 17 +- .../webviz-core/src/components/TextField.js | 7 +- .../src/components/TimeBasedChart/index.js | 51 +- .../src/components/TopicToRenderMenu.js | 29 +- .../components/TopicToRenderMenu.stories.js | 20 + .../src/dataProviders/CombinedDataProvider.js | 23 +- .../CombinedDataProvider.test.js | 28 +- packages/webviz-core/src/hooksImporter.js | 71 +- packages/webviz-core/src/loadWebviz.js | 12 + .../src/panels/Audio/AudioPlayer.js | 546 ++++++++ .../src/panels/Audio/AudioToolbar.js | 133 ++ .../src/panels/Audio/BlockLoadingProgress.js | 72 ++ .../Audio/BlockLoadingProgress.stories.js | 29 + .../src/panels/Audio/VolumeControl.js | 96 ++ .../src/panels/Audio/index.help.md | 8 + .../webviz-core/src/panels/Audio/index.js | 216 ++++ .../webviz-core/src/panels/Audio/utils.js | 235 ++++ .../webviz-core/src/panels/ImageView/index.js | 8 +- .../webviz-core/src/panels/Plot/PlotChart.js | 26 +- .../webviz-core/src/panels/Plot/index.help.md | 2 + packages/webviz-core/src/panels/Plot/index.js | 2 + .../src/panels/Plot/index.stories.js | 44 +- .../webviz-core/src/panels/Table/index.js | 19 +- .../Layout/LayoutWorker.js | 10 +- .../ThreeDimensionalViz/Layout/index.js | 30 +- .../ThreeDimensionalViz/MeasureMarker.js | 37 +- .../SceneBuilder/defaultHooks.js | 4 +- .../ThreeDimensionalViz/SceneBuilder/index.js | 44 +- .../TopicSettingsEditor/index.js | 36 +- .../TopicTree/TopicSettingsModal.js | 4 +- .../TopicTree/TopicTree.js | 26 +- .../ThreeDimensionalViz/TopicTree/types.js | 1 + .../TopicTree/useTopicTree.js | 46 +- .../TopicTree/useTopicTree.test.js | 31 + .../{ => Transforms}/TransformsBuilder.js | 0 .../TransformsBuilder.test.js | 2 +- .../{Transforms.js => Transforms/index.js} | 0 .../Transforms/useTransformsNear.js | 131 ++ .../Transforms/useTransformsNear.test.js | 51 + .../utils.js} | 2 +- .../src/panels/ThreeDimensionalViz/World.js | 5 +- .../commands/OverlayProjector/index.js | 12 +- .../ThreeDimensionalViz/withTransforms.js | 2 +- .../src/panels/TwoDimensionalPlot/index.js | 39 +- .../TwoDimensionalPlot/index.stories.js | 20 +- .../src/players/OrderedStampPlayer.js | 11 - .../src/players/OrderedStampPlayer.test.js | 6 +- .../typescript/userUtils/colors.test.ts | 48 + .../typescript/userUtils/colors.ts | 1151 ++++++++++++++++- .../typescript/userUtils/index.js | 6 +- .../typescript/userUtils/lodash.test.ts | 35 + .../typescript/userUtils/lodash.ts | 33 + .../automatedRun/AutomatedRunPlayer.js | 14 +- .../automatedRun/videoRecordingClient.js | 32 +- .../webviz-core/src/types/BinaryMessages.js | 13 + packages/webviz-core/src/util/datatypes.js | 16 +- .../webviz-core/src/util/globalConstants.js | 41 +- packages/webviz-core/src/util/layout.js | 12 +- packages/webviz-core/src/util/logEvent.js | 2 +- .../src/util/quaternionFromEuler.js | 6 + .../rosDatatypesToMessageDefinitions.test.js | 15 +- 82 files changed, 5130 insertions(+), 781 deletions(-) create mode 100644 packages/webviz-core/shared/fileUtils.js create mode 100644 packages/webviz-core/src/actions/panels.test.js create mode 100644 packages/webviz-core/src/components/GLChart/gridRenderer.js create mode 100644 packages/webviz-core/src/components/GLChart/index.js create mode 100644 packages/webviz-core/src/components/GLChart/linesRenderer.js create mode 100644 packages/webviz-core/src/components/GLChart/pointRenderer.js create mode 100644 packages/webviz-core/src/components/GLChart/types.js create mode 100644 packages/webviz-core/src/components/GLChart/utils.js create mode 100644 packages/webviz-core/src/components/ReactChartjs/ChartJSManager.test.js create mode 100644 packages/webviz-core/src/components/Resizable.js create mode 100644 packages/webviz-core/src/panels/Audio/AudioPlayer.js create mode 100644 packages/webviz-core/src/panels/Audio/AudioToolbar.js create mode 100644 packages/webviz-core/src/panels/Audio/BlockLoadingProgress.js create mode 100644 packages/webviz-core/src/panels/Audio/BlockLoadingProgress.stories.js create mode 100644 packages/webviz-core/src/panels/Audio/VolumeControl.js create mode 100644 packages/webviz-core/src/panels/Audio/index.help.md create mode 100644 packages/webviz-core/src/panels/Audio/index.js create mode 100644 packages/webviz-core/src/panels/Audio/utils.js rename packages/webviz-core/src/panels/ThreeDimensionalViz/{ => Transforms}/TransformsBuilder.js (100%) rename packages/webviz-core/src/panels/ThreeDimensionalViz/{ => Transforms}/TransformsBuilder.test.js (97%) rename packages/webviz-core/src/panels/ThreeDimensionalViz/{Transforms.js => Transforms/index.js} (100%) create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms/useTransformsNear.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms/useTransformsNear.test.js rename packages/webviz-core/src/panels/ThreeDimensionalViz/{utils/transformsUtils.js => Transforms/utils.js} (97%) create mode 100644 packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/colors.test.ts create mode 100644 packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/lodash.test.ts create mode 100644 packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/lodash.ts diff --git a/packages/webviz-core/package-lock.json b/packages/webviz-core/package-lock.json index b5dac91cc..a3b999287 100644 --- a/packages/webviz-core/package-lock.json +++ b/packages/webviz-core/package-lock.json @@ -32,6 +32,28 @@ } } }, + "@cruise-automation/button": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@cruise-automation/button/-/button-0.0.7.tgz", + "integrity": "sha512-ZwriH+VbIuG4LzQhXwqMg/NVHJMSByg7KcvfCQIRDFnHNd2ovvYXXw3S117bq71cUbsIfmAJfzjtjnbXnZyYRg==", + "requires": { + "classnames": "^2.2.5", + "styled-components": "^3.3.0" + } + }, + "@cruise-automation/hooks": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@cruise-automation/hooks/-/hooks-0.0.1.tgz", + "integrity": "sha512-sUwYShqRJAyg2XS7+uc9ZwdNkRArLdyjPTF6Ebl1GphXHX5QYU14aMt3SO3QtVg0fd/KSWM4FinRosxZDq5uZg==" + }, + "@cruise-automation/tooltip": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@cruise-automation/tooltip/-/tooltip-0.0.7.tgz", + "integrity": "sha512-KhnDUdt6CiQQpfBouPQcXBjGH0jmnDWEmA6Ax4xLWE1xfrz7KZfmrg4wWIrgQlklXjy+lpOgOcUmcNi0r0FtRA==", + "requires": { + "react-popper": "1.0.0" + } + }, "@mdi/svg": { "version": "5.7.55", "resolved": "https://registry.npmjs.org/@mdi/svg/-/svg-5.7.55.tgz", @@ -139,6 +161,13 @@ } } }, + "@types/node": { + "version": "15.6.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-15.6.1.tgz", + "integrity": "sha512-7EIraBEyRHEe7CH+Fm1XvgqU6uwZN8Q7jppJGcqjROMT29qhAuuOxYB1uEY5UMYQKEmA5D+5tBnhdaPXSsLONA==", + "dev": true, + "optional": true + }, "@types/prop-types": { "version": "15.7.3", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz", @@ -175,6 +204,16 @@ "resolved": "https://registry.npmjs.org/@types/shallowequal/-/shallowequal-1.1.1.tgz", "integrity": "sha512-Lhni3aX80zbpdxRuWhnuYPm8j8UQaa571lHP/xI4W+7BAFhSIhRReXnqjEgT/XzPoXZTJkCqstFMJ8CZTK6IlQ==" }, + "@types/yauzl": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.1.tgz", + "integrity": "sha512-A1b8SU4D10uoPjwb0lnHmmu8wZhR9d+9o2PKBQT2jU5YPTKsxac6M2qGAdY7VcL+dHHhARVUDmeg0rOrcd9EjA==", + "dev": true, + "optional": true, + "requires": { + "@types/node": "*" + } + }, "@welldone-software/why-did-you-render": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@welldone-software/why-did-you-render/-/why-did-you-render-6.1.1.tgz", @@ -205,6 +244,15 @@ "resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz", "integrity": "sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=" }, + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "requires": { + "debug": "4" + } + }, "airbnb-prop-types": { "version": "2.15.0", "resolved": "https://registry.npmjs.org/airbnb-prop-types/-/airbnb-prop-types-2.15.0.tgz", @@ -335,6 +383,11 @@ "retry": "0.12.0" } }, + "audiobuffer-to-wav": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/audiobuffer-to-wav/-/audiobuffer-to-wav-1.0.0.tgz", + "integrity": "sha1-1bQyJxRV5/7laxEc0PjWINf54QU=" + }, "babel-polyfill": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-polyfill/-/babel-polyfill-6.26.0.tgz", @@ -435,6 +488,29 @@ "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==" }, + "bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + }, + "dependencies": { + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + } + } + }, "blob": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.5.tgz", @@ -458,6 +534,12 @@ "ieee754": "^1.2.1" } }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=", + "dev": true + }, "buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", @@ -469,6 +551,11 @@ "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", "integrity": "sha1-KAOY5dZkvXQDi28JBRU+borxvCA=" }, + "camelize": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.0.tgz", + "integrity": "sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs=" + }, "cbor-js": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cbor-js/-/cbor-js-0.1.0.tgz", @@ -547,6 +634,12 @@ "version": "github:davidswinegar/chartjs-plugin-datalabels#b0ade94116d4e273f0fdc17e9528c9a33c796e90", "from": "github:davidswinegar/chartjs-plugin-datalabels#b0ade94116d4e273f0fdc17e9528c9a33c796e90" }, + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true + }, "classnames": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", @@ -685,6 +778,15 @@ "object-assign": "^4.1.1" } }, + "create-react-context": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/create-react-context/-/create-react-context-0.2.3.tgz", + "integrity": "sha512-CQBmD0+QGgTaxDL3OX1IDXYqjkp2It4RIbcb99jS6AEg27Ga+a9G3JtK6SIu0HBwPLZlmwt9F7UwWA4Bn92Rag==", + "requires": { + "fbjs": "^0.8.0", + "gud": "^1.0.0" + } + }, "css-animation": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/css-animation/-/css-animation-1.6.1.tgz", @@ -694,6 +796,21 @@ "component-classes": "^1.2.5" } }, + "css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha1-/qJhbcZ2spYmhrOvjb2+GAskTgU=" + }, + "css-to-react-native": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-2.3.2.tgz", + "integrity": "sha512-VOFaeZA053BqvvvqIA8c9n0+9vFppVBAHCp6JgFTtTMU3Mzi+XnelJ9XC9ul3BqFzZyQ5N+H0SnwsWT2Ebchxw==", + "requires": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^3.3.0" + } + }, "csstype": { "version": "2.6.10", "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.10.tgz", @@ -715,6 +832,12 @@ "object-keys": "^1.0.12" } }, + "devtools-protocol": { + "version": "0.0.847576", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.847576.tgz", + "integrity": "sha512-0M8kobnSQE0Jmly7Mhbeq0W/PpZfnuK+WjN2ZRVPbGqYwCHCioAVp84H0TcLimgECcN5H976y5QiXMGBC9JKmg==", + "dev": true + }, "diff-match-patch": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", @@ -853,6 +976,15 @@ "iconv-lite": "~0.4.13" } }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, "engine.io": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-3.3.2.tgz", @@ -1001,6 +1133,18 @@ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, + "extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "requires": { + "@types/yauzl": "^2.9.1", + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + } + }, "fake-indexeddb": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-2.0.4.tgz", @@ -1056,6 +1200,15 @@ } } }, + "fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=", + "dev": true, + "requires": { + "pend": "~1.2.0" + } + }, "fetch-mock": { "version": "7.2.5", "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-7.2.5.tgz", @@ -1113,6 +1266,12 @@ "integrity": "sha512-mmdEPEMoTuX+mguy/tjRlOlCtPfVdXZQeMgCAsEDVDgWMA5vwWhM2y653OcJiKX38t4gtZ2e/MNVo0qzyYeZDQ==", "dev": true }, + "fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -1143,6 +1302,15 @@ "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-1.1.4.tgz", "integrity": "sha512-JzK/lHjVZ6joAg3OnCjylwYXYVjRiwTY6Yb25LvfpJHK8bjisfnZJ5bY8aVWwTwCXgxPNgLAtmHL+Hs5q1ddLQ==" }, + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, "glob": { "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", @@ -1294,6 +1462,16 @@ "entities": "^2.0.0" } }, + "https-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", + "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", + "dev": true, + "requires": { + "agent-base": "6", + "debug": "4" + } + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -1743,6 +1921,12 @@ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" }, + "mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true + }, "moment": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", @@ -1914,6 +2098,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, "requires": { "p-try": "^2.0.0" } @@ -1930,7 +2115,8 @@ "p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true }, "panzoom": { "version": "8.4.0", @@ -1990,6 +2176,12 @@ "isarray": "0.0.1" } }, + "pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=", + "dev": true + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -2024,6 +2216,11 @@ "resolved": "https://registry.npmjs.org/pngparse/-/pngparse-2.0.1.tgz", "integrity": "sha1-hoUt5N40n077HoUudSVlXlrF37g=" }, + "popper.js": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", + "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==" + }, "postcss": { "version": "7.0.29", "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.29.tgz", @@ -2049,6 +2246,11 @@ } } }, + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==" + }, "prettier": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.16.0.tgz", @@ -2059,6 +2261,12 @@ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=" }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true + }, "promise": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", @@ -2091,6 +2299,22 @@ "reflect.ownkeys": "^0.2.0" } }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", @@ -2100,6 +2324,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-7.0.1.tgz", "integrity": "sha512-04V05BKQdloUCOa7JyQBaNXPIiVByz1eAFAElcrpMHIQkfu22J0RKFhRWkXZGXdl03yoHuaZwqyB/qG7YJu5Ew==", + "dev": true, "requires": { "debug": "^4.1.0", "devtools-protocol": "0.0.847576", @@ -2115,143 +2340,36 @@ "ws": "^7.2.3" }, "dependencies": { - "@types/node": { - "version": "15.6.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-15.6.1.tgz", - "integrity": "sha512-7EIraBEyRHEe7CH+Fm1XvgqU6uwZN8Q7jppJGcqjROMT29qhAuuOxYB1uEY5UMYQKEmA5D+5tBnhdaPXSsLONA==", - "optional": true - }, - "@types/yauzl": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.1.tgz", - "integrity": "sha512-A1b8SU4D10uoPjwb0lnHmmu8wZhR9d+9o2PKBQT2jU5YPTKsxac6M2qGAdY7VcL+dHHhARVUDmeg0rOrcd9EjA==", - "optional": true, - "requires": { - "@types/node": "*" - } - }, - "agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "requires": { - "debug": "4" - } - }, - "bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "requires": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "requires": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=" - }, - "chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" - }, - "devtools-protocol": { - "version": "0.0.847576", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.847576.tgz", - "integrity": "sha512-0M8kobnSQE0Jmly7Mhbeq0W/PpZfnuK+WjN2ZRVPbGqYwCHCioAVp84H0TcLimgECcN5H976y5QiXMGBC9JKmg==" - }, - "end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "requires": { - "once": "^1.4.0" - } - }, - "extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "requires": { - "@types/yauzl": "^2.9.1", - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - } - }, - "fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=", - "requires": { - "pend": "~1.2.0" - } - }, "find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, "requires": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, - "fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" - }, - "get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "requires": { - "pump": "^3.0.0" - } - }, - "https-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", - "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", - "requires": { - "agent-base": "6", - "debug": "4" - } - }, "locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, "requires": { "p-locate": "^4.1.0" } }, - "mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" - }, "node-fetch": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", - "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==", + "dev": true }, "p-locate": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, "requires": { "p-limit": "^2.2.0" } @@ -2259,93 +2377,26 @@ "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" - }, - "pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=" + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true }, "pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, "requires": { "find-up": "^4.0.0" } }, - "progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==" - }, - "proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, - "pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "requires": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, "requires": { "glob": "^7.1.3" } - }, - "tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", - "requires": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "requires": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - } - }, - "through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" - }, - "unbzip2-stream": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", - "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", - "requires": { - "buffer": "^5.2.1", - "through": "^2.3.8" - } - }, - "yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=", - "requires": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } } } }, @@ -2671,6 +2722,16 @@ } } }, + "react": { + "version": "16.10.2", + "resolved": "https://registry.npmjs.org/react/-/react-16.10.2.tgz", + "integrity": "sha512-MFVIq0DpIhrHFyqLU0S3+4dIcBhhOvBE8bJ/5kHPVOVaGdo0KuiQzpcjCPsf585WvhypqtrMILyoE2th6dT+Lw==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2" + } + }, "react-autocomplete": { "version": "github:janpaul123/react-autocomplete#bc8737070b5744069719c8fcd4e0a197192b0d48", "from": "github:janpaul123/react-autocomplete#bc8737070b5744069719c8fcd4e0a197192b0d48", @@ -2774,6 +2835,17 @@ "version": "github:davidswinegar/react-document-events#d7003228d8cd57535ee8b30fac9811aa237526b8", "from": "github:davidswinegar/react-document-events#d7003228d8cd57535ee8b30fac9811aa237526b8" }, + "react-dom": { + "version": "16.10.2", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.10.2.tgz", + "integrity": "sha512-kWGDcH3ItJK4+6Pl9DZB16BXYAZyrYQItU4OMy0jAkv5aNqc+mAKb4TpFtAteI6TJZu+9ZlNhaeNQSVQDHJzkw==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.16.2" + } + }, "react-draggable": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-3.0.5.tgz", @@ -2921,6 +2993,19 @@ } } }, + "react-popper": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.0.0.tgz", + "integrity": "sha1-uZRSFE6P5KzHf6PZWajHngemUIQ=", + "requires": { + "babel-runtime": "6.x.x", + "create-react-context": "^0.2.1", + "popper.js": "^1.14.1", + "prop-types": "^15.6.1", + "typed-styles": "^0.0.5", + "warning": "^3.0.0" + } + }, "react-range": { "version": "1.8.6", "resolved": "https://registry.npmjs.org/react-range/-/react-range-1.8.6.tgz", @@ -2980,6 +3065,26 @@ } } }, + "react-resizable": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.1.tgz", + "integrity": "sha512-wban8GguvXrmeObSVTmhxs99G9eUgYSGugf0i32qHXyqKDTzQsCKLhm3VJW24TpTBKJvM+cybNExskOCYkYF5Q==", + "requires": { + "prop-types": "15.x", + "react-draggable": "^4.0.3" + }, + "dependencies": { + "react-draggable": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.3.tgz", + "integrity": "sha512-jV4TE59MBuWm7gb6Ns3Q1mxX8Azffb7oTtDtBgFkxRvhDp38YAARmRplrj0+XGkhOJB5XziArX+4HUUABtyZ0w==", + "requires": { + "classnames": "^2.2.5", + "prop-types": "^15.6.0" + } + } + } + }, "react-resize-detector": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-4.2.1.tgz", @@ -3380,7 +3485,6 @@ "version": "0.16.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.16.2.tgz", "integrity": "sha512-BqYVWqwz6s1wZMhjFvLfVR5WXP7ZY32M/wYPo04CcuPM7XZEbV2TBNW7Z0UkguPTl0dWMA59VbNXxK6q+pHItg==", - "dev": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -3614,6 +3718,61 @@ "safe-buffer": "~5.2.0" } }, + "styled-components": { + "version": "3.4.10", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-3.4.10.tgz", + "integrity": "sha512-TA8ip8LoILgmSAFd3r326pKtXytUUGu5YWuqZcOQVwVVwB6XqUMn4MHW2IuYJ/HAD81jLrdQed8YWfLSG1LX4Q==", + "requires": { + "buffer": "^5.0.3", + "css-to-react-native": "^2.0.3", + "fbjs": "^0.8.16", + "hoist-non-react-statics": "^2.5.0", + "prop-types": "^15.5.4", + "react-is": "^16.3.1", + "stylis": "^3.5.0", + "stylis-rule-sheet": "^0.0.10", + "supports-color": "^3.2.3" + }, + "dependencies": { + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "has-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=" + }, + "hoist-non-react-statics": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz", + "integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==" + }, + "supports-color": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", + "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=", + "requires": { + "has-flag": "^1.0.0" + } + } + } + }, + "stylis": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-3.5.4.tgz", + "integrity": "sha512-8/3pSmthWM7lsPBKv7NXkzn2Uc9W7NotcwGNpJaa3k7WMM1XDCA4MgT5k/8BIexd5ydZdboXtU90XH9Ec4Bv/Q==" + }, + "stylis-rule-sheet": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stylis-rule-sheet/-/stylis-rule-sheet-0.0.10.tgz", + "integrity": "sha512-nTbZoaqoBnmK+ptANthb10ZRZOGC+EmTLLUxeYIuHNkEKcmKgXX1XWKkUBT2Ac4es3NybooPe0SmvKdhKJZAuw==" + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -3627,6 +3786,31 @@ "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==" }, + "tar-fs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "dev": true, + "requires": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "requires": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + } + }, "text-width": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/text-width/-/text-width-1.2.0.tgz", @@ -3635,6 +3819,12 @@ "xtend": "~4.0.0" } }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true + }, "tiny-invariant": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz", @@ -3684,6 +3874,11 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.11.2.tgz", "integrity": "sha512-tTSkux6IGPnUGUd1XAZHcpu85MOkIl5zX49pO+jfsie3eP0B6pyhOlLXm3cAC6T7s+euSDDUUV+Acop5WmtkVg==" }, + "typed-styles": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/typed-styles/-/typed-styles-0.0.5.tgz", + "integrity": "sha512-ht+rEe5UsdEBAa3gr64+QjUOqjOLJfWLvl5HZR5Ev9uo/OnD3p43wPeFSB1hNFc13GXQF/JU1Bn0YHLUqBRIlw==" + }, "typescript": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.5.3.tgz", @@ -3711,6 +3906,28 @@ "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.21.tgz", "integrity": "sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ==" }, + "unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dev": true, + "requires": { + "buffer": "^5.2.1", + "through": "^2.3.8" + }, + "dependencies": { + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + } + } + }, "underscore": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.4.4.tgz", @@ -3842,6 +4059,14 @@ "unist-util-stringify-position": "^1.1.1" } }, + "warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz", + "integrity": "sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w=", + "requires": { + "loose-envify": "^1.0.0" + } + }, "wasm-lz4": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wasm-lz4/-/wasm-lz4-1.0.0.tgz", @@ -3909,6 +4134,16 @@ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" }, + "yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=", + "dev": true, + "requires": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "yeast": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz", diff --git a/packages/webviz-core/package.json b/packages/webviz-core/package.json index 9b2a28d1f..38ce13068 100644 --- a/packages/webviz-core/package.json +++ b/packages/webviz-core/package.json @@ -14,6 +14,7 @@ "@sentry/browser": "5.11.0", "@welldone-software/why-did-you-render": "6.1.1", "async-retry": "1.2.3", + "audiobuffer-to-wav": "1.0.0", "buffer": "6.0.3", "chart.js": "davidswinegar/Chart.js#0785c29a4c57b3e1b4243f8163df7950a00b2a80", "chartjs-plugin-annotation": "0.5.7", @@ -45,7 +46,6 @@ "nearley": "2.15.1", "panzoom": "8.4.0", "prettier": "1.16.0", - "puppeteer": "7.0.1", "promise-queue": "2.2.5", "prop-types": "15.6.2", "rc-color-picker": "1.2.6", @@ -69,6 +69,7 @@ "react-range": "1.8.6", "react-reconciler": "^0.26.1", "react-redux": "7.1.0", + "react-resizable": "3.0.1", "react-resize-detector": "4.2.1", "react-router": "5.0.1", "react-router-dom": "5.0.1", @@ -106,6 +107,7 @@ "fake-indexeddb": "2.0.4", "fetch-mock": "7.2.5", "flow-bin": "0.110.0", + "puppeteer": "7.0.1", "react-test-renderer": "16.10.2", "redux-devtools-extension": "^2.13.7" }, diff --git a/packages/webviz-core/script/record-local-bag-video.js b/packages/webviz-core/script/record-local-bag-video.js index 7161d5b5d..b633bab29 100755 --- a/packages/webviz-core/script/record-local-bag-video.js +++ b/packages/webviz-core/script/record-local-bag-video.js @@ -17,7 +17,7 @@ const rmfr = require("rmfr"); const util = require("util"); require("@babel/register")(); -const recordVideo = require("../shared/recordVideo").default; +const { recordVideoAsBuffer } = require("../shared/recordVideo"); const exec = util.promisify(child_process.exec); @@ -91,7 +91,7 @@ async function main() { })(); console.log("Recording video..."); - const { mediaFile: video } = await recordVideo({ + const { mediaBuffer } = await recordVideoAsBuffer({ duration, speed, frameless, @@ -99,6 +99,7 @@ async function main() { parallel, bagPath: bag, experimentalFeaturesSettings, + errorIsWhitelisted: () => true, // Ignore errors for local recordings url, crop, dimensions: width && height ? { width, height } : undefined, @@ -109,7 +110,7 @@ async function main() { console.log("Saving video..."); if (program.mp3) { const tmpVideoFile = `${__dirname}/tmp-video.mp4`; - fs.writeFileSync(tmpVideoFile, video); + fs.writeFileSync(tmpVideoFile, mediaBuffer); await exec(`ffmpeg -y -i tmp-video.mp4 -i ${program.mp3} -vcodec copy -b:a 320k -shortest ${program.out}`, { cwd: __dirname, @@ -117,7 +118,7 @@ async function main() { await rmfr(tmpVideoFile); } else { - fs.writeFileSync(program.out, video); + fs.writeFileSync(program.out, mediaBuffer); } console.log("Done!"); diff --git a/packages/webviz-core/shared/fileUtils.js b/packages/webviz-core/shared/fileUtils.js new file mode 100644 index 000000000..0d3b586b8 --- /dev/null +++ b/packages/webviz-core/shared/fileUtils.js @@ -0,0 +1,30 @@ +// @flow +// +// Copyright (c) 2020-present, 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 fs from "fs"; +import rmfr from "rmfr"; +import uuid from "uuid"; + +import globalEnvVars from "./globalEnvVars"; + +export function makeTempDirectory() { + const tmpDir = `${globalEnvVars.tempVideosDirectory}/__video-recording-tmp-${uuid.v4()}__`; + fs.mkdirSync(tmpDir); + return tmpDir; +} + +// Executes the function passing in a new temp directory that will be automatically cleaned up afterwards. +export async function withTempDirectory(fn: (*) => Promise): Promise { + const tmpDir = makeTempDirectory(); + try { + return await fn(tmpDir); + } catch (error) { + throw error; + } finally { + rmfr(tmpDir); + } +} diff --git a/packages/webviz-core/shared/recordVideo.js b/packages/webviz-core/shared/recordVideo.js index b1ae2fa29..1a37e62e5 100644 --- a/packages/webviz-core/shared/recordVideo.js +++ b/packages/webviz-core/shared/recordVideo.js @@ -13,9 +13,11 @@ import rmfr from "rmfr"; import util from "util"; import uuid from "uuid"; +import type { VideoMetadata } from "../src/players/automatedRun/AutomatedRunPlayer"; import type { VideoRecordingAction } from "../src/players/automatedRun/videoRecordingClient"; import convertVideoToGif from "./convertVideoToGif"; import delay from "./delay"; +import { withTempDirectory } from "./fileUtils"; import globalEnvVars from "./globalEnvVars"; import promiseTimeout from "./promiseTimeout"; import runInBrowser from "./runInBrowser"; @@ -32,23 +34,7 @@ const waitForBrowserLoadTimeoutMs = 3 * 60000; // 3 minutes const actionTimeDurationMs = 1000; const pendingRequestPauseDurationMs = 1000; // Amount of time to wait for any pending XHR requests to settle -async function recordVideo({ - speed = 1, - framerate = 30, - frameless = false, - dimensions = { width: 1920, height: 1080 }, - crop, - parallel = 2, - duration, - bagPath, - url, - puppeteerLaunchConfig, - layout, - panelLayout, - mediaType, - errorIsWhitelisted, - experimentalFeaturesSettings, -}: { +type VideoConfig = { bagPath?: ?string, url: string, puppeteerLaunchConfig?: any, @@ -64,7 +50,50 @@ async function recordVideo({ mediaType?: "mp4" | "gif", crop?: { width: number, height: number, top: number, left: number }, errorIsWhitelisted?: (string) => boolean, -}): Promise<{ mediaFile: Buffer, sampledImageFile: Buffer }> { +}; + +type RecordingOptions = {| outputDirectory: string |}; + +// Delegates to recordVideo, but returns the resulting video as a Buffer. +// *PERFORMANCE NOTE* This should only be used for short videos since it reads +// the entire video into memory. For long videos, use recordVideo directly +// without reading the video into a Buffer. +export async function recordVideoAsBuffer( + config: VideoConfig +): Promise<{ mediaBuffer: Buffer, sampledImageFile: Buffer, metadata: VideoMetadata }> { + return withTempDirectory(async (tmpDir) => { + const { mediaPath, sampledImageFile, metadata } = await recordVideo(config, { outputDirectory: tmpDir }); + + const mediaBuffer = await readFile(mediaPath); + await rmfr(mediaPath); + + return { mediaBuffer, sampledImageFile, metadata }; + }); +} + +// Records a video with the specified configuration. +// Writes the resulting media file (mp4/gif) into the specified directory. +export async function recordVideo( + config: VideoConfig, + options: RecordingOptions +): Promise<{ mediaPath: string, sampledImageFile: Buffer, metadata: VideoMetadata }> { + const { + speed = 1, + framerate = 30, + frameless = false, + dimensions = { width: 1920, height: 1080 }, + crop, + parallel = 2, + duration, + bagPath, + url, + puppeteerLaunchConfig, + layout, + panelLayout, + mediaType, + errorIsWhitelisted, + experimentalFeaturesSettings, + } = config; const screenshotsDir = `${globalEnvVars.tempVideosDirectory}/__video-recording-tmp-${uuid.v4()}__`; await mkdir(screenshotsDir); @@ -73,7 +102,7 @@ async function recordVideo({ let hasFailed = false; try { - let msPerFrame; + let metadata: ?VideoMetadata; const promises = new Array(parallel).fill().map(async (_, parallelIndex) => { const urlObject = url ? new URL(url) : baseUrlObject; // Update the base url to point to localhost if it's set to something else. @@ -175,7 +204,7 @@ async function recordVideo({ } else if (actionObj.action === "finish") { log.info("Finished!"); isRunning = false; - msPerFrame = actionObj.msPerFrame; + metadata = actionObj.metadata; } else if (actionObj.action === "screenshot") { await waitForXhrRequests(pendingRequestUrls); @@ -207,8 +236,8 @@ async function recordVideo({ await Promise.all(promises); - if (msPerFrame == null) { - throw new Error("msPerFrame was not set"); + if (metadata == null) { + throw new Error("metadata was not set. The recording must have failed."); } const screenshotFileNames = fs.readdirSync(screenshotsDir); if (screenshotFileNames.length === 0) { @@ -219,11 +248,12 @@ async function recordVideo({ } const sampledImageFile = await readFile(`${screenshotsDir}/${last(screenshotFileNames)}`); - const outputFilename = "out.mp4"; + const outputDirectory = options.outputDirectory; // Once we're finished, we're going to stitch all the individual screenshots together // into a video, with the framerate specified by the client (via `msPerFrame`). - log.info(`Creating video with framerate ${framerate}fps (${msPerFrame}ms per frame)`); + let mediaPath = `${outputDirectory}/out.mp4`; + log.info(`Creating video with framerate ${framerate}fps (${metadata.msPerFrame}ms per frame)`); await exec( [ `ffmpeg -y`, @@ -234,26 +264,20 @@ async function recordVideo({ `-c:v libx264`, `-preset faster`, `-r ${framerate}`, - outputFilename, + mediaPath, ].join(" "), - { - cwd: screenshotsDir, - } + { cwd: screenshotsDir } ); - let mediaPath = `${screenshotsDir}/${outputFilename}`; log.info(`Video saved to ${mediaPath}`); // Convert the output to other mediaTypes, if requested if (mediaType === "gif") { - const gifPath = `${screenshotsDir}/out.gif`; + const gifPath = `${outputDirectory}/out.gif`; await convertVideoToGif(mediaPath, gifPath); mediaPath = gifPath; } - const mediaFile = await readFile(mediaPath); - await rmfr(mediaPath); - - return { mediaFile, sampledImageFile }; + return { mediaPath, sampledImageFile, metadata }; } catch (error) { hasFailed = true; throw error; @@ -297,5 +321,3 @@ export async function waitForXhrRequests(pendingRequestUrls: Set) { timeout = true; } } - -export default recordVideo; diff --git a/packages/webviz-core/src/actions/panels.js b/packages/webviz-core/src/actions/panels.js index 5928425a1..b11c743b5 100644 --- a/packages/webviz-core/src/actions/panels.js +++ b/packages/webviz-core/src/actions/panels.js @@ -133,11 +133,11 @@ export function applyPatchToLayout(patch: ?string, layout: PanelsState): PanelsS sendNotification( "Failed to apply patch on top of the layout.", `Ignoring the patch "${patch}".\n\n${e}`, - "user", - "warn" + "app", + "error" ); - return layout; } + return layout; } export const fetchLayout = (search: string): Dispatcher => (dispatch) => { diff --git a/packages/webviz-core/src/actions/panels.test.js b/packages/webviz-core/src/actions/panels.test.js new file mode 100644 index 000000000..1bee4f3cd --- /dev/null +++ b/packages/webviz-core/src/actions/panels.test.js @@ -0,0 +1,37 @@ +// @flow +// +// Copyright (c) 2021-present, 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 { applyPatchToLayout } from "webviz-core/src/actions/panels"; +import { deflatePatch } from "webviz-core/src/util/layout"; +import sendNotification from "webviz-core/src/util/sendNotification"; + +const DEFAULT_LAYOUT = { + layout: "", + savedProps: {}, + globalVariables: {}, + userNodes: {}, + linkedGlobalVariables: [], + playbackConfig: { speed: 0.1, messageOrder: "receiveTime", timeDisplayMethod: "ROS" }, +}; + +describe("panels", () => { + describe("applyPatchToLayout", () => { + it("handles patches", () => { + const patch = deflatePatch({ globalVariables: [{}, { foo: "bar" }] }); + expect(applyPatchToLayout(patch, DEFAULT_LAYOUT)).toEqual({ + ...DEFAULT_LAYOUT, + globalVariables: { foo: "bar" }, + }); + }); + it("handles invalid patches", () => { + applyPatchToLayout("abc", DEFAULT_LAYOUT); + + expect(sendNotification).toHaveBeenLastCalledWith(expect.any(String), expect.any(String), "app", "error"); + sendNotification.expectCalledDuringTest(); + }); + }); +}); diff --git a/packages/webviz-core/src/components/ChildToggle/index.js b/packages/webviz-core/src/components/ChildToggle/index.js index 78d0f51d9..c748f3e8c 100644 --- a/packages/webviz-core/src/components/ChildToggle/index.js +++ b/packages/webviz-core/src/components/ChildToggle/index.js @@ -80,14 +80,15 @@ export default class ChildToggle extends React.Component { } addDocListener() { - // add a document listener to hide the dropdown body if - // it is expanded and the document is clicked on - document.addEventListener("click", this.onDocumentClick, true); + // add a document listener to hide the dropdown body if it is expanded and + // the document is clicked on. Using 'mousedown' here instead of 'click' + // since we don't want click-and-drag interactions to close the toggle. + document.addEventListener("mousedown", this.onDocumentClick, true); } removeDocListener() { // cleanup the document listener - document.removeEventListener("click", this.onDocumentClick, true); + document.removeEventListener("mousedown", this.onDocumentClick, true); } componentDidUpdate(prevProps: Props) { diff --git a/packages/webviz-core/src/components/GLChart/gridRenderer.js b/packages/webviz-core/src/components/GLChart/gridRenderer.js new file mode 100644 index 000000000..ee1d1b7ee --- /dev/null +++ b/packages/webviz-core/src/components/GLChart/gridRenderer.js @@ -0,0 +1,109 @@ +// @flow +// +// Copyright (c) 2021-present, 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 { Mat4 } from "gl-matrix"; + +import type { GLContext, Bounds } from "./types"; +import { createShaderProgram, createBuffer, checkErrors } from "./utils"; + +export default function gridRenderer(gl: GLContext) { + if (!gl) { + return; + } + + const program = createShaderProgram( + gl, + ` + precision highp float; + + in vec2 position; + + uniform mat4 proj; + uniform mat4 padding; + + void main() { + gl_Position = padding * proj * vec4(position, 0, 1); + } + `, + ` + precision highp float; + + out vec4 outColor; + + void main() { + outColor = vec4(1, 1, 1, 1); + } + ` + ); + + // Buffer has a static size, but content is dynamic so we create it only once + // it has to be "big enough" for storing all posible vertices. It can be resized + // but we should do it only when necessary. + // TODO: resize buffers when needed. + const positionBuffer = createBuffer(gl, new Float32Array(5000)); + + const positionAttribLocation = gl.getAttribLocation(program, "position"); + const projUniformLocation = gl.getUniformLocation(program, "proj"); + const paddingUniformLocation = gl.getUniformLocation(program, "padding"); + + const vao = gl.createVertexArray(); + gl.bindVertexArray(vao); + gl.enableVertexAttribArray(positionAttribLocation); + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.vertexAttribPointer(positionAttribLocation, 2, gl.FLOAT, false, 0, 0); + + const error = checkErrors(gl); + if (error) { + console.warn("Error creating grid renderer:", error); + return; + } + + let primitiveCount = 0; + let prevBounds; + + return ({ proj, bounds, padding }: { proj: Mat4, bounds: Bounds, padding: Mat4 }) => { + if (bounds !== prevBounds) { + const minX = bounds.x.min; + const maxX = bounds.x.max; + const minY = bounds.y.min; + const maxY = bounds.y.max; + + const positions = []; + const xStep = (maxX - minX) / 5; + for (let x = minX; x <= maxX; x += xStep) { + positions.push(...[x, maxY, x, minY]); + } + + const yStep = (maxY - minY) / 5; + for (let y = minY; y <= maxY; y += yStep) { + positions.push(...[minX, y, maxX, y]); + } + + primitiveCount = positions.length / 2; + if (primitiveCount > 0) { + // Update position buffer data + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.bufferSubData(gl.ARRAY_BUFFER, 0, new Float32Array(positions)); + gl.bindBuffer(gl.ARRAY_BUFFER, null); + } + } + + prevBounds = bounds; + + if (primitiveCount === 0) { + return; + } + + // Render + gl.useProgram(program); + gl.uniformMatrix4fv(projUniformLocation, true, proj); + gl.uniformMatrix4fv(paddingUniformLocation, true, padding); + gl.bindVertexArray(vao); + gl.drawArrays(gl.LINES, 0, primitiveCount); + }; +} diff --git a/packages/webviz-core/src/components/GLChart/index.js b/packages/webviz-core/src/components/GLChart/index.js new file mode 100644 index 000000000..ed81ddbb1 --- /dev/null +++ b/packages/webviz-core/src/components/GLChart/index.js @@ -0,0 +1,161 @@ +// @flow +// +// Copyright (c) 2021-present, 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 { Mat4 } from "gl-matrix"; +import React, { useState, useRef, useMemo, useCallback } from "react"; +import uuid from "uuid"; + +import gridRenderer from "./gridRenderer"; +import linesRenderer from "./linesRenderer"; +import pointRenderer from "./pointRenderer"; +import type { GLContext, Bounds } from "./types"; +import { beginRender, devicePixelRatio, createGLContext } from "./utils"; +import { type Props } from "webviz-core/src/components/ReactChartjs"; +import { useDeepMemo } from "webviz-core/src/util/hooks"; + +// Compute the min/max values for each axis +// This is an expensive operation and should be called as few +// times as possible. +function computeDataBounds(data: any, options: any): Bounds { + const { + scales: { xAxes, yAxes }, + } = options; + + let minX = xAxes[0].ticks.min; + let maxX = xAxes[0].ticks.max; + let minY = yAxes[0].ticks.min; + let maxY = yAxes[0].ticks.max; + + if (minX == null || maxX == null || minY == null || maxY == null) { + data.datasets.forEach((dataset) => { + dataset.data.forEach((datum) => { + const { x, y } = datum; + minX = !(minX == null || isNaN(minX)) ? minX : x; + maxX = !(maxX == null || isNaN(maxX)) ? maxX : x; + minY = !(minY == null || isNaN(minY)) ? minY : y; + maxY = !(maxY == null || isNaN(maxY)) ? maxY : y; + + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + }); + }); + } + + const bounds = { + x: { + min: minX, + max: maxX, + }, + y: { + min: minY, + max: maxY, + }, + }; + + return bounds; +} + +// Compute a projection matrix +// TODO: Add types for parameters +function computeProjectionMatrix(bounds: Bounds): Mat4 { + const minX = bounds.x.min; + const maxX = bounds.x.max; + const minY = bounds.y.min; + const maxY = bounds.y.max; + + // This is the standard OpenGL orthographic matrix, except that we set + // the third row to 0 since we don't do any operations on the Z axis + // prettier-ignore + return [ + 2 / (maxX - minX), 0, 0, -(maxX + minX) / (maxX - minX), + 0, 2 / (maxY - minY), 0, -(maxY + minY) / (maxY - minY), + 0, 0, 0, 0, + 0, 0, 0, 1, + ]; +} + +// Compute a scaling matrix to leave some space for labels and margin +function computePaddingMatrix(gl: GLContext, x: number, y: number): Mat4 { + const { width, height } = gl.canvas; + const sx = 1 - x / width; + const sy = 1 - y / height; + + // This is a simple scaling matrix for X and Y axes. + // prettier-ignore + return [ + sx, 0, 0, 0, + 0, sy, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + ]; +} + +export default function GLChart({ width, height, data, options }: Props) { + const [id] = useState(uuid); + const [gl, setGL] = useState(); + + const canvasRef = useRef(); + + const setCanvasRef = useCallback((canvas) => { + if (canvas) { + if (canvasRef.current !== canvas) { + canvasRef.current = canvas; + // Save the GL context and force a render + setGL(createGLContext(canvas)); + } + } + }, []); + + const renderGrid = useMemo(() => gridRenderer(gl), [gl]); + const renderPoints = useMemo(() => pointRenderer(gl), [gl]); + const renderLines = useMemo(() => linesRenderer(gl), [gl]); + + // Memoizing data and options helps rendering functions to update + // internal buffers only when the values actually change. + const memoizedData = useDeepMemo(data); + const memoizedOptions = useDeepMemo(options); + const bounds = useMemo(() => computeDataBounds(memoizedData, memoizedOptions), [memoizedData, memoizedOptions]); + const proj = useMemo(() => computeProjectionMatrix(bounds), [bounds]); + + // Scaling the canvas will provide better looks in HDPI monitors, like Retina, + // by increasing the presentation framebuffer resolution. This, of course, has + // some performance impact on those devices. + // TODO: do not scale during playback? + const canvasScale = devicePixelRatio; + + if (gl) { + beginRender(gl); + + // Add some margin around the chart + const padding = computePaddingMatrix(gl, 20 * canvasScale, 20 * canvasScale); + + if (renderGrid) { + renderGrid({ proj, bounds, padding }); + } + + if (renderLines) { + renderLines({ data: memoizedData, proj, padding }); + } + + if (renderPoints) { + renderPoints({ data: memoizedData, proj, padding }); + } + } + + return ( + + ); +} diff --git a/packages/webviz-core/src/components/GLChart/linesRenderer.js b/packages/webviz-core/src/components/GLChart/linesRenderer.js new file mode 100644 index 000000000..e68d6dfd5 --- /dev/null +++ b/packages/webviz-core/src/components/GLChart/linesRenderer.js @@ -0,0 +1,108 @@ +// @flow +// +// Copyright (c) 2021-present, 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 GLContext } from "./types"; +import { createShaderProgram, createBuffer, checkErrors } from "./utils"; + +export default function testPointRenderer(gl: GLContext) { + if (!gl) { + return; + } + + const program = createShaderProgram( + gl, + ` + precision highp float; + + in vec2 inPosition; + + uniform mat4 proj; + uniform mat4 padding; + + void main() { + gl_Position = padding * proj * vec4(inPosition, 0, 1); + } + `, + ` + precision highp float; + + out vec4 outColor; + + void main() { + outColor = vec4(0, 1, 1, 1); + } + ` + ); + + // Buffer has a static size, but content is dynamic so we create it only once + // it has to be "big enough" for storing all posible vertices. It can be resized + // but we should do it only when necessary. + // TODO: resize buffers when needed. + const positionBuffer = createBuffer(gl, new Float32Array(10000)); + + const positionAttribLocation = gl.getAttribLocation(program, "inPosition"); + const projUniformLocation = gl.getUniformLocation(program, "proj"); + const paddingUniformLocation = gl.getUniformLocation(program, "padding"); + + const vao = gl.createVertexArray(); + gl.bindVertexArray(vao); + gl.enableVertexAttribArray(positionAttribLocation); + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.vertexAttribPointer(positionAttribLocation, 2, gl.FLOAT, false, 0, 0); + + const error = checkErrors(gl); + if (error) { + console.warn("Error creating point renderer:", error); + return; + } + + let primitiveCount = 0; + let prevData; + + return ({ data, proj, padding }: any) => { + const { datasets } = data; + + if (data !== prevData) { + const positions = []; + datasets.forEach((dataset) => { + if (!dataset.showLine) { + return; + } + dataset.data.forEach((datum) => { + const { x, y } = datum; + positions.push(x); + positions.push(y); + }); + positions.push(NaN); + positions.push(NaN); + }); + + primitiveCount = positions.length / 2; + if (primitiveCount > 0) { + // Update position buffer data + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.bufferSubData(gl.ARRAY_BUFFER, 0, new Float32Array(positions)); + gl.bindBuffer(gl.ARRAY_BUFFER, null); + } + + prevData = data; + } + + if (primitiveCount === 0) { + return; + } + + // Render + gl.useProgram(program); + gl.uniformMatrix4fv(projUniformLocation, true, proj); + gl.uniformMatrix4fv(paddingUniformLocation, true, padding); + gl.bindVertexArray(vao); + gl.lineWidth(20); + gl.drawArrays(gl.LINE_STRIP, 0, primitiveCount); + }; +} diff --git a/packages/webviz-core/src/components/GLChart/pointRenderer.js b/packages/webviz-core/src/components/GLChart/pointRenderer.js new file mode 100644 index 000000000..fbb1a8cee --- /dev/null +++ b/packages/webviz-core/src/components/GLChart/pointRenderer.js @@ -0,0 +1,115 @@ +// @flow +// +// Copyright (c) 2021-present, 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 GLContext } from "./types"; +import { createShaderProgram, createBuffer, checkErrors } from "./utils"; + +export default function testPointRenderer(gl: GLContext) { + if (!gl) { + return; + } + + const program = createShaderProgram( + gl, + ` + precision highp float; + + in vec2 inPosition; + + uniform mat4 proj; + uniform mat4 padding; + + void main() { + gl_PointSize = 5.0; + gl_Position = padding * proj * vec4(inPosition, 0, 1); + } + `, + ` + precision highp float; + + out vec4 outColor; + + void main() { + // Render points as circles + vec3 normal; + normal.xy = gl_PointCoord * 2.0 - 1.0; + float r2 = dot(normal.xy, normal.xy); + if (r2 > 1.0) { + discard; + } + + outColor = vec4(0, 1, 0, 1); + } + ` + ); + + // Buffer has a static size, but content is dynamic so we create it only once + // it has to be "big enough" for storing all posible vertices. It can be resized + // but we should do it only when necessary. + // TODO: resize buffers when needed. + const positionBuffer = createBuffer(gl, new Float32Array(10000)); + + const positionAttribLocation = gl.getAttribLocation(program, "inPosition"); + const projUniformLocation = gl.getUniformLocation(program, "proj"); + const paddingUniformLocation = gl.getUniformLocation(program, "padding"); + + const vao = gl.createVertexArray(); + gl.bindVertexArray(vao); + gl.enableVertexAttribArray(positionAttribLocation); + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.vertexAttribPointer(positionAttribLocation, 2, gl.FLOAT, false, 0, 0); + + const error = checkErrors(gl); + if (error) { + console.warn("Error creating point renderer:", error); + return; + } + + let primitiveCount = 0; + let prevData; + + return ({ data, proj, padding }: any) => { + const { datasets } = data; + + if (data !== prevData) { + const positions = []; + datasets.forEach((dataset) => { + if (!dataset.pointBackgroundColor) { + return; + } + dataset.data.forEach((datum) => { + const { x, y } = datum; + positions.push(x); + positions.push(y); + }); + }); + + primitiveCount = positions.length / 2; + + if (primitiveCount > 0) { + // Update position buffer data + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.bufferSubData(gl.ARRAY_BUFFER, 0, new Float32Array(positions)); + gl.bindBuffer(gl.ARRAY_BUFFER, null); + } + + prevData = data; + } + + if (primitiveCount === 0) { + return; + } + + // Render + gl.useProgram(program); + gl.uniformMatrix4fv(projUniformLocation, true, proj); + gl.uniformMatrix4fv(paddingUniformLocation, true, padding); + gl.bindVertexArray(vao); + gl.drawArrays(gl.POINTS, 0, primitiveCount); + }; +} diff --git a/packages/webviz-core/src/components/GLChart/types.js b/packages/webviz-core/src/components/GLChart/types.js new file mode 100644 index 000000000..867325d13 --- /dev/null +++ b/packages/webviz-core/src/components/GLChart/types.js @@ -0,0 +1,20 @@ +// @flow +// +// Copyright (c) 2021-present, 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 GLContext = any; + +export type Bounds = { + x: { + min: number, + max: number, + }, + y: { + min: number, + max: number, + }, +}; diff --git a/packages/webviz-core/src/components/GLChart/utils.js b/packages/webviz-core/src/components/GLChart/utils.js new file mode 100644 index 000000000..ec7c17203 --- /dev/null +++ b/packages/webviz-core/src/components/GLChart/utils.js @@ -0,0 +1,114 @@ +// @flow +// +// Copyright (c) 2021-present, 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 GLContext } from "./types"; + +export const devicePixelRatio = window.devicePixelRatio || 1; + +// This is a costly operation. DO NOT use during rendering loop if possible +export const checkErrors = (gl: GLContext) => { + const code = gl.getError(); + switch (code) { + case gl.NO_ERROR: + return; + case gl.INVALID_ENUM: + return { + code, + msg: "Invalid enum.", + }; + case gl.INVALID_VALUE: + return { + code, + msg: "Invalid value.", + }; + case gl.INVALID_OPERATION: + return { + code, + msg: "Invalid operation.", + }; + case gl.INVALID_FRAMEBUFFER_OPERATION: + return { + code, + msg: "Invalid Framebuffer operation.", + }; + case gl.OUT_OF_MEMORY: + return { + code, + msg: "Out of memory.", + }; + case gl.CONTEXT_LOST_WEBGL: + return { + code, + msg: "Context lost.", + }; + default: + return { + code, + msg: "Unknown error", + }; + } +}; + +export const createGLContext = (canvas: HTMLCanvasElement): GLContext => { + const gl = canvas.getContext("webgl2"); + if (!gl) { + throw new Error("Cannot initialize WebGL context"); + } + + return gl; +}; + +export const createShader = (gl: GLContext, type: number, source: string) => { + if (!source.startsWith("#version")) { + // Prepend GLES 3.0 version if none was provided + source = `#version 300 es\n${source}`; + } + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS); + if (!success) { + const msg = gl.getShaderInfoLog(shader); + gl.deleteShader(shader); + throw new Error(msg); + } + return shader; +}; + +export const createShaderProgram = (gl: GLContext, vert: string, frag: string) => { + const vertexShader = createShader(gl, gl.VERTEX_SHADER, vert); + const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, frag); + if (!vertexShader || !fragmentShader) { + return; + } + + const program = gl.createProgram(); + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + const success = gl.getProgramParameter(program, gl.LINK_STATUS); + if (!success) { + const msg = gl.getProgramInfoLog(program); + gl.deleteProgram(program); + throw new Error(msg); + } + + return program; +}; + +export const createBuffer = (gl: GLContext, data: any) => { + const buffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); + return buffer; +}; + +export const beginRender = (gl: GLContext) => { + gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); +}; diff --git a/packages/webviz-core/src/components/Menu/Menu.js b/packages/webviz-core/src/components/Menu/Menu.js index 77fe54744..2d26f2219 100644 --- a/packages/webviz-core/src/components/Menu/Menu.js +++ b/packages/webviz-core/src/components/Menu/Menu.js @@ -9,25 +9,73 @@ import cx from "classnames"; import * as React from "react"; +import "react-resizable/css/styles.css"; + import styles from "./index.module.scss"; +import Resizable, { type ResizeHandleAxis, type ContainerSize } from "webviz-core/src/components/Resizable"; type Props = { children: any, className?: string, style?: { [string]: any }, + // React-resizable props + resizable?: boolean, + resizeHandles?: ?(ResizeHandleAxis[]), + maxConstraints?: [number, number], + minConstraints?: [number, number], + initialSize?: ?[number, number], + onResize?: (size: ContainerSize) => void, }; // a small component which wraps its children in menu styles // and provides a helper { Item } component which can be used // to render typical menu items with text & an icon -export default class Menu extends React.PureComponent { - render() { - const { children, className, style } = this.props; - const classes = cx(styles.container, className); +const Menu = ({ + children, + className, + style, + resizable, + resizeHandles, + maxConstraints, + minConstraints, + initialSize, + onResize, +}: Props) => { + const classes = cx(styles.container, className); + + if (!resizable) { return (
{children}
); } -} + + return ( + + {({ ref, width, height, minWidth, minHeight }) => ( +
+ {children} +
+ )} +
+ ); +}; + +export default Menu; diff --git a/packages/webviz-core/src/components/ReactChartjs/ChartJSManager.js b/packages/webviz-core/src/components/ReactChartjs/ChartJSManager.js index df07d86a3..61308a280 100644 --- a/packages/webviz-core/src/components/ReactChartjs/ChartJSManager.js +++ b/packages/webviz-core/src/components/ReactChartjs/ChartJSManager.js @@ -39,14 +39,34 @@ function hideAllTicksScaleCallback() { return ""; } +// exported for tests +export function printShortNumber(value: number) { + if (value === 0) { + return "0"; + } + if (Math.abs(value) >= 100000 || Math.abs(value) < 0.00001) { + // Force scientific notation for large and small numbers. Don't allow too many significant + // figures. + return Number.parseFloat(value.toPrecision(3)) + .toExponential() + .replace("+", ""); // The "+" in "1.0e+10" just takes up space. + } + // Truncate the decimal representation based on the length of the whole-number part. + // For implementation simplicity don't count an extra digit for a leading "-". + const truncatedLength = Math.trunc(Math.abs(value)).toString().length; + // Values with length more than 6 will be in scientific notation, but clamp to zero just to be + // safe. + const decimalDigits = Math.max(0, 6 - truncatedLength); + return `${Math.round(value * 10 ** decimalDigits) / 10 ** decimalDigits}`; +} + function hideFirstAndLastTicksScaleCallback(value: number, index: number, values: number[]) { if (index === 0 || index === values.length - 1) { // First and last labels sometimes get super long rounding errors when zooming. // This fixes that. return ""; } - // Also round the scale value. - return `${Math.round(value * 1000) / 1000}`; + return printShortNumber(value); } function displayTicksInSecondsCallback(value: number) { diff --git a/packages/webviz-core/src/components/ReactChartjs/ChartJSManager.test.js b/packages/webviz-core/src/components/ReactChartjs/ChartJSManager.test.js new file mode 100644 index 000000000..07e179264 --- /dev/null +++ b/packages/webviz-core/src/components/ReactChartjs/ChartJSManager.test.js @@ -0,0 +1,48 @@ +// @flow +// +// Copyright (c) 2018-present, 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 { printShortNumber } from "./ChartJSManager"; + +describe("printShortNumber", () => { + it("Renders zero correctly", () => { + expect(printShortNumber(0)).toBe("0"); + expect(printShortNumber(-0)).toBe("0"); + }); + + it("Works for small numbers", () => { + expect(printShortNumber(0.00000123456789)).toBe("1.23e-6"); + expect(printShortNumber(0.0000123456789)).toBe("0.00001"); + expect(printShortNumber(0.000123456789)).toBe("0.00012"); + expect(printShortNumber(0.00123456789)).toBe("0.00123"); + expect(printShortNumber(0.0123456789)).toBe("0.01235"); + + expect(printShortNumber(-0.00000123456789)).toBe("-1.23e-6"); + expect(printShortNumber(-0.0000123456789)).toBe("-0.00001"); + expect(printShortNumber(-0.000123456789)).toBe("-0.00012"); + expect(printShortNumber(-0.00123456789)).toBe("-0.00123"); + expect(printShortNumber(-0.0123456789)).toBe("-0.01235"); + }); + + it("Works for larger numbers", () => { + expect(printShortNumber(0.123456789)).toBe("0.12346"); + expect(printShortNumber(1.23456789)).toBe("1.23457"); + expect(printShortNumber(12.3456789)).toBe("12.3457"); + expect(printShortNumber(123.456789)).toBe("123.457"); + expect(printShortNumber(1234.56789)).toBe("1234.57"); + expect(printShortNumber(12345.6789)).toBe("12345.7"); + expect(printShortNumber(123456.789)).toBe("1.23e5"); + + expect(printShortNumber(-0.123456789)).toBe("-0.12346"); + expect(printShortNumber(-1.23456789)).toBe("-1.23457"); + expect(printShortNumber(-12.3456789)).toBe("-12.3457"); + expect(printShortNumber(-123.456789)).toBe("-123.457"); + expect(printShortNumber(-1234.56789)).toBe("-1234.57"); + expect(printShortNumber(-12345.6789)).toBe("-12345.7"); + expect(printShortNumber(-123456.789)).toBe("-1.23e5"); + }); +}); diff --git a/packages/webviz-core/src/components/ReactChartjs/__mocks__/index.js b/packages/webviz-core/src/components/ReactChartjs/__mocks__/index.js index 4c30fd813..500611d5d 100644 --- a/packages/webviz-core/src/components/ReactChartjs/__mocks__/index.js +++ b/packages/webviz-core/src/components/ReactChartjs/__mocks__/index.js @@ -8,12 +8,14 @@ import React from "react"; +export const DEFAULT_PROPS = { + zoomOptions: { mode: "xy", enabled: true, sensitivity: 3, speed: 0.1 }, +}; + export default class ReactChartjs extends React.PureComponent { static onUpdate = jest.fn(); - static defaultProps = { - zoomOptions: {}, - }; + static defaultProps = DEFAULT_PROPS; componentDidUpdate() { ReactChartjs.onUpdate(); diff --git a/packages/webviz-core/src/components/ReactChartjs/index.js b/packages/webviz-core/src/components/ReactChartjs/index.js index 5966ee52b..de9275416 100644 --- a/packages/webviz-core/src/components/ReactChartjs/index.js +++ b/packages/webviz-core/src/components/ReactChartjs/index.js @@ -25,14 +25,16 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import Hammer from "hammerjs"; -import React from "react"; +import React, { useState, useRef, useCallback, useEffect } from "react"; import uuid from "uuid"; +import { useMessagePipeline } from "../MessagePipeline"; import type { ScaleOptions as ManagerScaleOptions } from "./ChartJSManager"; import ChartJSWorker from "./ChartJSWorker.worker"; import { type ScaleBounds, type ZoomOptions, type PanOptions, wheelZoomHandler } from "./zoomAndPanHelpers"; import { objectValues } from "webviz-core/src/util"; import { getFakeRpcs, type RpcLike } from "webviz-core/src/util/FakeRpc"; +import { useDeepMemo, useShallowMemo } from "webviz-core/src/util/hooks"; import supportsOffscreenCanvas from "webviz-core/src/util/supportsOffscreenCanvas"; import WebWorkerManager from "webviz-core/src/util/WebWorkerManager"; @@ -40,9 +42,14 @@ const getMainThreadChartJSWorker = () => import(/* webpackChunkName: "main-threa export type HoveredElement = any; export type ScaleOptions = ManagerScaleOptions; -type OnEndChartUpdate = () => void; -type Props = {| +export type ChartCallbacks = $ReadOnly<{| + canvasRef: $ReadOnly<{ current: ?HTMLCanvasElement }>, + getElementAtXAxis: (event: SyntheticMouseEvent | MouseEvent) => Promise, + resetZoom: () => Promise, +|}>; + +export type Props = {| id?: string, data: any, height: number, @@ -57,146 +64,135 @@ type Props = {| onClick?: (SyntheticMouseEvent, datalabel: ?any) => void, forceDisableWorkerRendering?: ?boolean, scaleOptions?: ?ScaleOptions, - onChartUpdate?: () => OnEndChartUpdate, + callbacksRef: { current: ?ChartCallbacks }, |}; const devicePixelRatio = window.devicePixelRatio || 1; const webWorkerManager = new WebWorkerManager(ChartJSWorker, 4); -class ChartComponent extends React.PureComponent { - canvas: ?HTMLCanvasElement; - _chartRpc: ?RpcLike; - // $FlowFixMe - _node: OffscreenCanvas; - _id = uuid.v4(); - _scaleBoundsByScaleId = {}; - _usingWebWorker = false; - _onEndChartUpdateCallbacks = {}; - - constructor(props: Props) { - super(props); - this._getRpc(); - } - - static defaultProps = { - legend: { - display: true, - position: "bottom", - }, - type: "doughnut", - height: 150, - width: 300, - options: {}, - zoomOptions: { mode: "xy", enabled: true, sensitivity: 3, speed: 0.1 }, - panOptions: { mode: "xy", enabled: true, speed: 20, threshold: 10 }, - }; - - _getRpc = async (): Promise => { - if (this._chartRpc) { - return this._chartRpc; +export const DEFAULT_PROPS = { + legend: { + display: true, + position: "bottom", + }, + type: "doughnut", + height: 150, + width: 300, + options: {}, + zoomOptions: { mode: "xy", enabled: true, sensitivity: 3, speed: 0.1 }, + panOptions: { mode: "xy", enabled: true, speed: 20, threshold: 10 }, +}; + +export default function ChartComponent({ + forceDisableWorkerRendering, + type, + data, + options, + scaleOptions, + height = DEFAULT_PROPS.height, + width = DEFAULT_PROPS.width, + onScaleBoundsUpdate, + onPanZoom, + panOptions = DEFAULT_PROPS.panOptions, + zoomOptions = DEFAULT_PROPS.zoomOptions, + onClick: onClickHandler, + callbacksRef, +}: Props) { + const [id] = useState(uuid); + + const usingWebWorker = useRef(false); + const chartRpc = useRef(); + const canvasRef = useRef(null); + const initialized = useRef(false); + const panning = useRef(false); + const currentDeltaX = useRef(); + const currentDeltaY = useRef(); + const currentPinchScaling = useRef(1); + const nodeRef = useRef(); + + const getRpc = useCallback(async (): Promise => { + if (chartRpc.current) { + return chartRpc.current; } - if (!this.props.forceDisableWorkerRendering && supportsOffscreenCanvas()) { + if (!forceDisableWorkerRendering && supportsOffscreenCanvas()) { // Only use a real chart worker if we support offscreenCanvas. - this._chartRpc = webWorkerManager.registerWorkerListener(this._id); - this._usingWebWorker = true; - } else { - // Otherwise use a fake RPC so that we don't have to maintain two separate APIs. - const { mainThreadRpc, workerRpc } = getFakeRpcs(); - const { default: MainThreadChartJSWorker } = await getMainThreadChartJSWorker(); - new MainThreadChartJSWorker(workerRpc); - this._chartRpc = mainThreadRpc; - this._usingWebWorker = false; + const rpc = webWorkerManager.registerWorkerListener(id); + chartRpc.current = rpc; + usingWebWorker.current = true; + return rpc; } - return this._chartRpc; - }; - - _sendToRpc = async (event: string, data: any, transferrables?: any[]): Promise => { - const rpc = await this._getRpc(); - return rpc.send(event, data, transferrables); - }; - - componentDidMount() { - const { type, data, options, scaleOptions, width, height } = this.props; - this._setupPanAndPinchHandlers(); - - let node = this.canvas; - if (!this.props.forceDisableWorkerRendering && supportsOffscreenCanvas()) { - // $FlowFixMe - node = this.canvas.transferControlToOffscreen(); + // Otherwise use a fake RPC so that we don't have to maintain two separate APIs. + const { mainThreadRpc, workerRpc } = getFakeRpcs(); + const { default: MainThreadChartJSWorker } = await getMainThreadChartJSWorker(); + new MainThreadChartJSWorker(workerRpc); + chartRpc.current = mainThreadRpc; + usingWebWorker.current = false; + return mainThreadRpc; + }, [forceDisableWorkerRendering, id]); + + const sendToRpc = useCallback(async ( + event: string, + dataToSend: any, + transferrables?: any[] + ): Promise => { + const rpc = await getRpc(); + return rpc.send(event, dataToSend, transferrables); + }, [getRpc]); + + const handleScaleBoundsUpdate = useCallback((scaleBoundsUpdate) => { + if (onScaleBoundsUpdate && scaleBoundsUpdate) { + onScaleBoundsUpdate(scaleBoundsUpdate); } - this._node = node; - this._sendToRpc( - "initialize", - { - node, - id: this._id, - type, - data, - options, - scaleOptions, - devicePixelRatio, - width, - height, - }, - [node] - ).then((scaleBoundsUpdate) => this._onUpdateScaleBounds(scaleBoundsUpdate)); - } - - componentDidUpdate() { - const { data, options, scaleOptions, width, height, onChartUpdate } = this.props; - let chartUpdateId; - if (onChartUpdate) { - const onEndChartUpdate = onChartUpdate(); - chartUpdateId = uuid.v4(); - this._onEndChartUpdateCallbacks[chartUpdateId] = onEndChartUpdate; + }, [onScaleBoundsUpdate]); + + const handlePanZoom = useCallback((scaleBoundsUpdate) => { + if (onPanZoom && scaleBoundsUpdate) { + onPanZoom(scaleBoundsUpdate); } - this._sendToRpc("update", { - id: this._id, - data, - options, - scaleOptions, - width, - height, - }) - .then((scaleBoundsUpdate) => { - this._onUpdateScaleBounds(scaleBoundsUpdate); - }) - .finally(() => { - if (this._onEndChartUpdateCallbacks[chartUpdateId]) { - this._onEndChartUpdateCallbacks[chartUpdateId](); - delete this._onEndChartUpdateCallbacks[chartUpdateId]; - } - }); - } + }, [onPanZoom]); - componentWillUnmount() { - // If this component will unmount, resolve any pending update callbacks. - objectValues(this._onEndChartUpdateCallbacks).forEach((callback) => callback()); - this._onEndChartUpdateCallbacks = {}; + const onWheel = useCallback(async (event: SyntheticWheelEvent) => { + if (!zoomOptions.enabled) { + return; + } + const { percentZoomX, percentZoomY, focalPoint } = wheelZoomHandler(event, zoomOptions); + const scaleBoundsUpdate = await sendToRpc("doZoom", { + id, + zoomOptions, + percentZoomX, + percentZoomY, + focalPoint, + whichAxesParam: "xy", + }); - if (this._chartRpc) { - this._chartRpc.send("destroy", { id: this._id }); - this._chartRpc = null; + handleScaleBoundsUpdate(scaleBoundsUpdate); + handlePanZoom(scaleBoundsUpdate); + }, [zoomOptions, sendToRpc, id, handleScaleBoundsUpdate, handlePanZoom]); - if (this._usingWebWorker) { - webWorkerManager.unregisterWorkerListener(this._id); - } + const onClick = useCallback(async (event: SyntheticMouseEvent) => { + if (!panning.current && onClickHandler && canvasRef.current) { + const rect = event.currentTarget.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const newEvent = { x, y }; + // Since our next call is asynchronous, we have to persist the event so that React doesn't clear it. + event.persist(); + const datalabel = await sendToRpc("getDatalabelAtEvent", { id, event: newEvent }); + onClickHandler(event, datalabel); } - } - - _ref = (element: ?HTMLCanvasElement) => { - this.canvas = element; - }; + }, [id, onClickHandler, sendToRpc]); - getElementAtXAxis = async (event: SyntheticMouseEvent | MouseEvent): Promise => { - if (!this.canvas) { + const getElementAtXAxis = useCallback(async ( + event: SyntheticMouseEvent | MouseEvent + ): Promise => { + if (!canvasRef.current) { return Promise.resolve(undefined); } - const boundingRect = this.canvas.getBoundingClientRect(); + const boundingRect = canvasRef.current.getBoundingClientRect(); if ( event.clientX < boundingRect.left || event.clientX > boundingRect.right || @@ -211,60 +207,61 @@ class ChartComponent extends React.PureComponent { x: event.clientX - boundingRect.left, y: event.clientY - boundingRect.top, }; - return this._sendToRpc("getElementAtXAxis", { id: this._id, event: newEvent }); - }; + return sendToRpc("getElementAtXAxis", { id, event: newEvent }); + }, [id, sendToRpc]); // Pan/zoom section - resetZoom = async () => { - const scaleBoundsUpdate = await this._sendToRpc("resetZoom", { id: this._id }); - this._onUpdateScaleBounds(scaleBoundsUpdate); - }; + const resetZoom = useCallback(async () => { + const scaleBoundsUpdate = await sendToRpc("resetZoom", { id }); + handleScaleBoundsUpdate(scaleBoundsUpdate); + }, [handleScaleBoundsUpdate, id, sendToRpc]); - _panning = false; - _currentDeltaX = null; - _currentDeltaY = null; - _currentPinchScaling = 1; + callbacksRef.current = useShallowMemo({ + canvasRef, + getElementAtXAxis, + resetZoom, + }); - _setupPanAndPinchHandlers() { - const { threshold } = this.props.panOptions; - const hammerManager = new Hammer.Manager(this.canvas); + const setupPanAndPinchHandlers = useCallback(() => { + const { threshold } = panOptions; + const hammerManager = new Hammer.Manager(canvasRef.current); hammerManager.add(new Hammer.Pinch()); hammerManager.add(new Hammer.Pan({ threshold })); const hammerPanHandler = async (event: any) => { - if (!this.props.panOptions.enabled) { + if (!panOptions.enabled) { return; } - if (this._currentDeltaX != null && this._currentDeltaY != null) { - const deltaX = event.deltaX - this._currentDeltaX; - const deltaY = event.deltaY - this._currentDeltaY; - this._currentDeltaX = event.deltaX; - this._currentDeltaY = event.deltaY; - const scaleBoundsUpdate = await this._sendToRpc("doPan", { - id: this._id, - panOptions: this.props.panOptions, + if (currentDeltaX.current != null && currentDeltaY.current != null) { + const deltaX = event.deltaX - currentDeltaX.current; + const deltaY = event.deltaY - currentDeltaY.current; + currentDeltaX.current = event.deltaX; + currentDeltaY.current = event.deltaY; + const scaleBoundsUpdate = await sendToRpc("doPan", { + id, + panOptions, deltaX, deltaY, }); - this._onPanZoom(scaleBoundsUpdate); - this._onUpdateScaleBounds(scaleBoundsUpdate); + handlePanZoom(scaleBoundsUpdate); + handleScaleBoundsUpdate(scaleBoundsUpdate); } }; hammerManager.on("panstart", (event) => { - this._panning = true; - this._currentDeltaX = 0; - this._currentDeltaY = 0; + panning.current = true; + currentDeltaX.current = 0; + currentDeltaY.current = 0; hammerPanHandler(event); }); hammerManager.on("panmove", hammerPanHandler); hammerManager.on("panend", () => { - this._currentDeltaX = null; - this._currentDeltaY = null; - this._sendToRpc("resetPanDelta", this._id); + currentDeltaX.current = null; + currentDeltaY.current = null; + sendToRpc("resetPanDelta", id); setTimeout(() => { - this._panning = false; + panning.current = false; }, 500); }); @@ -272,10 +269,10 @@ class ChartComponent extends React.PureComponent { // aggressive. Figure out why this is happening and fix it. This is almost identical to the original plugin that // does not have this problem. const handlePinch = async (e) => { - if (!this.props.panOptions.enabled) { + if (!panOptions.enabled) { return; } - const diff = (1 / this._currentPinchScaling) * e.scale; + const diff = (1 / currentPinchScaling.current) * e.scale; const rect = e.target.getBoundingClientRect(); const offsetX = e.center.x - rect.left; const offsetY = e.center.y - rect.top; @@ -300,89 +297,146 @@ class ChartComponent extends React.PureComponent { } // Keep track of overall scale - this._currentPinchScaling = e.scale; + currentPinchScaling.current = e.scale; - const scaleBoundsUpdate = await this._sendToRpc("doZoom", { - id: this._id, - zoomOptions: this.props.zoomOptions, + const scaleBoundsUpdate = await sendToRpc("doZoom", { + id, + zoomOptions, percentZoomX: diff, percentZoomY: diff, focalPoint: center, whichAxesParam: xy, }); - this._onPanZoom(scaleBoundsUpdate); - this._onUpdateScaleBounds(scaleBoundsUpdate); + handlePanZoom(scaleBoundsUpdate); + handleScaleBoundsUpdate(scaleBoundsUpdate); }; hammerManager.on("pinchstart", () => { - this._currentPinchScaling = 1; // reset tracker + currentPinchScaling.current = 1; // reset tracker }); hammerManager.on("pinch", handlePinch); hammerManager.on("pinchend", (e) => { handlePinch(e); - this._currentPinchScaling = 1; // reset - this._sendToRpc("resetZoomDelta", { id: this._id }); + currentPinchScaling.current = 1; // reset + sendToRpc("resetZoomDelta", { id }); }); - } + }, [handlePanZoom, handleScaleBoundsUpdate, id, panOptions, sendToRpc, zoomOptions]); - _onWheel = async (event: SyntheticWheelEvent) => { - if (!this.props.zoomOptions.enabled) { + // Initialization + useEffect(() => { + if (initialized.current) { return; } - const { percentZoomX, percentZoomY, focalPoint } = wheelZoomHandler(event, this.props.zoomOptions); - const scaleBoundsUpdate = await this._sendToRpc("doZoom", { - id: this._id, - zoomOptions: this.props.zoomOptions, - percentZoomX, - percentZoomY, - focalPoint, - whichAxesParam: "xy", - }); - this._onUpdateScaleBounds(scaleBoundsUpdate); - this._onPanZoom(scaleBoundsUpdate); - }; - _onPanZoom = (scaleBoundsUpdate: ScaleBounds[]) => { - if (this.props.onPanZoom) { - this.props.onPanZoom(scaleBoundsUpdate); + const canvas = canvasRef.current; + if (!canvas) { + return; } - }; - _onUpdateScaleBounds = (scaleBoundsUpdate: ScaleBounds[]) => { - if (this.props.onScaleBoundsUpdate && scaleBoundsUpdate) { - this.props.onScaleBoundsUpdate(scaleBoundsUpdate); - } - }; + setupPanAndPinchHandlers(); - _onClick = async (event: SyntheticMouseEvent) => { - const { onClick } = this.props; - if (!this._panning && onClick && this.canvas) { - const rect = event.currentTarget.getBoundingClientRect(); - const x = event.clientX - rect.left; - const y = event.clientY - rect.top; - const newEvent = { x, y }; - // Since our next call is asynchronous, we have to persist the event so that React doesn't clear it. - event.persist(); - const datalabel = await this._sendToRpc("getDatalabelAtEvent", { id: this._id, event: newEvent }); - onClick(event, datalabel); + let node = canvas; + if (!forceDisableWorkerRendering && supportsOffscreenCanvas()) { + // $FlowFixMe + node = canvas.transferControlToOffscreen(); } - }; - - render() { - const { height, width, id } = this.props; - - return ( - - ); - } -} + initialized.current = true; + nodeRef.current = node; + sendToRpc( + "initialize", + { + node, + id, + type, + data, + options, + scaleOptions, + devicePixelRatio, + width, + height, + }, + [node] + ).then(handleScaleBoundsUpdate); + }, [ + data, + forceDisableWorkerRendering, + handleScaleBoundsUpdate, + height, + id, + onScaleBoundsUpdate, + options, + scaleOptions, + sendToRpc, + setupPanAndPinchHandlers, + type, + width, + ]); + + const memoizedData = useDeepMemo(data); + const memoizedOptions = useDeepMemo(options); + const memoizedScaleOptions = useDeepMemo(scaleOptions); + + const { pauseFrame } = useMessagePipeline( + useCallback((messagePipeline) => ({ pauseFrame: messagePipeline.pauseFrame }), []) + ); + + // Keep track of playback callbacks in order to resume them in case this + // component is unmounted during the update step in workers. + const resumeFrameRefs = useRef({}); + + useEffect(() => { + (async () => { + if (!initialized.current) { + return; + } -export default ChartComponent; + const chartUpdateId = uuid.v4(); + resumeFrameRefs.current[chartUpdateId] = pauseFrame("ReactChartjs"); + + const scales = await sendToRpc("update", { + id, + data: memoizedData, + options: memoizedOptions, + scaleOptions: memoizedScaleOptions, + }); + + // Prevent forwarding scales if the component was unmounted during the update call. + if (initialized.current) { + handleScaleBoundsUpdate(scales); + } + + // Resume frame playback + const resumeFrame = resumeFrameRefs.current[chartUpdateId]; + resumeFrame(); + delete resumeFrameRefs.current[chartUpdateId]; + })(); + }, [handleScaleBoundsUpdate, id, memoizedData, memoizedOptions, memoizedScaleOptions, pauseFrame, sendToRpc]); + + useEffect(() => { + const resumeFrameCallbacks = resumeFrameRefs.current; + return () => { + // If this component will unmount, resolve any pending playback callback. + objectValues(resumeFrameCallbacks).forEach((callback) => callback()); + if (chartRpc.current) { + chartRpc.current.send("destroy", { id }); + chartRpc.current = null; + if (usingWebWorker.current) { + webWorkerManager.unregisterWorkerListener(id); + } + } + initialized.current = false; + }; + }, [id]); + + return ( + + ); +} diff --git a/packages/webviz-core/src/components/ReactChartjs/index.stories.js b/packages/webviz-core/src/components/ReactChartjs/index.stories.js index 9eb4d4de3..87017d2dc 100644 --- a/packages/webviz-core/src/components/ReactChartjs/index.stories.js +++ b/packages/webviz-core/src/components/ReactChartjs/index.stories.js @@ -10,7 +10,8 @@ import cloneDeep from "lodash/cloneDeep"; import React, { useState, useCallback } from "react"; import TestUtils from "react-dom/test-utils"; -import ChartComponent from "."; +import ChartComponent, { DEFAULT_PROPS } from "."; +import { MockMessagePipelineProvider } from "webviz-core/src/components/MessagePipeline"; const dataPoint = { x: 0.000057603000000000004, @@ -29,7 +30,6 @@ const props = { label: "/turtle1/pose.x", key: "0", showLine: true, - fill: false, borderWidth: 1, pointRadius: 1.5, pointHoverRadius: 3, @@ -82,6 +82,9 @@ const props = { }, }, type: "scatter", + zoomOptions: DEFAULT_PROPS.zoomOptions, + panOptions: DEFAULT_PROPS.panOptions, + callbacksRef: { current: null }, }; const propsWithDatalabels = cloneDeep(props); @@ -101,7 +104,9 @@ function DatalabelUpdateExample({ forceDisableWorkerRendering }: { forceDisableW } return (
- + + +
); } @@ -124,12 +129,14 @@ function DatalabelClickExample() { ? `Clicked datalabel with selection id: ${String(clickedDatalabel.selectionObj)}` : "Have not clicked datalabel"} - { - setClickedDatalabel(datalabel); - }} - /> + + { + setClickedDatalabel(datalabel); + }} + /> + ); } @@ -142,14 +149,18 @@ storiesOf("", module) }) .add("default", () => (
- + + +
)) .add("can update", () => ) .add("[web worker disabled] can update", () => ) .add("with datalabels", () => (
- + + +
)) .add("allows clicking on datalabels", () => ); diff --git a/packages/webviz-core/src/components/Resizable.js b/packages/webviz-core/src/components/Resizable.js new file mode 100644 index 000000000..ba04d9e94 --- /dev/null +++ b/packages/webviz-core/src/components/Resizable.js @@ -0,0 +1,161 @@ +// @flow +// +// Copyright (c) 2018-present, 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 ResizeIcon from "@mdi/svg/svg/resize-bottom-right.svg"; +import * as React from "react"; +import { Resizable } from "react-resizable"; +import "react-resizable/css/styles.css"; +import styled from "styled-components"; + +import Icon from "webviz-core/src/components/Icon"; + +export type ResizeHandleAxis = "s" | "w" | "e" | "n" | "sw" | "nw" | "se" | "ne"; + +const SHandle = styled.div` + position: absolute; + width: 20px; + height: 20px; + box-sizing: border-box; + padding: 0 3px 3px 0; + + // Copied wholesale from react-resizable. + .react-resizable-handle-sw { + bottom: 0; + left: 0; + cursor: sw-resize; + transform: rotate(90deg); + } + .react-resizable-handle-se { + bottom: 0; + right: 0; + cursor: se-resize; + } + .react-resizable-handle-nw { + top: 0; + left: 0; + cursor: nw-resize; + transform: rotate(180deg); + } + .react-resizable-handle-ne { + top: 0; + right: 0; + cursor: ne-resize; + transform: rotate(270deg); + } + .react-resizable-handle-w, + .react-resizable-handle-e { + top: 50%; + margin-top: -10px; + cursor: ew-resize; + } + .react-resizable-handle-w { + left: 0; + transform: rotate(135deg); + } + .react-resizable-handle-e { + right: 0; + transform: rotate(315deg); + } + .react-resizable-handle-n, + .react-resizable-handle-s { + left: 50%; + margin-left: -10px; + cursor: ns-resize; + } + .react-resizable-handle-n { + top: 0; + transform: rotate(225deg); + } + .react-resizable-handle-s { + bottom: 0; + transform: rotate(45deg); + } +`; + +export type ContainerSize = [number, number]; + +type Props = { + children: ({ + ref: { current: ?HTMLElement }, + width: ?number, + height: ?number, + minWidth: ?number, + minHeight: ?number, + }) => React.Node, + resizeHandles?: ?(ResizeHandleAxis[]), + maxConstraints?: ContainerSize, + minConstraints?: ContainerSize, + initialSize?: ?ContainerSize, + onResize?: (size: ContainerSize) => void, +}; + +// a small component which wraps its children in menu styles +// and provides a helper { Item } component which can be used +// to render typical menu items with text & an icon +export default function ResizableComponent({ + children, + resizeHandles, + maxConstraints, + minConstraints, + onResize, + initialSize, +}: Props) { + const [{ width, height }, setDims] = React.useState( + initialSize ? { width: initialSize[0], height: initialSize[1] } : { width: 0, height: 0 } + ); + const childRef = React.useRef(); + + const handleResize = React.useCallback(( + _: SyntheticEvent<>, + { size }: { size: { width: ?number, height: ?number } } + ) => { + const newWidth = size.width || childRef.current?.clientWidth; + const newHeight = size.height || childRef.current?.clientHeight; + setDims({ + width: newWidth, + height: newHeight, + }); + if (onResize && newWidth != null && newHeight != null) { + onResize([newWidth, newHeight]); + } + }, [onResize]); + + React.useLayoutEffect(() => { + if (childRef?.current) { + setDims({ + width: childRef.current?.clientWidth, + height: childRef.current?.clientHeight, + }); + } + }, []); + + return ( + ( + + + + + + )} + onResize={handleResize} + maxConstraints={maxConstraints} + minConstraints={minConstraints} + resizeHandles={resizeHandles}> + {children({ + ref: childRef, + width, + height, + minWidth: minConstraints && minConstraints[0], + minHeight: minConstraints && minConstraints[1], + })} + + ); +} diff --git a/packages/webviz-core/src/components/Switch.js b/packages/webviz-core/src/components/Switch.js index a5c672de7..66daa8446 100644 --- a/packages/webviz-core/src/components/Switch.js +++ b/packages/webviz-core/src/components/Switch.js @@ -8,7 +8,8 @@ import React from "react"; import styled from "styled-components"; -const SSwitch = styled.label` +const SSwitch = styled.label( + ({ disabled }) => ` position: relative; vertical-align: top; display: inline-flex; @@ -16,6 +17,7 @@ const SSwitch = styled.label` height: 16px; outline: 0; + input { opacity: 0; width: 0; @@ -24,7 +26,7 @@ const SSwitch = styled.label` .circle { position: absolute; - cursor: pointer; + cursor: ${disabled ? "not-allowed" : "pointer"}; top: 0; left: 0; right: 0; @@ -32,6 +34,7 @@ const SSwitch = styled.label` background-color: rgba(255, 255, 255, 0.15); transition: all 80ms ease-in-out; border-radius: 18px; + opacity: ${disabled ? 0.5 : 1}; } .circle:before { @@ -53,17 +56,19 @@ const SSwitch = styled.label` input:checked + .circle:before { transform: translateX(12px); } -`; +` +); type Props = {| + disabled?: boolean, isChecked: boolean, onChange: () => void, |}; -export default function Switch({ isChecked, onChange }: Props) { +export default function Switch({ disabled, isChecked, onChange }: Props) { return ( - - + + ); diff --git a/packages/webviz-core/src/components/TextField.js b/packages/webviz-core/src/components/TextField.js index 011a50dd4..de0cd332b 100644 --- a/packages/webviz-core/src/components/TextField.js +++ b/packages/webviz-core/src/components/TextField.js @@ -18,10 +18,13 @@ export const STextField = styled.div` flex-direction: column; `; -export const STextFieldLabel = styled.label` +export const STextFieldLabel = styled.label( + ({ disabled }) => ` margin: 8px 0; color: ${colors.LIGHT}; -`; + opacity: ${disabled ? 0.5 : 1}; +` +); export const SError = styled.div` color: ${colors.RED}; diff --git a/packages/webviz-core/src/components/TimeBasedChart/index.js b/packages/webviz-core/src/components/TimeBasedChart/index.js index 85acf3c18..16dcfb590 100644 --- a/packages/webviz-core/src/components/TimeBasedChart/index.js +++ b/packages/webviz-core/src/components/TimeBasedChart/index.js @@ -23,11 +23,16 @@ import { useForceRerenderOnVisibilityChange, } from "./utils"; import Button from "webviz-core/src/components/Button"; +import GLChart from "webviz-core/src/components/GLChart"; import HoverBar, { getChartTopAndHeight, SBar } from "webviz-core/src/components/HoverBar"; import { useClearHoverValue, useSetHoverValue } from "webviz-core/src/components/HoverBar/context"; import KeyListener from "webviz-core/src/components/KeyListener"; -import { useMessagePipeline } from "webviz-core/src/components/MessagePipeline"; -import ChartComponent, { type HoveredElement, type ScaleOptions } from "webviz-core/src/components/ReactChartjs"; +import ReactChartjs, { + type HoveredElement, + type ChartCallbacks, + type ScaleOptions, + DEFAULT_PROPS, +} from "webviz-core/src/components/ReactChartjs"; import { getChartValue, inBounds, @@ -123,6 +128,7 @@ type Props = {| scaleOptions?: ?ScaleOptions, currentTime?: ?number, defaultView?: ChartDefaultView, + renderPath?: "webgl" | "chartjs", |}; // Create a chart with any y-axis but with an x-axis that shows time since the @@ -131,7 +137,7 @@ type Props = {| // standard tooltips. export default memo(function TimeBasedChart(props: Props) { const { data, defaultView, currentTime, linesToHide, isSynced } = props; - const chartComponent = useRef(null); + const chartCallbacks = React.useRef(null); const tooltip = useRef(null); const hasUnmounted = useRef(false); @@ -142,16 +148,6 @@ export default memo(function TimeBasedChart(props: Props) { useForceRerenderOnVisibilityChange(); - const { pauseFrame } = useMessagePipeline( - useCallback((messagePipeline) => ({ pauseFrame: messagePipeline.pauseFrame }), []) - ); - const onChartUpdate = useCallback(() => { - const resumeFrame = pauseFrame("TimeBasedChart"); - return () => { - resumeFrame(); - }; - }, [pauseFrame]); - const { saveCurrentView, yAxes } = props; const forceUpdate = useForceUpdate(); const scaleBounds = useRef>(); @@ -221,8 +217,8 @@ export default memo(function TimeBasedChart(props: Props) { }, []); const onResetZoom = useCallback(() => { - if (chartComponent.current) { - chartComponent.current.resetZoom(); + if (chartCallbacks.current) { + chartCallbacks.current.resetZoom(); setHasUserPannedOrZoomed(false); } setFollowPlaybackState(null); @@ -282,11 +278,7 @@ export default memo(function TimeBasedChart(props: Props) { const tooltips = props.tooltips || []; // We use a custom tooltip so we can style it more nicely, and so that it can break // out of the bounds of the canvas, in case the panel is small. - const updateTooltip = useCallback(( - currentChartComponent: ChartComponent, - canvas: HTMLCanvasElement, - tooltipItem: ?HoveredElement - ) => { + const updateTooltip = useCallback((canvas: HTMLCanvasElement, tooltipItem: ?HoveredElement) => { // This is an async callback, so it can fire after this component is unmounted. Make sure that we remove the // tooltip if this fires after unmount. if (!tooltipItem || hasUnmounted.current) { @@ -339,13 +331,12 @@ export default memo(function TimeBasedChart(props: Props) { }, [hoverComponentId, setHoverValue, xAxisIsPlaybackTime]); const onMouseMove = useCallback(async (event: MouseEvent) => { - const currentChartComponent = chartComponent.current; - if (!currentChartComponent || !currentChartComponent.canvas) { + const canvas = chartCallbacks.current && chartCallbacks.current.canvasRef.current; + if (!canvas) { removeTooltip(); clearGlobalHoverTime(); return; } - const { canvas } = currentChartComponent; const canvasRect = canvas.getBoundingClientRect(); const xBounds = scaleBounds.current && scaleBounds.current.find(({ axes }) => axes === "xAxes"); const yBounds = scaleBounds.current && scaleBounds.current.find(({ axes }) => axes === "yAxes"); @@ -365,9 +356,9 @@ export default memo(function TimeBasedChart(props: Props) { clearGlobalHoverTime(); } - if (tooltips && tooltips.length) { - const tooltipElement = await currentChartComponent.getElementAtXAxis(event); - updateTooltip(currentChartComponent, canvas, tooltipElement); + if (tooltips && tooltips.length && chartCallbacks.current) { + const tooltipElement = await chartCallbacks.current.getElementAtXAxis(event); + updateTooltip(canvas, tooltipElement); } else { removeTooltip(); } @@ -533,7 +524,7 @@ export default memo(function TimeBasedChart(props: Props) { const zoomOptions = useMemo( () => ({ - ...ChartComponent.defaultProps.zoomOptions, + ...DEFAULT_PROPS.zoomOptions, enabled: props.zoom, mode: zoomMode, }), @@ -543,6 +534,8 @@ export default memo(function TimeBasedChart(props: Props) { const currentTimePx: ?number = currentTime != null ? getChartPx(xBounds, currentTime) : undefined; const chartTopAndHeight = getChartTopAndHeight(scaleBounds.current); + const ChartComponent = props.renderPath === "webgl" ? GLChart : ReactChartjs; + return (
@@ -564,15 +557,15 @@ export default memo(function TimeBasedChart(props: Props) { width={width} height={height} key={`${width}x${height}`} - ref={chartComponent} data={filteredData} onScaleBoundsUpdate={onScaleBoundsUpdate} onPanZoom={onPanZoom} onClick={onClickAddingValues} zoomOptions={zoomOptions} scaleOptions={scaleOptions} - onChartUpdate={onChartUpdate} options={chartJsOptions} + panOptions={DEFAULT_PROPS.panOptions} + callbacksRef={chartCallbacks} /> {hasUserPannedOrZoomed && ( diff --git a/packages/webviz-core/src/components/TopicToRenderMenu.js b/packages/webviz-core/src/components/TopicToRenderMenu.js index b4b32fac4..2bbaa58c0 100644 --- a/packages/webviz-core/src/components/TopicToRenderMenu.js +++ b/packages/webviz-core/src/components/TopicToRenderMenu.js @@ -26,12 +26,12 @@ type Props = { onChange: (topic: string) => void, topicToRender: string, topics: $ReadOnlyArray, - // Use either one of these: + // Use either one of these (or neither, to expose all topics): // singleTopicDatatype only supports a single datatype (search and select based on datatype) // topicsGroups selects the "parent" path of a group of topics (if either of the group topics suffixes+datatypes match) singleTopicDatatype?: string, topicsGroups?: TopicGroup[], - defaultTopicToRender: string, + defaultTopicToRender: ?string, }; const SDiv = styled.div` @@ -67,9 +67,6 @@ export default function TopicToRenderMenu({ if (topicsGroups && singleTopicDatatype) { throw new Error("Cannot set both topicsGroups and singleTopicDatatype"); } - if (!topicsGroups && !singleTopicDatatype) { - throw new Error("Must set either topicsGroups or singleTopicDatatype"); - } const availableTopics: string[] = []; for (const topic of topics) { if (topicsGroups) { @@ -79,14 +76,15 @@ export default function TopicToRenderMenu({ availableTopics.push(parentTopic); } } - } else { - if (topic.datatype === singleTopicDatatype) { - availableTopics.push(topic.name); - } + } else if (singleTopicDatatype == null || topic.datatype === singleTopicDatatype) { + availableTopics.push(topic.name); } } // Keeps only the first occurrence of each topic. - const renderTopics: string[] = uniq([defaultTopicToRender, ...availableTopics, topicToRender]); + // $FlowFixMe: Flow only understands .filter(Boolean), but we want to keep empty strings. + const renderTopics: string[] = uniq( + [defaultTopicToRender, ...availableTopics, topicToRender].filter((t) => t != null) + ); const parentTopicSpan = (topic: string, available: boolean) => { const topicDiv = topic ? topic : Default; return ( @@ -97,16 +95,17 @@ export default function TopicToRenderMenu({ ); }; + const tooltip = topicsGroups + ? `Parent topics selected by topic suffixes:\n ${topicsGroups.map((group) => group.suffix).join("\n")}` + : singleTopicDatatype != null + ? `Topics selected by datatype: ${singleTopicDatatype}` + : "Select topic:"; return ( group.suffix).join("\n")}` - : `Topics selected by datatype: ${singleTopicDatatype || ""}` // add || "" is to fix flow here - } + tooltip={tooltip} tooltipProps={{ placement: "top" }} style={{ color: topicToRender === defaultTopicToRender ? colors.LIGHT1 : colors.ORANGE }} dataTest={"topic-set"}> diff --git a/packages/webviz-core/src/components/TopicToRenderMenu.stories.js b/packages/webviz-core/src/components/TopicToRenderMenu.stories.js index 9b6586e41..dad554692 100644 --- a/packages/webviz-core/src/components/TopicToRenderMenu.stories.js +++ b/packages/webviz-core/src/components/TopicToRenderMenu.stories.js @@ -179,4 +179,24 @@ storiesOf("", module) /> ); + }) + .add("no defaultTopicToRender", () => { + return ( + { + const topicSet = el.querySelector("[data-test=topic-set]"); + if (topicSet) { + topicSet.click(); + } + }}> + {}} + topicToRender="" + topics={[]} + singleTopicDatatype={"abc_msgs/foo"} + defaultTopicToRender={null} + /> + + ); }); diff --git a/packages/webviz-core/src/dataProviders/CombinedDataProvider.js b/packages/webviz-core/src/dataProviders/CombinedDataProvider.js index a91e6a843..a00b113f8 100644 --- a/packages/webviz-core/src/dataProviders/CombinedDataProvider.js +++ b/packages/webviz-core/src/dataProviders/CombinedDataProvider.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 { assign, flatten, isEqual } from "lodash"; +import { assign, flatten, isEqual, uniqBy } from "lodash"; import memoizeWeak from "memoize-weak"; import allSettled from "promise.allsettled"; import { TimeUtil, type Time, type RosMsgField } from "rosbag"; @@ -99,14 +99,6 @@ const mergeAllMessageTypes = (result1: GetMessagesResult, result2: GetMessagesRe rosBinaryMessages: merge(result1.rosBinaryMessages, result2.rosBinaryMessages), }); -const throwOnDuplicateTopics = (topics: string[]) => { - [...topics].sort().forEach((topicName, i, sortedTopics) => { - if (sortedTopics[i + 1] && topicName === sortedTopics[i + 1]) { - throw new Error(`Duplicate topic found: ${topicName}`); - } - }); -}; - const throwOnUnequalDatatypes = (datatypes: [string, RosMsgField[]][]) => { datatypes .sort((a, b) => (a[0] && b[0] ? +(a[0][0] > b[0][0]) || -1 : 0)) @@ -131,16 +123,6 @@ function mergeMessageDefinitions(messageDefinitionArr: MessageDefinitions[], top ); // $FlowFixMe - flow does not work with Object.entries :( throwOnUnequalDatatypes(flatten(parsedMessageDefinitionArr.map(({ datatypes }) => Object.entries(datatypes)))); - throwOnDuplicateTopics( - flatten(parsedMessageDefinitionArr.map(({ messageDefinitionsByTopic }) => Object.keys(messageDefinitionsByTopic))) - ); - throwOnDuplicateTopics( - flatten( - parsedMessageDefinitionArr.map(({ parsedMessageDefinitionsByTopic }) => - Object.keys(parsedMessageDefinitionsByTopic) - ) - ) - ); return { type: "parsed", @@ -243,8 +225,7 @@ export default class CombinedDataProvider implements DataProvider { const end = sortTimes(results.map((result) => result.end)).pop(); // Error handling - const mergedTopics = flatten(results.map(({ topics }) => topics)); - throwOnDuplicateTopics(mergedTopics.map(({ name }) => name)); + const mergedTopics = uniqBy(flatten(results.map(({ topics }) => topics)), ({ name }) => name); throwOnMixedParsedMessages(results.map(({ providesParsedMessages }) => providesParsedMessages)); const combinedMessageDefinitions = mergeMessageDefinitions( results.map(({ messageDefinitions }) => messageDefinitions), diff --git a/packages/webviz-core/src/dataProviders/CombinedDataProvider.test.js b/packages/webviz-core/src/dataProviders/CombinedDataProvider.test.js index 5f97a4031..1a72d436e 100644 --- a/packages/webviz-core/src/dataProviders/CombinedDataProvider.test.js +++ b/packages/webviz-core/src/dataProviders/CombinedDataProvider.test.js @@ -129,12 +129,12 @@ function getCombinedDataProvider(data: any[]) { describe("CombinedDataProvider", () => { describe("error handling", () => { - it("throws if two providers have the same topics without a prefix", async () => { + it("does not throw if two providers have the same topics without a prefix", async () => { const combinedProvider = getCombinedDataProvider([{ provider: provider1() }, { provider: provider1Duplicate() }]); - await expect(combinedProvider.initialize(mockExtensionPoint().extensionPoint)).rejects.toThrow(); + await combinedProvider.initialize(mockExtensionPoint().extensionPoint); }); - it("should not allow duplicate topics", async () => { + it("allows duplicate topics", async () => { const p1 = new MemoryDataProvider({ messages: { parsedMessages: [{ topic: "/some_topic", receiveTime: { sec: 101, nsec: 0 }, message: { value: 1 } }], @@ -147,7 +147,9 @@ describe("CombinedDataProvider", () => { }); const p2 = new MemoryDataProvider({ messages: { - parsedMessages: [{ topic: "/some_topic", receiveTime: { sec: 101, nsec: 0 }, message: { value: 1 } }], + parsedMessages: [ + { topic: "/generic_topic/some_topic", receiveTime: { sec: 101, nsec: 0 }, message: { value: 1 } }, + ], bobjects: undefined, rosBinaryMessages: undefined, }, @@ -156,7 +158,17 @@ describe("CombinedDataProvider", () => { providesParsedMessages: true, }); const combinedProvider = getCombinedDataProvider([{ provider: p1, prefix: "/generic_topic" }, { provider: p2 }]); - await expect(combinedProvider.initialize(mockExtensionPoint().extensionPoint)).rejects.toThrow(); + await combinedProvider.initialize(mockExtensionPoint().extensionPoint); + // Merges messages on the same topic from child providers. + const data = await combinedProvider.getMessages( + { sec: 100, nsec: 0 }, + { sec: 102, nsec: 0 }, + { parsedMessages: ["/generic_topic/some_topic"] } + ); + expect(data.parsedMessages).toEqual([ + { topic: "/generic_topic/some_topic", receiveTime: { sec: 101, nsec: 0 }, message: { value: 1 } }, + { topic: "/generic_topic/some_topic", receiveTime: { sec: 101, nsec: 0 }, message: { value: 1 } }, + ]); }); it("should not allow conflicting datatypes", async () => { @@ -203,7 +215,7 @@ describe("CombinedDataProvider", () => { await expect(combinedProvider.initialize(mockExtensionPoint().extensionPoint)).rejects.toThrow(); }); - it("should not allow overlapping topics in messageDefinitionsByTopic", async () => { + it("allows overlapping topics in messageDefinitionsByTopic", async () => { const datatypes = { some_datatype: { fields: [{ name: "value", type: "int32" }] } }; const p1 = new MemoryDataProvider({ messages: { @@ -229,9 +241,7 @@ describe("CombinedDataProvider", () => { providesParsedMessages: true, }); const combinedProvider = getCombinedDataProvider([{ provider: p1 }, { provider: p2 }]); - await expect(combinedProvider.initialize(mockExtensionPoint().extensionPoint)).rejects.toThrow( - "Duplicate topic found" - ); + await combinedProvider.initialize(mockExtensionPoint().extensionPoint); }); it("should not mixed parsed and unparsed messaages", async () => { diff --git a/packages/webviz-core/src/hooksImporter.js b/packages/webviz-core/src/hooksImporter.js index dce45e635..ac8d53ed5 100644 --- a/packages/webviz-core/src/hooksImporter.js +++ b/packages/webviz-core/src/hooksImporter.js @@ -16,6 +16,7 @@ lazily importing this file at runtime. */ export function panelsByCategory() { + const Audio = require("webviz-core/src/panels/Audio").default; const DiagnosticStatusPanel = require("webviz-core/src/panels/diagnostics/DiagnosticStatusPanel").default; const DiagnosticSummary = require("webviz-core/src/panels/diagnostics/DiagnosticSummary").default; const GlobalVariables = require("webviz-core/src/panels/GlobalVariables").default; @@ -43,6 +44,7 @@ export function panelsByCategory() { const ros = [ { title: "2D Plot", component: TwoDimensionalPlot }, { title: "3D", component: ThreeDimensionalViz }, + { title: "Audio", component: Audio }, { title: `Diagnostics ${ndash} Summary`, component: DiagnosticSummary }, { title: `Diagnostics ${ndash} Detail`, component: DiagnosticStatusPanel }, { title: "Image", component: ImageViewPanel }, @@ -84,18 +86,18 @@ export function perPanelHooks() { const RadarIcon = require("@mdi/svg/svg/radar.svg").default; const RobotIcon = require("@mdi/svg/svg/robot.svg").default; const { - GEOMETRY_MSGS_POLYGON_STAMPED_DATATYPE, - NAV_MSGS_OCCUPANCY_GRID_DATATYPE, - NAV_MSGS_PATH_DATATYPE, - POINT_CLOUD_DATATYPE, - POSE_STAMPED_DATATYPE, - SENSOR_MSGS_LASER_SCAN_DATATYPE, - TF_DATATYPE, - VISUALIZATION_MSGS_MARKER_DATATYPE, - VISUALIZATION_MSGS_MARKER_ARRAY_DATATYPE, - WEBVIZ_MARKER_DATATYPE, - WEBVIZ_MARKER_ARRAY_DATATYPE, - WEBVIZ_3D_ICON_ARRAY_DATATYPE, + GEOMETRY_MSGS$POLYGON_STAMPED, + NAV_MSGS$OCCUPANCY_GRID, + NAV_MSGS$PATH, + SENSOR_MSGS$POINT_CLOUD_2, + GEOMETRY_MSGS$POSE_STAMPED, + SENSOR_MSGS$LASER_SCAN, + TF2_MSGS$TF_MESSAGE, + VISUALIZATION_MSGS$MARKER, + VISUALIZATION_MSGS$MARKER_ARRAY, + VISUALIZATION_MSGS$WEBVIZ_MARKER, + VISUALIZATION_MSGS$WEBVIZ_MARKER_ARRAY, + WEBVIZ_ICON_MSGS$WEBVIZ_3D_ICON_ARRAY, DIAGNOSTIC_TOPIC, } = require("webviz-core/src/util/globalConstants"); @@ -105,21 +107,22 @@ export function perPanelHooks() { const SUPPORTED_MARKER_DATATYPES = { // generally supported datatypes - VISUALIZATION_MSGS_MARKER_DATATYPE, - VISUALIZATION_MSGS_MARKER_ARRAY_DATATYPE, - WEBVIZ_MARKER_DATATYPE, - WEBVIZ_MARKER_ARRAY_DATATYPE, - WEBVIZ_3D_ICON_ARRAY_DATATYPE, - POSE_STAMPED_DATATYPE, - POINT_CLOUD_DATATYPE, - SENSOR_MSGS_LASER_SCAN_DATATYPE, - NAV_MSGS_PATH_DATATYPE, - NAV_MSGS_OCCUPANCY_GRID_DATATYPE, - GEOMETRY_MSGS_POLYGON_STAMPED_DATATYPE, - TF_DATATYPE, + VISUALIZATION_MSGS$MARKER, + VISUALIZATION_MSGS$MARKER_ARRAY, + VISUALIZATION_MSGS$WEBVIZ_MARKER, + VISUALIZATION_MSGS$WEBVIZ_MARKER_ARRAY, + WEBVIZ_ICON_MSGS$WEBVIZ_3D_ICON_ARRAY, + GEOMETRY_MSGS$POSE_STAMPED, + SENSOR_MSGS$POINT_CLOUD_2, + SENSOR_MSGS$LASER_SCAN, + NAV_MSGS$PATH, + NAV_MSGS$OCCUPANCY_GRID, + GEOMETRY_MSGS$POLYGON_STAMPED, + TF2_MSGS$TF_MESSAGE, }; return { + Audio: { defaultTopic: null }, DiagnosticSummary: { defaultConfig: { pinnedIds: [], @@ -163,16 +166,16 @@ export function perPanelHooks() { BLACKLIST_TOPICS: [], topics: [], iconsByDatatype: { - [VISUALIZATION_MSGS_MARKER_DATATYPE]: HexagonIcon, - [VISUALIZATION_MSGS_MARKER_ARRAY_DATATYPE]: HexagonMultipleIcon, - [NAV_MSGS_OCCUPANCY_GRID_DATATYPE]: GridIcon, - [NAV_MSGS_PATH_DATATYPE]: ChartIcon, - [SENSOR_MSGS_LASER_SCAN_DATATYPE]: RadarIcon, - [GEOMETRY_MSGS_POLYGON_STAMPED_DATATYPE]: PentagonOutlineIcon, - [POINT_CLOUD_DATATYPE]: BlurIcon, - [POSE_STAMPED_DATATYPE]: RobotIcon, - [WEBVIZ_MARKER_DATATYPE]: HexagonIcon, - [WEBVIZ_MARKER_ARRAY_DATATYPE]: HexagonMultipleIcon, + [VISUALIZATION_MSGS$MARKER]: HexagonIcon, + [VISUALIZATION_MSGS$MARKER_ARRAY]: HexagonMultipleIcon, + [NAV_MSGS$OCCUPANCY_GRID]: GridIcon, + [NAV_MSGS$PATH]: ChartIcon, + [SENSOR_MSGS$LASER_SCAN]: RadarIcon, + [GEOMETRY_MSGS$POLYGON_STAMPED]: PentagonOutlineIcon, + [SENSOR_MSGS$POINT_CLOUD_2]: BlurIcon, + [GEOMETRY_MSGS$POSE_STAMPED]: RobotIcon, + [VISUALIZATION_MSGS$WEBVIZ_MARKER]: HexagonIcon, + [VISUALIZATION_MSGS$WEBVIZ_MARKER_ARRAY]: HexagonMultipleIcon, }, // TODO(Audrey): remove icons config after topic group release icons: {}, diff --git a/packages/webviz-core/src/loadWebviz.js b/packages/webviz-core/src/loadWebviz.js index 76edf1837..dd1fd8a88 100644 --- a/packages/webviz-core/src/loadWebviz.js +++ b/packages/webviz-core/src/loadWebviz.js @@ -180,6 +180,18 @@ const defaultHooks = { developmentDefault: true, productionDefault: true, }, + useGLChartIn2dPlot: { + name: "Enable WebGL-based charts for the 2D plot panel", + description: "Replaces the Chartjs-based charts with a new implementation using WebGL instead.", + developmentDefault: false, + productionDefault: false, + }, + useGLChartInPlotPanel: { + name: "Enable WebGL-based charts for the Plot panel", + description: "Replaces the Chartjs-based charts with a new implementation using WebGL instead.", + developmentDefault: false, + productionDefault: false, + }, }; }, linkMessagePathSyntaxToHelpPage: () => true, diff --git a/packages/webviz-core/src/panels/Audio/AudioPlayer.js b/packages/webviz-core/src/panels/Audio/AudioPlayer.js new file mode 100644 index 000000000..38ac454cc --- /dev/null +++ b/packages/webviz-core/src/panels/Audio/AudioPlayer.js @@ -0,0 +1,546 @@ +// @flow +// +// Copyright (c) 2021-present, 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 { useCleanup, useEventListener } from "@cruise-automation/hooks"; +import ClipboardOutlineIcon from "@mdi/svg/svg/clipboard-outline.svg"; +import clamp from "lodash/clamp"; +import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; +import { type Time } from "rosbag"; +import styled from "styled-components"; +import uuid from "uuid"; + +import AudioToolbar from "./AudioToolbar"; +import { type ProcessedAudioData } from "./index"; +import { generateAudioBuffersFromSample, calculateWaveformData, drawWaveform, bufferToFileBlob } from "./utils"; +import Dimensions from "webviz-core/src/components/Dimensions"; +import Dropdown from "webviz-core/src/components/Dropdown"; +import HoverBar, { SBar } from "webviz-core/src/components/HoverBar"; +import { useSetHoverValue, useClearHoverValue } from "webviz-core/src/components/HoverBar/context"; +import Icon from "webviz-core/src/components/Icon"; +import { useMessagePipeline } from "webviz-core/src/components/MessagePipeline"; +import { downloadFiles } from "webviz-core/src/util"; +import clipboard from "webviz-core/src/util/clipboard"; +import { hexToRgbString } from "webviz-core/src/util/colorUtils"; +import { formatTimeRaw } from "webviz-core/src/util/formatTime"; +import { useChangeDetector } from "webviz-core/src/util/hooks"; +import { colors } from "webviz-core/src/util/sharedStyleConstants"; +import { subtractTimes, toSec } from "webviz-core/src/util/time"; + +const POINT_WIDTH = 3; +const CHANNEL_HEIGHT = 80; +const BAR_HEIGHT = 36; +const CANVAS_MARGIN = 16; + +const TIMESTAMP_OPTIONS = [ + { + label: "header.stamp", + value: false, + }, + { + label: "receive time", + value: true, + }, +]; +const CHANNEL_HOVER_BG = hexToRgbString(colors.RED2, 0.1); + +const DEFAULT_WAVE_CONFIG = { + color: hexToRgbString(colors.LIGHT1, 0.2), + playedColor: hexToRgbString(colors.LIGHT1, 0.5), + missingDataColor: colors.DARK2, + activeChannelColor: colors.RED2, + activeChannelPlayedColor: colors.RED, + markerColor: colors.BLUE, + pointWidth: POINT_WIDTH, + heightScaleFactor: 4, + multiChannelHeightScaleFactor: 12, // scale more so the stacked waveforms don't look flat +}; + +const SPEED_SELECT_OPTIONS = [ + { value: 0.1, label: "0.1x" }, + { value: 0.2, label: "0.2x" }, + { value: 0.5, label: "0.5x" }, + { value: 1, label: "1x" }, + // { value: -1, label: "Sync (bagPlaybackSpeed)x" }, // TODO[Audrey]: add sync support +]; + +const SAudioPlayer = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; + width: 100%; + position: relative; +`; + +const SCanvasWrapper = styled.div` + position: relative; + width: 100%; + overflow-y: scroll; + overflow-x: hidden; + margin-left: ${CANVAS_MARGIN}px; + margin-right: ${CANVAS_MARGIN}px; +`; +const SBarWrapper = styled.div` + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: ${BAR_HEIGHT}; +`; + +const STimestampWrapper = styled.div` + position: absolute; + right: 0; + bottom: 32px; + display: flex; + cursor: pointer; + width: 284px; +`; + +const SDropdown = styled.div` + width: 132px; + button { + background: transparent; + } +`; +const SCopyTimestamp = styled.div` + display: flex; + align-items: center; + .icon { + visibility: hidden; + } + :hover { + .icon { + visibility: visible; + } + } +`; + +const SChannelHover = styled.div` + width: 100%; + position: absolute; + cursor: pointer; + :hover { + background: ${CHANNEL_HOVER_BG}; + } +`; + +export type Config = {| + volume: number, + playbackRate: number, + // user can select any supported audio channels to view and play + selectedChannel: number, + showAllChannels: boolean, + topicToRender: ?string, +|}; + +type Props = { + ...ProcessedAudioData, + width: number, + height: number, + config: Config, + saveConfig: ($Shape) => void, +}; + +function AudioPlayerBase({ + channelCount, + height, + messageTimestamps, + samples, + samplingRate, + saveConfig, + width, + config: { volume = 1, playbackRate = 1, selectedChannel: selectedChannelAlt = 0, showAllChannels = false }, +}: Props) { + const hoverBar = useRef(); + const [hoverComponentId] = useState(uuid.v4()); + const { + globalSeek, + globalStartTime, + globalEndTime, + globalCurrentTime, + globalLastSeekTime, + globalIsPlaying, + } = useMessagePipeline( + useCallback( + ({ seekPlayback, playerState: { activeData } }) => ({ + globalSeek: seekPlayback, + globalStartTime: activeData && activeData.startTime, + globalEndTime: activeData && activeData.endTime, + globalCurrentTime: activeData && activeData.currentTime, + globalIsPlaying: activeData && activeData.isPlaying, + globalLastSeekTime: activeData && activeData.lastSeekTime, + }), + [] + ) + ); + + const [audioContext] = useState(() => new (window.AudioContext || window.webkitAudioContext)()); + // automatically close audioContext when the component unmounts + useCleanup(() => audioContext.close()); + + const [gainNode] = useState(() => audioContext.createGain()); // for volume control + // use useRef for states that are getting updated during canvas drawing cycle + const sourceRef = useRef(); + const currentPositionRef = useRef(0); // current played time relative to the whole audio buffer duration + const startTimeRef = useRef(0); // audioContext.currentTime at which the audio started to play + const canvasRef = useRef(); + const timestampDivRef = useRef(); + const [showReceiveTimestamp, setShowReceiveTimestamp] = useState(false); + + // Using isPlayingState and isPlayingRef to track isPlaying state separately for react and + // requestAnimationFrame updates. We could essentially use one state 'isPlayState' + 'useEffect', + // but source.onended is called on every pause which made it hard to track the currentTime of the player + const isPlayingRef = useRef(false); // track the playing state of the audio, for updating canvas + const [isPlayingState, setIsPlayingState] = useState(false); // for triggering AudioToolBar change + + const channelOptions = useMemo( + () => new Array(channelCount).fill().map((_, idx) => ({ value: idx, label: `${idx}` })), + [channelCount] + ); + + // make sure selectedChannel is in the range + const selectedChannel = clamp(selectedChannelAlt, 0, channelCount - 1); + + // only update audio buffer when inputs change + const buffers = useMemo( + () => generateAudioBuffersFromSample(samples, selectedChannel, showAllChannels, audioContext, samplingRate), + [samples, selectedChannel, showAllChannels, audioContext, samplingRate] + ); + // the audio buffer that's being played/selected + const activeBuffer = showAllChannels ? buffers[selectedChannel] : buffers[0]; + const totalSecs = activeBuffer.duration; + + const callbackInputsRef = useRef({ currentTime: audioContext.currentTime }); + callbackInputsRef.current = { currentTime: audioContext.currentTime }; + + const getCurrentTimestamp = useCallback((playedRatio: number): ?Time => { + const timestampIndex = Math.floor(messageTimestamps.length * playedRatio); + const currentMsgTimestamp = messageTimestamps[timestampIndex]; + if (!currentMsgTimestamp) { + return; + } + return showReceiveTimestamp ? currentMsgTimestamp.receiveTime : currentMsgTimestamp.headerStamp; + }, [messageTimestamps, showReceiveTimestamp]); + + const updateTimestampText = useCallback((ratio: number) => { + // generate current timestamp for copying + const currentTimeStamp = getCurrentTimestamp(ratio); + const timestampDiv = timestampDivRef.current; + if (timestampDiv && currentTimeStamp) { + timestampDiv.innerHTML = formatTimeRaw(currentTimeStamp); + } + }, [getCurrentTimestamp]); + + const updateCanvas = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + const playedRatio = currentPositionRef.current / totalSecs; + updateTimestampText(playedRatio); + const bounds = buffers.map((buffer) => calculateWaveformData(buffer, canvas.width, POINT_WIDTH)); + drawWaveform(bounds, canvas, DEFAULT_WAVE_CONFIG, playedRatio, selectedChannel); + }, [totalSecs, updateTimestampText, buffers, selectedChannel]); + + const timestampOnChange = useCallback(() => { + setShowReceiveTimestamp((showReceive) => !showReceive); + updateCanvas(); + }, [updateCanvas]); + + function pause() { + if (sourceRef.current) { + sourceRef.current.stop(0); + sourceRef.current = null; + } + + setIsPlayingState(false); + isPlayingRef.current = false; + currentPositionRef.current = audioContext.currentTime - startTimeRef.current; + } + + function stop() { + pause(); + currentPositionRef.current = 0; + } + + // to be called to update canvas continuously during the audio play + function tick() { + const canvas = canvasRef.current; + if (!isPlayingRef.current || !canvas) { + return; + } + const newPosition = (audioContext.currentTime - startTimeRef.current) * playbackRate; + currentPositionRef.current = newPosition; + if (newPosition > totalSecs) { + // stop playing when reached the end or over + stop(); + updateCanvas(); + return; + } + updateCanvas(); + requestAnimationFrame(tick); + } + + function play() { + if (isPlayingRef.current) { + pause(); + return; + } + // only set the new state once + if (!isPlayingState) { + setIsPlayingState(true); + } + const source = audioContext.createBufferSource(); + source.onended = () => setIsPlayingState(false); + sourceRef.current = source; + source.buffer = activeBuffer; + source.playbackRate.value = playbackRate; + source.connect(gainNode); + gainNode.connect(audioContext.destination); + source.start(audioContext.currentTime, currentPositionRef.current); + isPlayingRef.current = true; + startTimeRef.current = audioContext.currentTime - currentPositionRef.current; + requestAnimationFrame(tick); + } + + function onDownloadAudio() { + if (showAllChannels) { + downloadFiles( + buffers.map((buffer, idx) => { + return { + blob: bufferToFileBlob(buffer), + fileName: `audio_channel_${idx}`, + }; + }) + ); + } else { + downloadFiles([{ blob: bufferToFileBlob(activeBuffer), fileName: "audio" }]); + } + } + + // redraw the waveform when the audio buffer, width or height changes + useEffect( + () => { + updateCanvas(); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [activeBuffer, width, height] + ); + // reset the gainNode value when the volume changes + useEffect(() => { + gainNode.gain.setValueAtTime(volume, callbackInputsRef.current.currentTime); + }, [gainNode.gain, volume]); + + const [draggingStart, setDraggingStart] = useState(null); + const canvasHeight = buffers.length > 1 ? CHANNEL_HEIGHT * buffers.length : height - BAR_HEIGHT; + const canvasWidth = width - CANVAS_MARGIN * 2; + + useEventListener( + window, + "mousemove", + !!draggingStart, + (ev: MouseEvent) => { + const canvas = canvasRef.current; + if (!draggingStart || !canvas) { + return; + } + const delta = ev.clientX - draggingStart.clientX; + const deltaPosition = (delta / canvasWidth) * totalSecs; + const newPosition = clamp(draggingStart.value + deltaPosition, 0, totalSecs); + currentPositionRef.current = newPosition; + updateCanvas(); + }, + [canvasWidth, totalSecs] + ); + + useEventListener( + window, + "mouseup", + !!draggingStart, + (_event: MouseEvent) => { + setDraggingStart(null); + }, + [] + ); + + const setHoverValue = useSetHoverValue(); + const onMouseMove = useCallback((ev) => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + ev.preventDefault(); + // move the currentPosition to the clicked position and update canvas + const rect = canvas.getBoundingClientRect(); + const left = ev.clientX - rect.left; + // Keep the ratio within 1 since it's possible to go over the audio wave as we usually round up global playback time. + const hoveredRatio = clamp(left / canvasWidth, 0, 1); + // Only draw the timestamp on hover when not playing. + if (!isPlayingRef.current) { + updateTimestampText(hoveredRatio); + } + const timeInSecFromStart = totalSecs * hoveredRatio; + setHoverValue({ componentId: hoverComponentId, type: "PLAYBACK_SECONDS", value: timeInSecFromStart }); + }, [canvasWidth, setHoverValue, hoverComponentId, totalSecs, updateTimestampText]); + + const clearHoverValue = useClearHoverValue(); + const onMouseLeave = useCallback((ev) => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + ev.preventDefault(); + clearHoverValue(hoverComponentId); + // Reset the timestamp text. + const playedRatio = currentPositionRef.current / totalSecs; + updateTimestampText(playedRatio); + }, [clearHoverValue, hoverComponentId, totalSecs, updateTimestampText]); + const scaleBounds = useMemo( + () => ({ + // HoverBar takes a ref to avoid rerendering (and avoid needing to rerender) when the bounds + // change in charts that scroll at playback speed. + current: [ + { + id: hoverComponentId, + min: 0, + max: totalSecs, + axes: "xAxes", + minAlongAxis: 0, + maxAlongAxis: canvasWidth, + }, + { + id: hoverComponentId, + min: 0, + max: canvasHeight, + axes: "yAxes", + minAlongAxis: 0, + maxAlongAxis: canvasHeight, + }, + ], + }), + [canvasHeight, canvasWidth, hoverComponentId, totalSecs] + ); + + const globalSeekChanged = useChangeDetector([globalLastSeekTime], false); + // Update the audio played position to the new global played position if the user paused global playback and is seeking globally. + if (globalSeekChanged) { + const canvas = canvasRef.current; + if (!globalIsPlaying && globalStartTime && globalEndTime && globalCurrentTime && canvas) { + const totalGlobalSecs = toSec(subtractTimes(globalEndTime, globalStartTime)); + const globalPlayedRatio = toSec(subtractTimes(globalCurrentTime, globalStartTime)) / totalGlobalSecs; + currentPositionRef.current = globalPlayedRatio * totalGlobalSecs; + updateCanvas(); + } + } + + return ( + <> + { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + ev.preventDefault(); + pause(); + // move the currentPosition to the clicked position and update canvas + const rect = canvas.getBoundingClientRect(); + const left = ev.clientX - rect.left; + const clickedWidthRatio = left / canvasWidth; + const currentTimeStamp = getCurrentTimestamp(clickedWidthRatio); + if (currentTimeStamp) { + globalSeek(currentTimeStamp); + } + + currentPositionRef.current = clickedWidthRatio * totalSecs; + // No need to redraw if the user just sought globally since the global seek already triggered another redraw. + if (!globalSeekChanged) { + updateCanvas(); + } + setDraggingStart({ + value: currentPositionRef.current, + clientX: ev.clientX, + }); + if (showAllChannels) { + const top = ev.clientY - rect.top; + const clickedHeightRatio = top / canvasHeight; + const newSelectedChannel = Math.floor(channelCount * clickedHeightRatio); + if (newSelectedChannel !== selectedChannel) { + saveConfig({ selectedChannel: newSelectedChannel }); + } + } + }}> + {buffers.length > 1 && + channelOptions.map(({ value }) => ( + + ))} + + + + + + + + + + {TIMESTAMP_OPTIONS.map(({ label, value }) => ( + + ))} + + + timestampDivRef.current && clipboard.copy(timestampDivRef.current.textContent)}> +
+ + + + + + saveConfig({ playbackRate: newPlaybackRate })} + onSelectedChannelChange={(newSelectedChannel) => { + // updateCanvas is in different cycle from react update, we could recreate the buffer + // before the next updateCanvas or stop now and wait for the next react update which will + // automatically create a new buffer + stop(); + saveConfig({ selectedChannel: newSelectedChannel }); + }} + onTogglePlay={play} + onToggleShowAllChannels={() => saveConfig({ showAllChannels: !showAllChannels })} + onVolumeChange={(newVolume) => saveConfig({ volume: newVolume })} + playbackRate={playbackRate} + selectedChannel={selectedChannel} + showAllChannels={showAllChannels} + speedSelectOptions={SPEED_SELECT_OPTIONS} + volume={volume} + isPlaying={isPlayingState} + downloadTooltip={showAllChannels ? `Download all the audio channels` : `Download audio`} + onDownloadAudio={onDownloadAudio} + /> + + + ); +} + +export default function AudioPlayer(props: $Diff) { + return ( + + {({ width, height }) => } + + ); +} diff --git a/packages/webviz-core/src/panels/Audio/AudioToolbar.js b/packages/webviz-core/src/panels/Audio/AudioToolbar.js new file mode 100644 index 000000000..e3ee94660 --- /dev/null +++ b/packages/webviz-core/src/panels/Audio/AudioToolbar.js @@ -0,0 +1,133 @@ +// @flow +// +// Copyright (c) 2021-present, 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 DownloadIcon from "@mdi/svg/svg/download.svg"; +import PauseIcon from "@mdi/svg/svg/pause.svg"; +import PlayIcon from "@mdi/svg/svg/play.svg"; +import * as React from "react"; +import styled from "styled-components"; + +import VolumeControl from "./VolumeControl"; +import Dropdown from "webviz-core/src/components/Dropdown"; +import Icon from "webviz-core/src/components/Icon"; +import Item from "webviz-core/src/components/Menu/Item"; + +const SBar = styled.div` + display: flex; + align-items: center; +`; + +const SPlayButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + background: transparent; +`; + +const SSpeedWrapper = styled.div` + width: 64; + button { + background: transparent; + } +`; + +const STransparentDropdownBtn = styled.div` + button { + background: transparent; + } +`; + +const MIN_VOLUME = 0; +const MAX_VOLUME = 4; +const VOLUME_CHANGE_STEP = 0.1; + +type Option = { + label: string, + value: number, +}; + +type Props = { + chanelOptions: Option[], + downloadTooltip: string, + isPlaying: boolean, + onDownloadAudio: () => void, + onPlaybackRateChange: (number) => void, + onSelectedChannelChange: (number) => void, + onTogglePlay: () => void, + onToggleShowAllChannels: () => void, + onVolumeChange: (number) => void, + playbackRate: number, + selectedChannel: number, + showAllChannels: boolean, + speedSelectOptions: Option[], + volume: number, +}; + +export default function AudioToolbar({ + chanelOptions, + downloadTooltip, + isPlaying, + onDownloadAudio, + onPlaybackRateChange, + onSelectedChannelChange, + onTogglePlay, + onToggleShowAllChannels, + onVolumeChange, + playbackRate, + selectedChannel, + showAllChannels, + speedSelectOptions, + volume, +}: Props) { + return ( + + + {isPlaying ? : } + + + + {speedSelectOptions.map(({ label, value }) => ( + + ))} + + + + + + {chanelOptions.map(({ label, value }) => ( + + ))} + + + : } + onClick={onToggleShowAllChannels}> + show all channels + + + + + + ); +} diff --git a/packages/webviz-core/src/panels/Audio/BlockLoadingProgress.js b/packages/webviz-core/src/panels/Audio/BlockLoadingProgress.js new file mode 100644 index 000000000..14e71e9af --- /dev/null +++ b/packages/webviz-core/src/panels/Audio/BlockLoadingProgress.js @@ -0,0 +1,72 @@ +// @flow +// +// Copyright (c) 2021-present, Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. +import React, { useRef, useEffect } from "react"; +import styled from "styled-components"; + +import Dimensions from "webviz-core/src/components/Dimensions"; +import { colors } from "webviz-core/src/util/sharedStyleConstants"; + +const CANVAS_HEIGHT = 5; +const LOADED_COLOR = colors.RED; +const UNLOADED_COLOR = colors.GRAY; + +type Props = { + blockLoadingStates: boolean[], + canvasWidth: number, +}; + +const SProgressWrapper = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + height: ${CANVAS_HEIGHT}px; + width: 100%; + position: relative; +`; + +function BlockLoadingProgressBase({ canvasWidth, blockLoadingStates }: Props) { + const canvasRef = useRef(undefined); + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || blockLoadingStates.length === 0) { + return; + } + // Update canvas based on blockLoadingStates. + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); + const rectWidth = canvasWidth / blockLoadingStates.length; + let rangeStartIdx = 0; + blockLoadingStates.forEach((loadingState, idx) => { + if (blockLoadingStates[rangeStartIdx] !== loadingState) { + // Loading state changed, draw all the previous blocks. + ctx.fillStyle = !loadingState ? LOADED_COLOR : UNLOADED_COLOR; + ctx.beginPath(); + ctx.rect(rectWidth * rangeStartIdx, 0, (idx - rangeStartIdx) * rectWidth, CANVAS_HEIGHT); + ctx.fill(); + rangeStartIdx = idx; + } + // Reached the end and draw the last part. + if (idx === blockLoadingStates.length - 1) { + ctx.fillStyle = loadingState ? LOADED_COLOR : UNLOADED_COLOR; + ctx.beginPath(); + ctx.rect(rectWidth * rangeStartIdx, 0, (idx - rangeStartIdx + 1) * rectWidth, CANVAS_HEIGHT); + ctx.fill(); + } + }); + }, [blockLoadingStates, canvasWidth]); + + return ; +} + +export default function BlockLoadingProgress(props: $Diff) { + return ( + + {({ width }) => } + + ); +} diff --git a/packages/webviz-core/src/panels/Audio/BlockLoadingProgress.stories.js b/packages/webviz-core/src/panels/Audio/BlockLoadingProgress.stories.js new file mode 100644 index 000000000..e92b9bf7f --- /dev/null +++ b/packages/webviz-core/src/panels/Audio/BlockLoadingProgress.stories.js @@ -0,0 +1,29 @@ +// @flow +// +// Copyright (c) 2021-present, 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 BlockLoadingProgress from "./BlockLoadingProgress"; + +storiesOf("", module).add("default", () => ( +
+ {[ + [], + [true, false], + [false, true], + [false, false, true], + [false, true, false, false, true, true, true, true, false, false], + ].map((blockLoadingStates, idx) => ( +
+
{JSON.stringify(blockLoadingStates)}
+ +
+ ))} +
+)); diff --git a/packages/webviz-core/src/panels/Audio/VolumeControl.js b/packages/webviz-core/src/panels/Audio/VolumeControl.js new file mode 100644 index 000000000..68bebc003 --- /dev/null +++ b/packages/webviz-core/src/panels/Audio/VolumeControl.js @@ -0,0 +1,96 @@ +// @flow +// +// Copyright (c) 2021-present, 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 VolumeHigh from "@mdi/svg/svg/volume-high.svg"; +import VolumeLow from "@mdi/svg/svg/volume-low.svg"; +import * as React from "react"; +import styled from "styled-components"; + +import Tooltip from "webviz-core/src/components/Tooltip"; +import { hexToRgbString } from "webviz-core/src/util/colorUtils"; +import { colors } from "webviz-core/src/util/sharedStyleConstants"; + +const light60 = hexToRgbString(colors.LIGHT1, 0.6); + +const SWrapper = styled.div` + display: flex; + align-items: center; +`; + +const SliderInput = styled.input.attrs({ type: "range" })` + width: 100%; + appearance: none; + background: transparent; + &:focus { + outline: 0; + background: transparent; + } + &::-webkit-slider-thumb { + appearance: none; + height: 16px; + width: 16px; + border-radius: 8px; + background: ${colors.LIGHT1}; + margin-top: -4px; + cursor: pointer; + &:hover { + opacity: 0.9; + } + } + &::-webkit-slider-runnable-track { + width: 100%; + background: ${light60}; + height: 6px; + border-radius: 3px; + cursor: pointer; + } +`; + +type Props = { + onChange: (number) => void, + value: number, + min?: number, + max?: number, + step?: number, + rootStyle?: { [string]: number | string }, + iconStyle?: { [string]: number | string }, +}; + +export default function VolumeControl({ + onChange, + min = 0, + max = 1, + step = 0.1, + value, + rootStyle = {}, + iconStyle = { + fill: light60, + width: 24, + height: 24, + }, + ...rest +}: Props) { + return ( + + + +
+ onChange(+e.target.value)} + /> +
+
+ +
+ ); +} diff --git a/packages/webviz-core/src/panels/Audio/index.help.md b/packages/webviz-core/src/panels/Audio/index.help.md new file mode 100644 index 000000000..7bb5f19f9 --- /dev/null +++ b/packages/webviz-core/src/panels/Audio/index.help.md @@ -0,0 +1,8 @@ +# Audio + +The Audio panel provides the audio data visualization and replay. You can: + +- replay the audio from different channels in different speed +- check `show all channels` option and inspect the waveform of all the audio channels +- double-click to select the active audio channel when all channels are displayed +- hover over the right bottom corner and click to copy the ROS timestamp diff --git a/packages/webviz-core/src/panels/Audio/index.js b/packages/webviz-core/src/panels/Audio/index.js new file mode 100644 index 000000000..d683f3362 --- /dev/null +++ b/packages/webviz-core/src/panels/Audio/index.js @@ -0,0 +1,216 @@ +// @flow +// +// Copyright (c) 2021-present, 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 memoizeWeak from "memoize-weak"; +import React, { useMemo, useCallback } from "react"; +import { hot } from "react-hot-loader/root"; +import { type Time } from "rosbag"; +import styled from "styled-components"; + +import AudioPlayer, { type Config } from "./AudioPlayer"; +import helpContent from "./index.help.md"; +import { type AudioFrame, generateSamplesFromFrames, isAudioDatatype } from "./utils"; +import EmptyState from "webviz-core/src/components/EmptyState"; +import Flex from "webviz-core/src/components/Flex"; +import { useMessagePipeline } from "webviz-core/src/components/MessagePipeline"; +import Panel from "webviz-core/src/components/Panel"; +import PanelToolbar from "webviz-core/src/components/PanelToolbar"; +import SpinningLoadingIcon from "webviz-core/src/components/SpinningLoadingIcon"; +import TopicToRenderMenu from "webviz-core/src/components/TopicToRenderMenu"; +import { getGlobalHooks } from "webviz-core/src/loadWebviz"; +import { useBlocksByTopic } from "webviz-core/src/PanelAPI"; +import BlockLoadingProgress from "webviz-core/src/panels/Audio/BlockLoadingProgress"; +import type { TypedMessage } from "webviz-core/src/players/types"; +import { type Header } from "webviz-core/src/types/Messages"; +import { deepParse } from "webviz-core/src/util/binaryObjects"; +import { colors } from "webviz-core/src/util/sharedStyleConstants"; + +const SContainer = styled.div` + display: flex; + flex: 1 1 auto; + flex-direction: column; +`; +const SLoading = styled.div` + display: flex; + flex-direction: column; + height: 100%; +`; +const SLoadingToolbarWrapper = styled.div` + height: 32px; + display: flex; + align-items: center; + background: ${colors.DARK1}; + fill: ${colors.LIGHT}; +`; +const SSpinningWrapper = styled.span` + padding: 4px 8px; +`; + +const { defaultTopic } = getGlobalHooks().perPanelHooks().Audio; +const DEFAULT_SAMPLING_RATE = 48000; +const DEFAULT_FRAME_PER_MESSAGE = 1024; +export const DEFAULT_SAMPLE_VALUE = 0; +const DEFAULT_MESSAGE_PER_BLOCK = 4; + +type AudioMessage = $ReadOnly<{| + header: Header, + sampling_rate_hz: number, + frames: $ReadOnlyArray, +|}>; + +type AudioBlock = TypedMessage[]; +type MessageTimestamps = { headerStamp: Time, receiveTime: Time }[]; +type Props = { + config: Config, + saveConfig: ($Shape) => void, +}; + +export type ProcessedAudioData = {| + channelCount: number, + messageTimestamps: MessageTimestamps, + samples: number[][], + samplingRate: number, +|}; + +const memoizedGetAudioDataFromBlocks = memoizeWeak( + (audioBlocks: AudioBlock[]): ?ProcessedAudioData => { + if (audioBlocks.length === 0) { + return; + } + const firstAudioMsg = audioBlocks[0][0].message; + const { frames, sampling_rate_hz } = firstAudioMsg; + const channelCnt = frames[0].channels.length; + + const audioMsgTimestamps = []; + const audioSamples = new Array(channelCnt).fill().map(() => []); + + audioBlocks.forEach((block) => { + block.forEach((msg) => { + audioMsgTimestamps.push({ receiveTime: msg.receiveTime, headerStamp: msg.message.header.stamp }); + const msgFrames = msg.message.frames; + const perMsgSample = generateSamplesFromFrames(msgFrames); + perMsgSample.forEach((item, channelIdx) => { + for (let i = 0; i < item.length; i++) { + audioSamples[channelIdx].push(item[i]); + } + }); + }); + }); + return { + messageTimestamps: audioMsgTimestamps, + samplingRate: sampling_rate_hz, + samples: audioSamples, + channelCount: channelCnt, + }; + } +); + +function Audio({ config, config: { topicToRender: configTopic }, saveConfig }: Props) { + const availableTopics = useMessagePipeline( + useCallback( + ({ sortedTopics, datatypes }) => sortedTopics.filter(({ datatype }) => isAudioDatatype(datatype, datatypes)), + [] + ) + ); + // If there's no default, pick the first available but don't save it to the config. If there are none available show + // "Default (not available)" and don't save it to the config. + const topicToRender = configTopic ?? defaultTopic ?? availableTopics[0] ?? ""; + const isAudioTopicAvailable = useMemo(() => !!availableTopics.find((item) => item.name === topicToRender), [ + availableTopics, + topicToRender, + ]); + + const blocks = useBlocksByTopic([topicToRender]); + const { blockLoadingStates, isAllAudioBlocksLoaded } = useMemo(() => { + const blockLoadingInfo = blocks.map((block) => !!block[topicToRender]); + return { + blockLoadingStates: blockLoadingInfo, + isAllAudioBlocksLoaded: blockLoadingInfo.length > 0 && blockLoadingInfo.every((loaded) => !!loaded), + }; + }, [blocks, topicToRender]); + const defaultBlocks = useMemo( + () => + new Array(blocks.length).fill().map(() => + new Array(DEFAULT_MESSAGE_PER_BLOCK).fill().map(() => ({ + message: { + header: { frame_id: "", seq: 0, stamp: { sec: 0, nsec: 0 } }, + sampling_rate_hz: DEFAULT_SAMPLING_RATE, + frames: new Array(DEFAULT_FRAME_PER_MESSAGE).fill().map(() => ({ channels: [DEFAULT_SAMPLE_VALUE] })), + }, + receiveTime: { sec: 0, nsec: 0 }, + topic: topicToRender, + })) + ), + [blocks.length, topicToRender] + ); + const processedAudioData = useMemo(() => { + // Use default blocks if any audio block is not yet loaded. + const nonEmptyBlocks = !isAllAudioBlocksLoaded + ? defaultBlocks + : blocks + .map((block) => { + const audioBlock = block[topicToRender]; + // memoizedGetAudioDataFromBlocks expects each audioBlock to not be empty since it needs to read at least one msg to + // get sampling rate and channel count. + if (audioBlock.length === 0) { + return undefined; + } + return ((audioBlock.map((msg) => ({ + ...msg, + message: deepParse(msg.message), + })): any): AudioBlock); + }) + .filter(Boolean); + return memoizedGetAudioDataFromBlocks(nonEmptyBlocks); + }, [isAllAudioBlocksLoaded, defaultBlocks, blocks, topicToRender]); + + const renderAudioPlayer = isAllAudioBlocksLoaded && processedAudioData; + return ( + + saveConfig({ topicToRender: topic })} + topics={availableTopics} + defaultTopicToRender={defaultTopic} + /> + } + floating + /> + {!isAudioTopicAvailable && No audio messages} + {renderAudioPlayer && } + {!renderAudioPlayer && isAudioTopicAvailable && ( + + + + + + + + + Loading audio... + + + )} + + ); +} + +Audio.panelType = "Audio"; + +Audio.defaultConfig = { + showAllChannels: false, + volume: 1, + playbackRate: 1, + selectedChannel: 0, + topicToRender: defaultTopic, +}; + +export default hot(Panel(Audio)); diff --git a/packages/webviz-core/src/panels/Audio/utils.js b/packages/webviz-core/src/panels/Audio/utils.js new file mode 100644 index 000000000..74e099ee8 --- /dev/null +++ b/packages/webviz-core/src/panels/Audio/utils.js @@ -0,0 +1,235 @@ +// @flow +// +// Copyright (c) 2021-present, 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 audioBufferToWav from "audiobuffer-to-wav"; + +import { DEFAULT_SAMPLE_VALUE } from "./index"; +import type { RosDatatypes } from "webviz-core/src/types/RosDatatypes"; +import { isNumberType } from "webviz-core/src/util/binaryObjects/messageDefinitionUtils"; + +export type AudioSample = number[]; + +export type AudioFrame = { + channels: $ReadOnlyArray, +}; + +export const isAudioDatatype = (typeName: string, datatypes: RosDatatypes) => { + const datatype = datatypes[typeName]; + if (datatype == null) { + return false; + } + return ( + datatype.fields.some(({ name, type }) => name === "sampling_rate_hz" && isNumberType(type)) && + datatype.fields.some(({ name, isArray }) => name === "frames" && isArray) && + datatype.fields.some(({ name }) => name === "header") + ); +}; + +type Bound = { + min: number, + max: number, + missingData: boolean, +}; + +type WaveformConfig = { + color: string, + playedColor: string, + missingDataColor: string, + activeChannelColor: string, + activeChannelPlayedColor: string, + markerColor: string, + pointWidth: number, + heightScaleFactor: number, + multiChannelHeightScaleFactor: number, +}; + +// convert the frame data we got from ROS messages to audio samples +export function generateSamplesFromFrames(frames: $ReadOnlyArray): AudioSample[] { + const samples = new Array(frames[0].channels.length).fill().map(() => []); + for (let i = 0; i < frames.length; i++) { + const channels = frames[i].channels; + for (let c = 0; c < channels.length; c++) { + // scale the AudioFrame which is formatted as int32 to [-1, 1] + // by dividing by 2**31 + samples[c].push(channels[c] / 2147483648); + } + } + return samples; +} + +export function generateAudioBuffersFromSample( + samples: AudioSample[], + selectedChanel: number, + showAllChannels: boolean, + audioContext: AudioContext, + samplingRate: number +): AudioBuffer[] { + const samplesAlt = showAllChannels ? samples : [samples[selectedChanel]]; + return samplesAlt.map((sample) => { + // left and right output will have the data from the same selected channel + const newBuffer = audioContext.createBuffer(1, sample.length, samplingRate); + const floatArray = Float32Array.from(sample); + newBuffer.copyToChannel(floatArray, 0, 0); + return newBuffer; + }); +} + +// Get the min and max value of an array, more performant to use for loop. +// Mark the bound as missing data if both min and max are equal DEFAULT_SAMPLE_VALUE. +function getBound(values) { + let min = Infinity; + let max = -Infinity; + for (let i = 0; i < values.length; i++) { + const value = values[i]; + min = value > min ? min : value; + max = value < max ? max : value; + } + return { min, max, missingData: min === DEFAULT_SAMPLE_VALUE && max === DEFAULT_SAMPLE_VALUE }; +} + +// calculate the bounds of each step in the buffer +function getBoundArray(wave: Float32Array, pointCount: number, step: number): Bound[] { + const bounds = []; + for (let i = 0; i < pointCount; i++) { + // get the max and min values at this step + bounds.push(getBound(wave.slice(i * step, i * step + step))); + } + return bounds; +} + +// generate wave data based on the audio buffer, canvas width and pointWidth +export function calculateWaveformData(buffer: ?AudioBuffer, width: number, pointWidth: number): Bound[] { + if (!buffer) { + return []; + } + const wave = buffer.getChannelData(0); + const pointCount = width / pointWidth; + // total steps we are drawing + const step = Math.ceil(wave.length / pointCount); + // get the bounds (min and max) for each step + return getBoundArray(wave, pointCount, step); +} + +// draw the waveform points +function drawPoints({ + ctx, + color, + missingDataColor, + bounds, + maxAmp, + offsetX, + offsetY, + pointWidth, + heightScaleFactor, +}: {| + ctx: CanvasRenderingContext2D, + color: string, + missingDataColor: string, + bounds: Bound[], + maxAmp: number, + offsetX: number, + offsetY: number, + pointWidth: number, + heightScaleFactor: number, +|}) { + ctx.fillStyle = color; + + for (let i = 0; i < bounds.length; i++) { + const bound = bounds[i]; + const x = i * pointWidth + offsetX; + const y = (1 + bound.min) * maxAmp + offsetY; + const fillStyle = bound.missingData ? missingDataColor : color; + if (ctx.fillStyle !== fillStyle) { + ctx.fillStyle = fillStyle; + } + const height = Math.max(1, (bound.max - bound.min) * maxAmp); + const offset = Math.floor(heightScaleFactor / 2); + // draw a point, offset a little so the waveform looks centered vertically + // the alternative is to draw: ctx.fillRect(x, y, width - 1, height); + ctx.fillRect(x, y - height * offset, pointWidth - 1, height * heightScaleFactor); + } +} + +// draw the waveform on the provided canvas +export function drawWaveform( + bounds: Bound[][], + canvas: ?HTMLCanvasElement, + waveformConfig: WaveformConfig, + playedRatio: number = 0, + selectedChanel: number = 0 +) { + if (!canvas || !bounds.length || !bounds[0].length) { + return; + } + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); + const pointWidth = waveformConfig.pointWidth; + const hasMultiWaveForms = bounds.length > 1; + const heightScaleFactor = hasMultiWaveForms + ? waveformConfig.multiChannelHeightScaleFactor + : waveformConfig.heightScaleFactor; + + // find the max height we can draw + const maxAmp = canvas.height / (2 * bounds.length); + const maxPlayedBound = Math.ceil(bounds[0].length * playedRatio); + const playedAll = maxPlayedBound === bounds[0].length; + let playedBounds = []; + let unplayedBounds = []; + bounds.forEach((channelBounds, idx) => { + if (playedAll) { + playedBounds = channelBounds; + unplayedBounds = []; + } else if (maxPlayedBound === 0) { + playedBounds = []; + unplayedBounds = channelBounds; + } else { + playedBounds = channelBounds.slice(0, maxPlayedBound); + unplayedBounds = channelBounds.slice(maxPlayedBound, channelBounds.length); + } + const useActiveChannelColor = (hasMultiWaveForms && selectedChanel === idx) || !hasMultiWaveForms; + + // set up drawing style for unplayed waveform + const unplayedDrawColor = useActiveChannelColor ? waveformConfig.activeChannelColor : waveformConfig.color; + const offsetX = playedBounds.length * pointWidth; + drawPoints({ + ctx, + color: unplayedDrawColor, + missingDataColor: waveformConfig.missingDataColor, + bounds: unplayedBounds, + maxAmp, + offsetX, + offsetY: maxAmp * idx * 2, + pointWidth, + heightScaleFactor, + }); + + // set up drawing style for played waveform + const playedDrawColor = useActiveChannelColor + ? waveformConfig.activeChannelPlayedColor + : waveformConfig.playedColor; + drawPoints({ + ctx, + color: playedDrawColor, + missingDataColor: waveformConfig.missingDataColor, + bounds: playedBounds, + maxAmp, + offsetX: 0, + offsetY: maxAmp * idx * 2, + pointWidth, + heightScaleFactor, + }); + }); + + // draw the marker + ctx.fillStyle = waveformConfig.markerColor; + ctx.fillRect((playedBounds.length - 1) * pointWidth, 0, pointWidth, canvas.height); +} + +export function bufferToFileBlob(buffer: AudioBuffer): Blob { + const wav = audioBufferToWav(buffer, null); + return new window.Blob([new DataView(wav)], { type: "audio/wav" }); +} diff --git a/packages/webviz-core/src/panels/ImageView/index.js b/packages/webviz-core/src/panels/ImageView/index.js index 8d2f1169c..1d7c4aad5 100644 --- a/packages/webviz-core/src/panels/ImageView/index.js +++ b/packages/webviz-core/src/panels/ImageView/index.js @@ -43,7 +43,7 @@ import inScreenshotTests from "webviz-core/src/stories/inScreenshotTests"; import colors from "webviz-core/src/styles/colors.module.scss"; import type { CameraInfo } from "webviz-core/src/types/Messages"; import type { SaveConfig } from "webviz-core/src/types/panels"; -import { WEBVIZ_2D_ICON_ARRAY_DATATYPE } from "webviz-core/src/util/globalConstants"; +import { WEBVIZ_ICON_MSGS$WEBVIZ_2D_ICON_ARRAY } from "webviz-core/src/util/globalConstants"; import { useShallowMemo, useDeepMemo } from "webviz-core/src/util/hooks"; import naturalSort from "webviz-core/src/util/naturalSort"; import { getTopicsByTopicName } from "webviz-core/src/util/selectors"; @@ -68,7 +68,7 @@ type ImageViewPanelHooks = { }; const DEFAULT_PANEL_HOOKS = { imageMarkerDatatypes: [] }; -// const IMAGE_ICON_MARKER_DATATYPES = [WEBVIZ_2D_ICON_ARRAY_DATATYPE]; +// const IMAGE_ICON_MARKER_DATATYPES = [WEBVIZ_ICON_MSGS$WEBVIZ_2D_ICON_ARRAY]; export type Config = {| ...DefaultConfig, panelHooks?: ImageViewPanelHooks, @@ -257,7 +257,7 @@ function ImageView(props: Props) { const { imageMarkerDatatypes } = panelHooks || getGlobalHooks().perPanelHooks().ImageView || DEFAULT_PANEL_HOOKS; - const combinedImageMarkerDataTypes = useMemo(() => [...imageMarkerDatatypes, WEBVIZ_2D_ICON_ARRAY_DATATYPE], [ + const combinedImageMarkerDataTypes = useMemo(() => [...imageMarkerDatatypes, WEBVIZ_ICON_MSGS$WEBVIZ_2D_ICON_ARRAY], [ imageMarkerDatatypes, ]); const defaultAvailableMarkerTopics = useMemo( @@ -366,7 +366,7 @@ function ImageView(props: Props) { : filterMap(enabledMarkerTopics, (topic) => last(messagesByTopic[topic])); const [iconMarkers, nonIconMarkers] = partition(combinedMarkers, (item) => { - return topicsKeyByTopicName[item.topic].datatype === WEBVIZ_2D_ICON_ARRAY_DATATYPE; + return topicsKeyByTopicName[item.topic].datatype === WEBVIZ_ICON_MSGS$WEBVIZ_2D_ICON_ARRAY; }); return { iconMarkersToRender: iconMarkers, nonIconMarkersToRender: nonIconMarkers }; }, [enabledMarkerTopics, messagesByTopic, shouldSynchronize, synchronizedMessages, topicsKeyByTopicName]); diff --git a/packages/webviz-core/src/panels/Plot/PlotChart.js b/packages/webviz-core/src/panels/Plot/PlotChart.js index ec50a34cb..eeb9cf473 100644 --- a/packages/webviz-core/src/panels/Plot/PlotChart.js +++ b/packages/webviz-core/src/panels/Plot/PlotChart.js @@ -14,6 +14,7 @@ import uuid from "uuid"; import type { PlotXAxisVal } from "./index"; import styles from "./PlotChart.module.scss"; import Dimensions from "webviz-core/src/components/Dimensions"; +import { useExperimentalFeature } from "webviz-core/src/components/ExperimentalFeatures"; import TimeBasedChart, { type ChartDefaultView } from "webviz-core/src/components/TimeBasedChart"; import { type TimeBasedChartTooltipData, type TooltipItem } from "webviz-core/src/components/TimeBasedChart/utils"; import filterMap from "webviz-core/src/filterMap"; @@ -90,11 +91,12 @@ function getPointsAndTooltipsForMessagePathItem( xItem: ?TooltipItem, startTime: Time, timestampMethod, + path: string, xAxisVal: PlotXAxisVal, xAxisPath?: BasePlotPath, xAxisRanges: ?$ReadOnlyArray<$ReadOnlyArray>, datasetKey: string -) { +): { points: PlotChartPoint[], tooltips: TimeBasedChartTooltipData[], hasMismatchedData: boolean } { const points = []; const tooltips = []; const timestamp = timestampMethod === "headerStamp" ? yItem.headerStamp : yItem.receiveTime; @@ -131,6 +133,24 @@ function getPointsAndTooltipsForMessagePathItem( }; points.push({ x, y }); tooltips.push(tooltip); + } else if (path.endsWith(".@length") && typeof (value: any)?.length === "number") { + const valueNum: number = Number((value: any)?.length); + if (!isNaN(valueNum)) { + const x = getXForPoint(xAxisVal, elapsedTime, innerIdx, xAxisRanges, xItem, xAxisPath); + const y = valueNum; + const tooltip: TimeBasedChartTooltipData = { + x, + y, + datasetKey, + item: yItem, + path: queriedPath, + value: valueNum, + constantName, + startTime, + }; + points.push({ x, y }); + tooltips.push(tooltip); + } } } const hasMismatchedData = @@ -172,6 +192,7 @@ function getDatasetAndTooltipsFromMessagePlotPath( xItem, startTime, path.timestampMethod, + path.value, xAxisVal, xAxisPath, xAxisRanges, @@ -349,6 +370,8 @@ export default memo(function PlotChart(props: PlotChartProps) { ]; }, [maxYValue, minYValue]); + const chartRenderPath = useExperimentalFeature("useGLChartInPlotPanel") ? "webgl" : "chartjs"; + return (
@@ -371,6 +394,7 @@ export default memo(function PlotChart(props: PlotChartProps) { currentTime={currentTime} defaultView={defaultView} onClick={onClick} + renderPath={chartRenderPath} /> )} diff --git a/packages/webviz-core/src/panels/Plot/index.help.md b/packages/webviz-core/src/panels/Plot/index.help.md index f1bd5c60a..0a6dccdca 100644 --- a/packages/webviz-core/src/panels/Plot/index.help.md +++ b/packages/webviz-core/src/panels/Plot/index.help.md @@ -18,6 +18,8 @@ You can also enter an arbitrary number, which will add a horizontal line at that To take the derivative of a value (change per second), use the special `.@derivative` modifier. This does not work with scatter plots (when using slices). +To plot the length of an array, use the `.@length` modifier at the end of the queried array. + To switch the sign of a value, use the special `.@negative` modifier at the end of the topic path syntax. The following math functions are also available: `.@abs`, `.@acos`, `.@asin`, `.@atan`, `.@ceil`, `.@cos`, `.@log`, `.@log1p`, `.@log2`, `.@log10`, `.@round`, `.@sign`, `.@sin`, `.@sqrt`, `.@tan`, and `.@trunc`. See the [Javascript Math documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math) for details on each one. ## Array Index as X-Axis diff --git a/packages/webviz-core/src/panels/Plot/index.js b/packages/webviz-core/src/panels/Plot/index.js index b462eddd3..b6633001c 100644 --- a/packages/webviz-core/src/panels/Plot/index.js +++ b/packages/webviz-core/src/panels/Plot/index.js @@ -46,6 +46,8 @@ export const plotableRosTypes = [ "duration", "string", "json", + // We plot the length of arrays. + "array", ]; // X-axis values: diff --git a/packages/webviz-core/src/panels/Plot/index.stories.js b/packages/webviz-core/src/panels/Plot/index.stories.js index 1e4164368..a298457de 100644 --- a/packages/webviz-core/src/panels/Plot/index.stories.js +++ b/packages/webviz-core/src/panels/Plot/index.stories.js @@ -24,6 +24,15 @@ uint32 seq time stamp string frame_id`; +const float64ArrayStampedDefinition = `std_msgs/Header header +float64[] data + +================================================================================ +MSG: std_msgs/Header +uint32 seq +time stamp +string frame_id`; + const locationMessages = [ { header: { stamp: { sec: 0, nsec: 574635076 } }, pose: { acceleration: -0.00116662939, velocity: 1.184182664 } }, { header: { stamp: { sec: 0, nsec: 673758203 } }, pose: { acceleration: -0.0072101709, velocity: 1.182555127 } }, @@ -104,6 +113,12 @@ const datatypes = { ], }, "std_msgs/Bool": { fields: [{ name: "data", type: "bool", isArray: false }] }, + "nonstd_msgs/Float64Array": { + fields: [ + { name: "header", type: "std_msgs/Header", isArray: false, isComplex: true }, + { name: "data", type: "float64", isArray: true }, + ], + }, "nonstd_msgs/Float64Stamped": { fields: [ { name: "header", type: "std_msgs/Header", isArray: false, isComplex: true }, @@ -121,12 +136,22 @@ const getPreloadedMessage = (seconds) => ({ }), }); +const getArrayMessage = (seconds) => ({ + topic: "/array_topic", + receiveTime: fromSec(seconds), + message: wrapJsObject(datatypes, "nonstd_msgs/Float64Array", { + data: new Array(Math.round(seconds * 10)).fill(0), + header: { stamp: fromSec(seconds - 0.5), frame_id: "", seq: 0 }, + }), +}); + const messageCache = { blocks: [ ...[0.6, 0.7, 0.8, 0.9, 1.0].map((seconds) => ({ sizeInBytes: 0, messagesByTopic: { "/preloaded_topic": [getPreloadedMessage(seconds)], + "/array_topic": [getArrayMessage(seconds)], }, })), undefined, // 1.1 @@ -137,6 +162,7 @@ const messageCache = { sizeInBytes: 0, messagesByTopic: { "/preloaded_topic": [getPreloadedMessage(seconds)], + "/array_topic": [getArrayMessage(seconds)], }, })), ], @@ -151,13 +177,17 @@ export const fixture = { { name: "/some_topic/state", datatype: "msgs/State" }, { name: "/boolean_topic", datatype: "std_msgs/Bool" }, { name: "/preloaded_topic", datatype: "nonstd_msgs/Float64Stamped" }, + { name: "/array_topic", datatype: "nonstd_msgs/Float64Array" }, ], activeData: { startTime: { sec: 0, nsec: 202050 }, endTime: { sec: 24, nsec: 999997069 }, currentTime: { sec: 0, nsec: 750000000 }, isPlaying: false, - parsedMessageDefinitionsByTopic: { "/preloaded_topic": parseMessageDefinition(float64StampedDefinition) }, + parsedMessageDefinitionsByTopic: { + "/preloaded_topic": parseMessageDefinition(float64StampedDefinition), + "/array_topic": parseMessageDefinition(float64ArrayStampedDefinition), + }, speed: 0.2, }, frame: { @@ -620,4 +650,16 @@ storiesOf("", module) /> ); + }) + .add("array data with the .@length modifier", () => { + return ( + + + + ); }); diff --git a/packages/webviz-core/src/panels/Table/index.js b/packages/webviz-core/src/panels/Table/index.js index 03e8fcc22..e66d202df 100644 --- a/packages/webviz-core/src/panels/Table/index.js +++ b/packages/webviz-core/src/panels/Table/index.js @@ -39,6 +39,7 @@ import { useMessagePipeline } from "webviz-core/src/components/MessagePipeline"; import Panel from "webviz-core/src/components/Panel"; import PanelToolbar from "webviz-core/src/components/PanelToolbar"; import Tooltip from "webviz-core/src/components/Tooltip"; +import TopicToRenderMenu from "webviz-core/src/components/TopicToRenderMenu"; import { useDataSourceInfo, useMessagesByTopic } from "webviz-core/src/PanelAPI"; import { ColumnDropdown, ConditionaFormatsInput } from "webviz-core/src/panels/Table/TableSettings"; import type { @@ -886,6 +887,7 @@ function TablePanel({ config, saveConfig }: Props) { }, [saveConfig]); const { topics, datatypes } = useDataSourceInfo(); + const topicRosPath: ?RosPath = React.useMemo(() => parseRosPath(topicPath), [topicPath]); const topic: ?Topic = React.useMemo( () => topicRosPath && topics.find(({ name }) => name === topicRosPath.topicName), @@ -940,7 +942,22 @@ function TablePanel({ config, saveConfig }: Props) { - + { + // Maintain the path syntax so switching between base and feature topics is easy for users + const path = topicPath.match(/\.(.+)/); + const newTopicPath = path && path[1] ? `${_topic}.${path[1]}` : _topic; + saveConfig({ topicPath: newTopicPath }); + }} + topics={topics} + singleTopicDatatype={topic?.datatype} + defaultTopicToRender={""} + /> + }> MapComponent && ( ), - [MapComponent, props.cameraState.perspective, props.isDemoMode, mapNamespaces, memoizedScene, props.debug] + [MapComponent, metadataTopicNamespaces, memoizedScene, props.debug, props.cameraState.perspective, props.isDemoMode] ); } diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/Layout/index.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/Layout/index.js index adab6a48d..ba5783e68 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/Layout/index.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/Layout/index.js @@ -72,7 +72,7 @@ import { TOPIC_DISPLAY_MODES } from "webviz-core/src/panels/ThreeDimensionalViz/ import useSceneBuilderAndTransformsData from "webviz-core/src/panels/ThreeDimensionalViz/TopicTree/useSceneBuilderAndTransformsData"; import useTopicTree, { TopicTreeContext } from "webviz-core/src/panels/ThreeDimensionalViz/TopicTree/useTopicTree"; import Transforms from "webviz-core/src/panels/ThreeDimensionalViz/Transforms"; -import TransformsBuilder from "webviz-core/src/panels/ThreeDimensionalViz/TransformsBuilder"; +import TransformsBuilder from "webviz-core/src/panels/ThreeDimensionalViz/Transforms/TransformsBuilder"; import World from "webviz-core/src/panels/ThreeDimensionalViz/World"; import WorldContext from "webviz-core/src/panels/ThreeDimensionalViz/WorldContext"; import type { Frame, Topic } from "webviz-core/src/players/types"; @@ -84,6 +84,7 @@ import { inVideoRecordingMode } from "webviz-core/src/util/inAutomatedRunMode"; import Rpc from "webviz-core/src/util/Rpc"; import { setupMainThreadRpc } from "webviz-core/src/util/RpcMainThreadUtils"; import { getTopicsByTopicName } from "webviz-core/src/util/selectors"; +import sendNotification from "webviz-core/src/util/sendNotification"; import supportsOffscreenCanvas from "webviz-core/src/util/supportsOffscreenCanvas"; import { joinTopics } from "webviz-core/src/util/topicUtils"; @@ -361,6 +362,7 @@ export default function Layout({ settingsByKey, topicTreeConfig, uncategorizedGroupName, + staticallyAvailableNamespacesByTopic, }); const { allKeys, @@ -721,19 +723,19 @@ export default function Layout({ const { MapComponent } = sceneBuilderHooks; const memoizedScene = useShallowMemo(sceneBuilder ? sceneBuilder.getScene() : null); - const mapNamespaces = useShallowMemo(selectedNamespacesByTopic["/metadata"] || []); + const metadataTopicNamespaces = useShallowMemo(selectedNamespacesByTopic["/metadata"] || []); const mapElement = useMemo( () => MapComponent && ( ), - [MapComponent, cameraState.perspective, debug, isDemoMode, mapNamespaces, memoizedScene] + [MapComponent, cameraState.perspective, debug, isDemoMode, memoizedScene, metadataTopicNamespaces] ); // Memoize the threeDimensionalVizContextValue to avoid returning a new object every time @@ -911,14 +913,19 @@ export default function Layout({ if (canvas && !initialized && rpc) { // $FlowFixMe: flow does not recognize `transferControlToOffscreen` const transferableCanvas = supportsOffscreenCanvas() ? canvas.transferControlToOffscreen() : canvas; - rpc.send("initialize", { canvas: transferableCanvas }, [transferableCanvas]).then(() => { - if (stillMounted.current) { - setInitialized(true); - if (storyEvents) { - storyEvents.ready.resolve(); + rpc + .send("initialize", { canvas: transferableCanvas }, [transferableCanvas]) + .then(() => { + if (stillMounted.current) { + setInitialized(true); + if (storyEvents) { + storyEvents.ready.resolve(); + } } - } - }); + }) + .catch((error) => { + sendNotification("Error initializing 3D Panel", error, "app", "error"); + }); } else { // TODO: handle unmount with `canvas === undefined` } @@ -1081,7 +1088,6 @@ export default function Layout({ currentEditingTopic={currentEditingTopic} hasFeatureColumn={hasFeatureColumn} setCurrentEditingTopic={setCurrentEditingTopic} - message={frame?.[currentEditingTopic.name]?.[0]?.message} saveConfig={saveConfig} settingsByKey={settingsByKey} /> diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/MeasureMarker.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/MeasureMarker.js index 87f996b81..478136f5b 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/MeasureMarker.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/MeasureMarker.js @@ -7,44 +7,51 @@ // You may not use this file except in compliance with the License. import React from "react"; -import { Lines, Spheres, type Point } from "regl-worldview"; +import { Lines, Spheres, WorldviewReactContext, type Point } from "regl-worldview"; export type MeasurePoints = {| start: ?Point, end: ?Point, |}; -type Props = { +type Props = {| measurePoints: MeasurePoints, -}; + cameraDistance: number, +|}; -const sphereSize: number = 0.3; -const lineSize: number = 0.1; +const SPHERE_SIZE_PX = 13; +const defaultPose: any = Object.freeze({ orientation: { x: 0, y: 0, z: 0, w: 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 }, + color: { r: 1, g: 0.2, b: 0, a: 0.75 }, }); -const defaultPose: any = Object.freeze({ orientation: { x: 0, y: 0, z: 0, w: 1 } }); -export default function MeasureMarker({ measurePoints: { start, end } }: Props) { +export default function MeasureMarker({ measurePoints: { start, end }, cameraDistance }: Props) { + const { dimension } = React.useContext(WorldviewReactContext); + const sphere = React.useMemo(() => { + const size = (cameraDistance / dimension.height) * SPHERE_SIZE_PX; + return { + ...defaultSphere, + scale: { x: size, y: size, z: 0.1 }, + }; + }, [cameraDistance, dimension]); + const lineSize: number = sphere.scale.x / 3; + const spheres = []; const lines = []; if (start) { - const startPoint = { ...start }; - spheres.push({ - ...defaultSphere, + ...sphere, id: "_measure_start", - pose: { position: startPoint, ...defaultPose }, + pose: { position: start, ...defaultPose }, }); if (end) { const endPoint = { ...end }; lines.push({ - ...defaultSphere, + ...sphere, id: "_measure_line", points: [start, end], pose: { ...defaultPose, position: { x: 0, y: 0, z: 0 } }, @@ -53,7 +60,7 @@ export default function MeasureMarker({ measurePoints: { start, end } }: Props) }); spheres.push({ - ...defaultSphere, + ...sphere, id: "_measure_end", pose: { position: endPoint, ...defaultPose }, }); diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/defaultHooks.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/defaultHooks.js index a4b935a63..616e37c4d 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/defaultHooks.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/defaultHooks.js @@ -10,14 +10,14 @@ import type { ThreeDimensionalVizHooks } from "./types"; import PoseMarkers from "webviz-core/src/panels/ThreeDimensionalViz/commands/PoseMarkers"; import { defaultMapPalette } from "webviz-core/src/panels/ThreeDimensionalViz/commands/utils"; import LaserScanVert from "webviz-core/src/panels/ThreeDimensionalViz/LaserScanVert"; -import { TF_DATATYPE } from "webviz-core/src/util/globalConstants"; +import { TF2_MSGS$TF_MESSAGE } from "webviz-core/src/util/globalConstants"; const sceneBuilderHooks: ThreeDimensionalVizHooks = { getSelectionState: () => {}, getTopicsToRender: () => new Set(), consumeBobject: (topic, datatype, msg, consumeMethods, { errors }) => { // TF messages are consumed by TransformBuilder, not SceneBuilder. - if (datatype === TF_DATATYPE) { + if (datatype === TF2_MSGS$TF_MESSAGE) { return; } errors.topicsWithError.set(topic, `Unrecognized topic datatype for scene: ${datatype}`); diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/index.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/index.js index f0802fae0..02a85219b 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/index.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/SceneBuilder/index.js @@ -41,17 +41,17 @@ import { MARKER_ARRAY_DATATYPES, TRANSFORM_STATIC_TOPIC, TRANSFORM_TOPIC, - WEBVIZ_MARKER_DATATYPE, - WEBVIZ_MARKER_ARRAY_DATATYPE, - VISUALIZATION_MSGS_MARKER_DATATYPE, - VISUALIZATION_MSGS_MARKER_ARRAY_DATATYPE, - POSE_STAMPED_DATATYPE, - NAV_MSGS_PATH_DATATYPE, - NAV_MSGS_OCCUPANCY_GRID_DATATYPE, - POINT_CLOUD_DATATYPE, - SENSOR_MSGS_LASER_SCAN_DATATYPE, - GEOMETRY_MSGS_POLYGON_STAMPED_DATATYPE, - WEBVIZ_3D_ICON_ARRAY_DATATYPE, + VISUALIZATION_MSGS$WEBVIZ_MARKER, + VISUALIZATION_MSGS$WEBVIZ_MARKER_ARRAY, + VISUALIZATION_MSGS$MARKER, + VISUALIZATION_MSGS$MARKER_ARRAY, + GEOMETRY_MSGS$POSE_STAMPED, + NAV_MSGS$PATH, + NAV_MSGS$OCCUPANCY_GRID, + SENSOR_MSGS$POINT_CLOUD_2, + SENSOR_MSGS$LASER_SCAN, + GEOMETRY_MSGS$POLYGON_STAMPED, + WEBVIZ_ICON_MSGS$WEBVIZ_3D_ICON_ARRAY, MARKER_MSG_TYPES, } from "webviz-core/src/util/globalConstants"; import naturalSort from "webviz-core/src/util/naturalSort"; @@ -752,16 +752,16 @@ export default class SceneBuilder implements MarkerProvider { _consumeMessage = (topic: string, datatype: string, msg: BobjectMessage): void => { const { message } = msg; switch (datatype) { - case WEBVIZ_MARKER_DATATYPE: - case VISUALIZATION_MSGS_MARKER_DATATYPE: + case VISUALIZATION_MSGS$WEBVIZ_MARKER: + case VISUALIZATION_MSGS$MARKER: this._consumeMarker(topic, cast(message)); break; - case WEBVIZ_3D_ICON_ARRAY_DATATYPE: - case WEBVIZ_MARKER_ARRAY_DATATYPE: - case VISUALIZATION_MSGS_MARKER_ARRAY_DATATYPE: + case WEBVIZ_ICON_MSGS$WEBVIZ_3D_ICON_ARRAY: + case VISUALIZATION_MSGS$WEBVIZ_MARKER_ARRAY: + case VISUALIZATION_MSGS$MARKER_ARRAY: this._consumeMarkerArray(topic, message); break; - case POSE_STAMPED_DATATYPE: { + case GEOMETRY_MSGS$POSE_STAMPED: { // make synthetic arrow marker from the stamped pose const pose = deepParse(cast(msg.message).pose()); this.collectors[topic].addNonMarker( @@ -770,11 +770,11 @@ export default class SceneBuilder implements MarkerProvider { ); break; } - case NAV_MSGS_OCCUPANCY_GRID_DATATYPE: + case NAV_MSGS$OCCUPANCY_GRID: // flatten btn: set empty z values to be at the same level as the flattenedZHeightPose this._consumeOccupancyGrid(topic, deepParse(message)); break; - case NAV_MSGS_PATH_DATATYPE: { + case NAV_MSGS$PATH: { const pathStamped = cast(message); if (pathStamped.poses().length() === 0) { break; @@ -793,13 +793,13 @@ export default class SceneBuilder implements MarkerProvider { this._consumeNonMarkerMessage(topic, newMessage, MARKER_MSG_TYPES.LINE_STRIP, message); break; } - case POINT_CLOUD_DATATYPE: + case SENSOR_MSGS$POINT_CLOUD_2: this._consumeNonMarkerMessage(topic, deepParse(message), 102); break; - case SENSOR_MSGS_LASER_SCAN_DATATYPE: + case SENSOR_MSGS$LASER_SCAN: this._consumeNonMarkerMessage(topic, deepParse(message), 104); break; - case GEOMETRY_MSGS_POLYGON_STAMPED_DATATYPE: { + case GEOMETRY_MSGS$POLYGON_STAMPED: { // convert Polygon to a line strip const polygonStamped = cast(message); const polygon = polygonStamped.polygon(); diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSettingsEditor/index.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSettingsEditor/index.js index 400d26f11..82bab574f 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSettingsEditor/index.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicSettingsEditor/index.js @@ -24,15 +24,15 @@ import { Select, Option } from "webviz-core/src/components/Select"; import { getGlobalHooks } from "webviz-core/src/loadWebviz"; import type { Topic } from "webviz-core/src/players/types"; import { - POINT_CLOUD_DATATYPE, - POSE_STAMPED_DATATYPE, - SENSOR_MSGS_LASER_SCAN_DATATYPE, - WEBVIZ_MARKER_DATATYPE, - WEBVIZ_3D_ICON_ARRAY_DATATYPE, - WEBVIZ_MARKER_ARRAY_DATATYPE, - VISUALIZATION_MSGS_MARKER_DATATYPE, - VISUALIZATION_MSGS_MARKER_ARRAY_DATATYPE, - NAV_MSGS_PATH_DATATYPE, + SENSOR_MSGS$POINT_CLOUD_2, + GEOMETRY_MSGS$POSE_STAMPED, + SENSOR_MSGS$LASER_SCAN, + VISUALIZATION_MSGS$WEBVIZ_MARKER, + WEBVIZ_ICON_MSGS$WEBVIZ_3D_ICON_ARRAY, + VISUALIZATION_MSGS$WEBVIZ_MARKER_ARRAY, + VISUALIZATION_MSGS$MARKER, + VISUALIZATION_MSGS$MARKER_ARRAY, + NAV_MSGS$PATH, } from "webviz-core/src/util/globalConstants"; export const LINED_CONVEX_HULL_RENDERING_SETTING = "LinedConvexHull"; @@ -127,15 +127,15 @@ export type TopicSettingsEditorProps = {| export function topicSettingsEditorForDatatype(datatype: string): ?ComponentType> { const editors = { - [POINT_CLOUD_DATATYPE]: PointCloudSettingsEditor, - [POSE_STAMPED_DATATYPE]: PoseSettingsEditor, - [SENSOR_MSGS_LASER_SCAN_DATATYPE]: LaserScanSettingsEditor, - [WEBVIZ_MARKER_DATATYPE]: MarkerSettingsEditor, - [WEBVIZ_MARKER_ARRAY_DATATYPE]: MarkerSettingsEditor, - [VISUALIZATION_MSGS_MARKER_DATATYPE]: MarkerSettingsEditor, - [VISUALIZATION_MSGS_MARKER_ARRAY_DATATYPE]: MarkerSettingsEditor, - [WEBVIZ_3D_ICON_ARRAY_DATATYPE]: IconMarkerSettingsEditor, - [NAV_MSGS_PATH_DATATYPE]: MarkerOverrideColorSettingsEditor, + [SENSOR_MSGS$POINT_CLOUD_2]: PointCloudSettingsEditor, + [GEOMETRY_MSGS$POSE_STAMPED]: PoseSettingsEditor, + [SENSOR_MSGS$LASER_SCAN]: LaserScanSettingsEditor, + [VISUALIZATION_MSGS$WEBVIZ_MARKER]: MarkerSettingsEditor, + [VISUALIZATION_MSGS$WEBVIZ_MARKER_ARRAY]: MarkerSettingsEditor, + [VISUALIZATION_MSGS$MARKER]: MarkerSettingsEditor, + [VISUALIZATION_MSGS$MARKER_ARRAY]: MarkerSettingsEditor, + [WEBVIZ_ICON_MSGS$WEBVIZ_3D_ICON_ARRAY]: IconMarkerSettingsEditor, + [NAV_MSGS$PATH]: MarkerOverrideColorSettingsEditor, ...getGlobalHooks().perPanelHooks().ThreeDimensionalViz.topicSettingsEditors, }; return editors[datatype]; diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicTree/TopicSettingsModal.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicTree/TopicSettingsModal.js index 8951e1bb7..a0caf1e91 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicTree/TopicSettingsModal.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicTree/TopicSettingsModal.js @@ -17,6 +17,7 @@ import ErrorBoundary from "webviz-core/src/components/ErrorBoundary"; import Modal from "webviz-core/src/components/Modal"; import { RenderToBodyComponent } from "webviz-core/src/components/renderToBody"; import { getGlobalHooks } from "webviz-core/src/loadWebviz"; +import { useArbitraryTopicMessage } from "webviz-core/src/PanelAPI"; import { topicSettingsEditorForDatatype } from "webviz-core/src/panels/ThreeDimensionalViz/TopicSettingsEditor"; import type { Topic } from "webviz-core/src/players/types"; import { SECOND_SOURCE_PREFIX } from "webviz-core/src/util/globalConstants"; @@ -130,7 +131,6 @@ function MainEditor({ type Props = {| currentEditingTopic: Topic, hasFeatureColumn: boolean, - message: any, saveConfig: Save3DConfig, setCurrentEditingTopic: (?Topic) => void, settingsByKey: { [topic: string]: any }, @@ -140,7 +140,6 @@ function TopicSettingsModal({ currentEditingTopic, currentEditingTopic: { datatype, name: topicName }, hasFeatureColumn, - message, saveConfig, setCurrentEditingTopic, settingsByKey, @@ -166,6 +165,7 @@ function TopicSettingsModal({ const columnIndex = topicName.startsWith(SECOND_SOURCE_PREFIX) ? 1 : 0; const nonPrefixedTopic = columnIndex === 1 ? topicName.substr(SECOND_SOURCE_PREFIX.length) : topicName; + const message = useArbitraryTopicMessage(topicName); const editorElem = ( )} - {hasFeatureColumn && } + {showDiffMode && }
{showNoMatchesState ? ( @@ -401,7 +406,14 @@ function TopicTree({ } // A wrapper that can be resized horizontally, and it dynamically calculates the width of the base topic tree component. -function TopicTreeWrapper({ containerWidth, containerHeight, pinTopics, showTopicTree, ...rest }: Props) { +function TopicTreeWrapper({ + containerWidth, + containerHeight, + pinTopics, + showTopicTree, + hasFeatureColumn, + ...rest +}: Props) { const defaultTreeWidth = clamp(containerWidth, DEFAULT_XS_WIDTH, DEFAULT_WIDTH); const renderTopicTree = pinTopics || showTopicTree; const { sceneErrorsByKey, saveConfig, setShowTopicTree, isPlaying } = rest; @@ -415,6 +427,12 @@ function TopicTreeWrapper({ containerWidth, containerHeight, pinTopics, showTopi config: { tension: 340, friction: 26, clamp: true }, }); + const showDiffMode = hasFeatureColumn; + // TODO(troy): Remove hardcoded heights. We could use the + // component on top of the topic tree for a cleaner result. + const treeHeightRaw = containerHeight - SEARCH_BAR_HEIGHT - SWITCHER_HEIGHT - CONTAINER_SPACING * 2; + const treeHeight = showDiffMode ? treeHeightRaw - DIFF_MODE_HEIGHT : treeHeightRaw; + return ( @@ -450,7 +468,9 @@ function TopicTreeWrapper({ containerWidth, containerHeight, pinTopics, showTopi pinTopics={pinTopics} showTopicTree={showTopicTree} treeWidth={width} - treeHeight={containerHeight - SEARCH_BAR_HEIGHT - SWITCHER_HEIGHT - CONTAINER_SPACING * 2} + treeHeight={treeHeight} + showDiffMode={showDiffMode} + hasFeatureColumn={hasFeatureColumn} /> )} diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicTree/types.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicTree/types.js index 12acc4164..795a5b4d7 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicTree/types.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicTree/types.js @@ -88,6 +88,7 @@ export type UseTreeInput = {| settingsByKey: { [topicOrNamespaceKey: string]: any }, topicTreeConfig: TopicTreeConfig, // Never changes! uncategorizedGroupName: string, + staticallyAvailableNamespacesByTopic: NamespacesByTopic, |}; export type GetIsTreeNodeVisibleInScene = (topicNode: TreeNode, columnIndex: number, namespaceKey?: string) => boolean; diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicTree/useTopicTree.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicTree/useTopicTree.js index c6f6b8b63..5da4e6848 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicTree/useTopicTree.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicTree/useTopicTree.js @@ -11,7 +11,14 @@ import { useMemo, useCallback, useRef, createContext } from "react"; import { useDebounce } from "use-debounce"; import generateNodeKey from "./generateNodeKey"; -import type { TreeNode, TopicTreeConfig, UseTreeInput, UseTreeOutput, DerivedCustomSettingsByKey } from "./types"; +import type { + TreeNode, + TopicTreeConfig, + UseTreeInput, + NamespacesByTopic, + UseTreeOutput, + DerivedCustomSettingsByKey, +} from "./types"; import filterMap from "webviz-core/src/filterMap"; import { TOPIC_DISPLAY_MODES } from "webviz-core/src/panels/ThreeDimensionalViz/TopicTree/TopicViewModeSelector"; import { SECOND_SOURCE_PREFIX } from "webviz-core/src/util/globalConstants"; @@ -27,11 +34,13 @@ export function generateTreeNode( parentKey, datatypesByTopic, hasFeatureColumn, + staticallyAvailableNamespacesByTopic, }: {| availableTopicsNamesSet: Set, datatypesByTopic: { [topicName: string]: string }, parentKey: ?string, hasFeatureColumn: boolean, + staticallyAvailableNamespacesByTopic: NamespacesByTopic, |} ): TreeNode { const key = generateNodeKey({ name, topicName }); @@ -40,14 +49,22 @@ export function generateTreeNode( if (topicName) { const datatype = datatypesByTopic[topicName] || datatypesByTopic[`${SECOND_SOURCE_PREFIX}${topicName}`]; + // Statically available namespace topics are always available to turn on or off. + const isAlwaysAvailable = !!staticallyAvailableNamespacesByTopic[topicName]; + let availableByColumn; + if (isAlwaysAvailable) { + availableByColumn = hasFeatureColumn ? [true, true] : [true]; + } else { + availableByColumn = hasFeatureColumn + ? [availableTopicsNamesSet.has(topicName), availableTopicsNamesSet.has(`${SECOND_SOURCE_PREFIX}${topicName}`)] + : [availableTopicsNamesSet.has(topicName)]; + } return { type: "topic", key, featureKey, topicName, - availableByColumn: hasFeatureColumn - ? [availableTopicsNamesSet.has(topicName), availableTopicsNamesSet.has(`${SECOND_SOURCE_PREFIX}${topicName}`)] - : [availableTopicsNamesSet.has(topicName)], + availableByColumn, providerAvailable, ...(parentKey ? { parentKey } : undefined), ...(name ? { name } : undefined), @@ -63,6 +80,7 @@ export function generateTreeNode( parentKey: name === "root" ? undefined : key, datatypesByTopic, hasFeatureColumn, + staticallyAvailableNamespacesByTopic, }) ); return { @@ -106,7 +124,7 @@ const parseNamespaceKey = (key: string): { topicName: string, namespace: string return { topicName, namespace: namespaceParts.join(":") }; }; -export default function useTree({ +export default function useTopicTree({ availableNamespacesByTopic, checkedKeys, defaultTopicSettings, @@ -120,6 +138,7 @@ export default function useTree({ settingsByKey, topicTreeConfig, uncategorizedGroupName, + staticallyAvailableNamespacesByTopic, }: UseTreeInput): UseTreeOutput { const topicTreeTopics = useMemo( () => @@ -153,9 +172,22 @@ export default function useTree({ // Generate the rootTreeNode. Don't mutate the original treeConfig, just make a copy with newChildren. return generateTreeNode( { ...topicTreeConfig, children: newChildren }, - { parentKey: undefined, datatypesByTopic, availableTopicsNamesSet, hasFeatureColumn } + { + parentKey: undefined, + datatypesByTopic, + availableTopicsNamesSet, + hasFeatureColumn, + staticallyAvailableNamespacesByTopic, + } ); - }, [hasFeatureColumn, providerTopics, topicTreeConfig, topicTreeTopics, uncategorizedGroupName]); + }, [ + hasFeatureColumn, + providerTopics, + staticallyAvailableNamespacesByTopic, + topicTreeConfig, + topicTreeTopics, + uncategorizedGroupName, + ]); const nodesByKey: { [key: string]: TreeNode } = useMemo(() => { const flattenNodes = Array.from(flattenNode(rootTreeNode)); diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicTree/useTopicTree.test.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicTree/useTopicTree.test.js index 31e30fa9e..a8afc86be 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicTree/useTopicTree.test.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/TopicTree/useTopicTree.test.js @@ -41,6 +41,7 @@ const sharedProps = { settingsByKey: {}, topicTreeConfig: TREE_CONFIG, uncategorizedGroupName: "(Uncategorized)", + staticallyAvailableNamespacesByTopic: {}, }; function makeTopics(topicNames: string[]): Topic[] { @@ -100,6 +101,36 @@ describe("useTopicTree", () => { }); }); + it("simple tree with statically available topic / namespace", () => { + const Test = createTest(); + mount( + + ); + + expect(Test.result.mock.calls[0][0].rootTreeNode).toEqual({ + availableByColumn: [true], + children: [ + { + availableByColumn: [true], + featureKey: "t:/webviz_source_2/foo", + key: "t:/foo", + providerAvailable: false, + topicName: "/foo", + type: "topic", + }, + ], + featureKey: "name_2:root", + key: "name:root", + name: "root", + providerAvailable: false, + type: "group", + }); + }); + it("creates Uncategorized group node and adds uncategorized topics underneath", () => { const Test = createTest(); const root = mount( diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/TransformsBuilder.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms/TransformsBuilder.js similarity index 100% rename from packages/webviz-core/src/panels/ThreeDimensionalViz/TransformsBuilder.js rename to packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms/TransformsBuilder.js diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/TransformsBuilder.test.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms/TransformsBuilder.test.js similarity index 97% rename from packages/webviz-core/src/panels/ThreeDimensionalViz/TransformsBuilder.test.js rename to packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms/TransformsBuilder.test.js index 5de66fd3d..761fd56c5 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/TransformsBuilder.test.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms/TransformsBuilder.test.js @@ -9,7 +9,7 @@ import { mat4 } from "gl-matrix"; import { Transform } from "webviz-core/src/panels/ThreeDimensionalViz/Transforms"; -import { getArrowToParentMarkers } from "webviz-core/src/panels/ThreeDimensionalViz/TransformsBuilder"; +import { getArrowToParentMarkers } from "webviz-core/src/panels/ThreeDimensionalViz/Transforms/TransformsBuilder"; describe("TransformBuilder", () => { describe("getArrowToParentMarkers", () => { diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms/index.js similarity index 100% rename from packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms.js rename to packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms/index.js diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms/useTransformsNear.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms/useTransformsNear.js new file mode 100644 index 000000000..5784fe2c8 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms/useTransformsNear.js @@ -0,0 +1,131 @@ +// @flow +// +// Copyright (c) 2018-present, 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 { minBy, sortedLastIndexBy } from "lodash"; +import memoizeWeak from "memoize-weak"; +import * as React from "react"; +import { type Time } from "rosbag"; + +import type { TransformElement } from "."; +import { getGlobalHooks } from "webviz-core/src/loadWebviz"; +import useBlocksByTopicWithFallback, { + type BlocksForTopics, +} from "webviz-core/src/PanelAPI/useBlocksByTopicWithFallback"; +import { makeTransformElement } from "webviz-core/src/panels/ThreeDimensionalViz/Transforms/utils"; +import type { TypedMessage } from "webviz-core/src/players/types"; +import type { BinaryTfMessage, BinaryTransformStamped } from "webviz-core/src/types/BinaryMessages"; +import { deepParse } from "webviz-core/src/util/binaryObjects"; +import { TRANSFORM_STATIC_TOPIC, TRANSFORM_TOPIC } from "webviz-core/src/util/globalConstants"; +import { toSec } from "webviz-core/src/util/time"; + +const { useStaticTransformsData } = getGlobalHooks().perPanelHooks().ThreeDimensionalViz; + +type ProcessedBlock = { [frameId: string]: BinaryTransformStamped[] }; + +// Index a ROS block's TF data by child_frame_id +const getTransformElementBlock = memoizeWeak( + (tfMessages: TypedMessage[], framesToIgnore: Set): ProcessedBlock => { + const transformsByFrameId = {}; + for (const tfMessage of tfMessages) { + for (const transform of tfMessage.message.transforms()) { + const childFrameId = transform.child_frame_id(); + if (framesToIgnore.has(childFrameId)) { + continue; + } + const frameTransforms = (transformsByFrameId[childFrameId] = transformsByFrameId[childFrameId] ?? []); + frameTransforms.push(transform); + } + } + return transformsByFrameId; + } +); + +type FrameIndex = { [frameId: string]: BinaryTransformStamped[][] }; +// Index all transform data from all blocks by child_frame_id +const getBlockFrameIndex = (blocks: BlocksForTopics, framesToIgnore: Set): FrameIndex => { + const ret = {}; + for (const block of blocks) { + [block[TRANSFORM_TOPIC], block[TRANSFORM_STATIC_TOPIC]] + .filter(Boolean) + .map((topicBlock) => getTransformElementBlock(topicBlock, framesToIgnore)) + .forEach((processedBlock) => { + Object.keys(processedBlock).forEach((childFrameId) => { + const frameBlocks = (ret[childFrameId] = ret[childFrameId] ?? []); + frameBlocks.push(processedBlock[childFrameId]); + }); + }); + } + return ret; +}; + +// Find the ROS transforms in the indexed blocks with header stamps closest to the input timestamp. +// Notes: +// - frameBlocks and all contained arrays must be non-empty. +// - sortedLastIndexBy expects `value` to have the same type as the elements of `array`, but that +// doesn't quite work for us, so we use `any` and make the callback work for both types. +// - toSec might lose precision here, but we probably don't need it to be exact. +// - The "nearest" transform might be after `timeSecs`. Ideally we'd interpolate/extrapolate, but +// there probably isn't a philosophical issue with looking into the future a bit. +const findNearestTransformInBlocks = ( + frameBlocks: BinaryTransformStamped[][], + timeSecs: number +): BinaryTransformStamped => { + const candidateNearestTransforms = []; + // Find the index of the first block whose first element is >= `time`. + // If such a block exists, its first transform might be nearest to `time`. + const firstBlockAfterIndex = sortedLastIndexBy(frameBlocks, (timeSecs: any), (block) => + typeof block === "number" ? block : toSec(deepParse(block[0].header().stamp())) + ); + candidateNearestTransforms.push(frameBlocks[firstBlockAfterIndex]?.[0]); // maybe null + + // In the block before (if it exists), find the index of the first transform >= `time`. + // That message and the one before it (either may not exist) might be nearest to `time`. + const blockBefore = frameBlocks[firstBlockAfterIndex - 1] ?? []; + const firstMessageAfterIndex = sortedLastIndexBy(blockBefore, (timeSecs: any), (tf) => + typeof tf === "number" ? tf : toSec(deepParse(tf.header().stamp())) + ); + candidateNearestTransforms.push(blockBefore[firstMessageAfterIndex]); // maybe null + candidateNearestTransforms.push(blockBefore[firstMessageAfterIndex - 1]); // not null + + // Filter out nulls, and find the closest. + return minBy(candidateNearestTransforms.filter(Boolean), (tf) => + Math.abs(timeSecs - toSec(deepParse(tf.header().stamp()))) + ); +}; + +// Exported for tests. +export const findNearestTransformElementInBlocks = ( + frameBlocks: BinaryTransformStamped[][], + timeSecs: number +): TransformElement => makeTransformElement(deepParse(findNearestTransformInBlocks(frameBlocks, timeSecs))); + +const useDynamicTransformsNear = (time: Time, framesToIgnore: Set): $ReadOnlyArray => { + const blocks = useBlocksByTopicWithFallback([TRANSFORM_STATIC_TOPIC, TRANSFORM_TOPIC]); + const blockFrameIndex = React.useMemo(() => getBlockFrameIndex(blocks, framesToIgnore), [blocks, framesToIgnore]); + const timeSecs = toSec(time); + return React.useMemo( + () => + Object.keys(blockFrameIndex).map((frameId) => + findNearestTransformElementInBlocks(blockFrameIndex[frameId], timeSecs) + ), + [blockFrameIndex, timeSecs] + ); +}; + +const NO_HOOK_STATIC_TRANSFORMS = []; + +// Given a timestamp, find relevant transforms. Assumes transforms with a given frame id have +// monotonically increasing header stamps. +const useTransformsNear = (time: Time): TransformElement[] => { + const hookStaticTransforms: TransformElement[] = useStaticTransformsData() ?? NO_HOOK_STATIC_TRANSFORMS; + const framesToIgnore = React.useMemo(() => new Set(hookStaticTransforms.map((t) => t.childFrame)), [ + hookStaticTransforms, + ]); + const dynamicTransforms = useDynamicTransformsNear(time, framesToIgnore); + return React.useMemo(() => dynamicTransforms.concat(hookStaticTransforms), [dynamicTransforms, hookStaticTransforms]); +}; +export default useTransformsNear; diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms/useTransformsNear.test.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms/useTransformsNear.test.js new file mode 100644 index 000000000..ba7da53e8 --- /dev/null +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms/useTransformsNear.test.js @@ -0,0 +1,51 @@ +// @flow +// +// Copyright (c) 2020-present, 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 { findNearestTransformElementInBlocks } from "./useTransformsNear"; +import { wrapObjects } from "webviz-core/src/test/datatypes"; + +const makeTransform = (xAndSec) => ({ + header: { seq: 0, stamp: { sec: xAndSec, nsec: 0 }, frame_id: "f" }, + child_frame_id: "c", + transform: { translation: { x: xAndSec, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0, w: 0 } }, +}); + +const makeTransformElement = (x) => ({ + childFrame: "c", + parentFrame: "f", + pose: { position: { x, y: 0, z: 0 }, orientation: { x: 0, y: 0, z: 0, w: 0 } }, +}); + +describe("findNearestTransformElementInBlocks", () => { + describe("works around single-element blocks", () => { + const blocks = [[makeTransform(10)]].map(wrapObjects); + expect(findNearestTransformElementInBlocks(blocks, 9)).toEqual(makeTransformElement(10)); + expect(findNearestTransformElementInBlocks(blocks, 10)).toEqual(makeTransformElement(10)); + expect(findNearestTransformElementInBlocks(blocks, 11)).toEqual(makeTransformElement(10)); + }); + + describe("more complex examples", () => { + const blocks = [ + [makeTransform(1), makeTransform(2)], + [makeTransform(5), makeTransform(6)], + [makeTransform(9), makeTransform(10)], + ].map(wrapObjects); + expect(findNearestTransformElementInBlocks(blocks, 0)).toEqual(makeTransformElement(1)); + expect(findNearestTransformElementInBlocks(blocks, 1)).toEqual(makeTransformElement(1)); + expect(findNearestTransformElementInBlocks(blocks, 2)).toEqual(makeTransformElement(2)); + expect(findNearestTransformElementInBlocks(blocks, 3)).toEqual(makeTransformElement(2)); + expect(findNearestTransformElementInBlocks(blocks, 4)).toEqual(makeTransformElement(5)); + expect(findNearestTransformElementInBlocks(blocks, 5)).toEqual(makeTransformElement(5)); + expect(findNearestTransformElementInBlocks(blocks, 6)).toEqual(makeTransformElement(6)); + expect(findNearestTransformElementInBlocks(blocks, 7)).toEqual(makeTransformElement(6)); + expect(findNearestTransformElementInBlocks(blocks, 8)).toEqual(makeTransformElement(9)); + expect(findNearestTransformElementInBlocks(blocks, 9)).toEqual(makeTransformElement(9)); + expect(findNearestTransformElementInBlocks(blocks, 10)).toEqual(makeTransformElement(10)); + expect(findNearestTransformElementInBlocks(blocks, 11)).toEqual(makeTransformElement(10)); + }); +}); diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/utils/transformsUtils.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms/utils.js similarity index 97% rename from packages/webviz-core/src/panels/ThreeDimensionalViz/utils/transformsUtils.js rename to packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms/utils.js index 672807220..bb07c32c9 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/utils/transformsUtils.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/Transforms/utils.js @@ -12,7 +12,7 @@ import type { TF } from "webviz-core/src/types/Messages"; import { isBobject, deepParse } from "webviz-core/src/util/binaryObjects"; import { TRANSFORM_STATIC_TOPIC, TRANSFORM_TOPIC } from "webviz-core/src/util/globalConstants"; -const makeTransformElement = (tf: TF) => ({ +export const makeTransformElement = (tf: TF) => ({ childFrame: tf.child_frame_id, parentFrame: tf.header.frame_id, pose: { position: tf.transform.translation, orientation: tf.transform.rotation }, diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/World.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/World.js index ea64f0601..3d2fa5847 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/World.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/World.js @@ -181,6 +181,7 @@ function World( const offscreenProps = canvas ? { canvas, width: canvas.width, height: canvas.height, top: 0, left: 0 } : {}; const { overlayIcon } = processedMarkersByType; + const cameraDistance = cameraState.distance || DEFAULT_CAMERA_STATE.distance; return ( {!!canvas && {overlayIcon}} {!cameraState.perspective && showCrosshair && } - + ); } diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/OverlayProjector/index.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/OverlayProjector/index.js index f7de13f02..2d2a9f3f6 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/OverlayProjector/index.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/commands/OverlayProjector/index.js @@ -12,6 +12,7 @@ import { Overlay } from "regl-worldview"; import type { Interactive } from "webviz-core/src/panels/ThreeDimensionalViz/Interactions/types"; import type { OverlayIconMarker } from "webviz-core/src/types/Messages"; +import { emptyPose } from "webviz-core/src/util/Pose"; import sendNotification from "webviz-core/src/util/sendNotification"; export const DEFAULT_TEXT_COLOR = { r: 1, g: 1, b: 1, a: 1 }; @@ -96,21 +97,26 @@ export const projectItem = ({ const OverlayProjector = (props: Props) => { const { children, setOverlayIcons } = props; const renderItems = []; + // We call setOverlayIcons after the last child has been processed. If there are no children, we + // still want to delay calling setOverlayIcons until regl renders, so add a dummy child. + const nonEmptyChildren = children.length === 0 ? [{ pose: emptyPose(), dummy: true }] : children; return ( { if (index === 0) { renderItems.length = 0; } - renderItems.push(projectItem({ item, coordinates, dimension })); - if (index === children.length - 1) { + if (!("dummy" in item)) { + renderItems.push(projectItem({ item, coordinates, dimension })); + } + if (index === nonEmptyChildren.length - 1) { // Set icons even if there aren't any, so the main thread knows when the last ones have // disappeared. setOverlayIcons({ renderItems: renderItems.filter(Boolean), sceneBuilderDrawables: children }); } return null; }}> - {children} + {nonEmptyChildren} ); }; diff --git a/packages/webviz-core/src/panels/ThreeDimensionalViz/withTransforms.js b/packages/webviz-core/src/panels/ThreeDimensionalViz/withTransforms.js index 6553e33c6..774a389fe 100644 --- a/packages/webviz-core/src/panels/ThreeDimensionalViz/withTransforms.js +++ b/packages/webviz-core/src/panels/ThreeDimensionalViz/withTransforms.js @@ -11,7 +11,7 @@ import * as React from "react"; import { getGlobalHooks } from "../../loadWebviz"; import Transforms from "webviz-core/src/panels/ThreeDimensionalViz/Transforms"; -import { updateTransforms } from "webviz-core/src/panels/ThreeDimensionalViz/utils/transformsUtils"; +import { updateTransforms } from "webviz-core/src/panels/ThreeDimensionalViz/Transforms/utils"; import type { Frame } from "webviz-core/src/players/types"; const panelHooks = getGlobalHooks().perPanelHooks().ThreeDimensionalViz; diff --git a/packages/webviz-core/src/panels/TwoDimensionalPlot/index.js b/packages/webviz-core/src/panels/TwoDimensionalPlot/index.js index 83275fa92..9a8a60f98 100644 --- a/packages/webviz-core/src/panels/TwoDimensionalPlot/index.js +++ b/packages/webviz-core/src/panels/TwoDimensionalPlot/index.js @@ -17,7 +17,9 @@ import { PanelToolbarLabel, PanelToolbarInput } from "webviz-core/shared/panelTo import Button from "webviz-core/src/components/Button"; import Dimensions from "webviz-core/src/components/Dimensions"; import EmptyState from "webviz-core/src/components/EmptyState"; +import { useExperimentalFeature } from "webviz-core/src/components/ExperimentalFeatures"; import Flex from "webviz-core/src/components/Flex"; +import GLChart from "webviz-core/src/components/GLChart"; import { SBar } from "webviz-core/src/components/HoverBar"; import KeyListener from "webviz-core/src/components/KeyListener"; import { Item } from "webviz-core/src/components/Menu"; @@ -25,12 +27,16 @@ import MessagePathInput from "webviz-core/src/components/MessagePathSyntax/Messa import { useLatestMessageDataItem } from "webviz-core/src/components/MessagePathSyntax/useLatestMessageDataItem"; import Panel from "webviz-core/src/components/Panel"; import PanelToolbar from "webviz-core/src/components/PanelToolbar"; -import ChartComponent, { type HoveredElement } from "webviz-core/src/components/ReactChartjs"; +import ReactChartjs, { + type HoveredElement, + type ChartCallbacks, + DEFAULT_PROPS, +} from "webviz-core/src/components/ReactChartjs"; import { type ScaleBounds } from "webviz-core/src/components/ReactChartjs/zoomAndPanHelpers"; import Tooltip from "webviz-core/src/components/Tooltip"; import { cast } from "webviz-core/src/players/types"; import { deepParse, isBobject } from "webviz-core/src/util/binaryObjects"; -import { useDeepChangeDetector } from "webviz-core/src/util/hooks"; +import { useDeepChangeDetector, useShallowMemo } from "webviz-core/src/util/hooks"; import { colors, ROBOTO_MONO } from "webviz-core/src/util/sharedStyleConstants"; const SResetZoom = styled.div` @@ -300,7 +306,7 @@ function TwoDimensionalPlot(props: Props) { const [hasVerticalExclusiveZoom, setHasVerticalExclusiveZoom] = React.useState(false); const [hasBothAxesZoom, setHasBothAxesZoom] = React.useState(false); const tooltip = React.useRef(null); - const chartComponent = React.useRef(null); + const chartCallbacks = React.useRef(null); const [mousePosition, updateMousePosition] = React.useState(null); @@ -441,12 +447,11 @@ function TwoDimensionalPlot(props: Props) { }, [scaleBounds]); const onMouseMove = React.useCallback(async (event: MouseEvent) => { - const currentChartComponent = chartComponent.current; - if (!currentChartComponent || !currentChartComponent.canvas) { + const canvas = chartCallbacks.current && chartCallbacks.current.canvasRef.current; + if (!canvas) { removeTooltip(); return; } - const { canvas } = currentChartComponent; const canvasRect = canvas.getBoundingClientRect(); const isTargetingCanvas = event.target === canvas; const xMousePosition = event.pageX - canvasRect.left; @@ -467,7 +472,8 @@ function TwoDimensionalPlot(props: Props) { const newMousePosition = { x: xMousePosition, y: yMousePosition }; updateMousePosition(newMousePosition); - const tooltipElement = await currentChartComponent.getElementAtXAxis(event); + // $FlowFixMe flow doesn't like function calls in optional chains + const tooltipElement = await chartCallbacks.current?.getElementAtXAxis(event); if (!tooltipElement) { removeTooltip(); return; @@ -509,8 +515,8 @@ function TwoDimensionalPlot(props: Props) { }, [datasets, removeTooltip, xAxisLabel]); const onResetZoom = React.useCallback(() => { - if (chartComponent.current) { - chartComponent.current.resetZoom(); + if (chartCallbacks.current) { + chartCallbacks.current.resetZoom(); setHasUserPannedOrZoomed(false); } }, [setHasUserPannedOrZoomed]); @@ -539,7 +545,7 @@ function TwoDimensionalPlot(props: Props) { v: () => setHasVerticalExclusiveZoom(true), b: () => setHasBothAxesZoom(true), }), - [] + [setHasVerticalExclusiveZoom, setHasBothAxesZoom] ); const keyUphandlers = React.useMemo( @@ -558,7 +564,12 @@ function TwoDimensionalPlot(props: Props) { throw new Error("2D Plot datasets do not have unique labels"); } + const zoomOptions = useShallowMemo({ ...DEFAULT_PROPS.zoomOptions, mode: zoomMode }); + const onChange = React.useCallback((newValue) => saveConfig({ path: { value: newValue } }), [saveConfig]); + + const ChartComponent = useExperimentalFeature("useGLChartIn2dPlot") ? GLChart : ReactChartjs; + return ( @@ -584,7 +595,6 @@ function TwoDimensionalPlot(props: Props) { {hasUserPannedOrZoomed && ( diff --git a/packages/webviz-core/src/panels/TwoDimensionalPlot/index.stories.js b/packages/webviz-core/src/panels/TwoDimensionalPlot/index.stories.js index f664b94db..474bc6a49 100644 --- a/packages/webviz-core/src/panels/TwoDimensionalPlot/index.stories.js +++ b/packages/webviz-core/src/panels/TwoDimensionalPlot/index.stories.js @@ -132,6 +132,19 @@ function zoomOut(keyObj) { } } +function resetZoom(el, N = 5) { + // It might be possible that the reset zoom button is not available + // right away, so try a couple of times before throwing an error. + const resetZoomBtn = el.querySelector("button"); + if (resetZoomBtn) { + resetZoomBtn.click(); + } else if (N > 0) { + setTimeout(() => resetZoom(el, N - 1), 200); + } else { + throw new Error("Cannot find reset zoom button"); + } +} + storiesOf("", module) .addParameters({ screenshot: { @@ -242,12 +255,7 @@ storiesOf("", module) fixture={fixture} onMount={(el) => { setTimeout(zoomOut, 200); - setTimeout(() => { - const resetZoomBtn = el.querySelector("button"); - if (resetZoomBtn) { - resetZoomBtn.click(); - } - }, 400); + setTimeout(() => resetZoom(el), 400); }}> diff --git a/packages/webviz-core/src/players/OrderedStampPlayer.js b/packages/webviz-core/src/players/OrderedStampPlayer.js index 0cc59aa0a..b8e0ac8b8 100644 --- a/packages/webviz-core/src/players/OrderedStampPlayer.js +++ b/packages/webviz-core/src/players/OrderedStampPlayer.js @@ -6,7 +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 { partition, uniq } from "lodash"; -import microMemoize from "micro-memoize"; import { type Time, TimeUtil } from "rosbag"; import { type GlobalVariables } from "webviz-core/src/hooks/useGlobalVariables"; @@ -20,7 +19,6 @@ import { type Player, type PlayerState, type PlayerWarnings, - type Topic, } from "webviz-core/src/players/types"; import UserNodePlayer from "webviz-core/src/players/UserNodePlayer"; import type { BinaryStampedMessage } from "webviz-core/src/types/BinaryMessages"; @@ -39,13 +37,6 @@ import { // than their receive times. export const BUFFER_DURATION_SECS = 1.0; -const getTopicsWithHeaer = microMemoize((topics: Topic[], datatypes) => { - return topics.filter(({ datatype }) => { - const fields = datatypes[datatype]?.fields; - return fields && fields.find((field) => field.type === "std_msgs/Header"); - }); -}); - export default class OrderedStampPlayer implements Player { _player: UserNodePlayer; _messageOrder: TimestampMethod; @@ -137,12 +128,10 @@ export default class OrderedStampPlayer implements Player { }); const currentTime = clampTime(thresholdTime, activeData.startTime, activeData.endTime); this._currentTime = currentTime; - const topicsWithHeader = getTopicsWithHeaer(activeData.topics, activeData.datatypes); return listener({ ...state, activeData: { ...activeData, - topics: topicsWithHeader, messages, bobjects, messageOrder: "headerStamp", diff --git a/packages/webviz-core/src/players/OrderedStampPlayer.test.js b/packages/webviz-core/src/players/OrderedStampPlayer.test.js index 0c87b7047..e474596ab 100644 --- a/packages/webviz-core/src/players/OrderedStampPlayer.test.js +++ b/packages/webviz-core/src/players/OrderedStampPlayer.test.js @@ -126,7 +126,7 @@ describe("OrderedStampPlayer", () => { ]); }); - it("filters and reorders bobjects and updates topics by header stamp", async () => { + it("filters and reorders bobjects by header stamp", async () => { const { player, fakePlayer } = makePlayers("headerStamp"); const states = []; player.setListener(async (playerState) => { @@ -180,7 +180,7 @@ describe("OrderedStampPlayer", () => { }, }, ]); - expect(topics).toEqual(oldTopics.filter(({ name }) => name !== "/dummy_no_header_topic")); + expect(topics).toEqual(oldTopics); }); it("filters and reorders messages and updates topics by header stamp", async () => { @@ -219,7 +219,7 @@ describe("OrderedStampPlayer", () => { }), }), ]); - expect(states[0]?.activeData?.topics).toEqual(oldTopics.filter(({ name }) => name !== "/dummy_no_header_topic")); + expect(states[0]?.activeData?.topics).toEqual(oldTopics); }); it("sets time correctly", async () => { diff --git a/packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/colors.test.ts b/packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/colors.test.ts new file mode 100644 index 000000000..b50363d8a --- /dev/null +++ b/packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/colors.test.ts @@ -0,0 +1,48 @@ +// +// Copyright (c) 2021-present, 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 { rotateHue, rgbToHsl, hslToRgb, interpolateColormap, GRAY_COLORMAP, COLORS } from "./colors"; + +describe("colors", () => { + describe("rotateHue", () => { + it("rotates colors", () => { + // Rotating 0.5 (half way around the unit circle) turns red into teal + expect(rotateHue({ r: 1, g: 0, b: 0, a: 1 }, 0.5)).toEqual( + expect.objectContaining({ r: 0, g: 0.9999999999999998, b: 1, a: 1 }) + ); + + // Rotating 0.5 (half way around the unit circle) turns orange into blue + expect(rotateHue({ r: 1, g: 1, b: 0, a: 1 }, 0.5)).toEqual( + expect.objectContaining({ r: 0, g: 6.661338147750939e-16, b: 1, a: 1 }) + ); + }); + }); + + describe("rgbToHsl", () => { + it("returns hsl colors", () => { + // Pure red is 0 "hue" in HSL + expect(rgbToHsl({ r: 1, g: 0, b: 0, a: 1 })).toEqual({ h: 0, s: 1, l: 0.5 }); + }); + }); + + describe("hslToRgb", () => { + it("returns rgb colors", () => { + // 0 hue is pure red in RGB + expect(hslToRgb({ h: 0, s: 1, l: 0.5 })).toEqual({ r: 1, g: 0, b: 0, a: 1 }); + }); + }); + + describe("colormap", () => { + it("interpolates gray", () => { + expect(interpolateColormap(GRAY_COLORMAP, 1.0)).toEqual(COLORS.LIGHT); + const gray = interpolateColormap(GRAY_COLORMAP, 0.5); + expect(gray.r).toBeLessThanOrEqual(COLORS.GRAY.r); + expect(gray.g).toBeLessThanOrEqual(COLORS.GRAY.g); + expect(gray.b).toBeLessThanOrEqual(COLORS.GRAY.b); + expect(gray.a).toEqual(1.0); + }); + }); +}); diff --git a/packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/colors.ts b/packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/colors.ts index 2390357a1..d87293528 100644 --- a/packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/colors.ts +++ b/packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/colors.ts @@ -1,4 +1,12 @@ -// go/styles +// +// Copyright (c) 2021-present, 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 { RGBA } from "./types"; + export const COLORS = { DARK: { r: 0.03, g: 0.03, b: 0.04, a: 1 }, DARK1: { r: 0.07, g: 0.07, b: 0.08, a: 1 }, @@ -42,7 +50,7 @@ export const COLORS = { REDL1: { r: 1.0, g: 0.42, b: 0.51, a: 1 }, RED: { r: 0.96, g: 0.29, b: 0.4, a: 1 }, RED1: { r: 0.86, g: 0.21, b: 0.33, a: 1 }, - RED2: { r: 1.0, g: 0.49, b: 0.59, a: 1 } + RED2: { r: 1.0, g: 0.49, b: 0.59, a: 1 }, }; export const SEMANTIC_CLASS_TO_COLOR_MAP = { @@ -57,5 +65,1142 @@ export const SEMANTIC_CLASS_TO_COLOR_MAP = { "9": COLORS.BLUE, // TRAIN "10": COLORS.BLUE, // ANIMAL "2000": COLORS.LIME, // STATIC_UNKNOWN - defaultColor: COLORS.LIME + defaultColor: COLORS.LIME, +}; + +export type HSL = { + h: number; + s: number; + l: number; }; + +/** +* "Rotates" the given color around the HSL color wheel by an amount between 0 and 1 +* Similar to CSS hue-rotate() function: https://www.quackit.com/css/functions/css_hue-rotate_function.cfm +*/ +export function rotateHue(color: RGBA, amount: number): RGBA { + const { h, s, l } = rgbToHsl(color); + const { r, g, b } = hslToRgb({ h: (h + amount + 1) % 1, s, l }); + return { r, g, b, a: color.a }; +} + +/** +* Converts an RGBA color into a hue-saturation-luminance (HSL) color +*/ +export function rgbToHsl(rgbColor: RGBA): HSL { + const { r, g, b } = rgbColor; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2; + let h = 0; + let s; + + if (Math.abs(max - min) <= 0.001) { + h = s = 0; // achromatic + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + break; + } + h /= 6; + } + return { h, s, l }; +} + +/** +* Converts an HSL color into a RGBA color +*/ +export function hslToRgb(color: HSL): RGBA { + const { h, s, l } = color; + let r, g, b; + + if (s === 0) { + r = g = b = l; // achromatic + } else { + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hueToColorComponent(p, q, h + 1 / 3); + g = hueToColorComponent(p, q, h); + b = hueToColorComponent(p, q, h - 1 / 3); + } + return { r, g, b, a: 1 }; +} + +function hueToColorComponent(p: number, q: number, tRaw: number): number { + const t = (tRaw + 1) % 1; + if (t < 1 / 6) { + return p + (q - p) * 6 * t; + } + if (t < 1 / 2) { + return q; + } + if (t < 2 / 3) { + return p + (q - p) * (2 / 3 - t) * 6; + } + return p; +} + +/** +* Samples a colormap. +* @param colormap 256 entry rgb array +* @param x value between 0-1 that linearly interpolates the colormap +* @returns RGBA color +*/ +export function interpolateColormap(colormap: number[][], x: number): RGBA { + x = Math.max(0.0, Math.min(1.0, x)); + const a = Math.floor(x * 255.0); + const b = Math.min(255, a + 1); + const f = x * 255.0 - a; + return { + r: colormap[a][0] + (colormap[b][0] - colormap[a][0]) * f, + g: colormap[a][1] + (colormap[b][1] - colormap[a][1]) * f, + b: colormap[a][2] + (colormap[b][2] - colormap[a][2]) * f, + a: 1.0 + }; +} + +export const VIRIDIS_COLORMAP = [ + [0.267004, 0.004874, 0.329415], + [0.268510, 0.009605, 0.335427], + [0.269944, 0.014625, 0.341379], + [0.271305, 0.019942, 0.347269], + [0.272594, 0.025563, 0.353093], + [0.273809, 0.031497, 0.358853], + [0.274952, 0.037752, 0.364543], + [0.276022, 0.044167, 0.370164], + [0.277018, 0.050344, 0.375715], + [0.277941, 0.056324, 0.381191], + [0.278791, 0.062145, 0.386592], + [0.279566, 0.067836, 0.391917], + [0.280267, 0.073417, 0.397163], + [0.280894, 0.078907, 0.402329], + [0.281446, 0.084320, 0.407414], + [0.281924, 0.089666, 0.412415], + [0.282327, 0.094955, 0.417331], + [0.282656, 0.100196, 0.422160], + [0.282910, 0.105393, 0.426902], + [0.283091, 0.110553, 0.431554], + [0.283197, 0.115680, 0.436115], + [0.283229, 0.120777, 0.440584], + [0.283187, 0.125848, 0.444960], + [0.283072, 0.130895, 0.449241], + [0.282884, 0.135920, 0.453427], + [0.282623, 0.140926, 0.457517], + [0.282290, 0.145912, 0.461510], + [0.281887, 0.150881, 0.465405], + [0.281412, 0.155834, 0.469201], + [0.280868, 0.160771, 0.472899], + [0.280255, 0.165693, 0.476498], + [0.279574, 0.170599, 0.479997], + [0.278826, 0.175490, 0.483397], + [0.278012, 0.180367, 0.486697], + [0.277134, 0.185228, 0.489898], + [0.276194, 0.190074, 0.493001], + [0.275191, 0.194905, 0.496005], + [0.274128, 0.199721, 0.498911], + [0.273006, 0.204520, 0.501721], + [0.271828, 0.209303, 0.504434], + [0.270595, 0.214069, 0.507052], + [0.269308, 0.218818, 0.509577], + [0.267968, 0.223549, 0.512008], + [0.266580, 0.228262, 0.514349], + [0.265145, 0.232956, 0.516599], + [0.263663, 0.237631, 0.518762], + [0.262138, 0.242286, 0.520837], + [0.260571, 0.246922, 0.522828], + [0.258965, 0.251537, 0.524736], + [0.257322, 0.256130, 0.526563], + [0.255645, 0.260703, 0.528312], + [0.253935, 0.265254, 0.529983], + [0.252194, 0.269783, 0.531579], + [0.250425, 0.274290, 0.533103], + [0.248629, 0.278775, 0.534556], + [0.246811, 0.283237, 0.535941], + [0.244972, 0.287675, 0.537260], + [0.243113, 0.292092, 0.538516], + [0.241237, 0.296485, 0.539709], + [0.239346, 0.300855, 0.540844], + [0.237441, 0.305202, 0.541921], + [0.235526, 0.309527, 0.542944], + [0.233603, 0.313828, 0.543914], + [0.231674, 0.318106, 0.544834], + [0.229739, 0.322361, 0.545706], + [0.227802, 0.326594, 0.546532], + [0.225863, 0.330805, 0.547314], + [0.223925, 0.334994, 0.548053], + [0.221989, 0.339161, 0.548752], + [0.220057, 0.343307, 0.549413], + [0.218130, 0.347432, 0.550038], + [0.216210, 0.351535, 0.550627], + [0.214298, 0.355619, 0.551184], + [0.212395, 0.359683, 0.551710], + [0.210503, 0.363727, 0.552206], + [0.208623, 0.367752, 0.552675], + [0.206756, 0.371758, 0.553117], + [0.204903, 0.375746, 0.553533], + [0.203063, 0.379716, 0.553925], + [0.201239, 0.383670, 0.554294], + [0.199430, 0.387607, 0.554642], + [0.197636, 0.391528, 0.554969], + [0.195860, 0.395433, 0.555276], + [0.194100, 0.399323, 0.555565], + [0.192357, 0.403199, 0.555836], + [0.190631, 0.407061, 0.556089], + [0.188923, 0.410910, 0.556326], + [0.187231, 0.414746, 0.556547], + [0.185556, 0.418570, 0.556753], + [0.183898, 0.422383, 0.556944], + [0.182256, 0.426184, 0.557120], + [0.180629, 0.429975, 0.557282], + [0.179019, 0.433756, 0.557430], + [0.177423, 0.437527, 0.557565], + [0.175841, 0.441290, 0.557685], + [0.174274, 0.445044, 0.557792], + [0.172719, 0.448791, 0.557885], + [0.171176, 0.452530, 0.557965], + [0.169646, 0.456262, 0.558030], + [0.168126, 0.459988, 0.558082], + [0.166617, 0.463708, 0.558119], + [0.165117, 0.467423, 0.558141], + [0.163625, 0.471133, 0.558148], + [0.162142, 0.474838, 0.558140], + [0.160665, 0.478540, 0.558115], + [0.159194, 0.482237, 0.558073], + [0.157729, 0.485932, 0.558013], + [0.156270, 0.489624, 0.557936], + [0.154815, 0.493313, 0.557840], + [0.153364, 0.497000, 0.557724], + [0.151918, 0.500685, 0.557587], + [0.150476, 0.504369, 0.557430], + [0.149039, 0.508051, 0.557250], + [0.147607, 0.511733, 0.557049], + [0.146180, 0.515413, 0.556823], + [0.144759, 0.519093, 0.556572], + [0.143343, 0.522773, 0.556295], + [0.141935, 0.526453, 0.555991], + [0.140536, 0.530132, 0.555659], + [0.139147, 0.533812, 0.555298], + [0.137770, 0.537492, 0.554906], + [0.136408, 0.541173, 0.554483], + [0.135066, 0.544853, 0.554029], + [0.133743, 0.548535, 0.553541], + [0.132444, 0.552216, 0.553018], + [0.131172, 0.555899, 0.552459], + [0.129933, 0.559582, 0.551864], + [0.128729, 0.563265, 0.551229], + [0.127568, 0.566949, 0.550556], + [0.126453, 0.570633, 0.549841], + [0.125394, 0.574318, 0.549086], + [0.124395, 0.578002, 0.548287], + [0.123463, 0.581687, 0.547445], + [0.122606, 0.585371, 0.546557], + [0.121831, 0.589055, 0.545623], + [0.121148, 0.592739, 0.544641], + [0.120565, 0.596422, 0.543611], + [0.120092, 0.600104, 0.542530], + [0.119738, 0.603785, 0.541400], + [0.119512, 0.607464, 0.540218], + [0.119423, 0.611141, 0.538982], + [0.119483, 0.614817, 0.537692], + [0.119699, 0.618490, 0.536347], + [0.120081, 0.622161, 0.534946], + [0.120638, 0.625828, 0.533488], + [0.121380, 0.629492, 0.531973], + [0.122312, 0.633153, 0.530398], + [0.123444, 0.636809, 0.528763], + [0.124780, 0.640461, 0.527068], + [0.126326, 0.644107, 0.525311], + [0.128087, 0.647749, 0.523491], + [0.130067, 0.651384, 0.521608], + [0.132268, 0.655014, 0.519661], + [0.134692, 0.658636, 0.517649], + [0.137339, 0.662252, 0.515571], + [0.140210, 0.665859, 0.513427], + [0.143303, 0.669459, 0.511215], + [0.146616, 0.673050, 0.508936], + [0.150148, 0.676631, 0.506589], + [0.153894, 0.680203, 0.504172], + [0.157851, 0.683765, 0.501686], + [0.162016, 0.687316, 0.499129], + [0.166383, 0.690856, 0.496502], + [0.170948, 0.694384, 0.493803], + [0.175707, 0.697900, 0.491033], + [0.180653, 0.701402, 0.488189], + [0.185783, 0.704891, 0.485273], + [0.191090, 0.708366, 0.482284], + [0.196571, 0.711827, 0.479221], + [0.202219, 0.715272, 0.476084], + [0.208030, 0.718701, 0.472873], + [0.214000, 0.722114, 0.469588], + [0.220124, 0.725509, 0.466226], + [0.226397, 0.728888, 0.462789], + [0.232815, 0.732247, 0.459277], + [0.239374, 0.735588, 0.455688], + [0.246070, 0.738910, 0.452024], + [0.252899, 0.742211, 0.448284], + [0.259857, 0.745492, 0.444467], + [0.266941, 0.748751, 0.440573], + [0.274149, 0.751988, 0.436601], + [0.281477, 0.755203, 0.432552], + [0.288921, 0.758394, 0.428426], + [0.296479, 0.761561, 0.424223], + [0.304148, 0.764704, 0.419943], + [0.311925, 0.767822, 0.415586], + [0.319809, 0.770914, 0.411152], + [0.327796, 0.773980, 0.406640], + [0.335885, 0.777018, 0.402049], + [0.344074, 0.780029, 0.397381], + [0.352360, 0.783011, 0.392636], + [0.360741, 0.785964, 0.387814], + [0.369214, 0.788888, 0.382914], + [0.377779, 0.791781, 0.377939], + [0.386433, 0.794644, 0.372886], + [0.395174, 0.797475, 0.367757], + [0.404001, 0.800275, 0.362552], + [0.412913, 0.803041, 0.357269], + [0.421908, 0.805774, 0.351910], + [0.430983, 0.808473, 0.346476], + [0.440137, 0.811138, 0.340967], + [0.449368, 0.813768, 0.335384], + [0.458674, 0.816363, 0.329727], + [0.468053, 0.818921, 0.323998], + [0.477504, 0.821444, 0.318195], + [0.487026, 0.823929, 0.312321], + [0.496615, 0.826376, 0.306377], + [0.506271, 0.828786, 0.300362], + [0.515992, 0.831158, 0.294279], + [0.525776, 0.833491, 0.288127], + [0.535621, 0.835785, 0.281908], + [0.545524, 0.838039, 0.275626], + [0.555484, 0.840254, 0.269281], + [0.565498, 0.842430, 0.262877], + [0.575563, 0.844566, 0.256415], + [0.585678, 0.846661, 0.249897], + [0.595839, 0.848717, 0.243329], + [0.606045, 0.850733, 0.236712], + [0.616293, 0.852709, 0.230052], + [0.626579, 0.854645, 0.223353], + [0.636902, 0.856542, 0.216620], + [0.647257, 0.858400, 0.209861], + [0.657642, 0.860219, 0.203082], + [0.668054, 0.861999, 0.196293], + [0.678489, 0.863742, 0.189503], + [0.688944, 0.865448, 0.182725], + [0.699415, 0.867117, 0.175971], + [0.709898, 0.868751, 0.169257], + [0.720391, 0.870350, 0.162603], + [0.730889, 0.871916, 0.156029], + [0.741388, 0.873449, 0.149561], + [0.751884, 0.874951, 0.143228], + [0.762373, 0.876424, 0.137064], + [0.772852, 0.877868, 0.131109], + [0.783315, 0.879285, 0.125405], + [0.793760, 0.880678, 0.120005], + [0.804182, 0.882046, 0.114965], + [0.814576, 0.883393, 0.110347], + [0.824940, 0.884720, 0.106217], + [0.835270, 0.886029, 0.102646], + [0.845561, 0.887322, 0.099702], + [0.855810, 0.888601, 0.097452], + [0.866013, 0.889868, 0.095953], + [0.876168, 0.891125, 0.095250], + [0.886271, 0.892374, 0.095374], + [0.896320, 0.893616, 0.096335], + [0.906311, 0.894855, 0.098125], + [0.916242, 0.896091, 0.100717], + [0.926106, 0.897330, 0.104071], + [0.935904, 0.898570, 0.108131], + [0.945636, 0.899815, 0.112838], + [0.955300, 0.901065, 0.118128], + [0.964894, 0.902323, 0.123941], + [0.974417, 0.903590, 0.130215], + [0.983868, 0.904867, 0.136897], + [0.993248, 0.906157, 0.143936] +]; + +export const INFERNO_COLORMAP = [ + [0.001462, 0.000466, 0.013866], + [0.002267, 0.001270, 0.018570], + [0.003299, 0.002249, 0.024239], + [0.004547, 0.003392, 0.030909], + [0.006006, 0.004692, 0.038558], + [0.007676, 0.006136, 0.046836], + [0.009561, 0.007713, 0.055143], + [0.011663, 0.009417, 0.063460], + [0.013995, 0.011225, 0.071862], + [0.016561, 0.013136, 0.080282], + [0.019373, 0.015133, 0.088767], + [0.022447, 0.017199, 0.097327], + [0.025793, 0.019331, 0.105930], + [0.029432, 0.021503, 0.114621], + [0.033385, 0.023702, 0.123397], + [0.037668, 0.025921, 0.132232], + [0.042253, 0.028139, 0.141141], + [0.046915, 0.030324, 0.150164], + [0.051644, 0.032474, 0.159254], + [0.056449, 0.034569, 0.168414], + [0.061340, 0.036590, 0.177642], + [0.066331, 0.038504, 0.186962], + [0.071429, 0.040294, 0.196354], + [0.076637, 0.041905, 0.205799], + [0.081962, 0.043328, 0.215289], + [0.087411, 0.044556, 0.224813], + [0.092990, 0.045583, 0.234358], + [0.098702, 0.046402, 0.243904], + [0.104551, 0.047008, 0.253430], + [0.110536, 0.047399, 0.262912], + [0.116656, 0.047574, 0.272321], + [0.122908, 0.047536, 0.281624], + [0.129285, 0.047293, 0.290788], + [0.135778, 0.046856, 0.299776], + [0.142378, 0.046242, 0.308553], + [0.149073, 0.045468, 0.317085], + [0.155850, 0.044559, 0.325338], + [0.162689, 0.043554, 0.333277], + [0.169575, 0.042489, 0.340874], + [0.176493, 0.041402, 0.348111], + [0.183429, 0.040329, 0.354971], + [0.190367, 0.039309, 0.361447], + [0.197297, 0.038400, 0.367535], + [0.204209, 0.037632, 0.373238], + [0.211095, 0.037030, 0.378563], + [0.217949, 0.036615, 0.383522], + [0.224763, 0.036405, 0.388129], + [0.231538, 0.036405, 0.392400], + [0.238273, 0.036621, 0.396353], + [0.244967, 0.037055, 0.400007], + [0.251620, 0.037705, 0.403378], + [0.258234, 0.038571, 0.406485], + [0.264810, 0.039647, 0.409345], + [0.271347, 0.040922, 0.411976], + [0.277850, 0.042353, 0.414392], + [0.284321, 0.043933, 0.416608], + [0.290763, 0.045644, 0.418637], + [0.297178, 0.047470, 0.420491], + [0.303568, 0.049396, 0.422182], + [0.309935, 0.051407, 0.423721], + [0.316282, 0.053490, 0.425116], + [0.322610, 0.055634, 0.426377], + [0.328921, 0.057827, 0.427511], + [0.335217, 0.060060, 0.428524], + [0.341500, 0.062325, 0.429425], + [0.347771, 0.064616, 0.430217], + [0.354032, 0.066925, 0.430906], + [0.360284, 0.069247, 0.431497], + [0.366529, 0.071579, 0.431994], + [0.372768, 0.073915, 0.432400], + [0.379001, 0.076253, 0.432719], + [0.385228, 0.078591, 0.432955], + [0.391453, 0.080927, 0.433109], + [0.397674, 0.083257, 0.433183], + [0.403894, 0.085580, 0.433179], + [0.410113, 0.087896, 0.433098], + [0.416331, 0.090203, 0.432943], + [0.422549, 0.092501, 0.432714], + [0.428768, 0.094790, 0.432412], + [0.434987, 0.097069, 0.432039], + [0.441207, 0.099338, 0.431594], + [0.447428, 0.101597, 0.431080], + [0.453651, 0.103848, 0.430498], + [0.459875, 0.106089, 0.429846], + [0.466100, 0.108322, 0.429125], + [0.472328, 0.110547, 0.428334], + [0.478558, 0.112764, 0.427475], + [0.484789, 0.114974, 0.426548], + [0.491022, 0.117179, 0.425552], + [0.497257, 0.119379, 0.424488], + [0.503493, 0.121575, 0.423356], + [0.509730, 0.123769, 0.422156], + [0.515967, 0.125960, 0.420887], + [0.522206, 0.128150, 0.419549], + [0.528444, 0.130341, 0.418142], + [0.534683, 0.132534, 0.416667], + [0.540920, 0.134729, 0.415123], + [0.547157, 0.136929, 0.413511], + [0.553392, 0.139134, 0.411829], + [0.559624, 0.141346, 0.410078], + [0.565854, 0.143567, 0.408258], + [0.572081, 0.145797, 0.406369], + [0.578304, 0.148039, 0.404411], + [0.584521, 0.150294, 0.402385], + [0.590734, 0.152563, 0.400290], + [0.596940, 0.154848, 0.398125], + [0.603139, 0.157151, 0.395891], + [0.609330, 0.159474, 0.393589], + [0.615513, 0.161817, 0.391219], + [0.621685, 0.164184, 0.388781], + [0.627847, 0.166575, 0.386276], + [0.633998, 0.168992, 0.383704], + [0.640135, 0.171438, 0.381065], + [0.646260, 0.173914, 0.378359], + [0.652369, 0.176421, 0.375586], + [0.658463, 0.178962, 0.372748], + [0.664540, 0.181539, 0.369846], + [0.670599, 0.184153, 0.366879], + [0.676638, 0.186807, 0.363849], + [0.682656, 0.189501, 0.360757], + [0.688653, 0.192239, 0.357603], + [0.694627, 0.195021, 0.354388], + [0.700576, 0.197851, 0.351113], + [0.706500, 0.200728, 0.347777], + [0.712396, 0.203656, 0.344383], + [0.718264, 0.206636, 0.340931], + [0.724103, 0.209670, 0.337424], + [0.729909, 0.212759, 0.333861], + [0.735683, 0.215906, 0.330245], + [0.741423, 0.219112, 0.326576], + [0.747127, 0.222378, 0.322856], + [0.752794, 0.225706, 0.319085], + [0.758422, 0.229097, 0.315266], + [0.764010, 0.232554, 0.311399], + [0.769556, 0.236077, 0.307485], + [0.775059, 0.239667, 0.303526], + [0.780517, 0.243327, 0.299523], + [0.785929, 0.247056, 0.295477], + [0.791293, 0.250856, 0.291390], + [0.796607, 0.254728, 0.287264], + [0.801871, 0.258674, 0.283099], + [0.807082, 0.262692, 0.278898], + [0.812239, 0.266786, 0.274661], + [0.817341, 0.270954, 0.270390], + [0.822386, 0.275197, 0.266085], + [0.827372, 0.279517, 0.261750], + [0.832299, 0.283913, 0.257383], + [0.837165, 0.288385, 0.252988], + [0.841969, 0.292933, 0.248564], + [0.846709, 0.297559, 0.244113], + [0.851384, 0.302260, 0.239636], + [0.855992, 0.307038, 0.235133], + [0.860533, 0.311892, 0.230606], + [0.865006, 0.316822, 0.226055], + [0.869409, 0.321827, 0.221482], + [0.873741, 0.326906, 0.216886], + [0.878001, 0.332060, 0.212268], + [0.882188, 0.337287, 0.207628], + [0.886302, 0.342586, 0.202968], + [0.890341, 0.347957, 0.198286], + [0.894305, 0.353399, 0.193584], + [0.898192, 0.358911, 0.188860], + [0.902003, 0.364492, 0.184116], + [0.905735, 0.370140, 0.179350], + [0.909390, 0.375856, 0.174563], + [0.912966, 0.381636, 0.169755], + [0.916462, 0.387481, 0.164924], + [0.919879, 0.393389, 0.160070], + [0.923215, 0.399359, 0.155193], + [0.926470, 0.405389, 0.150292], + [0.929644, 0.411479, 0.145367], + [0.932737, 0.417627, 0.140417], + [0.935747, 0.423831, 0.135440], + [0.938675, 0.430091, 0.130438], + [0.941521, 0.436405, 0.125409], + [0.944285, 0.442772, 0.120354], + [0.946965, 0.449191, 0.115272], + [0.949562, 0.455660, 0.110164], + [0.952075, 0.462178, 0.105031], + [0.954506, 0.468744, 0.099874], + [0.956852, 0.475356, 0.094695], + [0.959114, 0.482014, 0.089499], + [0.961293, 0.488716, 0.084289], + [0.963387, 0.495462, 0.079073], + [0.965397, 0.502249, 0.073859], + [0.967322, 0.509078, 0.068659], + [0.969163, 0.515946, 0.063488], + [0.970919, 0.522853, 0.058367], + [0.972590, 0.529798, 0.053324], + [0.974176, 0.536780, 0.048392], + [0.975677, 0.543798, 0.043618], + [0.977092, 0.550850, 0.039050], + [0.978422, 0.557937, 0.034931], + [0.979666, 0.565057, 0.031409], + [0.980824, 0.572209, 0.028508], + [0.981895, 0.579392, 0.026250], + [0.982881, 0.586606, 0.024661], + [0.983779, 0.593849, 0.023770], + [0.984591, 0.601122, 0.023606], + [0.985315, 0.608422, 0.024202], + [0.985952, 0.615750, 0.025592], + [0.986502, 0.623105, 0.027814], + [0.986964, 0.630485, 0.030908], + [0.987337, 0.637890, 0.034916], + [0.987622, 0.645320, 0.039886], + [0.987819, 0.652773, 0.045581], + [0.987926, 0.660250, 0.051750], + [0.987945, 0.667748, 0.058329], + [0.987874, 0.675267, 0.065257], + [0.987714, 0.682807, 0.072489], + [0.987464, 0.690366, 0.079990], + [0.987124, 0.697944, 0.087731], + [0.986694, 0.705540, 0.095694], + [0.986175, 0.713153, 0.103863], + [0.985566, 0.720782, 0.112229], + [0.984865, 0.728427, 0.120785], + [0.984075, 0.736087, 0.129527], + [0.983196, 0.743758, 0.138453], + [0.982228, 0.751442, 0.147565], + [0.981173, 0.759135, 0.156863], + [0.980032, 0.766837, 0.166353], + [0.978806, 0.774545, 0.176037], + [0.977497, 0.782258, 0.185923], + [0.976108, 0.789974, 0.196018], + [0.974638, 0.797692, 0.206332], + [0.973088, 0.805409, 0.216877], + [0.971468, 0.813122, 0.227658], + [0.969783, 0.820825, 0.238686], + [0.968041, 0.828515, 0.249972], + [0.966243, 0.836191, 0.261534], + [0.964394, 0.843848, 0.273391], + [0.962517, 0.851476, 0.285546], + [0.960626, 0.859069, 0.298010], + [0.958720, 0.866624, 0.310820], + [0.956834, 0.874129, 0.323974], + [0.954997, 0.881569, 0.337475], + [0.953215, 0.888942, 0.351369], + [0.951546, 0.896226, 0.365627], + [0.950018, 0.903409, 0.380271], + [0.948683, 0.910473, 0.395289], + [0.947594, 0.917399, 0.410665], + [0.946809, 0.924168, 0.426373], + [0.946392, 0.930761, 0.442367], + [0.946403, 0.937159, 0.458592], + [0.946903, 0.943348, 0.474970], + [0.947937, 0.949318, 0.491426], + [0.949545, 0.955063, 0.507860], + [0.951740, 0.960587, 0.524203], + [0.954529, 0.965896, 0.540361], + [0.957896, 0.971003, 0.556275], + [0.961812, 0.975924, 0.571925], + [0.966249, 0.980678, 0.587206], + [0.971162, 0.985282, 0.602154], + [0.976511, 0.989753, 0.616760], + [0.982257, 0.994109, 0.631017], + [0.988362, 0.998364, 0.644924] +]; + +export const TURBO_COLORMAP = [ + [0.189950, 0.071760, 0.232170], + [0.194830, 0.083390, 0.261490], + [0.199560, 0.094980, 0.290240], + [0.204150, 0.106520, 0.318440], + [0.208600, 0.118020, 0.346070], + [0.212910, 0.129470, 0.373140], + [0.217080, 0.140870, 0.399640], + [0.221110, 0.152230, 0.425580], + [0.225000, 0.163540, 0.450960], + [0.228750, 0.174810, 0.475780], + [0.232360, 0.186030, 0.500040], + [0.235820, 0.197200, 0.523730], + [0.239150, 0.208330, 0.546860], + [0.242340, 0.219410, 0.569420], + [0.245390, 0.230440, 0.591420], + [0.248300, 0.241430, 0.612860], + [0.251070, 0.252370, 0.633740], + [0.253690, 0.263270, 0.654060], + [0.256180, 0.274120, 0.673810], + [0.258530, 0.284920, 0.693000], + [0.260740, 0.295680, 0.711620], + [0.262800, 0.306390, 0.729680], + [0.264730, 0.317060, 0.747180], + [0.266520, 0.327680, 0.764120], + [0.268160, 0.338250, 0.780500], + [0.269670, 0.348780, 0.796310], + [0.271030, 0.359260, 0.811560], + [0.272260, 0.369700, 0.826240], + [0.273340, 0.380080, 0.840370], + [0.274290, 0.390430, 0.853930], + [0.275090, 0.400720, 0.866920], + [0.275760, 0.410970, 0.879360], + [0.276280, 0.421180, 0.891230], + [0.276670, 0.431340, 0.902540], + [0.276910, 0.441450, 0.913280], + [0.277010, 0.451520, 0.923470], + [0.276980, 0.461530, 0.933090], + [0.276800, 0.471510, 0.942140], + [0.276480, 0.481440, 0.950640], + [0.276030, 0.491320, 0.958570], + [0.275430, 0.501150, 0.965940], + [0.274690, 0.510940, 0.972750], + [0.273810, 0.520690, 0.978990], + [0.272730, 0.530400, 0.984610], + [0.271060, 0.540150, 0.989300], + [0.268780, 0.549950, 0.993030], + [0.265920, 0.559790, 0.995830], + [0.262520, 0.569670, 0.997730], + [0.258620, 0.579580, 0.998760], + [0.254250, 0.589500, 0.998960], + [0.249460, 0.599430, 0.998350], + [0.244270, 0.609370, 0.996970], + [0.238740, 0.619310, 0.994850], + [0.232880, 0.629230, 0.992020], + [0.226760, 0.639130, 0.988510], + [0.220390, 0.649010, 0.984360], + [0.213820, 0.658860, 0.979590], + [0.207080, 0.668660, 0.974230], + [0.200210, 0.678420, 0.968330], + [0.193260, 0.688120, 0.961900], + [0.186250, 0.697750, 0.954980], + [0.179230, 0.707320, 0.947610], + [0.172230, 0.716800, 0.939810], + [0.165290, 0.726200, 0.931610], + [0.158440, 0.735510, 0.923050], + [0.151730, 0.744720, 0.914160], + [0.145190, 0.753810, 0.904960], + [0.138860, 0.762790, 0.895500], + [0.132780, 0.771650, 0.885800], + [0.126980, 0.780370, 0.875900], + [0.121510, 0.788960, 0.865810], + [0.116390, 0.797400, 0.855590], + [0.111670, 0.805690, 0.845250], + [0.107380, 0.813810, 0.834840], + [0.103570, 0.821770, 0.824370], + [0.100260, 0.829550, 0.813890], + [0.097500, 0.837140, 0.803420], + [0.095320, 0.844550, 0.792990], + [0.093770, 0.851750, 0.782640], + [0.092870, 0.858750, 0.772400], + [0.092670, 0.865540, 0.762300], + [0.093200, 0.872110, 0.752370], + [0.094510, 0.878440, 0.742650], + [0.096620, 0.884540, 0.733160], + [0.099580, 0.890400, 0.723930], + [0.103420, 0.896000, 0.715000], + [0.108150, 0.901420, 0.705990], + [0.113740, 0.906730, 0.696510], + [0.120140, 0.911930, 0.686600], + [0.127330, 0.917010, 0.676270], + [0.135260, 0.921970, 0.665560], + [0.143910, 0.926800, 0.654480], + [0.153230, 0.931510, 0.643080], + [0.163190, 0.936090, 0.631370], + [0.173770, 0.940530, 0.619380], + [0.184910, 0.944840, 0.607130], + [0.196590, 0.949010, 0.594660], + [0.208770, 0.953040, 0.581990], + [0.221420, 0.956920, 0.569140], + [0.234490, 0.960650, 0.556140], + [0.247970, 0.964230, 0.543030], + [0.261800, 0.967650, 0.529810], + [0.275970, 0.970920, 0.516530], + [0.290420, 0.974030, 0.503210], + [0.305130, 0.976970, 0.489870], + [0.320060, 0.979740, 0.476540], + [0.335170, 0.982340, 0.463250], + [0.350430, 0.984770, 0.450020], + [0.365810, 0.987020, 0.436880], + [0.381270, 0.989090, 0.423860], + [0.396780, 0.990980, 0.410980], + [0.412290, 0.992680, 0.398260], + [0.427780, 0.994190, 0.385750], + [0.443210, 0.995510, 0.373450], + [0.458540, 0.996630, 0.361400], + [0.473750, 0.997550, 0.349630], + [0.488790, 0.998280, 0.338160], + [0.503620, 0.998790, 0.327010], + [0.518220, 0.999100, 0.316220], + [0.532550, 0.999190, 0.305810], + [0.546580, 0.999070, 0.295810], + [0.560260, 0.998730, 0.286230], + [0.573570, 0.998170, 0.277120], + [0.586460, 0.997390, 0.268490], + [0.598910, 0.996380, 0.260380], + [0.610880, 0.995140, 0.252800], + [0.622330, 0.993660, 0.245790], + [0.633230, 0.991950, 0.239370], + [0.643620, 0.989990, 0.233560], + [0.653940, 0.987750, 0.228350], + [0.664280, 0.985240, 0.223700], + [0.674620, 0.982460, 0.219600], + [0.684940, 0.979410, 0.216020], + [0.695250, 0.976100, 0.212940], + [0.705530, 0.972550, 0.210320], + [0.715770, 0.968750, 0.208150], + [0.725960, 0.964700, 0.206400], + [0.736100, 0.960430, 0.205040], + [0.746170, 0.955930, 0.204060], + [0.756170, 0.951210, 0.203430], + [0.766080, 0.946270, 0.203110], + [0.775910, 0.941130, 0.203100], + [0.785630, 0.935790, 0.203360], + [0.795240, 0.930250, 0.203860], + [0.804730, 0.924520, 0.204590], + [0.814100, 0.918610, 0.205520], + [0.823330, 0.912530, 0.206630], + [0.832410, 0.906270, 0.207880], + [0.841330, 0.899860, 0.209260], + [0.850100, 0.893280, 0.210740], + [0.858680, 0.886550, 0.212300], + [0.867090, 0.879680, 0.213910], + [0.875300, 0.872670, 0.215550], + [0.883310, 0.865530, 0.217190], + [0.891120, 0.858260, 0.218800], + [0.898700, 0.850870, 0.220380], + [0.906050, 0.843370, 0.221880], + [0.913170, 0.835760, 0.223280], + [0.920040, 0.828060, 0.224560], + [0.926660, 0.820250, 0.225700], + [0.933010, 0.812360, 0.226670], + [0.939090, 0.804390, 0.227440], + [0.944890, 0.796340, 0.228000], + [0.950390, 0.788230, 0.228310], + [0.955600, 0.780050, 0.228360], + [0.960490, 0.771810, 0.228110], + [0.965070, 0.763520, 0.227540], + [0.969310, 0.755190, 0.226630], + [0.973230, 0.746820, 0.225360], + [0.976790, 0.738420, 0.223690], + [0.980000, 0.730000, 0.221610], + [0.982890, 0.721400, 0.219180], + [0.985490, 0.712500, 0.216500], + [0.987810, 0.703300, 0.213580], + [0.989860, 0.693820, 0.210430], + [0.991630, 0.684080, 0.207060], + [0.993140, 0.674080, 0.203480], + [0.994380, 0.663860, 0.199710], + [0.995350, 0.653410, 0.195770], + [0.996070, 0.642770, 0.191650], + [0.996540, 0.631930, 0.187380], + [0.996750, 0.620930, 0.182970], + [0.996720, 0.609770, 0.178420], + [0.996440, 0.598460, 0.173760], + [0.995930, 0.587030, 0.168990], + [0.995170, 0.575490, 0.164120], + [0.994190, 0.563860, 0.159180], + [0.992970, 0.552140, 0.154170], + [0.991530, 0.540360, 0.149100], + [0.989870, 0.528540, 0.143980], + [0.987990, 0.516670, 0.138830], + [0.985900, 0.504790, 0.133670], + [0.983600, 0.492910, 0.128490], + [0.981080, 0.481040, 0.123320], + [0.978370, 0.469200, 0.118170], + [0.975450, 0.457400, 0.113050], + [0.972340, 0.445650, 0.107970], + [0.969040, 0.433990, 0.102940], + [0.965550, 0.422410, 0.097980], + [0.961870, 0.410930, 0.093100], + [0.958010, 0.399580, 0.088310], + [0.953980, 0.388360, 0.083620], + [0.949770, 0.377290, 0.079050], + [0.945380, 0.366380, 0.074610], + [0.940840, 0.355660, 0.070310], + [0.936120, 0.345130, 0.066160], + [0.931250, 0.334820, 0.062180], + [0.926230, 0.324730, 0.058370], + [0.921050, 0.314890, 0.054750], + [0.915720, 0.305300, 0.051340], + [0.910240, 0.295990, 0.048140], + [0.904630, 0.286960, 0.045160], + [0.898880, 0.278240, 0.042430], + [0.892980, 0.269810, 0.039930], + [0.886910, 0.261520, 0.037530], + [0.880660, 0.253340, 0.035210], + [0.874220, 0.245260, 0.032970], + [0.867600, 0.237300, 0.030820], + [0.860790, 0.229450, 0.028750], + [0.853800, 0.221700, 0.026770], + [0.846620, 0.214070, 0.024870], + [0.839260, 0.206540, 0.023050], + [0.831720, 0.199120, 0.021310], + [0.823990, 0.191820, 0.019660], + [0.816080, 0.184620, 0.018090], + [0.807990, 0.177530, 0.016600], + [0.799710, 0.170550, 0.015200], + [0.791250, 0.163680, 0.013870], + [0.782600, 0.156930, 0.012640], + [0.773770, 0.150280, 0.011480], + [0.764760, 0.143740, 0.010410], + [0.755560, 0.137310, 0.009420], + [0.746170, 0.130980, 0.008510], + [0.736610, 0.124770, 0.007690], + [0.726860, 0.118670, 0.006950], + [0.716920, 0.112680, 0.006290], + [0.706800, 0.106800, 0.005710], + [0.696500, 0.101020, 0.005220], + [0.686020, 0.095360, 0.004810], + [0.675350, 0.089800, 0.004490], + [0.664490, 0.084360, 0.004240], + [0.653450, 0.079020, 0.004080], + [0.642230, 0.073800, 0.004010], + [0.630820, 0.068680, 0.004010], + [0.619230, 0.063670, 0.004100], + [0.607460, 0.058780, 0.004270], + [0.595500, 0.053990, 0.004530], + [0.583360, 0.049310, 0.004860], + [0.571030, 0.044740, 0.005290], + [0.558520, 0.040280, 0.005790], + [0.545830, 0.035930, 0.006380], + [0.532950, 0.031690, 0.007050], + [0.519890, 0.027560, 0.007800], + [0.506640, 0.023540, 0.008630], + [0.493210, 0.019630, 0.009550], + [0.479600, 0.015830, 0.010550] +]; + +export const GRAY_COLORMAP = [ + [0.000000, 0.000000, 0.000000], + [0.003922, 0.003922, 0.003922], + [0.007843, 0.007843, 0.007843], + [0.011765, 0.011765, 0.011765], + [0.015686, 0.015686, 0.015686], + [0.019608, 0.019608, 0.019608], + [0.023529, 0.023529, 0.023529], + [0.027451, 0.027451, 0.027451], + [0.031373, 0.031373, 0.031373], + [0.035294, 0.035294, 0.035294], + [0.039216, 0.039216, 0.039216], + [0.043137, 0.043137, 0.043137], + [0.047059, 0.047059, 0.047059], + [0.050980, 0.050980, 0.050980], + [0.054902, 0.054902, 0.054902], + [0.058824, 0.058824, 0.058824], + [0.062745, 0.062745, 0.062745], + [0.066667, 0.066667, 0.066667], + [0.070588, 0.070588, 0.070588], + [0.074510, 0.074510, 0.074510], + [0.078431, 0.078431, 0.078431], + [0.082353, 0.082353, 0.082353], + [0.086275, 0.086275, 0.086275], + [0.090196, 0.090196, 0.090196], + [0.094118, 0.094118, 0.094118], + [0.098039, 0.098039, 0.098039], + [0.101961, 0.101961, 0.101961], + [0.105882, 0.105882, 0.105882], + [0.109804, 0.109804, 0.109804], + [0.113725, 0.113725, 0.113725], + [0.117647, 0.117647, 0.117647], + [0.121569, 0.121569, 0.121569], + [0.125490, 0.125490, 0.125490], + [0.129412, 0.129412, 0.129412], + [0.133333, 0.133333, 0.133333], + [0.137255, 0.137255, 0.137255], + [0.141176, 0.141176, 0.141176], + [0.145098, 0.145098, 0.145098], + [0.149020, 0.149020, 0.149020], + [0.152941, 0.152941, 0.152941], + [0.156863, 0.156863, 0.156863], + [0.160784, 0.160784, 0.160784], + [0.164706, 0.164706, 0.164706], + [0.168627, 0.168627, 0.168627], + [0.172549, 0.172549, 0.172549], + [0.176471, 0.176471, 0.176471], + [0.180392, 0.180392, 0.180392], + [0.184314, 0.184314, 0.184314], + [0.188235, 0.188235, 0.188235], + [0.192157, 0.192157, 0.192157], + [0.196078, 0.196078, 0.196078], + [0.200000, 0.200000, 0.200000], + [0.203922, 0.203922, 0.203922], + [0.207843, 0.207843, 0.207843], + [0.211765, 0.211765, 0.211765], + [0.215686, 0.215686, 0.215686], + [0.219608, 0.219608, 0.219608], + [0.223529, 0.223529, 0.223529], + [0.227451, 0.227451, 0.227451], + [0.231373, 0.231373, 0.231373], + [0.235294, 0.235294, 0.235294], + [0.239216, 0.239216, 0.239216], + [0.243137, 0.243137, 0.243137], + [0.247059, 0.247059, 0.247059], + [0.250980, 0.250980, 0.250980], + [0.254902, 0.254902, 0.254902], + [0.258824, 0.258824, 0.258824], + [0.262745, 0.262745, 0.262745], + [0.266667, 0.266667, 0.266667], + [0.270588, 0.270588, 0.270588], + [0.274510, 0.274510, 0.274510], + [0.278431, 0.278431, 0.278431], + [0.282353, 0.282353, 0.282353], + [0.286275, 0.286275, 0.286275], + [0.290196, 0.290196, 0.290196], + [0.294118, 0.294118, 0.294118], + [0.298039, 0.298039, 0.298039], + [0.301961, 0.301961, 0.301961], + [0.305882, 0.305882, 0.305882], + [0.309804, 0.309804, 0.309804], + [0.313725, 0.313725, 0.313725], + [0.317647, 0.317647, 0.317647], + [0.321569, 0.321569, 0.321569], + [0.325490, 0.325490, 0.325490], + [0.329412, 0.329412, 0.329412], + [0.333333, 0.333333, 0.333333], + [0.337255, 0.337255, 0.337255], + [0.341176, 0.341176, 0.341176], + [0.345098, 0.345098, 0.345098], + [0.349020, 0.349020, 0.349020], + [0.352941, 0.352941, 0.352941], + [0.356863, 0.356863, 0.356863], + [0.360784, 0.360784, 0.360784], + [0.364706, 0.364706, 0.364706], + [0.368627, 0.368627, 0.368627], + [0.372549, 0.372549, 0.372549], + [0.376471, 0.376471, 0.376471], + [0.380392, 0.380392, 0.380392], + [0.384314, 0.384314, 0.384314], + [0.388235, 0.388235, 0.388235], + [0.392157, 0.392157, 0.392157], + [0.396078, 0.396078, 0.396078], + [0.400000, 0.400000, 0.400000], + [0.403922, 0.403922, 0.403922], + [0.407843, 0.407843, 0.407843], + [0.411765, 0.411765, 0.411765], + [0.415686, 0.415686, 0.415686], + [0.419608, 0.419608, 0.419608], + [0.423529, 0.423529, 0.423529], + [0.427451, 0.427451, 0.427451], + [0.431373, 0.431373, 0.431373], + [0.435294, 0.435294, 0.435294], + [0.439216, 0.439216, 0.439216], + [0.443137, 0.443137, 0.443137], + [0.447059, 0.447059, 0.447059], + [0.450980, 0.450980, 0.450980], + [0.454902, 0.454902, 0.454902], + [0.458824, 0.458824, 0.458824], + [0.462745, 0.462745, 0.462745], + [0.466667, 0.466667, 0.466667], + [0.470588, 0.470588, 0.470588], + [0.474510, 0.474510, 0.474510], + [0.478431, 0.478431, 0.478431], + [0.482353, 0.482353, 0.482353], + [0.486275, 0.486275, 0.486275], + [0.490196, 0.490196, 0.490196], + [0.494118, 0.494118, 0.494118], + [0.498039, 0.498039, 0.498039], + [0.501961, 0.501961, 0.501961], + [0.505882, 0.505882, 0.505882], + [0.509804, 0.509804, 0.509804], + [0.513725, 0.513725, 0.513725], + [0.517647, 0.517647, 0.517647], + [0.521569, 0.521569, 0.521569], + [0.525490, 0.525490, 0.525490], + [0.529412, 0.529412, 0.529412], + [0.533333, 0.533333, 0.533333], + [0.537255, 0.537255, 0.537255], + [0.541176, 0.541176, 0.541176], + [0.545098, 0.545098, 0.545098], + [0.549020, 0.549020, 0.549020], + [0.552941, 0.552941, 0.552941], + [0.556863, 0.556863, 0.556863], + [0.560784, 0.560784, 0.560784], + [0.564706, 0.564706, 0.564706], + [0.568627, 0.568627, 0.568627], + [0.572549, 0.572549, 0.572549], + [0.576471, 0.576471, 0.576471], + [0.580392, 0.580392, 0.580392], + [0.584314, 0.584314, 0.584314], + [0.588235, 0.588235, 0.588235], + [0.592157, 0.592157, 0.592157], + [0.596078, 0.596078, 0.596078], + [0.600000, 0.600000, 0.600000], + [0.603922, 0.603922, 0.603922], + [0.607843, 0.607843, 0.607843], + [0.611765, 0.611765, 0.611765], + [0.615686, 0.615686, 0.615686], + [0.619608, 0.619608, 0.619608], + [0.623529, 0.623529, 0.623529], + [0.627451, 0.627451, 0.627451], + [0.631373, 0.631373, 0.631373], + [0.635294, 0.635294, 0.635294], + [0.639216, 0.639216, 0.639216], + [0.643137, 0.643137, 0.643137], + [0.647059, 0.647059, 0.647059], + [0.650980, 0.650980, 0.650980], + [0.654902, 0.654902, 0.654902], + [0.658824, 0.658824, 0.658824], + [0.662745, 0.662745, 0.662745], + [0.666667, 0.666667, 0.666667], + [0.670588, 0.670588, 0.670588], + [0.674510, 0.674510, 0.674510], + [0.678431, 0.678431, 0.678431], + [0.682353, 0.682353, 0.682353], + [0.686275, 0.686275, 0.686275], + [0.690196, 0.690196, 0.690196], + [0.694118, 0.694118, 0.694118], + [0.698039, 0.698039, 0.698039], + [0.701961, 0.701961, 0.701961], + [0.705882, 0.705882, 0.705882], + [0.709804, 0.709804, 0.709804], + [0.713725, 0.713725, 0.713725], + [0.717647, 0.717647, 0.717647], + [0.721569, 0.721569, 0.721569], + [0.725490, 0.725490, 0.725490], + [0.729412, 0.729412, 0.729412], + [0.733333, 0.733333, 0.733333], + [0.737255, 0.737255, 0.737255], + [0.741176, 0.741176, 0.741176], + [0.745098, 0.745098, 0.745098], + [0.749020, 0.749020, 0.749020], + [0.752941, 0.752941, 0.752941], + [0.756863, 0.756863, 0.756863], + [0.760784, 0.760784, 0.760784], + [0.764706, 0.764706, 0.764706], + [0.768627, 0.768627, 0.768627], + [0.772549, 0.772549, 0.772549], + [0.776471, 0.776471, 0.776471], + [0.780392, 0.780392, 0.780392], + [0.784314, 0.784314, 0.784314], + [0.788235, 0.788235, 0.788235], + [0.792157, 0.792157, 0.792157], + [0.796078, 0.796078, 0.796078], + [0.800000, 0.800000, 0.800000], + [0.803922, 0.803922, 0.803922], + [0.807843, 0.807843, 0.807843], + [0.811765, 0.811765, 0.811765], + [0.815686, 0.815686, 0.815686], + [0.819608, 0.819608, 0.819608], + [0.823529, 0.823529, 0.823529], + [0.827451, 0.827451, 0.827451], + [0.831373, 0.831373, 0.831373], + [0.835294, 0.835294, 0.835294], + [0.839216, 0.839216, 0.839216], + [0.843137, 0.843137, 0.843137], + [0.847059, 0.847059, 0.847059], + [0.850980, 0.850980, 0.850980], + [0.854902, 0.854902, 0.854902], + [0.858824, 0.858824, 0.858824], + [0.862745, 0.862745, 0.862745], + [0.866667, 0.866667, 0.866667], + [0.870588, 0.870588, 0.870588], + [0.874510, 0.874510, 0.874510], + [0.878431, 0.878431, 0.878431], + [0.882353, 0.882353, 0.882353], + [0.886275, 0.886275, 0.886275], + [0.890196, 0.890196, 0.890196], + [0.894118, 0.894118, 0.894118], + [0.898039, 0.898039, 0.898039], + [0.901961, 0.901961, 0.901961], + [0.905882, 0.905882, 0.905882], + [0.909804, 0.909804, 0.909804], + [0.913725, 0.913725, 0.913725], + [0.917647, 0.917647, 0.917647], + [0.921569, 0.921569, 0.921569], + [0.925490, 0.925490, 0.925490], + [0.929412, 0.929412, 0.929412], + [0.933333, 0.933333, 0.933333], + [0.937255, 0.937255, 0.937255], + [0.941176, 0.941176, 0.941176], + [0.945098, 0.945098, 0.945098], + [0.949020, 0.949020, 0.949020], + [0.952941, 0.952941, 0.952941], + [0.956863, 0.956863, 0.956863], + [0.960784, 0.960784, 0.960784], + [0.964706, 0.964706, 0.964706], + [0.968627, 0.968627, 0.968627], + [0.972549, 0.972549, 0.972549], + [0.976471, 0.976471, 0.976471], + [0.980392, 0.980392, 0.980392], + [0.984314, 0.984314, 0.984314], + [0.988235, 0.988235, 0.988235], + [0.992157, 0.992157, 0.992157], + [0.996078, 0.996078, 0.996078], + [1.000000, 1.000000, 1.000000] +]; diff --git a/packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/index.js b/packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/index.js index 6d1259fe0..102282c94 100644 --- a/packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/index.js +++ b/packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/index.js @@ -7,6 +7,7 @@ // You may not use this file except in compliance with the License. import colors from "webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/colors.ts"; +import lodash from "webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/lodash.ts"; import markers from "webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/markers.ts"; import pointClouds from "webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/pointClouds.ts"; import readers from "webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/readers.ts"; @@ -15,11 +16,12 @@ import types from "webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/ import vectors from "webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/vectors.ts"; export default [ + { fileName: "colors.ts", sourceCode: colors }, + { fileName: "lodash.ts", sourceCode: lodash }, + { fileName: "markers.ts", sourceCode: markers }, { fileName: "pointClouds.ts", sourceCode: pointClouds }, { fileName: "readers.ts", sourceCode: readers }, { fileName: "time.ts", sourceCode: time }, { fileName: "types.ts", sourceCode: types }, { fileName: "vectors.ts", sourceCode: vectors }, - { fileName: "markers.ts", sourceCode: markers }, - { fileName: "colors.ts", sourceCode: colors }, ]; diff --git a/packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/lodash.test.ts b/packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/lodash.test.ts new file mode 100644 index 000000000..af16017b5 --- /dev/null +++ b/packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/lodash.test.ts @@ -0,0 +1,35 @@ +// +// Copyright (c) 2021-present, 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 { keyBy, groupBy, mapValues } from "./lodash"; + +const testData: { foo: string }[] = [{ foo: "1" }, { foo: "2" }, { foo: "3" }, { foo: "1" }]; + +describe("keyBy", () => { + it("retuns an object keyed by the given function", () => { + expect(keyBy(testData, ({ foo }) => foo)).toEqual({ + 1: { foo: "1" }, + 2: { foo: "2" }, + 3: { foo: "3" }, + }); + }); +}); + +describe("groupBy", () => { + it("returns an object of arrays keyed by the given function", () => { + expect(groupBy(testData, ({ foo }) => foo)).toEqual({ + 1: [{ foo: "1" }, { foo: "1" }], + 2: [{ foo: "2" }], + 3: [{ foo: "3" }], + }); + }); +}); + +describe("mapValues", () => { + it("maps values", () => { + expect(mapValues({ foo: 1, bar: 2 }, (num: number) => num + 1)).toEqual({ foo: 2, bar: 3 }); + }); +}); diff --git a/packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/lodash.ts b/packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/lodash.ts new file mode 100644 index 000000000..58269f1ea --- /dev/null +++ b/packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/userUtils/lodash.ts @@ -0,0 +1,33 @@ +// +// Copyright (c) 2021-present, 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 function keyBy(collection: T[], fn: (item: T) => string): Record { + const groups: Record = {}; + for (const item of collection) { + const key = fn(item); + groups[key] = item; + } + return groups; +} + +export function groupBy(collection: T[], fn: (item: T) => string): Record { + const groups: Record = {}; + for (const item of collection) { + const key = fn(item); + const existing = groups[key] || []; + groups[key] = [...existing, item]; + } + return groups; +} + +export function mapValues(obj: Record, fn: (val: I) => O): Record { + const result: Record = {}; + Object.keys(obj).forEach((key: string) => { + result[key] = fn(obj[key]); + }); + return result; +} diff --git a/packages/webviz-core/src/players/automatedRun/AutomatedRunPlayer.js b/packages/webviz-core/src/players/automatedRun/AutomatedRunPlayer.js index 3d0b2d3fb..26abce1ee 100644 --- a/packages/webviz-core/src/players/automatedRun/AutomatedRunPlayer.js +++ b/packages/webviz-core/src/players/automatedRun/AutomatedRunPlayer.js @@ -45,6 +45,12 @@ import { type TimestampMethod, } from "webviz-core/src/util/time"; +export type VideoMetadata = {| + startTimeMs: number, // time of first message in millis (inclusive) + endTimeMs: number, // time of last message in millis (inclusive) + msPerFrame: number, // millis per frame +|}; + export interface AutomatedRunClient { speed: number; msPerFrame: number; @@ -61,7 +67,7 @@ export interface AutomatedRunClient { markPreloadStart(): void; markPreloadEnd(): number; onFrameFinished(): Promise; - finish(): any; + finish(VideoMetadata): any; } export const AUTOMATED_RUN_START_DELAY = process.env.NODE_ENV === "test" ? 10 : 2000; @@ -398,7 +404,11 @@ export default class AutomatedRunPlayer implements Player { await this._client.onFrameFinished(); } - await this._client.finish(); + await this._client.finish({ + startTimeMs: toMillis(this._startTime), + endTimeMs: toMillis(endTime), + msPerFrame: this._msPerFrame, + }); const totalDuration = (Date.now() - startEpoch) / 1000; console.log(`AutomatedRunPlayer finished in ${formatSeconds(totalDuration)}`); } diff --git a/packages/webviz-core/src/players/automatedRun/videoRecordingClient.js b/packages/webviz-core/src/players/automatedRun/videoRecordingClient.js index 28228876e..6411e9259 100644 --- a/packages/webviz-core/src/players/automatedRun/videoRecordingClient.js +++ b/packages/webviz-core/src/players/automatedRun/videoRecordingClient.js @@ -8,6 +8,7 @@ import delay from "webviz-core/shared/delay"; import signal, { type Signal } from "webviz-core/shared/signal"; +import { type VideoMetadata } from "webviz-core/src/players/automatedRun/AutomatedRunPlayer"; // This is the interface between the video recording server (recordVideo.js) and // the client (whomever uses `videoRecordingClient`). The idea is that the server opens a webpage @@ -20,15 +21,26 @@ import signal, { type Signal } from "webviz-core/shared/signal"; // let screenshotResolve: ?() => void; -let finishedMsPerFrame: ?number; +let finishedVideoMetadata: ?VideoMetadata; let error: ?Error; let errorSignal: ?Signal; -export type VideoRecordingAction = { - action: "error" | "finish" | "screenshot", - error?: string, - msPerFrame?: number, +export type VideoRecordingFinishAction = { + action: "finish", + metadata: VideoMetadata, }; +export type VideoRecordingScreenshotAction = { + action: "screenshot", +}; + +export type VideoRecordingErrorAction = { + action: "error", + error: string, +}; +export type VideoRecordingAction = + | VideoRecordingFinishAction + | VideoRecordingScreenshotAction + | VideoRecordingErrorAction; window.videoRecording = { nextAction(): ?VideoRecordingAction { @@ -47,8 +59,8 @@ window.videoRecording = { } return payload; } - if (finishedMsPerFrame) { - return { action: "finish", msPerFrame: finishedMsPerFrame }; + if (finishedVideoMetadata) { + return { action: "finish", metadata: finishedVideoMetadata }; } if (screenshotResolve) { return { action: "screenshot" }; @@ -131,9 +143,9 @@ class VideoRecordingClient { }): Promise); } - finish() { - console.log("videoRecordingClient.finish()"); - finishedMsPerFrame = msPerFrame; + finish(metadata: VideoMetadata) { + console.log("videoRecordingClient.finish()", finishedVideoMetadata); + finishedVideoMetadata = metadata; } } diff --git a/packages/webviz-core/src/types/BinaryMessages.js b/packages/webviz-core/src/types/BinaryMessages.js index 8c79f76c8..1bc00b570 100644 --- a/packages/webviz-core/src/types/BinaryMessages.js +++ b/packages/webviz-core/src/types/BinaryMessages.js @@ -40,6 +40,19 @@ type Orientation = {| w(): number, |}; +export type BinaryTransformStamped = $ReadOnly<{| + header(): BinaryHeader, + child_frame_id(): string, + transform(): $ReadOnly<{| + rotation(): Orientation, + translation(): BinaryPoint, + |}>, +|}>; + +export type BinaryTfMessage = $ReadOnly<{| + transforms(): ArrayView, +|}>; + export type BinaryPose = $ReadOnly<{| position(): BinaryPoint, orientation(): Orientation, diff --git a/packages/webviz-core/src/util/datatypes.js b/packages/webviz-core/src/util/datatypes.js index 8e3638e10..49cba05b9 100644 --- a/packages/webviz-core/src/util/datatypes.js +++ b/packages/webviz-core/src/util/datatypes.js @@ -10,9 +10,9 @@ import type { MessageDefinitionsByTopic, ParsedMessageDefinitionsByTopic } from import type { RosDatatypes } from "webviz-core/src/types/RosDatatypes"; import { isComplex } from "webviz-core/src/util/binaryObjects/messageDefinitionUtils"; import { - FUTURE_VIZ_MSGS_DATATYPE, - WEBVIZ_MARKER_DATATYPE, - WEBVIZ_MARKER_ARRAY_DATATYPE, + FUTURE_VISUALIZATION_MSGS$WEBVIZ_MARKER_ARRAY, + VISUALIZATION_MSGS$WEBVIZ_MARKER, + VISUALIZATION_MSGS$WEBVIZ_MARKER_ARRAY, } from "webviz-core/src/util/globalConstants"; // Returns the subset of allDatatypes needed to fully define datatypes. @@ -79,26 +79,26 @@ const markerFields = [ ]; export const basicDatatypes: RosDatatypes = { - [FUTURE_VIZ_MSGS_DATATYPE]: { + [FUTURE_VISUALIZATION_MSGS$WEBVIZ_MARKER_ARRAY]: { fields: [ { type: "std_msgs/Header", name: "header", isArray: false, isComplex: true }, { isArray: true, isComplex: true, name: "allMarkers", - type: WEBVIZ_MARKER_DATATYPE, + type: VISUALIZATION_MSGS$WEBVIZ_MARKER, arrayLength: undefined, }, ], }, - [WEBVIZ_MARKER_ARRAY_DATATYPE]: { + [VISUALIZATION_MSGS$WEBVIZ_MARKER_ARRAY]: { fields: [ { isArray: true, isComplex: true, arrayLength: undefined, name: "markers", - type: WEBVIZ_MARKER_DATATYPE, + type: VISUALIZATION_MSGS$WEBVIZ_MARKER, }, { isArray: false, @@ -122,7 +122,7 @@ export const basicDatatypes: RosDatatypes = { "visualization_msgs/Marker": { fields: markerFields }, // This is a special marker type that has a string instead of an int ID field and an additional JSON "metadata" field. // For use internally to webviz, when we need to add extra data to markers. - [WEBVIZ_MARKER_DATATYPE]: { + [VISUALIZATION_MSGS$WEBVIZ_MARKER]: { fields: markerFields .filter(({ name }) => name !== "id") .concat([ diff --git a/packages/webviz-core/src/util/globalConstants.js b/packages/webviz-core/src/util/globalConstants.js index fd6691b13..8ea848957 100644 --- a/packages/webviz-core/src/util/globalConstants.js +++ b/packages/webviz-core/src/util/globalConstants.js @@ -36,26 +36,26 @@ export const ROSOUT_TOPIC = "/rosout"; export const SOCKET_KEY = "dataSource.websocket"; export const SECOND_SOURCE_PREFIX = "/webviz_source_2"; -export const GEOMETRY_MSGS_POLYGON_STAMPED_DATATYPE = "geometry_msgs/PolygonStamped"; -export const NAV_MSGS_OCCUPANCY_GRID_DATATYPE = "nav_msgs/OccupancyGrid"; -export const NAV_MSGS_PATH_DATATYPE = "nav_msgs/Path"; -export const POINT_CLOUD_DATATYPE = "sensor_msgs/PointCloud2"; -export const POSE_STAMPED_DATATYPE = "geometry_msgs/PoseStamped"; -export const SENSOR_MSGS_LASER_SCAN_DATATYPE = "sensor_msgs/LaserScan"; -export const WEBVIZ_MARKER_DATATYPE = "visualization_msgs/WebvizMarker"; -export const WEBVIZ_MARKER_ARRAY_DATATYPE = "visualization_msgs/WebvizMarkerArray"; -export const FUTURE_VIZ_MSGS_DATATYPE = "future_visualization_msgs/WebvizMarkerArray"; -export const TF_DATATYPE = "tf2_msgs/TFMessage"; -export const VISUALIZATION_MSGS_MARKER_DATATYPE = "visualization_msgs/Marker"; -export const VISUALIZATION_MSGS_MARKER_ARRAY_DATATYPE = "visualization_msgs/MarkerArray"; - -export const WEBVIZ_2D_ICON_ARRAY_DATATYPE = "webviz_icon_msgs/WebViz2dIconArray"; -export const WEBVIZ_3D_ICON_ARRAY_DATATYPE = "webviz_icon_msgs/WebViz3dIconArray"; +export const GEOMETRY_MSGS$POLYGON_STAMPED = "geometry_msgs/PolygonStamped"; +export const NAV_MSGS$OCCUPANCY_GRID = "nav_msgs/OccupancyGrid"; +export const NAV_MSGS$PATH = "nav_msgs/Path"; +export const SENSOR_MSGS$POINT_CLOUD_2 = "sensor_msgs/PointCloud2"; +export const GEOMETRY_MSGS$POSE_STAMPED = "geometry_msgs/PoseStamped"; +export const SENSOR_MSGS$LASER_SCAN = "sensor_msgs/LaserScan"; +export const VISUALIZATION_MSGS$WEBVIZ_MARKER = "visualization_msgs/WebvizMarker"; +export const VISUALIZATION_MSGS$WEBVIZ_MARKER_ARRAY = "visualization_msgs/WebvizMarkerArray"; +export const FUTURE_VISUALIZATION_MSGS$WEBVIZ_MARKER_ARRAY = "future_visualization_msgs/WebvizMarkerArray"; +export const TF2_MSGS$TF_MESSAGE = "tf2_msgs/TFMessage"; +export const VISUALIZATION_MSGS$MARKER = "visualization_msgs/Marker"; +export const VISUALIZATION_MSGS$MARKER_ARRAY = "visualization_msgs/MarkerArray"; + +export const WEBVIZ_ICON_MSGS$WEBVIZ_2D_ICON_ARRAY = "webviz_icon_msgs/WebViz2dIconArray"; +export const WEBVIZ_ICON_MSGS$WEBVIZ_3D_ICON_ARRAY = "webviz_icon_msgs/WebViz3dIconArray"; export const MARKER_ARRAY_DATATYPES = [ - "visualization_msgs/MarkerArray", - FUTURE_VIZ_MSGS_DATATYPE, - WEBVIZ_MARKER_ARRAY_DATATYPE, + VISUALIZATION_MSGS$MARKER_ARRAY, + FUTURE_VISUALIZATION_MSGS$WEBVIZ_MARKER_ARRAY, + VISUALIZATION_MSGS$WEBVIZ_MARKER_ARRAY, ]; export const USER_ERROR_PREFIX = "[WEBVIZ USER ERROR]"; @@ -105,8 +105,9 @@ export const MARKER_MSG_TYPES = { export const POSE_MARKER_SCALE = { x: 2, y: 2, z: 0.1 }; // Planning -export const MILES_PER_HOUR_TO_METERS_PER_SECOND = 0.44703; export const METERS_PER_SECOND_TO_MILES_PER_HOUR = 2.23694; +export const METERS_PER_SECOND_TO_KILOMETERS_PER_HOUR = 3.6; +export const MILES_PER_HOUR_TO_METERS_PER_SECOND = 1 / METERS_PER_SECOND_TO_MILES_PER_HOUR; export const jsonTreeTheme = { base00: "transparent", // bg @@ -126,4 +127,4 @@ export const PANEL_LAYOUT_ROOT_ID = "PanelLayout-root"; // Feature announcements export const FEATURE_ANNOUNCEMENTS_LOCAL_STORAGE_KEY = "webvizFeatureAnnouncements"; -export const TIMELINE_COMMENTING_ANNOUNCEMENT_KEY = "timelineCommenting"; +export const RECORDING_SERVICE_ANNOUNCEMENT_KEY = "recordingService"; diff --git a/packages/webviz-core/src/util/layout.js b/packages/webviz-core/src/util/layout.js index 6e59ca0ef..a1913958e 100644 --- a/packages/webviz-core/src/util/layout.js +++ b/packages/webviz-core/src/util/layout.js @@ -487,17 +487,19 @@ const stateKeyMap = { const layoutKeyMap = { direction: "d", first: "f", second: "se", row: "r", column: "c", splitPercentage: "sp" }; export const dictForPatchCompression = { ...layoutKeyMap, ...stateKeyMap }; +export function deflatePatch(jsObj: {}) { + const diffBuffer = Buffer.from(CBOR.encode(jsObj)); + const dictionaryBuffer = Buffer.from(CBOR.encode(dictForPatchCompression)); + return zlib.deflateSync(diffBuffer, { dictionary: dictionaryBuffer }).toString("base64"); +} + export function getUpdatedURLWithPatch(search: string, diff: string): string { // Return the original search directly if the diff is empty. if (!diff) { return search; } const params = new URLSearchParams(search); - - const diffBuffer = Buffer.from(CBOR.encode(JSON.parse(diff))); - const dictionaryBuffer = Buffer.from(CBOR.encode(dictForPatchCompression)); - const zlibPatch = zlib.deflateSync(diffBuffer, { dictionary: dictionaryBuffer }).toString("base64"); - + const zlibPatch = deflatePatch(JSON.parse(diff)); params.set(PATCH_QUERY_KEY, zlibPatch); return stringifyParams(params); } diff --git a/packages/webviz-core/src/util/logEvent.js b/packages/webviz-core/src/util/logEvent.js index 9fc64d037..b543e0b5e 100644 --- a/packages/webviz-core/src/util/logEvent.js +++ b/packages/webviz-core/src/util/logEvent.js @@ -122,7 +122,7 @@ function getQuery() { return location.search; } -type LogData = { +[string]: string | number | boolean | typeof undefined | null }; +type LogData = { +[string]: string | number | boolean | typeof undefined | null | LogData }; export function logEventAction(uniqueActionInfo: EventInfo, logData?: LogData) { if (isLogEventDisabled) { diff --git a/packages/webviz-core/src/util/quaternionFromEuler.js b/packages/webviz-core/src/util/quaternionFromEuler.js index 06cd5e214..56c6dcc57 100644 --- a/packages/webviz-core/src/util/quaternionFromEuler.js +++ b/packages/webviz-core/src/util/quaternionFromEuler.js @@ -23,3 +23,9 @@ export default function quaternionFromEuler({ x, y, z }: { x: number, y: number, w: c1 * c2 * c3 - s1 * s2 * s3, }; } + +export function quaternionFromRpy({ roll, pitch, yaw }: { roll: number, pitch: number, yaw: number }) { + // Adapted from https://github.com/iory/scikit-robot/blob/master/skrobot/coordinates/math.py#L857 + const { x, y, z, w } = quaternionFromEuler({ x: -roll, y: -pitch, z: -yaw }); + return { x: -x, y: -y, z: -z, w }; +} diff --git a/packages/webviz-core/src/util/rosDatatypesToMessageDefinitions.test.js b/packages/webviz-core/src/util/rosDatatypesToMessageDefinitions.test.js index 56ffa090b..bf75c178f 100644 --- a/packages/webviz-core/src/util/rosDatatypesToMessageDefinitions.test.js +++ b/packages/webviz-core/src/util/rosDatatypesToMessageDefinitions.test.js @@ -10,15 +10,18 @@ import { uniqBy } from "lodash"; import { basicDatatypes } from "./datatypes"; import rosDatatypesToMessageDefinition from "./rosDatatypesToMessageDefinition"; -import { WEBVIZ_MARKER_ARRAY_DATATYPE, WEBVIZ_MARKER_DATATYPE } from "webviz-core/src/util/globalConstants"; +import { + VISUALIZATION_MSGS$WEBVIZ_MARKER_ARRAY, + VISUALIZATION_MSGS$WEBVIZ_MARKER, +} from "webviz-core/src/util/globalConstants"; describe("rosDatatypesToMessageDefinition", () => { it(`Includes all of the definitions for "visualization_msgs/WebvizMarkerArray"`, () => { - expect(rosDatatypesToMessageDefinition(basicDatatypes, WEBVIZ_MARKER_ARRAY_DATATYPE)).toMatchSnapshot(); + expect(rosDatatypesToMessageDefinition(basicDatatypes, VISUALIZATION_MSGS$WEBVIZ_MARKER_ARRAY)).toMatchSnapshot(); }); it("produces a correct message definition", () => { - const definitions = rosDatatypesToMessageDefinition(basicDatatypes, WEBVIZ_MARKER_ARRAY_DATATYPE); + const definitions = rosDatatypesToMessageDefinition(basicDatatypes, VISUALIZATION_MSGS$WEBVIZ_MARKER_ARRAY); // Should have 1 definition without a name, the root datatype. expect(definitions.filter(({ name }) => !name).length).toEqual(1); // Should not duplicate definitions. @@ -27,14 +30,14 @@ describe("rosDatatypesToMessageDefinition", () => { it("Errors if it can't find the definition", () => { const datatypes = { - [WEBVIZ_MARKER_ARRAY_DATATYPE]: { + [VISUALIZATION_MSGS$WEBVIZ_MARKER_ARRAY]: { fields: [ { isArray: true, isComplex: true, arrayLength: undefined, name: "markers", - type: WEBVIZ_MARKER_DATATYPE, + type: VISUALIZATION_MSGS$WEBVIZ_MARKER, }, { isArray: false, @@ -45,7 +48,7 @@ describe("rosDatatypesToMessageDefinition", () => { ], }, }; - expect(() => rosDatatypesToMessageDefinition(datatypes, WEBVIZ_MARKER_ARRAY_DATATYPE)).toThrow( + expect(() => rosDatatypesToMessageDefinition(datatypes, VISUALIZATION_MSGS$WEBVIZ_MARKER_ARRAY)).toThrow( `While searching datatypes for "visualization_msgs/WebvizMarkerArray", could not find datatype "std_msgs/Header"` ); });