Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---------|--------------|
Expand Down
67 changes: 41 additions & 26 deletions web/src/components/viewer/WebGLCellOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(() => {
Expand Down
64 changes: 41 additions & 23 deletions web/src/components/viewer/WebGLTissueOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down Expand Up @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
33 changes: 14 additions & 19 deletions web/src/hooks/useViewerViewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,32 +68,27 @@ export function useViewerViewport({
// Refs for pending operations
const pendingSnapRef = useRef(false)
const lastAppliedViewportRef = useRef<string | null>(null)
const resizeThrottleRef = useRef<number | null>(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
Expand Down
Loading