From e82d62968b4455e49f929e6e22444adc75c5797a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Tue, 14 Jul 2026 14:15:43 +0200 Subject: [PATCH 1/7] [FEATURE] Add canvas panel plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół [ENHANCEMENT] update panel schema re-exports to use @perses-dev/plugin-system panelEditorSchema and buildPanelEditorSchema have moved from @perses-dev/spec to @perses-dev/plugin-system. Signed-off-by: Adrian Sepiół --- canvas/.cjs.swcrc | 20 ++ canvas/.gitignore | 21 ++ canvas/.swcrc | 21 ++ canvas/README.md | 43 +++ canvas/cue.mod/module.cue | 13 + canvas/go.mod | 25 ++ canvas/go.sum | 27 ++ canvas/jest.config.ts | 23 ++ canvas/package.json | 60 ++++ canvas/rsbuild.config.ts | 38 +++ canvas/schemas/panels/canvas/canvas.cue | 97 ++++++ canvas/schemas/panels/canvas/canvas.json | 20 ++ canvas/sdk/go/canvas.go | 179 +++++++++++ canvas/src/Canvas.tsx | 24 ++ canvas/src/bootstrap.tsx | 18 ++ .../editor/BackgroundPropertiesPanel.tsx | 206 ++++++++++++ .../components/editor/ConnectionHandles.tsx | 53 ++++ canvas/src/components/editor/DragEdgeLine.tsx | 45 +++ .../components/editor/EdgePropertiesPanel.tsx | 279 ++++++++++++++++ canvas/src/components/editor/EditorCanvas.tsx | 300 ++++++++++++++++++ canvas/src/components/editor/EditorEdge.tsx | 110 +++++++ .../components/editor/EditorItemsPanel.tsx | 172 ++++++++++ canvas/src/components/editor/EditorNode.tsx | 64 ++++ canvas/src/components/editor/IconPreview.tsx | 27 ++ .../components/editor/NodePropertiesPanel.tsx | 235 ++++++++++++++ .../editor/SelectionBoundingBox.tsx | 67 ++++ .../editor/SelectionRectOverlay.tsx | 36 +++ canvas/src/components/panel/CanvasPanel.tsx | 124 ++++++++ .../src/components/panel/PanelEdgeLayer.tsx | 165 ++++++++++ .../src/components/panel/PanelNodeLayer.tsx | 75 +++++ .../src/components/panel/ThresholdLegend.tsx | 81 +++++ .../settings/EdgeThicknessSettings.tsx | 129 ++++++++ .../settings/GlobalSettingsEditor.tsx | 69 ++++ .../components/settings/LegendSettings.tsx | 63 ++++ .../src/components/shared/BackgroundLayer.tsx | 78 +++++ canvas/src/components/shared/EdgeLabel.tsx | 60 ++++ canvas/src/components/shared/EdgeLines.tsx | 148 +++++++++ canvas/src/components/shared/IconNode.tsx | 68 ++++ canvas/src/components/shared/NodeRenderer.tsx | 69 ++++ .../src/components/shared/RectangleNode.tsx | 99 ++++++ canvas/src/components/shared/TextNode.tsx | 59 ++++ canvas/src/contexts/EditorContext.tsx | 61 ++++ canvas/src/contexts/SpecContext.test.tsx | 183 +++++++++++ canvas/src/contexts/SpecContext.tsx | 180 +++++++++++ canvas/src/contexts/ZoomContext.tsx | 31 ++ canvas/src/env.d.ts | 14 + canvas/src/getPluginModule.ts | 30 ++ canvas/src/hooks/useCanvasTheme.ts | 49 +++ canvas/src/hooks/useEdgeConnect.test.tsx | 147 +++++++++ canvas/src/hooks/useEdgeConnect.ts | 221 +++++++++++++ canvas/src/hooks/useNodeMove.test.tsx | 131 ++++++++ canvas/src/hooks/useNodeMove.ts | 112 +++++++ canvas/src/hooks/useRectSelect.test.tsx | 93 ++++++ canvas/src/hooks/useRectSelect.ts | 90 ++++++ canvas/src/hooks/useResize.test.tsx | 131 ++++++++ canvas/src/hooks/useResize.ts | 203 ++++++++++++ canvas/src/hooks/useZoom.ts | 102 ++++++ canvas/src/index-federation.ts | 14 + canvas/src/index.ts | 14 + canvas/src/model.test.ts | 52 +++ canvas/src/model.ts | 106 +++++++ canvas/src/setup-tests.ts | 17 + canvas/src/test-utils/hookWrapper.tsx | 90 ++++++ canvas/src/utils/edgeUtils.test.ts | 212 +++++++++++++ canvas/src/utils/edgeUtils.ts | 110 +++++++ canvas/src/utils/editorReducer.test.ts | 79 +++++ canvas/src/utils/editorReducer.ts | 73 +++++ canvas/src/utils/editorStyles.ts | 78 +++++ canvas/src/utils/generateId.ts | 23 ++ canvas/src/utils/icons.ts | 147 +++++++++ canvas/src/utils/labelPosition.test.ts | 69 ++++ canvas/src/utils/labelPosition.ts | 47 +++ canvas/src/utils/panelUtils.test.ts | 145 +++++++++ canvas/src/utils/panelUtils.ts | 79 +++++ canvas/src/utils/resizeUtils.test.ts | 79 +++++ canvas/src/utils/resizeUtils.ts | 91 ++++++ canvas/src/utils/selectionUtils.test.ts | 62 ++++ canvas/src/utils/selectionUtils.ts | 27 ++ canvas/tsconfig.build.json | 9 + canvas/tsconfig.json | 23 ++ package.json | 1 + .../PyroscopeProfileQuery.ts | 14 +- 82 files changed, 6939 insertions(+), 10 deletions(-) create mode 100644 canvas/.cjs.swcrc create mode 100644 canvas/.gitignore create mode 100644 canvas/.swcrc create mode 100644 canvas/README.md create mode 100644 canvas/cue.mod/module.cue create mode 100644 canvas/go.mod create mode 100644 canvas/go.sum create mode 100644 canvas/jest.config.ts create mode 100644 canvas/package.json create mode 100644 canvas/rsbuild.config.ts create mode 100644 canvas/schemas/panels/canvas/canvas.cue create mode 100644 canvas/schemas/panels/canvas/canvas.json create mode 100644 canvas/sdk/go/canvas.go create mode 100644 canvas/src/Canvas.tsx create mode 100644 canvas/src/bootstrap.tsx create mode 100644 canvas/src/components/editor/BackgroundPropertiesPanel.tsx create mode 100644 canvas/src/components/editor/ConnectionHandles.tsx create mode 100644 canvas/src/components/editor/DragEdgeLine.tsx create mode 100644 canvas/src/components/editor/EdgePropertiesPanel.tsx create mode 100644 canvas/src/components/editor/EditorCanvas.tsx create mode 100644 canvas/src/components/editor/EditorEdge.tsx create mode 100644 canvas/src/components/editor/EditorItemsPanel.tsx create mode 100644 canvas/src/components/editor/EditorNode.tsx create mode 100644 canvas/src/components/editor/IconPreview.tsx create mode 100644 canvas/src/components/editor/NodePropertiesPanel.tsx create mode 100644 canvas/src/components/editor/SelectionBoundingBox.tsx create mode 100644 canvas/src/components/editor/SelectionRectOverlay.tsx create mode 100644 canvas/src/components/panel/CanvasPanel.tsx create mode 100644 canvas/src/components/panel/PanelEdgeLayer.tsx create mode 100644 canvas/src/components/panel/PanelNodeLayer.tsx create mode 100644 canvas/src/components/panel/ThresholdLegend.tsx create mode 100644 canvas/src/components/settings/EdgeThicknessSettings.tsx create mode 100644 canvas/src/components/settings/GlobalSettingsEditor.tsx create mode 100644 canvas/src/components/settings/LegendSettings.tsx create mode 100644 canvas/src/components/shared/BackgroundLayer.tsx create mode 100644 canvas/src/components/shared/EdgeLabel.tsx create mode 100644 canvas/src/components/shared/EdgeLines.tsx create mode 100644 canvas/src/components/shared/IconNode.tsx create mode 100644 canvas/src/components/shared/NodeRenderer.tsx create mode 100644 canvas/src/components/shared/RectangleNode.tsx create mode 100644 canvas/src/components/shared/TextNode.tsx create mode 100644 canvas/src/contexts/EditorContext.tsx create mode 100644 canvas/src/contexts/SpecContext.test.tsx create mode 100644 canvas/src/contexts/SpecContext.tsx create mode 100644 canvas/src/contexts/ZoomContext.tsx create mode 100644 canvas/src/env.d.ts create mode 100644 canvas/src/getPluginModule.ts create mode 100644 canvas/src/hooks/useCanvasTheme.ts create mode 100644 canvas/src/hooks/useEdgeConnect.test.tsx create mode 100644 canvas/src/hooks/useEdgeConnect.ts create mode 100644 canvas/src/hooks/useNodeMove.test.tsx create mode 100644 canvas/src/hooks/useNodeMove.ts create mode 100644 canvas/src/hooks/useRectSelect.test.tsx create mode 100644 canvas/src/hooks/useRectSelect.ts create mode 100644 canvas/src/hooks/useResize.test.tsx create mode 100644 canvas/src/hooks/useResize.ts create mode 100644 canvas/src/hooks/useZoom.ts create mode 100644 canvas/src/index-federation.ts create mode 100644 canvas/src/index.ts create mode 100644 canvas/src/model.test.ts create mode 100644 canvas/src/model.ts create mode 100644 canvas/src/setup-tests.ts create mode 100644 canvas/src/test-utils/hookWrapper.tsx create mode 100644 canvas/src/utils/edgeUtils.test.ts create mode 100644 canvas/src/utils/edgeUtils.ts create mode 100644 canvas/src/utils/editorReducer.test.ts create mode 100644 canvas/src/utils/editorReducer.ts create mode 100644 canvas/src/utils/editorStyles.ts create mode 100644 canvas/src/utils/generateId.ts create mode 100644 canvas/src/utils/icons.ts create mode 100644 canvas/src/utils/labelPosition.test.ts create mode 100644 canvas/src/utils/labelPosition.ts create mode 100644 canvas/src/utils/panelUtils.test.ts create mode 100644 canvas/src/utils/panelUtils.ts create mode 100644 canvas/src/utils/resizeUtils.test.ts create mode 100644 canvas/src/utils/resizeUtils.ts create mode 100644 canvas/src/utils/selectionUtils.test.ts create mode 100644 canvas/src/utils/selectionUtils.ts create mode 100644 canvas/tsconfig.build.json create mode 100644 canvas/tsconfig.json diff --git a/canvas/.cjs.swcrc b/canvas/.cjs.swcrc new file mode 100644 index 000000000..2ed65083d --- /dev/null +++ b/canvas/.cjs.swcrc @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "jsc": { + "parser": { + "syntax": "typescript", + "tsx": true + }, + "target": "es2022", + "transform": { + "react": { + "runtime": "automatic", + "useBuiltins": true + } + } + }, + "module": { + "type": "commonjs" + }, + "exclude": ["\\.(stories|test)\\."] +} diff --git a/canvas/.gitignore b/canvas/.gitignore new file mode 100644 index 000000000..fe8baa8ba --- /dev/null +++ b/canvas/.gitignore @@ -0,0 +1,21 @@ +.idea/ + +# Local +.DS_Store +*.local +*.log* + +# Dist +node_modules +dist/ + +# IDE +.vscode/* +!.vscode/extensions.json +.idea + +# generated archives +*.tar.gz + +# external CUE dependencies +/*/cue.mod/pkg/ diff --git a/canvas/.swcrc b/canvas/.swcrc new file mode 100644 index 000000000..feaf67637 --- /dev/null +++ b/canvas/.swcrc @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "jsc": { + "parser": { + "syntax": "typescript", + "tsx": true + }, + "target": "es2022", + "transform": { + "react": { + "runtime": "automatic", + "useBuiltins": true + } + } + }, + "module": { + "type": "es6" + }, + "sourceMaps": true, + "exclude": ["\\.(stories|test)\\."] +} diff --git a/canvas/README.md b/canvas/README.md new file mode 100644 index 000000000..98ac5e3a6 --- /dev/null +++ b/canvas/README.md @@ -0,0 +1,43 @@ +# Plugin Module: canvas + +### How to install + +This plugin requires react and react-dom 18 + +Install peer dependencies: + +```bash +npm install react@18 react-dom@18 +``` + +Install the plugin: + +```bash +npm install @my-org/canvas-plugin +``` + +The Perses UI packages your plugin depends on are now maintained in the [`perses/shared`](https://github.com/perses/shared) repository. If you need to develop against local copies of those packages, follow the linking instructions in that repo. + +## Development + +### Setup + +Install dependencies: + +```bash +npm install +``` + +### Get Started + +Start the dev server: + +```bash +npm run dev +``` + +Build the plugin for distribution: + +```bash +npm run build +``` diff --git a/canvas/cue.mod/module.cue b/canvas/cue.mod/module.cue new file mode 100644 index 000000000..3c28b057b --- /dev/null +++ b/canvas/cue.mod/module.cue @@ -0,0 +1,13 @@ +module: "github.com/perses/plugins/canvas@v0" +language: { + version: "v0.12.0" +} +source: { + kind: "git" +} +deps: { + "github.com/perses/shared/cue@v0": { + v: "v0.53.1" + default: true + } +} diff --git a/canvas/go.mod b/canvas/go.mod new file mode 100644 index 000000000..eae5d79f3 --- /dev/null +++ b/canvas/go.mod @@ -0,0 +1,25 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +module github.com/perses/plugins/canvas + +go 1.26.2 + +require github.com/perses/perses v0.54.0-beta.2 + +require ( + github.com/kr/pretty v0.3.1 // indirect + github.com/perses/spec v0.2.0-beta.6 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/canvas/go.sum b/canvas/go.sum new file mode 100644 index 000000000..8d61612b1 --- /dev/null +++ b/canvas/go.sum @@ -0,0 +1,27 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/perses/perses v0.54.0-beta.2 h1:0okxiICGuBdSD7IlCL3zgTE6fIzx/MbEBlL/DwES/94= +github.com/perses/perses v0.54.0-beta.2/go.mod h1:TxPV2XVxR4hs6Q6RSc+Bqqy//nUyVEN94vWtt2hjpN4= +github.com/perses/spec v0.2.0-beta.6 h1:QppHTJudgwChcsp+miJRPKCe6QsIst3UwxBFuBBlWm0= +github.com/perses/spec v0.2.0-beta.6/go.mod h1:hV2Hojz3oYF/3Trvsy4E7BE5bP1V6e1/T2ExtHwRfkw= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/canvas/jest.config.ts b/canvas/jest.config.ts new file mode 100644 index 000000000..31a6238d6 --- /dev/null +++ b/canvas/jest.config.ts @@ -0,0 +1,23 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Config } from '@jest/types'; +import shared from '../jest.shared'; + +const jestConfig: Config.InitialOptions = { + ...shared, + + setupFilesAfterEnv: [...(shared.setupFilesAfterEnv ?? []), '/src/setup-tests.ts'], +}; + +export default jestConfig; diff --git a/canvas/package.json b/canvas/package.json new file mode 100644 index 000000000..103e7e6f4 --- /dev/null +++ b/canvas/package.json @@ -0,0 +1,60 @@ +{ + "name": "@perses-dev/canvas-plugin", + "version": "0.1.0", + "scripts": { + "dev": "rsbuild dev", + "build": "npm run build-mf && concurrently \"npm:build:*\"", + "build-mf": "rsbuild build", + "build:cjs": "swc ./src -d dist/lib/cjs --strip-leading-paths --config-file .cjs.swcrc", + "build:esm": "swc ./src -d dist/lib --strip-leading-paths --config-file .swcrc", + "build:types": "tsc --project tsconfig.build.json", + "lint": "eslint src --ext .ts,.tsx", + "test": "cross-env LC_ALL=C TZ=UTC jest", + "type-check": "tsc --noEmit" + }, + "main": "lib/cjs/index.js", + "module": "lib/index.js", + "types": "lib/index.d.ts", + "peerDependencies": { + "@emotion/react": "^11.7.1", + "@emotion/styled": "^11.6.0", + "@hookform/resolvers": "^3.2.0", + "@perses-dev/components": "^0.54.0-beta.3", + "@perses-dev/plugin-system": "^0.54.0-beta.3", + "@perses-dev/spec": "^0.2.0-beta.2", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "echarts": "5.5.0", + "immer": "^10.1.1", + "react": "^17.0.2 || ^18.0.0", + "react-dom": "^17.0.2 || ^18.0.0", + "use-resize-observer": "^9.0.0" + }, + "files": [ + "lib/**/*", + "__mf/**/*", + "mf-manifest.json", + "mf-stats.json" + ], + "perses": { + "plugins": [ + { + "kind": "Panel", + "spec": { + "display": { + "name": "Canvas" + }, + "name": "Canvas" + } + } + ] + }, + "dependencies": { + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + }, + "devDependencies": { + "@types/d3-selection": "^3.0.11", + "@types/d3-zoom": "^3.0.8" + } +} diff --git a/canvas/rsbuild.config.ts b/canvas/rsbuild.config.ts new file mode 100644 index 000000000..d3d8c7742 --- /dev/null +++ b/canvas/rsbuild.config.ts @@ -0,0 +1,38 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { pluginReact } from '@rsbuild/plugin-react'; +import { createConfigForPlugin } from '../rsbuild.shared'; + +export default createConfigForPlugin({ + name: 'Canvas', + rsbuildConfig: { + server: { port: 3033 }, + plugins: [pluginReact()], + }, + moduleFederation: { + exposes: [{ './Canvas': './src/Canvas.tsx' }], + shared: { + react: { requiredVersion: '18.2.0', singleton: true }, + 'react-dom': { requiredVersion: '18.2.0', singleton: true }, + echarts: { singleton: true }, + 'date-fns': { singleton: true }, + 'date-fns-tz': { singleton: true }, + '@perses-dev/components': { singleton: true }, + '@perses-dev/plugin-system': { singleton: true }, + '@emotion/react': { requiredVersion: '^11.11.3', singleton: true }, + '@emotion/styled': { singleton: true }, + '@hookform/resolvers': { singleton: true }, + }, + }, +}); diff --git a/canvas/schemas/panels/canvas/canvas.cue b/canvas/schemas/panels/canvas/canvas.cue new file mode 100644 index 000000000..c342df728 --- /dev/null +++ b/canvas/schemas/panels/canvas/canvas.cue @@ -0,0 +1,97 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import ( + "github.com/perses/shared/cue/common" +) + +kind: "Canvas" +spec: close({ + legend?: #legend + thresholds?: common.#thresholds + format?: common.#format + querySettings?: #querySettings + edgeDefaultStrokeWidth?: number & >0 + edgeThresholdWidths?: [...#edgeThresholdStep] + backgrounds?: [...#background] + nodes?: [...#node] + edges?: [...#edge] +}) + +#legend: { + position: "bottom" | "right" +} + +#querySettings: [...{ + queryIndex: int & >=0 + colorMode: "fixed" | "fixed-single" + colorValue: =~"^#(?:[0-9a-fA-F]{3}){1,2}$" // hexadecimal color code +}] + +#background: { + id: string + name?: string + x: number + y: number + width: number & >0 + height: number & >0 + color?: =~"^#(?:[0-9a-fA-F]{3}){1,2}$" + opacity?: number & >=0 & <=1 + image?: string + imageFit?: "cover" | "contain" | "stretch" + global?: bool +} + +#node: { + id: string + x: number + y: number + width: number & >0 + height: number & >0 + kind: "rectangle" | "icon" | "text" + label?: string + labelPosition?: "above" | "below" | "left" | "right" | "center" + labelPadding?: number & >=0 + icon?: string + link?: string + background?: =~"^#(?:[0-9a-fA-F]{3}){1,2}$" + backgroundImage?: string + queryIndex?: int & >=0 + colorMode?: "threshold" | "fixed" + color?: =~"^#(?:[0-9a-fA-F]{3}){1,2}$" +} + +#edge: { + id: string + name?: string + source: string + target: string + sourceAnchor?: "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se" + targetAnchor?: "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se" + x2?: number + y2?: number + bidirectional?: bool + thicknessMode?: "fixed" | "threshold" + strokeWidth?: number & >0 + sourceQueryIndex?: int & >=0 + targetQueryIndex?: int & >=0 + sourceLabelTemplate?: string + targetLabelTemplate?: string +} + +#edgeThresholdStep: { + value: number + strokeWidth: number & >0 +} diff --git a/canvas/schemas/panels/canvas/canvas.json b/canvas/schemas/panels/canvas/canvas.json new file mode 100644 index 000000000..400890bea --- /dev/null +++ b/canvas/schemas/panels/canvas/canvas.json @@ -0,0 +1,20 @@ +{ + "kind": "Canvas", + "spec": { + "legend": { + "position": "bottom" + }, + "thresholds": { + "steps": [ + { + "value": 0.6, + "name": "Alert: Warning condition example" + }, + { + "value": 0.8, + "name": "Alert: Critical condition example" + } + ] + } + } +} diff --git a/canvas/sdk/go/canvas.go b/canvas/sdk/go/canvas.go new file mode 100644 index 000000000..8df1f0dec --- /dev/null +++ b/canvas/sdk/go/canvas.go @@ -0,0 +1,179 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package canvas + +import ( + "github.com/perses/perses/go-sdk/common" + "github.com/perses/perses/go-sdk/panel" +) + +const PluginKind = "Canvas" + +type LabelPosition string + +const ( + AbovePosition LabelPosition = "above" + BelowPosition LabelPosition = "below" + LeftPosition LabelPosition = "left" + RightPosition LabelPosition = "right" + CenterPosition LabelPosition = "center" +) + +type AnchorPoint string + +const ( + AnchorN AnchorPoint = "n" + AnchorS AnchorPoint = "s" + AnchorE AnchorPoint = "e" + AnchorW AnchorPoint = "w" + AnchorNW AnchorPoint = "nw" + AnchorNE AnchorPoint = "ne" + AnchorSW AnchorPoint = "sw" + AnchorSE AnchorPoint = "se" +) + +type NodeKind string + +const ( + RectangleKind NodeKind = "rectangle" + IconKind NodeKind = "icon" + TextKind NodeKind = "text" +) + +type ColorMode string + +const ( + ThresholdColorMode ColorMode = "threshold" + FixedColorMode ColorMode = "fixed" +) + +type ThicknessMode string + +const ( + FixedThicknessMode ThicknessMode = "fixed" + ThresholdThicknessMode ThicknessMode = "threshold" +) + +type ImageFit string + +const ( + CoverImageFit ImageFit = "cover" + ContainImageFit ImageFit = "contain" + StretchImageFit ImageFit = "stretch" +) + +type NodeSpec struct { + ID string `json:"id" yaml:"id"` + X float64 `json:"x" yaml:"x"` + Y float64 `json:"y" yaml:"y"` + Width float64 `json:"width" yaml:"width"` + Height float64 `json:"height" yaml:"height"` + Kind NodeKind `json:"kind" yaml:"kind"` + Label string `json:"label,omitempty" yaml:"label,omitempty"` + LabelPosition LabelPosition `json:"labelPosition,omitempty" yaml:"labelPosition,omitempty"` + LabelPadding float64 `json:"labelPadding,omitempty" yaml:"labelPadding,omitempty"` + Icon string `json:"icon,omitempty" yaml:"icon,omitempty"` + Link string `json:"link,omitempty" yaml:"link,omitempty"` + Background string `json:"background,omitempty" yaml:"background,omitempty"` + BackgroundImage string `json:"backgroundImage,omitempty" yaml:"backgroundImage,omitempty"` + QueryIndex *uint `json:"queryIndex,omitempty" yaml:"queryIndex,omitempty"` + ColorMode ColorMode `json:"colorMode,omitempty" yaml:"colorMode,omitempty"` + Color string `json:"color,omitempty" yaml:"color,omitempty"` +} + +type EdgeSpec struct { + ID string `json:"id" yaml:"id"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Source string `json:"source" yaml:"source"` + Target string `json:"target" yaml:"target"` + SourceAnchor AnchorPoint `json:"sourceAnchor,omitempty" yaml:"sourceAnchor,omitempty"` + TargetAnchor AnchorPoint `json:"targetAnchor,omitempty" yaml:"targetAnchor,omitempty"` + X2 *float64 `json:"x2,omitempty" yaml:"x2,omitempty"` + Y2 *float64 `json:"y2,omitempty" yaml:"y2,omitempty"` + Bidirectional bool `json:"bidirectional,omitempty" yaml:"bidirectional,omitempty"` + ThicknessMode ThicknessMode `json:"thicknessMode,omitempty" yaml:"thicknessMode,omitempty"` + StrokeWidth *float64 `json:"strokeWidth,omitempty" yaml:"strokeWidth,omitempty"` + SourceQueryIndex *uint `json:"sourceQueryIndex,omitempty" yaml:"sourceQueryIndex,omitempty"` + TargetQueryIndex *uint `json:"targetQueryIndex,omitempty" yaml:"targetQueryIndex,omitempty"` + SourceLabelTemplate string `json:"sourceLabelTemplate,omitempty" yaml:"sourceLabelTemplate,omitempty"` + TargetLabelTemplate string `json:"targetLabelTemplate,omitempty" yaml:"targetLabelTemplate,omitempty"` +} + +type BackgroundSpec struct { + ID string `json:"id" yaml:"id"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + X float64 `json:"x" yaml:"x"` + Y float64 `json:"y" yaml:"y"` + Width float64 `json:"width" yaml:"width"` + Height float64 `json:"height" yaml:"height"` + Color string `json:"color,omitempty" yaml:"color,omitempty"` + Opacity *float64 `json:"opacity,omitempty" yaml:"opacity,omitempty"` + Image string `json:"image,omitempty" yaml:"image,omitempty"` + ImageFit ImageFit `json:"imageFit,omitempty" yaml:"imageFit,omitempty"` + Global bool `json:"global,omitempty" yaml:"global,omitempty"` +} + +type EdgeThresholdStep struct { + Value float64 `json:"value" yaml:"value"` + StrokeWidth float64 `json:"strokeWidth" yaml:"strokeWidth"` +} + +type QueryColorSettings struct { + QueryIndex uint `json:"queryIndex" yaml:"queryIndex"` + ColorMode ColorMode `json:"colorMode" yaml:"colorMode"` + ColorValue string `json:"colorValue" yaml:"colorValue"` +} + +type PluginSpec struct { + Thresholds *common.Thresholds `json:"thresholds,omitempty" yaml:"thresholds,omitempty"` + Format *common.Format `json:"format,omitempty" yaml:"format,omitempty"` + EdgeThresholdWidths []EdgeThresholdStep `json:"edgeThresholdWidths,omitempty" yaml:"edgeThresholdWidths,omitempty"` + EdgeDefaultStrokeWidth *float64 `json:"edgeDefaultStrokeWidth,omitempty" yaml:"edgeDefaultStrokeWidth,omitempty"` + QuerySettings []QueryColorSettings `json:"querySettings,omitempty" yaml:"querySettings,omitempty"` + Backgrounds []BackgroundSpec `json:"backgrounds,omitempty" yaml:"backgrounds,omitempty"` + Nodes []NodeSpec `json:"nodes,omitempty" yaml:"nodes,omitempty"` + Edges []EdgeSpec `json:"edges,omitempty" yaml:"edges,omitempty"` +} + +type Option func(plugin *Builder) error + +type Builder struct { + PluginSpec `json:",inline" yaml:",inline"` +} + +func create(options ...Option) (Builder, error) { + builder := &Builder{ + PluginSpec: PluginSpec{}, + } + + for _, opt := range options { + if err := opt(builder); err != nil { + return *builder, err + } + } + + return *builder, nil +} + +func Chart(options ...Option) panel.Option { + return func(builder *panel.Builder) error { + plugin, err := create(options...) + if err != nil { + return err + } + builder.Spec.Plugin.Kind = PluginKind + builder.Spec.Plugin.Spec = plugin.PluginSpec + return nil + } +} diff --git a/canvas/src/Canvas.tsx b/canvas/src/Canvas.tsx new file mode 100644 index 000000000..1f50377d2 --- /dev/null +++ b/canvas/src/Canvas.tsx @@ -0,0 +1,24 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { PanelPlugin } from '@perses-dev/plugin-system'; +import { CanvasPanel } from './components/panel/CanvasPanel'; +import { CanvasSpec, CanvasProps } from './model'; +import { GlobalSettingsEditor } from './components/settings/GlobalSettingsEditor'; + +export const Canvas: PanelPlugin = { + PanelComponent: CanvasPanel, + panelOptionsEditorComponents: [{ label: 'Settings', content: GlobalSettingsEditor }], + supportedQueryTypes: ['TimeSeriesQuery'], + createInitialOptions: () => ({}), +}; diff --git a/canvas/src/bootstrap.tsx b/canvas/src/bootstrap.tsx new file mode 100644 index 000000000..33fada8aa --- /dev/null +++ b/canvas/src/bootstrap.tsx @@ -0,0 +1,18 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import ReactDOM from 'react-dom/client'; + +const root = ReactDOM.createRoot(document.getElementById('root')!); +root.render(); diff --git a/canvas/src/components/editor/BackgroundPropertiesPanel.tsx b/canvas/src/components/editor/BackgroundPropertiesPanel.tsx new file mode 100644 index 000000000..ec90a9fb3 --- /dev/null +++ b/canvas/src/components/editor/BackgroundPropertiesPanel.tsx @@ -0,0 +1,206 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React, { ReactElement, useCallback } from 'react'; +import { + Box, + Checkbox, + FormControl, + FormControlLabel, + IconButton, + InputLabel, + MenuItem, + Select, + Slider, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import ArrowUpIcon from 'mdi-material-ui/ArrowUp'; +import ArrowDownIcon from 'mdi-material-ui/ArrowDown'; +import { OptionsColorPicker } from '@perses-dev/components'; +import { BackgroundSpec, CanvasSpec } from '../../model'; +import { useCanvasTheme } from '../../hooks/useCanvasTheme'; +import { useSpecContext } from '../../contexts/SpecContext'; + +interface BackgroundPropertiesPanelProps { + background: BackgroundSpec; + onChange: (updated: BackgroundSpec) => void; +} + +export function BackgroundPropertiesPanel({ background, onChange }: BackgroundPropertiesPanelProps): ReactElement { + const { nodeDefaultFill } = useCanvasTheme(); + const { spec, moveBackground } = useSpecContext(); + + const backgrounds: CanvasSpec['backgrounds'] = spec.backgrounds ?? []; + const idx = backgrounds.findIndex((bg) => bg.id === background.id); + + const onIntFieldChange = useCallback( + (key: 'x' | 'y' | 'width' | 'height', min = -Infinity) => + (e: React.ChangeEvent): void => { + const v = e.target.valueAsNumber; + if (Number.isFinite(v) && v >= min) { + onChange({ ...background, [key]: v }); + } + }, + [background, onChange] + ); + + const IMAGE_FIT_OPTIONS: Array = ['cover', 'contain', 'stretch']; + + function parseImageFit(value: string): BackgroundSpec['imageFit'] { + return IMAGE_FIT_OPTIONS.includes(value as BackgroundSpec['imageFit']) + ? (value as BackgroundSpec['imageFit']) + : undefined; + } + + return ( + + + + Background properties + + + + moveBackground(background.id, 'up')}> + + + + + + + = backgrounds.length - 1} + onClick={() => moveBackground(background.id, 'down')} + > + + + + + + + onChange({ ...background, global: e.target.checked || undefined })} + /> + } + label="Global (fit panel)" + /> + + onChange({ ...background, name: e.target.value || undefined })} + placeholder={background.id} + /> + + + + + + + + + + + Color + + + onChange({ ...background, color })} + onClear={() => onChange({ ...background, color: undefined })} + /> + + + Opacity + + onChange({ ...background, opacity: Array.isArray(v) ? v[0] : v })} + valueLabelDisplay="auto" + valueLabelFormat={(v) => `${Math.round(v * 100)}%`} + sx={{ pr: 2 }} + /> + + + + onChange({ ...background, image: e.target.value || undefined })} + sx={{ flex: 1 }} + /> + + Image fit + + label="Image fit" + value={background.imageFit ?? 'cover'} + onChange={(e) => onChange({ ...background, imageFit: parseImageFit(e.target.value ?? '') })} + MenuProps={{ PaperProps: { style: { maxHeight: 240 } } }} + > + Cover + Contain + Stretch + + + + + ); +} diff --git a/canvas/src/components/editor/ConnectionHandles.tsx b/canvas/src/components/editor/ConnectionHandles.tsx new file mode 100644 index 000000000..f97c7935a --- /dev/null +++ b/canvas/src/components/editor/ConnectionHandles.tsx @@ -0,0 +1,53 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ReactElement } from 'react'; +import { NodeSpec, AnchorPoint } from '../../model'; +import { ANCHOR_KEYS, anchorPosition } from '../../utils/edgeUtils'; +import { useCanvasTheme } from '../../hooks/useCanvasTheme'; + +const CROSS_LENGTH = 8; + +interface ConnectionHandlesProps { + node: NodeSpec; + onDragStart: (anchor: AnchorPoint, x: number, y: number) => void; +} + +export function ConnectionHandles({ node, onDragStart }: ConnectionHandlesProps): ReactElement { + const { connection } = useCanvasTheme(); + const armLen = CROSS_LENGTH; + + return ( + <> + {ANCHOR_KEYS.map((anchor) => { + const pos = anchorPosition(node, anchor); + return ( + { + event.stopPropagation(); + onDragStart(anchor, pos.x, pos.y); + }} + > + + + + + + ); + })} + + ); +} diff --git a/canvas/src/components/editor/DragEdgeLine.tsx b/canvas/src/components/editor/DragEdgeLine.tsx new file mode 100644 index 000000000..0b885f6a3 --- /dev/null +++ b/canvas/src/components/editor/DragEdgeLine.tsx @@ -0,0 +1,45 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ReactElement } from 'react'; +import { DragEdge } from '../../hooks/useEdgeConnect'; +import { editorStyles } from '../../utils/editorStyles'; +import { useCanvasTheme } from '../../hooks/useCanvasTheme'; +import { useZoomContext } from '../../contexts/ZoomContext'; +import { EdgeLines } from '../shared/EdgeLines'; + +const NS_PREFIX = 'wm-drag-edge'; + +interface DragEdgeLineProps { + dragEdge: DragEdge; +} + +export function DragEdgeLine({ dragEdge }: DragEdgeLineProps): ReactElement { + const { + transform: { k }, + } = useZoomContext(); + const theme = editorStyles(useCanvasTheme(), k); + const pts = { x1: dragEdge.x1, y1: dragEdge.y1, x2: dragEdge.x2, y2: dragEdge.y2 }; + return ( + + ); +} diff --git a/canvas/src/components/editor/EdgePropertiesPanel.tsx b/canvas/src/components/editor/EdgePropertiesPanel.tsx new file mode 100644 index 000000000..de16dee4b --- /dev/null +++ b/canvas/src/components/editor/EdgePropertiesPanel.tsx @@ -0,0 +1,279 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React, { ReactElement, useCallback, useMemo } from 'react'; +import { Checkbox, FormControlLabel, MenuItem, Stack, TextField, Typography } from '@mui/material'; +import { generateQueryNames, useDataQueriesContext } from '@perses-dev/plugin-system'; +import { AnchorPoint, EdgeSpec, NodeSpec } from '../../model'; + +const ANCHOR_OPTIONS: AnchorPoint[] = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw']; + +interface EdgePropertiesPanelProps { + edge: EdgeSpec; + nodes: NodeSpec[]; + onChange: (updated: EdgeSpec) => void; +} + +export function EdgePropertiesPanel({ edge, nodes, onChange }: EdgePropertiesPanelProps): ReactElement { + const hasFreeTarget = edge.target === ''; + const { queryDefinitions } = useDataQueriesContext(); + const queryCount = queryDefinitions.length; + const queryNames = useMemo(() => generateQueryNames(queryDefinitions), [queryDefinitions]); + const queryIndexes = Array.from({ length: queryCount }, (_, i) => i); + + const onSourceChange = useCallback( + (e: React.ChangeEvent): void => { + onChange({ ...edge, source: e.target.value }); + }, + [edge, onChange] + ); + + const onSourceAnchorChange = useCallback( + (e: React.ChangeEvent): void => { + onChange({ ...edge, sourceAnchor: e.target.value as AnchorPoint }); + }, + [edge, onChange] + ); + + const onTargetChange = useCallback( + (e: React.ChangeEvent): void => { + onChange({ + ...edge, + target: e.target.value, + targetAnchor: edge.targetAnchor ?? 'n', + x2: undefined, + y2: undefined, + }); + }, + [edge, onChange] + ); + + const onTargetAnchorChange = useCallback( + (e: React.ChangeEvent): void => { + onChange({ ...edge, targetAnchor: e.target.value as AnchorPoint }); + }, + [edge, onChange] + ); + + const onBidirectionalChange = useCallback( + (e: React.ChangeEvent): void => { + onChange({ ...edge, bidirectional: e.target.checked || undefined }); + }, + [edge, onChange] + ); + + const onThicknessModeChange = useCallback( + (e: React.ChangeEvent): void => { + onChange({ ...edge, thicknessMode: e.target.value as 'fixed' | 'threshold' }); + }, + [edge, onChange] + ); + + const onStrokeWidthChange = useCallback( + (e: React.ChangeEvent): void => { + const v = parseFloat(e.target.value); + onChange({ ...edge, strokeWidth: Number.isFinite(v) && v > 0 ? v : undefined }); + }, + [edge, onChange] + ); + + const onSourceQueryIndexChange = useCallback( + (e: React.ChangeEvent): void => { + const v = e.target.value; + onChange({ ...edge, sourceQueryIndex: v === '' ? undefined : Number(v) }); + }, + [edge, onChange] + ); + + const onSourceLabelTemplateChange = useCallback( + (e: React.ChangeEvent): void => { + onChange({ ...edge, sourceLabelTemplate: e.target.value || undefined }); + }, + [edge, onChange] + ); + + const onTargetQueryIndexChange = useCallback( + (e: React.ChangeEvent): void => { + const v = e.target.value; + onChange({ ...edge, targetQueryIndex: v === '' ? undefined : Number(v) }); + }, + [edge, onChange] + ); + + const onTargetLabelTemplateChange = useCallback( + (e: React.ChangeEvent): void => { + onChange({ ...edge, targetLabelTemplate: e.target.value || undefined }); + }, + [edge, onChange] + ); + + return ( + + Edge properties + + onChange({ ...edge, name: e.target.value || undefined })} + /> + + + {nodes.map((n) => ( + + {n.label ?? n.id} + + ))} + + + + {ANCHOR_OPTIONS.map((a) => ( + + {a} + + ))} + + + + {nodes.map((n) => ( + + {n.label ?? n.id} + + ))} + + + + {ANCHOR_OPTIONS.map((a) => ( + + {a} + + ))} + + + } + label="Bidirectional" + /> + + + Fixed + Threshold + + + {(edge.thicknessMode ?? 'fixed') === 'fixed' && ( + + )} + + + + None + + {queryIndexes.map((qi) => ( + + {queryNames[qi] ?? `#${qi + 1}`} + + ))} + + + + + {edge.bidirectional && ( + <> + + + None + + {queryIndexes.map((qi) => ( + + {queryNames[qi] ?? `#${qi + 1}`} + + ))} + + + + + )} + + ); +} diff --git a/canvas/src/components/editor/EditorCanvas.tsx b/canvas/src/components/editor/EditorCanvas.tsx new file mode 100644 index 000000000..971894119 --- /dev/null +++ b/canvas/src/components/editor/EditorCanvas.tsx @@ -0,0 +1,300 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { KeyboardEvent, MouseEvent, PointerEvent, ReactElement, useCallback, useLayoutEffect, useMemo } from 'react'; +import { produce } from 'immer'; +import { AnchorPoint, CanvasSpec, FloatingEdge, isFloatingEdge } from '../../model'; +import { nodeBoundingBox } from '../../utils/resizeUtils'; +import { useZoomContext } from '../../contexts/ZoomContext'; +import { useNodeMove } from '../../hooks/useNodeMove'; +import { useEdgeConnect } from '../../hooks/useEdgeConnect'; +import { useResize } from '../../hooks/useResize'; +import { useRectSelect } from '../../hooks/useRectSelect'; +import { useEditorContext } from '../../contexts/EditorContext'; +import { useSpecContext } from '../../contexts/SpecContext'; +import { BackgroundLayer, GlobalBackgroundLayer } from '../shared/BackgroundLayer'; +import { EditorEdge } from './EditorEdge'; +import { EditorNode } from './EditorNode'; +import { SelectionBoundingBox } from './SelectionBoundingBox'; +import { DragEdgeLine } from './DragEdgeLine'; +import { SelectionRectOverlay } from './SelectionRectOverlay'; + +const NS_PREFIX = 'wm-editor'; + +function isActivePointerMove(event: PointerEvent): boolean { + return event.buttons !== 0; +} + +export function EditorCanvas({ + svgRef, + width, + height, +}: { + svgRef: (node: SVGSVGElement | null) => void; + width: number; + height: number; +}): ReactElement { + const { spec, updateSpec, deleteSelected } = useSpecContext(); + const { + state, + selectItems, + hoverNode, + unhoverNode, + startSelectionRect, + startMove, + startDragEdge, + startResize, + endInteraction, + } = useEditorContext(); + const { transform, fitView, resetPan } = useZoomContext(); + + const { selectNode, updateMove, applyMove, resetMove } = useNodeMove(); + const { dragEdge, beginEdgeDrag, beginEndpointDrag, updateEdgeDrag, resetEdgeDrag, applyEdgeDrag } = useEdgeConnect(); + const { beginResize, updateResize, applyResize, resetResize } = useResize(); + const { beginSelection, updateSelection, applySelection, selectionRect } = useRectSelect(); + + const { mode, selectedIds, hoveredId } = state; + + const unsavedSpec = useMemo((): CanvasSpec => { + switch (mode.type) { + case 'moving': + return produce(spec, (draft) => applyMove(draft)); + case 'resizing': + return produce(spec, (draft) => applyResize(draft)); + case 'dragging-edge': + return produce(spec, (draft) => applyEdgeDrag(draft)); + default: + return spec; + } + }, [mode, spec, applyMove, applyResize, applyEdgeDrag]); + const displayNodes = useMemo(() => unsavedSpec.nodes ?? [], [unsavedSpec.nodes]); + const displayEdges = useMemo(() => unsavedSpec.edges ?? [], [unsavedSpec.edges]); + const nodeById = useMemo(() => new Map(displayNodes.map((n) => [n.id, n])), [displayNodes]); + + const selectionBoundingBox = useMemo(() => { + const selectedNodes = displayNodes.filter((n) => selectedIds.has(n.id)); + const selectedFloatingEdges = displayEdges.filter( + (ed): ed is FloatingEdge => selectedIds.has(ed.id) && isFloatingEdge(ed) + ); + return (mode.type === 'idle' || mode.type === 'resizing') && selectedNodes.length >= 1 + ? nodeBoundingBox( + selectedNodes, + selectedFloatingEdges.map((ed) => ({ x: ed.x2, y: ed.y2 })) + ) + : null; + }, [displayEdges, displayNodes, mode.type, selectedIds]); + + useLayoutEffect(() => { + if (displayNodes.length === 0) { + return; + } + const bbox = nodeBoundingBox(displayNodes); + if (bbox) { + fitView(bbox, width, height); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fitView, height, width]); + + const onSvgPointerDown = useCallback( + (event: PointerEvent): void => { + if (mode.type !== 'idle') { + return; + } + if (beginSelection(event)) { + startSelectionRect(); + } + }, + [mode.type, beginSelection, startSelectionRect] + ); + + const onSvgPointerMove = useCallback( + (event: PointerEvent): void => { + if (!isActivePointerMove(event)) { + return; + } + switch (mode.type) { + case 'resizing': + updateResize(event); + break; + case 'dragging-edge': + updateEdgeDrag(event); + break; + case 'selecting': + updateSelection(event); + break; + } + }, + [mode.type, updateResize, updateEdgeDrag, updateSelection] + ); + + const clearInteractionState = useCallback((): void => { + resetMove(); + resetResize(); + resetEdgeDrag(); + }, [resetMove, resetResize, resetEdgeDrag]); + + const onSvgPointerUp = useCallback((): void => { + switch (mode.type) { + case 'moving': + case 'resizing': + case 'dragging-edge': { + updateSpec(unsavedSpec); + clearInteractionState(); + endInteraction(); + break; + } + case 'selecting': { + const ids = applySelection(); + selectItems(ids); + endInteraction(); + break; + } + } + }, [mode.type, updateSpec, unsavedSpec, clearInteractionState, endInteraction, applySelection, selectItems]); + + const onSvgDoubleClick = useCallback( + (event: MouseEvent): void => { + if (event.ctrlKey || event.metaKey) { + const boundingBox = nodeBoundingBox(displayNodes); + if (boundingBox) { + fitView(boundingBox, width, height); + } + } else { + resetPan(); + } + }, + [displayNodes, fitView, resetPan, width, height] + ); + + const onKeyDown = useCallback( + (event: KeyboardEvent): void => { + if (event.key !== 'Delete' && event.key !== 'Backspace') { + return; + } + if (selectedIds.size > 0) { + deleteSelected(); + } + }, + [selectedIds, deleteSelected] + ); + + return ( + + + + + {displayNodes.map((node) => { + const onNodePointerDown = (event: PointerEvent): void => { + const unselectedId = selectNode(event, node.id); + if (unselectedId !== null) { + selectItems(new Set([unselectedId])); + } else { + startMove(); + } + }; + const onNodePointerMove = (event: PointerEvent): void => { + updateMove(event, node.id); + }; + const onNodeMouseEnter = (): void => { + if (mode.type !== 'dragging-edge') { + hoverNode(node.id); + } + }; + const onNodeMouseLeave = (): void => unhoverNode(node.id); + const onCrossDragStart = (anchor: AnchorPoint, x: number, y: number): void => { + beginEdgeDrag(node.id, anchor, x, y); + startDragEdge(); + }; + return ( + + ); + })} + + {displayEdges.map((edge) => { + const onEdgeClick = (event: PointerEvent): void => { + event.stopPropagation(); + selectItems(new Set([edge.id])); + }; + const onEndpointPointerDown = ( + event: PointerEvent, + end: 'source' | 'target', + fixedX: number, + fixedY: number, + fixedNodeId: string, + fixedAnchor: AnchorPoint + ): void => { + if (beginEndpointDrag(event, edge.id, end, fixedX, fixedY, fixedNodeId, fixedAnchor)) { + startDragEdge(); + } + }; + return ( + + ); + })} + + {selectionBoundingBox && ( + { + if (beginResize(event, handleId)) { + startResize(); + } + }} + /> + )} + + {mode.type === 'dragging-edge' && dragEdge && } + + {selectionRect && } + + + ); +} diff --git a/canvas/src/components/editor/EditorEdge.tsx b/canvas/src/components/editor/EditorEdge.tsx new file mode 100644 index 000000000..43d12654e --- /dev/null +++ b/canvas/src/components/editor/EditorEdge.tsx @@ -0,0 +1,110 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { PointerEvent, ReactElement } from 'react'; +import { AnchorPoint, EdgeSpec, NodeSpec } from '../../model'; +import { edgeEndpoints } from '../../utils/edgeUtils'; +import { editorStyles } from '../../utils/editorStyles'; +import { useCanvasTheme } from '../../hooks/useCanvasTheme'; +import { useZoomContext } from '../../contexts/ZoomContext'; +import { EdgeLines, LineStyle } from '../shared/EdgeLines'; + +interface EditorEdgeProps { + edge: EdgeSpec; + isSelected: boolean; + isDragging: boolean; + nsPrefix: string; + nodeById: Map; + onEdgeClick: (event: PointerEvent) => void; + onEndpointPointerDown: ( + event: PointerEvent, + end: 'source' | 'target', + fixedX: number, + fixedY: number, + fixedNodeId: string, + fixedAnchor: AnchorPoint + ) => void; +} + +export function EditorEdge({ + edge, + isSelected, + isDragging, + nsPrefix, + nodeById, + onEdgeClick, + onEndpointPointerDown, +}: EditorEdgeProps): ReactElement | null { + const { + transform: { k }, + } = useZoomContext(); + const theme = editorStyles(useCanvasTheme(), k); + const pts = edgeEndpoints(edge, nodeById); + if (!pts) { + return null; + } + const srcAnchor: AnchorPoint = edge.sourceAnchor ?? 'n'; + const tgtAnchor: AnchorPoint = edge.targetAnchor ?? 'n'; + + const rawStyle = isSelected ? theme.edgeSelected : theme.edge; + const lineStyle: LineStyle = { + stroke: rawStyle.stroke, + strokeWidth: rawStyle.strokeWidth, + strokeOpacity: rawStyle.strokeOpacity, + }; + + if (isDragging && isSelected) { + return null; + } + return ( + + + + {isSelected && !isDragging && ( + <> + + onEndpointPointerDown(event, 'source', pts.x2, pts.y2, edge.target || edge.source, tgtAnchor) + } + /> + onEndpointPointerDown(event, 'target', pts.x1, pts.y1, edge.source, srcAnchor)} + /> + + )} + + ); +} diff --git a/canvas/src/components/editor/EditorItemsPanel.tsx b/canvas/src/components/editor/EditorItemsPanel.tsx new file mode 100644 index 000000000..903cb3ef2 --- /dev/null +++ b/canvas/src/components/editor/EditorItemsPanel.tsx @@ -0,0 +1,172 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ReactElement, useCallback, useRef } from 'react'; +import { + Box, + Button, + FormControl, + InputLabel, + ListSubheader, + MenuItem, + Select, + SelectChangeEvent, +} from '@mui/material'; +import { useEditorContext } from '../../contexts/EditorContext'; +import { useSpecContext } from '../../contexts/SpecContext'; +import { useZoom } from '../../hooks/useZoom'; +import { ZoomProvider } from '../../contexts/ZoomContext'; +import { EditorCanvas } from './EditorCanvas'; +import { NodePropertiesPanel } from './NodePropertiesPanel'; +import { EdgePropertiesPanel } from './EdgePropertiesPanel'; +import { BackgroundPropertiesPanel } from './BackgroundPropertiesPanel'; + +const CANVAS_HEIGHT = 400; +const PROPERTIES_HEIGHT = 700; + +export function EditorItemsPanel(): ReactElement { + const { + spec, + nodeById, + edgeById, + backgroundById, + addNode, + addBackground, + deleteSelected, + onNodePropertiesChange, + onEdgePropertiesChange, + onBackgroundPropertiesChange, + } = useSpecContext(); + const { + state: { selectedIds }, + selectItems, + } = useEditorContext(); + const { svgRef, toCanvasPoint, transform, fitView, resetPan } = useZoom(); + const containerRef = useRef(null); + const [firstSelectedId] = selectedIds; + const selectedNode = selectedIds.size === 1 && firstSelectedId ? (nodeById.get(firstSelectedId) ?? null) : null; + const selectedEdge = selectedIds.size === 1 && firstSelectedId ? (edgeById.get(firstSelectedId) ?? null) : null; + const selectedBackground = + selectedIds.size === 1 && firstSelectedId ? (backgroundById.get(firstSelectedId) ?? null) : null; + + function onAddNode(): void { + const canvasWidth = containerRef.current?.clientWidth ?? 0; + const cx = transform.invertX(canvasWidth / 2); + const cy = transform.invertY(CANVAS_HEIGHT / 2); + addNode(cx, cy); + } + + function onAddBackground(): void { + const canvasWidth = containerRef.current?.clientWidth ?? 0; + const k = transform.k > 0 ? transform.k : 1; + const width = canvasWidth > 0 ? canvasWidth / k : 200; + const height = CANVAS_HEIGHT > 0 ? CANVAS_HEIGHT / k : 150; + const x = transform.invertX(0); + const y = transform.invertY(0); + addBackground(x, y, width, height); + } + + const onItemSelect = useCallback( + (event: SelectChangeEvent): void => { + const id = event.target.value; + selectItems(id ? new Set([id]) : new Set()); + }, + [selectItems] + ); + + const hasBackgrounds = (spec.backgrounds?.length ?? 0) > 0; + const hasNodes = (spec.nodes?.length ?? 0) > 0; + const hasEdges = (spec.edges?.length ?? 0) > 0; + + return ( + + + + + + + + + + Item + + + + + + + + + {selectedNode && } + {selectedEdge && ( + + )} + {selectedBackground && ( + + )} + {!selectedNode && !selectedEdge && !selectedBackground && ( + + Select a node or edge to edit its properties + + )} + + + ); +} diff --git a/canvas/src/components/editor/EditorNode.tsx b/canvas/src/components/editor/EditorNode.tsx new file mode 100644 index 000000000..76d86b34b --- /dev/null +++ b/canvas/src/components/editor/EditorNode.tsx @@ -0,0 +1,64 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { PointerEvent, ReactElement } from 'react'; +import { NodeSpec, AnchorPoint } from '../../model'; +import { editorStyles } from '../../utils/editorStyles'; +import { useCanvasTheme } from '../../hooks/useCanvasTheme'; +import { useZoomContext } from '../../contexts/ZoomContext'; +import { NodeRenderer } from '../shared/NodeRenderer'; +import { ConnectionHandles } from './ConnectionHandles'; + +interface EditorNodeProps { + node: NodeSpec; + isHovered: boolean; + isSelected: boolean; + snapTarget: boolean; + isDragging: boolean; + onPointerDown: (event: PointerEvent) => void; + onPointerMove: (event: PointerEvent) => void; + onMouseEnter: () => void; + onMouseLeave: () => void; + onCrossDragStart: (anchor: AnchorPoint, x: number, y: number) => void; +} + +export function EditorNode({ + node, + isHovered, + isSelected, + snapTarget, + isDragging, + onPointerDown, + onPointerMove, + onMouseEnter, + onMouseLeave, + onCrossDragStart, +}: EditorNodeProps): ReactElement { + const wmTheme = useCanvasTheme(); + const theme = editorStyles(wmTheme, useZoomContext().transform.k); + return ( + + + {isHovered && !isSelected && !isDragging && } + + ); +} diff --git a/canvas/src/components/editor/IconPreview.tsx b/canvas/src/components/editor/IconPreview.tsx new file mode 100644 index 000000000..2028b52fe --- /dev/null +++ b/canvas/src/components/editor/IconPreview.tsx @@ -0,0 +1,27 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ReactElement } from 'react'; +import { ICON_PATHS } from '../../utils/icons'; + +interface IconPreviewProps { + name: string; +} + +export function IconPreview({ name }: IconPreviewProps): ReactElement { + return ( + + + + ); +} diff --git a/canvas/src/components/editor/NodePropertiesPanel.tsx b/canvas/src/components/editor/NodePropertiesPanel.tsx new file mode 100644 index 000000000..36da06881 --- /dev/null +++ b/canvas/src/components/editor/NodePropertiesPanel.tsx @@ -0,0 +1,235 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React, { ReactElement, useCallback, useMemo } from 'react'; +import { Autocomplete, Box, MenuItem, Stack, TextField, Typography } from '@mui/material'; +import { OptionsColorPicker } from '@perses-dev/components'; +import { generateQueryNames, useDataQueriesContext } from '@perses-dev/plugin-system'; +import { NodeSpec } from '../../model'; +import { ICON_NAMES } from '../../utils/icons'; +import { useCanvasTheme } from '../../hooks/useCanvasTheme'; +import { IconPreview } from './IconPreview'; + +interface NodePropertiesPanelProps { + node: NodeSpec; + onChange: (updated: NodeSpec) => void; +} + +export function NodePropertiesPanel({ node, onChange }: NodePropertiesPanelProps): ReactElement { + const { queryDefinitions } = useDataQueriesContext(); + const { nodeDefaultFill } = useCanvasTheme(); + const queryCount = queryDefinitions.length; + const queryNames = useMemo(() => generateQueryNames(queryDefinitions), [queryDefinitions]); + const queryIndexes = Array.from({ length: queryCount }, (_, i) => i); + const shape = node.kind; + + const onIntFieldChange = useCallback( + (key: 'x' | 'y' | 'width' | 'height' | 'labelPadding', min = -Infinity, optional = false) => + (e: React.ChangeEvent): void => { + const v = e.target.valueAsNumber; + if (Number.isFinite(v) && v >= min) { + onChange({ ...node, [key]: v }); + } else if (optional && e.target.value === '') { + onChange({ ...node, [key]: undefined }); + } + }, + [node, onChange] + ); + + return ( + + Node properties + + + + + + + + + onChange({ ...node, kind: e.target.value as NodeSpec['kind'] })} + slotProps={{ select: { MenuProps: { PaperProps: { style: { maxHeight: 240 } } } } }} + > + Rectangle + Icon + Text + + + {shape !== 'text' && ( + onChange({ ...node, icon: newIcon ?? undefined })} + renderInput={(params) => } + renderOption={(props, name) => ( + + + {name} + + )} + isOptionEqualToValue={(option, value) => option === value} + clearOnEscape + size="small" + /> + )} + + {shape === 'rectangle' && ( + onChange({ ...node, backgroundImage: e.target.value || undefined })} + /> + )} + + onChange({ ...node, link: e.target.value || undefined })} + helperText="Navigate to this URL on click. Use ${varName} for dashboard variables." + /> + + onChange({ ...node, label: e.target.value || undefined })} + helperText="Use {{label_name}} or {{value}} to interpolate query data" + /> + + {shape !== 'text' && ( + + onChange({ ...node, labelPosition: e.target.value as NodeSpec['labelPosition'] })} + slotProps={{ select: { MenuProps: { PaperProps: { style: { maxHeight: 240 } } } } }} + sx={{ flex: 1 }} + > + Below + Above + Left + Right + Center + + + + )} + + { + const v = e.target.value; + onChange({ ...node, queryIndex: v === '' ? undefined : Number(v) }); + }} + slotProps={{ select: { MenuProps: { PaperProps: { style: { maxHeight: 240 } } } } }} + sx={{ minWidth: 120 }} + > + + None + + {queryIndexes.map((qi) => ( + + {queryNames[qi] ?? `#${qi + 1}`} + + ))} + + + + { + const v = e.target.value as '' | 'threshold' | 'fixed'; + onChange({ ...node, colorMode: v === '' ? undefined : v }); + }} + slotProps={{ select: { MenuProps: { PaperProps: { style: { maxHeight: 240 } } } } }} + sx={{ flex: 1 }} + > + + None (default) + + Threshold + Fixed + + + + onChange({ ...node, color })} + onClear={() => onChange({ ...node, color: undefined })} + /> + + + + ); +} diff --git a/canvas/src/components/editor/SelectionBoundingBox.tsx b/canvas/src/components/editor/SelectionBoundingBox.tsx new file mode 100644 index 000000000..5792f8d72 --- /dev/null +++ b/canvas/src/components/editor/SelectionBoundingBox.tsx @@ -0,0 +1,67 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { PointerEvent, ReactElement } from 'react'; +import { + BoundingBox, + HANDLE_POSITIONS, + handlePosition, + RESIZE_CURSORS, + RESIZE_HANDLE_IDS, + ResizeHandleId, +} from '../../utils/resizeUtils'; +import { editorStyles } from '../../utils/editorStyles'; +import { useCanvasTheme } from '../../hooks/useCanvasTheme'; +import { useZoomContext } from '../../contexts/ZoomContext'; + +interface SelectionBoundingBoxProps { + boundingBox: BoundingBox; + onResizeHandlePointerDown: (event: PointerEvent, handleId: ResizeHandleId) => void; +} + +export function SelectionBoundingBox({ + boundingBox, + onResizeHandlePointerDown, +}: SelectionBoundingBoxProps): ReactElement { + const { + transform: { k }, + } = useZoomContext(); + const theme = editorStyles(useCanvasTheme(), k); + const pad = theme.selectionBoundingBoxPad; + const bx = boundingBox.minX - pad; + const by = boundingBox.minY - pad; + const bw = boundingBox.maxX - boundingBox.minX + pad * 2; + const bh = boundingBox.maxY - boundingBox.minY + pad * 2; + const paddedBoundingBox: BoundingBox = { minX: bx, minY: by, maxX: bx + bw, maxY: by + bh }; + + return ( + + + {RESIZE_HANDLE_IDS.map((h) => { + const pos = handlePosition(paddedBoundingBox, h); + return ( + onResizeHandlePointerDown(event, h)} + /> + ); + })} + + ); +} + +export { HANDLE_POSITIONS }; diff --git a/canvas/src/components/editor/SelectionRectOverlay.tsx b/canvas/src/components/editor/SelectionRectOverlay.tsx new file mode 100644 index 000000000..c206bbacb --- /dev/null +++ b/canvas/src/components/editor/SelectionRectOverlay.tsx @@ -0,0 +1,36 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ReactElement } from 'react'; +import { SelectionRect } from '../../hooks/useRectSelect'; +import { editorStyles } from '../../utils/editorStyles'; +import { useCanvasTheme } from '../../hooks/useCanvasTheme'; +import { useZoomContext } from '../../contexts/ZoomContext'; + +interface SelectionRectOverlayProps { + rect: SelectionRect; +} + +export function SelectionRectOverlay({ rect }: SelectionRectOverlayProps): ReactElement { + const { + transform: { k }, + } = useZoomContext(); + const theme = editorStyles(useCanvasTheme(), k); + const minX = Math.min(rect.x0, rect.x1); + const minY = Math.min(rect.y0, rect.y1); + const width = Math.abs(rect.x1 - rect.x0); + const height = Math.abs(rect.y1 - rect.y0); + return ( + + ); +} diff --git a/canvas/src/components/panel/CanvasPanel.tsx b/canvas/src/components/panel/CanvasPanel.tsx new file mode 100644 index 000000000..a3b023a3b --- /dev/null +++ b/canvas/src/components/panel/CanvasPanel.tsx @@ -0,0 +1,124 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { MouseEvent, ReactElement, useCallback, useMemo } from 'react'; +import { TimeSeries } from '@perses-dev/core'; +import { useChartsTheme } from '@perses-dev/components'; +import { CanvasProps } from '../../model'; +import { nodeBoundingBox } from '../../utils/resizeUtils'; +import { useZoom } from '../../hooks/useZoom'; +import { useZoomContext, ZoomProvider } from '../../contexts/ZoomContext'; +import { BackgroundLayer, GlobalBackgroundLayer } from '../shared/BackgroundLayer'; +import { ThresholdLegend } from './ThresholdLegend'; +import { PanelEdgeLayer } from './PanelEdgeLayer'; +import { PanelNodeLayer } from './PanelNodeLayer'; + +interface PanelSvgProps { + svgRef: (node: SVGSVGElement | null) => void; + props: CanvasProps; + seriesByQueryIndex: Map; + paletteColors: string[]; +} + +function PanelSvg({ svgRef, props, seriesByQueryIndex, paletteColors }: PanelSvgProps): ReactElement { + const { contentDimensions, spec } = props; + const { transform, fitView, resetPan } = useZoomContext(); + + const nodes = useMemo(() => spec.nodes ?? [], [spec.nodes]); + + const width = contentDimensions?.width ?? 600; + const height = contentDimensions?.height ?? 400; + + const handleDoubleClick = useCallback( + (event: MouseEvent): void => { + if (event.ctrlKey || event.metaKey) { + const boundingBox = nodeBoundingBox(nodes); + if (boundingBox) { + fitView(boundingBox, width, height); + } + } else { + resetPan(); + } + }, + [fitView, resetPan, nodes, width, height] + ); + + const showLegend = spec.legend !== undefined && spec.thresholds !== undefined; + const legendPosition = spec.legend?.position ?? 'bottom'; + const LEGEND_MARGIN = 8; + const legendX = legendPosition === 'right' ? width - 118 - LEGEND_MARGIN : LEGEND_MARGIN; + const legendY = + legendPosition === 'right' ? LEGEND_MARGIN : height - ((spec.thresholds?.steps?.length ?? 0) + 1) * 18 - 24; + + return ( + + + + + + + + + {showLegend && ( + + )} + + ); +} + +export function CanvasPanel(props: CanvasProps): ReactElement | null { + const { queryResults } = props; + const chartsTheme = useChartsTheme(); + const paletteColors = chartsTheme.thresholds.palette; + + const seriesByQueryIndex = useMemo(() => { + const map = new Map(); + queryResults.forEach((result, i) => { + const first = result.data.series[0]; + if (first) { + map.set(i, first); + } + }); + return map; + }, [queryResults]); + + const { svgRef, toCanvasPoint, transform, fitView, resetPan } = useZoom(); + + return ( + + + + ); +} diff --git a/canvas/src/components/panel/PanelEdgeLayer.tsx b/canvas/src/components/panel/PanelEdgeLayer.tsx new file mode 100644 index 000000000..9f778c402 --- /dev/null +++ b/canvas/src/components/panel/PanelEdgeLayer.tsx @@ -0,0 +1,165 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ReactElement } from 'react'; +import { TimeSeries } from '@perses-dev/core'; +import { CanvasSpec } from '../../model'; +import { edgeEndpoints, strokeWidthFromThresholds } from '../../utils/edgeUtils'; +import { useCanvasTheme } from '../../hooks/useCanvasTheme'; +import { EdgeLabel } from '../shared/EdgeLabel'; +import { EdgeLines, edgeLabelPoints, LineStyle } from '../shared/EdgeLines'; +import { colorFromThresholds, interpolateLabel } from '../../utils/panelUtils'; + +const NS_PREFIX = 'wm-panel'; + +function resolveEdgeStyle( + queryIndex: number | undefined, + thicknessMode: 'fixed' | 'threshold' | undefined, + edgeStrokeWidth: number | undefined, + seriesByQueryIndex: Map, + spec: CanvasSpec, + paletteColors: string[], + fallbackColor: string +): { stroke: string; strokeWidth: number } { + const defaultWidth = edgeStrokeWidth ?? spec.edgeDefaultStrokeWidth ?? 2; + if (queryIndex === undefined) { + return { stroke: 'currentColor', strokeWidth: defaultWidth }; + } + const series = seriesByQueryIndex.get(queryIndex); + if (!series) { + return { stroke: 'currentColor', strokeWidth: defaultWidth }; + } + const lastTuple = series.values[series.values.length - 1]; + const lastValue = lastTuple?.[1]; + if (lastValue === null || lastValue === undefined) { + return { stroke: 'currentColor', strokeWidth: defaultWidth }; + } + + const stroke = spec.thresholds + ? colorFromThresholds(lastValue, spec.thresholds, paletteColors, fallbackColor) + : 'currentColor'; + const strokeWidth = + thicknessMode === 'threshold' && spec.edgeThresholdWidths?.length + ? strokeWidthFromThresholds(lastValue, spec.edgeThresholdWidths, defaultWidth) + : defaultWidth; + return { stroke, strokeWidth }; +} + +interface PanelEdgeLayerProps { + spec: CanvasSpec; + seriesByQueryIndex: Map; + k: number; + paletteColors: string[]; +} + +export function PanelEdgeLayer({ spec, seriesByQueryIndex, k, paletteColors }: PanelEdgeLayerProps): ReactElement { + const nodes = spec.nodes ?? []; + const edges = spec.edges ?? []; + const nodeById = new Map(nodes.map((n) => [n.id, n])); + const { labelBackground, labelBorder, labelText, connection: fallbackColor } = useCanvasTheme(); + + return ( + <> + {edges.map((edge, i) => { + const pts = edgeEndpoints(edge, nodeById); + if (!pts) { + return null; + } + + function resolveLabel(queryIndex: number | undefined, template: string | undefined): string | null { + if (queryIndex === undefined) { + return null; + } + const series = seriesByQueryIndex.get(queryIndex); + if (!series) { + return null; + } + return interpolateLabel(template ?? '{{value}}', series, spec.format); + } + + const fwdStyle = resolveEdgeStyle( + edge.sourceQueryIndex, + edge.thicknessMode, + edge.strokeWidth, + seriesByQueryIndex, + spec, + paletteColors, + fallbackColor + ); + const bwdStyle = resolveEdgeStyle( + edge.targetQueryIndex, + edge.thicknessMode, + edge.strokeWidth, + seriesByQueryIndex, + spec, + paletteColors, + fallbackColor + ); + const scaledFwdStyle: LineStyle = { + stroke: fwdStyle.stroke, + strokeWidth: fwdStyle.strokeWidth / k, + strokeOpacity: 0.8, + }; + const scaledBwdStyle: LineStyle = { + stroke: bwdStyle.stroke, + strokeWidth: bwdStyle.strokeWidth / k, + strokeOpacity: 0.8, + }; + + const labelPts = edgeLabelPoints( + pts, + edge.bidirectional ?? false, + scaledFwdStyle.strokeWidth, + scaledBwdStyle.strokeWidth + ); + const fwdLabel = resolveLabel(edge.sourceQueryIndex, edge.sourceLabelTemplate); + const bwdLabel = edge.bidirectional ? resolveLabel(edge.targetQueryIndex, edge.targetLabelTemplate) : null; + + return ( + + + {fwdLabel && ( + + )} + {bwdLabel && labelPts.bwd && ( + + )} + + ); + })} + + ); +} diff --git a/canvas/src/components/panel/PanelNodeLayer.tsx b/canvas/src/components/panel/PanelNodeLayer.tsx new file mode 100644 index 000000000..0dd34e450 --- /dev/null +++ b/canvas/src/components/panel/PanelNodeLayer.tsx @@ -0,0 +1,75 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ReactElement, useCallback } from 'react'; +import { TimeSeries } from '@perses-dev/core'; +import { replaceVariablesInString, useAllVariableValues } from '@perses-dev/plugin-system'; +import { useCanvasTheme } from '../../hooks/useCanvasTheme'; +import { CanvasSpec } from '../../model'; +import { NodeRenderer } from '../shared/NodeRenderer'; +import { colorFromThresholds, interpolateLabel } from '../../utils/panelUtils'; + +interface PanelNodeLayerProps { + spec: CanvasSpec; + seriesByQueryIndex: Map; + k: number; + paletteColors: string[]; +} + +export function PanelNodeLayer({ spec, seriesByQueryIndex, k, paletteColors }: PanelNodeLayerProps): ReactElement { + const nodes = spec.nodes ?? []; + const variableValues = useAllVariableValues(); + const { connection: fallbackColor, nodeDefaultFill } = useCanvasTheme(); + + const handleNodeClick = useCallback( + (link: string) => { + window.open(replaceVariablesInString(link, variableValues), '_blank', 'noopener,noreferrer'); + }, + [variableValues] + ); + + return ( + <> + {nodes.map((node) => { + let labelOverride: string | undefined; + let fillOverride: string | undefined; + + const series = node.queryIndex !== undefined ? seriesByQueryIndex.get(node.queryIndex) : undefined; + if (series && node.label) { + labelOverride = interpolateLabel(node.label, series, spec.format); + } + if (node.colorMode === 'fixed' && node.color) { + fillOverride = node.color; + } else if (node.colorMode === 'threshold' && spec.thresholds) { + const lastTuple = series?.values[series.values.length - 1]; + const lastValue = lastTuple?.[1]; + if (lastValue !== null && lastValue !== undefined) { + fillOverride = colorFromThresholds(lastValue, spec.thresholds, paletteColors, fallbackColor); + } + } + const { link } = node; + return ( + handleNodeClick(link), style: { cursor: 'pointer' } } : undefined} + rectProps={{ strokeWidth: 2 / k }} + labelOverride={labelOverride} + fillOverride={fillOverride} + /> + ); + })} + + ); +} diff --git a/canvas/src/components/panel/ThresholdLegend.tsx b/canvas/src/components/panel/ThresholdLegend.tsx new file mode 100644 index 000000000..97e4a98f1 --- /dev/null +++ b/canvas/src/components/panel/ThresholdLegend.tsx @@ -0,0 +1,81 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ReactElement } from 'react'; +import { useTheme } from '@mui/material'; +import { ThresholdOptions } from '@perses-dev/core'; +import { FormatOptions, formatValue } from '@perses-dev/components'; + +const SWATCH_SIZE = 12; +const ROW_HEIGHT = 18; +const LABEL_OFFSET = SWATCH_SIZE + 6; +const PADDING = 8; +const FONT_SIZE = 11; + +interface ThresholdLegendProps { + thresholds: ThresholdOptions; + format: FormatOptions | undefined; + paletteColors: string[]; + x: number; + y: number; +} + +export function ThresholdLegend({ thresholds, format, paletteColors, x, y }: ThresholdLegendProps): ReactElement { + const muiTheme = useTheme(); + const defaultColor = thresholds.defaultColor ?? paletteColors[0] ?? muiTheme.palette.success.main; + const steps = thresholds.steps ?? []; + + const rows: Array<{ color: string; label: string }> = [ + ...steps.map((step, i) => ({ + color: step.color ?? paletteColors[i] ?? defaultColor, + label: `≥ ${formatValue(step.value, format)}`, + })), + { color: defaultColor, label: 'default' }, + ].reverse(); + + const boxWidth = 110; + const boxHeight = rows.length * ROW_HEIGHT + PADDING * 2; + + return ( + + + {rows.map((row, i) => { + const ry = y + PADDING + i * ROW_HEIGHT + (ROW_HEIGHT - SWATCH_SIZE) / 2; + return ( + + + + {row.label} + + + ); + })} + + ); +} diff --git a/canvas/src/components/settings/EdgeThicknessSettings.tsx b/canvas/src/components/settings/EdgeThicknessSettings.tsx new file mode 100644 index 000000000..1daccd0db --- /dev/null +++ b/canvas/src/components/settings/EdgeThicknessSettings.tsx @@ -0,0 +1,129 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React, { ReactElement, useCallback, useMemo } from 'react'; +import { Box, InputAdornment, TextField, Typography } from '@mui/material'; +import { formatValue, StepOptions } from '@perses-dev/components'; +import { produce } from 'immer'; +import { CanvasSpec } from '../../model'; + +interface EdgeThicknessSettingsProps { + value: CanvasSpec; + onChange: (value: CanvasSpec) => void; +} + +interface ThresholdWidthRowProps { + step: StepOptions; + strokeWidth: number | undefined; + format: CanvasSpec['format']; + onChange: (strokeWidth: number | undefined) => void; +} + +function ThresholdWidthRow({ step, strokeWidth, format, onChange }: ThresholdWidthRowProps): ReactElement { + const onWidthChange = useCallback( + (event: React.ChangeEvent): void => { + const parsed = parseFloat(event.target.value); + onChange(Number.isFinite(parsed) && parsed > 0 ? parsed : undefined); + }, + [onChange] + ); + + return ( + + + ≥ {formatValue(step.value, format)} + + px }, + }} + value={strokeWidth ?? ''} + onChange={onWidthChange} + sx={{ width: 100 }} + /> + + ); +} + +export function EdgeThicknessSettings({ value, onChange }: EdgeThicknessSettingsProps): ReactElement { + const thresholdSteps = useMemo(() => value.thresholds?.steps ?? [], [value.thresholds]); + + const onDefaultStrokeWidthChange = useCallback( + (event: React.ChangeEvent): void => { + const parsed = parseFloat(event.target.value); + onChange({ + ...value, + edgeDefaultStrokeWidth: Number.isFinite(parsed) && parsed > 0 ? parsed : undefined, + }); + }, + [value, onChange] + ); + + const onThresholdWidthChange = useCallback( + (stepValue: number, strokeWidth: number | undefined): void => { + onChange( + produce(value, (draft) => { + draft.edgeThresholdWidths ??= []; + const existing = draft.edgeThresholdWidths.findIndex((w) => w.value === stepValue); + if (strokeWidth !== undefined) { + if (existing >= 0) { + draft.edgeThresholdWidths[existing]!.strokeWidth = strokeWidth; + } else { + draft.edgeThresholdWidths.push({ value: stepValue, strokeWidth }); + } + } else if (existing >= 0) { + draft.edgeThresholdWidths.splice(existing, 1); + } + }) + ); + }, + [value, onChange] + ); + + return ( + <> + px }, + }} + value={value.edgeDefaultStrokeWidth ?? ''} + onChange={onDefaultStrokeWidthChange} + placeholder="2" + sx={{ mb: 1, width: 180 }} + /> + {thresholdSteps.length > 0 && ( + + + Per-threshold widths + + {thresholdSteps.map((step) => ( + w.value === step.value)?.strokeWidth} + format={value.format} + onChange={(strokeWidth) => onThresholdWidthChange(step.value, strokeWidth)} + /> + ))} + + )} + + ); +} diff --git a/canvas/src/components/settings/GlobalSettingsEditor.tsx b/canvas/src/components/settings/GlobalSettingsEditor.tsx new file mode 100644 index 000000000..641892012 --- /dev/null +++ b/canvas/src/components/settings/GlobalSettingsEditor.tsx @@ -0,0 +1,69 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + FormatControls, + OptionsEditorColumn, + OptionsEditorGrid, + OptionsEditorGroup, + ThresholdsEditor, +} from '@perses-dev/components'; +import { OptionsEditorProps } from '@perses-dev/plugin-system'; +import { ReactElement } from 'react'; +import { Box } from '@mui/material'; +import { CanvasSpec } from '../../model'; +import { EditorStateProvider } from '../../contexts/EditorContext'; +import { SpecProvider } from '../../contexts/SpecContext'; +import { EditorItemsPanel } from '../editor/EditorItemsPanel'; +import { LegendSettings } from './LegendSettings'; +import { EdgeThicknessSettings } from './EdgeThicknessSettings'; + +type GlobalSettingsEditorProps = OptionsEditorProps; + +export function GlobalSettingsEditor({ value, onChange }: GlobalSettingsEditorProps): ReactElement { + return ( + + + + + + + + onChange({ ...value, format })} + /> + + + + onChange({ ...value, thresholds })} + /> + + + + + + + + + + + + + + + ); +} diff --git a/canvas/src/components/settings/LegendSettings.tsx b/canvas/src/components/settings/LegendSettings.tsx new file mode 100644 index 000000000..8f93398af --- /dev/null +++ b/canvas/src/components/settings/LegendSettings.tsx @@ -0,0 +1,63 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React, { ReactElement, useCallback } from 'react'; +import { FormControl, FormControlLabel, InputLabel, MenuItem, Select, SelectChangeEvent, Switch } from '@mui/material'; +import { CanvasSpec } from '../../model'; + +interface LegendSettingsProps { + value: CanvasSpec; + onChange: (value: CanvasSpec) => void; +} + +export function LegendSettings({ value, onChange }: LegendSettingsProps): ReactElement { + const onToggle = useCallback( + (event: React.ChangeEvent): void => { + onChange({ + ...value, + legend: event.target.checked ? { position: value.legend?.position ?? 'bottom' } : undefined, + }); + }, + [value, onChange] + ); + + const onPositionChange = useCallback( + (event: SelectChangeEvent<'bottom' | 'right'>): void => { + onChange({ ...value, legend: { position: event.target.value as 'bottom' | 'right' } }); + }, + [value, onChange] + ); + + return ( + <> + } + label="Show legend" + /> + {value.legend !== undefined && ( + + Position + + + )} + + ); +} diff --git a/canvas/src/components/shared/BackgroundLayer.tsx b/canvas/src/components/shared/BackgroundLayer.tsx new file mode 100644 index 000000000..0367478b3 --- /dev/null +++ b/canvas/src/components/shared/BackgroundLayer.tsx @@ -0,0 +1,78 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ReactElement } from 'react'; +import { BackgroundSpec } from '../../model'; +import { imageFitToPreserveAspectRatio, isSafeImageUrl } from '../../utils/panelUtils'; + +interface GlobalBackgroundLayerProps { + backgrounds: BackgroundSpec[]; + width: number; + height: number; +} + +export function GlobalBackgroundLayer({ backgrounds, width, height }: GlobalBackgroundLayerProps): ReactElement { + return ( + <> + {backgrounds + .filter((bg) => bg.global) + .map((bg) => ( + + + {bg.image && isSafeImageUrl(bg.image) && ( + + )} + + ))} + + ); +} + +interface BackgroundLayerProps { + backgrounds: BackgroundSpec[]; +} + +export function BackgroundLayer({ backgrounds }: BackgroundLayerProps): ReactElement { + return ( + <> + {backgrounds + .filter((bg) => !bg.global) + .map((bg) => ( + + + {bg.image && isSafeImageUrl(bg.image) && ( + + )} + + ))} + + ); +} diff --git a/canvas/src/components/shared/EdgeLabel.tsx b/canvas/src/components/shared/EdgeLabel.tsx new file mode 100644 index 000000000..ac5eacb8f --- /dev/null +++ b/canvas/src/components/shared/EdgeLabel.tsx @@ -0,0 +1,60 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ReactElement } from 'react'; + +const FONT_SIZE = 12; +const PADDING_X = 4; +const PADDING_Y = 2; +const HEIGHT = FONT_SIZE + PADDING_Y * 2; + +interface EdgeLabelProps { + x: number; + y: number; + text: string; + k?: number; + background: string; + border: string; + color: string; +} + +export function EdgeLabel({ x, y, text, k = 1, background, border, color }: EdgeLabelProps): ReactElement { + const approxWidth = text.length * FONT_SIZE * 0.55 + PADDING_X * 2; + const scale = 1 / k; + + return ( + + + + {text} + + + ); +} diff --git a/canvas/src/components/shared/EdgeLines.tsx b/canvas/src/components/shared/EdgeLines.tsx new file mode 100644 index 000000000..0ed23cfd6 --- /dev/null +++ b/canvas/src/components/shared/EdgeLines.tsx @@ -0,0 +1,148 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React, { ReactElement } from 'react'; +import { midpoint } from '../../utils/edgeUtils'; + +type Line = { x1: number; y1: number; x2: number; y2: number }; + +interface EdgeGeometry { + fwd: Line; + bwd: Line | null; +} + +function shortenEnd(line: Line, amount: number): Line { + const dx = line.x2 - line.x1; + const dy = line.y2 - line.y1; + const len = Math.hypot(dx, dy); + if (len <= amount) return line; + const t = (len - amount) / len; + return { x1: line.x1, y1: line.y1, x2: line.x1 + dx * t, y2: line.y1 + dy * t }; +} + +function computeEdgeGeometry( + pts: Line, + bidirectional: boolean, + fwdStrokeWidth: number, + bwdStrokeWidth: number +): EdgeGeometry { + const fwdShorten = ARROW_SW_W * fwdStrokeWidth; + const bwdShorten = ARROW_SW_W * bwdStrokeWidth; + + if (!bidirectional) { + return { fwd: shortenEnd(pts, fwdShorten), bwd: null }; + } + const mid = midpoint(pts); + return { + fwd: shortenEnd({ x1: pts.x1, y1: pts.y1, x2: mid.x, y2: mid.y }, fwdShorten), + bwd: shortenEnd({ x1: pts.x2, y1: pts.y2, x2: mid.x, y2: mid.y }, bwdShorten), + }; +} + +const ARROW_SW_W = 2.5; +const ARROW_SW_H = 1.75; + +export interface LineStyle { + stroke: string; + strokeWidth: number; + strokeOpacity?: number; +} + +function markerId(nsPrefix: string, direction: 'fwd' | 'bwd'): string { + return `${nsPrefix}-arrow-${direction}`; +} + +interface EdgeArrowMarkerProps { + nsPrefix: string; + direction: 'fwd' | 'bwd'; + fill: string; +} + +function EdgeArrowMarker({ nsPrefix, direction, fill }: EdgeArrowMarkerProps): ReactElement { + return ( + + + + ); +} + +interface EdgeLinesProps { + pts: Line; + bidirectional: boolean; + nsPrefix: string; + fwdStyle: LineStyle; + bwdStyle?: LineStyle; + lineProps?: React.SVGProps; +} + +export function EdgeLines({ + pts, + bidirectional, + nsPrefix, + fwdStyle, + bwdStyle, + lineProps, +}: EdgeLinesProps): ReactElement { + const resolvedBwdStyle = bwdStyle ?? fwdStyle; + const { fwd, bwd } = computeEdgeGeometry(pts, bidirectional, fwdStyle.strokeWidth, resolvedBwdStyle.strokeWidth); + + return ( + <> + + + {bwd && } + + + {bwd && ( + + )} + + ); +} + +export function edgeLabelPoints( + pts: Line, + bidirectional: boolean, + fwdStrokeWidth: number, + bwdStrokeWidth: number +): { fwd: { x: number; y: number }; bwd: { x: number; y: number } | null } { + const { fwd, bwd } = computeEdgeGeometry(pts, bidirectional, fwdStrokeWidth, bwdStrokeWidth); + return { fwd: midpoint(fwd), bwd: bwd ? midpoint(bwd) : null }; +} diff --git a/canvas/src/components/shared/IconNode.tsx b/canvas/src/components/shared/IconNode.tsx new file mode 100644 index 000000000..ebd4be8a2 --- /dev/null +++ b/canvas/src/components/shared/IconNode.tsx @@ -0,0 +1,68 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ReactElement, SVGProps } from 'react'; +import { NodeSpec } from '../../model'; +import { ICON_PATHS } from '../../utils/icons'; +import { labelAttrs } from '../../utils/labelPosition'; + +export interface IconNodeProps { + node: NodeSpec; + displayLabel: string | undefined; + defaultFill: string; + fillOverride: string | undefined; + rectProps?: SVGProps; +} + +export function IconNode({ node, displayLabel, defaultFill, fillOverride, rectProps }: IconNodeProps): ReactElement { + const { width, height } = node; + const halfW = width / 2; + const halfH = height / 2; + const iconPath = node.icon ? ICON_PATHS[node.icon] : undefined; + const iconScale = Math.min(width, height) / 24; + const lAttrs = labelAttrs(halfW, halfH, node.labelPosition, node.labelPadding); + const iconColor = fillOverride ?? defaultFill; + + return ( + <> + + {iconPath ? ( + + + + ) : ( + + )} + {displayLabel && ( + + {displayLabel} + + )} + + ); +} diff --git a/canvas/src/components/shared/NodeRenderer.tsx b/canvas/src/components/shared/NodeRenderer.tsx new file mode 100644 index 000000000..7161cb25c --- /dev/null +++ b/canvas/src/components/shared/NodeRenderer.tsx @@ -0,0 +1,69 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React, { ReactElement } from 'react'; +import { NodeSpec } from '../../model'; +import { RectangleNode } from './RectangleNode'; +import { IconNode } from './IconNode'; +import { TextNode } from './TextNode'; + +export const DEFAULT_NODE_WIDTH = 48; +export const DEFAULT_NODE_HEIGHT = 48; +export { CORNER_RADIUS_RATIO } from './RectangleNode'; + +interface NodeRendererProps { + node: NodeSpec; + defaultFill: string; + groupProps?: React.SVGProps; + rectProps?: React.SVGProps; + labelOverride?: string; + fillOverride?: string; +} + +export function NodeRenderer({ + node, + defaultFill, + groupProps, + rectProps, + labelOverride, + fillOverride, +}: NodeRendererProps): ReactElement { + const kind = node.kind; + const displayLabel = labelOverride ?? node.label; + + return ( + + {kind === 'rectangle' && ( + + )} + {kind === 'icon' && ( + + )} + {kind === 'text' && ( + + )} + + ); +} diff --git a/canvas/src/components/shared/RectangleNode.tsx b/canvas/src/components/shared/RectangleNode.tsx new file mode 100644 index 000000000..62dec69c1 --- /dev/null +++ b/canvas/src/components/shared/RectangleNode.tsx @@ -0,0 +1,99 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React, { ReactElement } from 'react'; +import { NodeSpec } from '../../model'; +import { ICON_PATHS } from '../../utils/icons'; +import { labelAttrs } from '../../utils/labelPosition'; +import { isSafeImageUrl } from '../../utils/panelUtils'; +import { useCanvasTheme } from '../../hooks/useCanvasTheme'; + +export const ICON_FILL_RATIO = 0.6; +export const CORNER_RADIUS_RATIO = 0.2; + +export interface RectangleNodeProps { + node: NodeSpec; + displayLabel: string | undefined; + defaultFill: string; + fillOverride: string | undefined; + rectProps?: React.SVGProps; +} + +export function RectangleNode({ + node, + displayLabel, + defaultFill, + fillOverride, + rectProps, +}: RectangleNodeProps): ReactElement { + const { width, height } = node; + const halfW = width / 2; + const halfH = height / 2; + const iconSize = Math.min(width, height) * ICON_FILL_RATIO; + const iconScale = iconSize / 24; + const cornerRadius = Math.min(width, height) * CORNER_RADIUS_RATIO; + const lAttrs = labelAttrs(halfW, halfH, node.labelPosition, node.labelPadding); + const { nodeStroke } = useCanvasTheme(); + const iconPath = node.icon ? ICON_PATHS[node.icon] : undefined; + const fill = fillOverride ?? node.background ?? defaultFill; + + return ( + <> + + {node.backgroundImage && isSafeImageUrl(node.backgroundImage) && ( + + )} + {iconPath && ( + + + + )} + {displayLabel && ( + + {displayLabel} + + )} + + ); +} diff --git a/canvas/src/components/shared/TextNode.tsx b/canvas/src/components/shared/TextNode.tsx new file mode 100644 index 000000000..b25d6fc2f --- /dev/null +++ b/canvas/src/components/shared/TextNode.tsx @@ -0,0 +1,59 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ReactElement } from 'react'; +import { NodeSpec } from '../../model'; + +const DEFAULT_TEXT_COLOR = 'currentColor'; + +export interface TextNodeProps { + node: NodeSpec; + displayLabel: string | undefined; + fillOverride: string | undefined; + rectProps?: React.SVGProps; +} + +export function TextNode({ node, displayLabel, fillOverride, rectProps }: TextNodeProps): ReactElement { + const { width, height } = node; + const halfW = width / 2; + const halfH = height / 2; + const fontSize = Math.max(10, Math.min(width, height) * 0.35); + const textColor = fillOverride ?? DEFAULT_TEXT_COLOR; + + return ( + <> + + {displayLabel && ( + + {displayLabel} + + )} + + ); +} diff --git a/canvas/src/contexts/EditorContext.tsx b/canvas/src/contexts/EditorContext.tsx new file mode 100644 index 000000000..26064692a --- /dev/null +++ b/canvas/src/contexts/EditorContext.tsx @@ -0,0 +1,61 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { createContext, ReactElement, ReactNode, useContext, useReducer } from 'react'; +import { EditorState, editorReducer, INITIAL_EDITOR_STATE } from '../utils/editorReducer'; + +export interface EditorContextValue { + state: EditorState; + selectItems: (ids: Set) => void; + clearSelection: () => void; + hoverNode: (id: string) => void; + unhoverNode: (id: string) => void; + startSelectionRect: () => void; + startMove: () => void; + startDragEdge: () => void; + startResize: () => void; + endInteraction: () => void; +} + +export const EditorContext = createContext(null); + +export function useEditorContext(): EditorContextValue { + const ctx = useContext(EditorContext); + if (!ctx) { + throw new Error('useEditorContext must be used inside an EditorStateProvider'); + } + return ctx; +} + +export function EditorStateProvider({ children }: { children: ReactNode }): ReactElement { + const [state, dispatch] = useReducer(editorReducer, INITIAL_EDITOR_STATE); + + return ( + dispatch({ type: 'SELECT_ITEMS', ids }), + clearSelection: () => dispatch({ type: 'CLEAR_SELECTION' }), + hoverNode: (id) => dispatch({ type: 'HOVER_NODE', id }), + unhoverNode: (id) => dispatch({ type: 'UNHOVER_NODE', id }), + startSelectionRect: () => dispatch({ type: 'SELECTION_RECT_START' }), + startMove: () => dispatch({ type: 'MOVE_START' }), + startDragEdge: () => dispatch({ type: 'DRAG_EDGE_START' }), + startResize: () => dispatch({ type: 'RESIZE_START' }), + endInteraction: () => dispatch({ type: 'INTERACTION_END' }), + }} + > + {children} + + ); +} diff --git a/canvas/src/contexts/SpecContext.test.tsx b/canvas/src/contexts/SpecContext.test.tsx new file mode 100644 index 000000000..b910b47c3 --- /dev/null +++ b/canvas/src/contexts/SpecContext.test.tsx @@ -0,0 +1,183 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { act, renderHook } from '@testing-library/react'; +import React, { ReactNode, useState } from 'react'; +import { BackgroundSpec, CanvasSpec } from '../model'; +import { EditorStateProvider, useEditorContext } from './EditorContext'; +import { SpecProvider, useSpecContext } from './SpecContext'; + +function useTestHook(): { spec: ReturnType; editor: ReturnType } { + return { spec: useSpecContext(), editor: useEditorContext() }; +} + +function makeBackground(id: string, x = 0, y = 0, width = 100, height = 50): BackgroundSpec { + return { id, x, y, width, height }; +} + +function makeWrapper(initialSpec: CanvasSpec) { + return function Wrapper({ children }: { children: ReactNode }): React.ReactElement { + const [spec, setSpec] = useState(initialSpec); + return ( + + + {children} + + + ); + }; +} + +describe('SpecContext — background operations', () => { + describe('addBackground', () => { + it('appends a background with the given geometry', async () => { + const { result } = renderHook(() => useSpecContext(), { wrapper: makeWrapper({}) }); + await act(async () => { + result.current.addBackground(10, 20, 300, 150); + }); + const backgrounds = result.current.spec.backgrounds ?? []; + expect(backgrounds).toHaveLength(1); + expect(backgrounds[0]).toMatchObject({ x: 10, y: 20, width: 300, height: 150 }); + }); + + it('assigns a unique id', async () => { + const { result } = renderHook(() => useSpecContext(), { wrapper: makeWrapper({}) }); + await act(async () => { + result.current.addBackground(0, 0, 100, 100); + }); + await act(async () => { + result.current.addBackground(0, 0, 100, 100); + }); + const ids = (result.current.spec.backgrounds ?? []).map((bg) => bg.id); + expect(new Set(ids).size).toBe(2); + }); + + it('appends without overwriting existing backgrounds', async () => { + const initial: CanvasSpec = { backgrounds: [makeBackground('existing')] }; + const { result } = renderHook(() => useSpecContext(), { wrapper: makeWrapper(initial) }); + await act(async () => { + result.current.addBackground(5, 5, 50, 50); + }); + expect(result.current.spec.backgrounds).toHaveLength(2); + expect(result.current.spec.backgrounds?.[0]?.id).toBe('existing'); + }); + }); + + describe('moveBackground', () => { + it('swaps with previous element when direction is up', async () => { + const initial: CanvasSpec = { + backgrounds: [makeBackground('a'), makeBackground('b'), makeBackground('c')], + }; + const { result } = renderHook(() => useSpecContext(), { wrapper: makeWrapper(initial) }); + await act(async () => { + result.current.moveBackground('b', 'up'); + }); + const ids = (result.current.spec.backgrounds ?? []).map((bg) => bg.id); + expect(ids).toEqual(['b', 'a', 'c']); + }); + + it('swaps with next element when direction is down', async () => { + const initial: CanvasSpec = { + backgrounds: [makeBackground('a'), makeBackground('b'), makeBackground('c')], + }; + const { result } = renderHook(() => useSpecContext(), { wrapper: makeWrapper(initial) }); + await act(async () => { + result.current.moveBackground('b', 'down'); + }); + const ids = (result.current.spec.backgrounds ?? []).map((bg) => bg.id); + expect(ids).toEqual(['a', 'c', 'b']); + }); + + it('no-ops when moving the first element up', async () => { + const initial: CanvasSpec = { + backgrounds: [makeBackground('a'), makeBackground('b')], + }; + const { result } = renderHook(() => useSpecContext(), { wrapper: makeWrapper(initial) }); + await act(async () => { + result.current.moveBackground('a', 'up'); + }); + const ids = (result.current.spec.backgrounds ?? []).map((bg) => bg.id); + expect(ids).toEqual(['a', 'b']); + }); + + it('no-ops when moving the last element down', async () => { + const initial: CanvasSpec = { + backgrounds: [makeBackground('a'), makeBackground('b')], + }; + const { result } = renderHook(() => useSpecContext(), { wrapper: makeWrapper(initial) }); + await act(async () => { + result.current.moveBackground('b', 'down'); + }); + const ids = (result.current.spec.backgrounds ?? []).map((bg) => bg.id); + expect(ids).toEqual(['a', 'b']); + }); + + it('no-ops for an unknown id', async () => { + const initial: CanvasSpec = { + backgrounds: [makeBackground('a'), makeBackground('b')], + }; + const { result } = renderHook(() => useSpecContext(), { wrapper: makeWrapper(initial) }); + await act(async () => { + result.current.moveBackground('unknown', 'up'); + }); + const ids = (result.current.spec.backgrounds ?? []).map((bg) => bg.id); + expect(ids).toEqual(['a', 'b']); + }); + }); + + describe('deleteSelected — backgrounds', () => { + it('removes the selected background', async () => { + const initial: CanvasSpec = { + backgrounds: [makeBackground('a'), makeBackground('b'), makeBackground('c')], + }; + const { result } = renderHook(() => useTestHook(), { wrapper: makeWrapper(initial) }); + await act(async () => { + result.current.editor.selectItems(new Set(['b'])); + }); + await act(async () => { + result.current.spec.deleteSelected(); + }); + const ids = (result.current.spec.spec.backgrounds ?? []).map((bg) => bg.id); + expect(ids).toEqual(['a', 'c']); + }); + + it('leaves backgrounds untouched when none are selected', async () => { + const initial: CanvasSpec = { + backgrounds: [makeBackground('a'), makeBackground('b')], + }; + const { result } = renderHook(() => useTestHook(), { wrapper: makeWrapper(initial) }); + await act(async () => { + result.current.spec.deleteSelected(); + }); + expect(result.current.spec.spec.backgrounds).toHaveLength(2); + }); + + it('does not remove nodes or edges when only a background is selected', async () => { + const initial: CanvasSpec = { + backgrounds: [makeBackground('bg1')], + nodes: [{ id: 'n1', x: 0, y: 0, width: 40, height: 40, kind: 'rectangle' }], + edges: [{ id: 'e1', source: 'n1', target: '' }], + }; + const { result } = renderHook(() => useTestHook(), { wrapper: makeWrapper(initial) }); + await act(async () => { + result.current.editor.selectItems(new Set(['bg1'])); + }); + await act(async () => { + result.current.spec.deleteSelected(); + }); + expect(result.current.spec.spec.backgrounds).toHaveLength(0); + expect(result.current.spec.spec.nodes).toHaveLength(1); + expect(result.current.spec.spec.edges).toHaveLength(1); + }); + }); +}); diff --git a/canvas/src/contexts/SpecContext.tsx b/canvas/src/contexts/SpecContext.tsx new file mode 100644 index 000000000..ad27c42d0 --- /dev/null +++ b/canvas/src/contexts/SpecContext.tsx @@ -0,0 +1,180 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { createContext, ReactElement, ReactNode, useContext, useMemo } from 'react'; +import { produce } from 'immer'; +import { BackgroundSpec, EdgeSpec, NodeSpec, CanvasSpec } from '../model'; +import { DEFAULT_NODE_WIDTH, DEFAULT_NODE_HEIGHT } from '../components/shared/NodeRenderer'; +import { generateId } from '../utils/generateId'; +import { useEditorContext } from './EditorContext'; + +export interface SpecContextValue { + spec: CanvasSpec; + nodeById: Map; + edgeById: Map; + backgroundById: Map; + updateSpec: (spec: CanvasSpec) => void; + addNode: (x: number, y: number) => void; + addBackground: (x: number, y: number, width: number, height: number) => void; + moveBackground: (id: string, direction: 'up' | 'down') => void; + deleteSelected: () => void; + onNodePropertiesChange: (updated: NodeSpec) => void; + onEdgePropertiesChange: (updated: EdgeSpec) => void; + onBackgroundPropertiesChange: (updated: BackgroundSpec) => void; +} + +export const SpecContext = createContext(null); + +export function useSpecContext(): SpecContextValue { + const ctx = useContext(SpecContext); + if (!ctx) { + throw new Error('useSpecContext must be used inside a SpecProvider'); + } + return ctx; +} + +interface SpecProviderProps { + spec: CanvasSpec; + onChange: (v: CanvasSpec) => void; + children: ReactNode; +} + +export function SpecProvider({ spec, onChange, children }: SpecProviderProps): ReactElement { + const { state, clearSelection, selectItems } = useEditorContext(); + + const nodeById = useMemo(() => { + const nodes = spec.nodes ?? []; + return new Map(nodes.map((n) => [n.id, n])); + }, [spec.nodes]); + + const edgeById = useMemo(() => { + const edges = spec.edges ?? []; + return new Map(edges.map((ed) => [ed.id, ed])); + }, [spec.edges]); + + const backgroundById = useMemo(() => { + const backgrounds = spec.backgrounds ?? []; + return new Map(backgrounds.map((bg) => [bg.id, bg])); + }, [spec.backgrounds]); + + function addNode(x: number, y: number): void { + const id = generateId('node'); + onChange( + produce(spec, (draft) => { + (draft.nodes ??= []).push({ + id, + x, + y, + width: DEFAULT_NODE_WIDTH, + height: DEFAULT_NODE_HEIGHT, + kind: 'icon', + }); + }) + ); + selectItems(new Set([id])); + } + + function addBackground(x: number, y: number, width: number, height: number): void { + const id = generateId('bg'); + onChange( + produce(spec, (draft) => { + (draft.backgrounds ??= []).push({ id, x, y, width, height }); + }) + ); + selectItems(new Set([id])); + } + + function moveBackground(id: string, direction: 'up' | 'down'): void { + onChange( + produce(spec, (draft) => { + const arr = draft.backgrounds ?? []; + const idx = arr.findIndex((bg) => bg.id === id); + const swapIdx = direction === 'up' ? idx - 1 : idx + 1; + if (idx === -1 || swapIdx < 0 || swapIdx >= arr.length) { + return; + } + const tmp = arr[idx]!; + arr[idx] = arr[swapIdx]!; + arr[swapIdx] = tmp; + }) + ); + } + + function deleteSelected(): void { + const { selectedIds } = state; + onChange( + produce(spec, (draft) => { + draft.backgrounds = (draft.backgrounds ?? []).filter((bg) => !selectedIds.has(bg.id)); + draft.nodes = (draft.nodes ?? []).filter((n) => !selectedIds.has(n.id)); + draft.edges = (draft.edges ?? []).filter( + (ed) => !selectedIds.has(ed.id) && !selectedIds.has(ed.source) && !selectedIds.has(ed.target) + ); + }) + ); + clearSelection(); + } + + function onNodePropertiesChange(updated: NodeSpec): void { + onChange( + produce(spec, (draft) => { + const idx = (draft.nodes ?? []).findIndex((n) => n.id === updated.id); + if (idx !== -1 && draft.nodes) { + draft.nodes[idx] = updated; + } + }) + ); + } + + function onEdgePropertiesChange(updated: EdgeSpec): void { + onChange( + produce(spec, (draft) => { + const idx = (draft.edges ?? []).findIndex((ed) => ed.id === updated.id); + if (idx !== -1 && draft.edges) { + draft.edges[idx] = updated; + } + }) + ); + } + + function onBackgroundPropertiesChange(updated: BackgroundSpec): void { + onChange( + produce(spec, (draft) => { + const idx = (draft.backgrounds ?? []).findIndex((bg) => bg.id === updated.id); + if (idx !== -1 && draft.backgrounds) { + draft.backgrounds[idx] = updated; + } + }) + ); + } + + return ( + + {children} + + ); +} diff --git a/canvas/src/contexts/ZoomContext.tsx b/canvas/src/contexts/ZoomContext.tsx new file mode 100644 index 000000000..69c9bcc05 --- /dev/null +++ b/canvas/src/contexts/ZoomContext.tsx @@ -0,0 +1,31 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { createContext, ReactNode, useContext } from 'react'; +import { UseZoomResult } from '../hooks/useZoom'; + +export type ZoomContextValue = Pick; + +export const ZoomContext = createContext(null); + +export function useZoomContext(): ZoomContextValue { + const ctx = useContext(ZoomContext); + if (!ctx) { + throw new Error('useZoomContext must be used inside a ZoomProvider'); + } + return ctx; +} + +export function ZoomProvider({ value, children }: { value: ZoomContextValue; children: ReactNode }): ReactNode { + return {children}; +} diff --git a/canvas/src/env.d.ts b/canvas/src/env.d.ts new file mode 100644 index 000000000..c375ce9fd --- /dev/null +++ b/canvas/src/env.d.ts @@ -0,0 +1,14 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// diff --git a/canvas/src/getPluginModule.ts b/canvas/src/getPluginModule.ts new file mode 100644 index 000000000..063431fab --- /dev/null +++ b/canvas/src/getPluginModule.ts @@ -0,0 +1,30 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { PluginModuleResource, PluginModuleSpec } from '@perses-dev/plugin-system'; +import packageJson from '../package.json'; + +/** + * Returns the plugin module information from package.json + */ +export function getPluginModule(): PluginModuleResource { + const { name, version, perses } = packageJson; + return { + kind: 'PluginModule', + metadata: { + name, + version, + }, + spec: perses as PluginModuleSpec, + }; +} diff --git a/canvas/src/hooks/useCanvasTheme.ts b/canvas/src/hooks/useCanvasTheme.ts new file mode 100644 index 000000000..65b881180 --- /dev/null +++ b/canvas/src/hooks/useCanvasTheme.ts @@ -0,0 +1,49 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { useTheme } from '@mui/material'; +import { useChartsTheme } from '@perses-dev/components'; + +export interface CanvasTheme { + palette: string[]; + selection: string; + connection: string; + snapHighlight: string; + background: string; + divider: string; + text: string; + labelBackground: string; + labelBorder: string; + labelText: string; + nodeStroke: string; + nodeDefaultFill: string; +} + +export function useCanvasTheme(): CanvasTheme { + const muiTheme = useTheme(); + const chartsTheme = useChartsTheme(); + return { + palette: chartsTheme.thresholds.palette, + selection: muiTheme.palette.warning.main, + connection: muiTheme.palette.info.main, + snapHighlight: muiTheme.palette.success.main, + background: muiTheme.palette.background.paper, + divider: muiTheme.palette.divider, + text: muiTheme.palette.text.primary, + labelBackground: muiTheme.palette.background.paper, + labelBorder: muiTheme.palette.divider, + labelText: muiTheme.palette.text.primary, + nodeStroke: muiTheme.palette.background.paper, + nodeDefaultFill: muiTheme.palette.primary.main, + }; +} diff --git a/canvas/src/hooks/useEdgeConnect.test.tsx b/canvas/src/hooks/useEdgeConnect.test.tsx new file mode 100644 index 000000000..a0b6e2888 --- /dev/null +++ b/canvas/src/hooks/useEdgeConnect.test.tsx @@ -0,0 +1,147 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { act, renderHook } from '@testing-library/react'; +import { produce } from 'immer'; +import React from 'react'; +import { CanvasSpec, NodeSpec } from '../model'; +import { makeWrapper } from '../test-utils/hookWrapper'; +import { useEdgeConnect } from './useEdgeConnect'; + +function makeNode(id: string, x: number, y: number, width = 100, height = 60): NodeSpec { + return { id, x, y, width, height, kind: 'rectangle' }; +} + +function makeCircleEvent(overrides: Partial = {}): React.PointerEvent { + return { + pointerId: 1, + stopPropagation: jest.fn(), + currentTarget: { setPointerCapture: jest.fn() }, + ...overrides, + } as unknown as React.PointerEvent; +} + +describe('useEdgeConnect', () => { + it('dragEdge is null initially', () => { + const { result } = renderHook(() => useEdgeConnect(), { wrapper: makeWrapper() }); + expect(result.current.dragEdge).toBeNull(); + }); + + it('beginEdgeDrag sets dragEdge', async () => { + const { result } = renderHook(() => useEdgeConnect(), { wrapper: makeWrapper() }); + await act(async () => { + result.current.beginEdgeDrag('a', 'n', 10, 20); + }); + expect(result.current.dragEdge).toMatchObject({ sourceId: 'a', sourceAnchor: 'n', x1: 10, y1: 20, x2: 10, y2: 20 }); + }); + + it('resetEdgeDrag clears dragEdge', async () => { + const { result } = renderHook(() => useEdgeConnect(), { wrapper: makeWrapper() }); + await act(async () => { + result.current.beginEdgeDrag('a', 'n', 0, 0); + }); + await act(async () => { + result.current.resetEdgeDrag(); + }); + expect(result.current.dragEdge).toBeNull(); + }); + + it('applyEdgeDrag creates a new free-endpoint edge when not snapped', async () => { + const spec: CanvasSpec = { nodes: [makeNode('a', 0, 0)] }; + const { result } = renderHook(() => useEdgeConnect(), { wrapper: makeWrapper(spec) }); + await act(async () => { + result.current.beginEdgeDrag('a', 'e', 50, 0); + }); + const draft = produce({ ...spec, edges: [] as CanvasSpec['edges'] }, (d) => { + if (result.current.dragEdge) { + result.current.dragEdge.x2 = 300; + result.current.dragEdge.y2 = 300; + } + result.current.applyEdgeDrag(d); + }); + expect(draft.edges?.length).toBe(1); + expect(draft.edges?.[0]?.source).toBe('a'); + expect(draft.edges?.[0]?.target).toBe(''); + expect(draft.edges?.[0]?.x2).toBe(300); + }); + + it('applyEdgeDrag creates a node-connected edge when snapped', async () => { + const nodes = [makeNode('a', 0, 0), makeNode('b', 200, 0)]; + const spec: CanvasSpec = { nodes }; + const { result } = renderHook(() => useEdgeConnect(), { wrapper: makeWrapper(spec) }); + await act(async () => { + result.current.beginEdgeDrag('a', 'e', 50, 0); + }); + const draft = produce({ ...spec, edges: [] as CanvasSpec['edges'] }, (d) => { + if (result.current.dragEdge) { + result.current.dragEdge.x2 = 150; + result.current.dragEdge.y2 = 0; + result.current.dragEdge.snapTargetId = 'b'; + result.current.dragEdge.snapTargetAnchor = 'w'; + } + result.current.applyEdgeDrag(d); + }); + expect(draft.edges?.[0]?.target).toBe('b'); + expect(draft.edges?.[0]?.targetAnchor).toBe('w'); + expect(draft.edges?.[0]?.x2).toBeUndefined(); + }); + + it('beginEndpointDrag returns false for unknown edge id', async () => { + const { result } = renderHook(() => useEdgeConnect(), { wrapper: makeWrapper() }); + let ok = true; + await act(async () => { + ok = result.current.beginEndpointDrag(makeCircleEvent(), 'no-such-edge', 'target', 0, 0, 'src', 'n'); + }); + expect(ok).toBe(false); + }); + + it('beginEndpointDrag sets dragEdge for an existing edge', async () => { + const nodes = [makeNode('a', 0, 0), makeNode('b', 200, 0)]; + const edges = [{ id: 'e1', source: 'a', target: 'b', sourceAnchor: 'e' as const, targetAnchor: 'w' as const }]; + const spec: CanvasSpec = { nodes, edges }; + const { result } = renderHook(() => useEdgeConnect(), { wrapper: makeWrapper(spec) }); + let ok = false; + await act(async () => { + ok = result.current.beginEndpointDrag(makeCircleEvent(), 'e1', 'target', 50, 0, 'a', 'e'); + }); + expect(ok).toBe(true); + expect(result.current.dragEdge?.editingEdgeId).toBe('e1'); + expect(result.current.dragEdge?.editingEnd).toBe('target'); + }); + + it('applyEdgeDrag reconnects target when editingEnd is target', async () => { + const nodes = [makeNode('a', 0, 0), makeNode('b', 200, 0), makeNode('c', 0, 200)]; + const edges = [{ id: 'e1', source: 'a', target: 'b', sourceAnchor: 'e' as const, targetAnchor: 'w' as const }]; + const spec: CanvasSpec = { nodes, edges }; + const { result } = renderHook(() => useEdgeConnect(), { wrapper: makeWrapper(spec) }); + await act(async () => { + result.current.beginEndpointDrag(makeCircleEvent(), 'e1', 'target', 50, 0, 'a', 'e'); + }); + const draft = produce(spec, (d) => { + if (result.current.dragEdge) { + result.current.dragEdge.snapTargetId = 'c'; + result.current.dragEdge.snapTargetAnchor = 'n'; + } + result.current.applyEdgeDrag(d); + }); + expect(draft.edges?.[0]?.target).toBe('c'); + expect(draft.edges?.[0]?.targetAnchor).toBe('n'); + }); + + it('applyEdgeDrag is a no-op when dragEdge is null', () => { + const spec: CanvasSpec = { edges: [{ id: 'e1', source: 'a', target: 'b' }] }; + const { result } = renderHook(() => useEdgeConnect(), { wrapper: makeWrapper(spec) }); + const draft = produce(spec, (d) => result.current.applyEdgeDrag(d)); + expect(draft).toEqual(spec); + }); +}); diff --git a/canvas/src/hooks/useEdgeConnect.ts b/canvas/src/hooks/useEdgeConnect.ts new file mode 100644 index 000000000..0a2883764 --- /dev/null +++ b/canvas/src/hooks/useEdgeConnect.ts @@ -0,0 +1,221 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { PointerEvent, useCallback, useState } from 'react'; +import { AnchorPoint, EdgeSpec, CanvasSpec } from '../model'; +import { anchorPosition, edgeEndpoints, pointInsideNode, snapTarget } from '../utils/edgeUtils'; +import { useZoomContext } from '../contexts/ZoomContext'; +import { useSpecContext } from '../contexts/SpecContext'; +import { generateId } from '../utils/generateId'; + +const SNAP_RADIUS = 20; + +export interface DragEdge { + sourceId: string; + sourceAnchor: AnchorPoint; + x1: number; + y1: number; + x2: number; + y2: number; + snapTargetId?: string; + snapTargetAnchor?: AnchorPoint; + editingEdgeId?: string; + editingEnd?: 'source' | 'target'; +} + +interface SnapResult { + node: { id: string }; + anchor: AnchorPoint; +} + +function reconnectTarget(edge: EdgeSpec, snap: SnapResult | null, pt: { x: number; y: number }): void { + if (snap) { + edge.target = snap.node.id; + edge.targetAnchor = snap.anchor; + edge.x2 = undefined; + edge.y2 = undefined; + } else { + edge.target = ''; + edge.targetAnchor = undefined; + edge.x2 = pt.x; + edge.y2 = pt.y; + } +} + +function reconnectSource(edge: EdgeSpec, snap: SnapResult | null, pt: { x: number; y: number }): void { + if (snap) { + edge.source = snap.node.id; + edge.sourceAnchor = snap.anchor; + } else if (edge.target) { + // Swap source/target when dragging the source end to a free position: + // the existing target becomes the new source, and the endpoint goes free. + const oldTarget = edge.target; + const oldTargetAnchor = edge.targetAnchor; + edge.target = edge.source; + edge.targetAnchor = edge.sourceAnchor; + edge.source = oldTarget; + edge.sourceAnchor = oldTargetAnchor; + edge.x2 = pt.x; + edge.y2 = pt.y; + edge.target = ''; + edge.targetAnchor = undefined; + } else { + edge.x2 = pt.x; + edge.y2 = pt.y; + } +} + +function buildNewEdge(dragEdge: DragEdge, snap: SnapResult | null, pt: { x: number; y: number }): EdgeSpec { + const id = generateId('edge'); + if (snap) { + return { + id, + source: dragEdge.sourceId, + target: snap.node.id, + sourceAnchor: dragEdge.sourceAnchor, + targetAnchor: snap.anchor, + }; + } + return { + id, + source: dragEdge.sourceId, + target: '', + sourceAnchor: dragEdge.sourceAnchor, + x2: pt.x, + y2: pt.y, + }; +} + +interface UseEdgeConnectResult { + dragEdge: DragEdge | null; + beginEdgeDrag: (nodeId: string, anchor: AnchorPoint, x: number, y: number) => void; + beginEndpointDrag: ( + event: PointerEvent, + edgeId: string, + end: 'source' | 'target', + fixedX: number, + fixedY: number, + fixedNodeId: string, + fixedAnchor: AnchorPoint + ) => boolean; + updateEdgeDrag: (event: PointerEvent) => void; + resetEdgeDrag: () => void; + applyEdgeDrag: (draft: CanvasSpec) => void; +} + +export function useEdgeConnect(): UseEdgeConnectResult { + const { spec, nodeById, edgeById } = useSpecContext(); + const { toCanvasPoint } = useZoomContext(); + const [dragEdge, setDragEdge] = useState(null); + + const beginEdgeDrag = useCallback((nodeId: string, anchor: AnchorPoint, x: number, y: number): void => { + setDragEdge({ sourceId: nodeId, sourceAnchor: anchor, x1: x, y1: y, x2: x, y2: y }); + }, []); + + const beginEndpointDrag = useCallback( + ( + event: PointerEvent, + edgeId: string, + end: 'source' | 'target', + fixedX: number, + fixedY: number, + fixedNodeId: string, + fixedAnchor: AnchorPoint + ): boolean => { + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + const edge = edgeById.get(edgeId); + if (!edge) { + return false; + } + const pts = edgeEndpoints(edge, nodeById); + if (!pts) { + return false; + } + const movingX = end === 'target' ? pts.x2 : pts.x1; + const movingY = end === 'target' ? pts.y2 : pts.y1; + setDragEdge({ + sourceId: fixedNodeId, + sourceAnchor: fixedAnchor, + x1: fixedX, + y1: fixedY, + x2: movingX, + y2: movingY, + editingEdgeId: edgeId, + editingEnd: end, + }); + return true; + }, + [edgeById, nodeById] + ); + + const updateEdgeDrag = useCallback( + (event: PointerEvent): void => { + const point = toCanvasPoint(event); + setDragEdge((current) => { + if (!current) { + return null; + } + const nodes = spec.nodes ?? []; + const snap = snapTarget(nodes, point, current.sourceId, SNAP_RADIUS); + return { + ...current, + x2: snap ? anchorPosition(snap.node, snap.anchor).x : point.x, + y2: snap ? anchorPosition(snap.node, snap.anchor).y : point.y, + snapTargetId: snap?.node.id, + snapTargetAnchor: snap?.anchor, + }; + }); + }, + [spec.nodes, toCanvasPoint] + ); + + const applyEdgeDrag = useCallback( + (draft: CanvasSpec): void => { + if (!dragEdge) { + return; + } + const pt = { x: dragEdge.x2, y: dragEdge.y2 }; + const snapNode = dragEdge.snapTargetId ? nodeById.get(dragEdge.snapTargetId) : undefined; + const snap = + snapNode !== undefined && dragEdge.snapTargetAnchor !== undefined + ? { node: snapNode, anchor: dragEdge.snapTargetAnchor } + : null; + + if (dragEdge.editingEdgeId !== undefined && dragEdge.editingEnd !== undefined) { + const edge = (draft.edges ?? []).find((ed) => ed.id === dragEdge.editingEdgeId); + if (!edge) { + return; + } + if (dragEdge.editingEnd === 'target') { + reconnectTarget(edge, snap, pt); + } else { + reconnectSource(edge, snap, pt); + } + } else { + const sourceNode = nodeById.get(dragEdge.sourceId); + if (!snap && sourceNode && pointInsideNode(sourceNode, pt, SNAP_RADIUS)) { + return; + } + (draft.edges ??= []).push(buildNewEdge(dragEdge, snap, pt)); + } + }, + [dragEdge, nodeById] + ); + + const resetEdgeDrag = useCallback((): void => { + setDragEdge(null); + }, []); + + return { dragEdge, beginEdgeDrag, beginEndpointDrag, updateEdgeDrag, resetEdgeDrag, applyEdgeDrag }; +} diff --git a/canvas/src/hooks/useNodeMove.test.tsx b/canvas/src/hooks/useNodeMove.test.tsx new file mode 100644 index 000000000..0bad80e12 --- /dev/null +++ b/canvas/src/hooks/useNodeMove.test.tsx @@ -0,0 +1,131 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { act, renderHook } from '@testing-library/react'; +import { produce } from 'immer'; +import React from 'react'; +import { CanvasSpec, NodeSpec } from '../model'; +import { makeWrapper } from '../test-utils/hookWrapper'; +import { useEditorContext } from '../contexts/EditorContext'; +import { useNodeMove } from './useNodeMove'; + +function makeNode(id: string, x: number, y: number): NodeSpec { + return { id, x, y, width: 40, height: 40, kind: 'rectangle' }; +} + +function makePointerEvent(overrides: Partial = {}): React.PointerEvent { + return { + buttons: 1, + movementX: 0, + movementY: 0, + pointerId: 1, + stopPropagation: jest.fn(), + currentTarget: { setPointerCapture: jest.fn() }, + ...overrides, + } as unknown as React.PointerEvent; +} + +function useTestHook(): { editor: ReturnType; move: ReturnType } { + return { editor: useEditorContext(), move: useNodeMove() }; +} + +describe('useNodeMove', () => { + it('applyMove is a no-op when no drag is active', () => { + const spec: CanvasSpec = { nodes: [makeNode('a', 100, 100)] }; + const { result } = renderHook(() => useNodeMove(), { wrapper: makeWrapper(spec) }); + const draft = produce(spec, (d) => result.current.applyMove(d)); + expect(draft.nodes?.[0]?.x).toBe(100); + }); + + it('selectNode returns the id when the node is not selected', async () => { + const spec: CanvasSpec = { nodes: [makeNode('a', 0, 0)] }; + const { result } = renderHook(() => useNodeMove(), { wrapper: makeWrapper(spec) }); + let returned: string | null = null; + await act(async () => { + returned = result.current.selectNode(makePointerEvent(), 'a'); + }); + expect(returned).toBe('a'); + }); + + it('selectNode returns null when the node is already selected', async () => { + const spec: CanvasSpec = { nodes: [makeNode('a', 10, 20)] }; + const { result } = renderHook(() => useTestHook(), { wrapper: makeWrapper(spec) }); + await act(async () => { + result.current.editor.selectItems(new Set(['a'])); + }); + let returned: string | null = 'not-set'; + await act(async () => { + returned = result.current.move.selectNode(makePointerEvent(), 'a'); + }); + expect(returned).toBeNull(); + }); + + it('applyMove translates nodes by accumulated delta', async () => { + const spec: CanvasSpec = { nodes: [makeNode('a', 10, 20)] }; + const { result } = renderHook(() => useTestHook(), { wrapper: makeWrapper(spec) }); + await act(async () => { + result.current.editor.selectItems(new Set(['a'])); + }); + await act(async () => { + result.current.move.selectNode(makePointerEvent(), 'a'); + }); + await act(async () => { + result.current.move.updateMove(makePointerEvent({ movementX: 5, movementY: 3 }), 'a'); + }); + await act(async () => { + result.current.move.updateMove(makePointerEvent({ movementX: 5, movementY: 3 }), 'a'); + }); + const draft = produce(spec, (d) => result.current.move.applyMove(d)); + expect(draft.nodes?.[0]?.x).toBeCloseTo(20); + expect(draft.nodes?.[0]?.y).toBeCloseTo(26); + }); + + it('applyMove also translates free edge endpoints', async () => { + const spec: CanvasSpec = { + nodes: [makeNode('a', 0, 0)], + edges: [{ id: 'e1', source: 'a', target: '', x2: 50, y2: 50 }], + }; + const { result } = renderHook(() => useTestHook(), { wrapper: makeWrapper(spec) }); + await act(async () => { + result.current.editor.selectItems(new Set(['a', 'e1'])); + }); + await act(async () => { + result.current.move.selectNode(makePointerEvent(), 'a'); + }); + await act(async () => { + result.current.move.updateMove(makePointerEvent({ movementX: 10, movementY: 10 }), 'a'); + }); + const draft = produce(spec, (d) => result.current.move.applyMove(d)); + expect(draft.edges?.[0]?.x2).toBeCloseTo(60); + expect(draft.edges?.[0]?.y2).toBeCloseTo(60); + }); + + it('resetMove clears the drag so applyMove becomes a no-op', async () => { + const spec: CanvasSpec = { nodes: [makeNode('a', 0, 0)] }; + const { result } = renderHook(() => useTestHook(), { wrapper: makeWrapper(spec) }); + await act(async () => { + result.current.editor.selectItems(new Set(['a'])); + }); + await act(async () => { + result.current.move.selectNode(makePointerEvent(), 'a'); + }); + await act(async () => { + result.current.move.updateMove(makePointerEvent({ movementX: 99, movementY: 99 }), 'a'); + }); + await act(async () => { + result.current.move.resetMove(); + }); + const draft = produce(spec, (d) => result.current.move.applyMove(d)); + expect(draft.nodes?.[0]?.x).toBe(0); + }); +}); diff --git a/canvas/src/hooks/useNodeMove.ts b/canvas/src/hooks/useNodeMove.ts new file mode 100644 index 000000000..24ba33ccf --- /dev/null +++ b/canvas/src/hooks/useNodeMove.ts @@ -0,0 +1,112 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { PointerEvent, useCallback, useState } from 'react'; +import { CanvasSpec } from '../model'; +import { useZoomContext } from '../contexts/ZoomContext'; +import { useEditorContext } from '../contexts/EditorContext'; +import { useSpecContext } from '../contexts/SpecContext'; + +interface MoveDrag { + totalDx: number; + totalDy: number; + origNodes: Array<{ id: string; x: number; y: number }>; + origEdges: Array<{ id: string; x2: number; y2: number }>; +} + +interface UseNodeMoveResult { + selectNode: (event: PointerEvent, id: string) => string | null; + updateMove: (event: PointerEvent, id: string) => void; + applyMove: (draft: CanvasSpec) => void; + resetMove: () => void; +} + +export function useNodeMove(): UseNodeMoveResult { + const { spec } = useSpecContext(); + const { + state: { selectedIds }, + } = useEditorContext(); + const { transform } = useZoomContext(); + const [moveDrag, setMoveDrag] = useState(null); + + const selectNode = useCallback( + (event: PointerEvent, id: string): string | null => { + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + if (!selectedIds.has(id)) { + return id; + } + const origNodes = (spec.nodes ?? []) + .filter((n) => selectedIds.has(n.id)) + .map((n) => ({ id: n.id, x: n.x, y: n.y })); + const origEdges = (spec.edges ?? []) + .filter( + (ed): ed is typeof ed & { x2: number; y2: number } => + selectedIds.has(ed.id) && ed.x2 !== undefined && ed.y2 !== undefined + ) + .map((ed) => ({ id: ed.id, x2: ed.x2, y2: ed.y2 })); + setMoveDrag({ totalDx: 0, totalDy: 0, origNodes, origEdges }); + return null; + }, + [selectedIds, spec] + ); + + const updateMove = useCallback( + (event: PointerEvent, id: string): void => { + if (event.buttons === 0 || !selectedIds.has(id)) { + return; + } + const dx = event.movementX / transform.k; + const dy = event.movementY / transform.k; + setMoveDrag((current) => { + if (!current) { + return null; + } + return { ...current, totalDx: current.totalDx + dx, totalDy: current.totalDy + dy }; + }); + }, + [selectedIds, transform.k] + ); + + const applyMove = useCallback( + (draft: CanvasSpec): void => { + if (!moveDrag) { + return; + } + const { totalDx, totalDy, origNodes, origEdges } = moveDrag; + const origNodeMap = new Map(origNodes.map((n) => [n.id, n])); + const origEdgeMap = new Map(origEdges.map((ed) => [ed.id, ed])); + (draft.nodes ?? []).forEach((n) => { + const orig = origNodeMap.get(n.id); + if (orig) { + n.x = orig.x + totalDx; + n.y = orig.y + totalDy; + } + }); + (draft.edges ?? []).forEach((edge) => { + const orig = origEdgeMap.get(edge.id); + if (orig) { + edge.x2 = orig.x2 + totalDx; + edge.y2 = orig.y2 + totalDy; + } + }); + }, + [moveDrag] + ); + + const resetMove = useCallback((): void => { + setMoveDrag(null); + }, []); + + return { selectNode, updateMove, applyMove, resetMove }; +} diff --git a/canvas/src/hooks/useRectSelect.test.tsx b/canvas/src/hooks/useRectSelect.test.tsx new file mode 100644 index 000000000..e2cbd338d --- /dev/null +++ b/canvas/src/hooks/useRectSelect.test.tsx @@ -0,0 +1,93 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { act, renderHook } from '@testing-library/react'; +import React from 'react'; +import { NodeSpec } from '../model'; +import { makeWrapper } from '../test-utils/hookWrapper'; +import { useRectSelect } from './useRectSelect'; + +function makeNode(id: string, x: number, y: number): NodeSpec { + return { id, x, y, width: 10, height: 10, kind: 'rectangle' }; +} + +function makePointerEvent( + x: number, + y: number, + overrides: Partial = {} +): React.PointerEvent { + return { + clientX: x, + clientY: y, + button: 0, + buttons: 1, + pointerId: 1, + target: document.createElement('svg'), + currentTarget: { focus: jest.fn(), setPointerCapture: jest.fn() }, + stopPropagation: jest.fn(), + ...overrides, + } as unknown as React.PointerEvent; +} + +describe('useRectSelect', () => { + it('begins with no selection rect', () => { + const { result } = renderHook(() => useRectSelect(), { wrapper: makeWrapper() }); + expect(result.current.selectionRect).toBeNull(); + }); + + it('beginSelection sets the selection rect', async () => { + const { result } = renderHook(() => useRectSelect(), { wrapper: makeWrapper() }); + await act(async () => { + result.current.beginSelection(makePointerEvent(10, 20)); + }); + expect(result.current.selectionRect).toEqual({ x0: 10, y0: 20, x1: 10, y1: 20 }); + }); + + it('updateSelection extends the rect', async () => { + const { result } = renderHook(() => useRectSelect(), { wrapper: makeWrapper() }); + await act(async () => { + result.current.beginSelection(makePointerEvent(10, 20)); + }); + await act(async () => { + result.current.updateSelection(makePointerEvent(50, 60)); + }); + expect(result.current.selectionRect).toEqual({ x0: 10, y0: 20, x1: 50, y1: 60 }); + }); + + it('applySelection returns ids of nodes inside the rect and clears it', async () => { + const nodes = [makeNode('a', 20, 30), makeNode('b', 200, 200)]; + const { result } = renderHook(() => useRectSelect(), { wrapper: makeWrapper({ nodes }) }); + await act(async () => { + result.current.beginSelection(makePointerEvent(0, 0)); + }); + await act(async () => { + result.current.updateSelection(makePointerEvent(100, 100)); + }); + let ids!: Set; + await act(async () => { + ids = result.current.applySelection(); + }); + expect(ids).toEqual(new Set(['a'])); + expect(result.current.selectionRect).toBeNull(); + }); + + it('beginSelection returns false for pan gesture (button=1)', async () => { + const { result } = renderHook(() => useRectSelect(), { wrapper: makeWrapper() }); + let started = false; + await act(async () => { + started = result.current.beginSelection(makePointerEvent(0, 0, { button: 1 })); + }); + expect(started).toBe(false); + expect(result.current.selectionRect).toBeNull(); + }); +}); diff --git a/canvas/src/hooks/useRectSelect.ts b/canvas/src/hooks/useRectSelect.ts new file mode 100644 index 000000000..7900f9813 --- /dev/null +++ b/canvas/src/hooks/useRectSelect.ts @@ -0,0 +1,90 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { PointerEvent, useCallback, useRef, useState } from 'react'; +import { useZoomContext } from '../contexts/ZoomContext'; +import { useSpecContext } from '../contexts/SpecContext'; +import { computeSelectionFromRect } from '../utils/selectionUtils'; + +export interface SelectionRect { + x0: number; + y0: number; + x1: number; + y1: number; +} + +function isPanGesture(event: PointerEvent): boolean { + return event.button === 1; +} + +function isCanvasBackground(event: PointerEvent): boolean { + if (!(event.target instanceof Element)) { + return false; + } + return !event.target.closest('rect') && !event.target.closest('[data-cross]'); +} + +interface UseRectSelectResult { + selectionRect: SelectionRect | null; + beginSelection: (event: PointerEvent) => boolean; + updateSelection: (event: PointerEvent) => void; + applySelection: () => Set; +} + +export function useRectSelect(): UseRectSelectResult { + const { spec } = useSpecContext(); + const { toCanvasPoint } = useZoomContext(); + const [selectionRect, setSelectionRect] = useState(null); + const rectRef = useRef(null); + + const beginSelection = useCallback( + (event: PointerEvent): boolean => { + if (isPanGesture(event) || !isCanvasBackground(event)) { + return false; + } + event.currentTarget.focus(); + event.currentTarget.setPointerCapture(event.pointerId); + const pt = toCanvasPoint(event); + const rect = { x0: pt.x, y0: pt.y, x1: pt.x, y1: pt.y }; + rectRef.current = rect; + setSelectionRect(rect); + return true; + }, + [toCanvasPoint] + ); + + const updateSelection = useCallback( + (event: PointerEvent): void => { + if (!rectRef.current) { + return; + } + const point = toCanvasPoint(event); + const updated = { ...rectRef.current, x1: point.x, y1: point.y }; + rectRef.current = updated; + setSelectionRect(updated); + }, + [toCanvasPoint] + ); + + const applySelection = useCallback((): Set => { + const rect = rectRef.current ?? { x0: 0, y0: 0, x1: 0, y1: 0 }; + const nodes = spec.nodes ?? []; + const edges = spec.edges ?? []; + const hit = computeSelectionFromRect(rect, nodes, edges); + rectRef.current = null; + setSelectionRect(null); + return hit; + }, [spec.nodes, spec.edges]); + + return { selectionRect, beginSelection, updateSelection, applySelection }; +} diff --git a/canvas/src/hooks/useResize.test.tsx b/canvas/src/hooks/useResize.test.tsx new file mode 100644 index 000000000..f6e8ea1a7 --- /dev/null +++ b/canvas/src/hooks/useResize.test.tsx @@ -0,0 +1,131 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { act, renderHook } from '@testing-library/react'; +import { produce } from 'immer'; +import React from 'react'; +import { CanvasSpec, NodeSpec } from '../model'; +import { useEditorContext } from '../contexts/EditorContext'; +import { makeWrapper } from '../test-utils/hookWrapper'; +import { useResize } from './useResize'; + +function makeNode(id: string, x: number, y: number, width = 100, height = 60): NodeSpec { + return { id, x, y, width, height, kind: 'rectangle' }; +} + +function makeCircleEvent(overrides: Partial = {}): React.PointerEvent { + return { + pointerId: 1, + stopPropagation: jest.fn(), + currentTarget: { setPointerCapture: jest.fn() }, + ...overrides, + } as unknown as React.PointerEvent; +} + +function makeSvgEvent(x: number, y: number): React.PointerEvent { + return { clientX: x, clientY: y } as unknown as React.PointerEvent; +} + +function useTestHook(): { editor: ReturnType; resize: ReturnType } { + return { editor: useEditorContext(), resize: useResize() }; +} + +describe('useResize', () => { + it('applyResize is a no-op when no drag is active', () => { + const spec: CanvasSpec = { nodes: [makeNode('a', 0, 0)] }; + const { result } = renderHook(() => useResize(), { wrapper: makeWrapper(spec) }); + const draft = produce(spec, (d) => result.current.applyResize(d)); + expect(draft).toEqual(spec); + }); + + it('beginResize returns false when selection is empty', async () => { + const { result } = renderHook(() => useResize(), { wrapper: makeWrapper() }); + let ok = true; + await act(async () => { + ok = result.current.beginResize(makeCircleEvent(), 'se'); + }); + expect(ok).toBe(false); + }); + + it('beginResize returns true when nodes are selected', async () => { + const spec: CanvasSpec = { nodes: [makeNode('a', 0, 0)] }; + const { result } = renderHook(() => useTestHook(), { wrapper: makeWrapper(spec) }); + await act(async () => { + result.current.editor.selectItems(new Set(['a'])); + }); + let ok = false; + await act(async () => { + ok = result.current.resize.beginResize(makeCircleEvent(), 'se'); + }); + expect(ok).toBe(true); + }); + + it('applyResize scales node position and size', async () => { + const spec: CanvasSpec = { nodes: [makeNode('a', 50, 30, 100, 60)] }; + const { result } = renderHook(() => useTestHook(), { wrapper: makeWrapper(spec) }); + await act(async () => { + result.current.editor.selectItems(new Set(['a'])); + }); + await act(async () => { + result.current.resize.beginResize(makeCircleEvent() as React.PointerEvent, 'se'); + }); + await act(async () => { + result.current.resize.updateResize(makeSvgEvent(200, 120)); + }); + const draft = produce(spec, (d) => result.current.resize.applyResize(d)); + const node = draft.nodes?.[0]; + expect(node?.width).toBeCloseTo(200); + expect(node?.height).toBeCloseTo(120); + expect(node?.x).toBeCloseTo(100); + expect(node?.y).toBeCloseTo(60); + }); + + it('applyResize also scales free edge endpoints', async () => { + const spec: CanvasSpec = { + nodes: [makeNode('a', 50, 30, 100, 60)], + edges: [{ id: 'e1', source: 'a', target: '', x2: 100, y2: 60 }], + }; + const { result } = renderHook(() => useTestHook(), { wrapper: makeWrapper(spec) }); + await act(async () => { + result.current.editor.selectItems(new Set(['a', 'e1'])); + }); + await act(async () => { + result.current.resize.beginResize(makeCircleEvent(), 'se'); + }); + await act(async () => { + result.current.resize.updateResize(makeSvgEvent(200, 120)); + }); + const draft = produce(spec, (d) => result.current.resize.applyResize(d)); + expect(draft.edges?.[0]?.x2).toBeCloseTo(200); + expect(draft.edges?.[0]?.y2).toBeCloseTo(120); + }); + + it('resetResize makes applyResize a no-op', async () => { + const spec: CanvasSpec = { nodes: [makeNode('a', 50, 30, 100, 60)] }; + const { result } = renderHook(() => useTestHook(), { wrapper: makeWrapper(spec) }); + await act(async () => { + result.current.editor.selectItems(new Set(['a'])); + }); + await act(async () => { + result.current.resize.beginResize(makeCircleEvent(), 'se'); + }); + await act(async () => { + result.current.resize.updateResize(makeSvgEvent(200, 120)); + }); + await act(async () => { + result.current.resize.resetResize(); + }); + const draft = produce(spec, (d) => result.current.resize.applyResize(d)); + expect(draft.nodes?.[0]?.width).toBe(100); + }); +}); diff --git a/canvas/src/hooks/useResize.ts b/canvas/src/hooks/useResize.ts new file mode 100644 index 000000000..c064047b6 --- /dev/null +++ b/canvas/src/hooks/useResize.ts @@ -0,0 +1,203 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { PointerEvent, useCallback, useMemo, useState } from 'react'; +import { CanvasSpec, FloatingEdge, isFloatingEdge } from '../model'; +import { useZoomContext } from '../contexts/ZoomContext'; +import { useEditorContext } from '../contexts/EditorContext'; +import { useSpecContext } from '../contexts/SpecContext'; +import { + BoundingBox, + HANDLE_POSITIONS, + handlePosition, + nodeBoundingBox, + OPPOSITE_HANDLE, + ResizeHandleId, +} from '../utils/resizeUtils'; + +const MIN_NODE_SIZE = 8; + +interface ResizeDrag { + handleId: ResizeHandleId; + fixedX: number; + fixedY: number; + currentX: number; + currentY: number; + origBoundingBox: BoundingBox; +} + +interface FinalBoundingBox { + minX: number; + maxX: number; + minY: number; + maxY: number; +} + +function resolveFinalBoundingBox(drag: ResizeDrag): FinalBoundingBox | null { + const { handleId, fixedX, fixedY, currentX, currentY, origBoundingBox } = drag; + const origWidth = origBoundingBox.maxX - origBoundingBox.minX; + const origHeight = origBoundingBox.maxY - origBoundingBox.minY; + if (origWidth === 0 || origHeight === 0) { + return null; + } + const [tx, ty] = HANDLE_POSITIONS[handleId]; + const newMinX = tx === 0 ? currentX : fixedX; + const newMaxX = tx === 1 ? currentX : fixedX; + const newMinY = ty === 0 ? currentY : fixedY; + const newMaxY = ty === 1 ? currentY : fixedY; + return { + minX: tx === 0.5 ? origBoundingBox.minX : Math.min(newMinX, newMaxX), + maxX: tx === 0.5 ? origBoundingBox.maxX : Math.max(newMinX, newMaxX), + minY: ty === 0.5 ? origBoundingBox.minY : Math.min(newMinY, newMaxY), + maxY: ty === 0.5 ? origBoundingBox.maxY : Math.max(newMinY, newMaxY), + }; +} + +function scalePoint( + px: number, + py: number, + origBoundingBox: BoundingBox, + final: FinalBoundingBox +): { x: number; y: number } { + const origWidth = origBoundingBox.maxX - origBoundingBox.minX; + const origHeight = origBoundingBox.maxY - origBoundingBox.minY; + const relX = (px - origBoundingBox.minX) / origWidth; + const relY = (py - origBoundingBox.minY) / origHeight; + return { + x: final.minX + relX * (final.maxX - final.minX), + y: final.minY + relY * (final.maxY - final.minY), + }; +} + +function scaleNodeSize( + width: number, + height: number, + kind: string, + origBoundingBox: BoundingBox, + final: FinalBoundingBox +): { width: number; height: number } { + const scaleX = (final.maxX - final.minX) / (origBoundingBox.maxX - origBoundingBox.minX); + const scaleY = (final.maxY - final.minY) / (origBoundingBox.maxY - origBoundingBox.minY); + if (kind === 'icon') { + const uniformScale = Math.max(scaleX, scaleY); + return { + width: Math.max(MIN_NODE_SIZE, width * uniformScale), + height: Math.max(MIN_NODE_SIZE, height * uniformScale), + }; + } + return { + width: Math.max(MIN_NODE_SIZE, width * scaleX), + height: Math.max(MIN_NODE_SIZE, height * scaleY), + }; +} + +interface UseResizeResult { + beginResize: (event: PointerEvent, handleId: ResizeHandleId) => boolean; + updateResize: (event: PointerEvent) => void; + applyResize: (draft: CanvasSpec) => void; + resetResize: () => void; +} + +export function useResize(): UseResizeResult { + const { selectedIds } = useEditorContext().state; + const { spec } = useSpecContext(); + const { toCanvasPoint } = useZoomContext(); + const [resizeDrag, setResizeDrag] = useState(null); + + const selectedNodes = useMemo( + () => (spec.nodes ?? []).filter((n) => selectedIds.has(n.id)), + [spec.nodes, selectedIds] + ); + const selectedFloatingEdges = useMemo( + () => (spec.edges ?? []).filter((ed): ed is FloatingEdge => selectedIds.has(ed.id) && isFloatingEdge(ed)), + [spec.edges, selectedIds] + ); + + const beginResize = useCallback( + (event: PointerEvent, handleId: ResizeHandleId): boolean => { + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + const freeEndpoints = selectedFloatingEdges.map((ed) => ({ x: ed.x2, y: ed.y2 })); + const selectionBounds = nodeBoundingBox(selectedNodes, freeEndpoints); + if (!selectionBounds) { + return false; + } + const current = handlePosition(selectionBounds, handleId); + const fixed = handlePosition(selectionBounds, OPPOSITE_HANDLE[handleId]); + setResizeDrag({ + handleId, + fixedX: fixed.x, + fixedY: fixed.y, + currentX: current.x, + currentY: current.y, + origBoundingBox: selectionBounds, + }); + return true; + }, + [selectedNodes, selectedFloatingEdges] + ); + + const updateResize = useCallback( + (event: PointerEvent): void => { + const point = toCanvasPoint(event); + setResizeDrag((current) => { + if (!current) { + return null; + } + return { ...current, currentX: point.x, currentY: point.y }; + }); + }, + [toCanvasPoint] + ); + + const applyResize = useCallback( + (draft: CanvasSpec): void => { + if (!resizeDrag) { + return; + } + const final = resolveFinalBoundingBox(resizeDrag); + if (!final) { + return; + } + const { origBoundingBox } = resizeDrag; + selectedNodes.forEach(({ id, x, y, width, height, kind }) => { + const node = (draft.nodes ?? []).find((n) => n.id === id); + if (!node) { + return; + } + const pos = scalePoint(x, y, origBoundingBox, final); + const size = scaleNodeSize(width, height, kind, origBoundingBox, final); + node.x = pos.x; + node.y = pos.y; + node.width = size.width; + node.height = size.height; + }); + selectedFloatingEdges.forEach(({ id, x2, y2 }) => { + const edge = (draft.edges ?? []).find((ed) => ed.id === id); + if (!edge) { + return; + } + const pos = scalePoint(x2, y2, origBoundingBox, final); + edge.x2 = pos.x; + edge.y2 = pos.y; + }); + }, + [resizeDrag, selectedNodes, selectedFloatingEdges] + ); + + const resetResize = useCallback((): void => { + setResizeDrag(null); + }, []); + + return { beginResize, updateResize, applyResize, resetResize }; +} diff --git a/canvas/src/hooks/useZoom.ts b/canvas/src/hooks/useZoom.ts new file mode 100644 index 000000000..195f41f00 --- /dev/null +++ b/canvas/src/hooks/useZoom.ts @@ -0,0 +1,102 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { PointerEvent, useCallback, useMemo, useRef, useState } from 'react'; +import { select } from 'd3-selection'; +import { zoom, zoomIdentity, ZoomTransform } from 'd3-zoom'; + +const FIT_PADDING = 40; + +export interface UseZoomResult { + svgRef: (node: SVGSVGElement | null) => void; + transform: ZoomTransform; + fitView: ( + boundingBox: { minX: number; minY: number; maxX: number; maxY: number }, + canvasWidth: number, + canvasHeight: number + ) => void; + toCanvasPoint: (event: PointerEvent) => { x: number; y: number }; + resetPan: () => void; +} + +export function useZoom(): UseZoomResult { + const [transform, setTransform] = useState(zoomIdentity); + const nodeRef = useRef(null); + + const zoomBehavior = useMemo(() => zoom(), []); + + const svgRef = useCallback( + (node: SVGSVGElement | null): void => { + if (!node) { + return; + } + nodeRef.current = node; + zoomBehavior.filter((event: Event) => { + if (event.type === 'dblclick') { + return false; + } + if (event instanceof WheelEvent) { + return event.ctrlKey || event.metaKey; + } + return event instanceof MouseEvent && event.button === 1; + }); + zoomBehavior.on('zoom', ({ transform: t }: { transform: ZoomTransform }) => { + setTransform(t); + }); + select(node).call(zoomBehavior); + }, + [zoomBehavior] + ); + + const resetPan = useCallback(() => { + if (!nodeRef.current) { + return; + } + select(nodeRef.current).call(zoomBehavior.transform, zoomIdentity); + }, [zoomBehavior]); + + const fitView = useCallback( + ( + boundingBox: { minX: number; minY: number; maxX: number; maxY: number }, + canvasWidth: number, + canvasHeight: number + ): void => { + if (!nodeRef.current) { + return; + } + const contentW = boundingBox.maxX - boundingBox.minX + FIT_PADDING * 2; + const contentH = boundingBox.maxY - boundingBox.minY + FIT_PADDING * 2; + const scale = Math.min(canvasWidth / contentW, canvasHeight / contentH, 1); + const tx = canvasWidth / 2 - (scale * (boundingBox.minX + boundingBox.maxX)) / 2; + const ty = canvasHeight / 2 - (scale * (boundingBox.minY + boundingBox.maxY)) / 2; + const t = zoomIdentity.translate(tx, ty).scale(scale); + select(nodeRef.current).call(zoomBehavior.transform, t); + }, + [zoomBehavior] + ); + + const toCanvasPoint = useCallback( + (event: PointerEvent): { x: number; y: number } => { + const rect = nodeRef.current?.getBoundingClientRect(); + if (!rect) { + throw new Error('SVG element is not available'); + } + const px = event.clientX - rect.left; + const py = event.clientY - rect.top; + return { x: transform.invertX(px), y: transform.invertY(py) }; + }, + [transform] + ); + + return { svgRef, transform, fitView, toCanvasPoint, resetPan }; +} diff --git a/canvas/src/index-federation.ts b/canvas/src/index-federation.ts new file mode 100644 index 000000000..d793032ac --- /dev/null +++ b/canvas/src/index-federation.ts @@ -0,0 +1,14 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import('./bootstrap'); diff --git a/canvas/src/index.ts b/canvas/src/index.ts new file mode 100644 index 000000000..17efb94c9 --- /dev/null +++ b/canvas/src/index.ts @@ -0,0 +1,14 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export { getPluginModule } from './getPluginModule'; diff --git a/canvas/src/model.test.ts b/canvas/src/model.test.ts new file mode 100644 index 000000000..f80a44675 --- /dev/null +++ b/canvas/src/model.test.ts @@ -0,0 +1,52 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { EdgeSpec, isFloatingEdge } from './model'; + +function makeEdge(overrides: Partial = {}): EdgeSpec { + return { id: 'e1', source: 'a', target: 'b', ...overrides }; +} + +describe('isFloatingEdge', () => { + it('returns false when both x2 and y2 are undefined', () => { + expect(isFloatingEdge(makeEdge())).toBe(false); + }); + + it('returns false when only x2 is defined', () => { + expect(isFloatingEdge(makeEdge({ x2: 10 }))).toBe(false); + }); + + it('returns false when only y2 is defined', () => { + expect(isFloatingEdge(makeEdge({ y2: 10 }))).toBe(false); + }); + + it('returns true when both x2 and y2 are defined', () => { + expect(isFloatingEdge(makeEdge({ x2: 10, y2: 20 }))).toBe(true); + }); + + it('returns true when both x2 and y2 are 0', () => { + expect(isFloatingEdge(makeEdge({ x2: 0, y2: 0 }))).toBe(true); + }); + + it('narrows the type so x2 and y2 are number after the check', () => { + const edge = makeEdge({ x2: 5, y2: 7 }); + if (isFloatingEdge(edge)) { + const x: number = edge.x2; + const y: number = edge.y2; + expect(x).toBe(5); + expect(y).toBe(7); + } else { + throw new Error('expected isFloatingEdge to return true'); + } + }); +}); diff --git a/canvas/src/model.ts b/canvas/src/model.ts new file mode 100644 index 000000000..81f236e50 --- /dev/null +++ b/canvas/src/model.ts @@ -0,0 +1,106 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { TimeSeriesData, ThresholdOptions } from '@perses-dev/core'; +import { FormatOptions } from '@perses-dev/components'; +import { PanelProps, LegendSpecOptions, OptionsEditorProps } from '@perses-dev/plugin-system'; + +export type QueryData = TimeSeriesData; + +export type CanvasProps = PanelProps; + +export interface QueryColorSettings { + queryIndex: number; + colorMode: 'fixed' | 'fixed-single'; + colorValue: string; +} + +export type LabelPosition = 'above' | 'below' | 'left' | 'right' | 'center'; + +export interface NodeSpec { + id: string; + x: number; + y: number; + width: number; + height: number; + kind: 'rectangle' | 'icon' | 'text'; + label?: string; + labelPosition?: LabelPosition; + labelPadding?: number; + icon?: string; + link?: string; + background?: string; + backgroundImage?: string; + queryIndex?: number; + colorMode?: 'threshold' | 'fixed'; + color?: string; +} + +export type AnchorPoint = 'n' | 's' | 'e' | 'w' | 'nw' | 'ne' | 'sw' | 'se'; + +export interface EdgeSpec { + id: string; + name?: string; + source: string; + target: string; + sourceAnchor?: AnchorPoint; + targetAnchor?: AnchorPoint; + x2?: number; + y2?: number; + bidirectional?: boolean; + thicknessMode?: 'fixed' | 'threshold'; + strokeWidth?: number; + sourceQueryIndex?: number; + targetQueryIndex?: number; + sourceLabelTemplate?: string; + targetLabelTemplate?: string; +} + +export interface EdgeThresholdStep { + value: number; + strokeWidth: number; +} + +export type FloatingEdge = EdgeSpec & { x2: number; y2: number }; + +export function isFloatingEdge(edge: EdgeSpec): edge is FloatingEdge { + return edge.x2 !== undefined && edge.y2 !== undefined; +} + +export interface BackgroundSpec { + id: string; + name?: string; + x: number; + y: number; + width: number; + height: number; + color?: string; + opacity?: number; + image?: string; + imageFit?: 'cover' | 'contain' | 'stretch'; + global?: boolean; +} + +export interface CanvasSpec { + legend?: LegendSpecOptions; + thresholds?: ThresholdOptions; + format?: FormatOptions; + edgeThresholdWidths?: EdgeThresholdStep[]; + edgeDefaultStrokeWidth?: number; + querySettings?: QueryColorSettings[]; + backgrounds?: BackgroundSpec[]; + nodes?: NodeSpec[]; + edges?: EdgeSpec[]; +} + +export type CanvasSpecEditorProps = OptionsEditorProps; diff --git a/canvas/src/setup-tests.ts b/canvas/src/setup-tests.ts new file mode 100644 index 000000000..012685e6a --- /dev/null +++ b/canvas/src/setup-tests.ts @@ -0,0 +1,17 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '@testing-library/jest-dom'; + +// Always mock e-charts during tests since we don't have a proper canvas in jsdom +jest.mock('echarts/core'); diff --git a/canvas/src/test-utils/hookWrapper.tsx b/canvas/src/test-utils/hookWrapper.tsx new file mode 100644 index 000000000..556cb05b7 --- /dev/null +++ b/canvas/src/test-utils/hookWrapper.tsx @@ -0,0 +1,90 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React, { ReactNode, useState } from 'react'; +import { CanvasSpec } from '../model'; +import { EditorStateProvider } from '../contexts/EditorContext'; +import { SpecContext, SpecContextValue } from '../contexts/SpecContext'; +import { ZoomContext, ZoomContextValue } from '../contexts/ZoomContext'; + +// Minimal identity-transform stub — d3-zoom is ESM-only and not transformable by Jest. +const identityTransform = { + k: 1, + x: 0, + y: 0, + toString: (): string => 'translate(0,0) scale(1)', + invertX: (x: number): number => x, + invertY: (y: number): number => y, + apply: (point: [number, number]): [number, number] => point, + applyX: (x: number): number => x, + applyY: (y: number): number => y, +}; + +export const stubZoom: ZoomContextValue = { + transform: identityTransform as ZoomContextValue['transform'], + toCanvasPoint: (event) => ({ + x: (event as unknown as MouseEvent).clientX, + y: (event as unknown as MouseEvent).clientY, + }), + fitView: jest.fn(), + resetPan: jest.fn(), +}; + +interface WrapperProps { + initialSpec?: CanvasSpec; + children: ReactNode; +} + +/** + * Provides all three contexts needed by canvas hooks. + * SpecContext is wired to local state so onChange calls are reflected in the hook. + */ +export function HookWrapper({ initialSpec = {}, children }: WrapperProps): React.ReactElement { + const [spec, setSpec] = useState(initialSpec); + + const nodeById = React.useMemo(() => new Map((spec.nodes ?? []).map((n) => [n.id, n])), [spec.nodes]); + const edgeById = React.useMemo(() => new Map((spec.edges ?? []).map((ed) => [ed.id, ed])), [spec.edges]); + const backgroundById = React.useMemo( + () => new Map((spec.backgrounds ?? []).map((bg) => [bg.id, bg])), + [spec.backgrounds] + ); + + const specCtx: SpecContextValue = { + spec, + nodeById, + edgeById, + backgroundById, + updateSpec: setSpec, + addNode: jest.fn(), + addBackground: jest.fn(), + moveBackground: jest.fn(), + deleteSelected: jest.fn(), + onNodePropertiesChange: jest.fn(), + onEdgePropertiesChange: jest.fn(), + onBackgroundPropertiesChange: jest.fn(), + }; + + return ( + + + {children} + + + ); +} + +export function makeWrapper(initialSpec?: CanvasSpec) { + return function Wrapper({ children }: { children: ReactNode }): React.ReactElement { + return {children}; + }; +} diff --git a/canvas/src/utils/edgeUtils.test.ts b/canvas/src/utils/edgeUtils.test.ts new file mode 100644 index 000000000..47b4a6777 --- /dev/null +++ b/canvas/src/utils/edgeUtils.test.ts @@ -0,0 +1,212 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NodeSpec } from '../model'; +import { + anchorPosition, + closestAnchor, + edgeEndpoints, + midpoint, + pointInsideNode, + snapTarget, + strokeWidthFromThresholds, +} from './edgeUtils'; + +function makeNode(id: string, x: number, y: number, width = 100, height = 60): NodeSpec { + return { id, x, y, width, height, kind: 'rectangle' }; +} + +describe('anchorPosition', () => { + const node = makeNode('a', 0, 0, 100, 60); + + it('returns center-top for n', () => { + expect(anchorPosition(node, 'n')).toEqual({ x: 0, y: -30 }); + }); + + it('returns center-bottom for s', () => { + expect(anchorPosition(node, 's')).toEqual({ x: 0, y: 30 }); + }); + + it('returns right-center for e', () => { + expect(anchorPosition(node, 'e')).toEqual({ x: 50, y: 0 }); + }); + + it('returns left-center for w', () => { + expect(anchorPosition(node, 'w')).toEqual({ x: -50, y: 0 }); + }); + + it('returns corner for se', () => { + expect(anchorPosition(node, 'se')).toEqual({ x: 50, y: 30 }); + }); + + it('respects node offset', () => { + const offset = makeNode('b', 200, 100, 100, 60); + expect(anchorPosition(offset, 'n')).toEqual({ x: 200, y: 70 }); + }); +}); + +describe('closestAnchor', () => { + const node = makeNode('a', 0, 0, 100, 60); + + it('returns n for a point directly above', () => { + expect(closestAnchor(node, { x: 0, y: -100 })).toBe('n'); + }); + + it('returns se for a point in the bottom-right', () => { + expect(closestAnchor(node, { x: 200, y: 200 })).toBe('se'); + }); + + it('returns w for a point far to the left', () => { + expect(closestAnchor(node, { x: -200, y: 0 })).toBe('w'); + }); +}); + +describe('edgeEndpoints', () => { + const a = makeNode('a', 0, 0, 100, 60); + const b = makeNode('b', 200, 0, 100, 60); + const nodeById = new Map([ + ['a', a], + ['b', b], + ]); + + it('returns null when source node is missing', () => { + expect(edgeEndpoints({ id: 'e1', source: 'x', target: 'b' }, nodeById)).toBeNull(); + }); + + it('returns null when target node is missing', () => { + expect(edgeEndpoints({ id: 'e1', source: 'a', target: 'x' }, nodeById)).toBeNull(); + }); + + it('returns null for free target with no x2/y2', () => { + expect(edgeEndpoints({ id: 'e1', source: 'a', target: '' }, nodeById)).toBeNull(); + }); + + it('uses node centers when no anchors specified', () => { + expect(edgeEndpoints({ id: 'e1', source: 'a', target: 'b' }, nodeById)).toEqual({ + x1: 0, + y1: 0, + x2: 200, + y2: 0, + }); + }); + + it('uses source anchor when specified', () => { + const pts = edgeEndpoints({ id: 'e1', source: 'a', target: 'b', sourceAnchor: 'e' }, nodeById); + expect(pts?.x1).toBe(50); + expect(pts?.y1).toBe(0); + }); + + it('uses free endpoint x2/y2 when target is empty', () => { + expect(edgeEndpoints({ id: 'e1', source: 'a', target: '', x2: 0, y2: 0 }, nodeById)).toEqual({ + x1: 0, + y1: 0, + x2: 0, + y2: 0, + }); + }); + + it('handles free endpoint at origin (x2=0, y2=0)', () => { + const pts = edgeEndpoints({ id: 'e1', source: 'a', target: '', x2: 0, y2: 0 }, nodeById); + expect(pts).not.toBeNull(); + expect(pts?.x2).toBe(0); + expect(pts?.y2).toBe(0); + }); +}); + +describe('midpoint', () => { + it('returns the midpoint of a line segment', () => { + expect(midpoint({ x1: 0, y1: 0, x2: 100, y2: 60 })).toEqual({ x: 50, y: 30 }); + }); + + it('handles negative coordinates', () => { + expect(midpoint({ x1: -50, y1: -20, x2: 50, y2: 20 })).toEqual({ x: 0, y: 0 }); + }); +}); + +describe('strokeWidthFromThresholds', () => { + const steps = [ + { value: 10, strokeWidth: 2 }, + { value: 50, strokeWidth: 4 }, + { value: 100, strokeWidth: 8 }, + ]; + + it('returns default when steps are empty', () => { + expect(strokeWidthFromThresholds(999, [], 3)).toBe(3); + }); + + it('returns default when value is below all steps', () => { + expect(strokeWidthFromThresholds(5, steps, 1)).toBe(1); + }); + + it('returns width of the highest matched step', () => { + expect(strokeWidthFromThresholds(60, steps, 1)).toBe(4); + }); + + it('returns width at exact step boundary', () => { + expect(strokeWidthFromThresholds(50, steps, 1)).toBe(4); + }); + + it('returns width of the last step for very large values', () => { + expect(strokeWidthFromThresholds(999, steps, 1)).toBe(8); + }); +}); + +describe('pointInsideNode', () => { + const node = makeNode('a', 0, 0, 100, 60); + + it('returns true for a point at the node center', () => { + expect(pointInsideNode(node, { x: 0, y: 0 }, 0)).toBe(true); + }); + + it('returns true for a point on the edge boundary', () => { + expect(pointInsideNode(node, { x: 50, y: 0 }, 0)).toBe(true); + }); + + it('returns false for a point just outside', () => { + expect(pointInsideNode(node, { x: 51, y: 0 }, 0)).toBe(false); + }); + + it('returns true when margin extends the boundary', () => { + expect(pointInsideNode(node, { x: 55, y: 0 }, 10)).toBe(true); + }); + + it('returns false when outside even with margin', () => { + expect(pointInsideNode(node, { x: 100, y: 0 }, 5)).toBe(false); + }); +}); + +describe('snapTarget', () => { + const a = makeNode('a', 0, 0, 100, 60); + const b = makeNode('b', 300, 0, 100, 60); + const nodes = [a, b]; + + it('returns null when no node is within snap radius', () => { + expect(snapTarget(nodes, { x: 150, y: 0 }, 'x', 20)).toBeNull(); + }); + + it('snaps to nearest anchor within radius', () => { + const result = snapTarget(nodes, { x: 298, y: -30 }, 'x', 20); + expect(result?.node.id).toBe('b'); + expect(result?.anchor).toBe('n'); + }); + + it('excludes the source node', () => { + expect(snapTarget(nodes, { x: 0, y: -30 }, 'a', 20)).toBeNull(); + }); + + it('prefers the closer node when two are in range', () => { + const c = makeNode('c', 302, 0, 100, 60); + const result = snapTarget([a, b, c], { x: 250, y: 0 }, 'x', 200); + expect(result?.node.id).toBe('b'); + }); +}); diff --git a/canvas/src/utils/edgeUtils.ts b/canvas/src/utils/edgeUtils.ts new file mode 100644 index 000000000..d5dfcd9e0 --- /dev/null +++ b/canvas/src/utils/edgeUtils.ts @@ -0,0 +1,110 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { AnchorPoint, EdgeSpec, EdgeThresholdStep, NodeSpec } from '../model'; + +export const ANCHOR_OFFSETS: Record = { + n: [0, -1], + s: [0, 1], + e: [1, 0], + w: [-1, 0], + nw: [-1, -1], + ne: [1, -1], + sw: [-1, 1], + se: [1, 1], +}; + +export const ANCHOR_KEYS = Object.keys(ANCHOR_OFFSETS) as AnchorPoint[]; + +export function anchorPosition(node: NodeSpec, anchor: AnchorPoint): { x: number; y: number } { + const halfW = node.width / 2; + const halfH = node.height / 2; + const [ox, oy] = ANCHOR_OFFSETS[anchor]; + return { x: node.x + ox * halfW, y: node.y + oy * halfH }; +} + +export function closestAnchor(node: NodeSpec, pt: { x: number; y: number }): AnchorPoint { + let best: AnchorPoint = 'n'; + let bestDist = Infinity; + for (const a of ANCHOR_KEYS) { + const pos = anchorPosition(node, a); + const d = Math.hypot(pos.x - pt.x, pos.y - pt.y); + if (d < bestDist) { + bestDist = d; + best = a; + } + } + return best; +} + +export function edgeEndpoints( + edge: EdgeSpec, + nodeById: Map +): { x1: number; y1: number; x2: number; y2: number } | null { + const src = nodeById.get(edge.source); + if (!src) return null; + + const p1 = edge.sourceAnchor ? anchorPosition(src, edge.sourceAnchor) : { x: src.x, y: src.y }; + + let p2: { x: number; y: number }; + if (edge.target) { + const tgt = nodeById.get(edge.target); + if (!tgt) return null; + p2 = edge.targetAnchor ? anchorPosition(tgt, edge.targetAnchor) : { x: tgt.x, y: tgt.y }; + } else { + if (edge.x2 === undefined || edge.y2 === undefined) return null; + p2 = { x: edge.x2, y: edge.y2 }; + } + + return { x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }; +} + +export function midpoint(pts: { x1: number; y1: number; x2: number; y2: number }): { x: number; y: number } { + return { x: (pts.x1 + pts.x2) / 2, y: (pts.y1 + pts.y2) / 2 }; +} + +export function strokeWidthFromThresholds(value: number, steps: EdgeThresholdStep[], defaultWidth: number): number { + if (!steps.length) return defaultWidth; + let result = defaultWidth; + for (const step of steps) { + if (value >= step.value) { + result = step.strokeWidth; + } + } + return result; +} + +// Returns true if pt is within the node's bounding box plus an extra margin (in SVG space) +export function pointInsideNode(node: NodeSpec, pt: { x: number; y: number }, margin: number): boolean { + const halfW = node.width / 2 + margin; + const halfH = node.height / 2 + margin; + return pt.x >= node.x - halfW && pt.x <= node.x + halfW && pt.y >= node.y - halfH && pt.y <= node.y + halfH; +} +export function snapTarget( + nodes: NodeSpec[], + pt: { x: number; y: number }, + excludeId: string, + snapRadius: number +): { node: NodeSpec; anchor: AnchorPoint } | null { + let best: { node: NodeSpec; anchor: AnchorPoint; dist: number } | null = null; + for (const node of nodes) { + if (node.id === excludeId) continue; + const anchor = closestAnchor(node, pt); + const pos = anchorPosition(node, anchor); + const d = Math.hypot(pos.x - pt.x, pos.y - pt.y); + if (d <= snapRadius && (!best || d < best.dist)) { + best = { node, anchor, dist: d }; + } + } + return best ? { node: best.node, anchor: best.anchor } : null; +} diff --git a/canvas/src/utils/editorReducer.test.ts b/canvas/src/utils/editorReducer.test.ts new file mode 100644 index 000000000..654a3be32 --- /dev/null +++ b/canvas/src/utils/editorReducer.test.ts @@ -0,0 +1,79 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { INITIAL_EDITOR_STATE, editorReducer, EditorState, EditorAction } from './editorReducer'; + +describe('editorReducer', () => { + const state: EditorState = INITIAL_EDITOR_STATE; + + it('SELECT_ITEMS replaces selectedIds', () => { + const ids = new Set(['a', 'b']); + const next = editorReducer(state, { type: 'SELECT_ITEMS', ids }); + expect(next.selectedIds).toBe(ids); + }); + + it('CLEAR_SELECTION empties selectedIds', () => { + const withSelection = { ...state, selectedIds: new Set(['a']) }; + const next = editorReducer(withSelection, { type: 'CLEAR_SELECTION' }); + expect(next.selectedIds.size).toBe(0); + }); + + it('HOVER_NODE sets hoveredId', () => { + const next = editorReducer(state, { type: 'HOVER_NODE', id: 'x' }); + expect(next.hoveredId).toBe('x'); + }); + + it('UNHOVER_NODE clears hoveredId when it matches', () => { + const hovered = { ...state, hoveredId: 'x' }; + const next = editorReducer(hovered, { type: 'UNHOVER_NODE', id: 'x' }); + expect(next.hoveredId).toBeNull(); + }); + + it('UNHOVER_NODE does not clear hoveredId when it does not match', () => { + const hovered = { ...state, hoveredId: 'x' }; + const next = editorReducer(hovered, { type: 'UNHOVER_NODE', id: 'y' }); + expect(next.hoveredId).toBe('x'); + }); + + it('SELECTION_RECT_START clears selection and sets selecting mode', () => { + const withSelection = { ...state, selectedIds: new Set(['a']) }; + const next = editorReducer(withSelection, { type: 'SELECTION_RECT_START' }); + expect(next.mode).toEqual({ type: 'selecting' }); + expect(next.selectedIds.size).toBe(0); + }); + + it('MOVE_START sets moving mode', () => { + expect(editorReducer(state, { type: 'MOVE_START' }).mode).toEqual({ type: 'moving' }); + }); + + it('DRAG_EDGE_START sets dragging-edge mode and clears hoveredId', () => { + const hovered = { ...state, hoveredId: 'x' }; + const next = editorReducer(hovered, { type: 'DRAG_EDGE_START' }); + expect(next.mode).toEqual({ type: 'dragging-edge' }); + expect(next.hoveredId).toBeNull(); + }); + + it('RESIZE_START sets resizing mode', () => { + expect(editorReducer(state, { type: 'RESIZE_START' }).mode).toEqual({ type: 'resizing' }); + }); + + it('INTERACTION_END returns to idle mode', () => { + const moving = { ...state, mode: { type: 'moving' as const } }; + expect(editorReducer(moving, { type: 'INTERACTION_END' }).mode).toEqual({ type: 'idle' }); + }); + + it('unknown action returns state unchanged', () => { + const next = editorReducer(state, { type: 'UNKNOWN' } as unknown as EditorAction); + expect(next).toBe(state); + }); +}); diff --git a/canvas/src/utils/editorReducer.ts b/canvas/src/utils/editorReducer.ts new file mode 100644 index 000000000..6b85d7ebd --- /dev/null +++ b/canvas/src/utils/editorReducer.ts @@ -0,0 +1,73 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export type EditorMode = + { type: 'idle' } | { type: 'selecting' } | { type: 'moving' } | { type: 'dragging-edge' } | { type: 'resizing' }; + +export interface EditorState { + mode: EditorMode; + selectedIds: Set; + hoveredId: string | null; +} + +export const INITIAL_EDITOR_STATE: EditorState = { + mode: { type: 'idle' }, + selectedIds: new Set(), + hoveredId: null, +}; + +export type EditorAction = + | { type: 'SELECT_ITEMS'; ids: Set } + | { type: 'CLEAR_SELECTION' } + | { type: 'HOVER_NODE'; id: string } + | { type: 'UNHOVER_NODE'; id: string } + | { type: 'SELECTION_RECT_START' } + | { type: 'MOVE_START' } + | { type: 'DRAG_EDGE_START' } + | { type: 'RESIZE_START' } + | { type: 'INTERACTION_END' }; + +export function editorReducer(state: EditorState, action: EditorAction): EditorState { + switch (action.type) { + case 'SELECT_ITEMS': { + return { ...state, selectedIds: action.ids }; + } + case 'CLEAR_SELECTION': { + return { ...state, selectedIds: new Set() }; + } + case 'HOVER_NODE': { + return { ...state, hoveredId: action.id }; + } + case 'UNHOVER_NODE': { + return { ...state, hoveredId: state.hoveredId === action.id ? null : state.hoveredId }; + } + case 'SELECTION_RECT_START': { + return { ...state, selectedIds: new Set(), mode: { type: 'selecting' } }; + } + case 'MOVE_START': { + return { ...state, mode: { type: 'moving' } }; + } + case 'DRAG_EDGE_START': { + return { ...state, mode: { type: 'dragging-edge' }, hoveredId: null }; + } + case 'RESIZE_START': { + return { ...state, mode: { type: 'resizing' } }; + } + case 'INTERACTION_END': { + return { ...state, mode: { type: 'idle' } }; + } + default: { + return state; + } + } +} diff --git a/canvas/src/utils/editorStyles.ts b/canvas/src/utils/editorStyles.ts new file mode 100644 index 000000000..494a136a6 --- /dev/null +++ b/canvas/src/utils/editorStyles.ts @@ -0,0 +1,78 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CanvasTheme } from '../hooks/useCanvasTheme'; + +export interface EditorStyles { + edgeHit: { strokeWidth: number }; + edge: { stroke: string; strokeWidth: number; strokeOpacity: number }; + edgeSelected: { stroke: string; strokeWidth: number; strokeOpacity: number }; + edgeHandle: { r: number; fill: string; stroke: string; strokeWidth: number }; + selectionBoundingBox: { fill: string; stroke: string; strokeWidth: number; strokeDasharray: string }; + resizeHandle: { r: number; fill: string; stroke: string; strokeWidth: number }; + dragEdge: { stroke: string; strokeWidth: number; strokeDasharray: string }; + selectionRect: { fill: string; stroke: string; strokeWidth: number; strokeDasharray: string }; + nodeDefault: { stroke: string; strokeWidth: number }; + nodeSnap: { stroke: string; strokeWidth: number }; + selectionBoundingBoxPad: number; +} + +export function editorStyles(theme: CanvasTheme, k: number): EditorStyles { + return { + edgeHit: { + strokeWidth: 12 / k, + }, + edge: { + stroke: 'currentColor', + strokeWidth: 2 / k, + strokeOpacity: 0.8, + }, + edgeSelected: { + stroke: theme.selection, + strokeWidth: 3 / k, + strokeOpacity: 0.8, + }, + edgeHandle: { + r: 6 / k, + fill: theme.selection, + stroke: theme.background, + strokeWidth: 1.5 / k, + }, + selectionBoundingBox: { + fill: 'none', + stroke: theme.selection, + strokeWidth: 1.5 / k, + strokeDasharray: `${5 / k},${3 / k}`, + }, + resizeHandle: { + r: 5 / k, + fill: theme.background, + stroke: theme.selection, + strokeWidth: 1.5 / k, + }, + dragEdge: { + stroke: theme.connection, + strokeWidth: 2 / k, + strokeDasharray: `${6 / k},${4 / k}`, + }, + selectionRect: { + fill: theme.connection + '1a', + stroke: theme.connection, + strokeWidth: 1 / k, + strokeDasharray: `${4 / k},${3 / k}`, + }, + nodeDefault: { stroke: theme.nodeStroke, strokeWidth: 2 }, + nodeSnap: { stroke: theme.snapHighlight, strokeWidth: 3 }, + selectionBoundingBoxPad: 6 / k, + }; +} diff --git a/canvas/src/utils/generateId.ts b/canvas/src/utils/generateId.ts new file mode 100644 index 000000000..f5c19c669 --- /dev/null +++ b/canvas/src/utils/generateId.ts @@ -0,0 +1,23 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +function uuid(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} + +export function generateId(prefix: string): string { + return `${prefix}-${uuid()}`; +} diff --git a/canvas/src/utils/icons.ts b/canvas/src/utils/icons.ts new file mode 100644 index 000000000..1d358c7e1 --- /dev/null +++ b/canvas/src/utils/icons.ts @@ -0,0 +1,147 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// MDI icon path data (viewBox 0 0 24 24). Paths sourced from mdi-material-ui. +export const ICON_PATHS: Record = { + // Servers & hardware + Server: + 'M4,1H20A1,1 0 0,1 21,2V6A1,1 0 0,1 20,7H4A1,1 0 0,1 3,6V2A1,1 0 0,1 4,1M4,9H20A1,1 0 0,1 21,10V14A1,1 0 0,1 20,15H4A1,1 0 0,1 3,14V10A1,1 0 0,1 4,9M4,17H20A1,1 0 0,1 21,18V22A1,1 0 0,1 20,23H4A1,1 0 0,1 3,22V18A1,1 0 0,1 4,17M9,5H10V3H9V5M9,13H10V11H9V13M9,21H10V19H9V21M5,3V5H7V3H5M5,11V13H7V11H5M5,19V21H7V19H5Z', + ServerNetwork: + 'M13,19H14A1,1 0 0,1 15,20H22V22H15A1,1 0 0,1 14,23H10A1,1 0 0,1 9,22H2V20H9A1,1 0 0,1 10,19H11V17H4A1,1 0 0,1 3,16V12A1,1 0 0,1 4,11H20A1,1 0 0,1 21,12V16A1,1 0 0,1 20,17H13V19M4,3H20A1,1 0 0,1 21,4V8A1,1 0 0,1 20,9H4A1,1 0 0,1 3,8V4A1,1 0 0,1 4,3M9,7H10V5H9V7M9,15H10V13H9V15M5,5V7H7V5H5M5,13V15H7V13H5Z', + ServerSecurity: + 'M3,1H19A1,1 0 0,1 20,2V6A1,1 0 0,1 19,7H3A1,1 0 0,1 2,6V2A1,1 0 0,1 3,1M3,9H19A1,1 0 0,1 20,10V10.67L17.5,9.56L11,12.44V15H3A1,1 0 0,1 2,14V10A1,1 0 0,1 3,9M3,17H11C11.06,19.25 12,21.4 13.46,23H3A1,1 0 0,1 2,22V18A1,1 0 0,1 3,17M8,5H9V3H8V5M8,13H9V11H8V13M8,21H9V19H8V21M4,3V5H6V3H4M4,11V13H6V11H4M4,19V21H6V19H4M17.5,12L22,14V17C22,19.78 20.08,22.37 17.5,23C14.92,22.37 13,19.78 13,17V14L17.5,12M17.5,13.94L15,15.06V17.72C15,19.26 16.07,20.7 17.5,21.06V13.94Z', + Nas: 'M4,5C2.89,5 2,5.89 2,7V17C2,18.11 2.89,19 4,19H20C21.11,19 22,18.11 22,17V7C22,5.89 21.11,5 20,5H4M4.5,7A1,1 0 0,1 5.5,8A1,1 0 0,1 4.5,9A1,1 0 0,1 3.5,8A1,1 0 0,1 4.5,7M7,7H20V17H7V7M8,8V16H11V8H8M12,8V16H15V8H12M16,8V16H19V8H16M9,9H10V10H9V9M13,9H14V10H13V9M17,9H18V10H17V9Z', + DesktopTower: + 'M8,2H16A2,2 0 0,1 18,4V20A2,2 0 0,1 16,22H8A2,2 0 0,1 6,20V4A2,2 0 0,1 8,2M8,4V6H16V4H8M16,8H8V10H16V8M16,18H14V20H16V18Z', + Chip: 'M6,4H18V5H21V7H18V9H21V11H18V13H21V15H18V17H21V19H18V20H6V19H3V17H6V15H3V13H6V11H3V9H6V7H3V5H6V4M11,15V18H12V15H11M13,15V18H14V15H13M15,15V18H16V15H15Z', + Cpu64Bit: + 'M9,3V5H7A2,2 0 0,0 5,7V9H3V11H5V13H3V15H5V17A2,2 0 0,0 7,19H9V21H11V19H13V21H15V19H17A2,2 0 0,0 19,17V15H21V13H19V11H21V9H19V7A2,2 0 0,0 17,5H15V3H13V5H11V3M8,9H11.5V10.5H8.5V11.25H10.5A1,1 0 0,1 11.5,12.25V14A1,1 0 0,1 10.5,15H8A1,1 0 0,1 7,14V10A1,1 0 0,1 8,9M12.5,9H14V11H15.5V9H17V15H15.5V12.5H12.5M8.5,12.75V13.5H10V12.75', + Memory: + 'M17,17H7V7H17M21,11V9H19V7C19,5.89 18.1,5 17,5H15V3H13V5H11V3H9V5H7C5.89,5 5,5.89 5,7V9H3V11H5V13H3V15H5V17A2,2 0 0,0 7,19H9V21H11V19H13V21H15V19H17A2,2 0 0,0 19,17V15H21V13H19V11M13,13H11V11H13M15,9H9V15H15V9Z', + RaspberryPi: + 'M20,8H22V10H20V8M4,5H20A2,2 0 0,1 22,7H19V9H5V13H8V16H19V17H22A2,2 0 0,1 20,19H16V20H14V19H11V20H7V19H4A2,2 0 0,1 2,17V7A2,2 0 0,1 4,5M19,15H9V10H19V11H22V13H19V15M13,12V14H15V12H13M5,6V8H6V6H5M7,6V8H8V6H7M9,6V8H10V6H9M11,6V8H12V6H11M13,6V8H14V6H13M15,6V8H16V6H15M20,14H22V16H20V14Z', + // Network devices + Router: + 'M12 2C6.5 2 2 6.5 2 12C2 17.5 6.5 22 12 22C17.5 22 22 17.5 22 12C22 6.5 17.5 2 12 2M12 20C7.58 20 4 16.42 4 12C4 7.58 7.58 4 12 4C16.42 4 20 7.58 20 12C20 16.42 16.42 20 12 20M13 13V16H15L12 19L9 16H11V13M5 13H8V15L11 12L8 9V11H5M11 11V8H9L12 5L15 8H13V11M19 11H16V9L13 12L16 15V13H19', + RouterNetwork: + 'M5 9C3.9 9 3 9.9 3 11V15C3 16.11 3.9 17 5 17H11V19H10C9.45 19 9 19.45 9 20H2V22H9C9 22.55 9.45 23 10 23H14C14.55 23 15 22.55 15 22H22V20H15C15 19.45 14.55 19 14 19H13V17H19C20.11 17 21 16.11 21 15V11C21 9.9 20.11 9 19 9H5M6 12H8V14H6V12M9.5 12H11.5V14H9.5V12M13 12H15V14H13V12Z', + RouterWireless: + 'M20.2,5.9L21,5.1C19.6,3.7 17.8,3 16,3C14.2,3 12.4,3.7 11,5.1L11.8,5.9C13,4.8 14.5,4.2 16,4.2C17.5,4.2 19,4.8 20.2,5.9M19.3,6.7C18.4,5.8 17.2,5.3 16,5.3C14.8,5.3 13.6,5.8 12.7,6.7L13.5,7.5C14.2,6.8 15.1,6.5 16,6.5C16.9,6.5 17.8,6.8 18.5,7.5L19.3,6.7M19,13H17V9H15V13H5A2,2 0 0,0 3,15V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V15A2,2 0 0,0 19,13M8,18H6V16H8V18M11.5,18H9.5V16H11.5V18M15,18H13V16H15V18Z', + Switch: + 'M13,18H14A1,1 0 0,1 15,19H22V21H15A1,1 0 0,1 14,22H10A1,1 0 0,1 9,21H2V19H9A1,1 0 0,1 10,18H11V16H8A1,1 0 0,1 7,15V3A1,1 0 0,1 8,2H16A1,1 0 0,1 17,3V15A1,1 0 0,1 16,16H13V18M13,6H14V4H13V6M9,4V6H11V4H9M9,8V10H11V8H9M9,12V14H11V12H9Z', + Hub: 'M8.4 18.2C8.8 18.7 9 19.3 9 20C9 21.7 7.7 23 6 23S3 21.7 3 20 4.3 17 6 17C6.4 17 6.8 17.1 7.2 17.3L8.6 15.5C7.7 14.5 7.3 13.1 7.5 11.8L5.5 11.1C5 11.9 4.1 12.5 3 12.5C1.3 12.5 0 11.2 0 9.5S1.3 6.5 3 6.5 6 7.8 6 9.5V9.7L8 10.4C8.6 9.2 9.8 8.3 11.2 8.1V5.9C10 5.6 9 4.4 9 3C9 1.3 10.3 0 12 0S15 1.3 15 3C15 4.4 14 5.6 12.8 5.9V8.1C14.2 8.3 15.4 9.2 16 10.4L18 9.7V9.5C18 7.8 19.3 6.5 21 6.5S24 7.8 24 9.5 22.7 12.5 21 12.5C19.9 12.5 19 11.9 18.5 11.1L16.5 11.8C16.7 13.1 16.3 14.5 15.4 15.5L16.8 17.3C17.2 17.1 17.6 17 18 17C19.7 17 21 18.3 21 20S19.7 23 18 23 15 21.7 15 20C15 19.3 15.2 18.7 15.6 18.2L14.2 16.4C12.8 17.2 11.2 17.2 9.8 16.4L8.4 18.2Z', + Antenna: + 'M12 7.5C12.69 7.5 13.27 7.73 13.76 8.2S14.5 9.27 14.5 10C14.5 11.05 14 11.81 13 12.28V21H11V12.28C10 11.81 9.5 11.05 9.5 10C9.5 9.27 9.76 8.67 10.24 8.2S11.31 7.5 12 7.5M16.69 5.3C17.94 6.55 18.61 8.11 18.7 10C18.7 11.8 18.03 13.38 16.69 14.72L15.5 13.5C16.5 12.59 17 11.42 17 10C17 8.67 16.5 7.5 15.5 6.5L16.69 5.3M6.09 4.08C4.5 5.67 3.7 7.64 3.7 10S4.5 14.3 6.09 15.89L4.92 17.11C3 15.08 2 12.7 2 10C2 7.3 3 4.94 4.92 2.91L6.09 4.08M19.08 2.91C21 4.94 22 7.3 22 10C22 12.8 21 15.17 19.08 17.11L17.91 15.89C19.5 14.3 20.3 12.33 20.3 10S19.5 5.67 17.91 4.08L19.08 2.91M7.31 5.3L8.5 6.5C7.5 7.42 7 8.58 7 10C7 11.33 7.5 12.5 8.5 13.5L7.31 14.72C5.97 13.38 5.3 11.8 5.3 10C5.3 8.2 5.97 6.64 7.31 5.3Z', + // Connectivity + Lan: 'M10,2C8.89,2 8,2.89 8,4V7C8,8.11 8.89,9 10,9H11V11H2V13H6V15H5C3.89,15 3,15.89 3,17V20C3,21.11 3.89,22 5,22H9C10.11,22 11,21.11 11,20V17C11,15.89 10.11,15 9,15H8V13H16V15H15C13.89,15 13,15.89 13,17V20C13,21.11 13.89,22 15,22H19C20.11,22 21,21.11 21,20V17C21,15.89 20.11,15 19,15H18V13H22V11H13V9H14C15.11,9 16,8.11 16,7V4C16,2.89 15.11,2 14,2H10M10,4H14V7H10V4M5,17H9V20H5V17M15,17H19V20H15V17Z', + LanConnect: + 'M4,1C2.89,1 2,1.89 2,3V7C2,8.11 2.89,9 4,9H1V11H13V9H10C11.11,9 12,8.11 12,7V3C12,1.89 11.11,1 10,1H4M4,3H10V7H4V3M3,13V18L3,20H10V18H5V13H3M14,13C12.89,13 12,13.89 12,15V19C12,20.11 12.89,21 14,21H11V23H23V21H20C21.11,21 22,20.11 22,19V15C22,13.89 21.11,13 20,13H14M14,15H20V19H14V15Z', + LanDisconnect: + 'M4,1C2.89,1 2,1.89 2,3V7C2,8.11 2.89,9 4,9H1V11H13V9H10C11.11,9 12,8.11 12,7V3C12,1.89 11.11,1 10,1H4M4,3H10V7H4V3M14,13C12.89,13 12,13.89 12,15V19C12,20.11 12.89,21 14,21H11V23H23V21H20C21.11,21 22,20.11 22,19V15C22,13.89 21.11,13 20,13H14M3.88,13.46L2.46,14.88L4.59,17L2.46,19.12L3.88,20.54L6,18.41L8.12,20.54L9.54,19.12L7.41,17L9.54,14.88L8.12,13.46L6,15.59L3.88,13.46M14,15H20V19H14V15Z', + Ethernet: + 'M7,15H9V18H11V15H13V18H15V15H17V18H19V9H15V6H9V9H5V18H7V15M4.38,3H19.63C20.94,3 22,4.06 22,5.38V19.63A2.37,2.37 0 0,1 19.63,22H4.38C3.06,22 2,20.94 2,19.63V5.38C2,4.06 3.06,3 4.38,3Z', + EthernetCable: 'M11,3V7H13V3H11M8,4V11H16V4H14V8H10V4H8M10,12V22H14V12H10Z', + EthernetCableOff: + 'M11,3H13V7H11V3M8,4H10V8H14V4H16V11H12.82L8,6.18V4M20,20.72L18.73,22L14,17.27V22H10V13.27L2,5.27L3.28,4L20,20.72Z', + Wifi: 'M12,21L15.6,16.2C14.6,15.45 13.35,15 12,15C10.65,15 9.4,15.45 8.4,16.2L12,21M12,3C7.95,3 4.21,4.34 1.2,6.6L3,9C5.5,7.12 8.62,6 12,6C15.38,6 18.5,7.12 21,9L22.8,6.6C19.79,4.34 16.05,3 12,3M12,9C9.3,9 6.81,9.89 4.8,11.4L6.6,13.8C8.1,12.67 9.97,12 12,12C14.03,12 15.9,12.67 17.4,13.8L19.2,11.4C17.19,9.89 14.7,9 12,9Z', + WifiStrength4: + 'M12,3C7.79,3 3.7,4.41 0.38,7C4.41,12.06 7.89,16.37 12,21.5C16.08,16.42 20.24,11.24 23.65,7C20.32,4.41 16.22,3 12,3Z', + WifiOff: + 'M2.28,3L1,4.27L2.47,5.74C2.04,6 1.61,6.29 1.2,6.6L3,9C3.53,8.6 4.08,8.25 4.66,7.93L6.89,10.16C6.15,10.5 5.44,10.91 4.8,11.4L6.6,13.8C7.38,13.22 8.26,12.77 9.2,12.47L11.75,15C10.5,15.07 9.34,15.5 8.4,16.2L12,21L14.46,17.73L17.74,21L19,19.72M12,3C9.85,3 7.8,3.38 5.9,4.07L8.29,6.47C9.5,6.16 10.72,6 12,6C15.38,6 18.5,7.11 21,9L22.8,6.6C19.79,4.34 16.06,3 12,3M12,9C11.62,9 11.25,9 10.88,9.05L14.07,12.25C15.29,12.53 16.43,13.07 17.4,13.8L19.2,11.4C17.2,9.89 14.7,9 12,9Z', + CellphoneWireless: + 'M20.07,4.93C21.88,6.74 23,9.24 23,12C23,14.76 21.88,17.26 20.07,19.07L18.66,17.66C20.11,16.22 21,14.22 21,12C21,9.79 20.11,7.78 18.66,6.34L20.07,4.93M17.24,7.76C18.33,8.85 19,10.35 19,12C19,13.65 18.33,15.15 17.24,16.24L15.83,14.83C16.55,14.11 17,13.11 17,12C17,10.89 16.55,9.89 15.83,9.17L17.24,7.76M13,10A2,2 0 0,1 15,12A2,2 0 0,1 13,14A2,2 0 0,1 11,12A2,2 0 0,1 13,10M11.5,1A2.5,2.5 0 0,1 14,3.5V8H12V4H3V19H12V16H14V20.5A2.5,2.5 0 0,1 11.5,23H3.5A2.5,2.5 0 0,1 1,20.5V3.5A2.5,2.5 0 0,1 3.5,1H11.5Z', + CellphoneLink: + 'M22,17H18V10H22M23,8H17A1,1 0 0,0 16,9V19A1,1 0 0,0 17,20H23A1,1 0 0,0 24,19V9A1,1 0 0,0 23,8M4,6H22V4H4A2,2 0 0,0 2,6V17H0V20H14V17H4V6Z', + SatelliteVariant: + 'M11.62,1L17.28,6.67L15.16,8.79L13.04,6.67L11.62,8.09L13.95,10.41L12.79,11.58L13.24,12.04C14.17,11.61 15.31,11.77 16.07,12.54L12.54,16.07C11.77,15.31 11.61,14.17 12.04,13.24L11.58,12.79L10.41,13.95L8.09,11.62L6.67,13.04L8.79,15.16L6.67,17.28L1,11.62L3.14,9.5L5.26,11.62L6.67,10.21L3.84,7.38C3.06,6.6 3.06,5.33 3.84,4.55L4.55,3.84C5.33,3.06 6.6,3.06 7.38,3.84L10.21,6.67L11.62,5.26L9.5,3.14L11.62,1M18,14A4,4 0 0,1 14,18V16A2,2 0 0,0 16,14H18M22,14A8,8 0 0,1 14,22V20A6,6 0 0,0 20,14H22Z', + // Cloud & applications + Cloud: + 'M6.5 20Q4.22 20 2.61 18.43 1 16.85 1 14.58 1 12.63 2.17 11.1 3.35 9.57 5.25 9.15 5.88 6.85 7.75 5.43 9.63 4 12 4 14.93 4 16.96 6.04 19 8.07 19 11 20.73 11.2 21.86 12.5 23 13.78 23 15.5 23 17.38 21.69 18.69 20.38 20 18.5 20Z', + CloudCircle: + 'M8.5 16H16Q17.25 16 18.13 15.13T19 13Q19 11.75 18.13 10.88T16 10Q15.8 8.55 14.68 7.53 13.55 6.5 12.15 6.5 10.88 6.5 9.84 7.15 8.8 7.8 8.3 9 6.88 9.13 5.94 10.09 5 11.05 5 12.5 5 13.95 6.03 15 7.05 16 8.5 16M12 22Q9.93 22 8.1 21.21 6.28 20.43 4.93 19.08 3.58 17.73 2.79 15.9 2 14.08 2 12T2.79 8.1Q3.58 6.28 4.93 4.93 6.28 3.58 8.1 2.79 9.93 2 12 2T15.9 2.79Q17.73 3.58 19.08 4.93 20.43 6.28 21.21 8.1 22 9.93 22 12T21.21 15.9Q20.43 17.73 19.08 19.08 17.73 20.43 15.9 21.21 14.08 22 12 22Z', + CloudOutline: + 'M6.5 20Q4.22 20 2.61 18.43 1 16.85 1 14.58 1 12.63 2.17 11.1 3.35 9.57 5.25 9.15 5.88 6.85 7.75 5.43 9.63 4 12 4 14.93 4 16.96 6.04 19 8.07 19 11 20.73 11.2 21.86 12.5 23 13.78 23 15.5 23 17.38 21.69 18.69 20.38 20 18.5 20M6.5 18H18.5Q19.55 18 20.27 17.27 21 16.55 21 15.5 21 14.45 20.27 13.73 19.55 13 18.5 13H17V11Q17 8.93 15.54 7.46 14.08 6 12 6 9.93 6 8.46 7.46 7 8.93 7 11H6.5Q5.05 11 4.03 12.03 3 13.05 3 14.5 3 15.95 4.03 17 5.05 18 6.5 18M12 12Z', + Application: + 'M21 2H3C1.9 2 1 2.9 1 4V20C1 21.1 1.9 22 3 22H21C22.1 22 23 21.1 23 20V4C23 2.9 22.1 2 21 2M21 7H3V4H21V7Z', + ApplicationOutline: + 'M21 2H3C1.9 2 1 2.9 1 4V20C1 21.1 1.9 22 3 22H21C22.1 22 23 21.1 23 20V4C23 2.9 22.1 2 21 2M21 20H3V6H21V20Z', + Kubernetes: + 'M13.95 13.5H13.72C13.54 13.61 13.46 13.82 13.54 14L14.4 16.11C15.23 15.58 15.86 14.79 16.19 13.86L13.96 13.5H13.95M10.5 13.79C10.44 13.62 10.29 13.5 10.12 13.5H10.04L7.82 13.87C8.15 14.79 8.78 15.57 9.61 16.1L10.46 14.03V14C10.5 13.95 10.5 13.86 10.5 13.79M12.33 14.6C12.23 14.42 12 14.35 11.82 14.45C11.75 14.5 11.7 14.53 11.67 14.6H11.66L10.57 16.57C11.35 16.83 12.19 16.88 13 16.69C13.14 16.66 13.29 16.62 13.43 16.57L12.34 14.6H12.33M15.78 10.03L14.1 11.5L14.11 11.53C13.95 11.67 13.93 11.91 14.07 12.06C14.12 12.12 14.18 12.16 14.25 12.18L14.26 12.19L16.43 12.81C16.5 11.84 16.29 10.86 15.78 10.03M12.67 10.19C12.68 10.4 12.85 10.56 13.06 10.55C13.14 10.55 13.21 10.53 13.27 10.5H13.28L15.11 9.19C14.41 8.5 13.5 8.07 12.54 7.95L12.67 10.19M10.73 10.5C10.9 10.61 11.13 10.58 11.25 10.41C11.3 10.35 11.32 10.28 11.33 10.2H11.34L11.46 7.95C11.31 7.97 11.16 8 11 8.03C10.2 8.21 9.46 8.61 8.88 9.19L10.72 10.5H10.73M9.74 12.19C9.94 12.14 10.06 11.93 10 11.73C10 11.65 9.95 11.59 9.89 11.54V11.53L8.21 10C7.69 10.86 7.47 11.84 7.58 12.82L9.74 12.2V12.19M11.38 12.85L12 13.15L12.62 12.85L12.77 12.18L12.34 11.65H11.65L11.22 12.18L11.38 12.85M22.27 14.17L20.5 6.5C20.41 6.08 20.13 5.74 19.76 5.56L12.59 2.13C12.22 1.96 11.78 1.96 11.4 2.13L4.24 5.56C3.87 5.74 3.59 6.08 3.5 6.5L1.73 14.17C1.68 14.37 1.68 14.57 1.73 14.76C1.74 14.82 1.76 14.88 1.78 14.94C1.81 15.03 1.86 15.13 1.91 15.21C1.94 15.25 1.96 15.29 2 15.32L6.95 21.5C6.97 21.5 7 21.54 7 21.56C7.1 21.65 7.19 21.72 7.28 21.78C7.4 21.86 7.54 21.92 7.68 21.95C7.79 22 7.91 22 8 22H16.12C16.19 22 16.26 21.97 16.32 21.95C16.37 21.94 16.42 21.92 16.46 21.91C16.5 21.89 16.53 21.88 16.57 21.86C16.62 21.84 16.67 21.81 16.72 21.78C16.84 21.7 16.95 21.6 17.05 21.5L17.2 21.3L22 15.32C22.1 15.2 22.17 15.07 22.22 14.94C22.24 14.88 22.26 14.82 22.27 14.76C22.32 14.57 22.32 14.36 22.27 14.17Z', + Docker: + 'M21.81 10.25C21.75 10.21 21.25 9.82 20.17 9.82C19.89 9.82 19.61 9.85 19.33 9.9C19.12 8.5 17.95 7.79 17.9 7.76L17.61 7.59L17.43 7.86C17.19 8.22 17 8.63 16.92 9.05C16.72 9.85 16.84 10.61 17.25 11.26C16.76 11.54 15.96 11.61 15.79 11.61H2.62C2.28 11.61 2 11.89 2 12.24C2 13.39 2.18 14.54 2.58 15.62C3.03 16.81 3.71 17.69 4.58 18.23C5.56 18.83 7.17 19.17 9 19.17C9.79 19.17 10.61 19.1 11.42 18.95C12.54 18.75 13.62 18.36 14.61 17.79C15.43 17.32 16.16 16.72 16.78 16C17.83 14.83 18.45 13.5 18.9 12.35H19.09C20.23 12.35 20.94 11.89 21.33 11.5C21.59 11.26 21.78 10.97 21.92 10.63L22 10.39L21.81 10.25M3.85 11.24H5.61C5.69 11.24 5.77 11.17 5.77 11.08V9.5C5.77 9.42 5.7 9.34 5.61 9.34H3.85C3.76 9.34 3.69 9.41 3.69 9.5V11.08C3.7 11.17 3.76 11.24 3.85 11.24M6.28 11.24H8.04C8.12 11.24 8.2 11.17 8.2 11.08V9.5C8.2 9.42 8.13 9.34 8.04 9.34H6.28C6.19 9.34 6.12 9.41 6.12 9.5V11.08C6.13 11.17 6.19 11.24 6.28 11.24M8.75 11.24H10.5C10.6 11.24 10.67 11.17 10.67 11.08V9.5C10.67 9.42 10.61 9.34 10.5 9.34H8.75C8.67 9.34 8.6 9.41 8.6 9.5V11.08C8.6 11.17 8.66 11.24 8.75 11.24M11.19 11.24H12.96C13.04 11.24 13.11 11.17 13.11 11.08V9.5C13.11 9.42 13.05 9.34 12.96 9.34H11.19C11.11 9.34 11.04 9.41 11.04 9.5V11.08C11.04 11.17 11.11 11.24 11.19 11.24M6.28 9H8.04C8.12 9 8.2 8.91 8.2 8.82V7.25C8.2 7.16 8.13 7.09 8.04 7.09H6.28C6.19 7.09 6.12 7.15 6.12 7.25V8.82C6.13 8.91 6.19 9 6.28 9M8.75 9H10.5C10.6 9 10.67 8.91 10.67 8.82V7.25C10.67 7.16 10.61 7.09 10.5 7.09H8.75C8.67 7.09 8.6 7.15 8.6 7.25V8.82C8.6 8.91 8.66 9 8.75 9M11.19 9H12.96C13.04 9 13.11 8.91 13.11 8.82V7.25C13.11 7.16 13.04 7.09 12.96 7.09H11.19C11.11 7.09 11.04 7.15 11.04 7.25V8.82C11.04 8.91 11.11 9 11.19 9M11.19 6.72H12.96C13.04 6.72 13.11 6.65 13.11 6.56V5C13.11 4.9 13.04 4.83 12.96 4.83H11.19C11.11 4.83 11.04 4.89 11.04 5V6.56C11.04 6.64 11.11 6.72 11.19 6.72M13.65 11.24H15.41C15.5 11.24 15.57 11.17 15.57 11.08V9.5C15.57 9.42 15.5 9.34 15.41 9.34H13.65C13.57 9.34 13.5 9.41 13.5 9.5V11.08C13.5 11.17 13.57 11.24 13.65 11.24', + // Databases + Database: + 'M12,3C7.58,3 4,4.79 4,7C4,9.21 7.58,11 12,11C16.42,11 20,9.21 20,7C20,4.79 16.42,3 12,3M4,9V12C4,14.21 7.58,16 12,16C16.42,16 20,14.21 20,12V9C20,11.21 16.42,13 12,13C7.58,13 4,11.21 4,9M4,14V17C4,19.21 7.58,21 12,21C16.42,21 20,19.21 20,17V14C20,16.21 16.42,18 12,18C7.58,18 4,16.21 4,14Z', + DatabaseOutline: + 'M12 3C7.58 3 4 4.79 4 7V17C4 19.21 7.59 21 12 21S20 19.21 20 17V7C20 4.79 16.42 3 12 3M18 17C18 17.5 15.87 19 12 19S6 17.5 6 17V14.77C7.61 15.55 9.72 16 12 16S16.39 15.55 18 14.77V17M18 12.45C16.7 13.4 14.42 14 12 14C9.58 14 7.3 13.4 6 12.45V9.64C7.47 10.47 9.61 11 12 11C14.39 11 16.53 10.47 18 9.64V12.45M12 9C8.13 9 6 7.5 6 7S8.13 5 12 5C15.87 5 18 6.5 18 7S15.87 9 12 9Z', + DatabaseSearch: + 'M18.68,12.32C16.92,10.56 14.07,10.57 12.32,12.33C10.56,14.09 10.56,16.94 12.32,18.69C13.81,20.17 16.11,20.43 17.89,19.32L21,22.39L22.39,21L19.3,17.89C20.43,16.12 20.17,13.8 18.68,12.32M17.27,17.27C16.29,18.25 14.71,18.24 13.73,17.27C12.76,16.29 12.76,14.71 13.74,13.73C14.71,12.76 16.29,12.76 17.27,13.73C18.24,14.71 18.24,16.29 17.27,17.27M10.9,20.1C10.25,19.44 9.74,18.65 9.42,17.78C6.27,17.25 4,15.76 4,14V17C4,19.21 7.58,21 12,21V21C11.6,20.74 11.23,20.44 10.9,20.1M4,9V12C4,13.68 6.07,15.12 9,15.7C9,15.63 9,15.57 9,15.5C9,14.57 9.2,13.65 9.58,12.81C6.34,12.3 4,10.79 4,9M12,3C7.58,3 4,4.79 4,7C4,9 7,10.68 10.85,11H10.9C12.1,9.74 13.76,9 15.5,9C16.41,9 17.31,9.19 18.14,9.56C19.17,9.09 19.87,8.12 20,7C20,4.79 16.42,3 12,3Z', + // Security + ShieldLock: + 'M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1M12,7C13.4,7 14.8,8.1 14.8,9.5V11C15.4,11 16,11.6 16,12.3V15.8C16,16.4 15.4,17 14.7,17H9.2C8.6,17 8,16.4 8,15.7V12.2C8,11.6 8.6,11 9.2,11V9.5C9.2,8.1 10.6,7 12,7M12,8.2C11.2,8.2 10.5,8.7 10.5,9.5V11H13.5V9.5C13.5,8.7 12.8,8.2 12,8.2Z', + ShieldCheck: + 'M10,17L6,13L7.41,11.59L10,14.17L16.59,7.58L18,9M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1Z', + Security: + 'M12,12H19C18.47,16.11 15.72,19.78 12,20.92V12H5V6.3L12,3.19M12,1L3,5V11C3,16.55 6.84,21.73 12,23C17.16,21.73 21,16.55 21,11V5L12,1Z', + SecurityNetwork: + 'M13,19H14A1,1 0 0,1 15,20H22V22H15A1,1 0 0,1 14,23H10A1,1 0 0,1 9,22H2V20H9A1,1 0 0,1 10,19H11V17.34C8.07,16.13 6,13 6,9.67V5.67L12,3L18,5.67V9.67C18,13 15.93,16.13 13,17.34V19M12,5L8,6.69V10H12V5M12,10V16C13.91,15.53 16,13.06 16,11V10H12Z', + Lock: 'M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V10C4,8.89 4.9,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z', + LockOutline: + 'M12,17C10.89,17 10,16.1 10,15C10,13.89 10.89,13 12,13A2,2 0 0,1 14,15A2,2 0 0,1 12,17M18,20V10H6V20H18M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V10C4,8.89 4.89,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z', + Key: 'M7 14C5.9 14 5 13.1 5 12S5.9 10 7 10 9 10.9 9 12 8.1 14 7 14M12.6 10C11.8 7.7 9.6 6 7 6C3.7 6 1 8.7 1 12S3.7 18 7 18C9.6 18 11.8 16.3 12.6 14H16V18H20V14H23V10H12.6Z', + // Monitoring & observability + Monitor: + 'M21,16H3V4H21M21,2H3C1.89,2 1,2.89 1,4V16A2,2 0 0,0 3,18H10V20H8V22H16V20H14V18H21A2,2 0 0,0 23,16V4C23,2.89 22.1,2 21,2Z', + MonitorDashboard: + 'M21,16V4H3V16H21M21,2A2,2 0 0,1 23,4V16A2,2 0 0,1 21,18H14V20H16V22H8V20H10V18H3C1.89,18 1,17.1 1,16V4C1,2.89 1.89,2 3,2H21M5,6H14V11H5V6M15,6H19V8H15V6M19,9V14H15V9H19M5,12H9V14H5V12M10,12H14V14H10V12Z', + Laptop: + 'M4,6H20V16H4M20,18A2,2 0 0,0 22,16V6C22,4.89 21.1,4 20,4H4C2.89,4 2,4.89 2,6V16A2,2 0 0,0 4,18H0V20H24V18H20Z', + Speedometer: + 'M12,16A3,3 0 0,1 9,13C9,11.88 9.61,10.9 10.5,10.39L20.21,4.77L14.68,14.35C14.18,15.33 13.17,16 12,16M12,3C13.81,3 15.5,3.5 16.97,4.32L14.87,5.53C14,5.19 13,5 12,5A8,8 0 0,0 4,13C4,15.21 4.89,17.21 6.34,18.65H6.35C6.74,19.04 6.74,19.67 6.35,20.06C5.96,20.45 5.32,20.45 4.93,20.07V20.07C3.12,18.26 2,15.76 2,13A10,10 0 0,1 12,3M22,13C22,15.76 20.88,18.26 19.07,20.07V20.07C18.68,20.45 18.05,20.45 17.66,20.06C17.27,19.67 17.27,19.04 17.66,18.65V18.65C19.11,17.2 20,15.21 20,13C20,12 19.81,11 19.46,10.1L20.67,8C21.5,9.5 22,11.18 22,13Z', + SpeedometerMedium: + 'M12 1.38L9.14 12.06C8.8 13.1 9.04 14.29 9.86 15.12C11.04 16.29 12.94 16.29 14.11 15.12C14.9 14.33 15.16 13.2 14.89 12.21M14.6 3.35L15.22 5.68C18.04 6.92 20 9.73 20 13C20 15.21 19.11 17.21 17.66 18.65H17.65C17.26 19.04 17.26 19.67 17.65 20.06C18.04 20.45 18.68 20.45 19.07 20.07C20.88 18.26 22 15.76 22 13C22 8.38 18.86 4.5 14.6 3.35M9.4 3.36C5.15 4.5 2 8.4 2 13C2 15.76 3.12 18.26 4.93 20.07C5.32 20.45 5.95 20.45 6.34 20.06C6.73 19.67 6.73 19.04 6.34 18.65C4.89 17.2 4 15.21 4 13C4 9.65 5.94 6.86 8.79 5.65', + Eye: 'M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9M12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17M12,4.5C7,4.5 2.73,7.61 1,12C2.73,16.39 7,19.5 12,19.5C17,19.5 21.27,16.39 23,12C21.27,7.61 17,4.5 12,4.5Z', + Bell: 'M21,19V20H3V19L5,17V11C5,7.9 7.03,5.17 10,4.29C10,4.19 10,4.1 10,4A2,2 0 0,1 12,2A2,2 0 0,1 14,4C14,4.1 14,4.19 14,4.29C16.97,5.17 19,7.9 19,11V17L21,19M14,21A2,2 0 0,1 12,23A2,2 0 0,1 10,21', + // Status indicators + CheckCircle: + 'M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M10 17L5 12L6.41 10.59L10 14.17L17.59 6.58L19 8L10 17Z', + AlertCircle: + 'M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z', + CloseCircle: + 'M12,2C17.53,2 22,6.47 22,12C22,17.53 17.53,22 12,22C6.47,22 2,17.53 2,12C2,6.47 6.47,2 12,2M15.59,7L12,10.59L8.41,7L7,8.41L10.59,12L7,15.59L8.41,17L12,13.41L15.59,17L17,15.59L13.41,12L17,8.41L15.59,7Z', + CheckNetworkOutline: + 'M15,20A1,1 0 0,0 14,19H13V17H17A2,2 0 0,0 19,15V5A2,2 0 0,0 17,3H7A2,2 0 0,0 5,5V15A2,2 0 0,0 7,17H11V19H10A1,1 0 0,0 9,20H2V22H9A1,1 0 0,0 10,23H14A1,1 0 0,0 15,22H22V20H15M7,15V5H17V15H7M8,10.37L9.24,9.13L10.93,10.83L14.76,7L16,8.5L10.93,13.57L8,10.37Z', + // Topology + Network: + 'M17,3A2,2 0 0,1 19,5V15A2,2 0 0,1 17,17H13V19H14A1,1 0 0,1 15,20H22V22H15A1,1 0 0,1 14,23H10A1,1 0 0,1 9,22H2V20H9A1,1 0 0,1 10,19H11V17H7C5.89,17 5,16.1 5,15V5A2,2 0 0,1 7,3H17Z', + NetworkOutline: + 'M15,20A1,1 0 0,0 14,19H13V17H17A2,2 0 0,0 19,15V5A2,2 0 0,0 17,3H7A2,2 0 0,0 5,5V15A2,2 0 0,0 7,17H11V19H10A1,1 0 0,0 9,20H2V22H9A1,1 0 0,0 10,23H14A1,1 0 0,0 15,22H22V20H15M7,15V5H17V15H7Z', + Connection: + 'M21.4 7.5C22.2 8.3 22.2 9.6 21.4 10.3L18.6 13.1L10.8 5.3L13.6 2.5C14.4 1.7 15.7 1.7 16.4 2.5L18.2 4.3L21.2 1.3L22.6 2.7L19.6 5.7L21.4 7.5M15.6 13.3L14.2 11.9L11.4 14.7L9.3 12.6L12.1 9.8L10.7 8.4L7.9 11.2L6.4 9.8L3.6 12.6C2.8 13.4 2.8 14.7 3.6 15.4L5.4 17.2L1.4 21.2L2.8 22.6L6.8 18.6L8.6 20.4C9.4 21.2 10.7 21.2 11.4 20.4L14.2 17.6L12.8 16.2L15.6 13.3Z', + ArrowUpDown: + 'M17.45,17.55L12,23L6.55,17.55L7.96,16.14L11,19.17V4.83L7.96,7.86L6.55,6.45L12,1L17.45,6.45L16.04,7.86L13,4.83V19.17L16.04,16.14L17.45,17.55Z', + ArrowLeftRight: + 'M6.45,17.45L1,12L6.45,6.55L7.86,7.96L4.83,11H19.17L16.14,7.96L17.55,6.55L23,12L17.55,17.45L16.14,16.04L19.17,13H4.83L7.86,16.04L6.45,17.45Z', + // Globe / internet + Earth: + 'M17.9,17.39C17.64,16.59 16.89,16 16,16H15V13A1,1 0 0,0 14,12H8V10H10A1,1 0 0,0 11,9V7H13A2,2 0 0,0 15,5V4.59C17.93,5.77 20,8.64 20,12C20,14.08 19.2,15.97 17.9,17.39M11,19.93C7.05,19.44 4,16.08 4,12C4,11.38 4.08,10.78 4.21,10.21L9,15V16A2,2 0 0,0 11,18M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z', + EarthBox: + 'M5,3C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3H5M15.78,5H19V17.18C18.74,16.38 17.69,15.79 16.8,15.79H15.8V12.79A1,1 0 0,0 14.8,11.79H8.8V9.79H10.8A1,1 0 0,0 11.8,8.79V6.79H13.8C14.83,6.79 15.67,6 15.78,5M5,10.29L9.8,14.79V15.79C9.8,16.9 10.7,17.79 11.8,17.79V19H5V10.29Z', + GlobeModel: + 'M17.36,2.64L15.95,4.06C17.26,5.37 18,7.14 18,9A7,7 0 0,1 11,16C9.15,16 7.37,15.26 6.06,13.95L4.64,15.36C6.08,16.8 7.97,17.71 10,17.93V20H6V22H16V20H12V17.94C16.55,17.43 20,13.58 20,9C20,6.62 19.05,4.33 17.36,2.64M11,3.5A5.5,5.5 0 0,0 5.5,9A5.5,5.5 0 0,0 11,14.5A5.5,5.5 0 0,0 16.5,9A5.5,5.5 0 0,0 11,3.5M11,5.5C12.94,5.5 14.5,7.07 14.5,9A3.5,3.5 0 0,1 11,12.5A3.5,3.5 0 0,1 7.5,9A3.5,3.5 0 0,1 11,5.5Z', + GlobeLight: + 'M7.1 10C8.1 9 9.5 8.3 11 8.1V2H13V8.1C14.5 8.3 15.9 9 16.9 10H7.1M5.3 13C5.1 13.6 5 14.3 5 15C5 18.9 8.1 22 12 22S19 18.9 19 15C19 14.3 18.9 13.6 18.7 13H5.3Z', + Web: 'M16.36,14C16.44,13.34 16.5,12.68 16.5,12C16.5,11.32 16.44,10.66 16.36,10H19.74C19.9,10.64 20,11.31 20,12C20,12.69 19.9,13.36 19.74,14M14.59,19.56C15.19,18.45 15.65,17.25 15.97,16H18.92C17.96,17.65 16.43,18.93 14.59,19.56M14.34,14H9.66C9.56,13.34 9.5,12.68 9.5,12C9.5,11.32 9.56,10.65 9.66,10H14.34C14.43,10.65 14.5,11.32 14.5,12C14.5,12.68 14.43,13.34 14.34,14M12,19.96C11.17,18.76 10.5,17.43 10.09,16H13.91C13.5,17.43 12.83,18.76 12,19.96M8,8H5.08C6.03,6.34 7.57,5.06 9.4,4.44C8.8,5.55 8.35,6.75 8,8M5.08,16H8C8.35,17.25 8.8,18.45 9.4,19.56C7.57,18.93 6.03,17.65 5.08,16M4.26,14C4.1,13.36 4,12.69 4,12C4,11.31 4.1,10.64 4.26,10H7.64C7.56,10.66 7.5,11.32 7.5,12C7.5,12.68 7.56,13.34 7.64,14M12,4.03C12.83,5.23 13.5,6.57 13.91,8H10.09C10.5,6.57 11.17,5.23 12,4.03M18.92,8H15.97C15.65,6.75 15.19,5.55 14.59,4.44C16.43,5.07 17.96,6.34 18.92,8M12,2C6.47,2 2,6.5 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z', +}; + +export const ICON_NAMES = Object.keys(ICON_PATHS); diff --git a/canvas/src/utils/labelPosition.test.ts b/canvas/src/utils/labelPosition.test.ts new file mode 100644 index 000000000..d6adc3b3b --- /dev/null +++ b/canvas/src/utils/labelPosition.test.ts @@ -0,0 +1,69 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { labelAttrs } from './labelPosition'; + +describe('labelAttrs', () => { + const halfW = 50; + const halfH = 30; + + it('defaults to below when position is undefined', () => { + const attrs = labelAttrs(halfW, halfH, undefined, undefined); + expect(attrs.x).toBe(0); + expect(attrs.y).toBe(30 + 12); + expect(attrs.textAnchor).toBe('middle'); + expect(attrs.dominantBaseline).toBe('hanging'); + }); + + it('above: places label above with default padding', () => { + const attrs = labelAttrs(halfW, halfH, 'above', undefined); + expect(attrs.x).toBe(0); + expect(attrs.y).toBe(-(30 + 12)); + expect(attrs.textAnchor).toBe('middle'); + expect(attrs.dominantBaseline).toBe('auto'); + }); + + it('below: places label below with custom padding', () => { + const attrs = labelAttrs(halfW, halfH, 'below', 20); + expect(attrs.y).toBe(30 + 20); + }); + + it('left: places label to the left', () => { + const attrs = labelAttrs(halfW, halfH, 'left', undefined); + expect(attrs.x).toBe(-(50 + 12)); + expect(attrs.y).toBe(0); + expect(attrs.textAnchor).toBe('end'); + expect(attrs.dominantBaseline).toBe('middle'); + }); + + it('right: places label to the right', () => { + const attrs = labelAttrs(halfW, halfH, 'right', undefined); + expect(attrs.x).toBe(50 + 12); + expect(attrs.y).toBe(0); + expect(attrs.textAnchor).toBe('start'); + expect(attrs.dominantBaseline).toBe('middle'); + }); + + it('center: places label at origin', () => { + const attrs = labelAttrs(halfW, halfH, 'center', undefined); + expect(attrs.x).toBe(0); + expect(attrs.y).toBe(0); + expect(attrs.textAnchor).toBe('middle'); + expect(attrs.dominantBaseline).toBe('middle'); + }); + + it('respects explicit padding of 0', () => { + const attrs = labelAttrs(halfW, halfH, 'below', 0); + expect(attrs.y).toBe(30); + }); +}); diff --git a/canvas/src/utils/labelPosition.ts b/canvas/src/utils/labelPosition.ts new file mode 100644 index 000000000..9d566e0f1 --- /dev/null +++ b/canvas/src/utils/labelPosition.ts @@ -0,0 +1,47 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { LabelPosition } from '../model'; + +const DEFAULT_PADDING = 12; + +interface LabelAttrs { + x: number; + y: number; + textAnchor: 'start' | 'middle' | 'end'; + dominantBaseline: 'hanging' | 'middle' | 'auto'; +} + +export function labelAttrs( + halfW: number, + halfH: number, + position: LabelPosition | undefined, + padding: number | undefined +): LabelAttrs { + const pos = position ?? 'below'; + const pad = padding ?? DEFAULT_PADDING; + + switch (pos) { + case 'above': + return { x: 0, y: -(halfH + pad), textAnchor: 'middle', dominantBaseline: 'auto' }; + case 'left': + return { x: -(halfW + pad), y: 0, textAnchor: 'end', dominantBaseline: 'middle' }; + case 'right': + return { x: halfW + pad, y: 0, textAnchor: 'start', dominantBaseline: 'middle' }; + case 'center': + return { x: 0, y: 0, textAnchor: 'middle', dominantBaseline: 'middle' }; + case 'below': + default: + return { x: 0, y: halfH + pad, textAnchor: 'middle', dominantBaseline: 'hanging' }; + } +} diff --git a/canvas/src/utils/panelUtils.test.ts b/canvas/src/utils/panelUtils.test.ts new file mode 100644 index 000000000..5521d7c90 --- /dev/null +++ b/canvas/src/utils/panelUtils.test.ts @@ -0,0 +1,145 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { TimeSeries } from '@perses-dev/core'; +import { colorFromThresholds, interpolateLabel, isSafeImageUrl } from './panelUtils'; + +function makeSeries(labels: Record, values: Array<[number, number | null]>): TimeSeries { + return { labels, values } as unknown as TimeSeries; +} + +describe('interpolateLabel', () => { + it('replaces {{value}} with the formatted last value', () => { + const series = makeSeries({}, [[0, 42]]); + const result = interpolateLabel('current: {{value}}', series, undefined); + expect(result).toContain('42'); + }); + + it('replaces label placeholders from series labels', () => { + const series = makeSeries({ instance: 'host1' }, []); + expect(interpolateLabel('host: {{instance}}', series, undefined)).toBe('host: host1'); + }); + + it('replaces missing placeholder with empty string', () => { + const series = makeSeries({}, []); + expect(interpolateLabel('{{missing}}', series, undefined)).toBe(''); + }); + + it('leaves template unchanged when no placeholders', () => { + const series = makeSeries({}, []); + expect(interpolateLabel('static text', series, undefined)).toBe('static text'); + }); + + it('does not expose {{value}} when last value is null', () => { + const series = makeSeries({}, [[0, null]]); + const result = interpolateLabel('{{value}}', series, undefined); + expect(result).toBe(''); + }); + + it('handles whitespace inside braces: {{ value }}', () => { + const series = makeSeries({}, [[0, 10]]); + const result = interpolateLabel('{{ value }}', series, undefined); + expect(result).toContain('10'); + }); +}); + +describe('colorFromThresholds', () => { + const palette = ['#aaa', '#bbb', '#ccc']; + const fallback = '#fff'; + + it('returns defaultColor when no steps are defined', () => { + expect(colorFromThresholds(50, { steps: [] }, palette, fallback)).toBe(palette[0]); + }); + + it('returns threshold defaultColor when set and no step matches', () => { + expect( + colorFromThresholds(1, { defaultColor: '#123', steps: [{ value: 10, color: '#abc' }] }, palette, fallback) + ).toBe('#123'); + }); + + it('returns step color when value meets the threshold', () => { + const thresholds = { steps: [{ value: 10, color: '#f00' }] }; + expect(colorFromThresholds(10, thresholds, palette, fallback)).toBe('#f00'); + }); + + it('returns the highest matched step color', () => { + const thresholds = { + steps: [ + { value: 10, color: '#f00' }, + { value: 50, color: '#0f0' }, + { value: 100, color: '#00f' }, + ], + }; + expect(colorFromThresholds(75, thresholds, palette, fallback)).toBe('#0f0'); + }); + + it('falls back to palette color when step has no color', () => { + const thresholds = { steps: [{ value: 0 }] }; + expect(colorFromThresholds(5, thresholds, palette, fallback)).toBe(palette[0]); + }); + + it('returns fallback when palette is empty and step has no color', () => { + const thresholds = { steps: [{ value: 0 }] }; + expect(colorFromThresholds(5, thresholds, [], fallback)).toBe(fallback); + }); +}); + +describe('isSafeImageUrl', () => { + it('allows https URLs', () => { + expect(isSafeImageUrl('https://example.com/image.png')).toBe(true); + }); + + it('rejects http URLs', () => { + expect(isSafeImageUrl('http://example.com/image.png')).toBe(false); + }); + + it('rejects javascript URLs', () => { + expect(isSafeImageUrl('javascript:alert(1)')).toBe(false); + }); + + it('rejects blob URLs', () => { + expect(isSafeImageUrl('blob:https://example.com/abc')).toBe(false); + }); + + it('rejects plain strings that are not URLs', () => { + expect(isSafeImageUrl('not-a-url')).toBe(false); + }); + + it('allows data:image/png', () => { + expect(isSafeImageUrl('data:image/png;base64,abc123')).toBe(true); + }); + + it('allows data:image/jpeg', () => { + expect(isSafeImageUrl('data:image/jpeg;base64,abc123')).toBe(true); + }); + + it('allows data:image/gif', () => { + expect(isSafeImageUrl('data:image/gif;base64,abc123')).toBe(true); + }); + + it('allows data:image/webp', () => { + expect(isSafeImageUrl('data:image/webp;base64,abc123')).toBe(true); + }); + + it('allows data:image/avif', () => { + expect(isSafeImageUrl('data:image/avif;base64,abc123')).toBe(true); + }); + + it('rejects data:image/svg+xml', () => { + expect(isSafeImageUrl('data:image/svg+xml;base64,abc123')).toBe(false); + }); + + it('rejects data:text/html', () => { + expect(isSafeImageUrl('data:text/html,')).toBe(false); + }); +}); diff --git a/canvas/src/utils/panelUtils.ts b/canvas/src/utils/panelUtils.ts new file mode 100644 index 000000000..b4067cc62 --- /dev/null +++ b/canvas/src/utils/panelUtils.ts @@ -0,0 +1,79 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ThresholdOptions, TimeSeries } from '@perses-dev/core'; +import { FormatOptions, formatValue } from '@perses-dev/components'; +import { BackgroundSpec } from '../model'; + +export function isSafeImageUrl(url: string): boolean { + try { + const { protocol } = new URL(url); + if (protocol === 'https:') { + return true; + } + if (protocol === 'data:') { + return ( + url.startsWith('data:image/png') || + url.startsWith('data:image/jpeg') || + url.startsWith('data:image/gif') || + url.startsWith('data:image/webp') || + url.startsWith('data:image/avif') + ); + } + return false; + } catch { + return false; + } +} + +export function imageFitToPreserveAspectRatio(imageFit: BackgroundSpec['imageFit']): string { + switch (imageFit) { + case 'stretch': + return 'none'; + case 'contain': + return 'xMidYMid meet'; + case 'cover': + return 'xMidYMid slice'; + default: + return 'xMidYMid slice'; + } +} + +export function interpolateLabel(template: string, series: TimeSeries, format: FormatOptions | undefined): string { + const lastValue = series.values.length > 0 ? series.values[series.values.length - 1]?.[1] : null; + const labels: Record = { ...series.labels }; + if (lastValue !== null && lastValue !== undefined) { + labels['value'] = formatValue(lastValue, format); + } + return template.replace(/{{\s*(.+?)\s*}}/g, (_match, key: string) => labels[key.trim()] ?? ''); +} + +export function colorFromThresholds( + thresholdValue: number, + thresholds: ThresholdOptions, + paletteColors: string[], + fallbackColor: string +): string { + const defaultColor = thresholds.defaultColor ?? paletteColors[0] ?? fallbackColor; + if (!thresholds.steps?.length) { + return defaultColor; + } + let result = defaultColor; + for (let i = 0; i < thresholds.steps.length; i++) { + const step = thresholds.steps[i]; + if (step && thresholdValue >= step.value) { + result = step.color ?? paletteColors[i] ?? defaultColor; + } + } + return result; +} diff --git a/canvas/src/utils/resizeUtils.test.ts b/canvas/src/utils/resizeUtils.test.ts new file mode 100644 index 000000000..b4403d9f7 --- /dev/null +++ b/canvas/src/utils/resizeUtils.test.ts @@ -0,0 +1,79 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { nodeBoundingBox, handlePosition } from './resizeUtils'; + +describe('nodeBoundingBox', () => { + it('returns null for empty inputs', () => { + expect(nodeBoundingBox([], [])).toBeNull(); + }); + + it('computes bounding box for a single node', () => { + const result = nodeBoundingBox([{ x: 0, y: 0, width: 100, height: 60 }]); + expect(result).toEqual({ minX: -50, minY: -30, maxX: 50, maxY: 30 }); + }); + + it('expands to cover multiple nodes', () => { + const result = nodeBoundingBox([ + { x: 0, y: 0, width: 100, height: 60 }, + { x: 200, y: 100, width: 100, height: 60 }, + ]); + expect(result).toEqual({ minX: -50, minY: -30, maxX: 250, maxY: 130 }); + }); + + it('expands to cover free edge endpoints', () => { + const result = nodeBoundingBox([{ x: 0, y: 0, width: 100, height: 60 }], [{ x: 300, y: 200 }]); + expect(result?.maxX).toBe(300); + expect(result?.maxY).toBe(200); + }); + + it('works with only free edge endpoints', () => { + const result = nodeBoundingBox( + [], + [ + { x: 10, y: 20 }, + { x: -5, y: 50 }, + ] + ); + expect(result).toEqual({ minX: -5, minY: 20, maxX: 10, maxY: 50 }); + }); + + it('handles a free edge endpoint at the origin (0, 0)', () => { + const result = nodeBoundingBox([], [{ x: 0, y: 0 }]); + expect(result).toEqual({ minX: 0, minY: 0, maxX: 0, maxY: 0 }); + }); +}); + +describe('handlePosition', () => { + const bbox = { minX: 0, minY: 0, maxX: 200, maxY: 100 }; + + it('nw is top-left corner', () => { + expect(handlePosition(bbox, 'nw')).toEqual({ x: 0, y: 0 }); + }); + + it('se is bottom-right corner', () => { + expect(handlePosition(bbox, 'se')).toEqual({ x: 200, y: 100 }); + }); + + it('n is top-center', () => { + expect(handlePosition(bbox, 'n')).toEqual({ x: 100, y: 0 }); + }); + + it('e is right-center', () => { + expect(handlePosition(bbox, 'e')).toEqual({ x: 200, y: 50 }); + }); + + it('s is bottom-center', () => { + expect(handlePosition(bbox, 's')).toEqual({ x: 100, y: 100 }); + }); +}); diff --git a/canvas/src/utils/resizeUtils.ts b/canvas/src/utils/resizeUtils.ts new file mode 100644 index 000000000..3c851643a --- /dev/null +++ b/canvas/src/utils/resizeUtils.ts @@ -0,0 +1,91 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export interface BoundingBox { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +export const RESIZE_HANDLE_IDS = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] as const; +export type ResizeHandleId = (typeof RESIZE_HANDLE_IDS)[number]; + +export function nodeBoundingBox( + nodes: Array<{ x: number; y: number; width: number; height: number }>, + floatingPoints: Array<{ x: number; y: number }> = [] +): BoundingBox | null { + if (nodes.length === 0 && floatingPoints.length === 0) { + return null; + } + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (const n of nodes) { + const halfW = n.width / 2; + const halfH = n.height / 2; + minX = Math.min(minX, n.x - halfW); + minY = Math.min(minY, n.y - halfH); + maxX = Math.max(maxX, n.x + halfW); + maxY = Math.max(maxY, n.y + halfH); + } + for (const p of floatingPoints) { + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + } + return { minX, minY, maxX, maxY }; +} + +export const HANDLE_POSITIONS = { + nw: [0, 0], + n: [0.5, 0], + ne: [1, 0], + w: [0, 0.5], + e: [1, 0.5], + sw: [0, 1], + s: [0.5, 1], + se: [1, 1], +} as const; + +export const OPPOSITE_HANDLE = { + nw: 'se', + n: 's', + ne: 'sw', + w: 'e', + e: 'w', + sw: 'ne', + s: 'n', + se: 'nw', +} as const; + +export function handlePosition(boundingBox: BoundingBox, h: ResizeHandleId): { x: number; y: number } { + const [tx, ty] = HANDLE_POSITIONS[h]; + return { + x: boundingBox.minX + (boundingBox.maxX - boundingBox.minX) * tx, + y: boundingBox.minY + (boundingBox.maxY - boundingBox.minY) * ty, + }; +} + +export const RESIZE_CURSORS: Record = { + nw: 'nwse-resize', + n: 'ns-resize', + ne: 'nesw-resize', + w: 'ew-resize', + e: 'ew-resize', + sw: 'nesw-resize', + s: 'ns-resize', + se: 'nwse-resize', +}; diff --git a/canvas/src/utils/selectionUtils.test.ts b/canvas/src/utils/selectionUtils.test.ts new file mode 100644 index 000000000..8932bdeeb --- /dev/null +++ b/canvas/src/utils/selectionUtils.test.ts @@ -0,0 +1,62 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NodeSpec } from '../model'; +import { computeSelectionFromRect } from './selectionUtils'; + +function makeNode(id: string, x: number, y: number): NodeSpec { + return { id, x, y, width: 10, height: 10, kind: 'rectangle' }; +} + +describe('computeSelectionFromRect', () => { + const nodes = [makeNode('a', 10, 10), makeNode('b', 50, 50), makeNode('c', 100, 100)]; + + it('selects nodes whose center is inside the rect', () => { + const result = computeSelectionFromRect({ x0: 0, y0: 0, x1: 60, y1: 60 }, nodes, []); + expect(result).toEqual(new Set(['a', 'b'])); + }); + + it('returns empty set when rect is empty', () => { + const result = computeSelectionFromRect({ x0: 200, y0: 200, x1: 300, y1: 300 }, nodes, []); + expect(result.size).toBe(0); + }); + + it('handles inverted rect coordinates (drag from bottom-right to top-left)', () => { + const result = computeSelectionFromRect({ x0: 60, y0: 60, x1: 0, y1: 0 }, nodes, []); + expect(result).toEqual(new Set(['a', 'b'])); + }); + + it('includes floating edge endpoints inside the rect', () => { + const edges = [{ id: 'e1', source: 'a', target: '', x2: 20, y2: 20 }]; + const result = computeSelectionFromRect({ x0: 0, y0: 0, x1: 30, y1: 30 }, nodes, edges); + expect(result.has('e1')).toBe(true); + }); + + it('excludes floating edges outside the rect', () => { + const edges = [{ id: 'e1', source: 'a', target: '', x2: 200, y2: 200 }]; + const result = computeSelectionFromRect({ x0: 0, y0: 0, x1: 60, y1: 60 }, nodes, edges); + expect(result.has('e1')).toBe(false); + }); + + it('excludes edges with no free endpoint (target-connected edges)', () => { + const edges = [{ id: 'e1', source: 'a', target: 'b' }]; + const result = computeSelectionFromRect({ x0: 0, y0: 0, x1: 200, y1: 200 }, nodes, edges); + expect(result.has('e1')).toBe(false); + }); + + it('selects floating edge with x2=0, y2=0 when origin is inside rect', () => { + const edges = [{ id: 'e1', source: 'a', target: '', x2: 0, y2: 0 }]; + const result = computeSelectionFromRect({ x0: -10, y0: -10, x1: 10, y1: 10 }, nodes, edges); + expect(result.has('e1')).toBe(true); + }); +}); diff --git a/canvas/src/utils/selectionUtils.ts b/canvas/src/utils/selectionUtils.ts new file mode 100644 index 000000000..2bf6b1152 --- /dev/null +++ b/canvas/src/utils/selectionUtils.ts @@ -0,0 +1,27 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { EdgeSpec, NodeSpec } from '../model'; +import type { SelectionRect } from '../hooks/useRectSelect'; + +export function computeSelectionFromRect(rect: SelectionRect, nodes: NodeSpec[], edges: EdgeSpec[]): Set { + const minX = Math.min(rect.x0, rect.x1); + const maxX = Math.max(rect.x0, rect.x1); + const minY = Math.min(rect.y0, rect.y1); + const maxY = Math.max(rect.y0, rect.y1); + const inBox = (x: number, y: number): boolean => x >= minX && x <= maxX && y >= minY && y <= maxY; + return new Set([ + ...nodes.filter((n) => inBox(n.x, n.y)).map((n) => n.id), + ...edges.filter((ed) => ed.x2 !== undefined && ed.y2 !== undefined && inBox(ed.x2, ed.y2)).map((ed) => ed.id), + ]); +} diff --git a/canvas/tsconfig.build.json b/canvas/tsconfig.build.json new file mode 100644 index 000000000..fc0aafe27 --- /dev/null +++ b/canvas/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["**/*.stories.*", "**/*.test.*", "**/*.map"], + "compilerOptions": { + "emitDeclarationOnly": true, + "declaration": true, + "preserveWatchOutput": true + } +} diff --git a/canvas/tsconfig.json b/canvas/tsconfig.json new file mode 100644 index 000000000..40e3c4dfe --- /dev/null +++ b/canvas/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "outDir": "./dist/lib", + "rootDir": "./src", + "target": "es2022", + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "jsx": "react-jsx", + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noUncheckedIndexedAccess": true, + "declaration": true, + "declarationMap": true, + "pretty": true + }, + "include": ["src"] +} \ No newline at end of file diff --git a/package.json b/package.json index c82c51852..376deba95 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "tracetable", "tracingganttchart", "victorialogs", + "canvas", "e2e" ], "peerDependencies": { diff --git a/pyroscope/src/plugins/pyroscope-profile-query/PyroscopeProfileQuery.ts b/pyroscope/src/plugins/pyroscope-profile-query/PyroscopeProfileQuery.ts index 61a3228e8..0b647b680 100644 --- a/pyroscope/src/plugins/pyroscope-profile-query/PyroscopeProfileQuery.ts +++ b/pyroscope/src/plugins/pyroscope-profile-query/PyroscopeProfileQuery.ts @@ -11,22 +11,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { LabelFilter } from '../../utils/types'; +import { ProfileQueryPlugin } from '@perses-dev/plugin-system'; +import { PyroscopeProfileQuerySpec } from '../../model/profile-query-model'; import { getProfileData } from './get-profile-data'; import { PyroscopeProfileQueryEditor } from './PyroscopeProfileQueryEditor'; -export const PyroscopeProfileQuery = { +export const PyroscopeProfileQuery: ProfileQueryPlugin = { getProfileData, OptionsEditorComponent: PyroscopeProfileQueryEditor, - createInitialOptions: (): { - maxNodes: number; - datasource?: string; - service: string; - profileType: string; - filters: LabelFilter[]; - } => ({ + createInitialOptions: (): PyroscopeProfileQuerySpec => ({ maxNodes: 0, - datasource: undefined, service: '', profileType: '', filters: [{ labelName: '', labelValue: '', operator: '=' }], From 338b1b892f4eb8a190300f73e6f4a37d39c585c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Thu, 20 Aug 2026 14:22:10 +0200 Subject: [PATCH 2/7] [IGNORE] Refactor node rendering to use EditorNodeItem component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- canvas/src/components/editor/EditorCanvas.tsx | 58 ++++------ .../src/components/editor/EditorNodeItem.tsx | 102 ++++++++++++++++++ 2 files changed, 121 insertions(+), 39 deletions(-) create mode 100644 canvas/src/components/editor/EditorNodeItem.tsx diff --git a/canvas/src/components/editor/EditorCanvas.tsx b/canvas/src/components/editor/EditorCanvas.tsx index 971894119..85ab68f83 100644 --- a/canvas/src/components/editor/EditorCanvas.tsx +++ b/canvas/src/components/editor/EditorCanvas.tsx @@ -24,7 +24,7 @@ import { useEditorContext } from '../../contexts/EditorContext'; import { useSpecContext } from '../../contexts/SpecContext'; import { BackgroundLayer, GlobalBackgroundLayer } from '../shared/BackgroundLayer'; import { EditorEdge } from './EditorEdge'; -import { EditorNode } from './EditorNode'; +import { EditorNodeItem } from './EditorNodeItem'; import { SelectionBoundingBox } from './SelectionBoundingBox'; import { DragEdgeLine } from './DragEdgeLine'; import { SelectionRectOverlay } from './SelectionRectOverlay'; @@ -210,44 +210,24 @@ export function EditorCanvas({ - {displayNodes.map((node) => { - const onNodePointerDown = (event: PointerEvent): void => { - const unselectedId = selectNode(event, node.id); - if (unselectedId !== null) { - selectItems(new Set([unselectedId])); - } else { - startMove(); - } - }; - const onNodePointerMove = (event: PointerEvent): void => { - updateMove(event, node.id); - }; - const onNodeMouseEnter = (): void => { - if (mode.type !== 'dragging-edge') { - hoverNode(node.id); - } - }; - const onNodeMouseLeave = (): void => unhoverNode(node.id); - const onCrossDragStart = (anchor: AnchorPoint, x: number, y: number): void => { - beginEdgeDrag(node.id, anchor, x, y); - startDragEdge(); - }; - return ( - - ); - })} + {displayNodes.map((node) => ( + + ))} {displayEdges.map((edge) => { const onEdgeClick = (event: PointerEvent): void => { diff --git a/canvas/src/components/editor/EditorNodeItem.tsx b/canvas/src/components/editor/EditorNodeItem.tsx new file mode 100644 index 000000000..39bf27a81 --- /dev/null +++ b/canvas/src/components/editor/EditorNodeItem.tsx @@ -0,0 +1,102 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { memo, PointerEvent, ReactElement, useCallback } from 'react'; +import { AnchorPoint, NodeSpec } from '../../model'; +import { EditorNode } from './EditorNode'; + +interface EditorNodeItemProps { + node: NodeSpec; + isHovered: boolean; + isSelected: boolean; + snapTarget: boolean; + isDragging: boolean; + selectNode: (event: PointerEvent, nodeId: string) => string | null; + selectItems: (ids: Set) => void; + startMove: () => void; + updateMove: (event: PointerEvent, nodeId: string) => void; + hoverNode: (nodeId: string) => void; + unhoverNode: (nodeId: string) => void; + beginEdgeDrag: (nodeId: string, anchor: AnchorPoint, x: number, y: number) => void; + startDragEdge: () => void; +} + +export const EditorNodeItem = memo(function EditorNodeItem({ + node, + isHovered, + isSelected, + snapTarget, + isDragging, + selectNode, + selectItems, + startMove, + updateMove, + hoverNode, + unhoverNode, + beginEdgeDrag, + startDragEdge, +}: EditorNodeItemProps): ReactElement { + const nodeId = node.id; + + const onPointerDown = useCallback( + (event: PointerEvent): void => { + const unselectedId = selectNode(event, nodeId); + if (unselectedId !== null) { + selectItems(new Set([unselectedId])); + } else { + startMove(); + } + }, + [nodeId, selectNode, selectItems, startMove] + ); + + const onPointerMove = useCallback( + (event: PointerEvent): void => { + updateMove(event, nodeId); + }, + [nodeId, updateMove] + ); + + const onMouseEnter = useCallback((): void => { + if (!isDragging) { + hoverNode(nodeId); + } + }, [isDragging, nodeId, hoverNode]); + + const onMouseLeave = useCallback((): void => { + unhoverNode(nodeId); + }, [nodeId, unhoverNode]); + + const onCrossDragStart = useCallback( + (anchor: AnchorPoint, x: number, y: number): void => { + beginEdgeDrag(nodeId, anchor, x, y); + startDragEdge(); + }, + [nodeId, beginEdgeDrag, startDragEdge] + ); + + return ( + + ); +}); From 9e6a19e94ed89a7e890bc4598015828c0e9b8eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Thu, 20 Aug 2026 14:25:44 +0200 Subject: [PATCH 3/7] [IGNORE] format code with oxfmt and fix package-lock.json after merge. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- canvas/jest.config.ts | 1 + canvas/package.json | 15 +- canvas/rsbuild.config.ts | 1 + canvas/src/Canvas.tsx | 3 +- .../editor/BackgroundPropertiesPanel.tsx | 13 +- .../components/editor/ConnectionHandles.tsx | 3 +- canvas/src/components/editor/DragEdgeLine.tsx | 5 +- .../components/editor/EdgePropertiesPanel.tsx | 25 +- canvas/src/components/editor/EditorCanvas.tsx | 194 ++--- canvas/src/components/editor/EditorEdge.tsx | 7 +- .../components/editor/EditorItemsPanel.tsx | 11 +- canvas/src/components/editor/EditorNode.tsx | 5 +- .../src/components/editor/EditorNodeItem.tsx | 7 +- canvas/src/components/editor/IconPreview.tsx | 1 + .../components/editor/NodePropertiesPanel.tsx | 7 +- .../editor/SelectionBoundingBox.tsx | 7 +- .../editor/SelectionRectOverlay.tsx | 5 +- canvas/src/components/panel/CanvasPanel.tsx | 13 +- .../src/components/panel/PanelEdgeLayer.tsx | 15 +- .../src/components/panel/PanelNodeLayer.tsx | 7 +- .../src/components/panel/ThresholdLegend.tsx | 4 +- .../settings/EdgeThicknessSettings.tsx | 11 +- .../settings/GlobalSettingsEditor.tsx | 7 +- .../components/settings/LegendSettings.tsx | 7 +- .../src/components/shared/BackgroundLayer.tsx | 1 + canvas/src/components/shared/EdgeLines.tsx | 5 +- canvas/src/components/shared/IconNode.tsx | 1 + canvas/src/components/shared/NodeRenderer.tsx | 3 +- .../src/components/shared/RectangleNode.tsx | 3 +- canvas/src/components/shared/TextNode.tsx | 1 + canvas/src/contexts/EditorContext.tsx | 1 + canvas/src/contexts/SpecContext.test.tsx | 1 + canvas/src/contexts/SpecContext.tsx | 21 +- canvas/src/contexts/ZoomContext.tsx | 1 + canvas/src/getPluginModule.ts | 1 + canvas/src/hooks/useEdgeConnect.test.tsx | 1 + canvas/src/hooks/useEdgeConnect.ts | 15 +- canvas/src/hooks/useNodeMove.test.tsx | 3 +- canvas/src/hooks/useNodeMove.ts | 13 +- canvas/src/hooks/useRectSelect.test.tsx | 3 +- canvas/src/hooks/useRectSelect.ts | 7 +- canvas/src/hooks/useResize.test.tsx | 3 +- canvas/src/hooks/useResize.ts | 19 +- canvas/src/hooks/useZoom.ts | 12 +- canvas/src/model.ts | 2 +- canvas/src/test-utils/hookWrapper.tsx | 5 +- canvas/src/utils/edgeUtils.ts | 4 +- canvas/src/utils/editorReducer.ts | 6 +- canvas/src/utils/labelPosition.ts | 2 +- canvas/src/utils/panelUtils.test.ts | 3 +- canvas/src/utils/panelUtils.ts | 5 +- canvas/src/utils/resizeUtils.test.ts | 2 +- canvas/src/utils/resizeUtils.ts | 2 +- canvas/src/utils/selectionUtils.ts | 2 +- canvas/tsconfig.json | 2 +- package-lock.json | 763 ++++++++++++------ .../PyroscopeProfileQuery.ts | 1 + 57 files changed, 800 insertions(+), 488 deletions(-) diff --git a/canvas/jest.config.ts b/canvas/jest.config.ts index 31a6238d6..3726c40ab 100644 --- a/canvas/jest.config.ts +++ b/canvas/jest.config.ts @@ -12,6 +12,7 @@ // limitations under the License. import type { Config } from '@jest/types'; + import shared from '../jest.shared'; const jestConfig: Config.InitialOptions = { diff --git a/canvas/package.json b/canvas/package.json index 103e7e6f4..9d940d88f 100644 --- a/canvas/package.json +++ b/canvas/package.json @@ -5,10 +5,10 @@ "dev": "rsbuild dev", "build": "npm run build-mf && concurrently \"npm:build:*\"", "build-mf": "rsbuild build", - "build:cjs": "swc ./src -d dist/lib/cjs --strip-leading-paths --config-file .cjs.swcrc", - "build:esm": "swc ./src -d dist/lib --strip-leading-paths --config-file .swcrc", + "build:cjs": "swc ./src -d dist/lib/cjs --strip-leading-paths --config-file ../.cjs.swcrc", + "build:esm": "swc ./src -d dist/lib --strip-leading-paths --config-file ../.swcrc", "build:types": "tsc --project tsconfig.build.json", - "lint": "eslint src --ext .ts,.tsx", + "lint": "oxlint src", "test": "cross-env LC_ALL=C TZ=UTC jest", "type-check": "tsc --noEmit" }, @@ -19,9 +19,12 @@ "@emotion/react": "^11.7.1", "@emotion/styled": "^11.6.0", "@hookform/resolvers": "^3.2.0", - "@perses-dev/components": "^0.54.0-beta.3", - "@perses-dev/plugin-system": "^0.54.0-beta.3", - "@perses-dev/spec": "^0.2.0-beta.2", + "@perses-dev/components": "^0.55.0-beta.1", + "@perses-dev/spec": "^0.3.0-beta.1", + "@perses-dev/dashboards": "^0.55.0-beta.1", + "@perses-dev/explore": "^0.55.0-beta.1", + "@perses-dev/plugin-system": "^0.55.0-beta.1", + "@tanstack/react-query": "^4.39.1", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "echarts": "5.5.0", diff --git a/canvas/rsbuild.config.ts b/canvas/rsbuild.config.ts index d3d8c7742..4fcfc3102 100644 --- a/canvas/rsbuild.config.ts +++ b/canvas/rsbuild.config.ts @@ -12,6 +12,7 @@ // limitations under the License. import { pluginReact } from '@rsbuild/plugin-react'; + import { createConfigForPlugin } from '../rsbuild.shared'; export default createConfigForPlugin({ diff --git a/canvas/src/Canvas.tsx b/canvas/src/Canvas.tsx index 1f50377d2..c0b943c34 100644 --- a/canvas/src/Canvas.tsx +++ b/canvas/src/Canvas.tsx @@ -12,9 +12,10 @@ // limitations under the License. import { PanelPlugin } from '@perses-dev/plugin-system'; + import { CanvasPanel } from './components/panel/CanvasPanel'; -import { CanvasSpec, CanvasProps } from './model'; import { GlobalSettingsEditor } from './components/settings/GlobalSettingsEditor'; +import { CanvasSpec, CanvasProps } from './model'; export const Canvas: PanelPlugin = { PanelComponent: CanvasPanel, diff --git a/canvas/src/components/editor/BackgroundPropertiesPanel.tsx b/canvas/src/components/editor/BackgroundPropertiesPanel.tsx index ec90a9fb3..785556d9b 100644 --- a/canvas/src/components/editor/BackgroundPropertiesPanel.tsx +++ b/canvas/src/components/editor/BackgroundPropertiesPanel.tsx @@ -11,7 +11,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, { ReactElement, useCallback } from 'react'; import { Box, Checkbox, @@ -27,12 +26,14 @@ import { Tooltip, Typography, } from '@mui/material'; -import ArrowUpIcon from 'mdi-material-ui/ArrowUp'; -import ArrowDownIcon from 'mdi-material-ui/ArrowDown'; import { OptionsColorPicker } from '@perses-dev/components'; -import { BackgroundSpec, CanvasSpec } from '../../model'; -import { useCanvasTheme } from '../../hooks/useCanvasTheme'; +import ArrowDownIcon from 'mdi-material-ui/ArrowDown'; +import ArrowUpIcon from 'mdi-material-ui/ArrowUp'; +import React, { ReactElement, useCallback } from 'react'; + import { useSpecContext } from '../../contexts/SpecContext'; +import { useCanvasTheme } from '../../hooks/useCanvasTheme'; +import { BackgroundSpec, CanvasSpec } from '../../model'; interface BackgroundPropertiesPanelProps { background: BackgroundSpec; @@ -54,7 +55,7 @@ export function BackgroundPropertiesPanel({ background, onChange }: BackgroundPr onChange({ ...background, [key]: v }); } }, - [background, onChange] + [background, onChange], ); const IMAGE_FIT_OPTIONS: Array = ['cover', 'contain', 'stretch']; diff --git a/canvas/src/components/editor/ConnectionHandles.tsx b/canvas/src/components/editor/ConnectionHandles.tsx index f97c7935a..363beca10 100644 --- a/canvas/src/components/editor/ConnectionHandles.tsx +++ b/canvas/src/components/editor/ConnectionHandles.tsx @@ -12,9 +12,10 @@ // limitations under the License. import { ReactElement } from 'react'; + +import { useCanvasTheme } from '../../hooks/useCanvasTheme'; import { NodeSpec, AnchorPoint } from '../../model'; import { ANCHOR_KEYS, anchorPosition } from '../../utils/edgeUtils'; -import { useCanvasTheme } from '../../hooks/useCanvasTheme'; const CROSS_LENGTH = 8; diff --git a/canvas/src/components/editor/DragEdgeLine.tsx b/canvas/src/components/editor/DragEdgeLine.tsx index 0b885f6a3..51fffc54d 100644 --- a/canvas/src/components/editor/DragEdgeLine.tsx +++ b/canvas/src/components/editor/DragEdgeLine.tsx @@ -12,10 +12,11 @@ // limitations under the License. import { ReactElement } from 'react'; + +import { useZoomContext } from '../../contexts/ZoomContext'; +import { useCanvasTheme } from '../../hooks/useCanvasTheme'; import { DragEdge } from '../../hooks/useEdgeConnect'; import { editorStyles } from '../../utils/editorStyles'; -import { useCanvasTheme } from '../../hooks/useCanvasTheme'; -import { useZoomContext } from '../../contexts/ZoomContext'; import { EdgeLines } from '../shared/EdgeLines'; const NS_PREFIX = 'wm-drag-edge'; diff --git a/canvas/src/components/editor/EdgePropertiesPanel.tsx b/canvas/src/components/editor/EdgePropertiesPanel.tsx index de16dee4b..738c9d287 100644 --- a/canvas/src/components/editor/EdgePropertiesPanel.tsx +++ b/canvas/src/components/editor/EdgePropertiesPanel.tsx @@ -11,9 +11,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, { ReactElement, useCallback, useMemo } from 'react'; import { Checkbox, FormControlLabel, MenuItem, Stack, TextField, Typography } from '@mui/material'; import { generateQueryNames, useDataQueriesContext } from '@perses-dev/plugin-system'; +import React, { ReactElement, useCallback, useMemo } from 'react'; + import { AnchorPoint, EdgeSpec, NodeSpec } from '../../model'; const ANCHOR_OPTIONS: AnchorPoint[] = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw']; @@ -35,14 +36,14 @@ export function EdgePropertiesPanel({ edge, nodes, onChange }: EdgePropertiesPan (e: React.ChangeEvent): void => { onChange({ ...edge, source: e.target.value }); }, - [edge, onChange] + [edge, onChange], ); const onSourceAnchorChange = useCallback( (e: React.ChangeEvent): void => { onChange({ ...edge, sourceAnchor: e.target.value as AnchorPoint }); }, - [edge, onChange] + [edge, onChange], ); const onTargetChange = useCallback( @@ -55,28 +56,28 @@ export function EdgePropertiesPanel({ edge, nodes, onChange }: EdgePropertiesPan y2: undefined, }); }, - [edge, onChange] + [edge, onChange], ); const onTargetAnchorChange = useCallback( (e: React.ChangeEvent): void => { onChange({ ...edge, targetAnchor: e.target.value as AnchorPoint }); }, - [edge, onChange] + [edge, onChange], ); const onBidirectionalChange = useCallback( (e: React.ChangeEvent): void => { onChange({ ...edge, bidirectional: e.target.checked || undefined }); }, - [edge, onChange] + [edge, onChange], ); const onThicknessModeChange = useCallback( (e: React.ChangeEvent): void => { onChange({ ...edge, thicknessMode: e.target.value as 'fixed' | 'threshold' }); }, - [edge, onChange] + [edge, onChange], ); const onStrokeWidthChange = useCallback( @@ -84,7 +85,7 @@ export function EdgePropertiesPanel({ edge, nodes, onChange }: EdgePropertiesPan const v = parseFloat(e.target.value); onChange({ ...edge, strokeWidth: Number.isFinite(v) && v > 0 ? v : undefined }); }, - [edge, onChange] + [edge, onChange], ); const onSourceQueryIndexChange = useCallback( @@ -92,14 +93,14 @@ export function EdgePropertiesPanel({ edge, nodes, onChange }: EdgePropertiesPan const v = e.target.value; onChange({ ...edge, sourceQueryIndex: v === '' ? undefined : Number(v) }); }, - [edge, onChange] + [edge, onChange], ); const onSourceLabelTemplateChange = useCallback( (e: React.ChangeEvent): void => { onChange({ ...edge, sourceLabelTemplate: e.target.value || undefined }); }, - [edge, onChange] + [edge, onChange], ); const onTargetQueryIndexChange = useCallback( @@ -107,14 +108,14 @@ export function EdgePropertiesPanel({ edge, nodes, onChange }: EdgePropertiesPan const v = e.target.value; onChange({ ...edge, targetQueryIndex: v === '' ? undefined : Number(v) }); }, - [edge, onChange] + [edge, onChange], ); const onTargetLabelTemplateChange = useCallback( (e: React.ChangeEvent): void => { onChange({ ...edge, targetLabelTemplate: e.target.value || undefined }); }, - [edge, onChange] + [edge, onChange], ); return ( diff --git a/canvas/src/components/editor/EditorCanvas.tsx b/canvas/src/components/editor/EditorCanvas.tsx index 85ab68f83..a6bd19487 100644 --- a/canvas/src/components/editor/EditorCanvas.tsx +++ b/canvas/src/components/editor/EditorCanvas.tsx @@ -11,22 +11,23 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { KeyboardEvent, MouseEvent, PointerEvent, ReactElement, useCallback, useLayoutEffect, useMemo } from 'react'; import { produce } from 'immer'; -import { AnchorPoint, CanvasSpec, FloatingEdge, isFloatingEdge } from '../../model'; -import { nodeBoundingBox } from '../../utils/resizeUtils'; +import { KeyboardEvent, MouseEvent, PointerEvent, ReactElement, useCallback, useLayoutEffect, useMemo } from 'react'; + +import { useEditorContext } from '../../contexts/EditorContext'; +import { useSpecContext } from '../../contexts/SpecContext'; import { useZoomContext } from '../../contexts/ZoomContext'; -import { useNodeMove } from '../../hooks/useNodeMove'; import { useEdgeConnect } from '../../hooks/useEdgeConnect'; -import { useResize } from '../../hooks/useResize'; +import { useNodeMove } from '../../hooks/useNodeMove'; import { useRectSelect } from '../../hooks/useRectSelect'; -import { useEditorContext } from '../../contexts/EditorContext'; -import { useSpecContext } from '../../contexts/SpecContext'; +import { useResize } from '../../hooks/useResize'; +import { AnchorPoint, CanvasSpec, FloatingEdge, isFloatingEdge } from '../../model'; +import { nodeBoundingBox } from '../../utils/resizeUtils'; import { BackgroundLayer, GlobalBackgroundLayer } from '../shared/BackgroundLayer'; +import { DragEdgeLine } from './DragEdgeLine'; import { EditorEdge } from './EditorEdge'; import { EditorNodeItem } from './EditorNodeItem'; import { SelectionBoundingBox } from './SelectionBoundingBox'; -import { DragEdgeLine } from './DragEdgeLine'; import { SelectionRectOverlay } from './SelectionRectOverlay'; const NS_PREFIX = 'wm-editor'; @@ -84,12 +85,12 @@ export function EditorCanvas({ const selectionBoundingBox = useMemo(() => { const selectedNodes = displayNodes.filter((n) => selectedIds.has(n.id)); const selectedFloatingEdges = displayEdges.filter( - (ed): ed is FloatingEdge => selectedIds.has(ed.id) && isFloatingEdge(ed) + (ed): ed is FloatingEdge => selectedIds.has(ed.id) && isFloatingEdge(ed), ); return (mode.type === 'idle' || mode.type === 'resizing') && selectedNodes.length >= 1 ? nodeBoundingBox( selectedNodes, - selectedFloatingEdges.map((ed) => ({ x: ed.x2, y: ed.y2 })) + selectedFloatingEdges.map((ed) => ({ x: ed.x2, y: ed.y2 })), ) : null; }, [displayEdges, displayNodes, mode.type, selectedIds]); @@ -114,7 +115,7 @@ export function EditorCanvas({ startSelectionRect(); } }, - [mode.type, beginSelection, startSelectionRect] + [mode.type, beginSelection, startSelectionRect], ); const onSvgPointerMove = useCallback( @@ -134,7 +135,7 @@ export function EditorCanvas({ break; } }, - [mode.type, updateResize, updateEdgeDrag, updateSelection] + [mode.type, updateResize, updateEdgeDrag, updateSelection], ); const clearInteractionState = useCallback((): void => { @@ -173,7 +174,7 @@ export function EditorCanvas({ resetPan(); } }, - [displayNodes, fitView, resetPan, width, height] + [displayNodes, fitView, resetPan, width, height], ); const onKeyDown = useCallback( @@ -185,96 +186,99 @@ export function EditorCanvas({ deleteSelected(); } }, - [selectedIds, deleteSelected] + [selectedIds, deleteSelected], ); return ( - - - - - {displayNodes.map((node) => ( - - ))} - - {displayEdges.map((edge) => { - const onEdgeClick = (event: PointerEvent): void => { - event.stopPropagation(); - selectItems(new Set([edge.id])); - }; - const onEndpointPointerDown = ( - event: PointerEvent, - end: 'source' | 'target', - fixedX: number, - fixedY: number, - fixedNodeId: string, - fixedAnchor: AnchorPoint - ): void => { - if (beginEndpointDrag(event, edge.id, end, fixedX, fixedY, fixedNodeId, fixedAnchor)) { - startDragEdge(); - } - }; - return ( - + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} + + + + + {displayNodes.map((node) => ( + - ); - })} + ))} - {selectionBoundingBox && ( - { - if (beginResize(event, handleId)) { - startResize(); + {displayEdges.map((edge) => { + const onEdgeClick = (event: PointerEvent): void => { + event.stopPropagation(); + selectItems(new Set([edge.id])); + }; + const onEndpointPointerDown = ( + event: PointerEvent, + end: 'source' | 'target', + fixedX: number, + fixedY: number, + fixedNodeId: string, + fixedAnchor: AnchorPoint, + ): void => { + if (beginEndpointDrag(event, edge.id, end, fixedX, fixedY, fixedNodeId, fixedAnchor)) { + startDragEdge(); } - }} - /> - )} + }; + return ( + + ); + })} + + {selectionBoundingBox && ( + { + if (beginResize(event, handleId)) { + startResize(); + } + }} + /> + )} - {mode.type === 'dragging-edge' && dragEdge && } + {mode.type === 'dragging-edge' && dragEdge && } - {selectionRect && } - - + {selectionRect && } + + + ); } diff --git a/canvas/src/components/editor/EditorEdge.tsx b/canvas/src/components/editor/EditorEdge.tsx index 43d12654e..f12dbebd5 100644 --- a/canvas/src/components/editor/EditorEdge.tsx +++ b/canvas/src/components/editor/EditorEdge.tsx @@ -12,11 +12,12 @@ // limitations under the License. import { PointerEvent, ReactElement } from 'react'; + +import { useZoomContext } from '../../contexts/ZoomContext'; +import { useCanvasTheme } from '../../hooks/useCanvasTheme'; import { AnchorPoint, EdgeSpec, NodeSpec } from '../../model'; import { edgeEndpoints } from '../../utils/edgeUtils'; import { editorStyles } from '../../utils/editorStyles'; -import { useCanvasTheme } from '../../hooks/useCanvasTheme'; -import { useZoomContext } from '../../contexts/ZoomContext'; import { EdgeLines, LineStyle } from '../shared/EdgeLines'; interface EditorEdgeProps { @@ -32,7 +33,7 @@ interface EditorEdgeProps { fixedX: number, fixedY: number, fixedNodeId: string, - fixedAnchor: AnchorPoint + fixedAnchor: AnchorPoint, ) => void; } diff --git a/canvas/src/components/editor/EditorItemsPanel.tsx b/canvas/src/components/editor/EditorItemsPanel.tsx index 903cb3ef2..699393996 100644 --- a/canvas/src/components/editor/EditorItemsPanel.tsx +++ b/canvas/src/components/editor/EditorItemsPanel.tsx @@ -11,7 +11,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { ReactElement, useCallback, useRef } from 'react'; import { Box, Button, @@ -22,14 +21,16 @@ import { Select, SelectChangeEvent, } from '@mui/material'; +import { ReactElement, useCallback, useRef } from 'react'; + import { useEditorContext } from '../../contexts/EditorContext'; import { useSpecContext } from '../../contexts/SpecContext'; -import { useZoom } from '../../hooks/useZoom'; import { ZoomProvider } from '../../contexts/ZoomContext'; +import { useZoom } from '../../hooks/useZoom'; +import { BackgroundPropertiesPanel } from './BackgroundPropertiesPanel'; +import { EdgePropertiesPanel } from './EdgePropertiesPanel'; import { EditorCanvas } from './EditorCanvas'; import { NodePropertiesPanel } from './NodePropertiesPanel'; -import { EdgePropertiesPanel } from './EdgePropertiesPanel'; -import { BackgroundPropertiesPanel } from './BackgroundPropertiesPanel'; const CANVAS_HEIGHT = 400; const PROPERTIES_HEIGHT = 700; @@ -81,7 +82,7 @@ export function EditorItemsPanel(): ReactElement { const id = event.target.value; selectItems(id ? new Set([id]) : new Set()); }, - [selectItems] + [selectItems], ); const hasBackgrounds = (spec.backgrounds?.length ?? 0) > 0; diff --git a/canvas/src/components/editor/EditorNode.tsx b/canvas/src/components/editor/EditorNode.tsx index 76d86b34b..5f46dd901 100644 --- a/canvas/src/components/editor/EditorNode.tsx +++ b/canvas/src/components/editor/EditorNode.tsx @@ -12,10 +12,11 @@ // limitations under the License. import { PointerEvent, ReactElement } from 'react'; + +import { useZoomContext } from '../../contexts/ZoomContext'; +import { useCanvasTheme } from '../../hooks/useCanvasTheme'; import { NodeSpec, AnchorPoint } from '../../model'; import { editorStyles } from '../../utils/editorStyles'; -import { useCanvasTheme } from '../../hooks/useCanvasTheme'; -import { useZoomContext } from '../../contexts/ZoomContext'; import { NodeRenderer } from '../shared/NodeRenderer'; import { ConnectionHandles } from './ConnectionHandles'; diff --git a/canvas/src/components/editor/EditorNodeItem.tsx b/canvas/src/components/editor/EditorNodeItem.tsx index 39bf27a81..da3d379ce 100644 --- a/canvas/src/components/editor/EditorNodeItem.tsx +++ b/canvas/src/components/editor/EditorNodeItem.tsx @@ -12,6 +12,7 @@ // limitations under the License. import { memo, PointerEvent, ReactElement, useCallback } from 'react'; + import { AnchorPoint, NodeSpec } from '../../model'; import { EditorNode } from './EditorNode'; @@ -57,14 +58,14 @@ export const EditorNodeItem = memo(function EditorNodeItem({ startMove(); } }, - [nodeId, selectNode, selectItems, startMove] + [nodeId, selectNode, selectItems, startMove], ); const onPointerMove = useCallback( (event: PointerEvent): void => { updateMove(event, nodeId); }, - [nodeId, updateMove] + [nodeId, updateMove], ); const onMouseEnter = useCallback((): void => { @@ -82,7 +83,7 @@ export const EditorNodeItem = memo(function EditorNodeItem({ beginEdgeDrag(nodeId, anchor, x, y); startDragEdge(); }, - [nodeId, beginEdgeDrag, startDragEdge] + [nodeId, beginEdgeDrag, startDragEdge], ); return ( diff --git a/canvas/src/components/editor/IconPreview.tsx b/canvas/src/components/editor/IconPreview.tsx index 2028b52fe..e6b7b8949 100644 --- a/canvas/src/components/editor/IconPreview.tsx +++ b/canvas/src/components/editor/IconPreview.tsx @@ -12,6 +12,7 @@ // limitations under the License. import { ReactElement } from 'react'; + import { ICON_PATHS } from '../../utils/icons'; interface IconPreviewProps { diff --git a/canvas/src/components/editor/NodePropertiesPanel.tsx b/canvas/src/components/editor/NodePropertiesPanel.tsx index 36da06881..b504f6b08 100644 --- a/canvas/src/components/editor/NodePropertiesPanel.tsx +++ b/canvas/src/components/editor/NodePropertiesPanel.tsx @@ -11,13 +11,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, { ReactElement, useCallback, useMemo } from 'react'; import { Autocomplete, Box, MenuItem, Stack, TextField, Typography } from '@mui/material'; import { OptionsColorPicker } from '@perses-dev/components'; import { generateQueryNames, useDataQueriesContext } from '@perses-dev/plugin-system'; +import React, { ReactElement, useCallback, useMemo } from 'react'; + +import { useCanvasTheme } from '../../hooks/useCanvasTheme'; import { NodeSpec } from '../../model'; import { ICON_NAMES } from '../../utils/icons'; -import { useCanvasTheme } from '../../hooks/useCanvasTheme'; import { IconPreview } from './IconPreview'; interface NodePropertiesPanelProps { @@ -43,7 +44,7 @@ export function NodePropertiesPanel({ node, onChange }: NodePropertiesPanelProps onChange({ ...node, [key]: undefined }); } }, - [node, onChange] + [node, onChange], ); return ( diff --git a/canvas/src/components/editor/SelectionBoundingBox.tsx b/canvas/src/components/editor/SelectionBoundingBox.tsx index 5792f8d72..f8be54be8 100644 --- a/canvas/src/components/editor/SelectionBoundingBox.tsx +++ b/canvas/src/components/editor/SelectionBoundingBox.tsx @@ -12,6 +12,10 @@ // limitations under the License. import { PointerEvent, ReactElement } from 'react'; + +import { useZoomContext } from '../../contexts/ZoomContext'; +import { useCanvasTheme } from '../../hooks/useCanvasTheme'; +import { editorStyles } from '../../utils/editorStyles'; import { BoundingBox, HANDLE_POSITIONS, @@ -20,9 +24,6 @@ import { RESIZE_HANDLE_IDS, ResizeHandleId, } from '../../utils/resizeUtils'; -import { editorStyles } from '../../utils/editorStyles'; -import { useCanvasTheme } from '../../hooks/useCanvasTheme'; -import { useZoomContext } from '../../contexts/ZoomContext'; interface SelectionBoundingBoxProps { boundingBox: BoundingBox; diff --git a/canvas/src/components/editor/SelectionRectOverlay.tsx b/canvas/src/components/editor/SelectionRectOverlay.tsx index c206bbacb..45a96db02 100644 --- a/canvas/src/components/editor/SelectionRectOverlay.tsx +++ b/canvas/src/components/editor/SelectionRectOverlay.tsx @@ -12,10 +12,11 @@ // limitations under the License. import { ReactElement } from 'react'; + +import { useZoomContext } from '../../contexts/ZoomContext'; +import { useCanvasTheme } from '../../hooks/useCanvasTheme'; import { SelectionRect } from '../../hooks/useRectSelect'; import { editorStyles } from '../../utils/editorStyles'; -import { useCanvasTheme } from '../../hooks/useCanvasTheme'; -import { useZoomContext } from '../../contexts/ZoomContext'; interface SelectionRectOverlayProps { rect: SelectionRect; diff --git a/canvas/src/components/panel/CanvasPanel.tsx b/canvas/src/components/panel/CanvasPanel.tsx index a3b023a3b..7d80b7f9a 100644 --- a/canvas/src/components/panel/CanvasPanel.tsx +++ b/canvas/src/components/panel/CanvasPanel.tsx @@ -11,17 +11,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { MouseEvent, ReactElement, useCallback, useMemo } from 'react'; -import { TimeSeries } from '@perses-dev/core'; import { useChartsTheme } from '@perses-dev/components'; +import { TimeSeries } from '@perses-dev/core'; +import { MouseEvent, ReactElement, useCallback, useMemo } from 'react'; + +import { useZoomContext, ZoomProvider } from '../../contexts/ZoomContext'; +import { useZoom } from '../../hooks/useZoom'; import { CanvasProps } from '../../model'; import { nodeBoundingBox } from '../../utils/resizeUtils'; -import { useZoom } from '../../hooks/useZoom'; -import { useZoomContext, ZoomProvider } from '../../contexts/ZoomContext'; import { BackgroundLayer, GlobalBackgroundLayer } from '../shared/BackgroundLayer'; -import { ThresholdLegend } from './ThresholdLegend'; import { PanelEdgeLayer } from './PanelEdgeLayer'; import { PanelNodeLayer } from './PanelNodeLayer'; +import { ThresholdLegend } from './ThresholdLegend'; interface PanelSvgProps { svgRef: (node: SVGSVGElement | null) => void; @@ -50,7 +51,7 @@ function PanelSvg({ svgRef, props, seriesByQueryIndex, paletteColors }: PanelSvg resetPan(); } }, - [fitView, resetPan, nodes, width, height] + [fitView, resetPan, nodes, width, height], ); const showLegend = spec.legend !== undefined && spec.thresholds !== undefined; diff --git a/canvas/src/components/panel/PanelEdgeLayer.tsx b/canvas/src/components/panel/PanelEdgeLayer.tsx index 9f778c402..11eacacdc 100644 --- a/canvas/src/components/panel/PanelEdgeLayer.tsx +++ b/canvas/src/components/panel/PanelEdgeLayer.tsx @@ -11,14 +11,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { ReactElement } from 'react'; import { TimeSeries } from '@perses-dev/core'; +import { ReactElement } from 'react'; + +import { useCanvasTheme } from '../../hooks/useCanvasTheme'; import { CanvasSpec } from '../../model'; import { edgeEndpoints, strokeWidthFromThresholds } from '../../utils/edgeUtils'; -import { useCanvasTheme } from '../../hooks/useCanvasTheme'; +import { colorFromThresholds, interpolateLabel } from '../../utils/panelUtils'; import { EdgeLabel } from '../shared/EdgeLabel'; import { EdgeLines, edgeLabelPoints, LineStyle } from '../shared/EdgeLines'; -import { colorFromThresholds, interpolateLabel } from '../../utils/panelUtils'; const NS_PREFIX = 'wm-panel'; @@ -29,7 +30,7 @@ function resolveEdgeStyle( seriesByQueryIndex: Map, spec: CanvasSpec, paletteColors: string[], - fallbackColor: string + fallbackColor: string, ): { stroke: string; strokeWidth: number } { const defaultWidth = edgeStrokeWidth ?? spec.edgeDefaultStrokeWidth ?? 2; if (queryIndex === undefined) { @@ -94,7 +95,7 @@ export function PanelEdgeLayer({ spec, seriesByQueryIndex, k, paletteColors }: P seriesByQueryIndex, spec, paletteColors, - fallbackColor + fallbackColor, ); const bwdStyle = resolveEdgeStyle( edge.targetQueryIndex, @@ -103,7 +104,7 @@ export function PanelEdgeLayer({ spec, seriesByQueryIndex, k, paletteColors }: P seriesByQueryIndex, spec, paletteColors, - fallbackColor + fallbackColor, ); const scaledFwdStyle: LineStyle = { stroke: fwdStyle.stroke, @@ -120,7 +121,7 @@ export function PanelEdgeLayer({ spec, seriesByQueryIndex, k, paletteColors }: P pts, edge.bidirectional ?? false, scaledFwdStyle.strokeWidth, - scaledBwdStyle.strokeWidth + scaledBwdStyle.strokeWidth, ); const fwdLabel = resolveLabel(edge.sourceQueryIndex, edge.sourceLabelTemplate); const bwdLabel = edge.bidirectional ? resolveLabel(edge.targetQueryIndex, edge.targetLabelTemplate) : null; diff --git a/canvas/src/components/panel/PanelNodeLayer.tsx b/canvas/src/components/panel/PanelNodeLayer.tsx index 0dd34e450..c2180ebc7 100644 --- a/canvas/src/components/panel/PanelNodeLayer.tsx +++ b/canvas/src/components/panel/PanelNodeLayer.tsx @@ -11,13 +11,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { ReactElement, useCallback } from 'react'; import { TimeSeries } from '@perses-dev/core'; import { replaceVariablesInString, useAllVariableValues } from '@perses-dev/plugin-system'; +import { ReactElement, useCallback } from 'react'; + import { useCanvasTheme } from '../../hooks/useCanvasTheme'; import { CanvasSpec } from '../../model'; -import { NodeRenderer } from '../shared/NodeRenderer'; import { colorFromThresholds, interpolateLabel } from '../../utils/panelUtils'; +import { NodeRenderer } from '../shared/NodeRenderer'; interface PanelNodeLayerProps { spec: CanvasSpec; @@ -35,7 +36,7 @@ export function PanelNodeLayer({ spec, seriesByQueryIndex, k, paletteColors }: P (link: string) => { window.open(replaceVariablesInString(link, variableValues), '_blank', 'noopener,noreferrer'); }, - [variableValues] + [variableValues], ); return ( diff --git a/canvas/src/components/panel/ThresholdLegend.tsx b/canvas/src/components/panel/ThresholdLegend.tsx index 97e4a98f1..2fa60541c 100644 --- a/canvas/src/components/panel/ThresholdLegend.tsx +++ b/canvas/src/components/panel/ThresholdLegend.tsx @@ -11,10 +11,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { ReactElement } from 'react'; import { useTheme } from '@mui/material'; -import { ThresholdOptions } from '@perses-dev/core'; import { FormatOptions, formatValue } from '@perses-dev/components'; +import { ThresholdOptions } from '@perses-dev/core'; +import { ReactElement } from 'react'; const SWATCH_SIZE = 12; const ROW_HEIGHT = 18; diff --git a/canvas/src/components/settings/EdgeThicknessSettings.tsx b/canvas/src/components/settings/EdgeThicknessSettings.tsx index 1daccd0db..03acef800 100644 --- a/canvas/src/components/settings/EdgeThicknessSettings.tsx +++ b/canvas/src/components/settings/EdgeThicknessSettings.tsx @@ -11,10 +11,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, { ReactElement, useCallback, useMemo } from 'react'; import { Box, InputAdornment, TextField, Typography } from '@mui/material'; import { formatValue, StepOptions } from '@perses-dev/components'; import { produce } from 'immer'; +import React, { ReactElement, useCallback, useMemo } from 'react'; + import { CanvasSpec } from '../../model'; interface EdgeThicknessSettingsProps { @@ -35,7 +36,7 @@ function ThresholdWidthRow({ step, strokeWidth, format, onChange }: ThresholdWid const parsed = parseFloat(event.target.value); onChange(Number.isFinite(parsed) && parsed > 0 ? parsed : undefined); }, - [onChange] + [onChange], ); return ( @@ -69,7 +70,7 @@ export function EdgeThicknessSettings({ value, onChange }: EdgeThicknessSettings edgeDefaultStrokeWidth: Number.isFinite(parsed) && parsed > 0 ? parsed : undefined, }); }, - [value, onChange] + [value, onChange], ); const onThresholdWidthChange = useCallback( @@ -87,10 +88,10 @@ export function EdgeThicknessSettings({ value, onChange }: EdgeThicknessSettings } else if (existing >= 0) { draft.edgeThresholdWidths.splice(existing, 1); } - }) + }), ); }, - [value, onChange] + [value, onChange], ); return ( diff --git a/canvas/src/components/settings/GlobalSettingsEditor.tsx b/canvas/src/components/settings/GlobalSettingsEditor.tsx index 641892012..9d2330b0b 100644 --- a/canvas/src/components/settings/GlobalSettingsEditor.tsx +++ b/canvas/src/components/settings/GlobalSettingsEditor.tsx @@ -11,6 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { Box } from '@mui/material'; import { FormatControls, OptionsEditorColumn, @@ -20,13 +21,13 @@ import { } from '@perses-dev/components'; import { OptionsEditorProps } from '@perses-dev/plugin-system'; import { ReactElement } from 'react'; -import { Box } from '@mui/material'; -import { CanvasSpec } from '../../model'; + import { EditorStateProvider } from '../../contexts/EditorContext'; import { SpecProvider } from '../../contexts/SpecContext'; +import { CanvasSpec } from '../../model'; import { EditorItemsPanel } from '../editor/EditorItemsPanel'; -import { LegendSettings } from './LegendSettings'; import { EdgeThicknessSettings } from './EdgeThicknessSettings'; +import { LegendSettings } from './LegendSettings'; type GlobalSettingsEditorProps = OptionsEditorProps; diff --git a/canvas/src/components/settings/LegendSettings.tsx b/canvas/src/components/settings/LegendSettings.tsx index 8f93398af..79b42aa76 100644 --- a/canvas/src/components/settings/LegendSettings.tsx +++ b/canvas/src/components/settings/LegendSettings.tsx @@ -11,8 +11,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, { ReactElement, useCallback } from 'react'; import { FormControl, FormControlLabel, InputLabel, MenuItem, Select, SelectChangeEvent, Switch } from '@mui/material'; +import React, { ReactElement, useCallback } from 'react'; + import { CanvasSpec } from '../../model'; interface LegendSettingsProps { @@ -28,14 +29,14 @@ export function LegendSettings({ value, onChange }: LegendSettingsProps): ReactE legend: event.target.checked ? { position: value.legend?.position ?? 'bottom' } : undefined, }); }, - [value, onChange] + [value, onChange], ); const onPositionChange = useCallback( (event: SelectChangeEvent<'bottom' | 'right'>): void => { onChange({ ...value, legend: { position: event.target.value as 'bottom' | 'right' } }); }, - [value, onChange] + [value, onChange], ); return ( diff --git a/canvas/src/components/shared/BackgroundLayer.tsx b/canvas/src/components/shared/BackgroundLayer.tsx index 0367478b3..81f18d61c 100644 --- a/canvas/src/components/shared/BackgroundLayer.tsx +++ b/canvas/src/components/shared/BackgroundLayer.tsx @@ -12,6 +12,7 @@ // limitations under the License. import { ReactElement } from 'react'; + import { BackgroundSpec } from '../../model'; import { imageFitToPreserveAspectRatio, isSafeImageUrl } from '../../utils/panelUtils'; diff --git a/canvas/src/components/shared/EdgeLines.tsx b/canvas/src/components/shared/EdgeLines.tsx index 0ed23cfd6..8e0ab34e4 100644 --- a/canvas/src/components/shared/EdgeLines.tsx +++ b/canvas/src/components/shared/EdgeLines.tsx @@ -12,6 +12,7 @@ // limitations under the License. import React, { ReactElement } from 'react'; + import { midpoint } from '../../utils/edgeUtils'; type Line = { x1: number; y1: number; x2: number; y2: number }; @@ -34,7 +35,7 @@ function computeEdgeGeometry( pts: Line, bidirectional: boolean, fwdStrokeWidth: number, - bwdStrokeWidth: number + bwdStrokeWidth: number, ): EdgeGeometry { const fwdShorten = ARROW_SW_W * fwdStrokeWidth; const bwdShorten = ARROW_SW_W * bwdStrokeWidth; @@ -141,7 +142,7 @@ export function edgeLabelPoints( pts: Line, bidirectional: boolean, fwdStrokeWidth: number, - bwdStrokeWidth: number + bwdStrokeWidth: number, ): { fwd: { x: number; y: number }; bwd: { x: number; y: number } | null } { const { fwd, bwd } = computeEdgeGeometry(pts, bidirectional, fwdStrokeWidth, bwdStrokeWidth); return { fwd: midpoint(fwd), bwd: bwd ? midpoint(bwd) : null }; diff --git a/canvas/src/components/shared/IconNode.tsx b/canvas/src/components/shared/IconNode.tsx index ebd4be8a2..e81a1788e 100644 --- a/canvas/src/components/shared/IconNode.tsx +++ b/canvas/src/components/shared/IconNode.tsx @@ -12,6 +12,7 @@ // limitations under the License. import { ReactElement, SVGProps } from 'react'; + import { NodeSpec } from '../../model'; import { ICON_PATHS } from '../../utils/icons'; import { labelAttrs } from '../../utils/labelPosition'; diff --git a/canvas/src/components/shared/NodeRenderer.tsx b/canvas/src/components/shared/NodeRenderer.tsx index 7161cb25c..225e63cfc 100644 --- a/canvas/src/components/shared/NodeRenderer.tsx +++ b/canvas/src/components/shared/NodeRenderer.tsx @@ -12,9 +12,10 @@ // limitations under the License. import React, { ReactElement } from 'react'; + import { NodeSpec } from '../../model'; -import { RectangleNode } from './RectangleNode'; import { IconNode } from './IconNode'; +import { RectangleNode } from './RectangleNode'; import { TextNode } from './TextNode'; export const DEFAULT_NODE_WIDTH = 48; diff --git a/canvas/src/components/shared/RectangleNode.tsx b/canvas/src/components/shared/RectangleNode.tsx index 62dec69c1..7618d3722 100644 --- a/canvas/src/components/shared/RectangleNode.tsx +++ b/canvas/src/components/shared/RectangleNode.tsx @@ -12,11 +12,12 @@ // limitations under the License. import React, { ReactElement } from 'react'; + +import { useCanvasTheme } from '../../hooks/useCanvasTheme'; import { NodeSpec } from '../../model'; import { ICON_PATHS } from '../../utils/icons'; import { labelAttrs } from '../../utils/labelPosition'; import { isSafeImageUrl } from '../../utils/panelUtils'; -import { useCanvasTheme } from '../../hooks/useCanvasTheme'; export const ICON_FILL_RATIO = 0.6; export const CORNER_RADIUS_RATIO = 0.2; diff --git a/canvas/src/components/shared/TextNode.tsx b/canvas/src/components/shared/TextNode.tsx index b25d6fc2f..b211dfa34 100644 --- a/canvas/src/components/shared/TextNode.tsx +++ b/canvas/src/components/shared/TextNode.tsx @@ -12,6 +12,7 @@ // limitations under the License. import { ReactElement } from 'react'; + import { NodeSpec } from '../../model'; const DEFAULT_TEXT_COLOR = 'currentColor'; diff --git a/canvas/src/contexts/EditorContext.tsx b/canvas/src/contexts/EditorContext.tsx index 26064692a..bf1252daa 100644 --- a/canvas/src/contexts/EditorContext.tsx +++ b/canvas/src/contexts/EditorContext.tsx @@ -12,6 +12,7 @@ // limitations under the License. import { createContext, ReactElement, ReactNode, useContext, useReducer } from 'react'; + import { EditorState, editorReducer, INITIAL_EDITOR_STATE } from '../utils/editorReducer'; export interface EditorContextValue { diff --git a/canvas/src/contexts/SpecContext.test.tsx b/canvas/src/contexts/SpecContext.test.tsx index b910b47c3..281ee0eee 100644 --- a/canvas/src/contexts/SpecContext.test.tsx +++ b/canvas/src/contexts/SpecContext.test.tsx @@ -13,6 +13,7 @@ import { act, renderHook } from '@testing-library/react'; import React, { ReactNode, useState } from 'react'; + import { BackgroundSpec, CanvasSpec } from '../model'; import { EditorStateProvider, useEditorContext } from './EditorContext'; import { SpecProvider, useSpecContext } from './SpecContext'; diff --git a/canvas/src/contexts/SpecContext.tsx b/canvas/src/contexts/SpecContext.tsx index ad27c42d0..bbcd07087 100644 --- a/canvas/src/contexts/SpecContext.tsx +++ b/canvas/src/contexts/SpecContext.tsx @@ -11,10 +11,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { createContext, ReactElement, ReactNode, useContext, useMemo } from 'react'; import { produce } from 'immer'; -import { BackgroundSpec, EdgeSpec, NodeSpec, CanvasSpec } from '../model'; +import { createContext, ReactElement, ReactNode, useContext, useMemo } from 'react'; + import { DEFAULT_NODE_WIDTH, DEFAULT_NODE_HEIGHT } from '../components/shared/NodeRenderer'; +import { BackgroundSpec, EdgeSpec, NodeSpec, CanvasSpec } from '../model'; import { generateId } from '../utils/generateId'; import { useEditorContext } from './EditorContext'; @@ -79,7 +80,7 @@ export function SpecProvider({ spec, onChange, children }: SpecProviderProps): R height: DEFAULT_NODE_HEIGHT, kind: 'icon', }); - }) + }), ); selectItems(new Set([id])); } @@ -89,7 +90,7 @@ export function SpecProvider({ spec, onChange, children }: SpecProviderProps): R onChange( produce(spec, (draft) => { (draft.backgrounds ??= []).push({ id, x, y, width, height }); - }) + }), ); selectItems(new Set([id])); } @@ -106,7 +107,7 @@ export function SpecProvider({ spec, onChange, children }: SpecProviderProps): R const tmp = arr[idx]!; arr[idx] = arr[swapIdx]!; arr[swapIdx] = tmp; - }) + }), ); } @@ -117,9 +118,9 @@ export function SpecProvider({ spec, onChange, children }: SpecProviderProps): R draft.backgrounds = (draft.backgrounds ?? []).filter((bg) => !selectedIds.has(bg.id)); draft.nodes = (draft.nodes ?? []).filter((n) => !selectedIds.has(n.id)); draft.edges = (draft.edges ?? []).filter( - (ed) => !selectedIds.has(ed.id) && !selectedIds.has(ed.source) && !selectedIds.has(ed.target) + (ed) => !selectedIds.has(ed.id) && !selectedIds.has(ed.source) && !selectedIds.has(ed.target), ); - }) + }), ); clearSelection(); } @@ -131,7 +132,7 @@ export function SpecProvider({ spec, onChange, children }: SpecProviderProps): R if (idx !== -1 && draft.nodes) { draft.nodes[idx] = updated; } - }) + }), ); } @@ -142,7 +143,7 @@ export function SpecProvider({ spec, onChange, children }: SpecProviderProps): R if (idx !== -1 && draft.edges) { draft.edges[idx] = updated; } - }) + }), ); } @@ -153,7 +154,7 @@ export function SpecProvider({ spec, onChange, children }: SpecProviderProps): R if (idx !== -1 && draft.backgrounds) { draft.backgrounds[idx] = updated; } - }) + }), ); } diff --git a/canvas/src/contexts/ZoomContext.tsx b/canvas/src/contexts/ZoomContext.tsx index 69c9bcc05..0943b7ca9 100644 --- a/canvas/src/contexts/ZoomContext.tsx +++ b/canvas/src/contexts/ZoomContext.tsx @@ -12,6 +12,7 @@ // limitations under the License. import { createContext, ReactNode, useContext } from 'react'; + import { UseZoomResult } from '../hooks/useZoom'; export type ZoomContextValue = Pick; diff --git a/canvas/src/getPluginModule.ts b/canvas/src/getPluginModule.ts index 063431fab..6c040526d 100644 --- a/canvas/src/getPluginModule.ts +++ b/canvas/src/getPluginModule.ts @@ -12,6 +12,7 @@ // limitations under the License. import { PluginModuleResource, PluginModuleSpec } from '@perses-dev/plugin-system'; + import packageJson from '../package.json'; /** diff --git a/canvas/src/hooks/useEdgeConnect.test.tsx b/canvas/src/hooks/useEdgeConnect.test.tsx index a0b6e2888..b2e51dbd3 100644 --- a/canvas/src/hooks/useEdgeConnect.test.tsx +++ b/canvas/src/hooks/useEdgeConnect.test.tsx @@ -14,6 +14,7 @@ import { act, renderHook } from '@testing-library/react'; import { produce } from 'immer'; import React from 'react'; + import { CanvasSpec, NodeSpec } from '../model'; import { makeWrapper } from '../test-utils/hookWrapper'; import { useEdgeConnect } from './useEdgeConnect'; diff --git a/canvas/src/hooks/useEdgeConnect.ts b/canvas/src/hooks/useEdgeConnect.ts index 0a2883764..07bc7a229 100644 --- a/canvas/src/hooks/useEdgeConnect.ts +++ b/canvas/src/hooks/useEdgeConnect.ts @@ -12,10 +12,11 @@ // limitations under the License. import { PointerEvent, useCallback, useState } from 'react'; + +import { useSpecContext } from '../contexts/SpecContext'; +import { useZoomContext } from '../contexts/ZoomContext'; import { AnchorPoint, EdgeSpec, CanvasSpec } from '../model'; import { anchorPosition, edgeEndpoints, pointInsideNode, snapTarget } from '../utils/edgeUtils'; -import { useZoomContext } from '../contexts/ZoomContext'; -import { useSpecContext } from '../contexts/SpecContext'; import { generateId } from '../utils/generateId'; const SNAP_RADIUS = 20; @@ -106,7 +107,7 @@ interface UseEdgeConnectResult { fixedX: number, fixedY: number, fixedNodeId: string, - fixedAnchor: AnchorPoint + fixedAnchor: AnchorPoint, ) => boolean; updateEdgeDrag: (event: PointerEvent) => void; resetEdgeDrag: () => void; @@ -130,7 +131,7 @@ export function useEdgeConnect(): UseEdgeConnectResult { fixedX: number, fixedY: number, fixedNodeId: string, - fixedAnchor: AnchorPoint + fixedAnchor: AnchorPoint, ): boolean => { event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); @@ -156,7 +157,7 @@ export function useEdgeConnect(): UseEdgeConnectResult { }); return true; }, - [edgeById, nodeById] + [edgeById, nodeById], ); const updateEdgeDrag = useCallback( @@ -177,7 +178,7 @@ export function useEdgeConnect(): UseEdgeConnectResult { }; }); }, - [spec.nodes, toCanvasPoint] + [spec.nodes, toCanvasPoint], ); const applyEdgeDrag = useCallback( @@ -210,7 +211,7 @@ export function useEdgeConnect(): UseEdgeConnectResult { (draft.edges ??= []).push(buildNewEdge(dragEdge, snap, pt)); } }, - [dragEdge, nodeById] + [dragEdge, nodeById], ); const resetEdgeDrag = useCallback((): void => { diff --git a/canvas/src/hooks/useNodeMove.test.tsx b/canvas/src/hooks/useNodeMove.test.tsx index 0bad80e12..20a412fa3 100644 --- a/canvas/src/hooks/useNodeMove.test.tsx +++ b/canvas/src/hooks/useNodeMove.test.tsx @@ -14,9 +14,10 @@ import { act, renderHook } from '@testing-library/react'; import { produce } from 'immer'; import React from 'react'; + +import { useEditorContext } from '../contexts/EditorContext'; import { CanvasSpec, NodeSpec } from '../model'; import { makeWrapper } from '../test-utils/hookWrapper'; -import { useEditorContext } from '../contexts/EditorContext'; import { useNodeMove } from './useNodeMove'; function makeNode(id: string, x: number, y: number): NodeSpec { diff --git a/canvas/src/hooks/useNodeMove.ts b/canvas/src/hooks/useNodeMove.ts index 24ba33ccf..b007449dd 100644 --- a/canvas/src/hooks/useNodeMove.ts +++ b/canvas/src/hooks/useNodeMove.ts @@ -12,10 +12,11 @@ // limitations under the License. import { PointerEvent, useCallback, useState } from 'react'; -import { CanvasSpec } from '../model'; -import { useZoomContext } from '../contexts/ZoomContext'; + import { useEditorContext } from '../contexts/EditorContext'; import { useSpecContext } from '../contexts/SpecContext'; +import { useZoomContext } from '../contexts/ZoomContext'; +import { CanvasSpec } from '../model'; interface MoveDrag { totalDx: number; @@ -52,13 +53,13 @@ export function useNodeMove(): UseNodeMoveResult { const origEdges = (spec.edges ?? []) .filter( (ed): ed is typeof ed & { x2: number; y2: number } => - selectedIds.has(ed.id) && ed.x2 !== undefined && ed.y2 !== undefined + selectedIds.has(ed.id) && ed.x2 !== undefined && ed.y2 !== undefined, ) .map((ed) => ({ id: ed.id, x2: ed.x2, y2: ed.y2 })); setMoveDrag({ totalDx: 0, totalDy: 0, origNodes, origEdges }); return null; }, - [selectedIds, spec] + [selectedIds, spec], ); const updateMove = useCallback( @@ -75,7 +76,7 @@ export function useNodeMove(): UseNodeMoveResult { return { ...current, totalDx: current.totalDx + dx, totalDy: current.totalDy + dy }; }); }, - [selectedIds, transform.k] + [selectedIds, transform.k], ); const applyMove = useCallback( @@ -101,7 +102,7 @@ export function useNodeMove(): UseNodeMoveResult { } }); }, - [moveDrag] + [moveDrag], ); const resetMove = useCallback((): void => { diff --git a/canvas/src/hooks/useRectSelect.test.tsx b/canvas/src/hooks/useRectSelect.test.tsx index e2cbd338d..09cea72c5 100644 --- a/canvas/src/hooks/useRectSelect.test.tsx +++ b/canvas/src/hooks/useRectSelect.test.tsx @@ -13,6 +13,7 @@ import { act, renderHook } from '@testing-library/react'; import React from 'react'; + import { NodeSpec } from '../model'; import { makeWrapper } from '../test-utils/hookWrapper'; import { useRectSelect } from './useRectSelect'; @@ -24,7 +25,7 @@ function makeNode(id: string, x: number, y: number): NodeSpec { function makePointerEvent( x: number, y: number, - overrides: Partial = {} + overrides: Partial = {}, ): React.PointerEvent { return { clientX: x, diff --git a/canvas/src/hooks/useRectSelect.ts b/canvas/src/hooks/useRectSelect.ts index 7900f9813..e30d0945e 100644 --- a/canvas/src/hooks/useRectSelect.ts +++ b/canvas/src/hooks/useRectSelect.ts @@ -12,8 +12,9 @@ // limitations under the License. import { PointerEvent, useCallback, useRef, useState } from 'react'; -import { useZoomContext } from '../contexts/ZoomContext'; + import { useSpecContext } from '../contexts/SpecContext'; +import { useZoomContext } from '../contexts/ZoomContext'; import { computeSelectionFromRect } from '../utils/selectionUtils'; export interface SelectionRect { @@ -60,7 +61,7 @@ export function useRectSelect(): UseRectSelectResult { setSelectionRect(rect); return true; }, - [toCanvasPoint] + [toCanvasPoint], ); const updateSelection = useCallback( @@ -73,7 +74,7 @@ export function useRectSelect(): UseRectSelectResult { rectRef.current = updated; setSelectionRect(updated); }, - [toCanvasPoint] + [toCanvasPoint], ); const applySelection = useCallback((): Set => { diff --git a/canvas/src/hooks/useResize.test.tsx b/canvas/src/hooks/useResize.test.tsx index f6e8ea1a7..eaaf982e5 100644 --- a/canvas/src/hooks/useResize.test.tsx +++ b/canvas/src/hooks/useResize.test.tsx @@ -14,8 +14,9 @@ import { act, renderHook } from '@testing-library/react'; import { produce } from 'immer'; import React from 'react'; -import { CanvasSpec, NodeSpec } from '../model'; + import { useEditorContext } from '../contexts/EditorContext'; +import { CanvasSpec, NodeSpec } from '../model'; import { makeWrapper } from '../test-utils/hookWrapper'; import { useResize } from './useResize'; diff --git a/canvas/src/hooks/useResize.ts b/canvas/src/hooks/useResize.ts index c064047b6..407c68f53 100644 --- a/canvas/src/hooks/useResize.ts +++ b/canvas/src/hooks/useResize.ts @@ -12,10 +12,11 @@ // limitations under the License. import { PointerEvent, useCallback, useMemo, useState } from 'react'; -import { CanvasSpec, FloatingEdge, isFloatingEdge } from '../model'; -import { useZoomContext } from '../contexts/ZoomContext'; + import { useEditorContext } from '../contexts/EditorContext'; import { useSpecContext } from '../contexts/SpecContext'; +import { useZoomContext } from '../contexts/ZoomContext'; +import { CanvasSpec, FloatingEdge, isFloatingEdge } from '../model'; import { BoundingBox, HANDLE_POSITIONS, @@ -67,7 +68,7 @@ function scalePoint( px: number, py: number, origBoundingBox: BoundingBox, - final: FinalBoundingBox + final: FinalBoundingBox, ): { x: number; y: number } { const origWidth = origBoundingBox.maxX - origBoundingBox.minX; const origHeight = origBoundingBox.maxY - origBoundingBox.minY; @@ -84,7 +85,7 @@ function scaleNodeSize( height: number, kind: string, origBoundingBox: BoundingBox, - final: FinalBoundingBox + final: FinalBoundingBox, ): { width: number; height: number } { const scaleX = (final.maxX - final.minX) / (origBoundingBox.maxX - origBoundingBox.minX); const scaleY = (final.maxY - final.minY) / (origBoundingBox.maxY - origBoundingBox.minY); @@ -116,11 +117,11 @@ export function useResize(): UseResizeResult { const selectedNodes = useMemo( () => (spec.nodes ?? []).filter((n) => selectedIds.has(n.id)), - [spec.nodes, selectedIds] + [spec.nodes, selectedIds], ); const selectedFloatingEdges = useMemo( () => (spec.edges ?? []).filter((ed): ed is FloatingEdge => selectedIds.has(ed.id) && isFloatingEdge(ed)), - [spec.edges, selectedIds] + [spec.edges, selectedIds], ); const beginResize = useCallback( @@ -144,7 +145,7 @@ export function useResize(): UseResizeResult { }); return true; }, - [selectedNodes, selectedFloatingEdges] + [selectedNodes, selectedFloatingEdges], ); const updateResize = useCallback( @@ -157,7 +158,7 @@ export function useResize(): UseResizeResult { return { ...current, currentX: point.x, currentY: point.y }; }); }, - [toCanvasPoint] + [toCanvasPoint], ); const applyResize = useCallback( @@ -192,7 +193,7 @@ export function useResize(): UseResizeResult { edge.y2 = pos.y; }); }, - [resizeDrag, selectedNodes, selectedFloatingEdges] + [resizeDrag, selectedNodes, selectedFloatingEdges], ); const resetResize = useCallback((): void => { diff --git a/canvas/src/hooks/useZoom.ts b/canvas/src/hooks/useZoom.ts index 195f41f00..93ecf0174 100644 --- a/canvas/src/hooks/useZoom.ts +++ b/canvas/src/hooks/useZoom.ts @@ -11,9 +11,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { PointerEvent, useCallback, useMemo, useRef, useState } from 'react'; import { select } from 'd3-selection'; import { zoom, zoomIdentity, ZoomTransform } from 'd3-zoom'; +import { PointerEvent, useCallback, useMemo, useRef, useState } from 'react'; const FIT_PADDING = 40; @@ -23,7 +23,7 @@ export interface UseZoomResult { fitView: ( boundingBox: { minX: number; minY: number; maxX: number; maxY: number }, canvasWidth: number, - canvasHeight: number + canvasHeight: number, ) => void; toCanvasPoint: (event: PointerEvent) => { x: number; y: number }; resetPan: () => void; @@ -55,7 +55,7 @@ export function useZoom(): UseZoomResult { }); select(node).call(zoomBehavior); }, - [zoomBehavior] + [zoomBehavior], ); const resetPan = useCallback(() => { @@ -69,7 +69,7 @@ export function useZoom(): UseZoomResult { ( boundingBox: { minX: number; minY: number; maxX: number; maxY: number }, canvasWidth: number, - canvasHeight: number + canvasHeight: number, ): void => { if (!nodeRef.current) { return; @@ -82,7 +82,7 @@ export function useZoom(): UseZoomResult { const t = zoomIdentity.translate(tx, ty).scale(scale); select(nodeRef.current).call(zoomBehavior.transform, t); }, - [zoomBehavior] + [zoomBehavior], ); const toCanvasPoint = useCallback( @@ -95,7 +95,7 @@ export function useZoom(): UseZoomResult { const py = event.clientY - rect.top; return { x: transform.invertX(px), y: transform.invertY(py) }; }, - [transform] + [transform], ); return { svgRef, transform, fitView, toCanvasPoint, resetPan }; diff --git a/canvas/src/model.ts b/canvas/src/model.ts index 81f236e50..e2aea10d9 100644 --- a/canvas/src/model.ts +++ b/canvas/src/model.ts @@ -11,8 +11,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { TimeSeriesData, ThresholdOptions } from '@perses-dev/core'; import { FormatOptions } from '@perses-dev/components'; +import { TimeSeriesData, ThresholdOptions } from '@perses-dev/core'; import { PanelProps, LegendSpecOptions, OptionsEditorProps } from '@perses-dev/plugin-system'; export type QueryData = TimeSeriesData; diff --git a/canvas/src/test-utils/hookWrapper.tsx b/canvas/src/test-utils/hookWrapper.tsx index 556cb05b7..1bdd043f9 100644 --- a/canvas/src/test-utils/hookWrapper.tsx +++ b/canvas/src/test-utils/hookWrapper.tsx @@ -12,10 +12,11 @@ // limitations under the License. import React, { ReactNode, useState } from 'react'; -import { CanvasSpec } from '../model'; + import { EditorStateProvider } from '../contexts/EditorContext'; import { SpecContext, SpecContextValue } from '../contexts/SpecContext'; import { ZoomContext, ZoomContextValue } from '../contexts/ZoomContext'; +import { CanvasSpec } from '../model'; // Minimal identity-transform stub — d3-zoom is ESM-only and not transformable by Jest. const identityTransform = { @@ -56,7 +57,7 @@ export function HookWrapper({ initialSpec = {}, children }: WrapperProps): React const edgeById = React.useMemo(() => new Map((spec.edges ?? []).map((ed) => [ed.id, ed])), [spec.edges]); const backgroundById = React.useMemo( () => new Map((spec.backgrounds ?? []).map((bg) => [bg.id, bg])), - [spec.backgrounds] + [spec.backgrounds], ); const specCtx: SpecContextValue = { diff --git a/canvas/src/utils/edgeUtils.ts b/canvas/src/utils/edgeUtils.ts index d5dfcd9e0..8ebf3d846 100644 --- a/canvas/src/utils/edgeUtils.ts +++ b/canvas/src/utils/edgeUtils.ts @@ -49,7 +49,7 @@ export function closestAnchor(node: NodeSpec, pt: { x: number; y: number }): Anc export function edgeEndpoints( edge: EdgeSpec, - nodeById: Map + nodeById: Map, ): { x1: number; y1: number; x2: number; y2: number } | null { const src = nodeById.get(edge.source); if (!src) return null; @@ -94,7 +94,7 @@ export function snapTarget( nodes: NodeSpec[], pt: { x: number; y: number }, excludeId: string, - snapRadius: number + snapRadius: number, ): { node: NodeSpec; anchor: AnchorPoint } | null { let best: { node: NodeSpec; anchor: AnchorPoint; dist: number } | null = null; for (const node of nodes) { diff --git a/canvas/src/utils/editorReducer.ts b/canvas/src/utils/editorReducer.ts index 6b85d7ebd..5f64ef2e8 100644 --- a/canvas/src/utils/editorReducer.ts +++ b/canvas/src/utils/editorReducer.ts @@ -12,7 +12,11 @@ // limitations under the License. export type EditorMode = - { type: 'idle' } | { type: 'selecting' } | { type: 'moving' } | { type: 'dragging-edge' } | { type: 'resizing' }; + | { type: 'idle' } + | { type: 'selecting' } + | { type: 'moving' } + | { type: 'dragging-edge' } + | { type: 'resizing' }; export interface EditorState { mode: EditorMode; diff --git a/canvas/src/utils/labelPosition.ts b/canvas/src/utils/labelPosition.ts index 9d566e0f1..1e16b82ba 100644 --- a/canvas/src/utils/labelPosition.ts +++ b/canvas/src/utils/labelPosition.ts @@ -26,7 +26,7 @@ export function labelAttrs( halfW: number, halfH: number, position: LabelPosition | undefined, - padding: number | undefined + padding: number | undefined, ): LabelAttrs { const pos = position ?? 'below'; const pad = padding ?? DEFAULT_PADDING; diff --git a/canvas/src/utils/panelUtils.test.ts b/canvas/src/utils/panelUtils.test.ts index 5521d7c90..03f682ab0 100644 --- a/canvas/src/utils/panelUtils.test.ts +++ b/canvas/src/utils/panelUtils.test.ts @@ -12,6 +12,7 @@ // limitations under the License. import { TimeSeries } from '@perses-dev/core'; + import { colorFromThresholds, interpolateLabel, isSafeImageUrl } from './panelUtils'; function makeSeries(labels: Record, values: Array<[number, number | null]>): TimeSeries { @@ -63,7 +64,7 @@ describe('colorFromThresholds', () => { it('returns threshold defaultColor when set and no step matches', () => { expect( - colorFromThresholds(1, { defaultColor: '#123', steps: [{ value: 10, color: '#abc' }] }, palette, fallback) + colorFromThresholds(1, { defaultColor: '#123', steps: [{ value: 10, color: '#abc' }] }, palette, fallback), ).toBe('#123'); }); diff --git a/canvas/src/utils/panelUtils.ts b/canvas/src/utils/panelUtils.ts index b4067cc62..03a5de894 100644 --- a/canvas/src/utils/panelUtils.ts +++ b/canvas/src/utils/panelUtils.ts @@ -11,8 +11,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { ThresholdOptions, TimeSeries } from '@perses-dev/core'; import { FormatOptions, formatValue } from '@perses-dev/components'; +import { ThresholdOptions, TimeSeries } from '@perses-dev/core'; + import { BackgroundSpec } from '../model'; export function isSafeImageUrl(url: string): boolean { @@ -62,7 +63,7 @@ export function colorFromThresholds( thresholdValue: number, thresholds: ThresholdOptions, paletteColors: string[], - fallbackColor: string + fallbackColor: string, ): string { const defaultColor = thresholds.defaultColor ?? paletteColors[0] ?? fallbackColor; if (!thresholds.steps?.length) { diff --git a/canvas/src/utils/resizeUtils.test.ts b/canvas/src/utils/resizeUtils.test.ts index b4403d9f7..28edf6d36 100644 --- a/canvas/src/utils/resizeUtils.test.ts +++ b/canvas/src/utils/resizeUtils.test.ts @@ -43,7 +43,7 @@ describe('nodeBoundingBox', () => { [ { x: 10, y: 20 }, { x: -5, y: 50 }, - ] + ], ); expect(result).toEqual({ minX: -5, minY: 20, maxX: 10, maxY: 50 }); }); diff --git a/canvas/src/utils/resizeUtils.ts b/canvas/src/utils/resizeUtils.ts index 3c851643a..a89d23d94 100644 --- a/canvas/src/utils/resizeUtils.ts +++ b/canvas/src/utils/resizeUtils.ts @@ -23,7 +23,7 @@ export type ResizeHandleId = (typeof RESIZE_HANDLE_IDS)[number]; export function nodeBoundingBox( nodes: Array<{ x: number; y: number; width: number; height: number }>, - floatingPoints: Array<{ x: number; y: number }> = [] + floatingPoints: Array<{ x: number; y: number }> = [], ): BoundingBox | null { if (nodes.length === 0 && floatingPoints.length === 0) { return null; diff --git a/canvas/src/utils/selectionUtils.ts b/canvas/src/utils/selectionUtils.ts index 2bf6b1152..c6cd48f9c 100644 --- a/canvas/src/utils/selectionUtils.ts +++ b/canvas/src/utils/selectionUtils.ts @@ -11,8 +11,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import type { EdgeSpec, NodeSpec } from '../model'; import type { SelectionRect } from '../hooks/useRectSelect'; +import type { EdgeSpec, NodeSpec } from '../model'; export function computeSelectionFromRect(rect: SelectionRect, nodes: NodeSpec[], edges: EdgeSpec[]): Set { const minX = Math.min(rect.x0, rect.x1); diff --git a/canvas/tsconfig.json b/canvas/tsconfig.json index 40e3c4dfe..d8471c931 100644 --- a/canvas/tsconfig.json +++ b/canvas/tsconfig.json @@ -20,4 +20,4 @@ "pretty": true }, "include": ["src"] -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index d809e357e..028aa3d76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "tracetable", "tracingganttchart", "victorialogs", + "canvas", "e2e" ], "devDependencies": { @@ -125,6 +126,36 @@ "use-resize-observer": "^9.0.0" } }, + "canvas": { + "name": "@perses-dev/canvas-plugin", + "version": "0.1.0", + "dependencies": { + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + }, + "devDependencies": { + "@types/d3-selection": "^3.0.11", + "@types/d3-zoom": "^3.0.8" + }, + "peerDependencies": { + "@emotion/react": "^11.7.1", + "@emotion/styled": "^11.6.0", + "@hookform/resolvers": "^3.2.0", + "@perses-dev/components": "^0.55.0-beta.1", + "@perses-dev/dashboards": "^0.55.0-beta.1", + "@perses-dev/explore": "^0.55.0-beta.1", + "@perses-dev/plugin-system": "^0.55.0-beta.1", + "@perses-dev/spec": "^0.3.0-beta.1", + "@tanstack/react-query": "^4.39.1", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "echarts": "5.5.0", + "immer": "^10.1.1", + "react": "^17.0.2 || ^18.0.0", + "react-dom": "^17.0.2 || ^18.0.0", + "use-resize-observer": "^9.0.0" + } + }, "clickhouse": { "name": "@perses-dev/clickhouse-plugin", "version": "0.6.0", @@ -503,9 +534,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -523,9 +551,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -543,9 +568,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -563,9 +585,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1311,16 +1330,20 @@ "license": "MIT" }, "node_modules/@emnapi/core": { - "version": "1.11.1", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.2", + "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.11.1", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, "dependencies": { @@ -1328,7 +1351,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "license": "MIT", "optional": true, "dependencies": { @@ -1337,6 +1362,8 @@ }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", "license": "MIT", "peer": true, "dependencies": { @@ -1355,6 +1382,8 @@ }, "node_modules/@emotion/cache": { "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", "license": "MIT", "peer": true, "dependencies": { @@ -1367,11 +1396,15 @@ }, "node_modules/@emotion/hash": { "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", "license": "MIT", "peer": true }, "node_modules/@emotion/is-prop-valid": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "license": "MIT", "peer": true, "dependencies": { @@ -1380,11 +1413,15 @@ }, "node_modules/@emotion/memoize": { "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", "license": "MIT", "peer": true }, "node_modules/@emotion/react": { "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", "peer": true, "dependencies": { @@ -1408,6 +1445,8 @@ }, "node_modules/@emotion/serialize": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", "license": "MIT", "peer": true, "dependencies": { @@ -1420,11 +1459,15 @@ }, "node_modules/@emotion/sheet": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", "license": "MIT", "peer": true }, "node_modules/@emotion/styled": { "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", "peer": true, "dependencies": { @@ -1447,11 +1490,15 @@ }, "node_modules/@emotion/unitless": { "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", "license": "MIT", "peer": true }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", "license": "MIT", "peer": true, "peerDependencies": { @@ -1460,11 +1507,15 @@ }, "node_modules/@emotion/utils": { "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", "license": "MIT", "peer": true }, "node_modules/@emotion/weak-memoize": { "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "license": "MIT", "peer": true }, @@ -1491,6 +1542,8 @@ }, "node_modules/@hookform/resolvers": { "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", + "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", "license": "MIT", "peer": true, "peerDependencies": { @@ -2342,6 +2395,8 @@ }, "node_modules/@mui/core-downloads-tracker": { "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.5.0.tgz", + "integrity": "sha512-LGb8t8i6M2ZtS3Drn3GbTI1DVhDY6FJ9crEey2lZ0aN2EMZo8IZBZj9wRf4vqbZHaWjsYgtbOnJw5V8UWbmK2Q==", "license": "MIT", "peer": true, "funding": { @@ -2351,6 +2406,8 @@ }, "node_modules/@mui/material": { "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.5.0.tgz", + "integrity": "sha512-yjvtXoFcrPLGtgKRxFaH6OQPtcLPhkloC0BML6rBG5UeldR0nPULR/2E2BfXdo5JNV7j7lOzrrLX2Qf/iSidow==", "license": "MIT", "peer": true, "dependencies": { @@ -2397,8 +2454,10 @@ } } }, - "node_modules/@mui/private-theming": { + "node_modules/@mui/material/node_modules/@mui/private-theming": { "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.9.tgz", + "integrity": "sha512-LktcVmI5X17/Q5SkwjCcdOLBzt1hXuc14jYa7NPShog0GBDCDvKtcnP0V7a2s6EiVRlv7BzbWEJzH6+l/zaCxw==", "license": "MIT", "peer": true, "dependencies": { @@ -2423,8 +2482,10 @@ } } }, - "node_modules/@mui/styled-engine": { + "node_modules/@mui/material/node_modules/@mui/styled-engine": { "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.5.0.tgz", + "integrity": "sha512-8woC2zAqF4qUDSPIBZ8v3sakj+WgweolpyM/FXf8jAx6FMls+IE4Y8VDZc+zS805J7PRz31vz73n2SovKGaYgw==", "license": "MIT", "peer": true, "dependencies": { @@ -2456,8 +2517,10 @@ } } }, - "node_modules/@mui/system": { + "node_modules/@mui/material/node_modules/@mui/system": { "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.5.0.tgz", + "integrity": "sha512-XcbBYxDS+h/lgsoGe78ExXFZXtuIlSBpn/KsZq8PtZcIkUNJInkuDqcLd2rVBQrDC1u+rvVovdaWPf2FHKJf3w==", "license": "MIT", "peer": true, "dependencies": { @@ -2495,9 +2558,12 @@ } } }, - "node_modules/@mui/types": { + "node_modules/@mui/material/node_modules/@mui/types": { "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -2507,9 +2573,12 @@ } } }, - "node_modules/@mui/utils": { + "node_modules/@mui/material/node_modules/@mui/utils": { "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.9.tgz", + "integrity": "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.26.0", "@mui/types": "~7.2.24", @@ -2535,6 +2604,157 @@ } } }, + "node_modules/@mui/private-theming": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.11.tgz", + "integrity": "sha512-9B+YKms0fRHbNrqp9tOT/DNbNnU5gyvJ1o3qAGXfq8GmZcbJnE3At9x07Zr/o0pkhzg4aDdwXVqe4+AcgtOCPA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/utils": "^7.3.11", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.10.tgz", + "integrity": "sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.28.6", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.11.tgz", + "integrity": "sha512-7izwGWdNawAKpBKcRlx7f2gFnAAjmASBWvMcyX4YYEeLOFsbfGRbUYGInvnAcUeql3rPxI7F9Ft4oY2OLRz44g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/private-theming": "^7.3.11", + "@mui/styled-engine": "^7.3.10", + "@mui/types": "^7.4.12", + "@mui/utils": "^7.3.11", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", + "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz", + "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/types": "^7.4.12", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/x-data-grid": { "version": "7.29.13", "license": "MIT", @@ -2793,9 +3013,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2813,9 +3030,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2833,9 +3047,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2853,9 +3064,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2873,9 +3081,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2893,9 +3098,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2913,9 +3115,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3140,9 +3339,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3160,9 +3356,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3180,9 +3373,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3200,9 +3390,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3220,9 +3407,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3240,9 +3424,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3260,9 +3441,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3280,9 +3458,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3497,9 +3672,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3517,9 +3689,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3537,9 +3706,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3557,9 +3723,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3577,9 +3740,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3597,9 +3757,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3617,9 +3774,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3637,9 +3791,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3844,9 +3995,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3864,9 +4012,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3884,9 +4029,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3904,9 +4046,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3924,9 +4063,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3944,9 +4080,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3964,9 +4097,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3984,9 +4114,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4072,6 +4199,10 @@ "resolved": "barchart", "link": true }, + "node_modules/@perses-dev/canvas-plugin": { + "resolved": "canvas", + "link": true + }, "node_modules/@perses-dev/clickhouse-plugin": { "resolved": "clickhouse", "link": true @@ -4609,6 +4740,8 @@ }, "node_modules/@popperjs/core": { "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", "peer": true, "funding": { @@ -4786,9 +4919,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4803,9 +4933,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4820,9 +4947,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4837,9 +4961,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4954,26 +5075,32 @@ } }, "node_modules/@rspack/binding": { - "version": "2.1.2", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-2.1.10.tgz", + "integrity": "sha512-vnu/UP5HnrND15lO9+VeG6eUrbTyycHNQNQ3XEiRiFojuoiGZkIZC3Hbzr8qQH44C6vScPODEPvvIVvLcO2LpQ==", "license": "MIT", "peer": true, "optionalDependencies": { - "@rspack/binding-darwin-arm64": "2.1.2", - "@rspack/binding-darwin-x64": "2.1.2", - "@rspack/binding-linux-arm64-gnu": "2.1.2", - "@rspack/binding-linux-arm64-musl": "2.1.2", - "@rspack/binding-linux-riscv64-gnu": "2.1.2", - "@rspack/binding-linux-riscv64-musl": "2.1.2", - "@rspack/binding-linux-x64-gnu": "2.1.2", - "@rspack/binding-linux-x64-musl": "2.1.2", - "@rspack/binding-wasm32-wasi": "2.1.2", - "@rspack/binding-win32-arm64-msvc": "2.1.2", - "@rspack/binding-win32-ia32-msvc": "2.1.2", - "@rspack/binding-win32-x64-msvc": "2.1.2" + "@rspack/binding-darwin-arm64": "2.1.10", + "@rspack/binding-darwin-x64": "2.1.10", + "@rspack/binding-linux-arm64-gnu": "2.1.10", + "@rspack/binding-linux-arm64-musl": "2.1.10", + "@rspack/binding-linux-ppc64-gnu": "2.1.10", + "@rspack/binding-linux-riscv64-gnu": "2.1.10", + "@rspack/binding-linux-riscv64-musl": "2.1.10", + "@rspack/binding-linux-s390x-gnu": "2.1.10", + "@rspack/binding-linux-x64-gnu": "2.1.10", + "@rspack/binding-linux-x64-musl": "2.1.10", + "@rspack/binding-wasm32-wasi": "2.1.10", + "@rspack/binding-win32-arm64-msvc": "2.1.10", + "@rspack/binding-win32-ia32-msvc": "2.1.10", + "@rspack/binding-win32-x64-msvc": "2.1.10" } }, "node_modules/@rspack/binding-darwin-arm64": { - "version": "2.1.2", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-2.1.10.tgz", + "integrity": "sha512-DZlcTpbIb2mjeS1aSG4k01UH33Zj7T+k8ZylPK6HmsKs4JvK4wgpWFC78WVv3p/Aj3MZS6DwtDLwpZ2Ihj/fpg==", "cpu": [ "arm64" ], @@ -4985,9 +5112,9 @@ "peer": true }, "node_modules/@rspack/binding-darwin-x64": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-2.1.2.tgz", - "integrity": "sha512-aoifkILvx/XEHyvg8yW57xu95nx7f9f/3ah1+RguHSNKcJMcoCep9VX1Ct1N0ftqg8MC0JUObc7xWL5W14hmjA==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-2.1.10.tgz", + "integrity": "sha512-my/0h2LwxCRT6cg3oDDC2e0ZOxQLVajAdIcv0fqnQk5JRNvVuL89PuTutitnSqie1A0/JSL8OQz5XHwmoS3kow==", "cpu": [ "x64" ], @@ -4999,15 +5126,12 @@ "peer": true }, "node_modules/@rspack/binding-linux-arm64-gnu": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-2.1.2.tgz", - "integrity": "sha512-My4m40tyJSgiCEf3bB2KIEX710q3nZg99LIjy+8Zxgi3oZTkg1bFmFRusFU5U4eN5408zfSqDDGvjDE3Yv7o4w==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-2.1.10.tgz", + "integrity": "sha512-laevn9g+E5PAUEGqiKe6Ju5KApsuQYp+bPI17XS3Lkl8eqL5pS/BmHYU7QMlst4GzV8+wlruVTMh//+st6Vqzg==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5016,14 +5140,25 @@ "peer": true }, "node_modules/@rspack/binding-linux-arm64-musl": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-2.1.2.tgz", - "integrity": "sha512-yt+GGWUH7WPE8K97cRc8OpZhH7Pbj1vU+lkvKbDtF/rR8X9a/bJsA/nBqyUV2oBKOVbrp5I8rFZlnDskMqgvKw==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-2.1.10.tgz", + "integrity": "sha512-V71+Qz5G72+ROZXrJn5zxOszdG1AEbO8pcC/itXXtf4yRR6a3bVHKNKGhipBNxb8eI6cnD/01FH1h3ZG655jLw==", "cpu": [ "arm64" ], - "libc": [ - "musl" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rspack/binding-linux-ppc64-gnu": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-2.1.10.tgz", + "integrity": "sha512-U7HlNzHcDtZ+LYOtOJmtx67kHEybZzUUAaP7aEXjGYO5WTCgh/176sW2UYP0rmZLrgUNFUuzn+B98RLaClNaVg==", + "cpu": [ + "ppc64" ], "license": "MIT", "optional": true, @@ -5033,15 +5168,12 @@ "peer": true }, "node_modules/@rspack/binding-linux-riscv64-gnu": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-2.1.2.tgz", - "integrity": "sha512-uys8Jyw8Z3ralvICbN/L/nZfy5qELIwpOY72rhIqhoDYwFcL4fmMaY7WsvUcJOjCB2rqOcWPaWKuF2oPvo9iDQ==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-2.1.10.tgz", + "integrity": "sha512-GMGTJpy9/ecE+5F5IfxZH4bXv0Wx/b2TiehTlCbTksbL+pKpLHYy0rwGdjWDKbmBkhxMMqPiC7PDnn9LbdnnLA==", "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5050,14 +5182,25 @@ "peer": true }, "node_modules/@rspack/binding-linux-riscv64-musl": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-2.1.2.tgz", - "integrity": "sha512-JYNVQwqCaRGQWvjHQYzZkIzQiwllMaJwh4Rdu3ww6W2OJcJUqT08sL1pkOtU0iCxT4VUYiRRcp93VGTGpHr8fg==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-2.1.10.tgz", + "integrity": "sha512-rkurnAWc04vIbzG1QCrPBWSJadZvaOt1mazFH3EdiJO8VUiu0I1T9zdiwuDOPrd50lOKIZlcTXbd5aaAkWEnvQ==", "cpu": [ "riscv64" ], - "libc": [ - "musl" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rspack/binding-linux-s390x-gnu": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-2.1.10.tgz", + "integrity": "sha512-X+DyxkriZEAF/wihI7ERDv+CAS0mbMv36aEuQ+vXzTlvS6cSmpou/r29AHbvIF3NlG1UeAbDVlOs9QrMBZjpUQ==", + "cpu": [ + "s390x" ], "license": "MIT", "optional": true, @@ -5067,15 +5210,12 @@ "peer": true }, "node_modules/@rspack/binding-linux-x64-gnu": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-2.1.2.tgz", - "integrity": "sha512-KDoPy0Msf/JLhxgPPrJQzZeB4Qpqd32em8AP5lSW2s6jR5I35dHgAe9xc2A++EQtnSrU4GTn6DBvFC7q84SihQ==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-2.1.10.tgz", + "integrity": "sha512-Fat09V6jUuyo9qG7Wyj9cQ31VDfLmokXyBtGqKxY5OvSWHereB7QUub5btbPXHwbp6Iq4aAQyUbbLTzvR1YaBw==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5084,15 +5224,12 @@ "peer": true }, "node_modules/@rspack/binding-linux-x64-musl": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-2.1.2.tgz", - "integrity": "sha512-66hWmIGvn4zCKAYXJE9Bp5SNSLYnLFq2Ke/efE+ZtWy43Dd5vk9AAOmThVGBwdwmIxmGtHGCp+cAuS4G0wu0TA==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-2.1.10.tgz", + "integrity": "sha512-lhHOnIJ4ClpIlA1f1L8aoxEZivYLjnjq5A6jKKz7BKsm+cHK8kqqEm6lO5KqA5xQT0Lonq1o28bmKHEj6JHInw==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5101,9 +5238,9 @@ "peer": true }, "node_modules/@rspack/binding-wasm32-wasi": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-2.1.2.tgz", - "integrity": "sha512-EB4SqH8DW/E/OmqssNQvnIVGQiVUyYNlA/pcc6Ia4MlTNwu6eNDppcNLrToH+kSZpL4CpHSFfSM3eIsSuar2Rw==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-2.1.10.tgz", + "integrity": "sha512-KY5YbWbuvYcoaLXnV+vzZOvGRCeb6jt4EpVpKdph1h1IJjwX/ju15EQ+GOe3iecZEdf0OttQcNVcwBkLkFT9ag==", "cpu": [ "wasm32" ], @@ -5111,15 +5248,15 @@ "optional": true, "peer": true, "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", + "@emnapi/core": "1.11.3", + "@emnapi/runtime": "1.11.3", "@napi-rs/wasm-runtime": "1.1.6" } }, "node_modules/@rspack/binding-win32-arm64-msvc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-2.1.2.tgz", - "integrity": "sha512-T6Fs/g32MRja/UpCq4AdyPRj8tA0cOkcEa4PrAcn/ztUgK8b/qMVxj5mhMI+n7k+kHZQnpeB1Q4HqdSJi6OocA==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-2.1.10.tgz", + "integrity": "sha512-z4GWzMLofaDGpAt9Z+MlN88LlUBDm+zM6R2GdOOPM6/4g/h3/+47OP7casmSL3AwTGYBEJqogwt08sRSosB6Cg==", "cpu": [ "arm64" ], @@ -5131,9 +5268,9 @@ "peer": true }, "node_modules/@rspack/binding-win32-ia32-msvc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-2.1.2.tgz", - "integrity": "sha512-OtxkFVz14mVL4QK8QriSELn9B6PaYGHw1jGJwVDEzpu2ZxSHCTQPz9dVE1ekYtREEqZUkRU7Fp7VfhJSmjTt2Q==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-2.1.10.tgz", + "integrity": "sha512-7qcWdsZ+GuGtzKjqgy7wTN7Dso/ezIY8yhx1r2yIbcczdmXj4FhaEampMDp/25HwtKwIGBBoh6HHSt3JWxpTUg==", "cpu": [ "ia32" ], @@ -5145,9 +5282,9 @@ "peer": true }, "node_modules/@rspack/binding-win32-x64-msvc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-2.1.2.tgz", - "integrity": "sha512-Am+nx9fLF3nzgD/K05Bs1Bb+WO8SFLWAYRbXkymaL1r+RQxjRj7jd5ap2PhGOCcfaNA4yVWkAFvmFP92eRu7bQ==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-2.1.10.tgz", + "integrity": "sha512-pgp23pLrzfhGnKycxzr7ifP17lAbWZEfnx1bX8gXtYrnpJ66DRNyTKSzxB6sa/HBWjS1L8PX5TjMZ44WfPydqQ==", "cpu": [ "x64" ], @@ -5159,11 +5296,13 @@ "peer": true }, "node_modules/@rspack/core": { - "version": "2.1.2", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@rspack/core/-/core-2.1.10.tgz", + "integrity": "sha512-YSS2/Xxz8uiG/KXDkqOoA3dTetNo/vysk7bAexQOrU8iuq7JuzDTTAwLKvWZnwmvME8M8m5wcM4YvfIwYmidHA==", "license": "MIT", "peer": true, "dependencies": { - "@rspack/binding": "2.1.2" + "@rspack/binding": "2.1.10" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -5401,9 +5540,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5421,9 +5557,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5441,9 +5574,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5461,9 +5591,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5481,9 +5608,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5501,9 +5625,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5631,6 +5752,8 @@ }, "node_modules/@tanstack/query-core": { "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.44.0.tgz", + "integrity": "sha512-swSgb7OiPRR3UuIL7NuDrZNSMGmQD+wdtHxPD7j60SvBEnxbXurl5XOirtGEX2gm2hbK6mC8kMV1I+uO3l0UOw==", "license": "MIT", "peer": true, "funding": { @@ -5681,6 +5804,8 @@ }, "node_modules/@tanstack/react-query": { "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.44.0.tgz", + "integrity": "sha512-RuIqHYrS98LrK/8kJJOJMMSQ/BCpojwsXDh7p0fBmp38ZOz6dlk+uyFRRusH+V+t3POoCsDOQ2zhomEYOeReXw==", "license": "MIT", "peer": true, "dependencies": { @@ -6048,6 +6173,41 @@ "@types/node": "*" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -6177,6 +6337,8 @@ }, "node_modules/@types/parse-json": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", "license": "MIT", "peer": true }, @@ -6196,6 +6358,8 @@ }, "node_modules/@types/react": { "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "license": "MIT", "peer": true, "dependencies": { @@ -6205,6 +6369,8 @@ }, "node_modules/@types/react-dom": { "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "license": "MIT", "peer": true, "peerDependencies": { @@ -6440,9 +6606,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6457,9 +6620,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6474,9 +6634,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6491,9 +6648,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6508,9 +6662,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6525,9 +6676,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6542,9 +6690,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6559,9 +6704,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6576,9 +6718,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6593,9 +6732,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7083,6 +7219,8 @@ }, "node_modules/babel-plugin-macros": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", "license": "MIT", "peer": true, "dependencies": { @@ -7834,6 +7972,8 @@ }, "node_modules/convert-source-map": { "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "license": "MIT", "peer": true }, @@ -7862,6 +8002,8 @@ }, "node_modules/cosmiconfig": { "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", "license": "MIT", "peer": true, "dependencies": { @@ -7877,6 +8019,8 @@ }, "node_modules/cosmiconfig/node_modules/yaml": { "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "license": "ISC", "peer": true, "engines": { @@ -7954,6 +8098,111 @@ "version": "3.2.3", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "devOptional": true, @@ -8254,6 +8503,8 @@ }, "node_modules/escape-string-regexp": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "license": "MIT", "peer": true, "engines": { @@ -8686,6 +8937,8 @@ }, "node_modules/find-root": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", "license": "MIT", "peer": true }, @@ -9042,6 +9295,8 @@ }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", "license": "BSD-3-Clause", "peer": true, "dependencies": { @@ -9050,6 +9305,8 @@ }, "node_modules/hoist-non-react-statics/node_modules/react-is": { "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT", "peer": true }, @@ -9188,6 +9445,8 @@ }, "node_modules/import-fresh": { "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "license": "MIT", "peer": true, "dependencies": { @@ -10634,9 +10893,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10658,9 +10914,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10682,9 +10935,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10706,9 +10956,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11488,6 +11735,8 @@ }, "node_modules/parent-module": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "license": "MIT", "peer": true, "dependencies": { @@ -11606,6 +11855,8 @@ }, "node_modules/path-type": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "license": "MIT", "peer": true, "engines": { @@ -12176,6 +12427,8 @@ }, "node_modules/resolve-from": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "license": "MIT", "peer": true, "engines": { @@ -12593,6 +12846,8 @@ }, "node_modules/source-map": { "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", "peer": true, "engines": { @@ -12837,6 +13092,8 @@ }, "node_modules/stylis": { "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", "license": "MIT", "peer": true }, diff --git a/pyroscope/src/plugins/pyroscope-profile-query/PyroscopeProfileQuery.ts b/pyroscope/src/plugins/pyroscope-profile-query/PyroscopeProfileQuery.ts index 0b647b680..b955a0e4d 100644 --- a/pyroscope/src/plugins/pyroscope-profile-query/PyroscopeProfileQuery.ts +++ b/pyroscope/src/plugins/pyroscope-profile-query/PyroscopeProfileQuery.ts @@ -12,6 +12,7 @@ // limitations under the License. import { ProfileQueryPlugin } from '@perses-dev/plugin-system'; + import { PyroscopeProfileQuerySpec } from '../../model/profile-query-model'; import { getProfileData } from './get-profile-data'; import { PyroscopeProfileQueryEditor } from './PyroscopeProfileQueryEditor'; From 5fee79409d51eb511bfa25f47376bcfdbc7554f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Fri, 21 Aug 2026 10:41:25 +0200 Subject: [PATCH 4/7] [IGNORE] Refactor edge rendering to use EditorEdgeItem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- canvas/src/components/editor/EditorCanvas.tsx | 47 ++++------ .../src/components/editor/EditorEdgeItem.tsx | 85 +++++++++++++++++++ 2 files changed, 100 insertions(+), 32 deletions(-) create mode 100644 canvas/src/components/editor/EditorEdgeItem.tsx diff --git a/canvas/src/components/editor/EditorCanvas.tsx b/canvas/src/components/editor/EditorCanvas.tsx index a6bd19487..926439b3e 100644 --- a/canvas/src/components/editor/EditorCanvas.tsx +++ b/canvas/src/components/editor/EditorCanvas.tsx @@ -21,11 +21,11 @@ import { useEdgeConnect } from '../../hooks/useEdgeConnect'; import { useNodeMove } from '../../hooks/useNodeMove'; import { useRectSelect } from '../../hooks/useRectSelect'; import { useResize } from '../../hooks/useResize'; -import { AnchorPoint, CanvasSpec, FloatingEdge, isFloatingEdge } from '../../model'; +import { CanvasSpec, FloatingEdge, isFloatingEdge } from '../../model'; import { nodeBoundingBox } from '../../utils/resizeUtils'; import { BackgroundLayer, GlobalBackgroundLayer } from '../shared/BackgroundLayer'; import { DragEdgeLine } from './DragEdgeLine'; -import { EditorEdge } from './EditorEdge'; +import { EditorEdgeItem } from './EditorEdgeItem'; import { EditorNodeItem } from './EditorNodeItem'; import { SelectionBoundingBox } from './SelectionBoundingBox'; import { SelectionRectOverlay } from './SelectionRectOverlay'; @@ -232,36 +232,19 @@ export function EditorCanvas({ /> ))} - {displayEdges.map((edge) => { - const onEdgeClick = (event: PointerEvent): void => { - event.stopPropagation(); - selectItems(new Set([edge.id])); - }; - const onEndpointPointerDown = ( - event: PointerEvent, - end: 'source' | 'target', - fixedX: number, - fixedY: number, - fixedNodeId: string, - fixedAnchor: AnchorPoint, - ): void => { - if (beginEndpointDrag(event, edge.id, end, fixedX, fixedY, fixedNodeId, fixedAnchor)) { - startDragEdge(); - } - }; - return ( - - ); - })} + {displayEdges.map((edge) => ( + + ))} {selectionBoundingBox && ( ; + selectItems: (ids: Set) => void; + beginEndpointDrag: ( + event: PointerEvent, + edgeId: string, + end: 'source' | 'target', + fixedX: number, + fixedY: number, + fixedNodeId: string, + fixedAnchor: AnchorPoint, + ) => boolean; + startDragEdge: () => void; +} + +export const EditorEdgeItem = memo(function EditorEdgeItem({ + edge, + isSelected, + isDragging, + nsPrefix, + nodeById, + selectItems, + beginEndpointDrag, + startDragEdge, +}: EditorEdgeItemProps): ReactElement | null { + const edgeId = edge.id; + + const onEdgeClick = useCallback( + (event: PointerEvent): void => { + event.stopPropagation(); + selectItems(new Set([edgeId])); + }, + [edgeId, selectItems], + ); + + const onEndpointPointerDown = useCallback( + ( + event: PointerEvent, + end: 'source' | 'target', + fixedX: number, + fixedY: number, + fixedNodeId: string, + fixedAnchor: AnchorPoint, + ): void => { + if (beginEndpointDrag(event, edgeId, end, fixedX, fixedY, fixedNodeId, fixedAnchor)) { + startDragEdge(); + } + }, + [edgeId, beginEndpointDrag, startDragEdge], + ); + + return ( + + ); +}); From 46e8d73322cbec53827c09aeef87e7462a6eff6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Fri, 21 Aug 2026 11:10:40 +0200 Subject: [PATCH 5/7] [IGNORE] fix dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- canvas/package.json | 12 +- canvas/src/components/panel/CanvasPanel.tsx | 2 +- .../src/components/panel/PanelEdgeLayer.tsx | 2 +- .../src/components/panel/PanelNodeLayer.tsx | 2 +- .../src/components/panel/ThresholdLegend.tsx | 3 +- canvas/src/model.ts | 4 +- canvas/src/utils/panelUtils.test.ts | 2 +- canvas/src/utils/panelUtils.ts | 4 +- package-lock.json | 176 ++++++++---------- 9 files changed, 89 insertions(+), 118 deletions(-) diff --git a/canvas/package.json b/canvas/package.json index 9d940d88f..0f99944d1 100644 --- a/canvas/package.json +++ b/canvas/package.json @@ -18,20 +18,12 @@ "peerDependencies": { "@emotion/react": "^11.7.1", "@emotion/styled": "^11.6.0", - "@hookform/resolvers": "^3.2.0", "@perses-dev/components": "^0.55.0-beta.1", - "@perses-dev/spec": "^0.3.0-beta.1", - "@perses-dev/dashboards": "^0.55.0-beta.1", - "@perses-dev/explore": "^0.55.0-beta.1", "@perses-dev/plugin-system": "^0.55.0-beta.1", - "@tanstack/react-query": "^4.39.1", - "date-fns": "^4.1.0", - "date-fns-tz": "^3.2.0", - "echarts": "5.5.0", + "@perses-dev/spec": "^0.3.0-beta.1", "immer": "^10.1.1", "react": "^17.0.2 || ^18.0.0", - "react-dom": "^17.0.2 || ^18.0.0", - "use-resize-observer": "^9.0.0" + "react-dom": "^17.0.2 || ^18.0.0" }, "files": [ "lib/**/*", diff --git a/canvas/src/components/panel/CanvasPanel.tsx b/canvas/src/components/panel/CanvasPanel.tsx index 7d80b7f9a..bc0e604d8 100644 --- a/canvas/src/components/panel/CanvasPanel.tsx +++ b/canvas/src/components/panel/CanvasPanel.tsx @@ -12,7 +12,7 @@ // limitations under the License. import { useChartsTheme } from '@perses-dev/components'; -import { TimeSeries } from '@perses-dev/core'; +import { TimeSeries } from '@perses-dev/spec'; import { MouseEvent, ReactElement, useCallback, useMemo } from 'react'; import { useZoomContext, ZoomProvider } from '../../contexts/ZoomContext'; diff --git a/canvas/src/components/panel/PanelEdgeLayer.tsx b/canvas/src/components/panel/PanelEdgeLayer.tsx index 11eacacdc..949a29107 100644 --- a/canvas/src/components/panel/PanelEdgeLayer.tsx +++ b/canvas/src/components/panel/PanelEdgeLayer.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { TimeSeries } from '@perses-dev/core'; +import { TimeSeries } from '@perses-dev/spec'; import { ReactElement } from 'react'; import { useCanvasTheme } from '../../hooks/useCanvasTheme'; diff --git a/canvas/src/components/panel/PanelNodeLayer.tsx b/canvas/src/components/panel/PanelNodeLayer.tsx index c2180ebc7..e2d909908 100644 --- a/canvas/src/components/panel/PanelNodeLayer.tsx +++ b/canvas/src/components/panel/PanelNodeLayer.tsx @@ -11,8 +11,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { TimeSeries } from '@perses-dev/core'; import { replaceVariablesInString, useAllVariableValues } from '@perses-dev/plugin-system'; +import { TimeSeries } from '@perses-dev/spec'; import { ReactElement, useCallback } from 'react'; import { useCanvasTheme } from '../../hooks/useCanvasTheme'; diff --git a/canvas/src/components/panel/ThresholdLegend.tsx b/canvas/src/components/panel/ThresholdLegend.tsx index 2fa60541c..eac6ccc5f 100644 --- a/canvas/src/components/panel/ThresholdLegend.tsx +++ b/canvas/src/components/panel/ThresholdLegend.tsx @@ -12,8 +12,7 @@ // limitations under the License. import { useTheme } from '@mui/material'; -import { FormatOptions, formatValue } from '@perses-dev/components'; -import { ThresholdOptions } from '@perses-dev/core'; +import { FormatOptions, formatValue, ThresholdOptions } from '@perses-dev/components'; import { ReactElement } from 'react'; const SWATCH_SIZE = 12; diff --git a/canvas/src/model.ts b/canvas/src/model.ts index e2aea10d9..b12fea716 100644 --- a/canvas/src/model.ts +++ b/canvas/src/model.ts @@ -11,9 +11,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { FormatOptions } from '@perses-dev/components'; -import { TimeSeriesData, ThresholdOptions } from '@perses-dev/core'; +import { FormatOptions, ThresholdOptions } from '@perses-dev/components'; import { PanelProps, LegendSpecOptions, OptionsEditorProps } from '@perses-dev/plugin-system'; +import { TimeSeriesData } from '@perses-dev/spec'; export type QueryData = TimeSeriesData; diff --git a/canvas/src/utils/panelUtils.test.ts b/canvas/src/utils/panelUtils.test.ts index 03f682ab0..24fda4107 100644 --- a/canvas/src/utils/panelUtils.test.ts +++ b/canvas/src/utils/panelUtils.test.ts @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { TimeSeries } from '@perses-dev/core'; +import { TimeSeries } from '@perses-dev/spec'; import { colorFromThresholds, interpolateLabel, isSafeImageUrl } from './panelUtils'; diff --git a/canvas/src/utils/panelUtils.ts b/canvas/src/utils/panelUtils.ts index 03a5de894..254903459 100644 --- a/canvas/src/utils/panelUtils.ts +++ b/canvas/src/utils/panelUtils.ts @@ -11,8 +11,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { FormatOptions, formatValue } from '@perses-dev/components'; -import { ThresholdOptions, TimeSeries } from '@perses-dev/core'; +import { FormatOptions, formatValue, ThresholdOptions } from '@perses-dev/components'; +import { TimeSeries } from '@perses-dev/spec'; import { BackgroundSpec } from '../model'; diff --git a/package-lock.json b/package-lock.json index 028aa3d76..dd1718a82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -140,20 +140,12 @@ "peerDependencies": { "@emotion/react": "^11.7.1", "@emotion/styled": "^11.6.0", - "@hookform/resolvers": "^3.2.0", "@perses-dev/components": "^0.55.0-beta.1", - "@perses-dev/dashboards": "^0.55.0-beta.1", - "@perses-dev/explore": "^0.55.0-beta.1", "@perses-dev/plugin-system": "^0.55.0-beta.1", "@perses-dev/spec": "^0.3.0-beta.1", - "@tanstack/react-query": "^4.39.1", - "date-fns": "^4.1.0", - "date-fns-tz": "^3.2.0", - "echarts": "5.5.0", "immer": "^10.1.1", "react": "^17.0.2 || ^18.0.0", - "react-dom": "^17.0.2 || ^18.0.0", - "use-resize-observer": "^9.0.0" + "react-dom": "^17.0.2 || ^18.0.0" } }, "clickhouse": { @@ -2454,27 +2446,14 @@ } } }, - "node_modules/@mui/material/node_modules/@mui/private-theming": { - "version": "6.4.9", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.9.tgz", - "integrity": "sha512-LktcVmI5X17/Q5SkwjCcdOLBzt1hXuc14jYa7NPShog0GBDCDvKtcnP0V7a2s6EiVRlv7BzbWEJzH6+l/zaCxw==", + "node_modules/@mui/material/node_modules/@mui/types": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", "license": "MIT", "peer": true, - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/utils": "^6.4.9", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -2482,19 +2461,19 @@ } } }, - "node_modules/@mui/material/node_modules/@mui/styled-engine": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.5.0.tgz", - "integrity": "sha512-8woC2zAqF4qUDSPIBZ8v3sakj+WgweolpyM/FXf8jAx6FMls+IE4Y8VDZc+zS805J7PRz31vz73n2SovKGaYgw==", + "node_modules/@mui/material/node_modules/@mui/utils": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.9.tgz", + "integrity": "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==", "license": "MIT", "peer": true, "dependencies": { "@babel/runtime": "^7.26.0", - "@emotion/cache": "^11.13.5", - "@emotion/serialize": "^1.3.3", - "@emotion/sheet": "^1.4.0", - "csstype": "^3.1.3", - "prop-types": "^15.8.1" + "@mui/types": "~7.2.24", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" }, "engines": { "node": ">=14.0.0" @@ -2504,33 +2483,24 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@emotion/react": "^11.4.1", - "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { + "@types/react": { "optional": true } } }, - "node_modules/@mui/material/node_modules/@mui/system": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.5.0.tgz", - "integrity": "sha512-XcbBYxDS+h/lgsoGe78ExXFZXtuIlSBpn/KsZq8PtZcIkUNJInkuDqcLd2rVBQrDC1u+rvVovdaWPf2FHKJf3w==", + "node_modules/@mui/private-theming": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.9.tgz", + "integrity": "sha512-LktcVmI5X17/Q5SkwjCcdOLBzt1hXuc14jYa7NPShog0GBDCDvKtcnP0V7a2s6EiVRlv7BzbWEJzH6+l/zaCxw==", "license": "MIT", "peer": true, "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/private-theming": "^6.4.9", - "@mui/styled-engine": "^6.5.0", - "@mui/types": "~7.2.24", "@mui/utils": "^6.4.9", - "clsx": "^2.1.1", - "csstype": "^3.1.3", "prop-types": "^15.8.1" }, "engines": { @@ -2541,24 +2511,16 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, "@types/react": { "optional": true } } }, - "node_modules/@mui/material/node_modules/@mui/types": { + "node_modules/@mui/private-theming/node_modules/@mui/types": { "version": "7.2.24", "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", @@ -2573,7 +2535,7 @@ } } }, - "node_modules/@mui/material/node_modules/@mui/utils": { + "node_modules/@mui/private-theming/node_modules/@mui/utils": { "version": "6.4.9", "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.9.tgz", "integrity": "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==", @@ -2604,15 +2566,18 @@ } } }, - "node_modules/@mui/private-theming": { - "version": "7.3.11", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.11.tgz", - "integrity": "sha512-9B+YKms0fRHbNrqp9tOT/DNbNnU5gyvJ1o3qAGXfq8GmZcbJnE3At9x07Zr/o0pkhzg4aDdwXVqe4+AcgtOCPA==", + "node_modules/@mui/styled-engine": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.5.0.tgz", + "integrity": "sha512-8woC2zAqF4qUDSPIBZ8v3sakj+WgweolpyM/FXf8jAx6FMls+IE4Y8VDZc+zS805J7PRz31vz73n2SovKGaYgw==", "license": "MIT", "peer": true, "dependencies": { - "@babel/runtime": "^7.28.6", - "@mui/utils": "^7.3.11", + "@babel/runtime": "^7.26.0", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", "prop-types": "^15.8.1" }, "engines": { @@ -2623,27 +2588,33 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { - "@types/react": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { "optional": true } } }, - "node_modules/@mui/styled-engine": { - "version": "7.3.10", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.10.tgz", - "integrity": "sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==", + "node_modules/@mui/system": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.5.0.tgz", + "integrity": "sha512-XcbBYxDS+h/lgsoGe78ExXFZXtuIlSBpn/KsZq8PtZcIkUNJInkuDqcLd2rVBQrDC1u+rvVovdaWPf2FHKJf3w==", "license": "MIT", "peer": true, "dependencies": { - "@babel/runtime": "^7.28.6", - "@emotion/cache": "^11.14.0", - "@emotion/serialize": "^1.3.3", - "@emotion/sheet": "^1.4.0", - "csstype": "^3.2.3", + "@babel/runtime": "^7.26.0", + "@mui/private-theming": "^6.4.9", + "@mui/styled-engine": "^6.5.0", + "@mui/types": "~7.2.24", + "@mui/utils": "^6.4.9", + "clsx": "^2.1.1", + "csstype": "^3.1.3", "prop-types": "^15.8.1" }, "engines": { @@ -2654,8 +2625,9 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@emotion/react": "^11.4.1", + "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { @@ -2664,24 +2636,40 @@ }, "@emotion/styled": { "optional": true + }, + "@types/react": { + "optional": true } } }, - "node_modules/@mui/system": { - "version": "7.3.11", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.11.tgz", - "integrity": "sha512-7izwGWdNawAKpBKcRlx7f2gFnAAjmASBWvMcyX4YYEeLOFsbfGRbUYGInvnAcUeql3rPxI7F9Ft4oY2OLRz44g==", + "node_modules/@mui/system/node_modules/@mui/types": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/system/node_modules/@mui/utils": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.9.tgz", + "integrity": "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==", "license": "MIT", "peer": true, "dependencies": { - "@babel/runtime": "^7.28.6", - "@mui/private-theming": "^7.3.11", - "@mui/styled-engine": "^7.3.10", - "@mui/types": "^7.4.12", - "@mui/utils": "^7.3.11", + "@babel/runtime": "^7.26.0", + "@mui/types": "~7.2.24", + "@types/prop-types": "^15.7.14", "clsx": "^2.1.1", - "csstype": "^3.2.3", - "prop-types": "^15.8.1" + "prop-types": "^15.8.1", + "react-is": "^19.0.0" }, "engines": { "node": ">=14.0.0" @@ -2691,18 +2679,10 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, "@types/react": { "optional": true } From 7c250fbef4b7efe1cf34b6516fc2ec564fab7c46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Fri, 21 Aug 2026 13:25:45 +0200 Subject: [PATCH 6/7] [IGNORE] apply code best practices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- .../editor/BackgroundPropertiesPanel.tsx | 101 +++++--- .../components/editor/ConnectionHandles.tsx | 16 +- canvas/src/components/editor/DragEdgeLine.tsx | 26 +-- .../components/editor/EdgePropertiesPanel.tsx | 44 ++-- canvas/src/components/editor/EditorCanvas.tsx | 41 ++-- canvas/src/components/editor/EditorEdge.tsx | 62 +++-- .../components/editor/EditorItemsPanel.tsx | 44 ++-- canvas/src/components/editor/EditorNode.tsx | 28 ++- .../components/editor/NodePropertiesPanel.tsx | 169 ++++++++++---- .../editor/SelectionBoundingBox.tsx | 21 +- .../editor/SelectionRectOverlay.tsx | 6 +- canvas/src/components/panel/CanvasPanel.tsx | 19 +- .../src/components/panel/PanelEdgeLayer.tsx | 16 +- .../src/components/panel/PanelNodeLayer.tsx | 13 +- .../src/components/panel/ThresholdLegend.tsx | 28 ++- .../settings/EdgeThicknessSettings.tsx | 57 +++-- .../settings/GlobalSettingsEditor.tsx | 29 ++- .../components/settings/LegendSettings.tsx | 4 +- .../src/components/shared/BackgroundLayer.tsx | 18 +- canvas/src/components/shared/EdgeLines.tsx | 6 +- canvas/src/components/shared/IconNode.tsx | 13 +- .../src/components/shared/RectangleNode.tsx | 21 +- canvas/src/components/shared/TextNode.tsx | 7 +- canvas/src/contexts/EditorContext.tsx | 56 +++-- canvas/src/contexts/SpecContext.tsx | 216 ++++++++++-------- canvas/src/hooks/useCanvasTheme.ts | 32 +-- canvas/src/setup-tests.ts | 1 + canvas/src/test-utils/hookWrapper.tsx | 44 ++-- 28 files changed, 711 insertions(+), 427 deletions(-) diff --git a/canvas/src/components/editor/BackgroundPropertiesPanel.tsx b/canvas/src/components/editor/BackgroundPropertiesPanel.tsx index 785556d9b..dd828865b 100644 --- a/canvas/src/components/editor/BackgroundPropertiesPanel.tsx +++ b/canvas/src/components/editor/BackgroundPropertiesPanel.tsx @@ -20,6 +20,7 @@ import { InputLabel, MenuItem, Select, + SelectChangeEvent, Slider, Stack, TextField, @@ -35,6 +36,18 @@ import { useSpecContext } from '../../contexts/SpecContext'; import { useCanvasTheme } from '../../hooks/useCanvasTheme'; import { BackgroundSpec, CanvasSpec } from '../../model'; +const IMAGE_FIT_OPTIONS: Array = ['cover', 'contain', 'stretch']; + +function parseImageFit(value: string): BackgroundSpec['imageFit'] { + return IMAGE_FIT_OPTIONS.includes(value as BackgroundSpec['imageFit']) + ? (value as BackgroundSpec['imageFit']) + : undefined; +} + +function formatOpacityLabel(v: number): string { + return `${Math.round(v * 100)}%`; +} + interface BackgroundPropertiesPanelProps { background: BackgroundSpec; onChange: (updated: BackgroundSpec) => void; @@ -58,13 +71,59 @@ export function BackgroundPropertiesPanel({ background, onChange }: BackgroundPr [background, onChange], ); - const IMAGE_FIT_OPTIONS: Array = ['cover', 'contain', 'stretch']; + const onGlobalChange = useCallback( + (e: React.ChangeEvent): void => { + onChange({ ...background, global: e.target.checked || undefined }); + }, + [background, onChange], + ); + + const onNameChange = useCallback( + (e: React.ChangeEvent): void => { + onChange({ ...background, name: e.target.value || undefined }); + }, + [background, onChange], + ); + + const onColorChange = useCallback( + (color: string): void => { + onChange({ ...background, color }); + }, + [background, onChange], + ); + + const onColorClear = useCallback((): void => { + onChange({ ...background, color: undefined }); + }, [background, onChange]); - function parseImageFit(value: string): BackgroundSpec['imageFit'] { - return IMAGE_FIT_OPTIONS.includes(value as BackgroundSpec['imageFit']) - ? (value as BackgroundSpec['imageFit']) - : undefined; - } + const onOpacityChange = useCallback( + (_: Event, v: number | number[]): void => { + onChange({ ...background, opacity: Array.isArray(v) ? v[0] : v }); + }, + [background, onChange], + ); + + const onImageUrlChange = useCallback( + (e: React.ChangeEvent): void => { + onChange({ ...background, image: e.target.value || undefined }); + }, + [background, onChange], + ); + + const onImageFitChange = useCallback( + (e: SelectChangeEvent): void => { + onChange({ ...background, imageFit: parseImageFit(e.target.value ?? '') }); + }, + [background, onChange], + ); + + const onMoveUp = useCallback((): void => { + moveBackground(background.id, 'up'); + }, [background.id, moveBackground]); + + const onMoveDown = useCallback((): void => { + moveBackground(background.id, 'down'); + }, [background.id, moveBackground]); return ( @@ -74,18 +133,14 @@ export function BackgroundPropertiesPanel({ background, onChange }: BackgroundPr - moveBackground(background.id, 'up')}> + - = backgrounds.length - 1} - onClick={() => moveBackground(background.id, 'down')} - > + = backgrounds.length - 1} onClick={onMoveDown}> @@ -93,13 +148,7 @@ export function BackgroundPropertiesPanel({ background, onChange }: BackgroundPr onChange({ ...background, global: e.target.checked || undefined })} - /> - } + control={} label="Global (fit panel)" /> @@ -107,7 +156,7 @@ export function BackgroundPropertiesPanel({ background, onChange }: BackgroundPr label="Name" size="small" value={background.name ?? ''} - onChange={(e) => onChange({ ...background, name: e.target.value || undefined })} + onChange={onNameChange} placeholder={background.id} /> @@ -160,8 +209,8 @@ export function BackgroundPropertiesPanel({ background, onChange }: BackgroundPr onChange({ ...background, color })} - onClear={() => onChange({ ...background, color: undefined })} + onColorChange={onColorChange} + onClear={onColorClear} /> @@ -173,9 +222,9 @@ export function BackgroundPropertiesPanel({ background, onChange }: BackgroundPr max={1} step={0.05} value={background.opacity ?? 1} - onChange={(_, v) => onChange({ ...background, opacity: Array.isArray(v) ? v[0] : v })} + onChange={onOpacityChange} valueLabelDisplay="auto" - valueLabelFormat={(v) => `${Math.round(v * 100)}%`} + valueLabelFormat={formatOpacityLabel} sx={{ pr: 2 }} /> @@ -185,7 +234,7 @@ export function BackgroundPropertiesPanel({ background, onChange }: BackgroundPr label="Image URL" size="small" value={background.image ?? ''} - onChange={(e) => onChange({ ...background, image: e.target.value || undefined })} + onChange={onImageUrlChange} sx={{ flex: 1 }} /> @@ -193,7 +242,7 @@ export function BackgroundPropertiesPanel({ background, onChange }: BackgroundPr label="Image fit" value={background.imageFit ?? 'cover'} - onChange={(e) => onChange({ ...background, imageFit: parseImageFit(e.target.value ?? '') })} + onChange={onImageFitChange} MenuProps={{ PaperProps: { style: { maxHeight: 240 } } }} > Cover diff --git a/canvas/src/components/editor/ConnectionHandles.tsx b/canvas/src/components/editor/ConnectionHandles.tsx index 363beca10..dcab2125a 100644 --- a/canvas/src/components/editor/ConnectionHandles.tsx +++ b/canvas/src/components/editor/ConnectionHandles.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { ReactElement } from 'react'; +import { ReactElement, useCallback } from 'react'; import { useCanvasTheme } from '../../hooks/useCanvasTheme'; import { NodeSpec, AnchorPoint } from '../../model'; @@ -28,6 +28,15 @@ export function ConnectionHandles({ node, onDragStart }: ConnectionHandlesProps) const { connection } = useCanvasTheme(); const armLen = CROSS_LENGTH; + const makePointerDownHandler = useCallback( + (anchor: AnchorPoint, x: number, y: number) => + (event: React.PointerEvent): void => { + event.stopPropagation(); + onDragStart(anchor, x, y); + }, + [onDragStart], + ); + return ( <> {ANCHOR_KEYS.map((anchor) => { @@ -37,10 +46,7 @@ export function ConnectionHandles({ node, onDragStart }: ConnectionHandlesProps) key={anchor} transform={`translate(${pos.x},${pos.y})`} style={{ cursor: 'crosshair' }} - onPointerDown={(event) => { - event.stopPropagation(); - onDragStart(anchor, pos.x, pos.y); - }} + onPointerDown={makePointerDownHandler(anchor, pos.x, pos.y)} > diff --git a/canvas/src/components/editor/DragEdgeLine.tsx b/canvas/src/components/editor/DragEdgeLine.tsx index 51fffc54d..c87680965 100644 --- a/canvas/src/components/editor/DragEdgeLine.tsx +++ b/canvas/src/components/editor/DragEdgeLine.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { ReactElement } from 'react'; +import { ReactElement, useMemo } from 'react'; import { useZoomContext } from '../../contexts/ZoomContext'; import { useCanvasTheme } from '../../hooks/useCanvasTheme'; @@ -30,17 +30,17 @@ export function DragEdgeLine({ dragEdge }: DragEdgeLineProps): ReactElement { transform: { k }, } = useZoomContext(); const theme = editorStyles(useCanvasTheme(), k); - const pts = { x1: dragEdge.x1, y1: dragEdge.y1, x2: dragEdge.x2, y2: dragEdge.y2 }; - return ( - + const pts = useMemo( + () => ({ x1: dragEdge.x1, y1: dragEdge.y1, x2: dragEdge.x2, y2: dragEdge.y2 }), + [dragEdge.x1, dragEdge.y1, dragEdge.x2, dragEdge.y2], ); + const fwdStyle = useMemo( + () => ({ stroke: theme.dragEdge.stroke, strokeWidth: theme.dragEdge.strokeWidth }), + [theme.dragEdge.stroke, theme.dragEdge.strokeWidth], + ); + const lineProps = useMemo( + () => ({ strokeDasharray: theme.dragEdge.strokeDasharray, style: { pointerEvents: 'none' } as const }), + [theme.dragEdge.strokeDasharray], + ); + return ; } diff --git a/canvas/src/components/editor/EdgePropertiesPanel.tsx b/canvas/src/components/editor/EdgePropertiesPanel.tsx index 738c9d287..00aae1305 100644 --- a/canvas/src/components/editor/EdgePropertiesPanel.tsx +++ b/canvas/src/components/editor/EdgePropertiesPanel.tsx @@ -18,6 +18,7 @@ import React, { ReactElement, useCallback, useMemo } from 'react'; import { AnchorPoint, EdgeSpec, NodeSpec } from '../../model'; const ANCHOR_OPTIONS: AnchorPoint[] = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw']; +const SELECT_SLOT_PROPS = { select: { MenuProps: { PaperProps: { style: { maxHeight: 240 } } } } } as const; interface EdgePropertiesPanelProps { edge: EdgeSpec; @@ -30,7 +31,14 @@ export function EdgePropertiesPanel({ edge, nodes, onChange }: EdgePropertiesPan const { queryDefinitions } = useDataQueriesContext(); const queryCount = queryDefinitions.length; const queryNames = useMemo(() => generateQueryNames(queryDefinitions), [queryDefinitions]); - const queryIndexes = Array.from({ length: queryCount }, (_, i) => i); + const queryIndexes = useMemo(() => Array.from({ length: queryCount }, (_, i) => i), [queryCount]); + + const onNameChange = useCallback( + (e: React.ChangeEvent): void => { + onChange({ ...edge, name: e.target.value || undefined }); + }, + [edge, onChange], + ); const onSourceChange = useCallback( (e: React.ChangeEvent): void => { @@ -118,17 +126,13 @@ export function EdgePropertiesPanel({ edge, nodes, onChange }: EdgePropertiesPan [edge, onChange], ); + const strokeWidthSlotProps = useMemo(() => ({ htmlInput: { min: 1, step: 1 } }), []); + return ( Edge properties - onChange({ ...edge, name: e.target.value || undefined })} - /> + {nodes.map((n) => ( @@ -151,7 +155,7 @@ export function EdgePropertiesPanel({ edge, nodes, onChange }: EdgePropertiesPan size="small" value={edge.sourceAnchor ?? 'n'} onChange={onSourceAnchorChange} - slotProps={{ select: { MenuProps: { PaperProps: { style: { maxHeight: 240 } } } } }} + slotProps={SELECT_SLOT_PROPS} > {ANCHOR_OPTIONS.map((a) => ( @@ -166,7 +170,7 @@ export function EdgePropertiesPanel({ edge, nodes, onChange }: EdgePropertiesPan size="small" value={edge.target} onChange={onTargetChange} - slotProps={{ select: { MenuProps: { PaperProps: { style: { maxHeight: 240 } } } } }} + slotProps={SELECT_SLOT_PROPS} > {nodes.map((n) => ( @@ -182,7 +186,7 @@ export function EdgePropertiesPanel({ edge, nodes, onChange }: EdgePropertiesPan value={edge.targetAnchor ?? 'n'} disabled={hasFreeTarget} onChange={onTargetAnchorChange} - slotProps={{ select: { MenuProps: { PaperProps: { style: { maxHeight: 240 } } } } }} + slotProps={SELECT_SLOT_PROPS} > {ANCHOR_OPTIONS.map((a) => ( @@ -202,23 +206,23 @@ export function EdgePropertiesPanel({ edge, nodes, onChange }: EdgePropertiesPan size="small" value={edge.thicknessMode ?? 'fixed'} onChange={onThicknessModeChange} - slotProps={{ select: { MenuProps: { PaperProps: { style: { maxHeight: 240 } } } } }} + slotProps={SELECT_SLOT_PROPS} > Fixed Threshold - {(edge.thicknessMode ?? 'fixed') === 'fixed' && ( + {(edge.thicknessMode ?? 'fixed') === 'fixed' ? ( - )} + ) : null} None @@ -246,7 +250,7 @@ export function EdgePropertiesPanel({ edge, nodes, onChange }: EdgePropertiesPan helperText="Use {{value}} to show query result" /> - {edge.bidirectional && ( + {edge.bidirectional ? ( <> None @@ -274,7 +278,7 @@ export function EdgePropertiesPanel({ edge, nodes, onChange }: EdgePropertiesPan helperText="Use {{value}} to show query result" /> - )} + ) : null} ); } diff --git a/canvas/src/components/editor/EditorCanvas.tsx b/canvas/src/components/editor/EditorCanvas.tsx index 926439b3e..fde982016 100644 --- a/canvas/src/components/editor/EditorCanvas.tsx +++ b/canvas/src/components/editor/EditorCanvas.tsx @@ -22,7 +22,7 @@ import { useNodeMove } from '../../hooks/useNodeMove'; import { useRectSelect } from '../../hooks/useRectSelect'; import { useResize } from '../../hooks/useResize'; import { CanvasSpec, FloatingEdge, isFloatingEdge } from '../../model'; -import { nodeBoundingBox } from '../../utils/resizeUtils'; +import { nodeBoundingBox, ResizeHandleId } from '../../utils/resizeUtils'; import { BackgroundLayer, GlobalBackgroundLayer } from '../shared/BackgroundLayer'; import { DragEdgeLine } from './DragEdgeLine'; import { EditorEdgeItem } from './EditorEdgeItem'; @@ -80,6 +80,7 @@ export function EditorCanvas({ }, [mode, spec, applyMove, applyResize, applyEdgeDrag]); const displayNodes = useMemo(() => unsavedSpec.nodes ?? [], [unsavedSpec.nodes]); const displayEdges = useMemo(() => unsavedSpec.edges ?? [], [unsavedSpec.edges]); + const displayBackgrounds = useMemo(() => unsavedSpec.backgrounds ?? [], [unsavedSpec.backgrounds]); const nodeById = useMemo(() => new Map(displayNodes.map((n) => [n.id, n])), [displayNodes]); const selectionBoundingBox = useMemo(() => { @@ -95,6 +96,17 @@ export function EditorCanvas({ : null; }, [displayEdges, displayNodes, mode.type, selectedIds]); + const svgStyle = useMemo( + () => ({ + display: 'block' as const, + cursor: mode.type === 'dragging-edge' ? 'crosshair' : 'default', + border: '1px solid', + borderColor: 'divider', + outline: 'none', + }), + [mode.type], + ); + useLayoutEffect(() => { if (displayNodes.length === 0) { return; @@ -189,6 +201,15 @@ export function EditorCanvas({ [selectedIds, deleteSelected], ); + const onResizeHandlePointerDown = useCallback( + (event: PointerEvent, handleId: ResizeHandleId): void => { + if (beginResize(event, handleId)) { + startResize(); + } + }, + [beginResize, startResize], + ); + return ( <> {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} @@ -197,22 +218,16 @@ export function EditorCanvas({ role="application" width={width} height={height} - style={{ - display: 'block', - cursor: mode.type === 'dragging-edge' ? 'crosshair' : 'default', - border: '1px solid', - borderColor: 'divider', - outline: 'none', - }} + style={svgStyle} onDoubleClick={onSvgDoubleClick} onKeyDown={onKeyDown} onPointerDown={onSvgPointerDown} onPointerMove={onSvgPointerMove} onPointerUp={onSvgPointerUp} > - + - + {displayNodes.map((node) => ( { - if (beginResize(event, handleId)) { - startResize(); - } - }} + onResizeHandlePointerDown={onResizeHandlePointerDown} /> )} diff --git a/canvas/src/components/editor/EditorEdge.tsx b/canvas/src/components/editor/EditorEdge.tsx index f12dbebd5..8efa32540 100644 --- a/canvas/src/components/editor/EditorEdge.tsx +++ b/canvas/src/components/editor/EditorEdge.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { PointerEvent, ReactElement } from 'react'; +import { PointerEvent, ReactElement, useCallback, useMemo } from 'react'; import { useZoomContext } from '../../contexts/ZoomContext'; import { useCanvasTheme } from '../../hooks/useCanvasTheme'; @@ -20,6 +20,11 @@ import { edgeEndpoints } from '../../utils/edgeUtils'; import { editorStyles } from '../../utils/editorStyles'; import { EdgeLines, LineStyle } from '../shared/EdgeLines'; +const POINTER_STYLE = { pointerEvents: 'none' } as const; +const LINE_PROPS = { style: POINTER_STYLE }; +const CURSOR_POINTER = { cursor: 'pointer' } as const; +const CURSOR_GRAB = { cursor: 'grab' } as const; + interface EditorEdgeProps { edge: EdgeSpec; isSelected: boolean; @@ -51,22 +56,39 @@ export function EditorEdge({ } = useZoomContext(); const theme = editorStyles(useCanvasTheme(), k); const pts = edgeEndpoints(edge, nodeById); - if (!pts) { - return null; - } + const srcAnchor: AnchorPoint = edge.sourceAnchor ?? 'n'; const tgtAnchor: AnchorPoint = edge.targetAnchor ?? 'n'; - const rawStyle = isSelected ? theme.edgeSelected : theme.edge; - const lineStyle: LineStyle = { - stroke: rawStyle.stroke, - strokeWidth: rawStyle.strokeWidth, - strokeOpacity: rawStyle.strokeOpacity, - }; + const lineStyle: LineStyle = useMemo(() => { + const rawStyle = isSelected ? theme.edgeSelected : theme.edge; + return { + stroke: rawStyle.stroke, + strokeWidth: rawStyle.strokeWidth, + strokeOpacity: rawStyle.strokeOpacity, + }; + }, [isSelected, theme]); - if (isDragging && isSelected) { + const onSourcePointerDown = useCallback( + (event: PointerEvent): void => { + if (!pts) return; + onEndpointPointerDown(event, 'source', pts.x2, pts.y2, edge.target || edge.source, tgtAnchor); + }, + [onEndpointPointerDown, pts, edge.target, edge.source, tgtAnchor], + ); + + const onTargetPointerDown = useCallback( + (event: PointerEvent): void => { + if (!pts) return; + onEndpointPointerDown(event, 'target', pts.x1, pts.y1, edge.source, srcAnchor); + }, + [onEndpointPointerDown, pts, edge.source, srcAnchor], + ); + + if (!pts || (isDragging && isSelected)) { return null; } + return ( - {isSelected && !isDragging && ( + {isSelected && !isDragging ? ( <> - onEndpointPointerDown(event, 'source', pts.x2, pts.y2, edge.target || edge.source, tgtAnchor) - } + style={CURSOR_GRAB} + onPointerDown={onSourcePointerDown} /> onEndpointPointerDown(event, 'target', pts.x1, pts.y1, edge.source, srcAnchor)} + style={CURSOR_GRAB} + onPointerDown={onTargetPointerDown} /> - )} + ) : null} ); } diff --git a/canvas/src/components/editor/EditorItemsPanel.tsx b/canvas/src/components/editor/EditorItemsPanel.tsx index 699393996..f345ff6ae 100644 --- a/canvas/src/components/editor/EditorItemsPanel.tsx +++ b/canvas/src/components/editor/EditorItemsPanel.tsx @@ -21,7 +21,7 @@ import { Select, SelectChangeEvent, } from '@mui/material'; -import { ReactElement, useCallback, useRef } from 'react'; +import { ReactElement, useCallback, useMemo, useRef } from 'react'; import { useEditorContext } from '../../contexts/EditorContext'; import { useSpecContext } from '../../contexts/SpecContext'; @@ -60,14 +60,14 @@ export function EditorItemsPanel(): ReactElement { const selectedBackground = selectedIds.size === 1 && firstSelectedId ? (backgroundById.get(firstSelectedId) ?? null) : null; - function onAddNode(): void { + const onAddNode = useCallback((): void => { const canvasWidth = containerRef.current?.clientWidth ?? 0; const cx = transform.invertX(canvasWidth / 2); const cy = transform.invertY(CANVAS_HEIGHT / 2); addNode(cx, cy); - } + }, [transform, addNode]); - function onAddBackground(): void { + const onAddBackground = useCallback((): void => { const canvasWidth = containerRef.current?.clientWidth ?? 0; const k = transform.k > 0 ? transform.k : 1; const width = canvasWidth > 0 ? canvasWidth / k : 200; @@ -75,7 +75,7 @@ export function EditorItemsPanel(): ReactElement { const x = transform.invertX(0); const y = transform.invertY(0); addBackground(x, y, width, height); - } + }, [transform, addBackground]); const onItemSelect = useCallback( (event: SelectChangeEvent): void => { @@ -88,12 +88,20 @@ export function EditorItemsPanel(): ReactElement { const hasBackgrounds = (spec.backgrounds?.length ?? 0) > 0; const hasNodes = (spec.nodes?.length ?? 0) > 0; const hasEdges = (spec.edges?.length ?? 0) > 0; + const specNodes = useMemo(() => spec.nodes ?? [], [spec.nodes]); + + const zoomValue = useMemo( + () => ({ toCanvasPoint, transform, fitView, resetPan }), + [toCanvasPoint, transform, fitView, resetPan], + ); + + const canvasWidth = containerRef.current?.clientWidth ?? 0; return ( - - + + @@ -109,19 +117,19 @@ export function EditorItemsPanel(): ReactElement { None - {hasBackgrounds && Backgrounds} + {hasBackgrounds ? Backgrounds : null} {spec.backgrounds?.map((bg) => ( {bg.name ?? bg.id} ))} - {hasNodes && Nodes} + {hasNodes ? Nodes : null} {spec.nodes?.map((n) => ( {n.label ?? n.id} ))} - {hasEdges && Edges} + {hasEdges ? Edges : null} {spec.edges?.map((ed) => ( {ed.name ?? ed.id} @@ -147,14 +155,14 @@ export function EditorItemsPanel(): ReactElement { - {selectedNode && } - {selectedEdge && ( - - )} - {selectedBackground && ( + {selectedNode ? : null} + {selectedEdge ? ( + + ) : null} + {selectedBackground ? ( - )} - {!selectedNode && !selectedEdge && !selectedBackground && ( + ) : null} + {!selectedNode && !selectedEdge && !selectedBackground ? ( Select a node or edge to edit its properties - )} + ) : null} ); diff --git a/canvas/src/components/editor/EditorNode.tsx b/canvas/src/components/editor/EditorNode.tsx index 5f46dd901..1ad697035 100644 --- a/canvas/src/components/editor/EditorNode.tsx +++ b/canvas/src/components/editor/EditorNode.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { PointerEvent, ReactElement } from 'react'; +import { PointerEvent, ReactElement, useMemo } from 'react'; import { useZoomContext } from '../../contexts/ZoomContext'; import { useCanvasTheme } from '../../hooks/useCanvasTheme'; @@ -47,19 +47,23 @@ export function EditorNode({ }: EditorNodeProps): ReactElement { const wmTheme = useCanvasTheme(); const theme = editorStyles(wmTheme, useZoomContext().transform.k); + + const rectProps = useMemo( + () => ({ + style: { cursor: 'move' } as const, + ...(snapTarget ? theme.nodeSnap : theme.nodeDefault), + onPointerDown, + onPointerMove, + }), + [snapTarget, theme.nodeSnap, theme.nodeDefault, onPointerDown, onPointerMove], + ); + return ( - - {isHovered && !isSelected && !isDragging && } + + {isHovered && !isSelected && !isDragging ? ( + + ) : null} ); } diff --git a/canvas/src/components/editor/NodePropertiesPanel.tsx b/canvas/src/components/editor/NodePropertiesPanel.tsx index b504f6b08..3d6359cf7 100644 --- a/canvas/src/components/editor/NodePropertiesPanel.tsx +++ b/canvas/src/components/editor/NodePropertiesPanel.tsx @@ -21,6 +21,8 @@ import { NodeSpec } from '../../model'; import { ICON_NAMES } from '../../utils/icons'; import { IconPreview } from './IconPreview'; +const SELECT_SLOT_PROPS = { select: { MenuProps: { PaperProps: { style: { maxHeight: 240 } } } } } as const; + interface NodePropertiesPanelProps { node: NodeSpec; onChange: (updated: NodeSpec) => void; @@ -31,7 +33,7 @@ export function NodePropertiesPanel({ node, onChange }: NodePropertiesPanelProps const { nodeDefaultFill } = useCanvasTheme(); const queryCount = queryDefinitions.length; const queryNames = useMemo(() => generateQueryNames(queryDefinitions), [queryDefinitions]); - const queryIndexes = Array.from({ length: queryCount }, (_, i) => i); + const queryIndexes = useMemo(() => Array.from({ length: queryCount }, (_, i) => i), [queryCount]); const shape = node.kind; const onIntFieldChange = useCallback( @@ -47,6 +49,101 @@ export function NodePropertiesPanel({ node, onChange }: NodePropertiesPanelProps [node, onChange], ); + const onKindChange = useCallback( + (e: React.ChangeEvent): void => { + onChange({ ...node, kind: e.target.value as NodeSpec['kind'] }); + }, + [node, onChange], + ); + + const onIconChange = useCallback( + (_: React.SyntheticEvent, newIcon: string | null): void => { + onChange({ ...node, icon: newIcon ?? undefined }); + }, + [node, onChange], + ); + + const onBackgroundImageChange = useCallback( + (e: React.ChangeEvent): void => { + onChange({ ...node, backgroundImage: e.target.value || undefined }); + }, + [node, onChange], + ); + + const onLinkChange = useCallback( + (e: React.ChangeEvent): void => { + onChange({ ...node, link: e.target.value || undefined }); + }, + [node, onChange], + ); + + const onLabelChange = useCallback( + (e: React.ChangeEvent): void => { + onChange({ ...node, label: e.target.value || undefined }); + }, + [node, onChange], + ); + + const onLabelPositionChange = useCallback( + (e: React.ChangeEvent): void => { + onChange({ ...node, labelPosition: e.target.value as NodeSpec['labelPosition'] }); + }, + [node, onChange], + ); + + const onQueryIndexChange = useCallback( + (e: React.ChangeEvent): void => { + const v = e.target.value; + onChange({ ...node, queryIndex: v === '' ? undefined : Number(v) }); + }, + [node, onChange], + ); + + const onColorModeChange = useCallback( + (e: React.ChangeEvent): void => { + const v = e.target.value as '' | 'threshold' | 'fixed'; + onChange({ ...node, colorMode: v === '' ? undefined : v }); + }, + [node, onChange], + ); + + const onColorChange = useCallback( + (color: string): void => { + onChange({ ...node, color }); + }, + [node, onChange], + ); + + const onColorClear = useCallback((): void => { + onChange({ ...node, color: undefined }); + }, [node, onChange]); + + const isOptionEqualToValue = useCallback((option: string, value: string) => option === value, []); + + const renderInput = useCallback( + (params: object) => )} label="Icon" size="small" />, + [], + ); + + const renderOption = useCallback( + (props: React.HTMLAttributes, name: string) => ( + + + {name} + + ), + [], + ); + + const colorBoxSx = useMemo( + () => ({ + flexShrink: 0, + opacity: node.colorMode !== 'fixed' ? 0.38 : 1, + pointerEvents: node.colorMode !== 'fixed' ? ('none' as const) : ('auto' as const), + }), + [node.colorMode], + ); + return ( Node properties @@ -88,51 +185,39 @@ export function NodePropertiesPanel({ node, onChange }: NodePropertiesPanelProps /> - onChange({ ...node, kind: e.target.value as NodeSpec['kind'] })} - slotProps={{ select: { MenuProps: { PaperProps: { style: { maxHeight: 240 } } } } }} - > + Rectangle Icon Text - {shape !== 'text' && ( + {shape !== 'text' ? ( onChange({ ...node, icon: newIcon ?? undefined })} - renderInput={(params) => } - renderOption={(props, name) => ( - - - {name} - - )} - isOptionEqualToValue={(option, value) => option === value} + onChange={onIconChange} + renderInput={renderInput} + renderOption={renderOption} + isOptionEqualToValue={isOptionEqualToValue} clearOnEscape size="small" /> - )} + ) : null} - {shape === 'rectangle' && ( + {shape === 'rectangle' ? ( onChange({ ...node, backgroundImage: e.target.value || undefined })} + onChange={onBackgroundImageChange} /> - )} + ) : null} onChange({ ...node, link: e.target.value || undefined })} + onChange={onLinkChange} helperText="Navigate to this URL on click. Use ${varName} for dashboard variables." /> @@ -140,19 +225,19 @@ export function NodePropertiesPanel({ node, onChange }: NodePropertiesPanelProps label="Label" size="small" value={node.label ?? ''} - onChange={(e) => onChange({ ...node, label: e.target.value || undefined })} + onChange={onLabelChange} helperText="Use {{label_name}} or {{value}} to interpolate query data" /> - {shape !== 'text' && ( + {shape !== 'text' ? ( onChange({ ...node, labelPosition: e.target.value as NodeSpec['labelPosition'] })} - slotProps={{ select: { MenuProps: { PaperProps: { style: { maxHeight: 240 } } } } }} + onChange={onLabelPositionChange} + slotProps={SELECT_SLOT_PROPS} sx={{ flex: 1 }} > Below @@ -172,18 +257,15 @@ export function NodePropertiesPanel({ node, onChange }: NodePropertiesPanelProps sx={{ width: 100 }} /> - )} + ) : null} { - const v = e.target.value; - onChange({ ...node, queryIndex: v === '' ? undefined : Number(v) }); - }} - slotProps={{ select: { MenuProps: { PaperProps: { style: { maxHeight: 240 } } } } }} + onChange={onQueryIndexChange} + slotProps={SELECT_SLOT_PROPS} sx={{ minWidth: 120 }} > @@ -202,11 +284,8 @@ export function NodePropertiesPanel({ node, onChange }: NodePropertiesPanelProps label="Color mode" size="small" value={node.colorMode ?? ''} - onChange={(e) => { - const v = e.target.value as '' | 'threshold' | 'fixed'; - onChange({ ...node, colorMode: v === '' ? undefined : v }); - }} - slotProps={{ select: { MenuProps: { PaperProps: { style: { maxHeight: 240 } } } } }} + onChange={onColorModeChange} + slotProps={SELECT_SLOT_PROPS} sx={{ flex: 1 }} > @@ -216,18 +295,12 @@ export function NodePropertiesPanel({ node, onChange }: NodePropertiesPanelProps Fixed - + onChange({ ...node, color })} - onClear={() => onChange({ ...node, color: undefined })} + onColorChange={onColorChange} + onClear={onColorClear} /> diff --git a/canvas/src/components/editor/SelectionBoundingBox.tsx b/canvas/src/components/editor/SelectionBoundingBox.tsx index f8be54be8..0cafcec15 100644 --- a/canvas/src/components/editor/SelectionBoundingBox.tsx +++ b/canvas/src/components/editor/SelectionBoundingBox.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { PointerEvent, ReactElement } from 'react'; +import { PointerEvent, ReactElement, useCallback } from 'react'; import { useZoomContext } from '../../contexts/ZoomContext'; import { useCanvasTheme } from '../../hooks/useCanvasTheme'; @@ -25,6 +25,11 @@ import { ResizeHandleId, } from '../../utils/resizeUtils'; +const NO_POINTER_EVENTS = { pointerEvents: 'none' } as const; +const HANDLE_CURSOR_STYLES = Object.fromEntries( + RESIZE_HANDLE_IDS.map((h) => [h, { cursor: RESIZE_CURSORS[h] }]), +) as Record; + interface SelectionBoundingBoxProps { boundingBox: BoundingBox; onResizeHandlePointerDown: (event: PointerEvent, handleId: ResizeHandleId) => void; @@ -45,9 +50,17 @@ export function SelectionBoundingBox({ const bh = boundingBox.maxY - boundingBox.minY + pad * 2; const paddedBoundingBox: BoundingBox = { minX: bx, minY: by, maxX: bx + bw, maxY: by + bh }; + const makeHandlerPointerDown = useCallback( + (h: ResizeHandleId) => + (event: PointerEvent): void => { + onResizeHandlePointerDown(event, h); + }, + [onResizeHandlePointerDown], + ); + return ( - + {RESIZE_HANDLE_IDS.map((h) => { const pos = handlePosition(paddedBoundingBox, h); return ( @@ -56,8 +69,8 @@ export function SelectionBoundingBox({ cx={pos.x} cy={pos.y} {...theme.resizeHandle} - style={{ cursor: RESIZE_CURSORS[h] }} - onPointerDown={(event) => onResizeHandlePointerDown(event, h)} + style={HANDLE_CURSOR_STYLES[h]} + onPointerDown={makeHandlerPointerDown(h)} /> ); })} diff --git a/canvas/src/components/editor/SelectionRectOverlay.tsx b/canvas/src/components/editor/SelectionRectOverlay.tsx index 45a96db02..5cfbb94ad 100644 --- a/canvas/src/components/editor/SelectionRectOverlay.tsx +++ b/canvas/src/components/editor/SelectionRectOverlay.tsx @@ -18,6 +18,8 @@ import { useCanvasTheme } from '../../hooks/useCanvasTheme'; import { SelectionRect } from '../../hooks/useRectSelect'; import { editorStyles } from '../../utils/editorStyles'; +const NO_POINTER_EVENTS = { pointerEvents: 'none' } as const; + interface SelectionRectOverlayProps { rect: SelectionRect; } @@ -31,7 +33,5 @@ export function SelectionRectOverlay({ rect }: SelectionRectOverlayProps): React const minY = Math.min(rect.y0, rect.y1); const width = Math.abs(rect.x1 - rect.x0); const height = Math.abs(rect.y1 - rect.y0); - return ( - - ); + return ; } diff --git a/canvas/src/components/panel/CanvasPanel.tsx b/canvas/src/components/panel/CanvasPanel.tsx index bc0e604d8..805c7952c 100644 --- a/canvas/src/components/panel/CanvasPanel.tsx +++ b/canvas/src/components/panel/CanvasPanel.tsx @@ -36,6 +36,7 @@ function PanelSvg({ svgRef, props, seriesByQueryIndex, paletteColors }: PanelSvg const { transform, fitView, resetPan } = useZoomContext(); const nodes = useMemo(() => spec.nodes ?? [], [spec.nodes]); + const backgrounds = useMemo(() => spec.backgrounds ?? [], [spec.backgrounds]); const width = contentDimensions?.width ?? 600; const height = contentDimensions?.height ?? 400; @@ -55,6 +56,7 @@ function PanelSvg({ svgRef, props, seriesByQueryIndex, paletteColors }: PanelSvg ); const showLegend = spec.legend !== undefined && spec.thresholds !== undefined; + const thresholds = spec.thresholds ?? {}; const legendPosition = spec.legend?.position ?? 'bottom'; const LEGEND_MARGIN = 8; const legendX = legendPosition === 'right' ? width - 118 - LEGEND_MARGIN : LEGEND_MARGIN; @@ -69,9 +71,9 @@ function PanelSvg({ svgRef, props, seriesByQueryIndex, paletteColors }: PanelSvg style={{ display: 'block', cursor: 'grab' }} onDoubleClick={handleDoubleClick} > - + - + - {showLegend && ( + {showLegend ? ( - )} + ) : null} ); } @@ -117,8 +119,13 @@ export function CanvasPanel(props: CanvasProps): ReactElement | null { const { svgRef, toCanvasPoint, transform, fitView, resetPan } = useZoom(); + const zoomValue = useMemo( + () => ({ toCanvasPoint, transform, fitView, resetPan }), + [toCanvasPoint, transform, fitView, resetPan], + ); + return ( - + ); diff --git a/canvas/src/components/panel/PanelEdgeLayer.tsx b/canvas/src/components/panel/PanelEdgeLayer.tsx index 949a29107..5180ef9fd 100644 --- a/canvas/src/components/panel/PanelEdgeLayer.tsx +++ b/canvas/src/components/panel/PanelEdgeLayer.tsx @@ -12,7 +12,7 @@ // limitations under the License. import { TimeSeries } from '@perses-dev/spec'; -import { ReactElement } from 'react'; +import { ReactElement, useMemo } from 'react'; import { useCanvasTheme } from '../../hooks/useCanvasTheme'; import { CanvasSpec } from '../../model'; @@ -64,9 +64,9 @@ interface PanelEdgeLayerProps { } export function PanelEdgeLayer({ spec, seriesByQueryIndex, k, paletteColors }: PanelEdgeLayerProps): ReactElement { - const nodes = spec.nodes ?? []; - const edges = spec.edges ?? []; - const nodeById = new Map(nodes.map((n) => [n.id, n])); + const nodes = useMemo(() => spec.nodes ?? [], [spec.nodes]); + const edges = useMemo(() => spec.edges ?? [], [spec.edges]); + const nodeById = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]); const { labelBackground, labelBorder, labelText, connection: fallbackColor } = useCanvasTheme(); return ( @@ -136,7 +136,7 @@ export function PanelEdgeLayer({ spec, seriesByQueryIndex, k, paletteColors }: P bwdStyle={scaledBwdStyle} lineProps={{ style: { pointerEvents: 'none' } }} /> - {fwdLabel && ( + {fwdLabel ? ( - )} - {bwdLabel && labelPts.bwd && ( + ) : null} + {bwdLabel && labelPts.bwd ? ( - )} + ) : null} ); })} diff --git a/canvas/src/components/panel/PanelNodeLayer.tsx b/canvas/src/components/panel/PanelNodeLayer.tsx index e2d909908..bb040722f 100644 --- a/canvas/src/components/panel/PanelNodeLayer.tsx +++ b/canvas/src/components/panel/PanelNodeLayer.tsx @@ -13,7 +13,7 @@ import { replaceVariablesInString, useAllVariableValues } from '@perses-dev/plugin-system'; import { TimeSeries } from '@perses-dev/spec'; -import { ReactElement, useCallback } from 'react'; +import { ReactElement, useCallback, useMemo } from 'react'; import { useCanvasTheme } from '../../hooks/useCanvasTheme'; import { CanvasSpec } from '../../model'; @@ -28,7 +28,7 @@ interface PanelNodeLayerProps { } export function PanelNodeLayer({ spec, seriesByQueryIndex, k, paletteColors }: PanelNodeLayerProps): ReactElement { - const nodes = spec.nodes ?? []; + const nodes = useMemo(() => spec.nodes ?? [], [spec.nodes]); const variableValues = useAllVariableValues(); const { connection: fallbackColor, nodeDefaultFill } = useCanvasTheme(); @@ -39,6 +39,8 @@ export function PanelNodeLayer({ spec, seriesByQueryIndex, k, paletteColors }: P [variableValues], ); + const rectProps = useMemo(() => ({ strokeWidth: 2 / k }), [k]); + return ( <> {nodes.map((node) => { @@ -59,13 +61,16 @@ export function PanelNodeLayer({ spec, seriesByQueryIndex, k, paletteColors }: P } } const { link } = node; + const groupProps = link + ? { onClick: (): void => handleNodeClick(link), style: { cursor: 'pointer' } } + : undefined; return ( handleNodeClick(link), style: { cursor: 'pointer' } } : undefined} - rectProps={{ strokeWidth: 2 / k }} + groupProps={groupProps} + rectProps={rectProps} labelOverride={labelOverride} fillOverride={fillOverride} /> diff --git a/canvas/src/components/panel/ThresholdLegend.tsx b/canvas/src/components/panel/ThresholdLegend.tsx index eac6ccc5f..48e1b9854 100644 --- a/canvas/src/components/panel/ThresholdLegend.tsx +++ b/canvas/src/components/panel/ThresholdLegend.tsx @@ -13,13 +13,14 @@ import { useTheme } from '@mui/material'; import { FormatOptions, formatValue, ThresholdOptions } from '@perses-dev/components'; -import { ReactElement } from 'react'; +import { ReactElement, useMemo } from 'react'; const SWATCH_SIZE = 12; const ROW_HEIGHT = 18; const LABEL_OFFSET = SWATCH_SIZE + 6; const PADDING = 8; const FONT_SIZE = 11; +const NO_SELECT_STYLE = { userSelect: 'none' } as const; interface ThresholdLegendProps { thresholds: ThresholdOptions; @@ -32,15 +33,20 @@ interface ThresholdLegendProps { export function ThresholdLegend({ thresholds, format, paletteColors, x, y }: ThresholdLegendProps): ReactElement { const muiTheme = useTheme(); const defaultColor = thresholds.defaultColor ?? paletteColors[0] ?? muiTheme.palette.success.main; - const steps = thresholds.steps ?? []; + const steps = useMemo(() => thresholds.steps ?? [], [thresholds.steps]); - const rows: Array<{ color: string; label: string }> = [ - ...steps.map((step, i) => ({ - color: step.color ?? paletteColors[i] ?? defaultColor, - label: `≥ ${formatValue(step.value, format)}`, - })), - { color: defaultColor, label: 'default' }, - ].reverse(); + const rows = useMemo( + () => + [ + ...steps.map((step, i) => ({ + color: step.color ?? paletteColors[i] ?? defaultColor, + label: `≥ ${formatValue(step.value, format)}`, + key: String(step.value), + })), + { color: defaultColor, label: 'default', key: 'default' }, + ].reverse(), + [steps, paletteColors, defaultColor, format], + ); const boxWidth = 110; const boxHeight = rows.length * ROW_HEIGHT + PADDING * 2; @@ -61,14 +67,14 @@ export function ThresholdLegend({ thresholds, format, paletteColors, x, y }: Thr {rows.map((row, i) => { const ry = y + PADDING + i * ROW_HEIGHT + (ROW_HEIGHT - SWATCH_SIZE) / 2; return ( - + {row.label} diff --git a/canvas/src/components/settings/EdgeThicknessSettings.tsx b/canvas/src/components/settings/EdgeThicknessSettings.tsx index 03acef800..07b3476a3 100644 --- a/canvas/src/components/settings/EdgeThicknessSettings.tsx +++ b/canvas/src/components/settings/EdgeThicknessSettings.tsx @@ -18,6 +18,17 @@ import React, { ReactElement, useCallback, useMemo } from 'react'; import { CanvasSpec } from '../../model'; +const STROKE_SLOT_PROPS = { + htmlInput: { min: 1, step: 1 }, + input: { endAdornment: px }, +} as const; + +const ROW_BOX_SX = { display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 } as const; +const ROW_CAPTION_SX = { minWidth: 70, color: 'text.secondary' } as const; +const ROW_TEXT_WIDTH_SX = { width: 100 } as const; +const DEFAULT_STROKE_SX = { mb: 1, width: 180 } as const; +const BLOCK_CAPTION_SX = { display: 'block', mb: 0.5 } as const; + interface EdgeThicknessSettingsProps { value: CanvasSpec; onChange: (value: CanvasSpec) => void; @@ -40,20 +51,17 @@ function ThresholdWidthRow({ step, strokeWidth, format, onChange }: ThresholdWid ); return ( - - + + ≥ {formatValue(step.value, format)} px }, - }} + slotProps={STROKE_SLOT_PROPS} value={strokeWidth ?? ''} onChange={onWidthChange} - sx={{ width: 100 }} + sx={ROW_TEXT_WIDTH_SX} /> ); @@ -100,31 +108,32 @@ export function EdgeThicknessSettings({ value, onChange }: EdgeThicknessSettings label="Default stroke width" size="small" type="number" - slotProps={{ - htmlInput: { min: 1, step: 1 }, - input: { endAdornment: px }, - }} + slotProps={STROKE_SLOT_PROPS} value={value.edgeDefaultStrokeWidth ?? ''} onChange={onDefaultStrokeWidthChange} placeholder="2" - sx={{ mb: 1, width: 180 }} + sx={DEFAULT_STROKE_SX} /> - {thresholdSteps.length > 0 && ( + {thresholdSteps.length > 0 ? ( - + Per-threshold widths - {thresholdSteps.map((step) => ( - w.value === step.value)?.strokeWidth} - format={value.format} - onChange={(strokeWidth) => onThresholdWidthChange(step.value, strokeWidth)} - /> - ))} + {thresholdSteps.map((step) => { + const handleChange = (strokeWidth: number | undefined): void => + onThresholdWidthChange(step.value, strokeWidth); + return ( + w.value === step.value)?.strokeWidth} + format={value.format} + onChange={handleChange} + /> + ); + })} - )} + ) : null} ); } diff --git a/canvas/src/components/settings/GlobalSettingsEditor.tsx b/canvas/src/components/settings/GlobalSettingsEditor.tsx index 9d2330b0b..0ed14adcb 100644 --- a/canvas/src/components/settings/GlobalSettingsEditor.tsx +++ b/canvas/src/components/settings/GlobalSettingsEditor.tsx @@ -14,13 +14,15 @@ import { Box } from '@mui/material'; import { FormatControls, + FormatOptions, OptionsEditorColumn, OptionsEditorGrid, OptionsEditorGroup, ThresholdsEditor, + ThresholdOptions, } from '@perses-dev/components'; import { OptionsEditorProps } from '@perses-dev/plugin-system'; -import { ReactElement } from 'react'; +import { ReactElement, useCallback } from 'react'; import { EditorStateProvider } from '../../contexts/EditorContext'; import { SpecProvider } from '../../contexts/SpecContext'; @@ -32,6 +34,20 @@ import { LegendSettings } from './LegendSettings'; type GlobalSettingsEditorProps = OptionsEditorProps; export function GlobalSettingsEditor({ value, onChange }: GlobalSettingsEditorProps): ReactElement { + const onFormatChange = useCallback( + (format: FormatOptions): void => { + onChange({ ...value, format }); + }, + [value, onChange], + ); + + const onThresholdsChange = useCallback( + (thresholds: ThresholdOptions | undefined): void => { + onChange({ ...value, thresholds }); + }, + [value, onChange], + ); + return ( @@ -40,18 +56,11 @@ export function GlobalSettingsEditor({ value, onChange }: GlobalSettingsEditorPr - onChange({ ...value, format })} - /> + - onChange({ ...value, thresholds })} - /> + diff --git a/canvas/src/components/settings/LegendSettings.tsx b/canvas/src/components/settings/LegendSettings.tsx index 79b42aa76..1b6d5ede0 100644 --- a/canvas/src/components/settings/LegendSettings.tsx +++ b/canvas/src/components/settings/LegendSettings.tsx @@ -45,7 +45,7 @@ export function LegendSettings({ value, onChange }: LegendSettingsProps): ReactE control={} label="Show legend" /> - {value.legend !== undefined && ( + {value.legend !== undefined ? ( Position - )} + ) : null} ); } diff --git a/canvas/src/components/shared/BackgroundLayer.tsx b/canvas/src/components/shared/BackgroundLayer.tsx index 81f18d61c..7c943d4ba 100644 --- a/canvas/src/components/shared/BackgroundLayer.tsx +++ b/canvas/src/components/shared/BackgroundLayer.tsx @@ -16,6 +16,8 @@ import { ReactElement } from 'react'; import { BackgroundSpec } from '../../model'; import { imageFitToPreserveAspectRatio, isSafeImageUrl } from '../../utils/panelUtils'; +const BG_GROUP_STYLE = { pointerEvents: 'none' } as const; + interface GlobalBackgroundLayerProps { backgrounds: BackgroundSpec[]; width: number; @@ -28,9 +30,9 @@ export function GlobalBackgroundLayer({ backgrounds, width, height }: GlobalBack {backgrounds .filter((bg) => bg.global) .map((bg) => ( - + - {bg.image && isSafeImageUrl(bg.image) && ( + {bg.image && isSafeImageUrl(bg.image) ? ( - )} + ) : null} ))} @@ -56,13 +58,9 @@ export function BackgroundLayer({ backgrounds }: BackgroundLayerProps): ReactEle {backgrounds .filter((bg) => !bg.global) .map((bg) => ( - + - {bg.image && isSafeImageUrl(bg.image) && ( + {bg.image && isSafeImageUrl(bg.image) ? ( - )} + ) : null} ))} diff --git a/canvas/src/components/shared/EdgeLines.tsx b/canvas/src/components/shared/EdgeLines.tsx index 8e0ab34e4..c2b5a8611 100644 --- a/canvas/src/components/shared/EdgeLines.tsx +++ b/canvas/src/components/shared/EdgeLines.tsx @@ -108,7 +108,7 @@ export function EdgeLines({ <> - {bwd && } + {bwd ? : null} - {bwd && ( + {bwd ? ( - )} + ) : null} ); } diff --git a/canvas/src/components/shared/IconNode.tsx b/canvas/src/components/shared/IconNode.tsx index e81a1788e..872b8a5a4 100644 --- a/canvas/src/components/shared/IconNode.tsx +++ b/canvas/src/components/shared/IconNode.tsx @@ -17,6 +17,9 @@ import { NodeSpec } from '../../model'; import { ICON_PATHS } from '../../utils/icons'; import { labelAttrs } from '../../utils/labelPosition'; +const NO_INTERACTION_STYLE = { pointerEvents: 'none', userSelect: 'none' } as const; +const NO_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const; + export interface IconNodeProps { node: NodeSpec; displayLabel: string | undefined; @@ -46,24 +49,24 @@ export function IconNode({ node, displayLabel, defaultFill, fillOverride, rectPr {...rectProps} /> {iconPath ? ( - + ) : ( - + )} - {displayLabel && ( + {displayLabel ? ( {displayLabel} - )} + ) : null} ); } diff --git a/canvas/src/components/shared/RectangleNode.tsx b/canvas/src/components/shared/RectangleNode.tsx index 7618d3722..2f34b9bf8 100644 --- a/canvas/src/components/shared/RectangleNode.tsx +++ b/canvas/src/components/shared/RectangleNode.tsx @@ -22,6 +22,9 @@ import { isSafeImageUrl } from '../../utils/panelUtils'; export const ICON_FILL_RATIO = 0.6; export const CORNER_RADIUS_RATIO = 0.2; +const NO_INTERACTION_STYLE = { pointerEvents: 'none', userSelect: 'none' } as const; +const NO_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const; + export interface RectangleNodeProps { node: NodeSpec; displayLabel: string | undefined; @@ -62,7 +65,7 @@ export function RectangleNode({ strokeWidth={2} {...rectProps} /> - {node.backgroundImage && isSafeImageUrl(node.backgroundImage) && ( + {node.backgroundImage && isSafeImageUrl(node.backgroundImage) ? ( - )} - {iconPath && ( + ) : null} + {iconPath ? ( - )} - {displayLabel && ( + ) : null} + {displayLabel ? ( {displayLabel} - )} + ) : null} ); } diff --git a/canvas/src/components/shared/TextNode.tsx b/canvas/src/components/shared/TextNode.tsx index b211dfa34..29021112a 100644 --- a/canvas/src/components/shared/TextNode.tsx +++ b/canvas/src/components/shared/TextNode.tsx @@ -16,6 +16,7 @@ import { ReactElement } from 'react'; import { NodeSpec } from '../../model'; const DEFAULT_TEXT_COLOR = 'currentColor'; +const NO_INTERACTION_STYLE = { pointerEvents: 'none', userSelect: 'none' } as const; export interface TextNodeProps { node: NodeSpec; @@ -43,18 +44,18 @@ export function TextNode({ node, displayLabel, fillOverride, rectProps }: TextNo strokeWidth={2} {...rectProps} /> - {displayLabel && ( + {displayLabel ? ( {displayLabel} - )} + ) : null} ); } diff --git a/canvas/src/contexts/EditorContext.tsx b/canvas/src/contexts/EditorContext.tsx index bf1252daa..367ea768d 100644 --- a/canvas/src/contexts/EditorContext.tsx +++ b/canvas/src/contexts/EditorContext.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { createContext, ReactElement, ReactNode, useContext, useReducer } from 'react'; +import { createContext, ReactElement, ReactNode, useCallback, useContext, useMemo, useReducer } from 'react'; import { EditorState, editorReducer, INITIAL_EDITOR_STATE } from '../utils/editorReducer'; @@ -41,22 +41,42 @@ export function useEditorContext(): EditorContextValue { export function EditorStateProvider({ children }: { children: ReactNode }): ReactElement { const [state, dispatch] = useReducer(editorReducer, INITIAL_EDITOR_STATE); - return ( - dispatch({ type: 'SELECT_ITEMS', ids }), - clearSelection: () => dispatch({ type: 'CLEAR_SELECTION' }), - hoverNode: (id) => dispatch({ type: 'HOVER_NODE', id }), - unhoverNode: (id) => dispatch({ type: 'UNHOVER_NODE', id }), - startSelectionRect: () => dispatch({ type: 'SELECTION_RECT_START' }), - startMove: () => dispatch({ type: 'MOVE_START' }), - startDragEdge: () => dispatch({ type: 'DRAG_EDGE_START' }), - startResize: () => dispatch({ type: 'RESIZE_START' }), - endInteraction: () => dispatch({ type: 'INTERACTION_END' }), - }} - > - {children} - + const selectItems = useCallback((ids: Set) => dispatch({ type: 'SELECT_ITEMS', ids }), []); + const clearSelection = useCallback(() => dispatch({ type: 'CLEAR_SELECTION' }), []); + const hoverNode = useCallback((id: string) => dispatch({ type: 'HOVER_NODE', id }), []); + const unhoverNode = useCallback((id: string) => dispatch({ type: 'UNHOVER_NODE', id }), []); + const startSelectionRect = useCallback(() => dispatch({ type: 'SELECTION_RECT_START' }), []); + const startMove = useCallback(() => dispatch({ type: 'MOVE_START' }), []); + const startDragEdge = useCallback(() => dispatch({ type: 'DRAG_EDGE_START' }), []); + const startResize = useCallback(() => dispatch({ type: 'RESIZE_START' }), []); + const endInteraction = useCallback(() => dispatch({ type: 'INTERACTION_END' }), []); + + const value = useMemo( + () => ({ + state, + selectItems, + clearSelection, + hoverNode, + unhoverNode, + startSelectionRect, + startMove, + startDragEdge, + startResize, + endInteraction, + }), + [ + state, + selectItems, + clearSelection, + hoverNode, + unhoverNode, + startSelectionRect, + startMove, + startDragEdge, + startResize, + endInteraction, + ], ); + + return {children}; } diff --git a/canvas/src/contexts/SpecContext.tsx b/canvas/src/contexts/SpecContext.tsx index bbcd07087..4dce03ad0 100644 --- a/canvas/src/contexts/SpecContext.tsx +++ b/canvas/src/contexts/SpecContext.tsx @@ -12,7 +12,7 @@ // limitations under the License. import { produce } from 'immer'; -import { createContext, ReactElement, ReactNode, useContext, useMemo } from 'react'; +import { createContext, ReactElement, ReactNode, useCallback, useContext, useMemo } from 'react'; import { DEFAULT_NODE_WIDTH, DEFAULT_NODE_HEIGHT } from '../components/shared/NodeRenderer'; import { BackgroundSpec, EdgeSpec, NodeSpec, CanvasSpec } from '../model'; @@ -68,50 +68,59 @@ export function SpecProvider({ spec, onChange, children }: SpecProviderProps): R return new Map(backgrounds.map((bg) => [bg.id, bg])); }, [spec.backgrounds]); - function addNode(x: number, y: number): void { - const id = generateId('node'); - onChange( - produce(spec, (draft) => { - (draft.nodes ??= []).push({ - id, - x, - y, - width: DEFAULT_NODE_WIDTH, - height: DEFAULT_NODE_HEIGHT, - kind: 'icon', - }); - }), - ); - selectItems(new Set([id])); - } + const addNode = useCallback( + (x: number, y: number): void => { + const id = generateId('node'); + onChange( + produce(spec, (draft) => { + (draft.nodes ??= []).push({ + id, + x, + y, + width: DEFAULT_NODE_WIDTH, + height: DEFAULT_NODE_HEIGHT, + kind: 'icon', + }); + }), + ); + selectItems(new Set([id])); + }, + [spec, onChange, selectItems], + ); - function addBackground(x: number, y: number, width: number, height: number): void { - const id = generateId('bg'); - onChange( - produce(spec, (draft) => { - (draft.backgrounds ??= []).push({ id, x, y, width, height }); - }), - ); - selectItems(new Set([id])); - } + const addBackground = useCallback( + (x: number, y: number, width: number, height: number): void => { + const id = generateId('bg'); + onChange( + produce(spec, (draft) => { + (draft.backgrounds ??= []).push({ id, x, y, width, height }); + }), + ); + selectItems(new Set([id])); + }, + [spec, onChange, selectItems], + ); - function moveBackground(id: string, direction: 'up' | 'down'): void { - onChange( - produce(spec, (draft) => { - const arr = draft.backgrounds ?? []; - const idx = arr.findIndex((bg) => bg.id === id); - const swapIdx = direction === 'up' ? idx - 1 : idx + 1; - if (idx === -1 || swapIdx < 0 || swapIdx >= arr.length) { - return; - } - const tmp = arr[idx]!; - arr[idx] = arr[swapIdx]!; - arr[swapIdx] = tmp; - }), - ); - } + const moveBackground = useCallback( + (id: string, direction: 'up' | 'down'): void => { + onChange( + produce(spec, (draft) => { + const arr = draft.backgrounds ?? []; + const idx = arr.findIndex((bg) => bg.id === id); + const swapIdx = direction === 'up' ? idx - 1 : idx + 1; + if (idx === -1 || swapIdx < 0 || swapIdx >= arr.length) { + return; + } + const tmp = arr[idx]!; + arr[idx] = arr[swapIdx]!; + arr[swapIdx] = tmp; + }), + ); + }, + [spec, onChange], + ); - function deleteSelected(): void { + const deleteSelected = useCallback((): void => { const { selectedIds } = state; onChange( produce(spec, (draft) => { @@ -123,59 +132,80 @@ export function SpecProvider({ spec, onChange, children }: SpecProviderProps): R }), ); clearSelection(); - } - - function onNodePropertiesChange(updated: NodeSpec): void { - onChange( - produce(spec, (draft) => { - const idx = (draft.nodes ?? []).findIndex((n) => n.id === updated.id); - if (idx !== -1 && draft.nodes) { - draft.nodes[idx] = updated; - } - }), - ); - } + }, [state, spec, onChange, clearSelection]); + + const onNodePropertiesChange = useCallback( + (updated: NodeSpec): void => { + onChange( + produce(spec, (draft) => { + const idx = (draft.nodes ?? []).findIndex((n) => n.id === updated.id); + if (idx !== -1 && draft.nodes) { + draft.nodes[idx] = updated; + } + }), + ); + }, + [spec, onChange], + ); - function onEdgePropertiesChange(updated: EdgeSpec): void { - onChange( - produce(spec, (draft) => { - const idx = (draft.edges ?? []).findIndex((ed) => ed.id === updated.id); - if (idx !== -1 && draft.edges) { - draft.edges[idx] = updated; - } - }), - ); - } + const onEdgePropertiesChange = useCallback( + (updated: EdgeSpec): void => { + onChange( + produce(spec, (draft) => { + const idx = (draft.edges ?? []).findIndex((ed) => ed.id === updated.id); + if (idx !== -1 && draft.edges) { + draft.edges[idx] = updated; + } + }), + ); + }, + [spec, onChange], + ); - function onBackgroundPropertiesChange(updated: BackgroundSpec): void { - onChange( - produce(spec, (draft) => { - const idx = (draft.backgrounds ?? []).findIndex((bg) => bg.id === updated.id); - if (idx !== -1 && draft.backgrounds) { - draft.backgrounds[idx] = updated; - } - }), - ); - } + const onBackgroundPropertiesChange = useCallback( + (updated: BackgroundSpec): void => { + onChange( + produce(spec, (draft) => { + const idx = (draft.backgrounds ?? []).findIndex((bg) => bg.id === updated.id); + if (idx !== -1 && draft.backgrounds) { + draft.backgrounds[idx] = updated; + } + }), + ); + }, + [spec, onChange], + ); - return ( - - {children} - + const value = useMemo( + () => ({ + spec, + updateSpec: onChange, + nodeById, + edgeById, + backgroundById, + addNode, + addBackground, + moveBackground, + deleteSelected, + onNodePropertiesChange, + onEdgePropertiesChange, + onBackgroundPropertiesChange, + }), + [ + spec, + onChange, + nodeById, + edgeById, + backgroundById, + addNode, + addBackground, + moveBackground, + deleteSelected, + onNodePropertiesChange, + onEdgePropertiesChange, + onBackgroundPropertiesChange, + ], ); + + return {children}; } diff --git a/canvas/src/hooks/useCanvasTheme.ts b/canvas/src/hooks/useCanvasTheme.ts index 65b881180..6e675421c 100644 --- a/canvas/src/hooks/useCanvasTheme.ts +++ b/canvas/src/hooks/useCanvasTheme.ts @@ -13,6 +13,7 @@ import { useTheme } from '@mui/material'; import { useChartsTheme } from '@perses-dev/components'; +import { useMemo } from 'react'; export interface CanvasTheme { palette: string[]; @@ -32,18 +33,21 @@ export interface CanvasTheme { export function useCanvasTheme(): CanvasTheme { const muiTheme = useTheme(); const chartsTheme = useChartsTheme(); - return { - palette: chartsTheme.thresholds.palette, - selection: muiTheme.palette.warning.main, - connection: muiTheme.palette.info.main, - snapHighlight: muiTheme.palette.success.main, - background: muiTheme.palette.background.paper, - divider: muiTheme.palette.divider, - text: muiTheme.palette.text.primary, - labelBackground: muiTheme.palette.background.paper, - labelBorder: muiTheme.palette.divider, - labelText: muiTheme.palette.text.primary, - nodeStroke: muiTheme.palette.background.paper, - nodeDefaultFill: muiTheme.palette.primary.main, - }; + return useMemo( + () => ({ + palette: chartsTheme.thresholds.palette, + selection: muiTheme.palette.warning.main, + connection: muiTheme.palette.info.main, + snapHighlight: muiTheme.palette.success.main, + background: muiTheme.palette.background.paper, + divider: muiTheme.palette.divider, + text: muiTheme.palette.text.primary, + labelBackground: muiTheme.palette.background.paper, + labelBorder: muiTheme.palette.divider, + labelText: muiTheme.palette.text.primary, + nodeStroke: muiTheme.palette.background.paper, + nodeDefaultFill: muiTheme.palette.primary.main, + }), + [muiTheme, chartsTheme], + ); } diff --git a/canvas/src/setup-tests.ts b/canvas/src/setup-tests.ts index 012685e6a..e5bf478d5 100644 --- a/canvas/src/setup-tests.ts +++ b/canvas/src/setup-tests.ts @@ -11,6 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +// eslint-disable-next-line import/no-unassigned-import import '@testing-library/jest-dom'; // Always mock e-charts during tests since we don't have a proper canvas in jsdom diff --git a/canvas/src/test-utils/hookWrapper.tsx b/canvas/src/test-utils/hookWrapper.tsx index 1bdd043f9..b036ff0be 100644 --- a/canvas/src/test-utils/hookWrapper.tsx +++ b/canvas/src/test-utils/hookWrapper.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, { ReactNode, useState } from 'react'; +import { ReactElement, ReactNode, useMemo, useState } from 'react'; import { EditorStateProvider } from '../contexts/EditorContext'; import { SpecContext, SpecContextValue } from '../contexts/SpecContext'; @@ -53,27 +53,27 @@ interface WrapperProps { export function HookWrapper({ initialSpec = {}, children }: WrapperProps): React.ReactElement { const [spec, setSpec] = useState(initialSpec); - const nodeById = React.useMemo(() => new Map((spec.nodes ?? []).map((n) => [n.id, n])), [spec.nodes]); - const edgeById = React.useMemo(() => new Map((spec.edges ?? []).map((ed) => [ed.id, ed])), [spec.edges]); - const backgroundById = React.useMemo( - () => new Map((spec.backgrounds ?? []).map((bg) => [bg.id, bg])), - [spec.backgrounds], - ); + const nodeById = useMemo(() => new Map((spec.nodes ?? []).map((n) => [n.id, n])), [spec.nodes]); + const edgeById = useMemo(() => new Map((spec.edges ?? []).map((ed) => [ed.id, ed])), [spec.edges]); + const backgroundById = useMemo(() => new Map((spec.backgrounds ?? []).map((bg) => [bg.id, bg])), [spec.backgrounds]); - const specCtx: SpecContextValue = { - spec, - nodeById, - edgeById, - backgroundById, - updateSpec: setSpec, - addNode: jest.fn(), - addBackground: jest.fn(), - moveBackground: jest.fn(), - deleteSelected: jest.fn(), - onNodePropertiesChange: jest.fn(), - onEdgePropertiesChange: jest.fn(), - onBackgroundPropertiesChange: jest.fn(), - }; + const specCtx = useMemo( + () => ({ + spec, + nodeById, + edgeById, + backgroundById, + updateSpec: setSpec, + addNode: jest.fn(), + addBackground: jest.fn(), + moveBackground: jest.fn(), + deleteSelected: jest.fn(), + onNodePropertiesChange: jest.fn(), + onEdgePropertiesChange: jest.fn(), + onBackgroundPropertiesChange: jest.fn(), + }), + [spec, nodeById, edgeById, backgroundById], + ); return ( @@ -85,7 +85,7 @@ export function HookWrapper({ initialSpec = {}, children }: WrapperProps): React } export function makeWrapper(initialSpec?: CanvasSpec) { - return function Wrapper({ children }: { children: ReactNode }): React.ReactElement { + return function Wrapper({ children }: { children: ReactNode }): ReactElement { return {children}; }; } From 29c29f27e5ecef5359f9e1dc5ef48281f6b550ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Fri, 21 Aug 2026 14:04:06 +0200 Subject: [PATCH 7/7] [IGNORE] Refactor Edge and Node property panels to use SelectField component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- .../components/editor/EdgePropertiesPanel.tsx | 67 +++++-------------- .../components/editor/NodePropertiesPanel.tsx | 38 +++-------- canvas/src/components/shared/SelectField.tsx | 22 ++++++ 3 files changed, 46 insertions(+), 81 deletions(-) create mode 100644 canvas/src/components/shared/SelectField.tsx diff --git a/canvas/src/components/editor/EdgePropertiesPanel.tsx b/canvas/src/components/editor/EdgePropertiesPanel.tsx index 00aae1305..27fe3fb90 100644 --- a/canvas/src/components/editor/EdgePropertiesPanel.tsx +++ b/canvas/src/components/editor/EdgePropertiesPanel.tsx @@ -16,9 +16,9 @@ import { generateQueryNames, useDataQueriesContext } from '@perses-dev/plugin-sy import React, { ReactElement, useCallback, useMemo } from 'react'; import { AnchorPoint, EdgeSpec, NodeSpec } from '../../model'; +import { SelectField } from '../shared/SelectField'; const ANCHOR_OPTIONS: AnchorPoint[] = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw']; -const SELECT_SLOT_PROPS = { select: { MenuProps: { PaperProps: { style: { maxHeight: 240 } } } } } as const; interface EdgePropertiesPanelProps { edge: EdgeSpec; @@ -134,83 +134,52 @@ export function EdgePropertiesPanel({ edge, nodes, onChange }: EdgePropertiesPan - + {nodes.map((n) => ( {n.label ?? n.id} ))} - + - + {ANCHOR_OPTIONS.map((a) => ( {a} ))} - + - + {nodes.map((n) => ( {n.label ?? n.id} ))} - + - {ANCHOR_OPTIONS.map((a) => ( {a} ))} - + } label="Bidirectional" /> - + Fixed Threshold - + {(edge.thicknessMode ?? 'fixed') === 'fixed' ? ( ) : null} - None @@ -240,7 +206,7 @@ export function EdgePropertiesPanel({ edge, nodes, onChange }: EdgePropertiesPan {queryNames[qi] ?? `#${qi + 1}`} ))} - + - None @@ -268,7 +231,7 @@ export function EdgePropertiesPanel({ edge, nodes, onChange }: EdgePropertiesPan {queryNames[qi] ?? `#${qi + 1}`} ))} - + void; @@ -185,11 +184,11 @@ export function NodePropertiesPanel({ node, onChange }: NodePropertiesPanelProps /> - + Rectangle Icon Text - + {shape !== 'text' ? ( - Below @@ -245,7 +241,7 @@ export function NodePropertiesPanel({ node, onChange }: NodePropertiesPanelProps Left Right Center - + ) : null} - + None @@ -276,24 +264,16 @@ export function NodePropertiesPanel({ node, onChange }: NodePropertiesPanelProps {queryNames[qi] ?? `#${qi + 1}`} ))} - + - + None (default) Threshold Fixed - + ; +}