Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions canvas/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions canvas/schemas/panels/canvas/canvas.cue
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -116,3 +118,8 @@ spec: close({
value: number
strokeWidth: number & >0
}

#nodeThresholdStep: {
value: number
scale: number & >0
}
31 changes: 29 additions & 2 deletions canvas/sdk/go/canvas.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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"`
Expand Down Expand Up @@ -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
Expand Down
67 changes: 60 additions & 7 deletions canvas/src/components/panel/CanvasPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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]);

Expand All @@ -88,14 +131,14 @@ function PanelSvg({
<g transform={transform.toString()}>
<BackgroundLayer backgrounds={backgrounds} />
<PanelNodeLayer
spec={spec}
spec={sizedSpec}
seriesByQueryIndex={seriesByQueryIndex}
k={transform.k}
paletteColors={paletteColors}
seriesByItemId={seriesByItemId}
/>
<PanelEdgeLayer
spec={spec}
spec={sizedSpec}
seriesByQueryIndex={seriesByQueryIndex}
k={transform.k}
paletteColors={paletteColors}
Expand All @@ -110,6 +153,16 @@ function PanelSvg({
format={spec.format}
paletteColors={paletteColors}
position={{ x: legendX, y: legendY }}
title={edgeThresholds ? 'Nodes' : undefined}
/>
) : null}
{showLegend && edgeThresholds ? (
<ThresholdLegend
thresholds={edgeThresholds}
format={spec.format}
paletteColors={paletteColors}
position={edgeLegendPos}
title="Edges"
/>
) : null}
</svg>
Expand Down
7 changes: 5 additions & 2 deletions canvas/src/components/panel/PanelEdgeLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 25 additions & 3 deletions canvas/src/components/panel/ThresholdLegend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,25 @@ 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 {
thresholds: ThresholdOptions;
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]);
Expand All @@ -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 (
<g>
Expand All @@ -66,8 +76,20 @@ export function ThresholdLegend({ thresholds, format, paletteColors, position }:
strokeWidth={1}
rx={4}
/>
{title ? (
<text
x={position.x + PADDING}
y={position.y + PADDING + FONT_SIZE}
fontSize={FONT_SIZE}
fontWeight={600}
fill={muiTheme.palette.text.primary}
style={NO_SELECT_STYLE}
>
{title}
</text>
) : 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 (
<g key={row.key}>
<rect x={position.x + PADDING} y={ry} width={SWATCH_SIZE} height={SWATCH_SIZE} fill={row.color} rx={2} />
Expand Down
7 changes: 5 additions & 2 deletions canvas/src/components/settings/EdgeThicknessSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<HTMLInputElement>): void => {
Expand Down
Loading
Loading