Skip to content
17 changes: 11 additions & 6 deletions canvas/src/components/editor/EditorCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type { CanvasSpec, FloatingEdge } from '../../model';
import { isFloatingEdge } from '../../model';
import type { ResizeHandleId } from '../../utils/resizeUtils';
import { nodeBoundingBox } from '../../utils/resizeUtils';
import { resolveMouseShortcut } from '../../utils/shortcuts';
import { BackgroundLayer, GlobalBackgroundLayer } from '../shared/BackgroundLayer';
import { DragEdgeLine } from './DragEdgeLine';
import { EditorEdgeItem } from './EditorEdgeItem';
Expand Down Expand Up @@ -167,13 +168,17 @@ export function EditorCanvas({

const onSvgDoubleClick = useCallback(
(event: MouseEvent<SVGSVGElement>): void => {
if (event.ctrlKey || event.metaKey) {
const boundingBox = nodeBoundingBox(displayNodes);
if (boundingBox) {
fitView(boundingBox, width, height);
switch (resolveMouseShortcut(event)?.id) {
case 'fitView': {
const boundingBox = nodeBoundingBox(displayNodes);
if (boundingBox) {
fitView(boundingBox, width, height);
}
return;
}
} else {
resetPan();
case 'resetView':
resetPan();
return;
}
},
[displayNodes, fitView, resetPan, width, height],
Expand Down
95 changes: 58 additions & 37 deletions canvas/src/components/panel/CanvasPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { Box } from '@mui/material';
import { useChartsTheme } from '@perses-dev/components';
import type { ThresholdOptions } from '@perses-dev/components';
import type { TimeSeries } from '@perses-dev/spec';
Expand All @@ -21,13 +22,20 @@ import { useZoomContext, ZoomProvider } from '../../contexts/ZoomContext';
import { useZoom } from '../../hooks/useZoom';
import type { CanvasProps } from '../../model';
import { nodeBoundingBox } from '../../utils/resizeUtils';
import { resolveMouseShortcut } from '../../utils/shortcuts';
import { BackgroundLayer, GlobalBackgroundLayer } from '../shared/BackgroundLayer';
import { CanvasShortcutsTooltip } from '../shared/CanvasShortcutsTooltip';
import { PanelEdgeLayer } from './PanelEdgeLayer';
import { PanelNodeLayer } from './PanelNodeLayer';
import { ThresholdLegend } from './ThresholdLegend';
import { LEGEND_WIDTH, ThresholdLegend } from './ThresholdLegend';

const SVG_STYLE_BASE = { display: 'block' } as const;
const EMPTY_THRESHOLDS: ThresholdOptions = {};
const PANEL_WRAPPER_SX = { position: 'relative' as const };

const LEGEND_MARGIN = 8;
const BTN_MARGIN = 4;
const BTN_HEIGHT = 32;

interface PanelSvgProps {
svgRef: (node: SVGSVGElement | null) => void;
Expand All @@ -48,13 +56,15 @@ function PanelSvg({ svgRef, props, seriesByQueryIndex, paletteColors }: PanelSvg

const handleDoubleClick = useCallback(
(event: MouseEvent<SVGSVGElement>): void => {
if (event.ctrlKey || event.metaKey) {
const boundingBox = nodeBoundingBox(nodes);
if (boundingBox) {
fitView(boundingBox, width, height);
switch (resolveMouseShortcut(event)?.id) {
case 'fitView': {
const boundingBox = nodeBoundingBox(nodes);
if (boundingBox) fitView(boundingBox, width, height);
return;
}
} else {
resetPan();
case 'resetView':
resetPan();
return;
}
},
[fitView, resetPan, nodes, width, height],
Expand All @@ -63,41 +73,52 @@ function PanelSvg({ svgRef, props, seriesByQueryIndex, paletteColors }: PanelSvg
const showLegend = spec.legend !== undefined && spec.thresholds !== undefined;
const thresholds = spec.thresholds ?? EMPTY_THRESHOLDS;
const legendPosition = spec.legend?.position ?? 'bottom';
const LEGEND_MARGIN = 8;
const legendX = legendPosition === 'right' ? width - 118 - LEGEND_MARGIN : LEGEND_MARGIN;
const legendX = legendPosition === 'right' ? width - LEGEND_WIDTH - LEGEND_MARGIN : LEGEND_MARGIN;
const legendY =
legendPosition === 'right' ? LEGEND_MARGIN : height - ((spec.thresholds?.steps?.length ?? 0) + 1) * 18 - 24;
legendPosition === 'right'
? BTN_MARGIN + BTN_HEIGHT + BTN_MARGIN
: height - ((spec.thresholds?.steps?.length ?? 0) + 1) * 18 - 24;

const shortcutsBtnSx = useMemo(
() => ({ position: 'absolute' as const, top: BTN_MARGIN, right: BTN_MARGIN, zIndex: 1 }),
[],
);

const svgStyle = useMemo(() => ({ ...SVG_STYLE_BASE, cursor: isPanning ? 'grabbing' : 'grab' }), [isPanning]);

return (
<svg ref={svgRef} width={width} height={height} style={svgStyle} onDoubleClick={handleDoubleClick}>
<GlobalBackgroundLayer backgrounds={backgrounds} width={width} height={height} />
<g transform={transform.toString()}>
<BackgroundLayer backgrounds={backgrounds} />
<PanelNodeLayer
spec={spec}
seriesByQueryIndex={seriesByQueryIndex}
k={transform.k}
paletteColors={paletteColors}
/>
<PanelEdgeLayer
spec={spec}
seriesByQueryIndex={seriesByQueryIndex}
k={transform.k}
paletteColors={paletteColors}
/>
</g>

{showLegend ? (
<ThresholdLegend
thresholds={thresholds}
format={spec.format}
paletteColors={paletteColors}
position={{ x: legendX, y: legendY }}
/>
) : null}
</svg>
<Box sx={PANEL_WRAPPER_SX}>
<svg ref={svgRef} width={width} height={height} style={svgStyle} onDoubleClick={handleDoubleClick}>
<GlobalBackgroundLayer backgrounds={backgrounds} width={width} height={height} />
<g transform={transform.toString()}>
<BackgroundLayer backgrounds={backgrounds} />
<PanelNodeLayer
spec={spec}
seriesByQueryIndex={seriesByQueryIndex}
k={transform.k}
paletteColors={paletteColors}
/>
<PanelEdgeLayer
spec={spec}
seriesByQueryIndex={seriesByQueryIndex}
k={transform.k}
paletteColors={paletteColors}
/>
</g>

{showLegend ? (
<ThresholdLegend
thresholds={thresholds}
format={spec.format}
paletteColors={paletteColors}
position={{ x: legendX, y: legendY }}
/>
) : null}
</svg>
<Box sx={shortcutsBtnSx}>
<CanvasShortcutsTooltip />
</Box>
</Box>
);
}

Expand Down
15 changes: 13 additions & 2 deletions canvas/src/components/panel/ThresholdLegend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,18 @@ const ROW_HEIGHT = 18;
const LABEL_OFFSET = SWATCH_SIZE + 6;
const PADDING = 8;
const FONT_SIZE = 11;
export const LEGEND_WIDTH = 110;
const TEXT_MAX_WIDTH = LEGEND_WIDTH - PADDING - LABEL_OFFSET - PADDING;
const NO_SELECT_STYLE = { userSelect: 'none' } as const;

// Approximate character width for font-size 11px sans-serif.
const CHAR_WIDTH_APPROX = 6.5;

function truncateLabel(label: string): string {
const maxChars = Math.floor(TEXT_MAX_WIDTH / CHAR_WIDTH_APPROX);
return label.length > maxChars ? `${label.slice(0, maxChars - 1)}…` : label;
}

interface ThresholdLegendProps {
thresholds: ThresholdOptions;
format: FormatOptions | undefined;
Expand All @@ -50,7 +60,7 @@ export function ThresholdLegend({ thresholds, format, paletteColors, position }:
[steps, paletteColors, defaultColor, format],
);

const boxWidth = 110;
const boxWidth = LEGEND_WIDTH;
const boxHeight = rows.length * ROW_HEIGHT + PADDING * 2;

return (
Expand Down Expand Up @@ -78,7 +88,8 @@ export function ThresholdLegend({ thresholds, format, paletteColors, position }:
fill={muiTheme.palette.text.primary}
style={NO_SELECT_STYLE}
>
{row.label}
<title>{row.label}</title>
{truncateLabel(row.label)}
</text>
</g>
);
Expand Down
29 changes: 27 additions & 2 deletions canvas/src/components/settings/GlobalSettingsEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,18 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { Box } from '@mui/material';
import { Box, IconButton } from '@mui/material';
import type { FormatOptions, ThresholdOptions } from '@perses-dev/components';
import {
FormatControls,
InfoTooltip,
OptionsEditorColumn,
OptionsEditorGrid,
OptionsEditorGroup,
ThresholdsEditor,
} from '@perses-dev/components';
import type { OptionsEditorProps } from '@perses-dev/plugin-system';
import InformationOutlineIcon from 'mdi-material-ui/InformationOutline';
import type { ReactElement } from 'react';
import { useCallback } from 'react';

Expand Down Expand Up @@ -64,7 +66,30 @@ export function GlobalSettingsEditor({ value, onChange }: GlobalSettingsEditorPr
</OptionsEditorColumn>
<OptionsEditorColumn>
<ThresholdsEditor hideDefault thresholds={value.thresholds} onChange={onThresholdsChange} />
<OptionsEditorGroup title="Edge thickness">
<OptionsEditorGroup
title="Edge thickness"
icon={
<InfoTooltip
id="edge-thickness-info-tooltip"
title="Edge thickness"
description="Defines stroke widths used when edges have thickness mode set to threshold. The default width applies when no threshold step matches the query value."
enterDelay={100}
>
<IconButton
aria-label="Edge thickness information"
aria-describedby="edge-thickness-info-tooltip"
size="small"
sx={(theme) => ({ borderRadius: theme.shape.borderRadius, padding: '4px', margin: '0 2px' })}
>
<InformationOutlineIcon
aria-hidden
fontSize="inherit"
sx={{ color: (theme) => theme.palette.grey[700] }}
/>
</IconButton>
</InfoTooltip>
}
>
<EdgeThicknessSettings value={value} onChange={onChange} />
</OptionsEditorGroup>
</OptionsEditorColumn>
Expand Down
92 changes: 92 additions & 0 deletions canvas/src/components/shared/CanvasShortcutsTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// 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, IconButton, Tooltip as MuiTooltip, tooltipClasses, Typography } from '@mui/material';
import type { TooltipProps as MuiTooltipProps } from '@mui/material';
import { styled } from '@mui/material/styles';
import InformationOutlineIcon from 'mdi-material-ui/InformationOutline';
import type { ReactElement } from 'react';

import type { MouseShortcutTooltip } from '../../utils/shortcuts';
import { SHORTCUTS } from '../../utils/shortcuts';

const Tooltip = styled(({ className, ...props }: MuiTooltipProps) => (
<MuiTooltip {...props} classes={{ popper: className }} />
))(({ theme }) => ({
[`& .${tooltipClasses.tooltip}`]: {
backgroundColor: theme.palette.background.tooltip,
color: theme.palette.text.primary,
maxWidth: '300px',
padding: theme.spacing(1),
boxShadow: theme.shadows[1],
},
[`& .${tooltipClasses.arrow}`]: {
color: theme.palette.background.tooltip,
},
}));

const styles = {
key: {
px: 0.75,
py: 0.25,
borderRadius: 0.5,
border: 1,
borderColor: 'divider',
backgroundColor: 'action.hover',
fontFamily: 'monospace',
fontSize: '0.75rem',
whiteSpace: 'nowrap',
},
row: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 2, py: 0.25 },
keys: { display: 'flex', gap: 0.5 },
title: { fontWeight: 'medium', mb: 0.5 },
iconButton: { opacity: 0.7, padding: '2px' },
} as const;

const SHORTCUT_ROWS: MouseShortcutTooltip[] = SHORTCUTS.map((s) => s.tooltip);

function ShortcutList({ rows }: { rows: MouseShortcutTooltip[] }): ReactElement {
return (
<>
{rows.map(({ keys, action }) => (
<Box key={action} sx={styles.row}>
<Typography variant="caption">{action}</Typography>
<Box sx={styles.keys}>
{keys.map((key) => (
<Typography key={key} sx={styles.key}>
{key}
</Typography>
))}
</Box>
</Box>
))}
</>
);
}

const TOOLTIP_TITLE = (
<Box sx={{ p: 0.5 }}>
<Typography sx={styles.title}>Canvas shortcuts</Typography>
<ShortcutList rows={SHORTCUT_ROWS} />
</Box>
);

export function CanvasShortcutsTooltip(): ReactElement {
return (
<Tooltip arrow placement="left" enterDelay={200} enterNextDelay={200} title={TOOLTIP_TITLE}>
<IconButton size="small" aria-label="Canvas keyboard shortcuts" sx={styles.iconButton}>
<InformationOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
);
}
15 changes: 9 additions & 6 deletions canvas/src/hooks/useZoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { PointerEvent } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';

import type { Point } from '../model';
import { resolveMouseShortcut } from '../utils/shortcuts';

const FIT_PADDING = 40;

Expand All @@ -44,19 +45,21 @@ export function useZoom(): UseZoomResult {
const svgRef = useCallback(
(node: SVGSVGElement | null): void => {
if (!node) {
nodeRef.current = null;
return;
}
nodeRef.current = node;
zoomBehavior.filter((event: Event) => {
if (event.type === 'dblclick') {
if (event instanceof MouseEvent) {
const shortcut = resolveMouseShortcut(event);
return shortcut?.id === 'zoom' || shortcut?.id === 'pan';
} else {
return false;
}
if (event instanceof WheelEvent) {
return event.ctrlKey || event.metaKey;
}
return event instanceof MouseEvent && event.button === 1;
});
zoomBehavior.on('start', () => setIsPanning(true));
zoomBehavior.on('start', ({ sourceEvent }: { sourceEvent: Event }) => {
setIsPanning(sourceEvent instanceof MouseEvent && resolveMouseShortcut(sourceEvent)?.id === 'pan');
});
zoomBehavior.on('zoom', ({ transform: t }: { transform: ZoomTransform }) => {
setTransform(t);
});
Expand Down
Loading
Loading