Add minimap, increase canvas size

This commit is contained in:
Aleksandar Petrov
2025-12-04 09:01:01 -04:00
parent 329da8298a
commit 4e2849f1a9
3 changed files with 230 additions and 18 deletions
@@ -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<HTMLDivElement>(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() {
<EdgeCursorIndicator key={info.cursor.sessionId} cursorInfo={info} />
))}
<ExploreMapComment />
<Minimap containerWidth={containerSize.width} containerHeight={containerSize.height} />
</div>
);
}
@@ -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)
@@ -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<MinimapProps> = ({ containerWidth, containerHeight }) => {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const canvasRef = useRef<HTMLCanvasElement>(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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
isDraggingRef.current = true;
handleMinimapInteraction(event);
},
[handleMinimapInteraction]
);
const handleMouseMove = useCallback(
(event: React.MouseEvent<HTMLCanvasElement>) => {
if (isDraggingRef.current) {
handleMinimapInteraction(event);
}
},
[handleMinimapInteraction]
);
const handleMouseUp = useCallback(() => {
isDraggingRef.current = false;
}, []);
const handleMouseLeave = useCallback(() => {
isDraggingRef.current = false;
}, []);
return (
<div className={styles.container}>
<canvas
ref={canvasRef}
width={MINIMAP_SIZE}
height={MINIMAP_SIZE}
className={styles.canvas}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
/>
</div>
);
};
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',
},
}),
});
@@ -30,7 +30,7 @@ interface UseCursorViewportTrackingOptions {
containerHeight: number;
}
const CANVAS_SIZE = 10000;
const CANVAS_SIZE = 50000;
const EDGE_INDICATOR_MARGIN = 20; // Pixels from edge
/**