diff --git a/README.md b/README.md index 3530463..7a6a736 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ A self-hostable collaborative whole-slide image viewer with real-time cursor pre ## Why PathCollab? -PathCollab is a **presenter-led collaborative viewer** where one host guides up to 20 followers through a whole-slide image. Everyone sees real-time cursors, can snap to the presenter's view, and overlay millions of AI-detected cells—all from a shareable link with **no accounts required**. +PathCollab is a **presenter-led collaborative viewer** where one host guides up to 20 followers through a whole-slide image. Everyone sees real-time cursors, can snap to the presenter's view, and overlay millions of cells - all from a shareable link with **no accounts required**. | Feature | What It Does | |---------|--------------| diff --git a/web/src/components/viewer/WebGLCellOverlay.tsx b/web/src/components/viewer/WebGLCellOverlay.tsx index 97dc0f0..47bebca 100644 --- a/web/src/components/viewer/WebGLCellOverlay.tsx +++ b/web/src/components/viewer/WebGLCellOverlay.tsx @@ -347,30 +347,6 @@ export const WebGLCellOverlay = memo(function WebGLCellOverlay({ } }, [cells, glReady]) - // Calculate viewport transform matrix - const transformMatrix = useMemo(() => { - if (viewport.zoom <= 0 || slideWidth <= 0 || slideHeight <= 0) { - return null - } - - const viewportWidth = 1 / viewport.zoom - const viewportHeight = viewerBounds.height / viewerBounds.width / viewport.zoom - - const viewportLeft = viewport.centerX - viewportWidth / 2 - const viewportTop = viewport.centerY - viewportHeight / 2 - - // Transform: slide coords -> normalized (0-1) -> viewport relative (0-1) -> clip space (-1 to 1) - // Combined into a 3x3 matrix (column-major for WebGL) - // NOTE: OpenSeadragon uses width-normalized coordinates (image width = 1), - // so both X and Y slide coords are normalized by slideWidth - const scaleX = 2 / viewportWidth / slideWidth - const scaleY = -2 / viewportHeight / slideWidth // Flip Y; use slideWidth for OSD normalization - const translateX = (-2 * viewportLeft) / viewportWidth - 1 - const translateY = (2 * viewportTop) / viewportHeight + 1 - - return new Float32Array([scaleX, 0, 0, 0, scaleY, 0, translateX, translateY, 1]) - }, [viewport, viewerBounds.width, viewerBounds.height, slideWidth, slideHeight]) - // Calculate current LOD level based on average cell size const lodLevel = useMemo(() => { if (cells.length === 0) return 'FULL' @@ -401,13 +377,52 @@ export const WebGLCellOverlay = memo(function WebGLCellOverlay({ // Render function const render = useCallback(() => { const gl = glRef.current + const canvas = canvasRef.current const program = programRef.current const locations = locationsRef.current - if (!gl || !program || !locations || !transformMatrix) { + if (!gl || !canvas || !program || !locations) { return } + // Read actual canvas dimensions for transform calculation + // This ensures the transform always matches the actual rendered canvas size, + // avoiding misalignment during resize when viewerBounds may be stale + const canvasWidth = canvas.clientWidth || 1 + const canvasHeight = canvas.clientHeight || 1 + + // Calculate transform matrix using actual canvas dimensions + if (viewport.zoom <= 0 || slideWidth <= 0 || slideHeight <= 0) { + return + } + + const viewportWidth = 1 / viewport.zoom + const viewportHeight = canvasHeight / canvasWidth / viewport.zoom + + const viewportLeft = viewport.centerX - viewportWidth / 2 + const viewportTop = viewport.centerY - viewportHeight / 2 + + // Transform: slide coords -> normalized (0-1) -> viewport relative (0-1) -> clip space (-1 to 1) + // Combined into a 3x3 matrix (column-major for WebGL) + // NOTE: OpenSeadragon uses width-normalized coordinates (image width = 1), + // so both X and Y slide coords are normalized by slideWidth + const scaleX = 2 / viewportWidth / slideWidth + const scaleY = -2 / viewportHeight / slideWidth // Flip Y; use slideWidth for OSD normalization + const translateX = (-2 * viewportLeft) / viewportWidth - 1 + const translateY = (2 * viewportTop) / viewportHeight + 1 + + const transformMatrix = new Float32Array([ + scaleX, + 0, + 0, + 0, + scaleY, + 0, + translateX, + translateY, + 1, + ]) + // Clear canvas gl.viewport(0, 0, gl.canvas.width, gl.canvas.height) gl.clearColor(0, 0, 0, 0) @@ -465,7 +480,7 @@ export const WebGLCellOverlay = memo(function WebGLCellOverlay({ } // bufferVersion is intentionally included to trigger re-render when buffers change // eslint-disable-next-line react-hooks/exhaustive-deps - }, [transformMatrix, lodLevel, bufferVersion, opacity]) + }, [viewport, slideWidth, slideHeight, lodLevel, bufferVersion, opacity]) // Render on each animation frame when viewport changes useEffect(() => { diff --git a/web/src/components/viewer/WebGLTissueOverlay.tsx b/web/src/components/viewer/WebGLTissueOverlay.tsx index 0218761..bc52813 100644 --- a/web/src/components/viewer/WebGLTissueOverlay.tsx +++ b/web/src/components/viewer/WebGLTissueOverlay.tsx @@ -394,27 +394,6 @@ export const WebGLTissueOverlay = memo(function WebGLTissueOverlay({ // when panning back or re-enabling the overlay. Textures are only cleaned up on unmount. }, [tiles, glReady]) - // Calculate viewport transform matrix - const transformMatrix = useMemo(() => { - if (viewport.zoom <= 0 || slideWidth <= 0) { - return null - } - - const viewportWidth = 1 / viewport.zoom - const viewportHeight = viewerBounds.height / viewerBounds.width / viewport.zoom - - const viewportLeft = viewport.centerX - viewportWidth / 2 - const viewportTop = viewport.centerY - viewportHeight / 2 - - // Transform: slide coords -> normalized (0-1) -> viewport relative (0-1) -> clip space (-1 to 1) - const scaleX = 2 / viewportWidth / slideWidth - const scaleY = -2 / viewportHeight / slideWidth // Flip Y; use slideWidth for OSD normalization - const translateX = (-2 * viewportLeft) / viewportWidth - 1 - const translateY = (2 * viewportTop) / viewportHeight + 1 - - return new Float32Array([scaleX, 0, 0, 0, scaleY, 0, translateX, translateY, 1]) - }, [viewport, viewerBounds.width, viewerBounds.height, slideWidth]) - // Build colormap from metadata (Float32Array for CPU-side reference) const colormap = useMemo(() => buildColormap(metadata.classes), [metadata.classes]) @@ -452,6 +431,8 @@ export const WebGLTissueOverlay = memo(function WebGLTissueOverlay({ }, [visibilityData, glReady]) // Calculate viewport bounds in slide coordinates for spatial queries + // Note: This uses viewerBounds for tile prefetching decisions, which is acceptable + // even if slightly stale - it just affects which tiles we request, not rendering alignment const viewportBounds = useMemo((): ViewportBounds | null => { if (viewport.zoom <= 0 || slideWidth <= 0) return null @@ -477,13 +458,49 @@ export const WebGLTissueOverlay = memo(function WebGLTissueOverlay({ // Render function - optimized to minimize allocations and redundant GL calls const render = useCallback(() => { const gl = glRef.current + const canvas = canvasRef.current const program = programRef.current const locations = locationsRef.current - if (!gl || !program || !locations || !transformMatrix || !viewportBounds) { + if (!gl || !canvas || !program || !locations || !viewportBounds) { return } + // Read actual canvas dimensions for transform calculation + // This ensures the transform always matches the actual rendered canvas size, + // avoiding misalignment during resize when viewerBounds may be stale + const canvasWidth = canvas.clientWidth || 1 + const canvasHeight = canvas.clientHeight || 1 + + // Calculate transform matrix using actual canvas dimensions + if (viewport.zoom <= 0 || slideWidth <= 0) { + return + } + + const viewportWidth = 1 / viewport.zoom + const viewportHeight = canvasHeight / canvasWidth / viewport.zoom + + const viewportLeft = viewport.centerX - viewportWidth / 2 + const viewportTop = viewport.centerY - viewportHeight / 2 + + // Transform: slide coords -> normalized (0-1) -> viewport relative (0-1) -> clip space (-1 to 1) + const scaleX = 2 / viewportWidth / slideWidth + const scaleY = -2 / viewportHeight / slideWidth // Flip Y; use slideWidth for OSD normalization + const translateX = (-2 * viewportLeft) / viewportWidth - 1 + const translateY = (2 * viewportTop) / viewportHeight + 1 + + const transformMatrix = new Float32Array([ + scaleX, + 0, + 0, + 0, + scaleY, + 0, + translateX, + translateY, + 1, + ]) + // Clear canvas gl.viewport(0, 0, gl.canvas.width, gl.canvas.height) gl.clearColor(0, 0, 0, 0) @@ -649,7 +666,8 @@ export const WebGLTissueOverlay = memo(function WebGLTissueOverlay({ } // eslint-disable-next-line react-hooks/exhaustive-deps -- tiles is intentionally included to trigger re-render when new tiles load }, [ - transformMatrix, + viewport, + slideWidth, viewportBounds, colormap, visibilityData, diff --git a/web/src/hooks/useViewerViewport.ts b/web/src/hooks/useViewerViewport.ts index dd76496..95240c1 100644 --- a/web/src/hooks/useViewerViewport.ts +++ b/web/src/hooks/useViewerViewport.ts @@ -68,32 +68,27 @@ export function useViewerViewport({ // Refs for pending operations const pendingSnapRef = useRef(false) const lastAppliedViewportRef = useRef(null) - const resizeThrottleRef = useRef(null) - // Update viewer bounds on resize (throttled to avoid excessive updates) + // Update viewer bounds on resize using ResizeObserver for immediate updates useEffect(() => { + const container = viewerContainerRef.current + if (!container) return + const updateBounds = () => { - if (viewerContainerRef.current) { - setViewerBounds(viewerContainerRef.current.getBoundingClientRect()) - } + setViewerBounds(container.getBoundingClientRect()) } - const throttledUpdateBounds = () => { - if (resizeThrottleRef.current) return + updateBounds() // Initial measurement + + // ResizeObserver is more immediate than window resize events + // and correctly handles container size changes from any source + const observer = new ResizeObserver(() => { updateBounds() - resizeThrottleRef.current = window.setTimeout(() => { - resizeThrottleRef.current = null - }, 100) // Throttle to max 10 updates/second - } + }) - updateBounds() // Initial measurement - window.addEventListener('resize', throttledUpdateBounds) - return () => { - window.removeEventListener('resize', throttledUpdateBounds) - if (resizeThrottleRef.current) { - clearTimeout(resizeThrottleRef.current) - } - } + observer.observe(container) + + return () => observer.disconnect() }, [viewerContainerRef]) // Apply presenter viewport to the viewer