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: {}, };