diff --git a/public/app/features/explore-map/components/ExploreMapCanvas.tsx b/public/app/features/explore-map/components/ExploreMapCanvas.tsx
index 4927c8dd691..91dc14ed17b 100644
--- a/public/app/features/explore-map/components/ExploreMapCanvas.tsx
+++ b/public/app/features/explore-map/components/ExploreMapCanvas.tsx
@@ -7,9 +7,11 @@ import { useStyles2 } from '@grafana/ui';
import { useDispatch, useSelector } from 'app/types/store';
import { useTransformContext } from '../context/TransformContext';
+import { useMockCursors } from '../hooks/useMockCursors';
import { selectPanel, updateViewport } from '../state/exploreMapSlice';
import { ExploreMapPanelContainer } from './ExploreMapPanelContainer';
+import { UserCursor } from './UserCursor';
export function ExploreMapCanvas() {
const styles = useStyles2(getStyles);
@@ -19,6 +21,10 @@ export function ExploreMapCanvas() {
const panels = useSelector((state) => state.exploreMap.panels);
const viewport = useSelector((state) => state.exploreMap.viewport);
+ const cursors = useSelector((state) => state.exploreMap.cursors);
+
+ // Initialize mock cursors
+ useMockCursors();
const handleCanvasClick = useCallback(
(e: React.MouseEvent) => {
@@ -67,6 +73,9 @@ export function ExploreMapCanvas() {
{Object.values(panels).map((panel) => (
))}
+ {Object.values(cursors).map((cursor) => (
+
+ ))}
diff --git a/public/app/features/explore-map/components/UserCursor.tsx b/public/app/features/explore-map/components/UserCursor.tsx
new file mode 100644
index 00000000000..bf8238bd052
--- /dev/null
+++ b/public/app/features/explore-map/components/UserCursor.tsx
@@ -0,0 +1,72 @@
+import { css } from '@emotion/css';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { useStyles2 } from '@grafana/ui';
+
+import { UserCursor as UserCursorType } from '../state/types';
+
+interface UserCursorProps {
+ cursor: UserCursorType;
+}
+
+export function UserCursor({ cursor }: UserCursorProps) {
+ const styles = useStyles2(getStyles);
+
+ return (
+
+
+
+ {cursor.userName}
+
+
+ );
+}
+
+const getStyles = (theme: GrafanaTheme2) => {
+ return {
+ cursorContainer: css({
+ position: 'absolute',
+ pointerEvents: 'none',
+ zIndex: 10000,
+ transition: 'left 0.15s ease-out, top 0.15s ease-out',
+ }),
+ cursorSvg: css({
+ display: 'block',
+ filter: 'drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3))',
+ }),
+ label: css({
+ position: 'absolute',
+ left: '20px',
+ top: '20px',
+ padding: '4px 8px',
+ borderRadius: theme.shape.radius.default,
+ color: 'white',
+ fontSize: theme.typography.bodySmall.fontSize,
+ fontWeight: theme.typography.fontWeightMedium,
+ whiteSpace: 'nowrap',
+ boxShadow: theme.shadows.z2,
+ }),
+ };
+};
diff --git a/public/app/features/explore-map/hooks/useMockCursors.ts b/public/app/features/explore-map/hooks/useMockCursors.ts
new file mode 100644
index 00000000000..9569e261dd0
--- /dev/null
+++ b/public/app/features/explore-map/hooks/useMockCursors.ts
@@ -0,0 +1,134 @@
+import { useEffect, useRef } from 'react';
+
+import { useDispatch } from 'app/types/store';
+
+import { updateCursor } from '../state/exploreMapSlice';
+
+interface MockUser {
+ userId: string;
+ userName: string;
+ color: string;
+ targetX: number;
+ targetY: number;
+ currentX: number;
+ currentY: number;
+ speed: number;
+ pauseUntil: number;
+}
+
+const MOCK_USERS: Array> = [
+ { userId: 'mock-1', userName: 'Christian', color: '#FF6B6B', speed: 8 },
+ { userId: 'mock-2', userName: 'Ryan', color: '#4ECDC4', speed: 6 },
+ { userId: 'mock-3', userName: 'Marc', color: '#45B7D1', speed: 10 },
+];
+
+// Constrain cursors to upper left area of canvas
+const MOVEMENT_AREA = {
+ minX: 100,
+ maxX: 1000,
+ minY: 100,
+ maxY: 800,
+};
+const UPDATE_INTERVAL = 50; // ms
+
+function getRandomPosition() {
+ return {
+ x: Math.random() * (MOVEMENT_AREA.maxX - MOVEMENT_AREA.minX) + MOVEMENT_AREA.minX,
+ y: Math.random() * (MOVEMENT_AREA.maxY - MOVEMENT_AREA.minY) + MOVEMENT_AREA.minY,
+ };
+}
+
+function getRandomPauseDuration() {
+ return Math.random() * 1500 + 300; // 300-1800ms pause
+}
+
+export function useMockCursors() {
+ const dispatch = useDispatch();
+ const mockUsersRef = useRef([]);
+ const animationFrameRef = useRef();
+ const lastUpdateRef = useRef(0);
+
+ useEffect(() => {
+ // Initialize mock users
+ mockUsersRef.current = MOCK_USERS.map((user) => {
+ const startPos = getRandomPosition();
+ const targetPos = getRandomPosition();
+ return {
+ ...user,
+ currentX: startPos.x,
+ currentY: startPos.y,
+ targetX: targetPos.x,
+ targetY: targetPos.y,
+ pauseUntil: 0,
+ };
+ });
+
+ const animate = (timestamp: number) => {
+ const deltaTime = timestamp - lastUpdateRef.current;
+
+ if (deltaTime >= UPDATE_INTERVAL) {
+ lastUpdateRef.current = timestamp;
+
+ mockUsersRef.current = mockUsersRef.current.map((user) => {
+ const now = Date.now();
+
+ // If paused, skip movement
+ if (now < user.pauseUntil) {
+ return user;
+ }
+
+ const dx = user.targetX - user.currentX;
+ const dy = user.targetY - user.currentY;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+
+ // If close to target, set new target and maybe pause
+ if (distance < 10) {
+ const newTarget = getRandomPosition();
+ const shouldPause = Math.random() > 0.5; // 50% chance to pause
+
+ return {
+ ...user,
+ targetX: newTarget.x,
+ targetY: newTarget.y,
+ pauseUntil: shouldPause ? now + getRandomPauseDuration() : 0,
+ };
+ }
+
+ // Move towards target
+ const moveDistance = user.speed;
+ const ratio = moveDistance / distance;
+ const newX = user.currentX + dx * ratio;
+ const newY = user.currentY + dy * ratio;
+
+ // Update cursor in Redux
+ dispatch(
+ updateCursor({
+ userId: user.userId,
+ userName: user.userName,
+ color: user.color,
+ x: newX,
+ y: newY,
+ lastUpdated: now,
+ })
+ );
+
+ return {
+ ...user,
+ currentX: newX,
+ currentY: newY,
+ };
+ });
+ }
+
+ animationFrameRef.current = requestAnimationFrame(animate);
+ };
+
+ animationFrameRef.current = requestAnimationFrame(animate);
+
+ return () => {
+ if (animationFrameRef.current) {
+ cancelAnimationFrame(animationFrameRef.current);
+ }
+ };
+ }, [dispatch]);
+}
diff --git a/public/app/features/explore-map/state/exploreMapSlice.ts b/public/app/features/explore-map/state/exploreMapSlice.ts
index b1398a7e610..52619213d08 100644
--- a/public/app/features/explore-map/state/exploreMapSlice.ts
+++ b/public/app/features/explore-map/state/exploreMapSlice.ts
@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
import { generateExploreId } from 'app/core/utils/explore';
-import { CanvasViewport, ExploreMapState, initialExploreMapState, PanelPosition } from './types';
+import { CanvasViewport, ExploreMapState, initialExploreMapState, PanelPosition, UserCursor } from './types';
const exploreMapSlice = createSlice({
name: 'exploreMap',
@@ -99,6 +99,14 @@ const exploreMapSlice = createSlice({
loadCanvas: (state, action: PayloadAction) => {
return action.payload;
},
+
+ updateCursor: (state, action: PayloadAction) => {
+ state.cursors[action.payload.userId] = action.payload;
+ },
+
+ removeCursor: (state, action: PayloadAction<{ userId: string }>) => {
+ delete state.cursors[action.payload.userId];
+ },
},
});
@@ -112,6 +120,8 @@ export const {
resetCanvas,
duplicatePanel,
loadCanvas,
+ updateCursor,
+ removeCursor,
} = exploreMapSlice.actions;
export const exploreMapReducer = exploreMapSlice.reducer;
diff --git a/public/app/features/explore-map/state/types.ts b/public/app/features/explore-map/state/types.ts
index e468eb8f3f4..d677ec1ce00 100644
--- a/public/app/features/explore-map/state/types.ts
+++ b/public/app/features/explore-map/state/types.ts
@@ -18,11 +18,21 @@ export interface CanvasViewport {
panY: number;
}
+export interface UserCursor {
+ userId: string;
+ userName: string;
+ color: string;
+ x: number;
+ y: number;
+ lastUpdated: number;
+}
+
export interface ExploreMapState {
viewport: CanvasViewport;
panels: Record;
selectedPanelId?: string;
nextZIndex: number;
+ cursors: Record;
}
export const initialExploreMapState: ExploreMapState = {
@@ -34,4 +44,5 @@ export const initialExploreMapState: ExploreMapState = {
panels: {},
selectedPanelId: undefined,
nextZIndex: 1,
+ cursors: {},
};