diff --git a/public/app/features/explore-map/components/ExploreMapCanvas.tsx b/public/app/features/explore-map/components/ExploreMapCanvas.tsx index 88f752f736d..1660a8ca0cf 100644 --- a/public/app/features/explore-map/components/ExploreMapCanvas.tsx +++ b/public/app/features/explore-map/components/ExploreMapCanvas.tsx @@ -17,6 +17,7 @@ import { ExploreMapComment } from './ExploreMapComment'; import { ExploreMapFrame } from './ExploreMapFrame'; import { ExploreMapPanelContainer } from './ExploreMapPanelContainer'; import { ExploreMapStickyNote } from './ExploreMapStickyNote'; +import { Minimap } from './Minimap'; import { UserCursor } from './UserCursor'; interface SelectionRect { @@ -36,6 +37,7 @@ export function ExploreMapCanvas() { const [isSelecting, setIsSelecting] = useState(false); const justCompletedSelectionRef = useRef(false); const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + const transformWrapperRef = useRef(null); const panels = useSelector((state) => selectPanels(state.exploreMapCRDT)); const frames = useSelector((state) => selectFrames(state.exploreMapCRDT)); @@ -61,27 +63,47 @@ export function ExploreMapCanvas() { }); // Track container size for viewport calculations + // Since the canvas is 50000px and all parent containers are expanding to fit it, + // we need to find the actual viewport-constrained container useEffect(() => { - if (!containerRef.current) { - return; - } - const updateSize = () => { - if (containerRef.current) { - const rect = containerRef.current.getBoundingClientRect(); - setContainerSize({ width: rect.width, height: rect.height }); + // Walk up the DOM to find the first element that has a constrained height + let element = containerRef.current?.parentElement; + let width = 0; + let height = 0; + + while (element) { + const computedStyle = window.getComputedStyle(element); + const elementHeight = element.clientHeight; + const elementWidth = element.clientWidth; + + // Look for an element with a constrained height (not 50000px) + if (elementHeight < 50000 && elementHeight > 0) { + width = elementWidth; + height = elementHeight; + break; + } + + element = element.parentElement; + } + + if (width > 0 && height > 0) { + setContainerSize({ width, height }); } }; - // Initial size - updateSize(); + // Delay initial size update to ensure DOM is mounted + const timeoutId = setTimeout(updateSize, 100); // Update on resize - const resizeObserver = new ResizeObserver(updateSize); - resizeObserver.observe(containerRef.current); + const handleResize = () => { + updateSize(); + }; + window.addEventListener('resize', handleResize); return () => { - resizeObserver.disconnect(); + clearTimeout(timeoutId); + window.removeEventListener('resize', handleResize); }; }, []); @@ -163,13 +185,13 @@ export function ExploreMapCanvas() { // The canvas element is inside TransformComponent which applies CSS transform: scale() and translate() // The bounding rect already reflects the transform, so screenX/screenY are in the zoomed coordinate space - // We need to convert back to the original 10000x10000 canvas coordinate space + // We need to convert back to the original 50000x50000 canvas coordinate space const scale = contextTransformRef?.current?.state?.scale ?? viewport.zoom; // Since getBoundingClientRect() gives us the rect AFTER transform, // we just need to divide by scale to get canvas coordinates - const canvasX = Math.max(0, Math.min(10000, screenX / scale)); - const canvasY = Math.max(0, Math.min(10000, screenY / scale)); + const canvasX = Math.max(0, Math.min(50000, screenX / scale)); + const canvasY = Math.max(0, Math.min(50000, screenY / scale)); // Update cursor position for all sessions (using actual canvas coordinates, clamped to bounds) // Include currently selected panel IDs for collaborative selection visualization @@ -363,6 +385,7 @@ export function ExploreMapCanvas() { ))} + ); } @@ -374,6 +397,8 @@ const getStyles = (theme: GrafanaTheme2) => { flex: 1, overflow: 'hidden', backgroundColor: theme.colors.background.canvas, + height: '100%', // Ensure it doesn't expand beyond flex allocation + minHeight: 0, // Allow flex shrinking }), transformWrapper: css({ width: '100%', @@ -389,8 +414,8 @@ const getStyles = (theme: GrafanaTheme2) => { }), canvas: css({ position: 'relative', - width: '10000px', - height: '10000px', + width: '50000px', + height: '50000px', '--grid-color': theme.colors.border.weak, backgroundImage: ` radial-gradient(circle, var(--grid-color) 1.5px, transparent 1.5px) diff --git a/public/app/features/explore-map/components/Minimap.tsx b/public/app/features/explore-map/components/Minimap.tsx new file mode 100644 index 00000000000..693c34265fc --- /dev/null +++ b/public/app/features/explore-map/components/Minimap.tsx @@ -0,0 +1,187 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; +import { useDispatch, useSelector } from 'app/types/store'; +import { selectViewport, selectPanels, selectFrames } from '../state/selectors'; +import { updateViewport as updateViewportCRDT } from '../state/crdtSlice'; +import { useTransformContext } from '../context/TransformContext'; + +const CANVAS_SIZE = 50000; +const MINIMAP_SIZE = 200; +const MINIMAP_SCALE = MINIMAP_SIZE / CANVAS_SIZE; + +interface MinimapProps { + containerWidth: number; + containerHeight: number; +} + +export const Minimap: React.FC = ({ containerWidth, containerHeight }) => { + const styles = useStyles2(getStyles); + const dispatch = useDispatch(); + const canvasRef = useRef(null); + const isDraggingRef = useRef(false); + + const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT)); + const panels = useSelector((state) => selectPanels(state.exploreMapCRDT)); + const frames = useSelector((state) => selectFrames(state.exploreMapCRDT)); + const { transformRef } = useTransformContext(); + + // Draw the minimap + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + // Clear canvas + ctx.clearRect(0, 0, MINIMAP_SIZE, MINIMAP_SIZE); + + // Draw frames + Object.values(frames).forEach((frame) => { + const x = frame.position.x * MINIMAP_SCALE; + const y = frame.position.y * MINIMAP_SCALE; + const width = frame.position.width * MINIMAP_SCALE; + const height = frame.position.height * MINIMAP_SCALE; + + ctx.fillStyle = '#6366f120'; // Light indigo with 20% opacity + ctx.strokeStyle = '#6366f1'; + ctx.lineWidth = 1; + ctx.fillRect(x, y, width, height); + ctx.strokeRect(x, y, width, height); + }); + + // Draw panels + Object.values(panels).forEach((panel) => { + const x = panel.position.x * MINIMAP_SCALE; + const y = panel.position.y * MINIMAP_SCALE; + const width = panel.position.width * MINIMAP_SCALE; + const height = panel.position.height * MINIMAP_SCALE; + + ctx.fillStyle = '#3f51b5'; + ctx.strokeStyle = '#5c6bc0'; + ctx.lineWidth = 0.5; + ctx.fillRect(x, y, width, height); + ctx.strokeRect(x, y, width, height); + }); + + // Calculate viewport rectangle in canvas coordinates + // The pan values are negative offsets in screen pixels + // To get canvas coordinates: canvasX = -panX / zoom + const viewportX = (-viewport.panX / viewport.zoom) * MINIMAP_SCALE; + const viewportY = (-viewport.panY / viewport.zoom) * MINIMAP_SCALE; + const viewportWidth = (containerWidth / viewport.zoom) * MINIMAP_SCALE; + const viewportHeight = (containerHeight / viewport.zoom) * MINIMAP_SCALE; + + // Draw viewport rectangle + ctx.strokeStyle = '#00b8d4'; + ctx.fillStyle = '#00b8d420'; + ctx.lineWidth = 2; + ctx.fillRect(viewportX, viewportY, viewportWidth, viewportHeight); + ctx.strokeRect(viewportX, viewportY, viewportWidth, viewportHeight); + }, [viewport, panels, frames, containerWidth, containerHeight]); + + // Handle minimap click to pan + const handleMinimapInteraction = useCallback( + (event: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas || !transformRef?.current) { + return; + } + + const rect = canvas.getBoundingClientRect(); + const clickX = event.clientX - rect.left; + const clickY = event.clientY - rect.top; + + // Convert minimap coordinates to canvas coordinates + const canvasX = clickX / MINIMAP_SCALE; + const canvasY = clickY / MINIMAP_SCALE; + + // Calculate the target pan to center the clicked point + const targetPanX = -(canvasX * viewport.zoom - containerWidth / 2); + const targetPanY = -(canvasY * viewport.zoom - containerHeight / 2); + + // Update viewport through react-zoom-pan-pinch + transformRef.current.setTransform(targetPanX, targetPanY, viewport.zoom, 200); + + // Update Redux state + dispatch( + updateViewportCRDT({ + zoom: viewport.zoom, + panX: targetPanX, + panY: targetPanY, + }) + ); + }, + [dispatch, viewport.zoom, transformRef] + ); + + const handleMouseDown = useCallback( + (event: React.MouseEvent) => { + isDraggingRef.current = true; + handleMinimapInteraction(event); + }, + [handleMinimapInteraction] + ); + + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + if (isDraggingRef.current) { + handleMinimapInteraction(event); + } + }, + [handleMinimapInteraction] + ); + + const handleMouseUp = useCallback(() => { + isDraggingRef.current = false; + }, []); + + const handleMouseLeave = useCallback(() => { + isDraggingRef.current = false; + }, []); + + return ( +
+ +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + position: 'fixed', // Changed from absolute to fixed to escape parent overflow + bottom: theme.spacing(2), + left: theme.spacing(2), + zIndex: 10000, // Increased z-index to be above everything + borderRadius: theme.shape.radius.default, + border: `2px solid ${theme.colors.primary.border}`, // More visible border + boxShadow: theme.shadows.z3, + backgroundColor: 'transparent', + padding: theme.spacing(1), + pointerEvents: 'auto', + backdropFilter: 'blur(8px)', // Add blur effect for better visibility + }), + canvas: css({ + display: 'block', + cursor: 'pointer', + borderRadius: theme.shape.radius.default, + '&:active': { + cursor: 'grabbing', + }, + }), +}); diff --git a/public/app/features/explore-map/hooks/useCursorViewportTracking.ts b/public/app/features/explore-map/hooks/useCursorViewportTracking.ts index dd6583b480c..112ded51c60 100644 --- a/public/app/features/explore-map/hooks/useCursorViewportTracking.ts +++ b/public/app/features/explore-map/hooks/useCursorViewportTracking.ts @@ -30,7 +30,7 @@ interface UseCursorViewportTrackingOptions { containerHeight: number; } -const CANVAS_SIZE = 10000; +const CANVAS_SIZE = 50000; const EDGE_INDICATOR_MARGIN = 20; // Pixels from edge /**