From d73e5e203c7013ad36cda965c6ef8d26ee6189b4 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] [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-lock.json | 1396 +++++++++-------- package.json | 1 + .../PyroscopeProfileQuery.ts | 14 +- 83 files changed, 7710 insertions(+), 635 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-lock.json b/package-lock.json index f20f95263..414cc50ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "tracetable", "tracingganttchart", "victorialogs", + "canvas", "e2e" ], "devDependencies": { @@ -128,6 +129,33 @@ "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.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" + } + }, "clickhouse": { "name": "@perses-dev/clickhouse-plugin", "version": "0.6.0-beta.2", @@ -1252,9 +1280,9 @@ } }, "node_modules/@codemirror/state": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.0.tgz", - "integrity": "sha512-Zbl9NyscLMZkfXPQnNAIIAFftidrA1UbcJEIMp24C0Bukc2I5T8wJS0wsXYsnDOqCFJUeJ1BITGNs5CqPDSmSg==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", "license": "MIT", "dependencies": { "@marijn/find-cluster-break": "^1.0.0" @@ -1273,9 +1301,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.43.4", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.4.tgz", - "integrity": "sha512-YImu23iyKfncJzT7sRy+rEqEhSc8RhOHqDxwy4WzXRKJwYm6iwf/9OJk5ctCAdZ6yi2ZqaGEvmf55fSVqMDrgg==", + "version": "6.43.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz", + "integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.7.0", @@ -1430,9 +1458,9 @@ "license": "MIT" }, "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", "license": "MIT", "optional": true, "dependencies": { @@ -1441,9 +1469,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "license": "MIT", "optional": true, "dependencies": { @@ -1673,9 +1701,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1770,9 +1798,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2498,24 +2526,22 @@ "license": "MIT" }, "node_modules/@module-federation/bridge-react-webpack-plugin": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@module-federation/bridge-react-webpack-plugin/-/bridge-react-webpack-plugin-2.6.0.tgz", - "integrity": "sha512-V+4+1PUmDgAozXPJA8evIa2G+dPJGYn1JzDoHS9wMFch2blko/DO2sMq6FGuZFnT1oyjxiWeiaWXluRElOU9rQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@module-federation/bridge-react-webpack-plugin/-/bridge-react-webpack-plugin-2.8.0.tgz", + "integrity": "sha512-7AaaiE4YOXFb+st6xlVDK65aNKZYR9S9ykH0q2mkHAHTgquXciAjypS8JuhmKn7Lob/2rkplMOc4YsGxG3Ng9Q==", "license": "MIT", "dependencies": { - "@module-federation/sdk": "2.6.0", - "@types/semver": "7.5.8", - "semver": "7.6.3" + "@module-federation/sdk": "2.8.0" } }, "node_modules/@module-federation/cli": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@module-federation/cli/-/cli-2.6.0.tgz", - "integrity": "sha512-CHBsi5f8Oe9sCN6rY4R0+P/oFApvuutnqfoYNtuORdMn6FHittFDkuLFCSYlrZN2QYw/Sqir2xgLcwmY77Z7PA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@module-federation/cli/-/cli-2.8.0.tgz", + "integrity": "sha512-yTxdWkCJPPo+IGASz+NqdW13cw3DhjSBEn9r85aYn1ahckDwB1WcZe0OjDWMqCcR6yi0BMLedk0SWrUeUj0fWw==", "license": "MIT", "dependencies": { - "@module-federation/dts-plugin": "2.6.0", - "@module-federation/sdk": "2.6.0", + "@module-federation/dts-plugin": "2.8.0", + "@module-federation/sdk": "2.8.0", "commander": "11.1.0", "jiti": "2.4.2" }, @@ -2527,24 +2553,22 @@ } }, "node_modules/@module-federation/dts-plugin": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@module-federation/dts-plugin/-/dts-plugin-2.6.0.tgz", - "integrity": "sha512-0dMjLhU+2HNPyX5Txu6JqA2CAYxVLRv3c/bpRk58aNVwhDO/jqp66IdFztdMZ1XiHqOW9jB3pkOSuIpcl/yXpQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@module-federation/dts-plugin/-/dts-plugin-2.8.0.tgz", + "integrity": "sha512-defjq4jOWMEfeejezPWLP5sc8kw0O6FqTT7/E5rbZPEVyjB1A0U3ynhW6GDE5/6hk9/TzdbWS+fBNi4MqUOY6Q==", "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.6.0", - "@module-federation/managers": "2.6.0", - "@module-federation/sdk": "2.6.0", - "@module-federation/third-party-dts-extractor": "2.6.0", + "@module-federation/error-codes": "2.8.0", + "@module-federation/managers": "2.8.0", + "@module-federation/sdk": "2.8.0", + "@module-federation/third-party-dts-extractor": "2.8.0", "adm-zip": "0.5.10", - "ansi-colors": "4.1.3", "isomorphic-ws": "5.0.0", - "node-schedule": "2.1.1", "undici": "7.28.0", "ws": "8.21.0" }, "peerDependencies": { - "typescript": "^4.9.0 || ^5.0.0", + "typescript": "^4.9.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "vue-tsc": ">=1.0.24" }, "peerDependenciesMeta": { @@ -2554,31 +2578,30 @@ } }, "node_modules/@module-federation/enhanced": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@module-federation/enhanced/-/enhanced-2.6.0.tgz", - "integrity": "sha512-pQ0V93FfeHtr67Nusr6ySTQO1qeOGs1x9MMjbeZ4ObOPbXdHNX1tH19xVP8mo5k3e8iIV2teMoSayTmHT+nD0g==", - "license": "MIT", - "dependencies": { - "@module-federation/bridge-react-webpack-plugin": "2.6.0", - "@module-federation/cli": "2.6.0", - "@module-federation/dts-plugin": "2.6.0", - "@module-federation/error-codes": "2.6.0", - "@module-federation/inject-external-runtime-core-plugin": "2.6.0", - "@module-federation/managers": "2.6.0", - "@module-federation/manifest": "2.6.0", - "@module-federation/rspack": "2.6.0", - "@module-federation/runtime-tools": "2.6.0", - "@module-federation/sdk": "2.6.0", - "@module-federation/webpack-bundler-runtime": "2.6.0", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@module-federation/enhanced/-/enhanced-2.8.0.tgz", + "integrity": "sha512-h8vkLdhK7tlcSPmyYNGfGyt0pSzfDB0tYVYdyUt2tXwQRfaJAi3bsIpujMXElw3MXtOfSHESa6M/hPKrtWTHBw==", + "license": "MIT", + "dependencies": { + "@module-federation/bridge-react-webpack-plugin": "2.8.0", + "@module-federation/cli": "2.8.0", + "@module-federation/dts-plugin": "2.8.0", + "@module-federation/error-codes": "2.8.0", + "@module-federation/inject-external-runtime-core-plugin": "2.8.0", + "@module-federation/managers": "2.8.0", + "@module-federation/manifest": "2.8.0", + "@module-federation/rspack": "2.8.0", + "@module-federation/runtime-tools": "2.8.0", + "@module-federation/sdk": "2.8.0", + "@module-federation/webpack-bundler-runtime": "2.8.0", "schema-utils": "4.3.0", - "tapable": "2.3.0", - "upath": "2.0.1" + "tapable": "2.3.0" }, "bin": { "mf": "bin/mf.js" }, "peerDependencies": { - "typescript": "^4.9.0 || ^5.0.0", + "typescript": "^4.9.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "vue-tsc": ">=1.0.24", "webpack": "^5.0.0" }, @@ -2595,52 +2618,50 @@ } }, "node_modules/@module-federation/error-codes": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-2.6.0.tgz", - "integrity": "sha512-J9opjWJJZ1JI/9EvNZF8Ps1VMHS9EsH/i0UjCkQQxFVYZxSDBX1CFunvc7awac4cY//LqJUfqBbfoQPhCfKKKw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-2.8.0.tgz", + "integrity": "sha512-Gaog9904EmxYOQV0hli3XQ7jXeFaADfh5bnBtTCtbZ37Qd/Sz9kQfd+gYQRyIj7RGmkv9DPiN/SsmrTMrTymKw==", "license": "MIT" }, "node_modules/@module-federation/inject-external-runtime-core-plugin": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@module-federation/inject-external-runtime-core-plugin/-/inject-external-runtime-core-plugin-2.6.0.tgz", - "integrity": "sha512-x5SmH33U1nSEcBXG6YzvkwIQLoKcd/CNfrAx4IJ4qBzlQKI0NvY7U4JE83LpBnwahNTQLvp27ZVX+1f7kt4Vww==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@module-federation/inject-external-runtime-core-plugin/-/inject-external-runtime-core-plugin-2.8.0.tgz", + "integrity": "sha512-fW3jD1ZVds6r/Ul8TtUA42RsB0LfT1yjo5KjqgirH9QrmEMH21x44e3e6BC6IN820XKawRDSmz2kFA5YHIQp7Q==", "license": "MIT", "peerDependencies": { - "@module-federation/runtime-tools": "2.6.0" + "@module-federation/runtime-tools": "2.8.0" } }, "node_modules/@module-federation/managers": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@module-federation/managers/-/managers-2.6.0.tgz", - "integrity": "sha512-7Y4Ri6Lh10dCHoEJvNGFmK2Gg1JKy7oJ1TEZhuU3n3mgIpZ519fDNRvU4+tiO9C49p1xyK+mQ8ewxth83waKUA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@module-federation/managers/-/managers-2.8.0.tgz", + "integrity": "sha512-SnVBCwmi962WGg6hLFElxZUCnrRJdR6glE2ZKPBY/iK07AHUN2ZxuaBCBsVzyws+xLZGHZxBHmVstijTh8dSUA==", "license": "MIT", "dependencies": { - "@module-federation/sdk": "2.6.0", - "find-pkg": "2.0.0" + "@module-federation/sdk": "2.8.0" } }, "node_modules/@module-federation/manifest": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@module-federation/manifest/-/manifest-2.6.0.tgz", - "integrity": "sha512-wIjI4wgFKBoq4l/iBOT2lva+i5sPv1+Ci1gOLOfjMAkygyGo97csUG5TaCWgLJpZzbYrpbFObCB2wZhqaLrQIw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@module-federation/manifest/-/manifest-2.8.0.tgz", + "integrity": "sha512-wfVeBXc4/C2F70nRFSPqJhkcwbDgo+wQyEn3jbjJTDoUqxxhYBfHFs1ACBYOk3Qm97L7hHclHGtUG0/nvDEfAA==", "license": "MIT", "dependencies": { - "@module-federation/dts-plugin": "2.6.0", - "@module-federation/managers": "2.6.0", - "@module-federation/sdk": "2.6.0", - "find-pkg": "2.0.0" + "@module-federation/dts-plugin": "2.8.0", + "@module-federation/managers": "2.8.0", + "@module-federation/sdk": "2.8.0" } }, "node_modules/@module-federation/node": { - "version": "2.7.45", - "resolved": "https://registry.npmjs.org/@module-federation/node/-/node-2.7.45.tgz", - "integrity": "sha512-h9/jKp+gAGiO+gF4QOrzGDBVZVy+kfN+S06Pywbnpvpwe3DVMuhRXw9lcvZUDe23DHW0D/rAzaLrgNyq2qWciw==", + "version": "2.7.47", + "resolved": "https://registry.npmjs.org/@module-federation/node/-/node-2.7.47.tgz", + "integrity": "sha512-mifMvCjWmLl53GS+badQws0j2bsu1ICpdGzCbez4I6kSpaYA8v86L6dwcHtVHIZtkUC6cjAZBDcgpxs4fK3nFQ==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/enhanced": "2.6.0", - "@module-federation/runtime": "2.6.0", - "@module-federation/sdk": "2.6.0", + "@module-federation/enhanced": "2.8.0", + "@module-federation/runtime": "2.8.0", + "@module-federation/sdk": "2.8.0", "encoding": "0.1.13", "node-fetch": "2.7.0", "tapable": "2.3.0" @@ -2655,15 +2676,15 @@ } }, "node_modules/@module-federation/rsbuild-plugin": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@module-federation/rsbuild-plugin/-/rsbuild-plugin-2.6.0.tgz", - "integrity": "sha512-o/FHCsI1Hw581Dmq0lUabNXUfML41kNX3uolEGIaZ2Xiurxw2ZHn7usVWqL8Ysa1hjvcj4BpGBfKTWE9BY3RpQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@module-federation/rsbuild-plugin/-/rsbuild-plugin-2.8.0.tgz", + "integrity": "sha512-rul5OPvLx599rWoAhCtKJ3UYqyM3Dxg0RWEfad3JdnJFqkcSLBojZXgHJE1vbF7DRdGQNmNscKw34iEyn89NwQ==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/enhanced": "2.6.0", - "@module-federation/node": "2.7.45", - "@module-federation/sdk": "2.6.0" + "@module-federation/enhanced": "2.8.0", + "@module-federation/node": "2.7.47", + "@module-federation/sdk": "2.8.0" }, "engines": { "node": ">=16.0.0" @@ -2678,22 +2699,22 @@ } }, "node_modules/@module-federation/rspack": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@module-federation/rspack/-/rspack-2.6.0.tgz", - "integrity": "sha512-Ayh7WLxhLRMyfBfajDwEytR5StFfnqf8p4tPHq5ds+HzJMlGz/M+NIYEzdOpfOFdE5IqN9bY/mj2AFI5lCureQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@module-federation/rspack/-/rspack-2.8.0.tgz", + "integrity": "sha512-TPcrkHpaZgL25Vx3c8oSNwyv7/KktC7uo6HTQdVWlFzbq5RSoMMGkWoir5pY5124isae2/p6v5xAuqICi4r0Zg==", "license": "MIT", "dependencies": { - "@module-federation/bridge-react-webpack-plugin": "2.6.0", - "@module-federation/dts-plugin": "2.6.0", - "@module-federation/inject-external-runtime-core-plugin": "2.6.0", - "@module-federation/managers": "2.6.0", - "@module-federation/manifest": "2.6.0", - "@module-federation/runtime-tools": "2.6.0", - "@module-federation/sdk": "2.6.0" + "@module-federation/bridge-react-webpack-plugin": "2.8.0", + "@module-federation/dts-plugin": "2.8.0", + "@module-federation/inject-external-runtime-core-plugin": "2.8.0", + "@module-federation/managers": "2.8.0", + "@module-federation/manifest": "2.8.0", + "@module-federation/runtime-tools": "2.8.0", + "@module-federation/sdk": "2.8.0" }, "peerDependencies": { "@rspack/core": "^0.7.0 || ^1.0.0 || ^2.0.0-0", - "typescript": "^4.9.0 || ^5.0.0", + "typescript": "^4.9.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "vue-tsc": ">=1.0.24" }, "peerDependenciesMeta": { @@ -2706,69 +2727,57 @@ } }, "node_modules/@module-federation/runtime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-2.6.0.tgz", - "integrity": "sha512-Y8KgL70RDxD8cCtGWGlGkt+TSDleIjdGmS27gnllibQAIgG/vHhPD11r8kd92x44k4dqtMIntzreOphGICl92g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-2.8.0.tgz", + "integrity": "sha512-cGtUBQ1/TVy7KrXy6xPgy3FEmOGyIYkBA2T4iGH3ZH5PNPPTmqN9jF2AfneTSOj0RtBr7Pxq3CUt81E/UCvK1A==", "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.6.0", - "@module-federation/runtime-core": "2.6.0", - "@module-federation/sdk": "2.6.0" + "@module-federation/error-codes": "2.8.0", + "@module-federation/runtime-core": "2.8.0", + "@module-federation/sdk": "2.8.0" } }, "node_modules/@module-federation/runtime-core": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-2.6.0.tgz", - "integrity": "sha512-9sL7Yj3H3COZI9JpK/J4hmJUBkIJdmowkj6phHuFiy5Hm5bHiE5U+9WBbR9KqTdyNhAVSL9FeprBW5/Io6i59A==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-2.8.0.tgz", + "integrity": "sha512-Tf98+epGGiPSHqmQHuXa2uXZMMvjGf1IqJDR1/FpXfmobv5ECN0mGZCjUHGNSyxvoDyXKIkKwJu7IwEoh0ouQA==", "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.6.0", - "@module-federation/sdk": "2.6.0" + "@module-federation/error-codes": "2.8.0", + "@module-federation/sdk": "2.8.0" } }, "node_modules/@module-federation/runtime-tools": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-2.6.0.tgz", - "integrity": "sha512-LfvihAhAWWNWlAL9zM+UDVDSSuLE2ZU0ATJ6dcbJuLstYbHYfUqq2e6IyU8TKLPXIib0VAWqPrT44TPam1VJ7g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-2.8.0.tgz", + "integrity": "sha512-3yOqjdSHXxX4HA3GhlXg3hghGAXW2RJUsnwXCcik2/lTxOHizKI8f3RM+GGCKPxDVqtw43IShe3tA12jNL5A/A==", "license": "MIT", "dependencies": { - "@module-federation/runtime": "2.6.0", - "@module-federation/webpack-bundler-runtime": "2.6.0" + "@module-federation/runtime": "2.8.0", + "@module-federation/webpack-bundler-runtime": "2.8.0" } }, "node_modules/@module-federation/sdk": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-2.6.0.tgz", - "integrity": "sha512-Z3HT1uDciMrvz2at3sw6TJbAK9Fl7RmoC/8iqO35HDtQMQJ1qSsMIAjnsiCpAC0D4nAm6PIqgSqPWRO/WYgo0Q==", - "license": "MIT", - "peerDependencies": { - "node-fetch": "^2.7.0 || ^3.3.2" - }, - "peerDependenciesMeta": { - "node-fetch": { - "optional": true - } - } + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-2.8.0.tgz", + "integrity": "sha512-yBP+9+0Z8nlvKEXAZS3AsQVy7bFbZf8eMivGk4q4ZdwG3TsLMlsPjb1dQb2i7gcAG6ux9y2LWLkj/0LVk74cnQ==", + "license": "MIT" }, "node_modules/@module-federation/third-party-dts-extractor": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@module-federation/third-party-dts-extractor/-/third-party-dts-extractor-2.6.0.tgz", - "integrity": "sha512-rEC1YRC3gTafoOcFm1Htz6cAwHRoWEMQNCm1LXR1HiuayJzUkViVpK/1Pp37UZfytOyQ0qIaP6Ag81PdGvDbmw==", - "license": "MIT", - "dependencies": { - "find-pkg": "2.0.0", - "resolve": "1.22.8" - } + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@module-federation/third-party-dts-extractor/-/third-party-dts-extractor-2.8.0.tgz", + "integrity": "sha512-nAMlr74OKIylkfRwlunOhytQbmsgb3gCqdXWnPQhG+ZtqWXGELLfMT4a1Q1ht3cS+sRpWj2SZRqK2M7GadI6tA==", + "license": "MIT" }, "node_modules/@module-federation/webpack-bundler-runtime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-2.6.0.tgz", - "integrity": "sha512-JjuWOU3ktwDjBWhM4WCoeiErcUxcB5YwUnVjyTMfNJHC3+DMhG4m6ziCl5EJrCv+KaEQdcvXmxLliNZidBZGQg==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-2.8.0.tgz", + "integrity": "sha512-82fDy9v+7qV5fiN8TKVhOdrxhmAZnUIX/IKivYX5ulCt8aoOzVFTiwm/P1GQUDD8z6dqR48xgJdZdf0548Mc9w==", "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.6.0", - "@module-federation/runtime": "2.6.0", - "@module-federation/sdk": "2.6.0" + "@module-federation/error-codes": "2.8.0", + "@module-federation/runtime": "2.8.0", + "@module-federation/sdk": "2.8.0" } }, "node_modules/@mui/core-downloads-tracker": { @@ -3497,6 +3506,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 @@ -3511,6 +3524,62 @@ "zod": "^3.21.4" } }, + "node_modules/@perses-dev/client/node_modules/@perses-dev/spec": { + "version": "0.2.0-beta.6", + "resolved": "https://registry.npmjs.org/@perses-dev/spec/-/spec-0.2.0-beta.6.tgz", + "integrity": "sha512-J8oWuZgc/0nlT8PDlEIahA7XZ/tKITC9C0FG7e+FFhYGRzZ5oyMz2keuPkAzl3KPYB9yeczit9TFQWsoY/ot5Q==", + "license": "Apache-2.0", + "dependencies": { + "date-fns": "^4.1.0", + "mathjs": "^15.1.1", + "zod": "^3.21.4" + } + }, + "node_modules/@perses-dev/client/node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/@perses-dev/client/node_modules/mathjs": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.2.0.tgz", + "integrity": "sha512-UAQzSVob9rNLdGpqcFMYmSu9dkuLYy7Lr2hBEQS5SHQdknA9VppJz3cy2KkpMzTODunad6V6cNv+5kOLsePLow==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.26.10", + "complex.js": "^2.2.5", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "^5.2.1", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.2.1" + }, + "bin": { + "mathjs": "bin/cli.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@perses-dev/client/node_modules/typed-function": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.2.tgz", + "integrity": "sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, "node_modules/@perses-dev/components": { "version": "0.54.0-beta.11", "resolved": "https://registry.npmjs.org/@perses-dev/components/-/components-0.54.0-beta.11.tgz", @@ -3550,6 +3619,62 @@ "react-dom": "^17.0.2 || ^18.0.0" } }, + "node_modules/@perses-dev/components/node_modules/@perses-dev/spec": { + "version": "0.2.0-beta.6", + "resolved": "https://registry.npmjs.org/@perses-dev/spec/-/spec-0.2.0-beta.6.tgz", + "integrity": "sha512-J8oWuZgc/0nlT8PDlEIahA7XZ/tKITC9C0FG7e+FFhYGRzZ5oyMz2keuPkAzl3KPYB9yeczit9TFQWsoY/ot5Q==", + "license": "Apache-2.0", + "dependencies": { + "date-fns": "^4.1.0", + "mathjs": "^15.1.1", + "zod": "^3.21.4" + } + }, + "node_modules/@perses-dev/components/node_modules/@perses-dev/spec/node_modules/mathjs": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.2.0.tgz", + "integrity": "sha512-UAQzSVob9rNLdGpqcFMYmSu9dkuLYy7Lr2hBEQS5SHQdknA9VppJz3cy2KkpMzTODunad6V6cNv+5kOLsePLow==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.26.10", + "complex.js": "^2.2.5", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "^5.2.1", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.2.1" + }, + "bin": { + "mathjs": "bin/cli.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@perses-dev/components/node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/@perses-dev/components/node_modules/typed-function": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.2.tgz", + "integrity": "sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, "node_modules/@perses-dev/core": { "version": "0.53.0", "resolved": "https://registry.npmjs.org/@perses-dev/core/-/core-0.53.0.tgz", @@ -3593,6 +3718,62 @@ "react-dom": "^17.0.2 || ^18.0.0" } }, + "node_modules/@perses-dev/dashboards/node_modules/@perses-dev/spec": { + "version": "0.2.0-beta.6", + "resolved": "https://registry.npmjs.org/@perses-dev/spec/-/spec-0.2.0-beta.6.tgz", + "integrity": "sha512-J8oWuZgc/0nlT8PDlEIahA7XZ/tKITC9C0FG7e+FFhYGRzZ5oyMz2keuPkAzl3KPYB9yeczit9TFQWsoY/ot5Q==", + "license": "Apache-2.0", + "dependencies": { + "date-fns": "^4.1.0", + "mathjs": "^15.1.1", + "zod": "^3.21.4" + } + }, + "node_modules/@perses-dev/dashboards/node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/@perses-dev/dashboards/node_modules/mathjs": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.2.0.tgz", + "integrity": "sha512-UAQzSVob9rNLdGpqcFMYmSu9dkuLYy7Lr2hBEQS5SHQdknA9VppJz3cy2KkpMzTODunad6V6cNv+5kOLsePLow==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.26.10", + "complex.js": "^2.2.5", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "^5.2.1", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.2.1" + }, + "bin": { + "mathjs": "bin/cli.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@perses-dev/dashboards/node_modules/typed-function": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.2.tgz", + "integrity": "sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, "node_modules/@perses-dev/datasource-variable-plugin": { "resolved": "datasourcevariable", "link": true @@ -3697,16 +3878,60 @@ "react-dom": "^17.0.2 || ^18.0.0" } }, - "node_modules/@perses-dev/plugin-system/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", + "node_modules/@perses-dev/plugin-system/node_modules/@perses-dev/spec": { + "version": "0.2.0-beta.6", + "resolved": "https://registry.npmjs.org/@perses-dev/spec/-/spec-0.2.0-beta.6.tgz", + "integrity": "sha512-J8oWuZgc/0nlT8PDlEIahA7XZ/tKITC9C0FG7e+FFhYGRzZ5oyMz2keuPkAzl3KPYB9yeczit9TFQWsoY/ot5Q==", + "license": "Apache-2.0", + "dependencies": { + "date-fns": "^4.1.0", + "mathjs": "^15.1.1", + "zod": "^3.21.4" + } + }, + "node_modules/@perses-dev/plugin-system/node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/@perses-dev/plugin-system/node_modules/mathjs": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.2.0.tgz", + "integrity": "sha512-UAQzSVob9rNLdGpqcFMYmSu9dkuLYy7Lr2hBEQS5SHQdknA9VppJz3cy2KkpMzTODunad6V6cNv+5kOLsePLow==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.26.10", + "complex.js": "^2.2.5", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "^5.2.1", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.2.1" + }, "bin": { - "semver": "bin/semver.js" + "mathjs": "bin/cli.js" }, "engines": { - "node": ">=10" + "node": ">= 18" + } + }, + "node_modules/@perses-dev/plugin-system/node_modules/typed-function": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.2.tgz", + "integrity": "sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==", + "license": "MIT", + "engines": { + "node": ">= 18" } }, "node_modules/@perses-dev/plugins-e2e": { @@ -3726,9 +3951,9 @@ "link": true }, "node_modules/@perses-dev/spec": { - "version": "0.2.0-beta.6", - "resolved": "https://registry.npmjs.org/@perses-dev/spec/-/spec-0.2.0-beta.6.tgz", - "integrity": "sha512-J8oWuZgc/0nlT8PDlEIahA7XZ/tKITC9C0FG7e+FFhYGRzZ5oyMz2keuPkAzl3KPYB9yeczit9TFQWsoY/ot5Q==", + "version": "0.2.0-beta.9", + "resolved": "https://registry.npmjs.org/@perses-dev/spec/-/spec-0.2.0-beta.9.tgz", + "integrity": "sha512-hB+xmz5nnFWOypYF8UBzqL8YuHaqKVHzRBVlFj6f88ZtLw3j8bxrnMkQ/bSGwgsrEKzbdKWrCrtraokzl6nccA==", "license": "Apache-2.0", "dependencies": { "date-fns": "^4.1.0", @@ -4196,30 +4421,30 @@ } }, "node_modules/@rspack/binding": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-2.1.2.tgz", - "integrity": "sha512-/mFcRSUW7Pl19KeaBIujJvZYNJQu0wD5D3aa5h+Qcph26v7nmLYlX7eajIHGi8tt2qTZX1lXifw2KLIXKwYaRQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-2.1.4.tgz", + "integrity": "sha512-iye4BaTYtTt0qa39avWwEsUobVxFNhQHr6B8reFeKU7sdaZsC9LUoDR8JAt5gOnbnzFMPdKgrdU/VzkC6rXbIg==", "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.4", + "@rspack/binding-darwin-x64": "2.1.4", + "@rspack/binding-linux-arm64-gnu": "2.1.4", + "@rspack/binding-linux-arm64-musl": "2.1.4", + "@rspack/binding-linux-riscv64-gnu": "2.1.4", + "@rspack/binding-linux-riscv64-musl": "2.1.4", + "@rspack/binding-linux-x64-gnu": "2.1.4", + "@rspack/binding-linux-x64-musl": "2.1.4", + "@rspack/binding-wasm32-wasi": "2.1.4", + "@rspack/binding-win32-arm64-msvc": "2.1.4", + "@rspack/binding-win32-ia32-msvc": "2.1.4", + "@rspack/binding-win32-x64-msvc": "2.1.4" } }, "node_modules/@rspack/binding-darwin-arm64": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-2.1.2.tgz", - "integrity": "sha512-IYcxareUOYJZz+uNMSIwn+iDRiVyjZNOjoxO/zL4OFaPK8Ncrw0ka/9DqL9Gd7OpnAXN1zK3uS8yD0O1yIYI3Q==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-2.1.4.tgz", + "integrity": "sha512-3Xcs01iw48F4WeE4SHga6bCNb/UEFvtQX4P4eMIaJfGPjTQuxfabGE8yCPm9e3tpLZ5uo+IBnJ6nh5r6tIDOXQ==", "cpu": [ "arm64" ], @@ -4231,9 +4456,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.4", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-2.1.4.tgz", + "integrity": "sha512-bz/AsCplLs+3fXULPQU9d4r8H4PdeljgHFyItvVIrA/NKZqzQ8sX0topf/zJVZAOtPH7GNnrKjq0/F0U2DHikQ==", "cpu": [ "x64" ], @@ -4245,9 +4470,9 @@ "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.4", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-2.1.4.tgz", + "integrity": "sha512-x0HQTLU1MusCtNamuXxf3ayEPkvh9uuaq4wVyBqveRkn4FznSOoHUsxTAKMnjGARX+vdLV/y/SwWJRDp2RI4zw==", "cpu": [ "arm64" ], @@ -4259,9 +4484,9 @@ "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.4", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-2.1.4.tgz", + "integrity": "sha512-SEYCQD9UflKJMkYGnG5nt2gcsqdkgJsQmryBC/jxo+bOuY9gUSReU99FqCt7WRQrsHLbGeAFeTWs1QzItWG/Bw==", "cpu": [ "arm64" ], @@ -4273,9 +4498,9 @@ "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.4", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-2.1.4.tgz", + "integrity": "sha512-jYtQKtnDRaVfyasvTGY04Z7m+xDWZYVwAIEOB4hP7czM7FVLOMgHlMlvw/EgF0DNHrBthqbPfBIS2tP50CzpEA==", "cpu": [ "riscv64" ], @@ -4287,9 +4512,9 @@ "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.4", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-2.1.4.tgz", + "integrity": "sha512-ZgxKjQAm9pidq2kChQO2PqKI9OQpLuGD7iPBuyT0gQg3m1+7vvbbkArLBPzJ12CQHVZvqua7n/QGx8pQjJI2Zw==", "cpu": [ "riscv64" ], @@ -4301,9 +4526,9 @@ "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.4", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-2.1.4.tgz", + "integrity": "sha512-C53B3e6M4yzlYn4hDxR9cHZV+HqkfFGR0zuhH8QCfdBfN5KyGsmXmujiFU85ANEvBgf9CF8VreBQeJ20lyto/g==", "cpu": [ "x64" ], @@ -4315,9 +4540,9 @@ "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.4", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-2.1.4.tgz", + "integrity": "sha512-UaeG3FRo7e5RameQvWRNQ6KeXF29LEk67U/ohb9tF4U38mKH1OGqhmwCf5yLkqiOSgOi7Ff3ireOZD83Fso1iw==", "cpu": [ "x64" ], @@ -4329,9 +4554,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.4", + "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-2.1.4.tgz", + "integrity": "sha512-0P1WZEfu7JOPzD/jQfk9U/6gnRFc7RpvYCQaYZVVYZNJ2gU6O0/yLegRGKNK/2L0zjYwiD0ynhOIBVUUERf5Pw==", "cpu": [ "wasm32" ], @@ -4339,15 +4564,15 @@ "optional": true, "peer": true, "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", "@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.4", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-2.1.4.tgz", + "integrity": "sha512-+aStQipk1EakLRPfD+/aQbtmTXfxqSWetcRRDWV3cAsD4ebv1tF8FLKYY1PCaFpQbX1FxzCBGF0KHxkAsi9cxA==", "cpu": [ "arm64" ], @@ -4359,9 +4584,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.4", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-2.1.4.tgz", + "integrity": "sha512-7nyrLRQ07j6i6omZuwiwwTI6rpjdy/Su3niLDAG7WsLL21u3/UQQrw6Fvm8SH3XYteghoHLSM3dhgaisuUhdJg==", "cpu": [ "ia32" ], @@ -4373,9 +4598,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.4", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-2.1.4.tgz", + "integrity": "sha512-Z4je7JBaDpO9vvNMgCxlycycRJq+GQwwuXSXagW2xvvGM10Ij63YVtIehSMG9xaW8PED41oc9nWndoEsiD60pA==", "cpu": [ "x64" ], @@ -4387,13 +4612,13 @@ "peer": true }, "node_modules/@rspack/core": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@rspack/core/-/core-2.1.2.tgz", - "integrity": "sha512-crpNQKhHfnzrIl4Sa4fjH30Ho5aAPgyqpmJZ41SkUFOzyKHdZKYfE5LF3CMh7MiFQFPPxiiKf5BcpxmtZZx4MQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@rspack/core/-/core-2.1.4.tgz", + "integrity": "sha512-lpJgtr+JAXuDAMBJfRJ1LHyWVuYJyhZu6L6aj9t4lipUU03qwakHnzn3vwSCr68PsVvVPzR6NbJE1gJienSV0g==", "license": "MIT", "peer": true, "dependencies": { - "@rspack/binding": "2.1.2" + "@rspack/binding": "2.1.4" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -4411,6 +4636,13 @@ } } }, + "node_modules/@rspack/lite-tapable": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.1.2.tgz", + "integrity": "sha512-1OnyWChLGE46YzWyjlmYJssOu/Y0STAnnr2ueKPqDCYTf63GJMs0mxNnCul4dNiVqHYPKv3/fxrTY3IpqoVwZQ==", + "extraneous": true, + "license": "MIT" + }, "node_modules/@rspack/plugin-react-refresh": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/@rspack/plugin-react-refresh/-/plugin-react-refresh-1.6.2.tgz", @@ -4445,9 +4677,9 @@ "license": "MIT" }, "node_modules/@sinclair/typebox": { - "version": "0.34.49", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", - "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", "dev": true, "license": "MIT" }, @@ -5177,9 +5409,9 @@ "license": "MIT" }, "node_modules/@turbo/darwin-64": { - "version": "2.10.2", - "resolved": "https://registry.npmjs.org/@turbo/darwin-64/-/darwin-64-2.10.2.tgz", - "integrity": "sha512-wBM3ObqOWnKUDmg7QfUFDkDHPFUAJmrYlYqmEM8jMPAPA/I6wRJIbWimeQUqhOiQ8xPKhzyWM+xaiUP0wz8FEQ==", + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/@turbo/darwin-64/-/darwin-64-2.10.5.tgz", + "integrity": "sha512-ENvPwy3x5yS7MwNYHeWjqOBXkwIMp39Pd+/zXC6PoiNzF8EIvvLZOZZ+ny6L9x4WgS5vxUii2LM5gM+zjPdnWw==", "cpu": [ "x64" ], @@ -5191,9 +5423,9 @@ ] }, "node_modules/@turbo/darwin-arm64": { - "version": "2.10.2", - "resolved": "https://registry.npmjs.org/@turbo/darwin-arm64/-/darwin-arm64-2.10.2.tgz", - "integrity": "sha512-/Cq0joWnuMjDPfhjbFP4sv+C/7gkQ415zlaO4XUzD5EZxbtrKgXKvuuydMvogG8GeUnN1aDltW71RlmEfpjbyw==", + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/@turbo/darwin-arm64/-/darwin-arm64-2.10.5.tgz", + "integrity": "sha512-rqROo9zsF/P9RqsdtbLD1nFJicjSrYyvQ9kNJC38AbxA3pAs6VAlATvtvOFx7bqOv6vicf20SP9kF33avJjy2w==", "cpu": [ "arm64" ], @@ -5205,9 +5437,9 @@ ] }, "node_modules/@turbo/linux-64": { - "version": "2.10.2", - "resolved": "https://registry.npmjs.org/@turbo/linux-64/-/linux-64-2.10.2.tgz", - "integrity": "sha512-mMsf5IIhiKuceEXNstd25IbadjBXZ0amxzFOqliEzJX6HyeeHdBQPVSY583PWqYDyqM/FB8d5ZjkthfBSeuH3Q==", + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/@turbo/linux-64/-/linux-64-2.10.5.tgz", + "integrity": "sha512-RoSSiNFUxi27zLJuM9F6GyWWjHgLch9t6nwD6K0FkXRirZkTLlzIj6IhFnK8H9++nefLtdFqylE4vGjZAv6AAA==", "cpu": [ "x64" ], @@ -5219,9 +5451,9 @@ ] }, "node_modules/@turbo/linux-arm64": { - "version": "2.10.2", - "resolved": "https://registry.npmjs.org/@turbo/linux-arm64/-/linux-arm64-2.10.2.tgz", - "integrity": "sha512-Wcng1i2kaKmXutmwxT9MUoYZvdaIekXAdlGr4+0TpgbhGLw7nDuEcRBFrxb5BbRoX1d1q8SpdRxLc45TvDZIdQ==", + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/@turbo/linux-arm64/-/linux-arm64-2.10.5.tgz", + "integrity": "sha512-4ZComcpzmHGmVynQqvvi+iZOSq/tBvY1SltXB8g4NZRsrA01W8E+yRL8RNM+PLoyWsrCnJa8xa+DkWkv+xg4iQ==", "cpu": [ "arm64" ], @@ -5233,9 +5465,9 @@ ] }, "node_modules/@turbo/windows-64": { - "version": "2.10.2", - "resolved": "https://registry.npmjs.org/@turbo/windows-64/-/windows-64-2.10.2.tgz", - "integrity": "sha512-SsNhM7Ho7EpAdwtrJKBOic9Hso23vu6Dp0gAfLOvUFjPzurr/sGQlXZEvr6z89ne4RDOypTwz5CBDrixpMKtXw==", + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/@turbo/windows-64/-/windows-64-2.10.5.tgz", + "integrity": "sha512-eL2Iyj4DbMINq1Sr1w0iAi6nAiZOF16KSlRGwCJpVh+IWZeY33MAsLHVOBMj1xoFtncVJXclCVpTPL2nBoYkFg==", "cpu": [ "x64" ], @@ -5247,9 +5479,9 @@ ] }, "node_modules/@turbo/windows-arm64": { - "version": "2.10.2", - "resolved": "https://registry.npmjs.org/@turbo/windows-arm64/-/windows-arm64-2.10.2.tgz", - "integrity": "sha512-Gf+S7ICAdimT/n02bOuVWKvhHnct/HYjZg3oBNIz5hZ9ZyWHbQim9J3P5Qip8WpX0ksxF7eaBVziJCuLnjhqDg==", + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/@turbo/windows-arm64/-/windows-arm64-2.10.5.tgz", + "integrity": "sha512-sog+wP+8YSJrdWZ/rUJg8xghVTrwoG+BrSlDQpnK5fzSgJHn1INRWXbVWRH0d3vX8dBI01E3yxXRre9Dn+OXQA==", "cpu": [ "arm64" ], @@ -5347,14 +5579,49 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@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/node": "*" + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" } }, "node_modules/@types/express": { @@ -5370,9 +5637,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", - "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.2.tgz", + "integrity": "sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg==", "dev": true, "license": "MIT", "dependencies": { @@ -5496,9 +5763,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", - "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", "dev": true, "license": "MIT", "dependencies": { @@ -5562,12 +5829,6 @@ "@types/react": "*" } }, - "node_modules/@types/semver": { - "version": "7.5.8", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", - "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", - "license": "MIT" - }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -5628,17 +5889,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", - "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", + "integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/type-utils": "8.62.1", - "@typescript-eslint/utils": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/type-utils": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -5651,23 +5912,23 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.62.1", + "@typescript-eslint/parser": "^8.64.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", - "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz", + "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", "debug": "^4.4.3" }, "engines": { @@ -5683,14 +5944,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", - "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz", + "integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.62.1", - "@typescript-eslint/types": "^8.62.1", + "@typescript-eslint/tsconfig-utils": "^8.64.0", + "@typescript-eslint/types": "^8.64.0", "debug": "^4.4.3" }, "engines": { @@ -5705,14 +5966,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", - "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz", + "integrity": "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1" + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5723,9 +5984,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", - "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz", + "integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==", "dev": true, "license": "MIT", "engines": { @@ -5740,15 +6001,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", - "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz", + "integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1", - "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -5765,9 +6026,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", - "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz", + "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", "dev": true, "license": "MIT", "engines": { @@ -5779,16 +6040,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", - "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz", + "integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.62.1", - "@typescript-eslint/tsconfig-utils": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", + "@typescript-eslint/project-service": "8.64.0", + "@typescript-eslint/tsconfig-utils": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -5845,30 +6106,17 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", - "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz", + "integrity": "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1" + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5883,13 +6131,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", - "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz", + "integrity": "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/types": "8.64.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -5914,9 +6162,9 @@ } }, "node_modules/@uiw/codemirror-extensions-basic-setup": { - "version": "4.25.10", - "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.10.tgz", - "integrity": "sha512-P3vytLlpE62KYSWrMUnwDCv2lvaQDuDZzyj03mHntuHo5bSl34fRZpjTY3kQTPGuXHxkGSYpoPFFj+hMTqaaMQ==", + "version": "4.25.11", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.11.tgz", + "integrity": "sha512-otyFa+n9IOYtEjaKOxPedHkj15fTPUF21wdR9pv0GpZPfuGl27cvmcv6+tognbRu9VvEcsHKE+ESoszeo3KfTw==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -5941,16 +6189,16 @@ } }, "node_modules/@uiw/react-codemirror": { - "version": "4.25.10", - "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.10.tgz", - "integrity": "sha512-DzgSMwM5qzB7v1FIb4gEeriYt67iiay756/HIOM9mAbeOVK0MO7rqefHf0O5c0269pJKMW7AH9FjclExD23V9w==", + "version": "4.25.11", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.11.tgz", + "integrity": "sha512-DYVFAKLX+F/4JS9N/7xexh+TICrlncwkX9HKKInrP1bwO0tSfc3k0GB6oawTYhelVKh20cX3TuRx+NJSkVXuMw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.6", "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", - "@uiw/codemirror-extensions-basic-setup": "4.25.10", + "@uiw/codemirror-extensions-basic-setup": "4.25.11", "codemirror": "^6.0.0" }, "funding": { @@ -5967,9 +6215,9 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", - "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", "dev": true, "license": "ISC" }, @@ -6609,15 +6857,6 @@ "node": "*" } }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -7103,9 +7342,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.40", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", - "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -7268,9 +7507,9 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "dev": true, "license": "MIT", "dependencies": { @@ -7323,9 +7562,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -7333,9 +7572,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", - "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", "dev": true, "funding": [ { @@ -7353,10 +7592,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.38", - "caniuse-lite": "^1.0.30001799", - "electron-to-chromium": "^1.5.376", - "node-releases": "^2.0.48", + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -7538,9 +7777,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001800", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", - "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "version": "1.0.30001805", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", + "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==", "dev": true, "funding": [ { @@ -7975,18 +8214,6 @@ "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", "license": "MIT" }, - "node_modules/cron-parser": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", - "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", - "license": "MIT", - "dependencies": { - "luxon": "^3.2.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -8048,6 +8275,111 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "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/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -8059,7 +8391,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -8341,9 +8673,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -8394,9 +8726,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.384", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.384.tgz", - "integrity": "sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==", + "version": "1.5.392", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.392.tgz", + "integrity": "sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g==", "dev": true, "license": "ISC" }, @@ -8579,9 +8911,9 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.3.tgz", - "integrity": "sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz", + "integrity": "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==", "dev": true, "license": "MIT", "dependencies": { @@ -8820,9 +9152,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.13.0.tgz", - "integrity": "sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ==", + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", "dev": true, "license": "MIT", "dependencies": { @@ -8882,9 +9214,9 @@ } }, "node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -8979,9 +9311,9 @@ } }, "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -9080,9 +9412,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -9181,9 +9513,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -9349,18 +9681,6 @@ "node": ">= 0.8.0" } }, - "node_modules/expand-tilde": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", - "integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==", - "license": "MIT", - "dependencies": { - "homedir-polyfill": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/expect": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", @@ -9581,7 +9901,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -9714,30 +10034,6 @@ "dev": true, "license": "MIT" }, - "node_modules/find-file-up": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/find-file-up/-/find-file-up-2.0.1.tgz", - "integrity": "sha512-qVdaUhYO39zmh28/JLQM5CoYN9byEOKEH4qfa8K1eNV17W0UUMJ9WgbR/hHFH+t5rcl+6RTb5UC7ck/I+uRkpQ==", - "license": "MIT", - "dependencies": { - "resolve-dir": "^1.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-pkg": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/find-pkg/-/find-pkg-2.0.0.tgz", - "integrity": "sha512-WgZ+nKbELDa6N3i/9nrHeNznm+lY3z4YfhDDWgW+5P0pdmMj26bxaxU11ookgY3NyP9GC7HvZ9etp0jRFqGEeQ==", - "license": "MIT", - "dependencies": { - "find-file-up": "^2.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -9848,7 +10144,7 @@ "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "fetch-blob": "^3.1.2" @@ -10128,54 +10424,6 @@ "node": ">=10.13.0" } }, - "node_modules/global-modules": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", - "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", - "license": "MIT", - "dependencies": { - "global-prefix": "^1.0.1", - "is-windows": "^1.0.1", - "resolve-dir": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/global-prefix": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", - "integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==", - "license": "MIT", - "dependencies": { - "expand-tilde": "^2.0.2", - "homedir-polyfill": "^1.0.1", - "ini": "^1.3.4", - "is-windows": "^1.0.1", - "which": "^1.2.14" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/global-prefix/node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/global-prefix/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -10403,18 +10651,6 @@ "license": "MIT", "peer": true }, - "node_modules/homedir-polyfill": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", - "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", - "license": "MIT", - "dependencies": { - "parse-passwd": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -10550,9 +10786,9 @@ "license": "BSD-3-Clause" }, "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { @@ -10644,12 +10880,6 @@ "dev": true, "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, "node_modules/inspect-with-kind": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/inspect-with-kind/-/inspect-with-kind-1.0.5.tgz", @@ -11164,15 +11394,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-windows": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -12243,19 +12464,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jest-util": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", @@ -12639,12 +12847,6 @@ "dev": true, "license": "MIT" }, - "node_modules/long-timeout": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", - "integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==", - "license": "MIT" - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -12680,15 +12882,6 @@ "yallist": "^3.0.2" } }, - "node_modules/luxon": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", - "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -13016,7 +13209,7 @@ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", "deprecated": "Use your platform's native DOMException instead", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -13065,7 +13258,7 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "data-uri-to-buffer": "^4.0.0", @@ -13088,29 +13281,15 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "dev": true, "license": "MIT", "engines": { "node": ">=18" } }, - "node_modules/node-schedule": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/node-schedule/-/node-schedule-2.1.1.tgz", - "integrity": "sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==", - "license": "MIT", - "dependencies": { - "cron-parser": "^4.2.0", - "long-timeout": "0.1.1", - "sorted-array-functions": "^1.3.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -13541,15 +13720,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-passwd": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", - "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -13664,9 +13834,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -13833,9 +14003,9 @@ } }, "node_modules/prettier": { - "version": "3.9.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", - "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", "dev": true, "license": "MIT", "peer": true, @@ -14080,9 +14250,9 @@ } }, "node_modules/react-colorful": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.7.0.tgz", - "integrity": "sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.8.0.tgz", + "integrity": "sha512-Wy9OzPfjSN9bF12OB8N7UQvlsZ0I+7wHxpN+bV5BjNQGxOj6IiwkRjevJK9yOBjJWGQvAaf1OXtn8rUeEatAng==", "license": "MIT", "peerDependencies": { "react": ">=16.8.0", @@ -14152,9 +14322,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.80.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.80.0.tgz", - "integrity": "sha512-4P+fk6oXsxY+6xSj7Euhc2sumQD8zQqCuVHoJwoyp9EchP+IUW9OESB7uHFJOKsIBQ4MQqYE84INJFqUCYNoOg==", + "version": "7.81.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.81.0.tgz", + "integrity": "sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -14382,18 +14552,23 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "license": "MIT", + "peer": true, "dependencies": { - "is-core-module": "^2.13.0", + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -14428,19 +14603,6 @@ "node": ">=8" } }, - "node_modules/resolve-dir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", - "integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==", - "license": "MIT", - "dependencies": { - "expand-tilde": "^2.0.0", - "global-modules": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -14495,9 +14657,9 @@ } }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -14801,9 +14963,9 @@ } }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -15128,12 +15290,6 @@ "node": ">=0.10.0" } }, - "node_modules/sorted-array-functions": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz", - "integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==", - "license": "MIT" - }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -15698,9 +15854,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -16014,21 +16170,21 @@ "license": "0BSD" }, "node_modules/turbo": { - "version": "2.10.2", - "resolved": "https://registry.npmjs.org/turbo/-/turbo-2.10.2.tgz", - "integrity": "sha512-wTExrNrRjB8qzIcg+ZLm0A3GFNLDsWNwdS/RBXB0FPrBDyzk3i96Yx+TxWZC7a0k1SIreFB8ciUbxjmEqTH8IQ==", + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/turbo/-/turbo-2.10.5.tgz", + "integrity": "sha512-07Y/C7OUp23l4P92PJoYtFNbHjLhftrZH5Ce7dbczS4kX2Re+wtbXvZLoxn/pUtzgsQaRCBaRuZPJp4zmAn0WQ==", "dev": true, "license": "MIT", "bin": { "turbo": "bin/turbo" }, "optionalDependencies": { - "@turbo/darwin-64": "2.10.2", - "@turbo/darwin-arm64": "2.10.2", - "@turbo/linux-64": "2.10.2", - "@turbo/linux-arm64": "2.10.2", - "@turbo/windows-64": "2.10.2", - "@turbo/windows-arm64": "2.10.2" + "@turbo/darwin-64": "2.10.5", + "@turbo/darwin-arm64": "2.10.5", + "@turbo/linux-64": "2.10.5", + "@turbo/linux-arm64": "2.10.5", + "@turbo/windows-64": "2.10.5", + "@turbo/windows-arm64": "2.10.5" } }, "node_modules/type-check": { @@ -16300,16 +16456,6 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" } }, - "node_modules/upath": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", - "integrity": "sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==", - "license": "MIT", - "engines": { - "node": ">=4", - "yarn": "*" - } - }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -16488,7 +16634,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 8" diff --git a/package.json b/package.json index d4ce6c680..69934945f 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,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: '=' }],