diff --git a/public/app/features/explore-map/components/EdgeCursorIndicator.tsx b/public/app/features/explore-map/components/EdgeCursorIndicator.tsx
new file mode 100644
index 00000000000..210a55ad18b
--- /dev/null
+++ b/public/app/features/explore-map/components/EdgeCursorIndicator.tsx
@@ -0,0 +1,128 @@
+/**
+ * Component to display cursor indicators at the edge of the viewport
+ * for cursors that are currently off-screen
+ */
+
+import { css, keyframes } from '@emotion/css';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { useStyles2 } from '@grafana/ui';
+
+import { CursorViewportInfo } from '../hooks/useCursorViewportTracking';
+
+interface EdgeCursorIndicatorProps {
+ cursorInfo: CursorViewportInfo;
+}
+
+export function EdgeCursorIndicator({ cursorInfo }: EdgeCursorIndicatorProps) {
+ const styles = useStyles2(getStyles);
+
+ if (cursorInfo.isVisible || !cursorInfo.edgePosition) {
+ return null;
+ }
+
+ const { cursor, edgePosition } = cursorInfo;
+ const { x, y, side } = edgePosition;
+
+ // Calculate rotation based on side
+ let rotation = 0;
+ switch (side) {
+ case 'top':
+ rotation = -90;
+ break;
+ case 'bottom':
+ rotation = 90;
+ break;
+ case 'left':
+ rotation = 180;
+ break;
+ case 'right':
+ rotation = 0;
+ break;
+ }
+
+ return (
+
+
+
+ {cursor.userName}
+
+
+ );
+}
+
+const pulseAnimation = keyframes`
+ 0%, 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+ 50% {
+ transform: scale(1.1);
+ opacity: 0.8;
+ }
+`;
+
+const fadeInAnimation = keyframes`
+ from {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0.8);
+ }
+ to {
+ opacity: 1;
+ transform: translate(-50%, -50%) scale(1);
+ }
+`;
+
+const getStyles = (theme: GrafanaTheme2) => {
+ return {
+ container: css({
+ position: 'absolute',
+ pointerEvents: 'none',
+ zIndex: 10001, // Above regular cursors
+ transform: 'translate(-50%, -50%)',
+ transition: 'left 0.2s ease-out, top 0.2s ease-out, opacity 0.2s ease-out',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ gap: '4px',
+ animation: `${fadeInAnimation} 0.2s ease-out`,
+ }),
+ indicator: css({
+ width: '32px',
+ height: '32px',
+ borderRadius: '50%',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ boxShadow: theme.shadows.z3,
+ border: `2px solid white`,
+ animation: `${pulseAnimation} 2s ease-in-out infinite`,
+ }),
+ label: css({
+ padding: '2px 6px',
+ borderRadius: theme.shape.radius.default,
+ color: 'white',
+ fontSize: '11px',
+ fontWeight: theme.typography.fontWeightMedium,
+ whiteSpace: 'nowrap',
+ boxShadow: theme.shadows.z2,
+ }),
+ };
+};
diff --git a/public/app/features/explore-map/components/ExploreMapCanvas.tsx b/public/app/features/explore-map/components/ExploreMapCanvas.tsx
index 1b2e6677467..21a16e951b2 100644
--- a/public/app/features/explore-map/components/ExploreMapCanvas.tsx
+++ b/public/app/features/explore-map/components/ExploreMapCanvas.tsx
@@ -8,9 +8,11 @@ import { useDispatch, useSelector } from 'app/types/store';
import { useTransformContext } from '../context/TransformContext';
import { useCursorSync } from '../hooks/useCursorSync';
+import { useCursorViewportTracking } from '../hooks/useCursorViewportTracking';
import { selectPanel as selectPanelCRDT, updateViewport as updateViewportCRDT, selectMultiplePanels as selectMultiplePanelsCRDT } from '../state/crdtSlice';
import { selectPanels, selectViewport, selectCursors, selectSelectedPanelIds, selectMapUid } from '../state/selectors';
+import { EdgeCursorIndicator } from './EdgeCursorIndicator';
import { ExploreMapComment } from './ExploreMapComment';
import { ExploreMapPanelContainer } from './ExploreMapPanelContainer';
import { UserCursor } from './UserCursor';
@@ -26,10 +28,12 @@ export function ExploreMapCanvas() {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const canvasRef = useRef(null);
+ const containerRef = useRef(null);
const { transformRef: contextTransformRef } = useTransformContext();
const [selectionRect, setSelectionRect] = useState(null);
const [isSelecting, setIsSelecting] = useState(false);
const justCompletedSelectionRef = useRef(false);
+ const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const panels = useSelector((state) => selectPanels(state.exploreMapCRDT));
const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT));
@@ -43,6 +47,40 @@ export function ExploreMapCanvas() {
enabled: !!mapUid,
});
+ // Track cursor viewport positions for edge indicators
+ const cursorViewportInfo = useCursorViewportTracking({
+ cursors,
+ viewport,
+ transformRef: contextTransformRef,
+ containerWidth: containerSize.width,
+ containerHeight: containerSize.height,
+ });
+
+ // Track container size for viewport calculations
+ useEffect(() => {
+ if (!containerRef.current) {
+ return;
+ }
+
+ const updateSize = () => {
+ if (containerRef.current) {
+ const rect = containerRef.current.getBoundingClientRect();
+ setContainerSize({ width: rect.width, height: rect.height });
+ }
+ };
+
+ // Initial size
+ updateSize();
+
+ // Update on resize
+ const resizeObserver = new ResizeObserver(updateSize);
+ resizeObserver.observe(containerRef.current);
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ }, []);
+
// Send selection update immediately when selection changes
// Use position (0, 0) since we only care about the selection, not cursor position
useEffect(() => {
@@ -248,7 +286,7 @@ export function ExploreMapCanvas() {
);
return (
-
+
{
return ;
})}
- {Object.values(cursors).map((cursor) => (
-
- ))}
+ {cursorViewportInfo
+ .filter((info) => info.isVisible)
+ .map((info) => (
+
+ ))}
{selectionRect && (
+ {/* Render edge indicators for off-screen cursors */}
+ {cursorViewportInfo
+ .filter((info) => !info.isVisible)
+ .map((info) => (
+
+ ))}
);
diff --git a/public/app/features/explore-map/components/UserCursor.tsx b/public/app/features/explore-map/components/UserCursor.tsx
index c42877aca0e..67260105589 100644
--- a/public/app/features/explore-map/components/UserCursor.tsx
+++ b/public/app/features/explore-map/components/UserCursor.tsx
@@ -56,9 +56,20 @@ const getStyles = (theme: GrafanaTheme2) => {
// Smooth transition matching the update frequency (100ms)
// Using linear for more predictive movement
// Also transition transform for smooth scaling when zoom changes
- transition: 'left 0.1s linear, top 0.1s linear, transform 0.2s ease-out',
+ // Add opacity transition for smooth fade in/out when entering/leaving view
+ transition: 'left 0.1s linear, top 0.1s linear, transform 0.2s ease-out, opacity 0.2s ease-out',
// Will-change hint for better performance
- willChange: 'left, top, transform',
+ willChange: 'left, top, transform, opacity',
+ // Fade in animation
+ animation: 'fadeIn 0.2s ease-out',
+ '@keyframes fadeIn': {
+ from: {
+ opacity: 0,
+ },
+ to: {
+ opacity: 1,
+ },
+ },
}),
cursorSvg: css({
display: 'block',
diff --git a/public/app/features/explore-map/hooks/useCursorViewportTracking.ts b/public/app/features/explore-map/hooks/useCursorViewportTracking.ts
new file mode 100644
index 00000000000..dd6583b480c
--- /dev/null
+++ b/public/app/features/explore-map/hooks/useCursorViewportTracking.ts
@@ -0,0 +1,177 @@
+/**
+ * Hook to track cursor positions relative to the viewport
+ *
+ * Determines if cursors are visible in the current viewport and calculates
+ * edge positions for off-screen cursors to show indicators at the viewport edge.
+ */
+
+import { useMemo } from 'react';
+import { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch';
+
+import { UserCursor } from '../state/types';
+import { CanvasViewport } from '../state/types';
+
+export interface CursorViewportInfo {
+ cursor: UserCursor;
+ isVisible: boolean;
+ edgePosition?: {
+ x: number; // Screen pixel position on the edge
+ y: number; // Screen pixel position on the edge
+ side: 'top' | 'bottom' | 'left' | 'right'; // Which edge
+ distance: number; // Distance from viewport in canvas units
+ };
+}
+
+interface UseCursorViewportTrackingOptions {
+ cursors: Record;
+ viewport: CanvasViewport;
+ transformRef: React.RefObject | null;
+ containerWidth: number;
+ containerHeight: number;
+}
+
+const CANVAS_SIZE = 10000;
+const EDGE_INDICATOR_MARGIN = 20; // Pixels from edge
+
+/**
+ * Calculate visible bounds of the viewport in canvas coordinates
+ */
+function getVisibleBounds(
+ viewport: CanvasViewport,
+ transformRef: React.RefObject | null,
+ containerWidth: number,
+ containerHeight: number
+) {
+ // Get current transform state
+ const scale = transformRef?.current?.state?.scale ?? viewport.zoom;
+ const panX = transformRef?.current?.state?.positionX ?? viewport.panX;
+ const panY = transformRef?.current?.state?.positionY ?? viewport.panY;
+
+ // The pan values are in screen pixels and represent how much the canvas is shifted
+ // Negative panX means the canvas is shifted left (showing more right content)
+ // To convert to canvas coordinates, we need to:
+ // 1. Negate the pan to get the offset
+ // 2. Divide by scale to get canvas units
+
+ const visibleLeft = -panX / scale;
+ const visibleTop = -panY / scale;
+ const visibleRight = visibleLeft + containerWidth / scale;
+ const visibleBottom = visibleTop + containerHeight / scale;
+
+ return {
+ left: Math.max(0, visibleLeft),
+ top: Math.max(0, visibleTop),
+ right: Math.min(CANVAS_SIZE, visibleRight),
+ bottom: Math.min(CANVAS_SIZE, visibleBottom),
+ scale,
+ };
+}
+
+/**
+ * Calculate edge position for a cursor outside the viewport
+ */
+function calculateEdgePosition(
+ cursor: UserCursor,
+ bounds: ReturnType,
+ containerWidth: number,
+ containerHeight: number
+): CursorViewportInfo['edgePosition'] {
+ const { left, top, right, bottom, scale } = bounds;
+ const { x: cursorX, y: cursorY } = cursor;
+
+ // Determine which edge(s) the cursor is beyond
+ const isAbove = cursorY < top;
+ const isBelow = cursorY > bottom;
+ const isLeft = cursorX < left;
+ const isRight = cursorX > right;
+
+ // Calculate clamped position at viewport edges
+ const clampedX = Math.max(left, Math.min(right, cursorX));
+ const clampedY = Math.max(top, Math.min(bottom, cursorY));
+
+ // Convert clamped canvas position to screen coordinates
+ let screenX = (clampedX - left) * scale;
+ let screenY = (clampedY - top) * scale;
+
+ // Determine primary side and adjust to edge with margin
+ let side: 'top' | 'bottom' | 'left' | 'right';
+ let distance: number;
+
+ // Priority: vertical edges over horizontal if both apply
+ if (isLeft || isRight) {
+ if (isLeft) {
+ side = 'left';
+ screenX = EDGE_INDICATOR_MARGIN;
+ distance = left - cursorX;
+ } else {
+ side = 'right';
+ screenX = containerWidth - EDGE_INDICATOR_MARGIN;
+ distance = cursorX - right;
+ }
+ } else if (isAbove || isBelow) {
+ if (isAbove) {
+ side = 'top';
+ screenY = EDGE_INDICATOR_MARGIN;
+ distance = top - cursorY;
+ } else {
+ side = 'bottom';
+ screenY = containerHeight - EDGE_INDICATOR_MARGIN;
+ distance = cursorY - bottom;
+ }
+ } else {
+ // Should not happen, but handle gracefully
+ side = 'top';
+ distance = 0;
+ }
+
+ // Clamp screen positions to container bounds with margin
+ screenX = Math.max(EDGE_INDICATOR_MARGIN, Math.min(containerWidth - EDGE_INDICATOR_MARGIN, screenX));
+ screenY = Math.max(EDGE_INDICATOR_MARGIN, Math.min(containerHeight - EDGE_INDICATOR_MARGIN, screenY));
+
+ return {
+ x: screenX,
+ y: screenY,
+ side,
+ distance,
+ };
+}
+
+/**
+ * Track cursor positions relative to viewport
+ */
+export function useCursorViewportTracking({
+ cursors,
+ viewport,
+ transformRef,
+ containerWidth,
+ containerHeight,
+}: UseCursorViewportTrackingOptions): CursorViewportInfo[] {
+ return useMemo(() => {
+ if (containerWidth === 0 || containerHeight === 0) {
+ return [];
+ }
+
+ const bounds = getVisibleBounds(viewport, transformRef, containerWidth, containerHeight);
+
+ return Object.values(cursors).map((cursor) => {
+ const { x, y } = cursor;
+ const { left, top, right, bottom } = bounds;
+
+ // Check if cursor is within visible bounds
+ const isVisible = x >= left && x <= right && y >= top && y <= bottom;
+
+ if (isVisible) {
+ return { cursor, isVisible: true };
+ }
+
+ // Calculate edge position for off-screen cursor
+ const edgePosition = calculateEdgePosition(cursor, bounds, containerWidth, containerHeight);
+
+ return {
+ cursor,
+ isVisible: false,
+ edgePosition,
+ };
+ });
+ }, [cursors, viewport, transformRef, containerWidth, containerHeight]);
+}