chore: connection lines for fun
This commit is contained in:
@@ -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,
|
||||
|
||||
+69
-9
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user