From e5d96111f3a5a229cd3fb4c9fe227ea964892185 Mon Sep 17 00:00:00 2001 From: Markus Wieland Date: Fri, 23 Oct 2026 15:57:09 +0200 Subject: [PATCH 1/9] [FEATURE] Generate graphs from query data Add a data-driven mode to the canvas panel that derives nodes and edges live from query results instead of a hand-drawn graph, addressing the need to visualize dynamic topologies (e.g. frequently changing service or host relationships) where manual authoring is not feasible. Closes perses/perses#4634 - Infer graph structure from series labels: one node per distinct node-id label value, edges from configurable source/target labels (endpoints not among the generated nodes are dropped). - Lay out generated graphs with a deterministic, dependency-free force simulation so a given topology always settles to the same positions; nodes only move when nodes/edges are added or removed, not when metric values change. - Drive visuals from each series' last value: node color via panel thresholds, optional node-size scaling via per-threshold multipliers, edge color via dedicated edge thresholds (falling back to node thresholds), edge thickness via edge-thickness thresholds, and label interpolation for node/edge captions. - Add a Manual / From data mode toggle to the settings editor with the shared styling controls plus the new graph-generation and node-size panels. - Switch the read-only panel to view-mode zoom/pan (plain wheel zoom, left/middle drag pan) and clip edges to node perimeters so lines and arrowheads touch the shape boundary. - Keep every representation coherent: TypeScript runtime/editor, CUE schema, Go SDK builders, docs, and tests. Signed-off-by: Philipp Matthes --- canvas/README.md | 39 +++++ canvas/schemas/panels/canvas/canvas.cue | 22 +++ canvas/sdk/go/canvas.go | 96 ++++++++++- canvas/src/components/panel/CanvasPanel.tsx | 96 +++++++++-- .../src/components/panel/PanelEdgeLayer.tsx | 62 ++++--- .../src/components/panel/PanelNodeLayer.tsx | 14 +- .../settings/GlobalSettingsEditor.tsx | 127 +++++++++++--- .../settings/GraphGenerationSettings.tsx | 152 +++++++++++++++++ .../components/settings/NodeSizeSettings.tsx | 146 +++++++++++++++++ canvas/src/components/shared/IconNode.tsx | 22 ++- canvas/src/hooks/useZoom.ts | 37 ++++- canvas/src/model.ts | 40 +++++ canvas/src/utils/edgeUtils.test.ts | 32 ++++ canvas/src/utils/edgeUtils.ts | 52 ++++++ canvas/src/utils/graphGeneration.test.ts | 138 ++++++++++++++++ canvas/src/utils/graphGeneration.ts | 123 ++++++++++++++ canvas/src/utils/graphLayout.test.ts | 94 +++++++++++ canvas/src/utils/graphLayout.ts | 155 ++++++++++++++++++ canvas/src/utils/panelUtils.test.ts | 34 +++- canvas/src/utils/panelUtils.ts | 24 ++- docs/canvas/examples/README.md | 96 +++++++++++ docs/canvas/examples/dashboard.json | 105 ++++++++++++ docs/canvas/examples/docker-compose.yml | 17 ++ docs/canvas/examples/metrics/nginx.conf | 9 + docs/canvas/examples/metrics/topology.prom | 73 +++++++++ 25 files changed, 1730 insertions(+), 75 deletions(-) create mode 100644 canvas/src/components/settings/GraphGenerationSettings.tsx create mode 100644 canvas/src/components/settings/NodeSizeSettings.tsx create mode 100644 canvas/src/utils/graphGeneration.test.ts create mode 100644 canvas/src/utils/graphGeneration.ts create mode 100644 canvas/src/utils/graphLayout.test.ts create mode 100644 canvas/src/utils/graphLayout.ts create mode 100644 docs/canvas/examples/README.md create mode 100644 docs/canvas/examples/dashboard.json create mode 100644 docs/canvas/examples/docker-compose.yml create mode 100644 docs/canvas/examples/metrics/nginx.conf create mode 100644 docs/canvas/examples/metrics/topology.prom diff --git a/canvas/README.md b/canvas/README.md index 98ac5e3a6..b1b035852 100644 --- a/canvas/README.md +++ b/canvas/README.md @@ -1,5 +1,44 @@ # Plugin Module: canvas +The canvas panel renders weathermap-style network diagrams. Nodes and edges can be colored by +query-bound thresholds, labeled with metric values, and (for edges) scaled in thickness by value. + +The panel has two modes, selected in the panel's **Settings** editor under **Items**: + +- **Manual** — draw the graph by hand: place nodes, connect them with edges, and add background + shapes or images. Each node/edge can bind to a query to drive its color and label. +- **From data** — generate the graph live from query results. Use this for dynamic topologies + (for example, frequently changing service or host relationships) where drawing by hand is not + feasible. + +### Data-driven mode + +In **From data** mode the panel derives nodes and edges from your queries on every refresh and +lays them out automatically — no manual positioning. Graph structure is inferred from the labels on +the returned time series, and each series' **last value** drives color (via the panel thresholds), +label interpolation, and edge thickness. + +Configuration: + +- **Node query** — the query whose series define the nodes. +- **Node id label** — the series label that identifies a node (e.g. `instance`). One node is created + per distinct label value. +- **Node label template** — optional caption, using `{{label_name}}` / `{{value}}` placeholders. + Defaults to the node id label. +- **Edge query** — the query whose series define the edges (leave as *None* for a nodes-only graph). +- **Edge source label** / **Edge target label** — series labels whose values name the edge's source + and target nodes. Edges whose endpoints are not among the generated nodes are dropped. +- **Edge label template** — optional edge caption (e.g. `{{value}}`). +- **Bidirectional edges** — render edges with arrows in both directions. + +Node colors come from the **Node color** thresholds configured for the panel, and **Node size** +thresholds can scale a node's size by its value (relative to the configurable default node size). +Edges use the **Edge color** thresholds when set and otherwise fall back to the node thresholds, so +edges can carry their own color scheme independent of the nodes. Edge thickness uses the **Edge +thickness** thresholds. Layout is deterministic: a given topology always settles to the same +positions, so nodes stay put as their metric values change — they only move when nodes or edges are +added or removed. + ### How to install This plugin requires react and react-dom 18 diff --git a/canvas/schemas/panels/canvas/canvas.cue b/canvas/schemas/panels/canvas/canvas.cue index 89b9b2b95..12ce29fc4 100644 --- a/canvas/schemas/panels/canvas/canvas.cue +++ b/canvas/schemas/panels/canvas/canvas.cue @@ -21,15 +21,31 @@ kind: "Canvas" spec: close({ legend?: #legend thresholds?: common.#thresholds + edgeThresholds?: common.#thresholds format?: common.#format querySettings?: #querySettings edgeDefaultStrokeWidth?: number & >0 edgeThresholdWidths?: [...#edgeThresholdStep] + nodeThresholdSizes?: [...#nodeThresholdStep] + nodeDefaultSize?: number & >0 backgrounds?: [...#background] nodes?: [...#node] edges?: [...#edge] + graphGeneration?: #graphGeneration }) +#graphGeneration: { + enabled?: bool + nodeQueryIndex: int & >=0 + nodeIdLabel: string + nodeLabelTemplate?: string + edgeQueryIndex?: int & >=0 + edgeSourceLabel?: string + edgeTargetLabel?: string + edgeLabelTemplate?: string + bidirectional?: bool +} + #legend: { position: "bottom" | "right" } @@ -75,6 +91,7 @@ spec: close({ queryIndex?: int & >=0 colorMode?: "threshold" | "fixed" color?: =~"^#(?:[0-9a-fA-F]{3}){1,2}$" + sizeMode?: "fixed" | "threshold" } #edge: { @@ -101,3 +118,8 @@ spec: close({ value: number strokeWidth: number & >0 } + +#nodeThresholdStep: { + value: number + scale: number & >0 +} diff --git a/canvas/sdk/go/canvas.go b/canvas/sdk/go/canvas.go index d0e568873..25f02f223 100644 --- a/canvas/sdk/go/canvas.go +++ b/canvas/sdk/go/canvas.go @@ -65,6 +65,13 @@ const ( ThresholdThicknessMode ThicknessMode = "threshold" ) +type SizeMode string + +const ( + FixedSizeMode SizeMode = "fixed" + ThresholdSizeMode SizeMode = "threshold" +) + type ImageFit string const ( @@ -94,7 +101,8 @@ type NodeSpec struct { QueryIndex *uint `json:"queryIndex,omitempty" yaml:"queryIndex,omitempty"` ColorMode ColorMode `json:"colorMode,omitempty" yaml:"colorMode,omitempty"` // Color is a hex color string (e.g. "#ff0000"). - Color string `json:"color,omitempty" yaml:"color,omitempty"` + Color string `json:"color,omitempty" yaml:"color,omitempty"` + SizeMode SizeMode `json:"sizeMode,omitempty" yaml:"sizeMode,omitempty"` } type EdgeSpec struct { @@ -132,21 +140,46 @@ type EdgeThresholdStep struct { StrokeWidth float64 `json:"strokeWidth" yaml:"strokeWidth"` } +type NodeThresholdStep struct { + Value float64 `json:"value" yaml:"value"` + // Scale multiplies the node's base width/height. + Scale float64 `json:"scale" yaml:"scale"` +} + type QueryColorSettings struct { QueryIndex uint `json:"queryIndex" yaml:"queryIndex"` ColorMode ColorMode `json:"colorMode" yaml:"colorMode"` ColorValue string `json:"colorValue" yaml:"colorValue"` } +// GraphGenerationSpec configures the data-driven graph mode. When Enabled, the panel derives nodes +// and edges live from query results and lays them out automatically; graph structure is inferred +// from series labels. +type GraphGenerationSpec struct { + Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + NodeQueryIndex uint `json:"nodeQueryIndex" yaml:"nodeQueryIndex"` + NodeIDLabel string `json:"nodeIdLabel" yaml:"nodeIdLabel"` + NodeLabelTemplate string `json:"nodeLabelTemplate,omitempty" yaml:"nodeLabelTemplate,omitempty"` + EdgeQueryIndex *uint `json:"edgeQueryIndex,omitempty" yaml:"edgeQueryIndex,omitempty"` + EdgeSourceLabel string `json:"edgeSourceLabel,omitempty" yaml:"edgeSourceLabel,omitempty"` + EdgeTargetLabel string `json:"edgeTargetLabel,omitempty" yaml:"edgeTargetLabel,omitempty"` + EdgeLabelTemplate string `json:"edgeLabelTemplate,omitempty" yaml:"edgeLabelTemplate,omitempty"` + Bidirectional bool `json:"bidirectional,omitempty" yaml:"bidirectional,omitempty"` +} + 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"` + Thresholds *common.Thresholds `json:"thresholds,omitempty" yaml:"thresholds,omitempty"` + EdgeThresholds *common.Thresholds `json:"edgeThresholds,omitempty" yaml:"edgeThresholds,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"` + NodeThresholdSizes []NodeThresholdStep `json:"nodeThresholdSizes,omitempty" yaml:"nodeThresholdSizes,omitempty"` + NodeDefaultSize *float64 `json:"nodeDefaultSize,omitempty" yaml:"nodeDefaultSize,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"` + GraphGeneration *GraphGenerationSpec `json:"graphGeneration,omitempty" yaml:"graphGeneration,omitempty"` } type Option func(plugin *Builder) error @@ -180,3 +213,48 @@ func Chart(options ...Option) panel.Option { return nil } } + +// WithGraphGeneration enables the data-driven graph mode, deriving nodes and edges from query +// results instead of a manually authored graph. +func WithGraphGeneration(spec GraphGenerationSpec) Option { + return func(builder *Builder) error { + builder.GraphGeneration = &spec + return nil + } +} + +// WithThresholds sets the color scheme applied to nodes. +func WithThresholds(thresholds common.Thresholds) Option { + return func(builder *Builder) error { + builder.Thresholds = &thresholds + return nil + } +} + +// WithEdgeThresholds sets a color scheme applied to edges. When unset, edges fall back to the node +// thresholds configured with WithThresholds. +func WithEdgeThresholds(thresholds common.Thresholds) Option { + return func(builder *Builder) error { + builder.EdgeThresholds = &thresholds + return nil + } +} + +// WithNodeThresholdSizes scales nodes by their bound value. Each step's Scale multiplies the node's +// base width/height when the value reaches the step's Value. Only applies to nodes whose SizeMode is +// "threshold" (generated nodes set this automatically). +func WithNodeThresholdSizes(steps ...NodeThresholdStep) Option { + return func(builder *Builder) error { + builder.NodeThresholdSizes = steps + return nil + } +} + +// WithNodeDefaultSize sets the base width/height (px) for generated nodes, which the node-size +// thresholds scale. Defaults to 48 when unset. +func WithNodeDefaultSize(size float64) Option { + return func(builder *Builder) error { + builder.NodeDefaultSize = &size + return nil + } +} diff --git a/canvas/src/components/panel/CanvasPanel.tsx b/canvas/src/components/panel/CanvasPanel.tsx index 7ae7317ca..95abfff51 100644 --- a/canvas/src/components/panel/CanvasPanel.tsx +++ b/canvas/src/components/panel/CanvasPanel.tsx @@ -19,7 +19,10 @@ import { useCallback, useMemo } from 'react'; import { useZoomContext, ZoomProvider } from '../../contexts/ZoomContext'; import { useZoom } from '../../hooks/useZoom'; -import type { CanvasProps } from '../../model'; +import type { CanvasProps, CanvasSpec } from '../../model'; +import { buildGraphFromQueries } from '../../utils/graphGeneration'; +import { layoutGraph } from '../../utils/graphLayout'; +import { scaleFromThresholds } from '../../utils/panelUtils'; import { nodeBoundingBox } from '../../utils/resizeUtils'; import { BackgroundLayer, GlobalBackgroundLayer } from '../shared/BackgroundLayer'; import { PanelEdgeLayer } from './PanelEdgeLayer'; @@ -31,17 +34,54 @@ const EMPTY_THRESHOLDS: ThresholdOptions = {}; interface PanelSvgProps { svgRef: (node: SVGSVGElement | null) => void; - props: CanvasProps; + spec: CanvasSpec; + contentDimensions: CanvasProps['contentDimensions']; seriesByQueryIndex: Map; paletteColors: string[]; + seriesByItemId?: Map; } -function PanelSvg({ svgRef, props, seriesByQueryIndex, paletteColors }: PanelSvgProps): ReactElement { - const { contentDimensions, spec } = props; +function PanelSvg({ + svgRef, + spec, + contentDimensions, + seriesByQueryIndex, + paletteColors, + seriesByItemId, +}: PanelSvgProps): ReactElement { const { transform, isPanning, fitView, resetPan } = useZoomContext(); - const nodes = useMemo(() => spec.nodes ?? [], [spec.nodes]); - const backgrounds = useMemo(() => spec.backgrounds ?? [], [spec.backgrounds]); + // Resolve node sizes up front so both layers share one geometry: nodes render at their scaled + // size and edges clip to that same scaled boundary. Scaling only widens/heightens a node, which + // keeps it centered on its position. + const sizedSpec = useMemo(() => { + const steps = spec.nodeThresholdSizes; + const specNodes = spec.nodes ?? []; + if (!steps?.length || !specNodes.some((n) => n.sizeMode === 'threshold')) { + return spec; + } + const nextNodes = specNodes.map((node) => { + if (node.sizeMode !== 'threshold') { + return node; + } + const series = + seriesByItemId?.get(node.id) ?? + (node.queryIndex !== undefined ? seriesByQueryIndex.get(node.queryIndex) : undefined); + const lastValue = series?.values[series.values.length - 1]?.[1]; + if (lastValue === null || lastValue === undefined) { + return node; + } + const scale = scaleFromThresholds(lastValue, steps); + if (scale === 1) { + return node; + } + return Object.assign({}, node, { width: node.width * scale, height: node.height * scale }); + }); + return { ...spec, nodes: nextNodes }; + }, [spec, seriesByItemId, seriesByQueryIndex]); + + const nodes = useMemo(() => sizedSpec.nodes ?? [], [sizedSpec.nodes]); + const backgrounds = useMemo(() => sizedSpec.backgrounds ?? [], [sizedSpec.backgrounds]); const width = contentDimensions?.width ?? 600; const height = contentDimensions?.height ?? 400; @@ -76,16 +116,18 @@ function PanelSvg({ svgRef, props, seriesByQueryIndex, paletteColors }: PanelSvg @@ -102,7 +144,7 @@ function PanelSvg({ svgRef, props, seriesByQueryIndex, paletteColors }: PanelSvg } export function CanvasPanel(props: CanvasProps): ReactElement | null { - const { queryResults } = props; + const { queryResults, spec } = props; const chartsTheme = useChartsTheme(); const paletteColors = chartsTheme.thresholds.palette; @@ -117,7 +159,32 @@ export function CanvasPanel(props: CanvasProps): ReactElement | null { return map; }, [queryResults]); - const { svgRef, toCanvasPoint, transform, isPanning, fitView, resetPan } = useZoom(); + // Data-driven mode: derive nodes/edges from query results on every refresh, then lay them out + // with a deterministic force simulation. Because layout is a pure function of the topology, nodes + // settle to the same positions regardless of how metric values change between refreshes. + const generation = spec.graphGeneration; + const generated = useMemo( + () => (generation?.enabled ? buildGraphFromQueries(generation, queryResults, spec.nodeDefaultSize) : undefined), + [generation, queryResults, spec.nodeDefaultSize], + ); + const effectiveSpec: CanvasSpec = useMemo(() => { + if (!generated) { + return spec; + } + // layoutGraph centers the generated graph on the origin (0, 0). Under the identity zoom + // transform the origin is the SVG's top-left corner, so the graph would render clipped into that + // corner. Offset every node by the viewport center so the graph sits in the middle of the panel. + // layoutGraph returns freshly built node objects, so mutating position in place is safe here. + const offsetX = (props.contentDimensions?.width ?? 600) / 2; + const offsetY = (props.contentDimensions?.height ?? 400) / 2; + const nodes = layoutGraph(generated.nodes, generated.edges); + for (const node of nodes) { + node.position = { x: node.position.x + offsetX, y: node.position.y + offsetY }; + } + return { ...spec, nodes, edges: generated.edges }; + }, [spec, generated, props.contentDimensions?.width, props.contentDimensions?.height]); + + const { svgRef, toCanvasPoint, transform, isPanning, fitView, resetPan } = useZoom({ interactionMode: 'view' }); const zoomValue = useMemo( () => ({ toCanvasPoint, transform, isPanning, fitView, resetPan }), @@ -126,7 +193,14 @@ export function CanvasPanel(props: CanvasProps): ReactElement | null { return ( - + ); } diff --git a/canvas/src/components/panel/PanelEdgeLayer.tsx b/canvas/src/components/panel/PanelEdgeLayer.tsx index dbc185e7c..bbb0c9a4e 100644 --- a/canvas/src/components/panel/PanelEdgeLayer.tsx +++ b/canvas/src/components/panel/PanelEdgeLayer.tsx @@ -17,7 +17,7 @@ import { useMemo } from 'react'; import { useCanvasTheme } from '../../hooks/useCanvasTheme'; import type { CanvasSpec, ThicknessMode } from '../../model'; -import { edgeEndpoints, strokeWidthFromThresholds } from '../../utils/edgeUtils'; +import { clipLineToNodes, edgeEndpoints, strokeWidthFromThresholds } from '../../utils/edgeUtils'; import { colorFromThresholds, interpolateLabel } from '../../utils/panelUtils'; import { EdgeLabel } from '../shared/EdgeLabel'; import type { LineStyle } from '../shared/EdgeLines'; @@ -26,19 +26,14 @@ import { EdgeLines, edgeLabelPoints } from '../shared/EdgeLines'; const NS_PREFIX = 'wm-panel'; function resolveEdgeStyle( - queryIndex: number | undefined, + series: TimeSeries | undefined, thicknessMode: ThicknessMode | 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 }; } @@ -48,8 +43,11 @@ function resolveEdgeStyle( return { stroke: 'currentColor', strokeWidth: defaultWidth }; } - const stroke = spec.thresholds - ? colorFromThresholds(lastValue, spec.thresholds, paletteColors, fallbackColor) + // Edges use their own color scheme when configured, falling back to the node thresholds so that + // existing dashboards (which only set `thresholds`) keep their current edge colors. + const edgeThresholds = spec.edgeThresholds ?? spec.thresholds; + const stroke = edgeThresholds + ? colorFromThresholds(lastValue, edgeThresholds, paletteColors, fallbackColor) : 'currentColor'; const strokeWidth = thicknessMode === 'threshold' && spec.edgeThresholdWidths?.length @@ -63,9 +61,17 @@ interface PanelEdgeLayerProps { seriesByQueryIndex: Map; k: number; paletteColors: string[]; + /** Series backing generated edges (data-driven mode), keyed by edge id. Takes precedence over queryIndex. */ + seriesByItemId?: Map; } -export function PanelEdgeLayer({ spec, seriesByQueryIndex, k, paletteColors }: PanelEdgeLayerProps): ReactElement { +export function PanelEdgeLayer({ + spec, + seriesByQueryIndex, + k, + paletteColors, + seriesByItemId, +}: PanelEdgeLayerProps): ReactElement { const nodes = useMemo(() => spec.nodes ?? [], [spec.nodes]); const edges = useMemo(() => spec.edges ?? [], [spec.edges]); const nodeById = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]); @@ -74,36 +80,48 @@ export function PanelEdgeLayer({ spec, seriesByQueryIndex, k, paletteColors }: P return ( <> {edges.map((edge, i) => { - const pts = edgeEndpoints(edge, nodeById); - if (!pts) { + const rawPts = edgeEndpoints(edge, nodeById); + if (!rawPts) { return null; } - function resolveLabel(queryIndex: number | undefined, template: string | undefined): string | null { - if (queryIndex === undefined) { - return null; + // Trim each end that attaches to a node center (no explicit anchor) back to the node + // boundary, so the line and arrowhead touch the shape instead of running into its center. + const clipSource = edge.sourceAnchor ? undefined : nodeById.get(edge.source); + const clipTarget = edge.target && !edge.targetAnchor ? nodeById.get(edge.target) : undefined; + const pts = clipLineToNodes(rawPts, clipSource, clipTarget); + + const generatedSeries = seriesByItemId?.get(edge.id); + + function resolveSeries(queryIndex: number | undefined): TimeSeries | undefined { + if (generatedSeries) { + return generatedSeries; } - const series = seriesByQueryIndex.get(queryIndex); + return queryIndex !== undefined ? seriesByQueryIndex.get(queryIndex) : undefined; + } + + function resolveLabel(series: TimeSeries | undefined, template: string | undefined): string | null { if (!series) { return null; } return interpolateLabel(template ?? '{{value}}', series, spec.format); } + const fwdSeries = resolveSeries(edge.sourceQueryIndex); + const bwdSeries = resolveSeries(edge.targetQueryIndex); + const fwdStyle = resolveEdgeStyle( - edge.sourceQueryIndex, + fwdSeries, edge.thicknessMode, edge.strokeWidth, - seriesByQueryIndex, spec, paletteColors, fallbackColor, ); const bwdStyle = resolveEdgeStyle( - edge.targetQueryIndex, + bwdSeries, edge.thicknessMode, edge.strokeWidth, - seriesByQueryIndex, spec, paletteColors, fallbackColor, @@ -125,8 +143,8 @@ export function PanelEdgeLayer({ spec, seriesByQueryIndex, k, paletteColors }: P scaledFwdStyle.strokeWidth, scaledBwdStyle.strokeWidth, ); - const fwdLabel = resolveLabel(edge.sourceQueryIndex, edge.sourceLabelTemplate); - const bwdLabel = edge.bidirectional ? resolveLabel(edge.targetQueryIndex, edge.targetLabelTemplate) : null; + const fwdLabel = resolveLabel(fwdSeries, edge.sourceLabelTemplate); + const bwdLabel = edge.bidirectional ? resolveLabel(bwdSeries, edge.targetLabelTemplate) : null; return ( diff --git a/canvas/src/components/panel/PanelNodeLayer.tsx b/canvas/src/components/panel/PanelNodeLayer.tsx index ba5b49393..497d7ad8d 100644 --- a/canvas/src/components/panel/PanelNodeLayer.tsx +++ b/canvas/src/components/panel/PanelNodeLayer.tsx @@ -67,9 +67,17 @@ interface PanelNodeLayerProps { seriesByQueryIndex: Map; k: number; paletteColors: string[]; + /** Series backing generated nodes (data-driven mode), keyed by node id. Takes precedence over queryIndex. */ + seriesByItemId?: Map; } -export function PanelNodeLayer({ spec, seriesByQueryIndex, k, paletteColors }: PanelNodeLayerProps): ReactElement { +export function PanelNodeLayer({ + spec, + seriesByQueryIndex, + k, + paletteColors, + seriesByItemId, +}: PanelNodeLayerProps): ReactElement { const nodes = useMemo(() => spec.nodes ?? [], [spec.nodes]); const variableValues = useAllVariableValues(); const { connection: fallbackColor, nodeDefaultFill } = useCanvasTheme(); @@ -89,7 +97,9 @@ export function PanelNodeLayer({ spec, seriesByQueryIndex, k, paletteColors }: P let labelOverride: string | undefined; let fillOverride: string | undefined; - const series = node.queryIndex !== undefined ? seriesByQueryIndex.get(node.queryIndex) : undefined; + const series = + seriesByItemId?.get(node.id) ?? + (node.queryIndex !== undefined ? seriesByQueryIndex.get(node.queryIndex) : undefined); if (series && node.label) { labelOverride = interpolateLabel(node.label, series, spec.format); } diff --git a/canvas/src/components/settings/GlobalSettingsEditor.tsx b/canvas/src/components/settings/GlobalSettingsEditor.tsx index b550a7dd0..1c039c208 100644 --- a/canvas/src/components/settings/GlobalSettingsEditor.tsx +++ b/canvas/src/components/settings/GlobalSettingsEditor.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Box } from '@mui/material'; +import { Box, ToggleButton, ToggleButtonGroup } from '@mui/material'; import type { FormatOptions, ThresholdOptions } from '@perses-dev/components'; import { FormatControls, @@ -21,21 +21,73 @@ import { ThresholdsEditor, } from '@perses-dev/components'; import type { OptionsEditorProps } from '@perses-dev/plugin-system'; -import type { ReactElement } from 'react'; +import type { MouseEvent, ReactElement } from 'react'; import { useCallback } from 'react'; import { EditorStateProvider } from '../../contexts/EditorContext'; import { SpecProvider } from '../../contexts/SpecContext'; -import type { CanvasSpec } from '../../model'; +import type { CanvasSpec, GraphGenerationSpec } from '../../model'; import { EditorItemsPanel } from '../editor/EditorItemsPanel'; import { EdgeThicknessSettings } from './EdgeThicknessSettings'; +import { GraphGenerationSettings } from './GraphGenerationSettings'; import { LegendSettings } from './LegendSettings'; +import { NodeSizeSettings } from './NodeSizeSettings'; const DEFAULT_FORMAT: FormatOptions = { unit: 'decimal' }; const EDITOR_COLUMN_SX = { display: 'flex', flexDirection: 'column' as const, gap: 3 }; +const MODE_TOGGLE_SX = { mb: 2 }; +const DEFAULT_GENERATION: GraphGenerationSpec = { enabled: true, nodeQueryIndex: 0, nodeIdLabel: '' }; type GlobalSettingsEditorProps = OptionsEditorProps; +interface StylingSettingsProps { + value: CanvasSpec; + onChange: (value: CanvasSpec) => void; + onFormatChange: (format: FormatOptions) => void; + onThresholdsChange: (thresholds: ThresholdOptions | undefined) => void; + onEdgeThresholdsChange: (thresholds: ThresholdOptions | undefined) => void; +} + +// The appearance controls shared by both the manual and data-driven tabs. Rendering the same block +// in each tab keeps every adjustment option close together instead of split between a top section +// and the tab contents. +function StylingSettings({ + value, + onChange, + onFormatChange, + onThresholdsChange, + onEdgeThresholdsChange, +}: StylingSettingsProps): ReactElement { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + export function GlobalSettingsEditor({ value, onChange }: GlobalSettingsEditorProps): ReactElement { const onFormatChange = useCallback( (format: FormatOptions): void => { @@ -51,31 +103,58 @@ export function GlobalSettingsEditor({ value, onChange }: GlobalSettingsEditorPr [value, onChange], ); + const onEdgeThresholdsChange = useCallback( + (edgeThresholds: ThresholdOptions | undefined): void => { + onChange({ ...value, edgeThresholds }); + }, + [value, onChange], + ); + + const mode = value.graphGeneration?.enabled ? 'auto' : 'manual'; + + const onModeChange = useCallback( + (_: MouseEvent, next: 'manual' | 'auto' | null): void => { + if (next === null) { + return; + } + if (next === 'auto') { + onChange({ ...value, graphGeneration: { ...DEFAULT_GENERATION, ...value.graphGeneration, enabled: true } }); + } else { + onChange( + value.graphGeneration ? { ...value, graphGeneration: { ...value.graphGeneration, enabled: false } } : value, + ); + } + }, + [value, onChange], + ); + return ( - - - - - - - - - - - - - - - - + + Manual + From data + + + {/* Each tab is self-contained: the same styling controls plus the tab's own item editor, so + every adjustment option stays together within the active mode. */} + - - - - - + {mode === 'auto' ? ( + + ) : ( + + + + + + )} ); diff --git a/canvas/src/components/settings/GraphGenerationSettings.tsx b/canvas/src/components/settings/GraphGenerationSettings.tsx new file mode 100644 index 000000000..b2b7452ee --- /dev/null +++ b/canvas/src/components/settings/GraphGenerationSettings.tsx @@ -0,0 +1,152 @@ +// 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 { FormControlLabel, MenuItem, Stack, Switch, TextField, Typography } from '@mui/material'; +import { generateQueryNames, useDataQueriesContext } from '@perses-dev/plugin-system'; +import type { ReactElement } from 'react'; +import { useCallback, useMemo } from 'react'; + +import type { CanvasSpec, GraphGenerationSpec } from '../../model'; +import { SelectField } from '../shared/SelectField'; + +type GraphGenerationSettingsProps = { + value: CanvasSpec; + onChange: (next: CanvasSpec) => void; +}; + +const DEFAULT_GENERATION: GraphGenerationSpec = { enabled: true, nodeQueryIndex: 0, nodeIdLabel: '' }; + +export function GraphGenerationSettings({ value, onChange }: GraphGenerationSettingsProps): ReactElement { + const { queryDefinitions } = useDataQueriesContext(); + const queryCount = queryDefinitions.length; + const queryNames = useMemo(() => generateQueryNames(queryDefinitions), [queryDefinitions]); + const queryIndexes = useMemo(() => Array.from({ length: queryCount }, (_, i) => i), [queryCount]); + + const generation = value.graphGeneration ?? DEFAULT_GENERATION; + + const update = useCallback( + (patch: Partial): void => { + onChange({ ...value, graphGeneration: { ...generation, ...patch } }); + }, + [value, onChange, generation], + ); + + const onNodeQueryChange = useCallback( + (e: React.ChangeEvent): void => update({ nodeQueryIndex: Number(e.target.value) }), + [update], + ); + const onEdgeQueryChange = useCallback( + (e: React.ChangeEvent): void => + update({ edgeQueryIndex: e.target.value === '' ? undefined : Number(e.target.value) }), + [update], + ); + const onNodeIdLabelChange = useCallback( + (e: React.ChangeEvent): void => update({ nodeIdLabel: e.target.value }), + [update], + ); + const onNodeLabelTemplateChange = useCallback( + (e: React.ChangeEvent): void => update({ nodeLabelTemplate: e.target.value || undefined }), + [update], + ); + const onEdgeSourceLabelChange = useCallback( + (e: React.ChangeEvent): void => update({ edgeSourceLabel: e.target.value || undefined }), + [update], + ); + const onEdgeTargetLabelChange = useCallback( + (e: React.ChangeEvent): void => update({ edgeTargetLabel: e.target.value || undefined }), + [update], + ); + const onEdgeLabelTemplateChange = useCallback( + (e: React.ChangeEvent): void => update({ edgeLabelTemplate: e.target.value || undefined }), + [update], + ); + const onBidirectionalChange = useCallback( + (e: React.ChangeEvent): void => update({ bidirectional: e.target.checked }), + [update], + ); + + const bidirectionalSwitch = useMemo( + () => , + [generation.bidirectional, onBidirectionalChange], + ); + + return ( + + + Nodes and edges are generated from query results and laid out automatically. Graph structure is inferred from + series labels; node and edge colors use the thresholds above. + + + Nodes + + {queryIndexes.map((qi) => ( + + {queryNames[qi] ?? `#${qi + 1}`} + + ))} + + + + + Edges + + + None (nodes only) + + {queryIndexes.map((qi) => ( + + {queryNames[qi] ?? `#${qi + 1}`} + + ))} + + + + + + + ); +} diff --git a/canvas/src/components/settings/NodeSizeSettings.tsx b/canvas/src/components/settings/NodeSizeSettings.tsx new file mode 100644 index 000000000..86b37ad15 --- /dev/null +++ b/canvas/src/components/settings/NodeSizeSettings.tsx @@ -0,0 +1,146 @@ +// 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 { Box, InputAdornment, TextField, Typography } from '@mui/material'; +import type { StepOptions } from '@perses-dev/components'; +import { formatValue } from '@perses-dev/components'; +import { produce } from 'immer'; +import type { ReactElement } from 'react'; +import React, { useCallback, useMemo } from 'react'; + +import type { CanvasSpec } from '../../model'; + +const SCALE_SLOT_PROPS = { + htmlInput: { min: 0.1, step: 0.1 }, + input: { endAdornment: × }, +} as const; + +const SIZE_SLOT_PROPS = { + htmlInput: { min: 1, step: 1 }, + input: { endAdornment: px }, +} as const; + +const ROW_BOX_SX = { display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 } as const; +const ROW_CAPTION_SX = { minWidth: 70, color: 'text.secondary' } as const; +const ROW_TEXT_WIDTH_SX = { width: 100 } as const; +const DEFAULT_SIZE_SX = { mb: 1, width: 180 } as const; +const BLOCK_CAPTION_SX = { display: 'block', mb: 0.5 } as const; + +interface NodeSizeSettingsProps { + value: CanvasSpec; + onChange: (value: CanvasSpec) => void; +} + +interface ThresholdScaleRowProps { + step: StepOptions; + scale: number | undefined; + format: CanvasSpec['format']; + onThresholdScaleChange: (stepValue: number, scale: number | undefined) => void; +} + +function ThresholdScaleRow({ step, scale, format, onThresholdScaleChange }: ThresholdScaleRowProps): ReactElement { + const onScaleChange = useCallback( + (event: React.ChangeEvent): void => { + const parsed = parseFloat(event.target.value); + onThresholdScaleChange(step.value, Number.isFinite(parsed) && parsed > 0 ? parsed : undefined); + }, + [step.value, onThresholdScaleChange], + ); + + return ( + + + ≥ {formatValue(step.value, format)} + + + + ); +} + +export function NodeSizeSettings({ value, onChange }: NodeSizeSettingsProps): ReactElement { + const thresholdSteps = useMemo(() => value.thresholds?.steps ?? [], [value.thresholds]); + + const onDefaultSizeChange = useCallback( + (event: React.ChangeEvent): void => { + const parsed = parseFloat(event.target.value); + onChange({ + ...value, + nodeDefaultSize: Number.isFinite(parsed) && parsed > 0 ? parsed : undefined, + }); + }, + [value, onChange], + ); + + const onThresholdScaleChange = useCallback( + (stepValue: number, scale: number | undefined): void => { + onChange( + produce(value, (draft) => { + draft.nodeThresholdSizes = draft.nodeThresholdSizes ?? []; + const existingIdx = draft.nodeThresholdSizes.findIndex((s) => s.value === stepValue); + if (scale !== undefined) { + if (existingIdx >= 0) { + draft.nodeThresholdSizes[existingIdx]!.scale = scale; + } else { + draft.nodeThresholdSizes.push({ value: stepValue, scale }); + } + } else if (existingIdx >= 0) { + draft.nodeThresholdSizes.splice(existingIdx, 1); + } + }), + ); + }, + [value, onChange], + ); + + return ( + <> + + {thresholdSteps.length > 0 ? ( + + + Per-threshold size multipliers + + {thresholdSteps.map((step) => ( + s.value === step.value)?.scale} + format={value.format} + onThresholdScaleChange={onThresholdScaleChange} + /> + ))} + + ) : ( + + Add node color thresholds to scale nodes by value. + + )} + + ); +} diff --git a/canvas/src/components/shared/IconNode.tsx b/canvas/src/components/shared/IconNode.tsx index 0d7ca794d..400848cd9 100644 --- a/canvas/src/components/shared/IconNode.tsx +++ b/canvas/src/components/shared/IconNode.tsx @@ -11,6 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { darken, lighten, useTheme } from '@mui/material'; import type { ReactElement, SVGProps } from 'react'; import type { NodeSpec } from '../../model'; @@ -20,6 +21,10 @@ import { labelAttrs } from '../../utils/labelPosition'; const NO_INTERACTION_STYLE = { pointerEvents: 'none', userSelect: 'none' } as const; const NO_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const; +// Shade the fill to derive a thin border that gives the node a bit of shape: darken in light mode, +// lighten in dark mode, so the outline always has contrast against the fill. +const BORDER_SHADE = 0.35; + export interface IconNodeProps { node: NodeSpec; displayLabel: string | undefined; @@ -29,6 +34,7 @@ export interface IconNodeProps { } export function IconNode({ node, displayLabel, defaultFill, fillOverride, rectProps }: IconNodeProps): ReactElement { + const theme = useTheme(); const { width, height } = node; const halfW = width / 2; const halfH = height / 2; @@ -36,6 +42,13 @@ export function IconNode({ node, displayLabel, defaultFill, fillOverride, rectPr const iconScale = Math.min(width, height) / 24; const lAttrs = labelAttrs(halfW, halfH, node.labelPosition, node.labelPadding); const iconColor = fillOverride ?? defaultFill; + let borderColor: string; + try { + borderColor = theme.palette.mode === 'dark' ? lighten(iconColor, BORDER_SHADE) : darken(iconColor, BORDER_SHADE); + } catch { + // lighten/darken throw on non-color strings (e.g. 'currentColor'); fall back to the fill. + borderColor = iconColor; + } return ( <> @@ -53,7 +66,14 @@ export function IconNode({ node, displayLabel, defaultFill, fillOverride, rectPr ) : ( - + )} {displayLabel ? ( void; transform: ZoomTransform; @@ -34,7 +52,7 @@ export interface UseZoomResult { resetPan: () => void; } -export function useZoom(): UseZoomResult { +export function useZoom({ interactionMode = 'edit' }: UseZoomOptions = {}): UseZoomResult { const [transform, setTransform] = useState(zoomIdentity); const [isPanning, setIsPanning] = useState(false); const nodeRef = useRef(null); @@ -47,14 +65,25 @@ export function useZoom(): UseZoomResult { return; } nodeRef.current = node; + if (interactionMode === 'view') { + zoomBehavior.scaleExtent([0.05, 8]); + } zoomBehavior.filter((event: Event) => { if (event.type === 'dblclick') { return false; } if (event instanceof WheelEvent) { - return event.ctrlKey || event.metaKey; + // In view mode a plain wheel zooms; in edit mode it must be modified + // so that an unmodified wheel still scrolls the surrounding page. + return interactionMode === 'view' || event.ctrlKey || event.metaKey; } - return event instanceof MouseEvent && event.button === 1; + if (!(event instanceof MouseEvent)) { + return false; + } + // Middle-click always pans. The left button only pans in view mode, + // where there are no editing gestures competing for it; d3-zoom still + // lets a stationary left click through to node click handlers. + return event.button === 1 || (interactionMode === 'view' && event.button === 0); }); zoomBehavior.on('start', () => setIsPanning(true)); zoomBehavior.on('zoom', ({ transform: t }: { transform: ZoomTransform }) => { @@ -63,7 +92,7 @@ export function useZoom(): UseZoomResult { zoomBehavior.on('end', () => setIsPanning(false)); select(node).call(zoomBehavior); }, - [zoomBehavior], + [zoomBehavior, interactionMode], ); const resetPan = useCallback(() => { diff --git a/canvas/src/model.ts b/canvas/src/model.ts index e8fa78868..138ca4613 100644 --- a/canvas/src/model.ts +++ b/canvas/src/model.ts @@ -50,6 +50,8 @@ export interface NodeSpec { queryIndex?: number; colorMode?: 'threshold' | 'fixed'; color?: string; + /** When `threshold`, the node's base size is scaled by the `nodeThresholdSizes` steps. */ + sizeMode?: 'fixed' | 'threshold'; } export type AnchorPoint = 'n' | 's' | 'e' | 'w' | 'nw' | 'ne' | 'sw' | 'se'; @@ -98,6 +100,12 @@ export interface EdgeThresholdStep { strokeWidth: number; } +export interface NodeThresholdStep { + value: number; + /** Multiplier applied to the node's base width/height. */ + scale: number; +} + export type FloatingEdge = EdgeSpec & { freeEndpoint: Point }; export function isFloatingEdge(edge: EdgeSpec): edge is FloatingEdge { @@ -117,16 +125,48 @@ export interface BackgroundSpec { global?: boolean; } +/** + * Configuration for the data-driven graph mode. When `enabled`, the panel derives nodes and edges + * live from query results on every render (ignoring the manually authored `nodes`/`edges`) and lays + * them out automatically. Graph structure is inferred from series labels. + */ +export interface GraphGenerationSpec { + enabled?: boolean; + /** Index of the query whose series define the nodes. */ + nodeQueryIndex: number; + /** Series label whose value identifies a node (e.g. "instance"). */ + nodeIdLabel: string; + /** Label template for a node's caption. Defaults to the value of `nodeIdLabel`. */ + nodeLabelTemplate?: string; + /** Index of the query whose series define the edges. Omit for a nodes-only graph. */ + edgeQueryIndex?: number; + /** Series label whose value identifies an edge's source node. */ + edgeSourceLabel?: string; + /** Series label whose value identifies an edge's target node. */ + edgeTargetLabel?: string; + /** Label template for an edge's caption (e.g. "{{value}}"). */ + edgeLabelTemplate?: string; + /** Render edges as bidirectional. */ + bidirectional?: boolean; +} + export interface CanvasSpec { legend?: LegendSpecOptions; thresholds?: ThresholdOptions; + /** Color scheme applied to edges. Falls back to `thresholds` when unset. */ + edgeThresholds?: ThresholdOptions; format?: FormatOptions; edgeThresholdWidths?: EdgeThresholdStep[]; edgeDefaultStrokeWidth?: number; + /** Threshold steps that scale node size by the node's bound value. */ + nodeThresholdSizes?: NodeThresholdStep[]; + /** Base width/height (px) for generated nodes, scaled by `nodeThresholdSizes`. */ + nodeDefaultSize?: number; querySettings?: QueryColorSettings[]; backgrounds?: BackgroundSpec[]; nodes?: NodeSpec[]; edges?: EdgeSpec[]; + graphGeneration?: GraphGenerationSpec; } export type CanvasSpecEditorProps = OptionsEditorProps; diff --git a/canvas/src/utils/edgeUtils.test.ts b/canvas/src/utils/edgeUtils.test.ts index 8107af622..25e63c344 100644 --- a/canvas/src/utils/edgeUtils.test.ts +++ b/canvas/src/utils/edgeUtils.test.ts @@ -14,6 +14,7 @@ import type { NodeSpec } from '../model'; import { anchorPosition, + clipLineToNodes, closestAnchor, edgeEndpoints, midpoint, @@ -129,6 +130,37 @@ describe('midpoint', () => { }); }); +describe('clipLineToNodes', () => { + it('trims a horizontal line to a rectangle boundary on both ends', () => { + const a = makeNode('a', 0, 0, 100, 60); // half-width 50 + const b = makeNode('b', 200, 0, 100, 60); + const clipped = clipLineToNodes({ start: { x: 0, y: 0 }, end: { x: 200, y: 0 } }, a, b); + expect(clipped.start).toEqual({ x: 50, y: 0 }); + expect(clipped.end).toEqual({ x: 150, y: 0 }); + }); + + it('trims to the ellipse boundary for icon nodes', () => { + const a: NodeSpec = { id: 'a', position: { x: 0, y: 0 }, width: 48, height: 48, kind: 'icon' }; + const b: NodeSpec = { id: 'b', position: { x: 100, y: 0 }, width: 48, height: 48, kind: 'icon' }; + const clipped = clipLineToNodes({ start: { x: 0, y: 0 }, end: { x: 100, y: 0 } }, a, b); + expect(clipped.start.x).toBeCloseTo(24); // radius of the circle + expect(clipped.end.x).toBeCloseTo(76); + }); + + it('leaves an end untouched when its node is undefined (explicit anchor or free endpoint)', () => { + const a = makeNode('a', 0, 0, 100, 60); + const clipped = clipLineToNodes({ start: { x: 0, y: 0 }, end: { x: 200, y: 0 } }, a, undefined); + expect(clipped.start).toEqual({ x: 50, y: 0 }); + expect(clipped.end).toEqual({ x: 200, y: 0 }); + }); + + it('returns the line unchanged for a zero-length segment', () => { + const a = makeNode('a', 0, 0); + const line = { start: { x: 5, y: 5 }, end: { x: 5, y: 5 } }; + expect(clipLineToNodes(line, a, a)).toEqual(line); + }); +}); + describe('strokeWidthFromThresholds', () => { const steps = [ { value: 10, strokeWidth: 2 }, diff --git a/canvas/src/utils/edgeUtils.ts b/canvas/src/utils/edgeUtils.ts index 3ee656d2c..5e2152dc9 100644 --- a/canvas/src/utils/edgeUtils.ts +++ b/canvas/src/utils/edgeUtils.ts @@ -81,6 +81,58 @@ export function midpoint(line: Line): Point { return { x: (line.start.x + line.end.x) / 2, y: (line.start.y + line.end.y) / 2 }; } +/** + * Returns the distance from a node's center to its perimeter along the direction (dx, dy). Icon + * nodes render as an ellipse, so they use the ellipse boundary; rectangle and text nodes use their + * rectangular boundary. The input direction need not be normalized. + */ +function perimeterDistance(node: NodeSpec, dx: number, dy: number): number { + const len = Math.hypot(dx, dy); + if (len === 0) { + return 0; + } + const ux = dx / len; + const uy = dy / len; + const halfW = node.width / 2; + const halfH = node.height / 2; + if (node.kind === 'icon') { + // Ellipse: solve for t where ((t*ux)/halfW)^2 + ((t*uy)/halfH)^2 = 1. + const denom = Math.hypot(ux / halfW, uy / halfH); + return denom === 0 ? 0 : 1 / denom; + } + // Rectangle: distance to the nearest vertical/horizontal edge along the ray. + const tx = ux === 0 ? Infinity : halfW / Math.abs(ux); + const ty = uy === 0 ? Infinity : halfH / Math.abs(uy); + return Math.min(tx, ty); +} + +/** + * Trims an edge's endpoints back to the perimeter of the nodes they connect, so the line and its + * arrowhead touch the node boundary instead of overlapping into the node center. Endpoints that are + * not a node center (explicit anchors or a free endpoint) are left untouched. + */ +export function clipLineToNodes(line: Line, source: NodeSpec | undefined, target: NodeSpec | undefined): Line { + const dx = line.end.x - line.start.x; + const dy = line.end.y - line.start.y; + const len = Math.hypot(dx, dy); + if (len === 0) { + return line; + } + const ux = dx / len; + const uy = dy / len; + let start = line.start; + let end = line.end; + if (source) { + const d = perimeterDistance(source, dx, dy); + start = { x: line.start.x + ux * d, y: line.start.y + uy * d }; + } + if (target) { + const d = perimeterDistance(target, dx, dy); + end = { x: line.end.x - ux * d, y: line.end.y - uy * d }; + } + return { start, end }; +} + export function strokeWidthFromThresholds(value: number, steps: EdgeThresholdStep[], defaultWidth: number): number { if (!steps.length) return defaultWidth; let result = defaultWidth; diff --git a/canvas/src/utils/graphGeneration.test.ts b/canvas/src/utils/graphGeneration.test.ts new file mode 100644 index 000000000..a60b5c770 --- /dev/null +++ b/canvas/src/utils/graphGeneration.test.ts @@ -0,0 +1,138 @@ +// 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 { PanelData } from '@perses-dev/plugin-system'; +import type { TimeSeries, TimeSeriesData } from '@perses-dev/spec'; + +import type { GraphGenerationSpec } from '../model'; +import { buildGraphFromQueries } from './graphGeneration'; + +function series(labels: Record, lastValue: number): TimeSeries { + return { labels, values: [[0, lastValue]] } as unknown as TimeSeries; +} + +function result(...s: TimeSeries[]): PanelData { + return { data: { series: s } } as unknown as PanelData; +} + +const baseConfig: GraphGenerationSpec = { + enabled: true, + nodeQueryIndex: 0, + nodeIdLabel: 'instance', + edgeQueryIndex: 1, + edgeSourceLabel: 'source', + edgeTargetLabel: 'target', +}; + +describe('buildGraphFromQueries', () => { + it('creates one node per distinct node-id label value', () => { + const results = [result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), result()]; + const graph = buildGraphFromQueries(baseConfig, results); + expect(graph.nodes.map((n) => n.id)).toEqual(['gen-node:a', 'gen-node:b']); + expect(graph.nodes[0]?.colorMode).toBe('threshold'); + expect(graph.nodes[0]?.label).toBe('{{instance}}'); + }); + + it('deduplicates repeated node-id values, keeping the first series', () => { + const results = [result(series({ instance: 'a' }, 1), series({ instance: 'a' }, 99)), result()]; + const graph = buildGraphFromQueries(baseConfig, results); + expect(graph.nodes).toHaveLength(1); + expect(graph.seriesByItemId.get('gen-node:a')?.values[0]?.[1]).toBe(1); + }); + + it('skips node series missing the id label', () => { + const results = [result(series({ other: 'x' }, 1), series({ instance: 'a' }, 2)), result()]; + const graph = buildGraphFromQueries(baseConfig, results); + expect(graph.nodes.map((n) => n.id)).toEqual(['gen-node:a']); + }); + + it('builds edges referencing generated node ids', () => { + const results = [ + result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), + result(series({ source: 'a', target: 'b' }, 5)), + ]; + const graph = buildGraphFromQueries(baseConfig, results); + expect(graph.edges).toHaveLength(1); + expect(graph.edges[0]).toMatchObject({ source: 'gen-node:a', target: 'gen-node:b' }); + expect(graph.seriesByItemId.get(graph.edges[0]!.id)?.values[0]?.[1]).toBe(5); + }); + + it('drops edges whose endpoints are not known nodes', () => { + const results = [ + result(series({ instance: 'a' }, 1)), + result(series({ source: 'a', target: 'ghost' }, 5), series({ source: 'nope', target: 'a' }, 6)), + ]; + const graph = buildGraphFromQueries(baseConfig, results); + expect(graph.edges).toHaveLength(0); + }); + + it('carries the edge label template and bidirectional flag', () => { + const config: GraphGenerationSpec = { ...baseConfig, edgeLabelTemplate: '{{value}}', bidirectional: true }; + const results = [ + result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), + result(series({ source: 'a', target: 'b' }, 5)), + ]; + const graph = buildGraphFromQueries(config, results); + expect(graph.edges[0]?.sourceLabelTemplate).toBe('{{value}}'); + expect(graph.edges[0]?.bidirectional).toBe(true); + }); + + it('drives generated edge thickness from the edge-thickness thresholds', () => { + const results = [ + result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), + result(series({ source: 'a', target: 'b' }, 5)), + ]; + const graph = buildGraphFromQueries(baseConfig, results); + expect(graph.edges[0]?.thicknessMode).toBe('threshold'); + expect(graph.nodes[0]?.sizeMode).toBe('threshold'); + }); + + it('uses the configured default node size, falling back to 48', () => { + const results = [result(series({ instance: 'a' }, 1)), result()]; + expect(buildGraphFromQueries(baseConfig, results).nodes[0]).toMatchObject({ width: 48, height: 48 }); + const sized = buildGraphFromQueries(baseConfig, results, 60); + expect(sized.nodes[0]).toMatchObject({ width: 60, height: 60 }); + }); + + it('respects a custom node label template', () => { + const config: GraphGenerationSpec = { ...baseConfig, nodeLabelTemplate: '{{instance}}: {{value}}' }; + const results = [result(series({ instance: 'a' }, 1)), result()]; + const graph = buildGraphFromQueries(config, results); + expect(graph.nodes[0]?.label).toBe('{{instance}}: {{value}}'); + }); + + it('produces a nodes-only graph when no edge query is configured', () => { + const config: GraphGenerationSpec = { enabled: true, nodeQueryIndex: 0, nodeIdLabel: 'instance' }; + const results = [result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2))]; + const graph = buildGraphFromQueries(config, results); + expect(graph.nodes).toHaveLength(2); + expect(graph.edges).toHaveLength(0); + }); + + it('builds a topology key that changes with topology but not with values', () => { + const r1 = [ + result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), + result(series({ source: 'a', target: 'b' }, 5)), + ]; + const r2 = [ + result(series({ instance: 'a' }, 100), series({ instance: 'b' }, 200)), + result(series({ source: 'a', target: 'b' }, 500)), + ]; + const r3 = [result(series({ instance: 'a' }, 1)), result()]; + const key1 = buildGraphFromQueries(baseConfig, r1).topologyKey; + const key2 = buildGraphFromQueries(baseConfig, r2).topologyKey; + const key3 = buildGraphFromQueries(baseConfig, r3).topologyKey; + expect(key1).toBe(key2); // only values changed + expect(key1).not.toBe(key3); // topology changed + }); +}); diff --git a/canvas/src/utils/graphGeneration.ts b/canvas/src/utils/graphGeneration.ts new file mode 100644 index 000000000..3ff6b7c7e --- /dev/null +++ b/canvas/src/utils/graphGeneration.ts @@ -0,0 +1,123 @@ +// 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 { PanelData } from '@perses-dev/plugin-system'; +import type { TimeSeries, TimeSeriesData } from '@perses-dev/spec'; + +import { DEFAULT_NODE_WIDTH } from '../components/shared/NodeRenderer'; +import type { EdgeSpec, GraphGenerationSpec, NodeSpec } from '../model'; + +export interface GeneratedGraph { + nodes: NodeSpec[]; + edges: EdgeSpec[]; + /** Series backing each generated node/edge id, used to resolve color/label/thickness at render time. */ + seriesByItemId: Map; + /** Stable key describing the graph topology (node + edge ids), for memoizing layout. */ + topologyKey: string; +} + +const NODE_ID_PREFIX = 'gen-node:'; +const EDGE_ID_PREFIX = 'gen-edge:'; + +function nodeId(labelValue: string): string { + return `${NODE_ID_PREFIX}${labelValue}`; +} + +/** + * Builds nodes and edges from query results by grouping series on their labels. Node series are + * grouped by `nodeIdLabel`; edge series are grouped by `edgeSourceLabel`/`edgeTargetLabel` and are + * dropped when either endpoint is not a known node. Each generated item keeps the series it was + * derived from so the existing threshold/label/thickness pipeline can resolve its visuals. + */ +export function buildGraphFromQueries( + config: GraphGenerationSpec, + queryResults: Array>, + nodeDefaultSize?: number, +): GeneratedGraph { + const seriesByItemId = new Map(); + const nodes: NodeSpec[] = []; + const nodeIds = new Set(); + const nodeSize = nodeDefaultSize && nodeDefaultSize > 0 ? nodeDefaultSize : DEFAULT_NODE_WIDTH; + + const nodeResult = queryResults[config.nodeQueryIndex]; + const nodeSeries = nodeResult?.data.series ?? []; + for (const series of nodeSeries) { + const labelValue = series.labels?.[config.nodeIdLabel]; + if (labelValue === undefined || labelValue === '') { + continue; + } + const id = nodeId(labelValue); + if (nodeIds.has(id)) { + // Keep the first series for a given node id; later duplicates are ignored for determinism. + continue; + } + nodeIds.add(id); + seriesByItemId.set(id, series); + nodes.push({ + id, + position: { x: 0, y: 0 }, // filled in by layoutGraph + width: nodeSize, + height: nodeSize, + kind: 'icon', + label: config.nodeLabelTemplate ?? `{{${config.nodeIdLabel}}}`, + labelPosition: 'below', + colorMode: 'threshold', + // Drive size from the node-size thresholds so generated nodes grow/shrink with their value; + // with no nodeThresholdSizes configured this is a no-op (scale 1). + sizeMode: 'threshold', + }); + } + + const edges: EdgeSpec[] = []; + const edgeIds = new Set(); + const canBuildEdges = + config.edgeQueryIndex !== undefined && config.edgeSourceLabel !== undefined && config.edgeTargetLabel !== undefined; + + if (canBuildEdges) { + const edgeResult = queryResults[config.edgeQueryIndex!]; + const edgeSeries = edgeResult?.data.series ?? []; + for (const series of edgeSeries) { + const sourceValue = series.labels?.[config.edgeSourceLabel!]; + const targetValue = series.labels?.[config.edgeTargetLabel!]; + if (sourceValue === undefined || targetValue === undefined) { + continue; + } + const source = nodeId(sourceValue); + const target = nodeId(targetValue); + if (!nodeIds.has(source) || !nodeIds.has(target)) { + // Drop edges whose endpoints were not produced by the node query. + continue; + } + const id = `${EDGE_ID_PREFIX}${sourceValue}->${targetValue}`; + if (edgeIds.has(id)) { + continue; + } + edgeIds.add(id); + seriesByItemId.set(id, series); + edges.push({ + id, + source, + target, + bidirectional: config.bidirectional ?? false, + sourceLabelTemplate: config.edgeLabelTemplate, + // Drive thickness from the edge-thickness thresholds so generated edges vary in width with + // their value; without this they would all fall back to the fixed default width. + thicknessMode: 'threshold', + }); + } + } + + const topologyKey = [nodes.map((n) => n.id).join(','), edges.map((e) => e.id).join(',')].join('|'); + + return { nodes, edges, seriesByItemId, topologyKey }; +} diff --git a/canvas/src/utils/graphLayout.test.ts b/canvas/src/utils/graphLayout.test.ts new file mode 100644 index 000000000..48483afd4 --- /dev/null +++ b/canvas/src/utils/graphLayout.test.ts @@ -0,0 +1,94 @@ +// 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 { layoutGraph } from './graphLayout'; + +function node(id: string): NodeSpec { + return { id, position: { x: 0, y: 0 }, width: 48, height: 48, kind: 'icon' }; +} + +function edge(id: string, source: string, target: string): EdgeSpec { + return { id, source, target }; +} + +describe('layoutGraph', () => { + it('returns an empty array for no nodes', () => { + expect(layoutGraph([], [])).toEqual([]); + }); + + it('centers a single node at the origin', () => { + const [n] = layoutGraph([node('a')], []); + expect(n?.position).toEqual({ x: 0, y: 0 }); + }); + + it('is deterministic: identical input yields identical positions', () => { + const nodes = [node('a'), node('b'), node('c')]; + const edges = [edge('e1', 'a', 'b'), edge('e2', 'b', 'c')]; + const first = layoutGraph(nodes, edges); + const second = layoutGraph(nodes, edges); + expect(first.map((n) => n.position)).toEqual(second.map((n) => n.position)); + }); + + it('does not depend on input array order (positions follow node id)', () => { + const a = layoutGraph([node('a'), node('b'), node('c')], []); + const b = layoutGraph([node('c'), node('a'), node('b')], []); + const posA = new Map(a.map((n) => [n.id, n.position])); + const posB = new Map(b.map((n) => [n.id, n.position])); + for (const id of ['a', 'b', 'c']) { + expect(posB.get(id)).toEqual(posA.get(id)); + } + }); + + it('produces finite coordinates and separates nodes', () => { + const nodes = [node('a'), node('b'), node('c'), node('d')]; + const edges = [edge('e1', 'a', 'b'), edge('e2', 'c', 'd')]; + const laid = layoutGraph(nodes, edges); + for (const n of laid) { + expect(Number.isFinite(n.position.x)).toBe(true); + expect(Number.isFinite(n.position.y)).toBe(true); + } + // No two nodes end up on top of each other. + for (let i = 0; i < laid.length; i++) { + for (let j = i + 1; j < laid.length; j++) { + const dist = Math.hypot(laid[i]!.position.x - laid[j]!.position.x, laid[i]!.position.y - laid[j]!.position.y); + expect(dist).toBeGreaterThan(1); + } + } + }); + + it('centers the layout on its own centroid', () => { + const nodes = [node('a'), node('b'), node('c'), node('d'), node('e')]; + const edges = [edge('e1', 'a', 'b'), edge('e2', 'b', 'c')]; + const laid = layoutGraph(nodes, edges); + const cx = laid.reduce((sum, n) => sum + n.position.x, 0) / laid.length; + const cy = laid.reduce((sum, n) => sum + n.position.y, 0) / laid.length; + expect(cx).toBeCloseTo(0, 6); + expect(cy).toBeCloseTo(0, 6); + }); + + it('preserves node identity and other fields', () => { + const laid = layoutGraph([node('a'), node('b')], []); + expect(laid.map((n) => n.id).toSorted()).toEqual(['a', 'b']); + expect(laid[0]?.width).toBe(48); + expect(laid[0]?.kind).toBe('icon'); + }); + + it('ignores edges that reference unknown nodes without crashing', () => { + const laid = layoutGraph([node('a'), node('b')], [edge('e1', 'a', 'ghost')]); + expect(laid).toHaveLength(2); + for (const n of laid) { + expect(Number.isFinite(n.position.x)).toBe(true); + } + }); +}); diff --git a/canvas/src/utils/graphLayout.ts b/canvas/src/utils/graphLayout.ts new file mode 100644 index 000000000..967289f6a --- /dev/null +++ b/canvas/src/utils/graphLayout.ts @@ -0,0 +1,155 @@ +// 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, Point } from '../model'; + +export interface LayoutOptions { + iterations?: number; + /** Target edge length / natural spring length. */ + springLength?: number; + /** Repulsion strength between all node pairs. */ + repulsion?: number; + /** Spring (attraction) strength along edges. */ + attraction?: number; + /** Pull toward the origin, keeping disconnected components from drifting away. */ + centering?: number; + /** Radius of the deterministic seed circle. */ + seedRadius?: number; +} + +const DEFAULTS: Required = { + iterations: 300, + springLength: 120, + repulsion: 60000, + attraction: 0.02, + centering: 0.01, + seedRadius: 200, +}; + +// Deterministic 32-bit string hash (FNV-1a) so a given node id always seeds to the same angle. +function hashString(str: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +/** + * Computes positions for generated nodes using a small, dependency-free force simulation + * (Coulomb-style repulsion + Hookean edge springs + mild centering). Seeding is deterministic from + * node ids, so the same topology always lays out identically across refreshes — nodes only move + * when the set of nodes/edges changes, not when their metric values change. + */ +export function layoutGraph(nodes: NodeSpec[], edges: EdgeSpec[], options: LayoutOptions = {}): NodeSpec[] { + const opts = { ...DEFAULTS, ...options }; + const n = nodes.length; + if (n === 0) { + return nodes; + } + if (n === 1) { + return [{ ...nodes[0]!, position: { x: 0, y: 0 } }]; + } + + // Deterministic seed: place each node on a circle at an angle derived from its id hash. + const pos: Point[] = nodes.map((node) => { + const angle = (hashString(node.id) / 0xffffffff) * Math.PI * 2; + return { x: Math.cos(angle) * opts.seedRadius, y: Math.sin(angle) * opts.seedRadius }; + }); + + const indexById = new Map(); + nodes.forEach((node, i) => indexById.set(node.id, i)); + + const edgePairs: Array<[number, number]> = []; + for (const edge of edges) { + const a = indexById.get(edge.source); + const b = edge.target !== undefined ? indexById.get(edge.target) : undefined; + if (a !== undefined && b !== undefined && a !== b) { + edgePairs.push([a, b]); + } + } + + const disp: Point[] = pos.map(() => ({ x: 0, y: 0 })); + + for (let iter = 0; iter < opts.iterations; iter++) { + for (let i = 0; i < n; i++) { + disp[i]!.x = 0; + disp[i]!.y = 0; + } + + // Pairwise repulsion. + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + let dx = pos[i]!.x - pos[j]!.x; + let dy = pos[i]!.y - pos[j]!.y; + let distSq = dx * dx + dy * dy; + if (distSq < 0.01) { + // Deterministically nudge coincident nodes apart. + dx = ((i - j) % 2 === 0 ? 1 : -1) * 0.1; + dy = 0.1; + distSq = dx * dx + dy * dy; + } + const force = opts.repulsion / distSq; + const dist = Math.sqrt(distSq); + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + disp[i]!.x += fx; + disp[i]!.y += fy; + disp[j]!.x -= fx; + disp[j]!.y -= fy; + } + } + + // Edge springs. + for (const [a, b] of edgePairs) { + const dx = pos[b]!.x - pos[a]!.x; + const dy = pos[b]!.y - pos[a]!.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 0.01; + const force = (dist - opts.springLength) * opts.attraction; + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + disp[a]!.x += fx; + disp[a]!.y += fy; + disp[b]!.x -= fx; + disp[b]!.y -= fy; + } + + // Centering + integrate. + for (let i = 0; i < n; i++) { + disp[i]!.x -= pos[i]!.x * opts.centering; + disp[i]!.y -= pos[i]!.y * opts.centering; + pos[i]!.x += disp[i]!.x; + pos[i]!.y += disp[i]!.y; + } + } + + // Recenter on the layout's own centroid. The centering force only approximately pulls toward the + // origin and reaches equilibrium wherever repulsion/springs balance it — for small or unevenly + // seeded graphs that equilibrium is noticeably off-origin. Shifting by the centroid guarantees the + // result is centered on (0, 0) regardless of how the simulation settled. + // + // Accumulate in id-sorted order so floating-point rounding of the sum does not depend on the input + // array order (layout is otherwise order-independent — positions follow node id, not slot). + const order = nodes.map((_, i) => i).toSorted((a, b) => (nodes[a]!.id < nodes[b]!.id ? -1 : 1)); + let cx = 0; + let cy = 0; + for (const i of order) { + cx += pos[i]!.x; + cy += pos[i]!.y; + } + cx /= n; + cy /= n; + + return nodes.map((node, i) => ({ ...node, position: { x: pos[i]!.x - cx, y: pos[i]!.y - cy } })); +} diff --git a/canvas/src/utils/panelUtils.test.ts b/canvas/src/utils/panelUtils.test.ts index a1d40bf11..2be6ff376 100644 --- a/canvas/src/utils/panelUtils.test.ts +++ b/canvas/src/utils/panelUtils.test.ts @@ -13,7 +13,7 @@ import type { TimeSeries } from '@perses-dev/spec'; -import { colorFromThresholds, interpolateLabel, isSafeImageUrl } from './panelUtils'; +import { colorFromThresholds, interpolateLabel, isSafeImageUrl, scaleFromThresholds } from './panelUtils'; function makeSeries(labels: Record, values: Array<[number, number | null]>): TimeSeries { return { labels, values } as unknown as TimeSeries; @@ -95,6 +95,38 @@ describe('colorFromThresholds', () => { }); }); +describe('scaleFromThresholds', () => { + const steps = [ + { value: 0.5, scale: 1.3 }, + { value: 0.8, scale: 1.7 }, + ]; + + it('returns 1 when there are no steps', () => { + expect(scaleFromThresholds(0.9, undefined)).toBe(1); + expect(scaleFromThresholds(0.9, [])).toBe(1); + }); + + it('returns 1 below the first step', () => { + expect(scaleFromThresholds(0.2, steps)).toBe(1); + }); + + it('returns the matching step scale and prefers the highest reached step', () => { + expect(scaleFromThresholds(0.5, steps)).toBe(1.3); + expect(scaleFromThresholds(0.79, steps)).toBe(1.3); + expect(scaleFromThresholds(0.8, steps)).toBe(1.7); + expect(scaleFromThresholds(0.95, steps)).toBe(1.7); + }); + + it('prefers the highest reached step regardless of array order', () => { + const unsorted = [ + { value: 0.8, scale: 1.7 }, + { value: 0.5, scale: 1.3 }, + ]; + expect(scaleFromThresholds(0.95, unsorted)).toBe(1.7); + expect(scaleFromThresholds(0.6, unsorted)).toBe(1.3); + }); +}); + describe('isSafeImageUrl', () => { it('allows https URLs', () => { expect(isSafeImageUrl('https://example.com/image.png')).toBe(true); diff --git a/canvas/src/utils/panelUtils.ts b/canvas/src/utils/panelUtils.ts index 830cf00d7..21b00ec03 100644 --- a/canvas/src/utils/panelUtils.ts +++ b/canvas/src/utils/panelUtils.ts @@ -15,7 +15,7 @@ import type { FormatOptions, ThresholdOptions } from '@perses-dev/components'; import { formatValue } from '@perses-dev/components'; import type { TimeSeries } from '@perses-dev/spec'; -import type { BackgroundSpec } from '../model'; +import type { BackgroundSpec, NodeThresholdStep } from '../model'; export function isSafeImageUrl(url: string): boolean { try { @@ -79,3 +79,25 @@ export function colorFromThresholds( } return result; } + +/** + * Returns the size multiplier for a node given its bound value and the node-size threshold steps. + * The highest step whose `value` the input reaches wins, and an empty/absent step list leaves the + * node at its base size (scale 1). The steps need not be sorted: `nodeThresholdSizes` is persisted in + * the order the user edited each row, so the match is chosen by the greatest reached `value` rather + * than by array position. + */ +export function scaleFromThresholds(value: number, steps: NodeThresholdStep[] | undefined): number { + if (!steps?.length) { + return 1; + } + let result = 1; + let bestValue = -Infinity; + for (const step of steps) { + if (value >= step.value && step.value >= bestValue) { + bestValue = step.value; + result = step.scale; + } + } + return result; +} diff --git a/docs/canvas/examples/README.md b/docs/canvas/examples/README.md new file mode 100644 index 000000000..dd1cfd221 --- /dev/null +++ b/docs/canvas/examples/README.md @@ -0,0 +1,96 @@ +# Canvas data-driven graph demo + +This directory provides a self-contained demo for the canvas panel's **data-driven graph mode**: a +small microservice topology served as Prometheus metrics, plus a ready-to-apply dashboard. Use it to +see nodes and edges generated live from query data. + +The stock Perses dev metrics do not carry paired `source`/`target` labels, so they cannot exercise +edge generation. The exporter here is shaped for the feature: + +- `node_load{instance="..."}` — one series per node. The `instance` label is the node id, and each + series also carries `name`, `role`, `tier`, and `region` labels for richer captions. +- `edge_traffic{source=...,target=...}` — one series per directed edge. + +Each series' value (0..1) drives node/edge color via the panel thresholds and edge thickness via the +edge-thickness thresholds. Nodes use a traffic-light scheme (green → amber → red) while edges use a +separate blue → indigo → purple scheme, so the demo shows the independent **Node color** and **Edge +color** thresholds. Nodes also grow with their value via the **Node size** thresholds. The demo +dashboard builds node captions from the extra labels with a template (`{{name}} · {{value}}`); try +other labels such as `{{role}}` or `{{region}}` to see how the label templates work. + +## Prerequisites + +- Docker. +- A local checkout of [`perses/perses`](https://github.com/perses/perses) with its dev stack, plus + `percli`. See the [plugin development guide](../../../README.md#development). + +## Steps + +1. **Start the demo exporter** (from the plugins repo root). It serves the metrics at + : + + ```bash + docker compose --file docs/canvas/examples/docker-compose.yml up --detach + ``` + +2. **Scrape it from the Perses dev Prometheus.** In your `perses/perses` checkout, add this job + under `scrape_configs:` in `dev/extra/prometheus.yaml`, then start/restart Prometheus: + + ```yaml + - job_name: 'canvas-demo' + honor_labels: true + scrape_interval: 5s + static_configs: + - targets: ['host.docker.internal:9101'] + ``` + + ```bash + docker compose --file dev/docker-compose.yaml --profile prometheus up --detach + ``` + + The dev `PrometheusLocal` datasource (`http://localhost:9090`) is the default, so no datasource + setup is needed. Confirm `node_load` and `edge_traffic` return data at . + +3. **Run the backend and the plugin dev server.** In `perses/perses`, set `plugin.enable_dev: true` + in `dev/config.yaml`, then: + + ```bash + ./scripts/api_backend_dev.sh + percli login http://localhost:8080 + ``` + + From the plugins repo, point the plugin dev server at the canvas workspace: + + ```bash + percli plugin start ./canvas + ``` + +4. **Load the demo dashboard** (or build a panel by hand with the two queries above): + + ```bash + percli project perses + percli apply -f docs/canvas/examples/dashboard.json + ``` + + Open the `CanvasGraphDemo` dashboard in the `perses` project. You should see the microservice + nodes and the links between them, laid out automatically and colored by the thresholds. Pan with + a left or middle mouse drag and zoom with the wheel; double-click to reset the pan, or + ctrl/cmd-double-click to fit the whole graph to the panel. + +5. **Experiment.** Edit `metrics/topology.prom` to change values, or add/remove `node_load` / + `edge_traffic` series, then reload the exporter: + + ```bash + docker compose --file docs/canvas/examples/docker-compose.yml restart + ``` + + Changing values recolors nodes/edges in place on the next refresh; adding or removing a series + changes the topology and triggers a fresh layout. + +## Teardown + +```bash +docker compose --file docs/canvas/examples/docker-compose.yml down +``` + +Remove the `canvas-demo` scrape job from `dev/extra/prometheus.yaml` when you are done. diff --git a/docs/canvas/examples/dashboard.json b/docs/canvas/examples/dashboard.json new file mode 100644 index 000000000..11006bc66 --- /dev/null +++ b/docs/canvas/examples/dashboard.json @@ -0,0 +1,105 @@ +{ + "kind": "Dashboard", + "metadata": { + "name": "CanvasGraphDemo", + "project": "perses" + }, + "spec": { + "display": { + "name": "Canvas – Data-driven graph demo" + }, + "duration": "5m", + "panels": { + "topology": { + "kind": "Panel", + "spec": { + "display": { + "name": "Host topology (generated from data)", + "description": "Nodes come from node_load (id label: instance); edges come from edge_traffic (labels: source, target). Node captions use the name/role/region labels; colors and edge thickness follow the thresholds." + }, + "plugin": { + "kind": "Canvas", + "spec": { + "thresholds": { + "steps": [ + { "value": 0.5, "color": "#f9a825" }, + { "value": 0.8, "color": "#d32f2f" } + ] + }, + "edgeThresholds": { + "defaultColor": "#90caf9", + "steps": [ + { "value": 0.5, "color": "#5c6bc0" }, + { "value": 0.8, "color": "#6a1b9a" } + ] + }, + "edgeThresholdWidths": [ + { "value": 0.5, "strokeWidth": 4 }, + { "value": 0.8, "strokeWidth": 8 } + ], + "nodeThresholdSizes": [ + { "value": 0.5, "scale": 1.3 }, + { "value": 0.8, "scale": 1.7 } + ], + "nodeDefaultSize": 44, + "format": { "unit": "decimal" }, + "legend": { "position": "bottom" }, + "graphGeneration": { + "enabled": true, + "nodeQueryIndex": 0, + "nodeIdLabel": "instance", + "nodeLabelTemplate": "{{name}} · {{value}}", + "edgeQueryIndex": 1, + "edgeSourceLabel": "source", + "edgeTargetLabel": "target", + "edgeLabelTemplate": "{{value}}" + } + } + }, + "queries": [ + { + "kind": "TimeSeriesQuery", + "spec": { + "plugin": { + "kind": "PrometheusTimeSeriesQuery", + "spec": { + "query": "node_load" + } + } + } + }, + { + "kind": "TimeSeriesQuery", + "spec": { + "plugin": { + "kind": "PrometheusTimeSeriesQuery", + "spec": { + "query": "edge_traffic" + } + } + } + } + ] + } + } + }, + "layouts": [ + { + "kind": "Grid", + "spec": { + "items": [ + { + "x": 0, + "y": 0, + "width": 24, + "height": 16, + "content": { + "$ref": "#/spec/panels/topology" + } + } + ] + } + } + ] + } +} diff --git a/docs/canvas/examples/docker-compose.yml b/docs/canvas/examples/docker-compose.yml new file mode 100644 index 000000000..057d684c6 --- /dev/null +++ b/docs/canvas/examples/docker-compose.yml @@ -0,0 +1,17 @@ +# Self-contained demo exporter for the canvas data-driven graph mode. +# +# Serves metrics/topology.prom at http://localhost:9101/metrics so the Perses dev Prometheus can +# scrape a controlled node/edge topology. See README.md for the full walkthrough. +# +# docker compose --file docs/canvas/examples/docker-compose.yml up --detach +# +# nginx serves the static exposition file as-is; no image build required. +services: + canvas-demo-metrics: + image: nginx:latest + container_name: canvas-demo-metrics + ports: + - '9101:80' + volumes: + - ./metrics/topology.prom:/usr/share/nginx/html/metrics:ro + - ./metrics/nginx.conf:/etc/nginx/conf.d/default.conf:ro diff --git a/docs/canvas/examples/metrics/nginx.conf b/docs/canvas/examples/metrics/nginx.conf new file mode 100644 index 000000000..589bcb80f --- /dev/null +++ b/docs/canvas/examples/metrics/nginx.conf @@ -0,0 +1,9 @@ +# nginx config for the canvas demo exporter: serve the static exposition file as text/plain so +# Prometheus v3 accepts it (it rejects the default application/octet-stream content type). +server { + listen 80; + location = /metrics { + default_type text/plain; + alias /usr/share/nginx/html/metrics; + } +} diff --git a/docs/canvas/examples/metrics/topology.prom b/docs/canvas/examples/metrics/topology.prom new file mode 100644 index 000000000..70323d072 --- /dev/null +++ b/docs/canvas/examples/metrics/topology.prom @@ -0,0 +1,73 @@ +# Demo metrics for the canvas panel's data-driven graph mode. +# +# A small microservice topology shaped exactly the way the canvas "From data" mode expects: +# - node_load{instance="..."} one series per node; label `instance` is the node id +# - edge_traffic{source=...,target=...} one series per directed edge +# +# The last value of each series (0..1) drives node/edge color via the panel thresholds, and edge +# thickness via the edge-thickness thresholds. The extra labels (`name`, `role`, `tier`, `region`) +# carry no behavior on their own — they exist so the demo dashboard can show off rich label +# templates like `{{name}}` / `{{role}} · {{region}}` and the `{{value}}` placeholder. +# +# Topology (an e-commerce-style request path across two regions): +# +# ┌──────────────┐ +# │ edge gateway │ +# └──────┬───────┘ +# ┌─────────┼──────────┐ +# ▼ ▼ ▼ +# web-eu web-us auth (shared) +# │ │ │ +# ▼ ▼ ▼ +# api-eu ◄──► api-us (both call auth) +# │ │ │ │ +# ▼ ▼ ▼ ▼ +# orders cart orders search +# │ │ │ │ +# ▼ ▼ ▼ ▼ +# db-eu cache db-us db-us (+ cross-region db replication) +# +# Served at /metrics by the nginx service in docker-compose.yml. See README.md. + +# HELP node_load Synthetic per-service load in [0,1]. `instance` is the node id. +# TYPE node_load gauge +node_load{instance="gateway",name="Edge Gateway",role="ingress",tier="edge",region="global"} 0.41 +node_load{instance="auth",name="Auth Service",role="authn",tier="platform",region="global"} 0.62 +node_load{instance="web-eu",name="Web (EU)",role="frontend",tier="web",region="eu-west"} 0.28 +node_load{instance="web-us",name="Web (US)",role="frontend",tier="web",region="us-east"} 0.36 +node_load{instance="api-eu",name="API (EU)",role="backend",tier="app",region="eu-west"} 0.54 +node_load{instance="api-us",name="API (US)",role="backend",tier="app",region="us-east"} 0.73 +node_load{instance="orders-eu",name="Orders (EU)",role="service",tier="app",region="eu-west"} 0.48 +node_load{instance="orders-us",name="Orders (US)",role="service",tier="app",region="us-east"} 0.81 +node_load{instance="cart",name="Cart Service",role="service",tier="app",region="eu-west"} 0.33 +node_load{instance="search",name="Search Service",role="service",tier="app",region="us-east"} 0.67 +node_load{instance="cache",name="Redis Cache",role="cache",tier="data",region="eu-west"} 0.22 +node_load{instance="db-eu",name="Postgres (EU)",role="database",tier="data",region="eu-west"} 0.59 +node_load{instance="db-us",name="Postgres (US)",role="database",tier="data",region="us-east"} 0.92 + +# HELP edge_traffic Synthetic per-link traffic in [0,1]. `source`/`target` are node ids. +# TYPE edge_traffic gauge +# Gateway fans out to the regional frontends and the shared auth service. +edge_traffic{source="gateway",target="web-eu"} 0.44 +edge_traffic{source="gateway",target="web-us"} 0.52 +edge_traffic{source="gateway",target="auth"} 0.61 +# Frontends call their regional API. +edge_traffic{source="web-eu",target="api-eu"} 0.47 +edge_traffic{source="web-us",target="api-us"} 0.58 +# Both APIs authenticate against the shared auth service. +edge_traffic{source="api-eu",target="auth"} 0.39 +edge_traffic{source="api-us",target="auth"} 0.55 +# APIs call their domain services. +edge_traffic{source="api-eu",target="orders-eu"} 0.63 +edge_traffic{source="api-eu",target="cart"} 0.31 +edge_traffic{source="api-us",target="orders-us"} 0.84 +edge_traffic{source="api-us",target="search"} 0.70 +# Cross-region API chatter (failover / shared traffic). +edge_traffic{source="api-eu",target="api-us"} 0.26 +# Services reach their data stores. +edge_traffic{source="orders-eu",target="db-eu"} 0.57 +edge_traffic{source="orders-us",target="db-us"} 0.88 +edge_traffic{source="cart",target="cache"} 0.24 +edge_traffic{source="search",target="db-us"} 0.66 +# Database replication EU -> US. +edge_traffic{source="db-eu",target="db-us"} 0.49 From 01fa4c12b0b38aaf425c50a482911f224dd5a7a6 Mon Sep 17 00:00:00 2001 From: Markus Wieland Date: Wed, 28 Oct 2026 11:15:37 +0100 Subject: [PATCH 2/9] [ENHANCEMENT] Enable instant queries in example dashboard Signed-off-by: Markus Wieland --- docs/canvas/examples/dashboard.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/canvas/examples/dashboard.json b/docs/canvas/examples/dashboard.json index 11006bc66..08bbc82dd 100644 --- a/docs/canvas/examples/dashboard.json +++ b/docs/canvas/examples/dashboard.json @@ -63,6 +63,7 @@ "plugin": { "kind": "PrometheusTimeSeriesQuery", "spec": { + "instant": true, "query": "node_load" } } @@ -74,6 +75,7 @@ "plugin": { "kind": "PrometheusTimeSeriesQuery", "spec": { + "instant": true, "query": "edge_traffic" } } From 1873118d49c0e746c4f5c4f306e894f9593cd343 Mon Sep 17 00:00:00 2001 From: Markus Wieland Date: Wed, 28 Oct 2026 11:16:49 +0100 Subject: [PATCH 3/9] [FIX] remove dead code Signed-off-by: Markus Wieland --- canvas/src/utils/graphGeneration.test.ts | 17 ----------------- canvas/src/utils/graphGeneration.ts | 6 +----- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/canvas/src/utils/graphGeneration.test.ts b/canvas/src/utils/graphGeneration.test.ts index a60b5c770..13862a741 100644 --- a/canvas/src/utils/graphGeneration.test.ts +++ b/canvas/src/utils/graphGeneration.test.ts @@ -118,21 +118,4 @@ describe('buildGraphFromQueries', () => { expect(graph.nodes).toHaveLength(2); expect(graph.edges).toHaveLength(0); }); - - it('builds a topology key that changes with topology but not with values', () => { - const r1 = [ - result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), - result(series({ source: 'a', target: 'b' }, 5)), - ]; - const r2 = [ - result(series({ instance: 'a' }, 100), series({ instance: 'b' }, 200)), - result(series({ source: 'a', target: 'b' }, 500)), - ]; - const r3 = [result(series({ instance: 'a' }, 1)), result()]; - const key1 = buildGraphFromQueries(baseConfig, r1).topologyKey; - const key2 = buildGraphFromQueries(baseConfig, r2).topologyKey; - const key3 = buildGraphFromQueries(baseConfig, r3).topologyKey; - expect(key1).toBe(key2); // only values changed - expect(key1).not.toBe(key3); // topology changed - }); }); diff --git a/canvas/src/utils/graphGeneration.ts b/canvas/src/utils/graphGeneration.ts index 3ff6b7c7e..c59193d66 100644 --- a/canvas/src/utils/graphGeneration.ts +++ b/canvas/src/utils/graphGeneration.ts @@ -22,8 +22,6 @@ export interface GeneratedGraph { edges: EdgeSpec[]; /** Series backing each generated node/edge id, used to resolve color/label/thickness at render time. */ seriesByItemId: Map; - /** Stable key describing the graph topology (node + edge ids), for memoizing layout. */ - topologyKey: string; } const NODE_ID_PREFIX = 'gen-node:'; @@ -117,7 +115,5 @@ export function buildGraphFromQueries( } } - const topologyKey = [nodes.map((n) => n.id).join(','), edges.map((e) => e.id).join(',')].join('|'); - - return { nodes, edges, seriesByItemId, topologyKey }; + return { nodes, edges, seriesByItemId }; } From b39aa4b05cd677fd878571702fb5bfe07b3bcd90 Mon Sep 17 00:00:00 2001 From: Philipp Matthes Date: Thu, 29 Oct 2026 13:13:56 +0100 Subject: [PATCH 4/9] [FEATURE] Address review feedback on canvas graph generation - Bound the force simulation with a cooling schedule so high-degree hub graphs stay viewable instead of diverging to extreme coordinates. - Cache the layout by topology key so the simulation reruns only when the node/edge set changes, not on every value refresh or viewport resize. - Show the node-size controls only in From data mode, where generated nodes actually opt into threshold-based sizing. - Base edge-thickness breakpoints on the edge thresholds (falling back to node thresholds), matching edge color resolution. - Give per-threshold size multiplier inputs accessible names. - Prune node-size multipliers whose threshold no longer exists so they cannot linger as active-but-hidden scales. - Collapse clipped edges to a point when nodes overlap instead of reversing the line, and skip rendering the collapsed edge. - Encode generated edge ids unambiguously so endpoint pairs sharing a separator-joined form no longer collide. - Apply the edge label template to both directions of a bidirectional edge. Signed-off-by: Philipp Matthes --- canvas/README.md | 9 +++-- canvas/src/components/panel/CanvasPanel.tsx | 34 ++++++++++++----- .../src/components/panel/PanelEdgeLayer.tsx | 6 +++ .../settings/EdgeThicknessSettings.tsx | 7 +++- .../settings/GlobalSettingsEditor.tsx | 26 +++++++++++-- .../components/settings/NodeSizeSettings.tsx | 15 +++++++- canvas/src/utils/edgeUtils.test.ts | 12 ++++++ canvas/src/utils/edgeUtils.ts | 23 +++++++----- canvas/src/utils/graphGeneration.test.ts | 37 +++++++++++++++++++ canvas/src/utils/graphGeneration.ts | 15 +++++++- canvas/src/utils/graphLayout.test.ts | 16 ++++++++ canvas/src/utils/graphLayout.ts | 17 ++++++++- 12 files changed, 184 insertions(+), 33 deletions(-) diff --git a/canvas/README.md b/canvas/README.md index b1b035852..2e3c58262 100644 --- a/canvas/README.md +++ b/canvas/README.md @@ -34,10 +34,11 @@ Configuration: Node colors come from the **Node color** thresholds configured for the panel, and **Node size** thresholds can scale a node's size by its value (relative to the configurable default node size). Edges use the **Edge color** thresholds when set and otherwise fall back to the node thresholds, so -edges can carry their own color scheme independent of the nodes. Edge thickness uses the **Edge -thickness** thresholds. Layout is deterministic: a given topology always settles to the same -positions, so nodes stay put as their metric values change — they only move when nodes or edges are -added or removed. +edges can carry their own color scheme independent of the nodes. Edge thickness is set per +breakpoint under **Edge thickness**, which shares the same thresholds as the edge color (the edge +thresholds when set, otherwise the node thresholds). Layout is deterministic: a given topology +always settles to the same positions, so nodes stay put as their metric values change — they only +move when nodes or edges are added or removed. ### How to install diff --git a/canvas/src/components/panel/CanvasPanel.tsx b/canvas/src/components/panel/CanvasPanel.tsx index 95abfff51..146eec96c 100644 --- a/canvas/src/components/panel/CanvasPanel.tsx +++ b/canvas/src/components/panel/CanvasPanel.tsx @@ -15,11 +15,11 @@ import { useChartsTheme } from '@perses-dev/components'; import type { ThresholdOptions } from '@perses-dev/components'; import type { TimeSeries } from '@perses-dev/spec'; import type { MouseEvent, ReactElement } from 'react'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useZoomContext, ZoomProvider } from '../../contexts/ZoomContext'; import { useZoom } from '../../hooks/useZoom'; -import type { CanvasProps, CanvasSpec } from '../../model'; +import type { CanvasProps, CanvasSpec, NodeSpec } from '../../model'; import { buildGraphFromQueries } from '../../utils/graphGeneration'; import { layoutGraph } from '../../utils/graphLayout'; import { scaleFromThresholds } from '../../utils/panelUtils'; @@ -167,22 +167,38 @@ export function CanvasPanel(props: CanvasProps): ReactElement | null { () => (generation?.enabled ? buildGraphFromQueries(generation, queryResults, spec.nodeDefaultSize) : undefined), [generation, queryResults, spec.nodeDefaultSize], ); + + // The force simulation is expensive (O(n²) per iteration) and must not rerun when only metric + // values change. Cache its origin-centered output keyed by the topology, using React's sanctioned + // "adjust state during render" pattern: a value-only refresh keeps the same topologyKey and reuses + // the cached layout, so the simulation reruns only when the set of nodes/edges actually changes. + const [layoutCache, setLayoutCache] = useState<{ key: string; nodes: NodeSpec[] } | undefined>(undefined); + let laidOutNodes = layoutCache?.nodes; + if (generated && layoutCache?.key !== generated.topologyKey) { + laidOutNodes = layoutGraph(generated.nodes, generated.edges); + setLayoutCache({ key: generated.topologyKey, nodes: laidOutNodes }); + } else if (!generated && layoutCache) { + laidOutNodes = undefined; + setLayoutCache(undefined); + } + const effectiveSpec: CanvasSpec = useMemo(() => { - if (!generated) { + if (!generated || !laidOutNodes) { return spec; } // layoutGraph centers the generated graph on the origin (0, 0). Under the identity zoom // transform the origin is the SVG's top-left corner, so the graph would render clipped into that // corner. Offset every node by the viewport center so the graph sits in the middle of the panel. - // layoutGraph returns freshly built node objects, so mutating position in place is safe here. + // Build fresh node objects here (never mutate the cached layout) so refreshes and resizes reuse + // the same origin-centered positions. const offsetX = (props.contentDimensions?.width ?? 600) / 2; const offsetY = (props.contentDimensions?.height ?? 400) / 2; - const nodes = layoutGraph(generated.nodes, generated.edges); - for (const node of nodes) { - node.position = { x: node.position.x + offsetX, y: node.position.y + offsetY }; - } + const nodes = laidOutNodes.map((node) => ({ + ...node, + position: { x: node.position.x + offsetX, y: node.position.y + offsetY }, + })); return { ...spec, nodes, edges: generated.edges }; - }, [spec, generated, props.contentDimensions?.width, props.contentDimensions?.height]); + }, [spec, generated, laidOutNodes, props.contentDimensions?.width, props.contentDimensions?.height]); const { svgRef, toCanvasPoint, transform, isPanning, fitView, resetPan } = useZoom({ interactionMode: 'view' }); diff --git a/canvas/src/components/panel/PanelEdgeLayer.tsx b/canvas/src/components/panel/PanelEdgeLayer.tsx index bbb0c9a4e..7afc32326 100644 --- a/canvas/src/components/panel/PanelEdgeLayer.tsx +++ b/canvas/src/components/panel/PanelEdgeLayer.tsx @@ -91,6 +91,12 @@ export function PanelEdgeLayer({ const clipTarget = edge.target && !edge.targetAnchor ? nodeById.get(edge.target) : undefined; const pts = clipLineToNodes(rawPts, clipSource, clipTarget); + // Overlapping nodes collapse the clipped segment to a point; skip it rather than draw a + // reversed arrow or a stray label at the overlap. + if (pts.start.x === pts.end.x && pts.start.y === pts.end.y) { + return null; + } + const generatedSeries = seriesByItemId?.get(edge.id); function resolveSeries(queryIndex: number | undefined): TimeSeries | undefined { diff --git a/canvas/src/components/settings/EdgeThicknessSettings.tsx b/canvas/src/components/settings/EdgeThicknessSettings.tsx index 27273092b..0d3bd01bb 100644 --- a/canvas/src/components/settings/EdgeThicknessSettings.tsx +++ b/canvas/src/components/settings/EdgeThicknessSettings.tsx @@ -16,7 +16,7 @@ import type { StepOptions } from '@perses-dev/components'; import { formatValue } from '@perses-dev/components'; import { produce } from 'immer'; import type { ReactElement } from 'react'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback } from 'react'; import type { CanvasSpec } from '../../model'; @@ -75,7 +75,10 @@ function ThresholdWidthRow({ } export function EdgeThicknessSettings({ value, onChange }: EdgeThicknessSettingsProps): ReactElement { - const thresholdSteps = useMemo(() => value.thresholds?.steps ?? [], [value.thresholds]); + // Width rows mirror the edge color thresholds, falling back to node thresholds just like edge + // color resolution does, so breakpoints match the ranges edges are actually colored by. React + // Compiler memoizes this derivation, so no manual useMemo is needed. + const thresholdSteps = (value.edgeThresholds ?? value.thresholds)?.steps ?? []; const onDefaultStrokeWidthChange = useCallback( (event: React.ChangeEvent): void => { diff --git a/canvas/src/components/settings/GlobalSettingsEditor.tsx b/canvas/src/components/settings/GlobalSettingsEditor.tsx index 1c039c208..2e0e8fec2 100644 --- a/canvas/src/components/settings/GlobalSettingsEditor.tsx +++ b/canvas/src/components/settings/GlobalSettingsEditor.tsx @@ -46,6 +46,8 @@ interface StylingSettingsProps { onFormatChange: (format: FormatOptions) => void; onThresholdsChange: (thresholds: ThresholdOptions | undefined) => void; onEdgeThresholdsChange: (thresholds: ThresholdOptions | undefined) => void; + /** Whether the data-driven ("From data") mode is active. */ + isDataMode: boolean; } // The appearance controls shared by both the manual and data-driven tabs. Rendering the same block @@ -57,6 +59,7 @@ function StylingSettings({ onFormatChange, onThresholdsChange, onEdgeThresholdsChange, + isDataMode, }: StylingSettingsProps): ReactElement { return ( @@ -72,9 +75,14 @@ function StylingSettings({ - - - + {/* Node size scaling only applies to generated nodes (which opt into `sizeMode: 'threshold'`); + manually authored nodes have no size-mode control, so these inputs would have no effect in + Manual mode. Show them only in From data mode. */} + {isDataMode ? ( + + + + ) : null} @@ -98,7 +106,16 @@ export function GlobalSettingsEditor({ value, onChange }: GlobalSettingsEditorPr const onThresholdsChange = useCallback( (thresholds: ThresholdOptions | undefined): void => { - onChange({ ...value, thresholds }); + // The per-threshold node-size multipliers are keyed by threshold value. When a color threshold + // is moved or removed, drop any multiplier whose value no longer matches a step so it cannot + // linger as an active-but-hidden scale in the panel while disappearing from the editor. + const stepValues = new Set(thresholds?.steps?.map((step) => step.value)); + const nodeThresholdSizes = value.nodeThresholdSizes?.filter((size) => stepValues.has(size.value)); + onChange({ + ...value, + thresholds, + nodeThresholdSizes: nodeThresholdSizes?.length ? nodeThresholdSizes : undefined, + }); }, [value, onChange], ); @@ -143,6 +160,7 @@ export function GlobalSettingsEditor({ value, onChange }: GlobalSettingsEditorPr onFormatChange={onFormatChange} onThresholdsChange={onThresholdsChange} onEdgeThresholdsChange={onEdgeThresholdsChange} + isDataMode={mode === 'auto'} /> diff --git a/canvas/src/components/settings/NodeSizeSettings.tsx b/canvas/src/components/settings/NodeSizeSettings.tsx index 86b37ad15..51264b348 100644 --- a/canvas/src/components/settings/NodeSizeSettings.tsx +++ b/canvas/src/components/settings/NodeSizeSettings.tsx @@ -57,15 +57,26 @@ function ThresholdScaleRow({ step, scale, format, onThresholdScaleChange }: Thre [step.value, onThresholdScaleChange], ); + const thresholdLabel = formatValue(step.value, format); + // MUI forwards accessibility attributes to the underlying input via the `htmlInput` slot, not the + // top-level props, so the screen-reader name must be set there alongside the numeric constraints. + const scaleSlotProps = useMemo( + () => ({ + ...SCALE_SLOT_PROPS, + htmlInput: { ...SCALE_SLOT_PROPS.htmlInput, 'aria-label': `Size multiplier for values ≥ ${thresholdLabel}` }, + }), + [thresholdLabel], + ); + return ( - ≥ {formatValue(step.value, format)} + ≥ {thresholdLabel} { const line = { start: { x: 5, y: 5 }, end: { x: 5, y: 5 } }; expect(clipLineToNodes(line, a, a)).toEqual(line); }); + + it('collapses to a point instead of reversing when nodes overlap', () => { + // Two 48px circular nodes 40px apart overlap: each radius (24) exceeds the remaining gap, so a + // naive clip would produce start.x=24, end.x=16 and point the edge backwards. + const a: NodeSpec = { id: 'a', position: { x: 0, y: 0 }, width: 48, height: 48, kind: 'icon' }; + const b: NodeSpec = { id: 'b', position: { x: 40, y: 0 }, width: 48, height: 48, kind: 'icon' }; + const clipped = clipLineToNodes({ start: { x: 0, y: 0 }, end: { x: 40, y: 0 } }, a, b); + expect(clipped.start).toEqual(clipped.end); + // The collapsed point sits forward of the source, never past the target center. + expect(clipped.start.x).toBeGreaterThanOrEqual(0); + expect(clipped.start.x).toBeLessThanOrEqual(40); + }); }); describe('strokeWidthFromThresholds', () => { diff --git a/canvas/src/utils/edgeUtils.ts b/canvas/src/utils/edgeUtils.ts index 5e2152dc9..bd1a6889e 100644 --- a/canvas/src/utils/edgeUtils.ts +++ b/canvas/src/utils/edgeUtils.ts @@ -110,6 +110,11 @@ function perimeterDistance(node: NodeSpec, dx: number, dy: number): number { * Trims an edge's endpoints back to the perimeter of the nodes they connect, so the line and its * arrowhead touch the node boundary instead of overlapping into the node center. Endpoints that are * not a node center (explicit anchors or a free endpoint) are left untouched. + * + * When the nodes overlap so much that their combined perimeter distances consume the whole segment, + * trimming would push the endpoints past each other and reverse the line's direction. In that case + * the edge is collapsed to a zero-length segment at the trim point so it neither renders a reversed + * arrow nor points the wrong way; callers can treat a zero-length result as "nothing to draw". */ export function clipLineToNodes(line: Line, source: NodeSpec | undefined, target: NodeSpec | undefined): Line { const dx = line.end.x - line.start.x; @@ -120,16 +125,16 @@ export function clipLineToNodes(line: Line, source: NodeSpec | undefined, target } const ux = dx / len; const uy = dy / len; - let start = line.start; - let end = line.end; - if (source) { - const d = perimeterDistance(source, dx, dy); - start = { x: line.start.x + ux * d, y: line.start.y + uy * d }; - } - if (target) { - const d = perimeterDistance(target, dx, dy); - end = { x: line.end.x - ux * d, y: line.end.y - uy * d }; + const startTrim = source ? perimeterDistance(source, dx, dy) : 0; + const endTrim = target ? perimeterDistance(target, dx, dy) : 0; + if (startTrim + endTrim >= len) { + // The nodes overlap along this edge: the two perimeters meet or cross. Collapse the edge to a + // single point at the start-side trim so it does not reverse direction. + const point = { x: line.start.x + ux * startTrim, y: line.start.y + uy * startTrim }; + return { start: point, end: point }; } + const start = source ? { x: line.start.x + ux * startTrim, y: line.start.y + uy * startTrim } : line.start; + const end = target ? { x: line.end.x - ux * endTrim, y: line.end.y - uy * endTrim } : line.end; return { start, end }; } diff --git a/canvas/src/utils/graphGeneration.test.ts b/canvas/src/utils/graphGeneration.test.ts index 13862a741..9fe9b525a 100644 --- a/canvas/src/utils/graphGeneration.test.ts +++ b/canvas/src/utils/graphGeneration.test.ts @@ -84,9 +84,29 @@ describe('buildGraphFromQueries', () => { ]; const graph = buildGraphFromQueries(config, results); expect(graph.edges[0]?.sourceLabelTemplate).toBe('{{value}}'); + // Both directional templates come from the single generation setting so a bidirectional edge + // shows the same caption on each half instead of the reverse defaulting to `{{value}}`. + expect(graph.edges[0]?.targetLabelTemplate).toBe('{{value}}'); expect(graph.edges[0]?.bidirectional).toBe(true); }); + it('gives distinct ids to endpoint pairs that share a separator-joined form', () => { + // (`a->b`, `c`) and (`a`, `b->c`) both flatten to `a->b->c` under a naive join; distinct ids + // keep the second edge from being deduplicated away. + const results = [ + result( + series({ instance: 'a->b' }, 1), + series({ instance: 'c' }, 2), + series({ instance: 'a' }, 3), + series({ instance: 'b->c' }, 4), + ), + result(series({ source: 'a->b', target: 'c' }, 5), series({ source: 'a', target: 'b->c' }, 6)), + ]; + const graph = buildGraphFromQueries(baseConfig, results); + expect(graph.edges).toHaveLength(2); + expect(new Set(graph.edges.map((e) => e.id)).size).toBe(2); + }); + it('drives generated edge thickness from the edge-thickness thresholds', () => { const results = [ result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), @@ -118,4 +138,21 @@ describe('buildGraphFromQueries', () => { expect(graph.nodes).toHaveLength(2); expect(graph.edges).toHaveLength(0); }); + + it('builds a topology key that changes with topology but not with values', () => { + const r1 = [ + result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), + result(series({ source: 'a', target: 'b' }, 5)), + ]; + const r2 = [ + result(series({ instance: 'a' }, 100), series({ instance: 'b' }, 200)), + result(series({ source: 'a', target: 'b' }, 500)), + ]; + const r3 = [result(series({ instance: 'a' }, 1)), result()]; + const key1 = buildGraphFromQueries(baseConfig, r1).topologyKey; + const key2 = buildGraphFromQueries(baseConfig, r2).topologyKey; + const key3 = buildGraphFromQueries(baseConfig, r3).topologyKey; + expect(key1).toBe(key2); // only values changed + expect(key1).not.toBe(key3); // topology changed + }); }); diff --git a/canvas/src/utils/graphGeneration.ts b/canvas/src/utils/graphGeneration.ts index c59193d66..cf4eebc38 100644 --- a/canvas/src/utils/graphGeneration.ts +++ b/canvas/src/utils/graphGeneration.ts @@ -22,6 +22,8 @@ export interface GeneratedGraph { edges: EdgeSpec[]; /** Series backing each generated node/edge id, used to resolve color/label/thickness at render time. */ seriesByItemId: Map; + /** Stable key describing the graph topology (node + edge ids), for memoizing layout. */ + topologyKey: string; } const NODE_ID_PREFIX = 'gen-node:'; @@ -96,7 +98,10 @@ export function buildGraphFromQueries( // Drop edges whose endpoints were not produced by the node query. continue; } - const id = `${EDGE_ID_PREFIX}${sourceValue}->${targetValue}`; + // Encode the endpoint pair unambiguously: a plain `source->target` join collides across + // different pairs whose values contain the separator (e.g. (`a->b`, `c`) vs (`a`, `b->c`)). + // JSON.stringify of the tuple escapes the values so each distinct pair maps to a distinct id. + const id = `${EDGE_ID_PREFIX}${JSON.stringify([sourceValue, targetValue])}`; if (edgeIds.has(id)) { continue; } @@ -108,6 +113,10 @@ export function buildGraphFromQueries( target, bidirectional: config.bidirectional ?? false, sourceLabelTemplate: config.edgeLabelTemplate, + // Apply the single generation label template to both directions so a bidirectional edge + // shows the same caption on its forward and reverse halves instead of the reverse half + // falling back to the default `{{value}}`. + targetLabelTemplate: config.edgeLabelTemplate, // Drive thickness from the edge-thickness thresholds so generated edges vary in width with // their value; without this they would all fall back to the fixed default width. thicknessMode: 'threshold', @@ -115,5 +124,7 @@ export function buildGraphFromQueries( } } - return { nodes, edges, seriesByItemId }; + const topologyKey = [nodes.map((n) => n.id).join(','), edges.map((e) => e.id).join(',')].join('|'); + + return { nodes, edges, seriesByItemId, topologyKey }; } diff --git a/canvas/src/utils/graphLayout.test.ts b/canvas/src/utils/graphLayout.test.ts index 48483afd4..86537133f 100644 --- a/canvas/src/utils/graphLayout.test.ts +++ b/canvas/src/utils/graphLayout.test.ts @@ -91,4 +91,20 @@ describe('layoutGraph', () => { expect(Number.isFinite(n.position.x)).toBe(true); } }); + + it('stays bounded on a high-degree hub-and-spoke graph', () => { + // A star topology clusters every spoke's repulsion onto the hub; without a bounded integration + // step the inverse-square forces compound into runaway coordinates (observed order 10^61). + const hub = node('hub'); + const spokes = Array.from({ length: 128 }, (_, i) => node(`spoke-${i}`)); + const edges = spokes.map((s, i) => edge(`e${i}`, 'hub', s.id)); + const laid = layoutGraph([hub, ...spokes], edges); + // Every coordinate stays within a sane viewport-scale bound rather than diverging. + for (const n of laid) { + expect(Number.isFinite(n.position.x)).toBe(true); + expect(Number.isFinite(n.position.y)).toBe(true); + expect(Math.abs(n.position.x)).toBeLessThan(100_000); + expect(Math.abs(n.position.y)).toBeLessThan(100_000); + } + }); }); diff --git a/canvas/src/utils/graphLayout.ts b/canvas/src/utils/graphLayout.ts index 967289f6a..7d3faa2f5 100644 --- a/canvas/src/utils/graphLayout.ts +++ b/canvas/src/utils/graphLayout.ts @@ -25,6 +25,8 @@ export interface LayoutOptions { centering?: number; /** Radius of the deterministic seed circle. */ seedRadius?: number; + /** Maximum distance a node may move on the first iteration; decays linearly to zero. */ + maxStep?: number; } const DEFAULTS: Required = { @@ -34,6 +36,7 @@ const DEFAULTS: Required = { attraction: 0.02, centering: 0.01, seedRadius: 200, + maxStep: 100, }; // Deterministic 32-bit string hash (FNV-1a) so a given node id always seeds to the same angle. @@ -83,6 +86,11 @@ export function layoutGraph(nodes: NodeSpec[], edges: EdgeSpec[], options: Layou const disp: Point[] = pos.map(() => ({ x: 0, y: 0 })); for (let iter = 0; iter < opts.iterations; iter++) { + // Cooling schedule: the per-node step shrinks linearly to zero over the run. Capping the step + // keeps the simulation stable — without it the inverse-square repulsion between a hub's many + // tightly-seeded spokes produces enormous displacements that compound into runaway coordinates. + const maxStep = opts.maxStep * (1 - iter / opts.iterations); + for (let i = 0; i < n; i++) { disp[i]!.x = 0; disp[i]!.y = 0; @@ -125,10 +133,17 @@ export function layoutGraph(nodes: NodeSpec[], edges: EdgeSpec[], options: Layou disp[b]!.y -= fy; } - // Centering + integrate. + // Centering + integrate, clamping each node's movement to the current cooling step so the + // layout always stays bounded no matter how large the accumulated forces are. for (let i = 0; i < n; i++) { disp[i]!.x -= pos[i]!.x * opts.centering; disp[i]!.y -= pos[i]!.y * opts.centering; + const mag = Math.hypot(disp[i]!.x, disp[i]!.y); + if (mag > maxStep) { + const s = maxStep / mag; + disp[i]!.x *= s; + disp[i]!.y *= s; + } pos[i]!.x += disp[i]!.x; pos[i]!.y += disp[i]!.y; } From 5257ed2a0260aa9116d39686adfb7dbe78488657 Mon Sep 17 00:00:00 2001 From: Philipp Matthes Date: Thu, 29 Oct 2026 16:32:34 +0100 Subject: [PATCH 5/9] [FEATURE] Address follow-up review on canvas graph generation - Cache only node positions by id (not whole node specs) so appearance edits like default size and label template take effect on refresh without rerunning the force simulation. - Reconcile edgeThresholdWidths with the effective edge thresholds (edge thresholds, or node thresholds as fallback) so a width cannot linger as an active-but-hidden step after its breakpoint changes. - Build the topology key from JSON-serialized sorted id arrays so it is unambiguous (no separator collision) and independent of series order. Signed-off-by: Philipp Matthes --- canvas/src/components/panel/CanvasPanel.tsx | 36 ++++++++++--------- .../settings/GlobalSettingsEditor.tsx | 20 +++++++++-- canvas/src/utils/graphGeneration.test.ts | 17 +++++++++ canvas/src/utils/graphGeneration.ts | 8 ++++- 4 files changed, 60 insertions(+), 21 deletions(-) diff --git a/canvas/src/components/panel/CanvasPanel.tsx b/canvas/src/components/panel/CanvasPanel.tsx index 146eec96c..67e8d9df0 100644 --- a/canvas/src/components/panel/CanvasPanel.tsx +++ b/canvas/src/components/panel/CanvasPanel.tsx @@ -19,7 +19,7 @@ import { useCallback, useMemo, useState } from 'react'; import { useZoomContext, ZoomProvider } from '../../contexts/ZoomContext'; import { useZoom } from '../../hooks/useZoom'; -import type { CanvasProps, CanvasSpec, NodeSpec } from '../../model'; +import type { CanvasProps, CanvasSpec, Point } from '../../model'; import { buildGraphFromQueries } from '../../utils/graphGeneration'; import { layoutGraph } from '../../utils/graphLayout'; import { scaleFromThresholds } from '../../utils/panelUtils'; @@ -169,36 +169,38 @@ export function CanvasPanel(props: CanvasProps): ReactElement | null { ); // The force simulation is expensive (O(n²) per iteration) and must not rerun when only metric - // values change. Cache its origin-centered output keyed by the topology, using React's sanctioned - // "adjust state during render" pattern: a value-only refresh keeps the same topologyKey and reuses - // the cached layout, so the simulation reruns only when the set of nodes/edges actually changes. - const [layoutCache, setLayoutCache] = useState<{ key: string; nodes: NodeSpec[] } | undefined>(undefined); - let laidOutNodes = layoutCache?.nodes; + // values change. Cache just the origin-centered positions (keyed by node id) for a given topology, + // using React's sanctioned "adjust state during render" pattern: a value-only refresh keeps the + // same topologyKey and reuses the cached positions, so the simulation reruns only when the set of + // nodes/edges actually changes. Caching positions rather than whole node specs means appearance + // edits (default size, labels) still flow through from the freshly generated nodes below. + const [layoutCache, setLayoutCache] = useState<{ key: string; positions: Map } | undefined>(undefined); + let positions = layoutCache?.positions; if (generated && layoutCache?.key !== generated.topologyKey) { - laidOutNodes = layoutGraph(generated.nodes, generated.edges); - setLayoutCache({ key: generated.topologyKey, nodes: laidOutNodes }); + positions = new Map(layoutGraph(generated.nodes, generated.edges).map((node) => [node.id, node.position])); + setLayoutCache({ key: generated.topologyKey, positions }); } else if (!generated && layoutCache) { - laidOutNodes = undefined; + positions = undefined; setLayoutCache(undefined); } const effectiveSpec: CanvasSpec = useMemo(() => { - if (!generated || !laidOutNodes) { + if (!generated || !positions) { return spec; } // layoutGraph centers the generated graph on the origin (0, 0). Under the identity zoom // transform the origin is the SVG's top-left corner, so the graph would render clipped into that // corner. Offset every node by the viewport center so the graph sits in the middle of the panel. - // Build fresh node objects here (never mutate the cached layout) so refreshes and resizes reuse - // the same origin-centered positions. + // Apply the cached position onto the freshly generated node (keeping its current label/size), so + // appearance edits take effect without rerunning the simulation. const offsetX = (props.contentDimensions?.width ?? 600) / 2; const offsetY = (props.contentDimensions?.height ?? 400) / 2; - const nodes = laidOutNodes.map((node) => ({ - ...node, - position: { x: node.position.x + offsetX, y: node.position.y + offsetY }, - })); + const nodes = generated.nodes.map((node) => { + const pos = positions.get(node.id) ?? node.position; + return { ...node, position: { x: pos.x + offsetX, y: pos.y + offsetY } }; + }); return { ...spec, nodes, edges: generated.edges }; - }, [spec, generated, laidOutNodes, props.contentDimensions?.width, props.contentDimensions?.height]); + }, [spec, generated, positions, props.contentDimensions?.width, props.contentDimensions?.height]); const { svgRef, toCanvasPoint, transform, isPanning, fitView, resetPan } = useZoom({ interactionMode: 'view' }); diff --git a/canvas/src/components/settings/GlobalSettingsEditor.tsx b/canvas/src/components/settings/GlobalSettingsEditor.tsx index 2e0e8fec2..c2a5138dc 100644 --- a/canvas/src/components/settings/GlobalSettingsEditor.tsx +++ b/canvas/src/components/settings/GlobalSettingsEditor.tsx @@ -109,12 +109,17 @@ export function GlobalSettingsEditor({ value, onChange }: GlobalSettingsEditorPr // The per-threshold node-size multipliers are keyed by threshold value. When a color threshold // is moved or removed, drop any multiplier whose value no longer matches a step so it cannot // linger as an active-but-hidden scale in the panel while disappearing from the editor. - const stepValues = new Set(thresholds?.steps?.map((step) => step.value)); - const nodeThresholdSizes = value.nodeThresholdSizes?.filter((size) => stepValues.has(size.value)); + const nodeStepValues = new Set(thresholds?.steps?.map((step) => step.value)); + const nodeThresholdSizes = value.nodeThresholdSizes?.filter((size) => nodeStepValues.has(size.value)); + // Edge widths key off the effective edge thresholds, which fall back to these node thresholds + // when no edge thresholds are set — so reconcile them here too. + const edgeStepValues = new Set((value.edgeThresholds ?? thresholds)?.steps?.map((step) => step.value)); + const edgeThresholdWidths = value.edgeThresholdWidths?.filter((width) => edgeStepValues.has(width.value)); onChange({ ...value, thresholds, nodeThresholdSizes: nodeThresholdSizes?.length ? nodeThresholdSizes : undefined, + edgeThresholdWidths: edgeThresholdWidths?.length ? edgeThresholdWidths : undefined, }); }, [value, onChange], @@ -122,7 +127,16 @@ export function GlobalSettingsEditor({ value, onChange }: GlobalSettingsEditorPr const onEdgeThresholdsChange = useCallback( (edgeThresholds: ThresholdOptions | undefined): void => { - onChange({ ...value, edgeThresholds }); + // Edge thickness breakpoints key off the effective edge thresholds (edge thresholds, or the + // node thresholds when edge thresholds are cleared). Drop any width whose value no longer + // matches a step so it cannot linger as an active-but-hidden width in the panel. + const edgeStepValues = new Set((edgeThresholds ?? value.thresholds)?.steps?.map((step) => step.value)); + const edgeThresholdWidths = value.edgeThresholdWidths?.filter((width) => edgeStepValues.has(width.value)); + onChange({ + ...value, + edgeThresholds, + edgeThresholdWidths: edgeThresholdWidths?.length ? edgeThresholdWidths : undefined, + }); }, [value, onChange], ); diff --git a/canvas/src/utils/graphGeneration.test.ts b/canvas/src/utils/graphGeneration.test.ts index 9fe9b525a..4f0c55f8b 100644 --- a/canvas/src/utils/graphGeneration.test.ts +++ b/canvas/src/utils/graphGeneration.test.ts @@ -155,4 +155,21 @@ describe('buildGraphFromQueries', () => { expect(key1).toBe(key2); // only values changed expect(key1).not.toBe(key3); // topology changed }); + + it('topology key is independent of series order', () => { + const forward = [result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), result()]; + const reversed = [result(series({ instance: 'b' }, 2), series({ instance: 'a' }, 1)), result()]; + expect(buildGraphFromQueries(baseConfig, forward).topologyKey).toBe( + buildGraphFromQueries(baseConfig, reversed).topologyKey, + ); + }); + + it('topology key does not collide between distinct node sets that share a joined form', () => { + // Two nodes `a` and `b` must not key the same as a single node literally named `a,gen-node:b`. + const twoNodes = [result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), result()]; + const oneNode = [result(series({ instance: 'a,gen-node:b' }, 1)), result()]; + expect(buildGraphFromQueries(baseConfig, twoNodes).topologyKey).not.toBe( + buildGraphFromQueries(baseConfig, oneNode).topologyKey, + ); + }); }); diff --git a/canvas/src/utils/graphGeneration.ts b/canvas/src/utils/graphGeneration.ts index cf4eebc38..6cdd2518b 100644 --- a/canvas/src/utils/graphGeneration.ts +++ b/canvas/src/utils/graphGeneration.ts @@ -124,7 +124,13 @@ export function buildGraphFromQueries( } } - const topologyKey = [nodes.map((n) => n.id).join(','), edges.map((e) => e.id).join(',')].join('|'); + // Serialize sorted id arrays via JSON so the key is unambiguous (no separator collision between, + // e.g., ids `a`,`b` and a single id `a,gen-node:b`) and independent of series order, so merely + // reordering the same series does not change the topology and rerun the layout. + const topologyKey = JSON.stringify({ + nodes: nodes.map((n) => n.id).toSorted(), + edges: edges.map((e) => e.id).toSorted(), + }); return { nodes, edges, seriesByItemId, topologyKey }; } From 439dc3225a893084904b251c9832f6d974ca3a3f Mon Sep 17 00:00:00 2001 From: Philipp Matthes Date: Thu, 29 Oct 2026 17:10:50 +0100 Subject: [PATCH 6/9] [FIX] Fix misaligned whitespace in topology.prom Signed-off-by: Philipp Matthes --- docs/canvas/examples/metrics/topology.prom | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/canvas/examples/metrics/topology.prom b/docs/canvas/examples/metrics/topology.prom index 70323d072..8994cbb65 100644 --- a/docs/canvas/examples/metrics/topology.prom +++ b/docs/canvas/examples/metrics/topology.prom @@ -11,9 +11,9 @@ # # Topology (an e-commerce-style request path across two regions): # -# ┌──────────────┐ +# ┌───────────────┐ # │ edge gateway │ -# └──────┬───────┘ +# └──────┬────────┘ # ┌─────────┼──────────┐ # ▼ ▼ ▼ # web-eu web-us auth (shared) From 3773fd66eca4ee153dc6f416abf30b3ecb32c262 Mon Sep 17 00:00:00 2001 From: Philipp Matthes Date: Thu, 29 Oct 2026 17:10:56 +0100 Subject: [PATCH 7/9] [FEATURE] Address further review on canvas graph generation - Canonicalize the force-simulation node and edge order (id-sorted) so a topology laid out again with reordered query results settles to identical positions; map back to input order on return. Add a larger shuffled-input regression test. - Merge reciprocal A->B / B->A edge rows into a single bidirectional edge carrying a per-direction series, so the two directions no longer overpaint one path or overlap their labels. Plumb a reverse-series map through to the edge layer. - Shrink an edge's arrowhead to fit when a node-clipped directional segment is shorter than the marker, so arrowheads no longer intrude into the target node; accounts for zoom via the already-scaled stroke width. - Fix checkdocs: write the localhost dev-stack URLs in the example README as inline code instead of autolinks so mdox does not try to reach them. Signed-off-by: Philipp Matthes --- canvas/src/components/panel/CanvasPanel.tsx | 4 ++ .../src/components/panel/PanelEdgeLayer.tsx | 13 +++- .../src/components/shared/EdgeLines.test.ts | 40 +++++++++++ canvas/src/components/shared/EdgeLines.tsx | 59 +++++++++++++--- canvas/src/utils/graphGeneration.test.ts | 39 +++++++++++ canvas/src/utils/graphGeneration.ts | 70 +++++++++++++++---- canvas/src/utils/graphLayout.test.ts | 25 +++++++ canvas/src/utils/graphLayout.ts | 34 ++++++--- docs/canvas/examples/README.md | 4 +- 9 files changed, 252 insertions(+), 36 deletions(-) create mode 100644 canvas/src/components/shared/EdgeLines.test.ts diff --git a/canvas/src/components/panel/CanvasPanel.tsx b/canvas/src/components/panel/CanvasPanel.tsx index 67e8d9df0..3e9318e57 100644 --- a/canvas/src/components/panel/CanvasPanel.tsx +++ b/canvas/src/components/panel/CanvasPanel.tsx @@ -39,6 +39,7 @@ interface PanelSvgProps { seriesByQueryIndex: Map; paletteColors: string[]; seriesByItemId?: Map; + reverseSeriesByItemId?: Map; } function PanelSvg({ @@ -48,6 +49,7 @@ function PanelSvg({ seriesByQueryIndex, paletteColors, seriesByItemId, + reverseSeriesByItemId, }: PanelSvgProps): ReactElement { const { transform, isPanning, fitView, resetPan } = useZoomContext(); @@ -128,6 +130,7 @@ function PanelSvg({ k={transform.k} paletteColors={paletteColors} seriesByItemId={seriesByItemId} + reverseSeriesByItemId={reverseSeriesByItemId} /> @@ -218,6 +221,7 @@ export function CanvasPanel(props: CanvasProps): ReactElement | null { seriesByQueryIndex={seriesByQueryIndex} paletteColors={paletteColors} seriesByItemId={generated?.seriesByItemId} + reverseSeriesByItemId={generated?.reverseSeriesByItemId} /> ); diff --git a/canvas/src/components/panel/PanelEdgeLayer.tsx b/canvas/src/components/panel/PanelEdgeLayer.tsx index 7afc32326..15ba8578a 100644 --- a/canvas/src/components/panel/PanelEdgeLayer.tsx +++ b/canvas/src/components/panel/PanelEdgeLayer.tsx @@ -63,6 +63,8 @@ interface PanelEdgeLayerProps { paletteColors: string[]; /** Series backing generated edges (data-driven mode), keyed by edge id. Takes precedence over queryIndex. */ seriesByItemId?: Map; + /** Series backing the reverse half of a reciprocal generated edge, keyed by edge id. */ + reverseSeriesByItemId?: Map; } export function PanelEdgeLayer({ @@ -71,6 +73,7 @@ export function PanelEdgeLayer({ k, paletteColors, seriesByItemId, + reverseSeriesByItemId, }: PanelEdgeLayerProps): ReactElement { const nodes = useMemo(() => spec.nodes ?? [], [spec.nodes]); const edges = useMemo(() => spec.edges ?? [], [spec.edges]); @@ -98,8 +101,14 @@ export function PanelEdgeLayer({ } const generatedSeries = seriesByItemId?.get(edge.id); + const generatedReverseSeries = reverseSeriesByItemId?.get(edge.id); - function resolveSeries(queryIndex: number | undefined): TimeSeries | undefined { + function resolveSeries(queryIndex: number | undefined, reverse = false): TimeSeries | undefined { + // For a reciprocal generated edge the reverse half has its own series; fall back to the + // forward series otherwise so a plain bidirectional edge still shows a value on both halves. + if (reverse && generatedReverseSeries) { + return generatedReverseSeries; + } if (generatedSeries) { return generatedSeries; } @@ -114,7 +123,7 @@ export function PanelEdgeLayer({ } const fwdSeries = resolveSeries(edge.sourceQueryIndex); - const bwdSeries = resolveSeries(edge.targetQueryIndex); + const bwdSeries = resolveSeries(edge.targetQueryIndex, true); const fwdStyle = resolveEdgeStyle( fwdSeries, diff --git a/canvas/src/components/shared/EdgeLines.test.ts b/canvas/src/components/shared/EdgeLines.test.ts new file mode 100644 index 000000000..58699f9ac --- /dev/null +++ b/canvas/src/components/shared/EdgeLines.test.ts @@ -0,0 +1,40 @@ +// 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 { arrowScaleForSegment } from './EdgeLines'; + +// The arrowhead renders ARROW_SW_W (2.5) * strokeWidth user-units long. +describe('arrowScaleForSegment', () => { + it('keeps full size when the segment is longer than the arrowhead', () => { + // strokeWidth 2 -> arrowhead 5; a 100px segment easily fits it. + expect(arrowScaleForSegment(100, 2)).toBe(1); + }); + + it('keeps full size at exactly the arrowhead length', () => { + expect(arrowScaleForSegment(5, 2)).toBe(1); + }); + + it('shrinks the arrowhead to fit a short clipped segment', () => { + // Two 48px nodes 60px apart leave a 12px gap; an 8px stroke wants a 20px arrowhead. + // Scale to 12/20 = 0.6 so the marker tip lands on the node boundary instead of overshooting. + expect(arrowScaleForSegment(12, 8)).toBeCloseTo(0.6); + }); + + it('never returns a negative scale for a zero-length segment', () => { + expect(arrowScaleForSegment(0, 8)).toBe(0); + }); + + it('is full size when stroke width is zero (no arrowhead to overshoot)', () => { + expect(arrowScaleForSegment(10, 0)).toBe(1); + }); +}); diff --git a/canvas/src/components/shared/EdgeLines.tsx b/canvas/src/components/shared/EdgeLines.tsx index 09d632746..87072f2d6 100644 --- a/canvas/src/components/shared/EdgeLines.tsx +++ b/canvas/src/components/shared/EdgeLines.tsx @@ -20,6 +20,9 @@ import { midpoint } from '../../utils/edgeUtils'; interface EdgeGeometry { fwd: Line; bwd: Line | null; + /** Arrowhead length scale in [0, 1] per direction, shrunk so a short segment's marker still fits. */ + fwdArrowScale: number; + bwdArrowScale: number; } function shortenEnd(line: Line, amount: number): Line { @@ -31,6 +34,24 @@ function shortenEnd(line: Line, amount: number): Line { return { start: line.start, end: { x: line.start.x + dx * t, y: line.start.y + dy * t } }; } +function lineLength(line: Line): number { + return Math.hypot(line.end.x - line.start.x, line.end.y - line.start.y); +} + +/** + * The arrowhead renders `ARROW_SW_W * strokeWidth` user-units long (markerUnits="strokeWidth"). + * When the (already node-clipped) directional segment is shorter than that, the full-size marker + * would overshoot past the segment into the node. Return a scale in (0, 1] that shrinks the marker + * to fit the available length so its tip still lands on the node boundary. + */ +export function arrowScaleForSegment(segmentLength: number, strokeWidth: number): number { + const arrowLength = ARROW_SW_W * strokeWidth; + if (arrowLength <= 0 || segmentLength >= arrowLength) { + return 1; + } + return Math.max(0, segmentLength / arrowLength); +} + function computeEdgeGeometry( pts: Line, bidirectional: boolean, @@ -41,12 +62,17 @@ function computeEdgeGeometry( const bwdShorten = ARROW_SW_W * bwdStrokeWidth; if (!bidirectional) { - return { fwd: shortenEnd(pts, fwdShorten), bwd: null }; + const fwd = shortenEnd(pts, fwdShorten); + return { fwd, bwd: null, fwdArrowScale: arrowScaleForSegment(lineLength(pts), fwdStrokeWidth), bwdArrowScale: 1 }; } const mid = midpoint(pts); + const fwdFull = { start: pts.start, end: mid }; + const bwdFull = { start: pts.end, end: mid }; return { - fwd: shortenEnd({ start: pts.start, end: mid }, fwdShorten), - bwd: shortenEnd({ start: pts.end, end: mid }, bwdShorten), + fwd: shortenEnd(fwdFull, fwdShorten), + bwd: shortenEnd(bwdFull, bwdShorten), + fwdArrowScale: arrowScaleForSegment(lineLength(fwdFull), fwdStrokeWidth), + bwdArrowScale: arrowScaleForSegment(lineLength(bwdFull), bwdStrokeWidth), }; } @@ -67,19 +93,23 @@ interface EdgeArrowMarkerProps { nsPrefix: string; direction: 'fwd' | 'bwd'; fill: string; + /** Shrinks the marker so a short, node-clipped segment's arrowhead does not overshoot the node. */ + scale?: number; } -function EdgeArrowMarker({ nsPrefix, direction, fill }: EdgeArrowMarkerProps): ReactElement { +function EdgeArrowMarker({ nsPrefix, direction, fill, scale = 1 }: EdgeArrowMarkerProps): ReactElement { + const w = ARROW_SW_W * scale; + const h = ARROW_SW_H * scale; return ( - + ); } @@ -102,13 +132,20 @@ export function EdgeLines({ lineProps, }: EdgeLinesProps): ReactElement { const resolvedBwdStyle = bwdStyle ?? fwdStyle; - const { fwd, bwd } = computeEdgeGeometry(pts, bidirectional, fwdStyle.strokeWidth, resolvedBwdStyle.strokeWidth); + const { fwd, bwd, fwdArrowScale, bwdArrowScale } = computeEdgeGeometry( + pts, + bidirectional, + fwdStyle.strokeWidth, + resolvedBwdStyle.strokeWidth, + ); return ( <> - - {bwd ? : null} + + {bwd ? ( + + ) : null} { expect(new Set(graph.edges.map((e) => e.id)).size).toBe(2); }); + it('merges reciprocal A→B and B→A rows into one bidirectional edge with per-direction series', () => { + const results = [ + result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), + result(series({ source: 'a', target: 'b' }, 5), series({ source: 'b', target: 'a' }, 9)), + ]; + const graph = buildGraphFromQueries(baseConfig, results); + expect(graph.edges).toHaveLength(1); + const edge = graph.edges[0]!; + expect(edge).toMatchObject({ source: 'gen-node:a', target: 'gen-node:b', bidirectional: true }); + // Forward half keeps the A→B series; the reverse half gets the B→A series. + expect(graph.seriesByItemId.get(edge.id)?.values[0]?.[1]).toBe(5); + expect(graph.reverseSeriesByItemId.get(edge.id)?.values[0]?.[1]).toBe(9); + }); + + it('keeps a single-direction edge non-bidirectional by default and leaves no reverse series', () => { + const results = [ + result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), + result(series({ source: 'a', target: 'b' }, 5)), + ]; + const graph = buildGraphFromQueries(baseConfig, results); + expect(graph.edges).toHaveLength(1); + expect(graph.edges[0]?.bidirectional).toBe(false); + expect(graph.reverseSeriesByItemId.get(graph.edges[0]!.id)).toBeUndefined(); + }); + + it('merges reciprocal rows regardless of which direction appears first', () => { + const results = [ + result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), + result(series({ source: 'b', target: 'a' }, 9), series({ source: 'a', target: 'b' }, 5)), + ]; + const graph = buildGraphFromQueries(baseConfig, results); + expect(graph.edges).toHaveLength(1); + const edge = graph.edges[0]!; + // Orientation follows the first-seen row (B→A here). + expect(edge).toMatchObject({ source: 'gen-node:b', target: 'gen-node:a', bidirectional: true }); + expect(graph.seriesByItemId.get(edge.id)?.values[0]?.[1]).toBe(9); + expect(graph.reverseSeriesByItemId.get(edge.id)?.values[0]?.[1]).toBe(5); + }); + it('drives generated edge thickness from the edge-thickness thresholds', () => { const results = [ result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), diff --git a/canvas/src/utils/graphGeneration.ts b/canvas/src/utils/graphGeneration.ts index 6cdd2518b..bf0d44f19 100644 --- a/canvas/src/utils/graphGeneration.ts +++ b/canvas/src/utils/graphGeneration.ts @@ -22,6 +22,11 @@ export interface GeneratedGraph { edges: EdgeSpec[]; /** Series backing each generated node/edge id, used to resolve color/label/thickness at render time. */ seriesByItemId: Map; + /** + * Series backing the reverse half of a reciprocal generated edge, keyed by edge id. Present only + * for edges where the query returned both A→B and B→A; the forward half uses `seriesByItemId`. + */ + reverseSeriesByItemId: Map; /** Stable key describing the graph topology (node + edge ids), for memoizing layout. */ topologyKey: string; } @@ -45,6 +50,7 @@ export function buildGraphFromQueries( nodeDefaultSize?: number, ): GeneratedGraph { const seriesByItemId = new Map(); + const reverseSeriesByItemId = new Map(); const nodes: NodeSpec[] = []; const nodeIds = new Set(); const nodeSize = nodeDefaultSize && nodeDefaultSize > 0 ? nodeDefaultSize : DEFAULT_NODE_WIDTH; @@ -79,13 +85,28 @@ export function buildGraphFromQueries( } const edges: EdgeSpec[] = []; - const edgeIds = new Set(); const canBuildEdges = config.edgeQueryIndex !== undefined && config.edgeSourceLabel !== undefined && config.edgeTargetLabel !== undefined; if (canBuildEdges) { const edgeResult = queryResults[config.edgeQueryIndex!]; const edgeSeries = edgeResult?.data.series ?? []; + + // Group directed series by their unordered node pair so reciprocal rows (A→B and B→A) become a + // single bidirectional edge. Each entry keeps the orientation of the first-seen series (its + // `source`/`target`) plus the series for each observed direction. Building edges once per pair + // avoids two generated edges overpainting the same path and overlapping their labels. + interface PairEntry { + source: string; + target: string; + sourceValue: string; + targetValue: string; + forward?: TimeSeries; + reverse?: TimeSeries; + } + const pairs = new Map(); + const pairOrder: string[] = []; + for (const series of edgeSeries) { const sourceValue = series.labels?.[config.edgeSourceLabel!]; const targetValue = series.labels?.[config.edgeTargetLabel!]; @@ -98,20 +119,43 @@ export function buildGraphFromQueries( // Drop edges whose endpoints were not produced by the node query. continue; } - // Encode the endpoint pair unambiguously: a plain `source->target` join collides across - // different pairs whose values contain the separator (e.g. (`a->b`, `c`) vs (`a`, `b->c`)). - // JSON.stringify of the tuple escapes the values so each distinct pair maps to a distinct id. - const id = `${EDGE_ID_PREFIX}${JSON.stringify([sourceValue, targetValue])}`; - if (edgeIds.has(id)) { - continue; + // Unordered pair key so A→B and B→A map to the same entry. JSON.stringify of the sorted tuple + // escapes the values so distinct pairs never collide (e.g. (`a->b`, `c`) vs (`a`, `b->c`)). + const pairKey = JSON.stringify([sourceValue, targetValue].toSorted()); + let entry = pairs.get(pairKey); + if (!entry) { + entry = { source, target, sourceValue, targetValue }; + pairs.set(pairKey, entry); + pairOrder.push(pairKey); + } + // Record the series for this direction relative to the entry's orientation. A repeated series + // in the same direction keeps the first one for determinism. + if (source === entry.source && target === entry.target) { + entry.forward ??= series; + } else { + entry.reverse ??= series; + } + } + + for (const pairKey of pairOrder) { + const entry = pairs.get(pairKey)!; + const isReciprocal = entry.forward !== undefined && entry.reverse !== undefined; + // Stable id from the entry's (first-seen) orientation. + const id = `${EDGE_ID_PREFIX}${JSON.stringify([entry.sourceValue, entry.targetValue])}`; + const forwardSeries = entry.forward ?? entry.reverse; + if (forwardSeries) { + seriesByItemId.set(id, forwardSeries); + } + if (isReciprocal) { + reverseSeriesByItemId.set(id, entry.reverse!); } - edgeIds.add(id); - seriesByItemId.set(id, series); edges.push({ id, - source, - target, - bidirectional: config.bidirectional ?? false, + source: entry.source, + target: entry.target, + // A reciprocal pair is always rendered bidirectionally so each direction shows on its own + // half; a single-direction edge honors the configured flag. + bidirectional: isReciprocal || (config.bidirectional ?? false), sourceLabelTemplate: config.edgeLabelTemplate, // Apply the single generation label template to both directions so a bidirectional edge // shows the same caption on its forward and reverse halves instead of the reverse half @@ -132,5 +176,5 @@ export function buildGraphFromQueries( edges: edges.map((e) => e.id).toSorted(), }); - return { nodes, edges, seriesByItemId, topologyKey }; + return { nodes, edges, seriesByItemId, reverseSeriesByItemId, topologyKey }; } diff --git a/canvas/src/utils/graphLayout.test.ts b/canvas/src/utils/graphLayout.test.ts index 86537133f..3459ad721 100644 --- a/canvas/src/utils/graphLayout.test.ts +++ b/canvas/src/utils/graphLayout.test.ts @@ -22,6 +22,16 @@ function edge(id: string, source: string, target: string): EdgeSpec { return { id, source, target }; } +// Deterministic shuffle so order-independence tests are stable. +function shuffled(xs: T[], seed: number): T[] { + const out = [...xs]; + for (let i = out.length - 1; i > 0; i--) { + const j = (i * 1103515245 + seed) % (i + 1); + [out[i], out[j]] = [out[j]!, out[i]!]; + } + return out; +} + describe('layoutGraph', () => { it('returns an empty array for no nodes', () => { expect(layoutGraph([], [])).toEqual([]); @@ -50,6 +60,21 @@ describe('layoutGraph', () => { } }); + it('is order-independent for a larger connected graph with shuffled nodes and edges', () => { + const ids = Array.from({ length: 20 }, (_, i) => `n${i}`); + const nodes = ids.map(node); + // A connected graph: a ring plus a few chords, so the spring pass is exercised. + const edges: EdgeSpec[] = ids.map((id, i) => edge(`e${i}`, id, ids[(i + 1) % ids.length]!)); + edges.push(edge('c1', 'n0', 'n10'), edge('c2', 'n3', 'n15'), edge('c3', 'n5', 'n12')); + + const base = layoutGraph(nodes, edges); + const reordered = layoutGraph(shuffled(nodes, 7), shuffled(edges, 13)); + const basePos = new Map(base.map((n) => [n.id, n.position])); + for (const n of reordered) { + expect(n.position).toEqual(basePos.get(n.id)); + } + }); + it('produces finite coordinates and separates nodes', () => { const nodes = [node('a'), node('b'), node('c'), node('d')]; const edges = [edge('e1', 'a', 'b'), edge('e2', 'c', 'd')]; diff --git a/canvas/src/utils/graphLayout.ts b/canvas/src/utils/graphLayout.ts index 7d3faa2f5..6b9f4b779 100644 --- a/canvas/src/utils/graphLayout.ts +++ b/canvas/src/utils/graphLayout.ts @@ -65,23 +65,37 @@ export function layoutGraph(nodes: NodeSpec[], edges: EdgeSpec[], options: Layou return [{ ...nodes[0]!, position: { x: 0, y: 0 } }]; } + // Run the simulation over a canonical (id-sorted) ordering of the nodes, not the incoming array + // order. Floating-point force sums are not associative, so iterating nodes in query order would + // let a mere reordering of the same topology settle to slightly different positions and visibly + // move nodes. `inputToSim[inputIdx]` maps an input-array index to its simulation slot so the + // returned array can preserve the caller's node order. + const simOrder = nodes.map((_, i) => i).toSorted((a, b) => (nodes[a]!.id < nodes[b]!.id ? -1 : 1)); + const inputToSim = Array.from({ length: n }, () => 0); + simOrder.forEach((inputIdx, simIdx) => { + inputToSim[inputIdx] = simIdx; + }); + const simNodes = simOrder.map((i) => nodes[i]!); + // Deterministic seed: place each node on a circle at an angle derived from its id hash. - const pos: Point[] = nodes.map((node) => { + const pos: Point[] = simNodes.map((node) => { const angle = (hashString(node.id) / 0xffffffff) * Math.PI * 2; return { x: Math.cos(angle) * opts.seedRadius, y: Math.sin(angle) * opts.seedRadius }; }); const indexById = new Map(); - nodes.forEach((node, i) => indexById.set(node.id, i)); + simNodes.forEach((node, i) => indexById.set(node.id, i)); + // Build the edge list in a canonical order too, so the spring pass is order-independent as well. const edgePairs: Array<[number, number]> = []; for (const edge of edges) { const a = indexById.get(edge.source); const b = edge.target !== undefined ? indexById.get(edge.target) : undefined; if (a !== undefined && b !== undefined && a !== b) { - edgePairs.push([a, b]); + edgePairs.push(a < b ? [a, b] : [b, a]); } } + edgePairs.sort((p, q) => p[0] - q[0] || p[1] - q[1]); const disp: Point[] = pos.map(() => ({ x: 0, y: 0 })); @@ -154,17 +168,21 @@ export function layoutGraph(nodes: NodeSpec[], edges: EdgeSpec[], options: Layou // seeded graphs that equilibrium is noticeably off-origin. Shifting by the centroid guarantees the // result is centered on (0, 0) regardless of how the simulation settled. // - // Accumulate in id-sorted order so floating-point rounding of the sum does not depend on the input - // array order (layout is otherwise order-independent — positions follow node id, not slot). - const order = nodes.map((_, i) => i).toSorted((a, b) => (nodes[a]!.id < nodes[b]!.id ? -1 : 1)); + // `pos` is already in the canonical (id-sorted) simulation order, so the sum is independent of the + // caller's input order. let cx = 0; let cy = 0; - for (const i of order) { + for (let i = 0; i < n; i++) { cx += pos[i]!.x; cy += pos[i]!.y; } cx /= n; cy /= n; - return nodes.map((node, i) => ({ ...node, position: { x: pos[i]!.x - cx, y: pos[i]!.y - cy } })); + // Map each input node to its simulation slot so the returned array preserves the caller's order + // while carrying the canonically computed positions. + return nodes.map((node, inputIdx) => { + const p = pos[inputToSim[inputIdx]!]!; + return { ...node, position: { x: p.x - cx, y: p.y - cy } }; + }); } diff --git a/docs/canvas/examples/README.md b/docs/canvas/examples/README.md index dd1cfd221..0a5e0cc6b 100644 --- a/docs/canvas/examples/README.md +++ b/docs/canvas/examples/README.md @@ -27,7 +27,7 @@ other labels such as `{{role}}` or `{{region}}` to see how the label templates w ## Steps 1. **Start the demo exporter** (from the plugins repo root). It serves the metrics at - : + `http://localhost:9101/metrics`: ```bash docker compose --file docs/canvas/examples/docker-compose.yml up --detach @@ -49,7 +49,7 @@ other labels such as `{{role}}` or `{{region}}` to see how the label templates w ``` The dev `PrometheusLocal` datasource (`http://localhost:9090`) is the default, so no datasource - setup is needed. Confirm `node_load` and `edge_traffic` return data at . + setup is needed. Confirm `node_load` and `edge_traffic` return data at `http://localhost:9090`. 3. **Run the backend and the plugin dev server.** In `perses/perses`, set `plugin.enable_dev: true` in `dev/config.yaml`, then: From e1574cc0c18e269c7cc13f59d3f8c3c7b23cc3d6 Mon Sep 17 00:00:00 2001 From: Philipp Matthes Date: Fri, 30 Oct 2026 08:16:07 +0100 Subject: [PATCH 8/9] [FEATURE] Make reciprocal edge ids order-independent Derive a generated edge's id from the canonical unordered pair key rather than the first-seen orientation. Reordering reciprocal A->B / B->A query rows no longer flips the id or the topology key, so CanvasPanel does not rerun the force simulation on an order-only refresh. Orientation is still carried by the entry's source/target for the directional series. Signed-off-by: Philipp Matthes --- canvas/src/utils/graphGeneration.test.ts | 18 ++++++++++++++++++ canvas/src/utils/graphGeneration.ts | 7 +++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/canvas/src/utils/graphGeneration.test.ts b/canvas/src/utils/graphGeneration.test.ts index 074a00045..8efaddc6d 100644 --- a/canvas/src/utils/graphGeneration.test.ts +++ b/canvas/src/utils/graphGeneration.test.ts @@ -146,6 +146,24 @@ describe('buildGraphFromQueries', () => { expect(graph.reverseSeriesByItemId.get(edge.id)?.values[0]?.[1]).toBe(5); }); + it('keeps a stable edge id and topology key when reciprocal rows are reordered', () => { + const nodes = result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)); + const forwardFirst = [ + nodes, + result(series({ source: 'a', target: 'b' }, 5), series({ source: 'b', target: 'a' }, 9)), + ]; + const reverseFirst = [ + nodes, + result(series({ source: 'b', target: 'a' }, 9), series({ source: 'a', target: 'b' }, 5)), + ]; + const a = buildGraphFromQueries(baseConfig, forwardFirst); + const b = buildGraphFromQueries(baseConfig, reverseFirst); + // A reordering of the same reciprocal pair must not change the edge id or the topology key, + // otherwise CanvasPanel would rerun the whole force simulation on an order-only refresh. + expect(a.edges[0]?.id).toBe(b.edges[0]?.id); + expect(a.topologyKey).toBe(b.topologyKey); + }); + it('drives generated edge thickness from the edge-thickness thresholds', () => { const results = [ result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), diff --git a/canvas/src/utils/graphGeneration.ts b/canvas/src/utils/graphGeneration.ts index bf0d44f19..a9d8f86c5 100644 --- a/canvas/src/utils/graphGeneration.ts +++ b/canvas/src/utils/graphGeneration.ts @@ -140,8 +140,11 @@ export function buildGraphFromQueries( for (const pairKey of pairOrder) { const entry = pairs.get(pairKey)!; const isReciprocal = entry.forward !== undefined && entry.reverse !== undefined; - // Stable id from the entry's (first-seen) orientation. - const id = `${EDGE_ID_PREFIX}${JSON.stringify([entry.sourceValue, entry.targetValue])}`; + // Derive the id from the canonical (order-independent) pair key, not the entry's first-seen + // orientation. Otherwise reordering reciprocal A→B / B→A rows would flip the id and thus the + // topologyKey, forcing CanvasPanel to rerun the whole force simulation on an order-only + // refresh. Orientation is still carried by entry.source/target for the directional series. + const id = `${EDGE_ID_PREFIX}${pairKey}`; const forwardSeries = entry.forward ?? entry.reverse; if (forwardSeries) { seriesByItemId.set(id, forwardSeries); From 81fd3fbce2f76923975dc0a2d354f14673cb582f Mon Sep 17 00:00:00 2001 From: Markus Wieland Date: Fri, 30 Oct 2026 12:35:43 +0100 Subject: [PATCH 9/9] [REFACTOR] Split out edge colors and zoom control for separate PRs Keep this branch focused on automatic graph generation. Remove the edge color scheme (edgeThresholds), node-size scaling (nodeThresholdSizes, sizeMode scaling), and the view-mode zoom interaction change, which move to follow-up PRs stacked on top of this one. Generation plumbing is retained, including the no-op sizeMode:'threshold' on generated nodes and nodeDefaultSize. Signed-off-by: Markus Wieland --- canvas/README.md | 12 +- canvas/schemas/panels/canvas/canvas.cue | 7 - canvas/sdk/go/canvas.go | 31 +--- canvas/src/components/panel/CanvasPanel.tsx | 40 +---- .../src/components/panel/PanelEdgeLayer.tsx | 7 +- .../settings/EdgeThicknessSettings.tsx | 7 +- .../settings/GlobalSettingsEditor.tsx | 53 +----- .../components/settings/NodeSizeSettings.tsx | 157 ------------------ canvas/src/hooks/useZoom.ts | 37 +---- canvas/src/model.ts | 14 +- canvas/src/utils/graphGeneration.ts | 4 +- canvas/src/utils/panelUtils.test.ts | 34 +--- canvas/src/utils/panelUtils.ts | 24 +-- docs/canvas/examples/dashboard.json | 11 -- 14 files changed, 26 insertions(+), 412 deletions(-) delete mode 100644 canvas/src/components/settings/NodeSizeSettings.tsx diff --git a/canvas/README.md b/canvas/README.md index 2e3c58262..d5591e535 100644 --- a/canvas/README.md +++ b/canvas/README.md @@ -31,14 +31,10 @@ Configuration: - **Edge label template** — optional edge caption (e.g. `{{value}}`). - **Bidirectional edges** — render edges with arrows in both directions. -Node colors come from the **Node color** thresholds configured for the panel, and **Node size** -thresholds can scale a node's size by its value (relative to the configurable default node size). -Edges use the **Edge color** thresholds when set and otherwise fall back to the node thresholds, so -edges can carry their own color scheme independent of the nodes. Edge thickness is set per -breakpoint under **Edge thickness**, which shares the same thresholds as the edge color (the edge -thresholds when set, otherwise the node thresholds). Layout is deterministic: a given topology -always settles to the same positions, so nodes stay put as their metric values change — they only -move when nodes or edges are added or removed. +Node colors come from the **Node color** thresholds configured for the panel, and edge colors use +those same thresholds. Edge thickness is set per breakpoint under **Edge thickness**. Layout is +deterministic: a given topology always settles to the same positions, so nodes stay put as their +metric values change — they only move when nodes or edges are added or removed. ### How to install diff --git a/canvas/schemas/panels/canvas/canvas.cue b/canvas/schemas/panels/canvas/canvas.cue index 12ce29fc4..93f442279 100644 --- a/canvas/schemas/panels/canvas/canvas.cue +++ b/canvas/schemas/panels/canvas/canvas.cue @@ -21,12 +21,10 @@ kind: "Canvas" spec: close({ legend?: #legend thresholds?: common.#thresholds - edgeThresholds?: common.#thresholds format?: common.#format querySettings?: #querySettings edgeDefaultStrokeWidth?: number & >0 edgeThresholdWidths?: [...#edgeThresholdStep] - nodeThresholdSizes?: [...#nodeThresholdStep] nodeDefaultSize?: number & >0 backgrounds?: [...#background] nodes?: [...#node] @@ -118,8 +116,3 @@ spec: close({ value: number strokeWidth: number & >0 } - -#nodeThresholdStep: { - value: number - scale: number & >0 -} diff --git a/canvas/sdk/go/canvas.go b/canvas/sdk/go/canvas.go index 25f02f223..5e7504703 100644 --- a/canvas/sdk/go/canvas.go +++ b/canvas/sdk/go/canvas.go @@ -140,12 +140,6 @@ type EdgeThresholdStep struct { StrokeWidth float64 `json:"strokeWidth" yaml:"strokeWidth"` } -type NodeThresholdStep struct { - Value float64 `json:"value" yaml:"value"` - // Scale multiplies the node's base width/height. - Scale float64 `json:"scale" yaml:"scale"` -} - type QueryColorSettings struct { QueryIndex uint `json:"queryIndex" yaml:"queryIndex"` ColorMode ColorMode `json:"colorMode" yaml:"colorMode"` @@ -169,11 +163,9 @@ type GraphGenerationSpec struct { type PluginSpec struct { Thresholds *common.Thresholds `json:"thresholds,omitempty" yaml:"thresholds,omitempty"` - EdgeThresholds *common.Thresholds `json:"edgeThresholds,omitempty" yaml:"edgeThresholds,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"` - NodeThresholdSizes []NodeThresholdStep `json:"nodeThresholdSizes,omitempty" yaml:"nodeThresholdSizes,omitempty"` NodeDefaultSize *float64 `json:"nodeDefaultSize,omitempty" yaml:"nodeDefaultSize,omitempty"` QuerySettings []QueryColorSettings `json:"querySettings,omitempty" yaml:"querySettings,omitempty"` Backgrounds []BackgroundSpec `json:"backgrounds,omitempty" yaml:"backgrounds,omitempty"` @@ -231,27 +223,8 @@ func WithThresholds(thresholds common.Thresholds) Option { } } -// WithEdgeThresholds sets a color scheme applied to edges. When unset, edges fall back to the node -// thresholds configured with WithThresholds. -func WithEdgeThresholds(thresholds common.Thresholds) Option { - return func(builder *Builder) error { - builder.EdgeThresholds = &thresholds - return nil - } -} - -// WithNodeThresholdSizes scales nodes by their bound value. Each step's Scale multiplies the node's -// base width/height when the value reaches the step's Value. Only applies to nodes whose SizeMode is -// "threshold" (generated nodes set this automatically). -func WithNodeThresholdSizes(steps ...NodeThresholdStep) Option { - return func(builder *Builder) error { - builder.NodeThresholdSizes = steps - return nil - } -} - -// WithNodeDefaultSize sets the base width/height (px) for generated nodes, which the node-size -// thresholds scale. Defaults to 48 when unset. +// WithNodeDefaultSize sets the base width/height (px) for generated nodes. Defaults to 48 when +// unset. func WithNodeDefaultSize(size float64) Option { return func(builder *Builder) error { builder.NodeDefaultSize = &size diff --git a/canvas/src/components/panel/CanvasPanel.tsx b/canvas/src/components/panel/CanvasPanel.tsx index 3e9318e57..5bc20d64e 100644 --- a/canvas/src/components/panel/CanvasPanel.tsx +++ b/canvas/src/components/panel/CanvasPanel.tsx @@ -22,7 +22,6 @@ import { useZoom } from '../../hooks/useZoom'; import type { CanvasProps, CanvasSpec, Point } from '../../model'; import { buildGraphFromQueries } from '../../utils/graphGeneration'; import { layoutGraph } from '../../utils/graphLayout'; -import { scaleFromThresholds } from '../../utils/panelUtils'; import { nodeBoundingBox } from '../../utils/resizeUtils'; import { BackgroundLayer, GlobalBackgroundLayer } from '../shared/BackgroundLayer'; import { PanelEdgeLayer } from './PanelEdgeLayer'; @@ -53,37 +52,8 @@ function PanelSvg({ }: PanelSvgProps): ReactElement { const { transform, isPanning, fitView, resetPan } = useZoomContext(); - // Resolve node sizes up front so both layers share one geometry: nodes render at their scaled - // size and edges clip to that same scaled boundary. Scaling only widens/heightens a node, which - // keeps it centered on its position. - const sizedSpec = useMemo(() => { - const steps = spec.nodeThresholdSizes; - const specNodes = spec.nodes ?? []; - if (!steps?.length || !specNodes.some((n) => n.sizeMode === 'threshold')) { - return spec; - } - const nextNodes = specNodes.map((node) => { - if (node.sizeMode !== 'threshold') { - return node; - } - const series = - seriesByItemId?.get(node.id) ?? - (node.queryIndex !== undefined ? seriesByQueryIndex.get(node.queryIndex) : undefined); - const lastValue = series?.values[series.values.length - 1]?.[1]; - if (lastValue === null || lastValue === undefined) { - return node; - } - const scale = scaleFromThresholds(lastValue, steps); - if (scale === 1) { - return node; - } - return Object.assign({}, node, { width: node.width * scale, height: node.height * scale }); - }); - return { ...spec, nodes: nextNodes }; - }, [spec, seriesByItemId, seriesByQueryIndex]); - - const nodes = useMemo(() => sizedSpec.nodes ?? [], [sizedSpec.nodes]); - const backgrounds = useMemo(() => sizedSpec.backgrounds ?? [], [sizedSpec.backgrounds]); + const nodes = useMemo(() => spec.nodes ?? [], [spec.nodes]); + const backgrounds = useMemo(() => spec.backgrounds ?? [], [spec.backgrounds]); const width = contentDimensions?.width ?? 600; const height = contentDimensions?.height ?? 400; @@ -118,14 +88,14 @@ function PanelSvg({ ({ toCanvasPoint, transform, isPanning, fitView, resetPan }), diff --git a/canvas/src/components/panel/PanelEdgeLayer.tsx b/canvas/src/components/panel/PanelEdgeLayer.tsx index 15ba8578a..732a34639 100644 --- a/canvas/src/components/panel/PanelEdgeLayer.tsx +++ b/canvas/src/components/panel/PanelEdgeLayer.tsx @@ -43,11 +43,8 @@ function resolveEdgeStyle( return { stroke: 'currentColor', strokeWidth: defaultWidth }; } - // Edges use their own color scheme when configured, falling back to the node thresholds so that - // existing dashboards (which only set `thresholds`) keep their current edge colors. - const edgeThresholds = spec.edgeThresholds ?? spec.thresholds; - const stroke = edgeThresholds - ? colorFromThresholds(lastValue, edgeThresholds, paletteColors, fallbackColor) + const stroke = spec.thresholds + ? colorFromThresholds(lastValue, spec.thresholds, paletteColors, fallbackColor) : 'currentColor'; const strokeWidth = thicknessMode === 'threshold' && spec.edgeThresholdWidths?.length diff --git a/canvas/src/components/settings/EdgeThicknessSettings.tsx b/canvas/src/components/settings/EdgeThicknessSettings.tsx index 0d3bd01bb..27273092b 100644 --- a/canvas/src/components/settings/EdgeThicknessSettings.tsx +++ b/canvas/src/components/settings/EdgeThicknessSettings.tsx @@ -16,7 +16,7 @@ import type { StepOptions } from '@perses-dev/components'; import { formatValue } from '@perses-dev/components'; import { produce } from 'immer'; import type { ReactElement } from 'react'; -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import type { CanvasSpec } from '../../model'; @@ -75,10 +75,7 @@ function ThresholdWidthRow({ } export function EdgeThicknessSettings({ value, onChange }: EdgeThicknessSettingsProps): ReactElement { - // Width rows mirror the edge color thresholds, falling back to node thresholds just like edge - // color resolution does, so breakpoints match the ranges edges are actually colored by. React - // Compiler memoizes this derivation, so no manual useMemo is needed. - const thresholdSteps = (value.edgeThresholds ?? value.thresholds)?.steps ?? []; + const thresholdSteps = useMemo(() => value.thresholds?.steps ?? [], [value.thresholds]); const onDefaultStrokeWidthChange = useCallback( (event: React.ChangeEvent): void => { diff --git a/canvas/src/components/settings/GlobalSettingsEditor.tsx b/canvas/src/components/settings/GlobalSettingsEditor.tsx index c2a5138dc..bc255cc79 100644 --- a/canvas/src/components/settings/GlobalSettingsEditor.tsx +++ b/canvas/src/components/settings/GlobalSettingsEditor.tsx @@ -31,7 +31,6 @@ import { EditorItemsPanel } from '../editor/EditorItemsPanel'; import { EdgeThicknessSettings } from './EdgeThicknessSettings'; import { GraphGenerationSettings } from './GraphGenerationSettings'; import { LegendSettings } from './LegendSettings'; -import { NodeSizeSettings } from './NodeSizeSettings'; const DEFAULT_FORMAT: FormatOptions = { unit: 'decimal' }; const EDITOR_COLUMN_SX = { display: 'flex', flexDirection: 'column' as const, gap: 3 }; @@ -45,9 +44,6 @@ interface StylingSettingsProps { onChange: (value: CanvasSpec) => void; onFormatChange: (format: FormatOptions) => void; onThresholdsChange: (thresholds: ThresholdOptions | undefined) => void; - onEdgeThresholdsChange: (thresholds: ThresholdOptions | undefined) => void; - /** Whether the data-driven ("From data") mode is active. */ - isDataMode: boolean; } // The appearance controls shared by both the manual and data-driven tabs. Rendering the same block @@ -58,8 +54,6 @@ function StylingSettings({ onChange, onFormatChange, onThresholdsChange, - onEdgeThresholdsChange, - isDataMode, }: StylingSettingsProps): ReactElement { return ( @@ -75,19 +69,6 @@ function StylingSettings({ - {/* Node size scaling only applies to generated nodes (which opt into `sizeMode: 'threshold'`); - manually authored nodes have no size-mode control, so these inputs would have no effect in - Manual mode. Show them only in From data mode. */} - {isDataMode ? ( - - - - ) : null} - - - - - @@ -106,37 +87,7 @@ export function GlobalSettingsEditor({ value, onChange }: GlobalSettingsEditorPr const onThresholdsChange = useCallback( (thresholds: ThresholdOptions | undefined): void => { - // The per-threshold node-size multipliers are keyed by threshold value. When a color threshold - // is moved or removed, drop any multiplier whose value no longer matches a step so it cannot - // linger as an active-but-hidden scale in the panel while disappearing from the editor. - const nodeStepValues = new Set(thresholds?.steps?.map((step) => step.value)); - const nodeThresholdSizes = value.nodeThresholdSizes?.filter((size) => nodeStepValues.has(size.value)); - // Edge widths key off the effective edge thresholds, which fall back to these node thresholds - // when no edge thresholds are set — so reconcile them here too. - const edgeStepValues = new Set((value.edgeThresholds ?? thresholds)?.steps?.map((step) => step.value)); - const edgeThresholdWidths = value.edgeThresholdWidths?.filter((width) => edgeStepValues.has(width.value)); - onChange({ - ...value, - thresholds, - nodeThresholdSizes: nodeThresholdSizes?.length ? nodeThresholdSizes : undefined, - edgeThresholdWidths: edgeThresholdWidths?.length ? edgeThresholdWidths : undefined, - }); - }, - [value, onChange], - ); - - const onEdgeThresholdsChange = useCallback( - (edgeThresholds: ThresholdOptions | undefined): void => { - // Edge thickness breakpoints key off the effective edge thresholds (edge thresholds, or the - // node thresholds when edge thresholds are cleared). Drop any width whose value no longer - // matches a step so it cannot linger as an active-but-hidden width in the panel. - const edgeStepValues = new Set((edgeThresholds ?? value.thresholds)?.steps?.map((step) => step.value)); - const edgeThresholdWidths = value.edgeThresholdWidths?.filter((width) => edgeStepValues.has(width.value)); - onChange({ - ...value, - edgeThresholds, - edgeThresholdWidths: edgeThresholdWidths?.length ? edgeThresholdWidths : undefined, - }); + onChange({ ...value, thresholds }); }, [value, onChange], ); @@ -173,8 +124,6 @@ export function GlobalSettingsEditor({ value, onChange }: GlobalSettingsEditorPr onChange={onChange} onFormatChange={onFormatChange} onThresholdsChange={onThresholdsChange} - onEdgeThresholdsChange={onEdgeThresholdsChange} - isDataMode={mode === 'auto'} /> diff --git a/canvas/src/components/settings/NodeSizeSettings.tsx b/canvas/src/components/settings/NodeSizeSettings.tsx deleted file mode 100644 index 51264b348..000000000 --- a/canvas/src/components/settings/NodeSizeSettings.tsx +++ /dev/null @@ -1,157 +0,0 @@ -// 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 { Box, InputAdornment, TextField, Typography } from '@mui/material'; -import type { StepOptions } from '@perses-dev/components'; -import { formatValue } from '@perses-dev/components'; -import { produce } from 'immer'; -import type { ReactElement } from 'react'; -import React, { useCallback, useMemo } from 'react'; - -import type { CanvasSpec } from '../../model'; - -const SCALE_SLOT_PROPS = { - htmlInput: { min: 0.1, step: 0.1 }, - input: { endAdornment: × }, -} as const; - -const SIZE_SLOT_PROPS = { - htmlInput: { min: 1, step: 1 }, - input: { endAdornment: px }, -} as const; - -const ROW_BOX_SX = { display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 } as const; -const ROW_CAPTION_SX = { minWidth: 70, color: 'text.secondary' } as const; -const ROW_TEXT_WIDTH_SX = { width: 100 } as const; -const DEFAULT_SIZE_SX = { mb: 1, width: 180 } as const; -const BLOCK_CAPTION_SX = { display: 'block', mb: 0.5 } as const; - -interface NodeSizeSettingsProps { - value: CanvasSpec; - onChange: (value: CanvasSpec) => void; -} - -interface ThresholdScaleRowProps { - step: StepOptions; - scale: number | undefined; - format: CanvasSpec['format']; - onThresholdScaleChange: (stepValue: number, scale: number | undefined) => void; -} - -function ThresholdScaleRow({ step, scale, format, onThresholdScaleChange }: ThresholdScaleRowProps): ReactElement { - const onScaleChange = useCallback( - (event: React.ChangeEvent): void => { - const parsed = parseFloat(event.target.value); - onThresholdScaleChange(step.value, Number.isFinite(parsed) && parsed > 0 ? parsed : undefined); - }, - [step.value, onThresholdScaleChange], - ); - - const thresholdLabel = formatValue(step.value, format); - // MUI forwards accessibility attributes to the underlying input via the `htmlInput` slot, not the - // top-level props, so the screen-reader name must be set there alongside the numeric constraints. - const scaleSlotProps = useMemo( - () => ({ - ...SCALE_SLOT_PROPS, - htmlInput: { ...SCALE_SLOT_PROPS.htmlInput, 'aria-label': `Size multiplier for values ≥ ${thresholdLabel}` }, - }), - [thresholdLabel], - ); - - return ( - - - ≥ {thresholdLabel} - - - - ); -} - -export function NodeSizeSettings({ value, onChange }: NodeSizeSettingsProps): ReactElement { - const thresholdSteps = useMemo(() => value.thresholds?.steps ?? [], [value.thresholds]); - - const onDefaultSizeChange = useCallback( - (event: React.ChangeEvent): void => { - const parsed = parseFloat(event.target.value); - onChange({ - ...value, - nodeDefaultSize: Number.isFinite(parsed) && parsed > 0 ? parsed : undefined, - }); - }, - [value, onChange], - ); - - const onThresholdScaleChange = useCallback( - (stepValue: number, scale: number | undefined): void => { - onChange( - produce(value, (draft) => { - draft.nodeThresholdSizes = draft.nodeThresholdSizes ?? []; - const existingIdx = draft.nodeThresholdSizes.findIndex((s) => s.value === stepValue); - if (scale !== undefined) { - if (existingIdx >= 0) { - draft.nodeThresholdSizes[existingIdx]!.scale = scale; - } else { - draft.nodeThresholdSizes.push({ value: stepValue, scale }); - } - } else if (existingIdx >= 0) { - draft.nodeThresholdSizes.splice(existingIdx, 1); - } - }), - ); - }, - [value, onChange], - ); - - return ( - <> - - {thresholdSteps.length > 0 ? ( - - - Per-threshold size multipliers - - {thresholdSteps.map((step) => ( - s.value === step.value)?.scale} - format={value.format} - onThresholdScaleChange={onThresholdScaleChange} - /> - ))} - - ) : ( - - Add node color thresholds to scale nodes by value. - - )} - - ); -} diff --git a/canvas/src/hooks/useZoom.ts b/canvas/src/hooks/useZoom.ts index 9634057b3..5f863ad7b 100644 --- a/canvas/src/hooks/useZoom.ts +++ b/canvas/src/hooks/useZoom.ts @@ -21,24 +21,6 @@ import type { Point } from '../model'; const FIT_PADDING = 40; -/** - * Controls which gestures start a pan or zoom. - * - * - `edit` (default): the gestures the manual editor relies on. Pan with the - * middle mouse button only, and zoom with the wheel only while ctrl/meta is - * held. This leaves the left button free for the editor's node move, edge - * drag and rubber-band selection, and leaves a plain wheel free for page - * scrolling. - * - `view`: the read-only display panel, which has no editing gestures to - * protect. Pan with the left or middle mouse button and zoom with a plain - * wheel, matching the Grafana node graph. - */ -export type ZoomInteractionMode = 'edit' | 'view'; - -export interface UseZoomOptions { - interactionMode?: ZoomInteractionMode; -} - export interface UseZoomResult { svgRef: (node: SVGSVGElement | null) => void; transform: ZoomTransform; @@ -52,7 +34,7 @@ export interface UseZoomResult { resetPan: () => void; } -export function useZoom({ interactionMode = 'edit' }: UseZoomOptions = {}): UseZoomResult { +export function useZoom(): UseZoomResult { const [transform, setTransform] = useState(zoomIdentity); const [isPanning, setIsPanning] = useState(false); const nodeRef = useRef(null); @@ -65,25 +47,14 @@ export function useZoom({ interactionMode = 'edit' }: UseZoomOptions = {}): UseZ return; } nodeRef.current = node; - if (interactionMode === 'view') { - zoomBehavior.scaleExtent([0.05, 8]); - } zoomBehavior.filter((event: Event) => { if (event.type === 'dblclick') { return false; } if (event instanceof WheelEvent) { - // In view mode a plain wheel zooms; in edit mode it must be modified - // so that an unmodified wheel still scrolls the surrounding page. - return interactionMode === 'view' || event.ctrlKey || event.metaKey; + return event.ctrlKey || event.metaKey; } - if (!(event instanceof MouseEvent)) { - return false; - } - // Middle-click always pans. The left button only pans in view mode, - // where there are no editing gestures competing for it; d3-zoom still - // lets a stationary left click through to node click handlers. - return event.button === 1 || (interactionMode === 'view' && event.button === 0); + return event instanceof MouseEvent && event.button === 1; }); zoomBehavior.on('start', () => setIsPanning(true)); zoomBehavior.on('zoom', ({ transform: t }: { transform: ZoomTransform }) => { @@ -92,7 +63,7 @@ export function useZoom({ interactionMode = 'edit' }: UseZoomOptions = {}): UseZ zoomBehavior.on('end', () => setIsPanning(false)); select(node).call(zoomBehavior); }, - [zoomBehavior, interactionMode], + [zoomBehavior], ); const resetPan = useCallback(() => { diff --git a/canvas/src/model.ts b/canvas/src/model.ts index 138ca4613..633f6bf38 100644 --- a/canvas/src/model.ts +++ b/canvas/src/model.ts @@ -50,7 +50,7 @@ export interface NodeSpec { queryIndex?: number; colorMode?: 'threshold' | 'fixed'; color?: string; - /** When `threshold`, the node's base size is scaled by the `nodeThresholdSizes` steps. */ + /** Reserved for data-driven size scaling; generated nodes set this to `threshold`. */ sizeMode?: 'fixed' | 'threshold'; } @@ -100,12 +100,6 @@ export interface EdgeThresholdStep { strokeWidth: number; } -export interface NodeThresholdStep { - value: number; - /** Multiplier applied to the node's base width/height. */ - scale: number; -} - export type FloatingEdge = EdgeSpec & { freeEndpoint: Point }; export function isFloatingEdge(edge: EdgeSpec): edge is FloatingEdge { @@ -153,14 +147,10 @@ export interface GraphGenerationSpec { export interface CanvasSpec { legend?: LegendSpecOptions; thresholds?: ThresholdOptions; - /** Color scheme applied to edges. Falls back to `thresholds` when unset. */ - edgeThresholds?: ThresholdOptions; format?: FormatOptions; edgeThresholdWidths?: EdgeThresholdStep[]; edgeDefaultStrokeWidth?: number; - /** Threshold steps that scale node size by the node's bound value. */ - nodeThresholdSizes?: NodeThresholdStep[]; - /** Base width/height (px) for generated nodes, scaled by `nodeThresholdSizes`. */ + /** Base width/height (px) for generated nodes. */ nodeDefaultSize?: number; querySettings?: QueryColorSettings[]; backgrounds?: BackgroundSpec[]; diff --git a/canvas/src/utils/graphGeneration.ts b/canvas/src/utils/graphGeneration.ts index a9d8f86c5..43e786912 100644 --- a/canvas/src/utils/graphGeneration.ts +++ b/canvas/src/utils/graphGeneration.ts @@ -78,8 +78,8 @@ export function buildGraphFromQueries( label: config.nodeLabelTemplate ?? `{{${config.nodeIdLabel}}}`, labelPosition: 'below', colorMode: 'threshold', - // Drive size from the node-size thresholds so generated nodes grow/shrink with their value; - // with no nodeThresholdSizes configured this is a no-op (scale 1). + // Opt generated nodes into data-driven size scaling. This is a no-op today (nodes keep their + // base size) and becomes active once node-size thresholds are available. sizeMode: 'threshold', }); } diff --git a/canvas/src/utils/panelUtils.test.ts b/canvas/src/utils/panelUtils.test.ts index 2be6ff376..a1d40bf11 100644 --- a/canvas/src/utils/panelUtils.test.ts +++ b/canvas/src/utils/panelUtils.test.ts @@ -13,7 +13,7 @@ import type { TimeSeries } from '@perses-dev/spec'; -import { colorFromThresholds, interpolateLabel, isSafeImageUrl, scaleFromThresholds } from './panelUtils'; +import { colorFromThresholds, interpolateLabel, isSafeImageUrl } from './panelUtils'; function makeSeries(labels: Record, values: Array<[number, number | null]>): TimeSeries { return { labels, values } as unknown as TimeSeries; @@ -95,38 +95,6 @@ describe('colorFromThresholds', () => { }); }); -describe('scaleFromThresholds', () => { - const steps = [ - { value: 0.5, scale: 1.3 }, - { value: 0.8, scale: 1.7 }, - ]; - - it('returns 1 when there are no steps', () => { - expect(scaleFromThresholds(0.9, undefined)).toBe(1); - expect(scaleFromThresholds(0.9, [])).toBe(1); - }); - - it('returns 1 below the first step', () => { - expect(scaleFromThresholds(0.2, steps)).toBe(1); - }); - - it('returns the matching step scale and prefers the highest reached step', () => { - expect(scaleFromThresholds(0.5, steps)).toBe(1.3); - expect(scaleFromThresholds(0.79, steps)).toBe(1.3); - expect(scaleFromThresholds(0.8, steps)).toBe(1.7); - expect(scaleFromThresholds(0.95, steps)).toBe(1.7); - }); - - it('prefers the highest reached step regardless of array order', () => { - const unsorted = [ - { value: 0.8, scale: 1.7 }, - { value: 0.5, scale: 1.3 }, - ]; - expect(scaleFromThresholds(0.95, unsorted)).toBe(1.7); - expect(scaleFromThresholds(0.6, unsorted)).toBe(1.3); - }); -}); - describe('isSafeImageUrl', () => { it('allows https URLs', () => { expect(isSafeImageUrl('https://example.com/image.png')).toBe(true); diff --git a/canvas/src/utils/panelUtils.ts b/canvas/src/utils/panelUtils.ts index 21b00ec03..830cf00d7 100644 --- a/canvas/src/utils/panelUtils.ts +++ b/canvas/src/utils/panelUtils.ts @@ -15,7 +15,7 @@ import type { FormatOptions, ThresholdOptions } from '@perses-dev/components'; import { formatValue } from '@perses-dev/components'; import type { TimeSeries } from '@perses-dev/spec'; -import type { BackgroundSpec, NodeThresholdStep } from '../model'; +import type { BackgroundSpec } from '../model'; export function isSafeImageUrl(url: string): boolean { try { @@ -79,25 +79,3 @@ export function colorFromThresholds( } return result; } - -/** - * Returns the size multiplier for a node given its bound value and the node-size threshold steps. - * The highest step whose `value` the input reaches wins, and an empty/absent step list leaves the - * node at its base size (scale 1). The steps need not be sorted: `nodeThresholdSizes` is persisted in - * the order the user edited each row, so the match is chosen by the greatest reached `value` rather - * than by array position. - */ -export function scaleFromThresholds(value: number, steps: NodeThresholdStep[] | undefined): number { - if (!steps?.length) { - return 1; - } - let result = 1; - let bestValue = -Infinity; - for (const step of steps) { - if (value >= step.value && step.value >= bestValue) { - bestValue = step.value; - result = step.scale; - } - } - return result; -} diff --git a/docs/canvas/examples/dashboard.json b/docs/canvas/examples/dashboard.json index 08bbc82dd..38d0969c7 100644 --- a/docs/canvas/examples/dashboard.json +++ b/docs/canvas/examples/dashboard.json @@ -26,21 +26,10 @@ { "value": 0.8, "color": "#d32f2f" } ] }, - "edgeThresholds": { - "defaultColor": "#90caf9", - "steps": [ - { "value": 0.5, "color": "#5c6bc0" }, - { "value": 0.8, "color": "#6a1b9a" } - ] - }, "edgeThresholdWidths": [ { "value": 0.5, "strokeWidth": 4 }, { "value": 0.8, "strokeWidth": 8 } ], - "nodeThresholdSizes": [ - { "value": 0.5, "scale": 1.3 }, - { "value": 0.8, "scale": 1.7 } - ], "nodeDefaultSize": 44, "format": { "unit": "decimal" }, "legend": { "position": "bottom" },