From c153d4360617512a271cb11547f87882fc4f7751 Mon Sep 17 00:00:00 2001 From: Markus Wieland Date: Fri, 30 Oct 2026 12:40:04 +0100 Subject: [PATCH] [FEATURE] Add edge color scheme and node-size scaling Reintroduce the edge color scheme (edgeThresholds, falling back to node thresholds) and value-driven node-size scaling (nodeThresholdSizes, sizeMode) across runtime, editor, schema, and SDK. Also surface edge colors in the legend: when edges use their own color scheme, the panel renders a separate titled "Edges" legend alongside the "Nodes" legend. Signed-off-by: Markus Wieland Co-authored-by: Philipp Matthes Signed-off-by: Philipp Matthes --- canvas/README.md | 13 +- canvas/schemas/panels/canvas/canvas.cue | 7 + canvas/sdk/go/canvas.go | 31 +++- canvas/src/components/panel/CanvasPanel.tsx | 67 +++++++- .../src/components/panel/PanelEdgeLayer.tsx | 7 +- .../src/components/panel/ThresholdLegend.tsx | 28 +++- .../settings/EdgeThicknessSettings.tsx | 7 +- .../settings/GlobalSettingsEditor.tsx | 60 ++++++- .../components/settings/NodeSizeSettings.tsx | 157 ++++++++++++++++++ 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, 436 insertions(+), 28 deletions(-) create mode 100644 canvas/src/components/settings/NodeSizeSettings.tsx diff --git a/canvas/README.md b/canvas/README.md index 022eb1314..44fca54f5 100644 --- a/canvas/README.md +++ b/canvas/README.md @@ -31,10 +31,15 @@ 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 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. +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. When edges use their own color +scheme, the legend shows a separate **Edges** key alongside the **Nodes** key. 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/schemas/panels/canvas/canvas.cue b/canvas/schemas/panels/canvas/canvas.cue index 93f442279..12ce29fc4 100644 --- a/canvas/schemas/panels/canvas/canvas.cue +++ b/canvas/schemas/panels/canvas/canvas.cue @@ -21,10 +21,12 @@ 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] @@ -116,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 5e7504703..25f02f223 100644 --- a/canvas/sdk/go/canvas.go +++ b/canvas/sdk/go/canvas.go @@ -140,6 +140,12 @@ 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"` @@ -163,9 +169,11 @@ 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"` @@ -223,8 +231,27 @@ func WithThresholds(thresholds common.Thresholds) Option { } } -// WithNodeDefaultSize sets the base width/height (px) for generated nodes. Defaults to 48 when -// unset. +// 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 diff --git a/canvas/src/components/panel/CanvasPanel.tsx b/canvas/src/components/panel/CanvasPanel.tsx index 5bc20d64e..0e4c07af0 100644 --- a/canvas/src/components/panel/CanvasPanel.tsx +++ b/canvas/src/components/panel/CanvasPanel.tsx @@ -22,6 +22,7 @@ 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'; @@ -52,8 +53,37 @@ function PanelSvg({ }: 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; @@ -74,11 +104,24 @@ function PanelSvg({ const showLegend = spec.legend !== undefined && spec.thresholds !== undefined; const thresholds = spec.thresholds ?? EMPTY_THRESHOLDS; + // Show a separate edge legend only when edges use their own color scheme; otherwise they share the + // node colors and a single legend already describes both. + const edgeThresholds = spec.edgeThresholds; const legendPosition = spec.legend?.position ?? 'bottom'; const LEGEND_MARGIN = 8; - const legendX = legendPosition === 'right' ? width - 118 - LEGEND_MARGIN : LEGEND_MARGIN; - const legendY = - legendPosition === 'right' ? LEGEND_MARGIN : height - ((spec.thresholds?.steps?.length ?? 0) + 1) * 18 - 24; + const LEGEND_WIDTH = 110; + const LEGEND_GAP = 8; + const nodeLegendRows = (spec.thresholds?.steps?.length ?? 0) + 1; + // Node and edge legends carry a title (16px) only when both are shown; a lone legend has none. + const titleHeight = edgeThresholds ? 16 : 0; + const nodeLegendHeight = nodeLegendRows * 18 + 16 + titleHeight; + const legendX = legendPosition === 'right' ? width - LEGEND_WIDTH - LEGEND_MARGIN : LEGEND_MARGIN; + const legendY = legendPosition === 'right' ? LEGEND_MARGIN : height - nodeLegendHeight - 16; + // Place the edge legend beside the node legend on the bottom, or stacked below it on the right. + const edgeLegendPos = + legendPosition === 'right' + ? { x: legendX, y: legendY + nodeLegendHeight + LEGEND_GAP } + : { x: legendX + LEGEND_WIDTH + LEGEND_GAP, y: legendY }; const svgStyle = useMemo(() => ({ ...SVG_STYLE_BASE, cursor: isPanning ? 'grabbing' : 'grab' }), [isPanning]); @@ -88,14 +131,14 @@ function PanelSvg({ + ) : null} + {showLegend && edgeThresholds ? ( + ) : null} diff --git a/canvas/src/components/panel/PanelEdgeLayer.tsx b/canvas/src/components/panel/PanelEdgeLayer.tsx index 732a34639..15ba8578a 100644 --- a/canvas/src/components/panel/PanelEdgeLayer.tsx +++ b/canvas/src/components/panel/PanelEdgeLayer.tsx @@ -43,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 diff --git a/canvas/src/components/panel/ThresholdLegend.tsx b/canvas/src/components/panel/ThresholdLegend.tsx index 5a675e2ed..a08172f27 100644 --- a/canvas/src/components/panel/ThresholdLegend.tsx +++ b/canvas/src/components/panel/ThresholdLegend.tsx @@ -24,6 +24,7 @@ const ROW_HEIGHT = 18; const LABEL_OFFSET = SWATCH_SIZE + 6; const PADDING = 8; const FONT_SIZE = 11; +const TITLE_HEIGHT = 16; const NO_SELECT_STYLE = { userSelect: 'none' } as const; interface ThresholdLegendProps { @@ -31,9 +32,17 @@ interface ThresholdLegendProps { format: FormatOptions | undefined; paletteColors: string[]; position: Point; + /** Optional heading shown above the swatches (e.g. "Nodes" / "Edges"). */ + title?: string; } -export function ThresholdLegend({ thresholds, format, paletteColors, position }: ThresholdLegendProps): ReactElement { +export function ThresholdLegend({ + thresholds, + format, + paletteColors, + position, + title, +}: ThresholdLegendProps): ReactElement { const muiTheme = useTheme(); const defaultColor = thresholds.defaultColor ?? paletteColors[0] ?? muiTheme.palette.success.main; const steps = useMemo(() => thresholds.steps ?? [], [thresholds.steps]); @@ -50,8 +59,9 @@ export function ThresholdLegend({ thresholds, format, paletteColors, position }: [steps, paletteColors, defaultColor, format], ); + const titleOffset = title ? TITLE_HEIGHT : 0; const boxWidth = 110; - const boxHeight = rows.length * ROW_HEIGHT + PADDING * 2; + const boxHeight = rows.length * ROW_HEIGHT + PADDING * 2 + titleOffset; return ( @@ -66,8 +76,20 @@ export function ThresholdLegend({ thresholds, format, paletteColors, position }: strokeWidth={1} rx={4} /> + {title ? ( + + {title} + + ) : null} {rows.map((row, i) => { - const ry = position.y + PADDING + i * ROW_HEIGHT + (ROW_HEIGHT - SWATCH_SIZE) / 2; + const ry = position.y + PADDING + titleOffset + i * ROW_HEIGHT + (ROW_HEIGHT - SWATCH_SIZE) / 2; return ( 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 a051a293b..b66884ef8 100644 --- a/canvas/src/components/settings/GlobalSettingsEditor.tsx +++ b/canvas/src/components/settings/GlobalSettingsEditor.tsx @@ -31,6 +31,7 @@ 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 }; @@ -43,12 +44,22 @@ 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 // 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 }: StylingSettingsProps): ReactElement { +function StylingSettings({ + value, + onChange, + onFormatChange, + onThresholdsChange, + onEdgeThresholdsChange, + isDataMode, +}: StylingSettingsProps): ReactElement { return ( @@ -63,6 +74,19 @@ function StylingSettings({ value, onChange, onFormatChange, onThresholdsChange } + {/* 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} + + + + + @@ -81,7 +105,37 @@ 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 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, + }); }, [value, onChange], ); @@ -125,6 +179,8 @@ 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 new file mode 100644 index 000000000..51264b348 --- /dev/null +++ b/canvas/src/components/settings/NodeSizeSettings.tsx @@ -0,0 +1,157 @@ +// 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/model.ts b/canvas/src/model.ts index 633f6bf38..138ca4613 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; - /** Reserved for data-driven size scaling; generated nodes set this to `threshold`. */ + /** When `threshold`, the node's base size is scaled by the `nodeThresholdSizes` steps. */ sizeMode?: 'fixed' | 'threshold'; } @@ -100,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 { @@ -147,10 +153,14 @@ 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; - /** Base width/height (px) for generated nodes. */ + /** 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[]; diff --git a/canvas/src/utils/graphGeneration.ts b/canvas/src/utils/graphGeneration.ts index 43e786912..a9d8f86c5 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', - // 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. + // 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', }); } 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/dashboard.json b/docs/canvas/examples/dashboard.json index 38d0969c7..08bbc82dd 100644 --- a/docs/canvas/examples/dashboard.json +++ b/docs/canvas/examples/dashboard.json @@ -26,10 +26,21 @@ { "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" },