chore: connection lines for fun

This commit is contained in:
Alex Spencer
2025-12-01 18:32:23 -08:00
parent 0a3fc554ee
commit 91cb43d124
3 changed files with 248 additions and 9 deletions
@@ -0,0 +1,177 @@
import { css } from '@emotion/css';
import { useLayoutEffect, useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
interface Connection {
from: string;
to: string;
}
interface ConnectionLinesProps {
connections: Connection[];
}
export function ConnectionLines({ connections }: ConnectionLinesProps) {
const styles = useStyles2(getStyles);
const [positions, setPositions] = useState<Map<string, DOMRect>>(new Map());
const containerRef = useRef<SVGSVGElement>(null);
// Update positions when cards change
useLayoutEffect(() => {
const updatePositions = () => {
const newPositions = new Map<string, DOMRect>();
// Find all cards by their data-card-id attribute
const cards = document.querySelectorAll('[data-card-id]');
cards.forEach((element) => {
const cardId = element.getAttribute('data-card-id');
if (cardId) {
const rect = element.getBoundingClientRect();
newPositions.set(cardId, rect);
}
});
setPositions(newPositions);
};
// Delay to ensure cards are rendered
const timeoutId = setTimeout(updatePositions, 100);
// Update on resize and scroll
window.addEventListener('resize', updatePositions);
const container = containerRef.current?.parentElement;
if (container) {
// Watch for scroll events
const scrollContainer = container.querySelector('[data-scrollcontainer]');
scrollContainer?.addEventListener('scroll', updatePositions);
// Watch for DOM changes
const observer = new MutationObserver(() => {
setTimeout(updatePositions, 50);
});
observer.observe(container, { childList: true, subtree: true });
// Watch for container resize (splitter changes)
const resizeObserver = new ResizeObserver(updatePositions);
resizeObserver.observe(container);
return () => {
clearTimeout(timeoutId);
window.removeEventListener('resize', updatePositions);
scrollContainer?.removeEventListener('scroll', updatePositions);
observer.disconnect();
resizeObserver.disconnect();
};
}
return () => {
clearTimeout(timeoutId);
window.removeEventListener('resize', updatePositions);
};
}, [connections]);
const containerRect = containerRef.current?.parentElement?.getBoundingClientRect();
if (!containerRect || connections.length === 0) {
return <svg ref={containerRef} className={styles.svg} />;
}
// Group connections by expression (each "to" card gets its own lane)
const lanesByExpression = new Map<string, Set<string>>();
connections.forEach((conn) => {
if (!lanesByExpression.has(conn.to)) {
lanesByExpression.set(conn.to, new Set());
}
const lane = lanesByExpression.get(conn.to)!;
lane.add(conn.from); // Add the referenced query
lane.add(conn.to); // Add the expression itself
});
// Convert to array of lanes
const activeLanes = Array.from(lanesByExpression.values());
// Find the rightmost edge of all cards
let maxCardRight = 0;
positions.forEach((rect) => {
const cardRight = rect.right - containerRect.left;
if (cardRight > maxCardRight) {
maxCardRight = cardRight;
}
});
const laneSpacing = 16; // Spacing between lanes (must match QueryTransformList.tsx)
const baseOffset = 24; // Offset from rightmost card (must match QueryTransformList.tsx)
return (
<svg ref={containerRef} className={styles.svg}>
{activeLanes.map((lane, laneIndex) => {
// Calculate Y positions for cards in this lane
const laneYPositions: number[] = [];
lane.forEach((cardId) => {
const cardRect = positions.get(cardId);
if (cardRect) {
laneYPositions.push(cardRect.top + cardRect.height / 2 - containerRect.top);
}
});
if (laneYPositions.length === 0) {
return null;
}
// Calculate swimlane X position for this lane (start from rightmost card edge)
const swimlaneX = maxCardRight + baseOffset + laneIndex * laneSpacing;
// Find min/max Y to draw the line only between connected points
const minY = Math.min(...laneYPositions);
const maxY = Math.max(...laneYPositions);
return (
<g key={laneIndex}>
{/* Vertical swimlane line */}
<line x1={swimlaneX} y1={minY} x2={swimlaneX} y2={maxY} className={styles.swimlane} />
{/* Connection points for cards in this lane */}
{Array.from(lane).map((cardId) => {
const cardRect = positions.get(cardId);
if (!cardRect) {
return null;
}
const pointY = cardRect.top + cardRect.height / 2 - containerRect.top;
return <circle key={cardId} cx={swimlaneX} cy={pointY} r={4} className={styles.point} />;
})}
</g>
);
})}
</svg>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
svg: css({
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 10,
overflow: 'visible',
}),
swimlane: css({
stroke: theme.colors.text.maxContrast,
strokeWidth: 2,
opacity: 0.3,
}),
point: css({
fill: theme.colors.text.maxContrast,
opacity: 0.8,
}),
});
@@ -68,6 +68,7 @@ export const QueryTransformCard = memo(
onClick={onClick}
onKeyDown={handleKeyDown}
data-testid={`${type}-card-${index}`}
data-card-id={'refId' in item ? item.refId : `transform-${index}`}
>
{/* Header with type and action icons */}
<div
@@ -149,6 +150,7 @@ const getStyles = (theme: GrafanaTheme2) => {
return {
card: css({
position: 'relative',
cursor: 'pointer',
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
@@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { memo } from 'react';
import { memo, useMemo } from 'react';
import { DataTransformerConfig, GrafanaTheme2 } from '@grafana/data';
import { SceneDataQuery } from '@grafana/scenes';
@@ -7,6 +7,7 @@ import { ScrollContainer, Stack, useStyles2 } from '@grafana/ui';
import { ExpressionQueryType } from 'app/features/expressions/types';
import { AddDataItemMenu } from './AddDataItemMenu';
import { ConnectionLines } from './ConnectionLines';
import { QueryTransformCard } from './QueryTransformCard';
export interface QueryTransformItem {
@@ -50,6 +51,41 @@ export const QueryTransformList = memo(
}: QueryTransformListProps) => {
const styles = useStyles2(getStyles);
// Detect connections between items
const connections = useMemo(() => {
const conns: Array<{ from: string; to: string }> = [];
items.forEach((item) => {
if (item.type === 'expression' && 'expression' in item.data && 'refId' in item.data) {
const expr = item.data;
if ('expression' in expr && typeof expr.expression === 'string' && 'type' in expr) {
const expressionType = expr.type;
const expressionString = expr.expression;
if (expressionType === 'math') {
// Math expressions: parse $A, $B, etc.
const matches = expressionString.matchAll(/\$(\w+)/g);
for (const match of matches) {
const refId = match[1];
conns.push({ from: refId, to: expr.refId });
}
} else if (expressionType === 'reduce' || expressionType === 'resample' || expressionType === 'threshold') {
// Reduce/Resample/Threshold: expression field is a single refId
if (expressionString) {
conns.push({ from: expressionString, to: expr.refId });
}
}
// TODO: Handle 'sql' and 'classic_conditions' types if needed
}
}
});
console.log('Detected connections:', conns);
return conns;
}, [items]);
const getHandlers = (item: QueryTransformItem) => {
switch (item.type) {
case 'query':
@@ -75,10 +111,22 @@ export const QueryTransformList = memo(
}
};
// Calculate number of lanes for dynamic padding
const lanesByExpression = useMemo(() => {
const lanes = new Map<string, Set<string>>();
connections.forEach((conn) => {
if (!lanes.has(conn.to)) {
lanes.set(conn.to, new Set());
}
});
return lanes.size;
}, [connections]);
return (
<div className={styles.container}>
<div className={styles.scrollContainer}>
<ScrollContainer>
<ConnectionLines connections={connections} />
<ScrollContainer data-scrollcontainer>
<div className={lanesByExpression > 0 ? styles.contentWithConnections(lanesByExpression) : styles.content}>
<Stack direction="column" gap={2}>
{items.map((item) => (
<QueryTransformCard
@@ -98,8 +146,8 @@ export const QueryTransformList = memo(
onAddExpression={onAddExpression}
/>
</Stack>
</ScrollContainer>
</div>
</div>
</ScrollContainer>
</div>
);
}
@@ -108,19 +156,31 @@ export const QueryTransformList = memo(
QueryTransformList.displayName = 'QueryTransformList';
const getStyles = (theme: GrafanaTheme2) => {
const laneSpacing = 16; // Must match ConnectionLines.tsx
const baseOffset = 24; // Must match ConnectionLines.tsx
const extraPadding = 8; // Extra breathing room beyond the last lane
return {
container: css({
position: 'relative',
display: 'flex',
flexDirection: 'column',
height: '100%',
width: '100%',
overflow: 'hidden',
}),
scrollContainer: css({
content: css({
position: 'relative',
padding: theme.spacing(2),
flex: 1,
minHeight: 0,
overflow: 'auto',
zIndex: 1,
}),
contentWithConnections: (numLanes: number) =>
css({
position: 'relative',
padding: theme.spacing(2),
// Calculate exact space needed: base offset + (lanes * spacing) + extra padding
paddingRight: baseOffset + numLanes * laneSpacing + extraPadding,
zIndex: 1,
}),
};
};