# Plan: Client-Side Viewport Zoom + Synced Physical Scale ## Context Server-side crop in `ImageGet` physically removes pixels from served images. This causes problems: the viewer gets different pixel dimensions per series, can't compute correct FOVs, and can't normalize display size across panels. Johan's direction: stop cropping server-side, store a suggested viewport as metadata, let the viewer handle zoom-to-content and synced sizing. ## Changes ### 1. Remove server-side crop from ImageGet **`lib/dbcore.go`** - Delete `NoCrop` field from `ImageOpts` struct (line 450) - Delete the crop block (lines 546-571) — parent lookup, crop struct, draw.Draw - Function becomes: read PNG → window/level → return full image **`api/api_image.go`** - Remove `NoCrop: r.URL.Query().Get("full") == "1"` (line 27) ### 2. Import: aspect-ratio-preserving viewport ratios **`lib/dicom.go`** (lines 1326-1352) Keep `boundingBox()` and `unionBBox()` unchanged. Change the series metadata storage: - Take the union bounding box (content region) - Expand it to match the original image's aspect ratio (e.g., 420×395 content in 512×512 image → 420×420 square, centered on content center) - Clamp to image bounds - Store as ratios 0.0-1.0: `viewport_left`, `viewport_top`, `viewport_right`, `viewport_bottom` - Keep the 5% threshold — don't store viewport if content fills >95% of the image - Stop writing old `crop_x1/y1/x2/y2/width/height` keys ### 3. Expose viewport in series API **`api/api_series.go`** Parse `viewport_left/top/right/bottom` from series entry Data JSON. Include in response only when present: ```json { "id": "...", "series_desc": "AX T2", "slice_count": 24, "viewport": [0.084, 0.113, 0.902, 0.885] } ``` Array format `[left, top, right, bottom]` — compact, no key overhead. Old series (with `crop_x1` but no `viewport_*`) simply won't have the field — they show full image with no zoom-to-content. No migration needed. ### 4. Viewer: zoom-to-content + synced physical scale **`portal/static/viewer.js`** Replace the disabled `normalizeScale()` with viewport + scale logic. **On `loadSeries()`:** - Read `viewport` from the series metadata (already in `seriesList`) - Store as `panels[idx].viewport` - Compute physical FOV of the viewport region in mm: ``` vpWidthPx = viewport_width_fraction * cols vpFovMM = vpWidthPx * pixel_spacing_col ``` (Similarly for height; take the larger dimension) **`applyViewportAndScale()` — replaces `normalizeScale()`:** For each panel, compute a base transform (zoom + pan) that: 1. Zooms to show the viewport region (or full image if no viewport) 2. In multi-panel mode, normalizes mm/pixel across all panels ``` viewportZoom = 1.0 / max(vpWidth, vpHeight) // fraction of image scaleFactor = thisFovMM / maxFovMM // synced sizing (<=1.0) baseZoom = viewportZoom * scaleFactor panX = (0.5 - vpCenterX) * wrapperWidth panY = (0.5 - vpCenterY) * wrapperHeight ``` The panel with the largest physical FOV fills its panel completely. Smaller FOV panels appear proportionally smaller — same mm per screen pixel. **Compose with user zoom** in `applyZoom()`: ``` totalZoom = baseZoom * zoomLevels[state.level] totalPanX = basePanX + state.panX totalPanY = basePanY + state.panY wrapper.style.transform = 'scale(' + totalZoom + ') translate(...)' ``` At user zoom level 0, they see the viewport+synced view. Zoom in layers on top. **Single panel mode:** No scale normalization needed (scaleFactor = 1.0). Just zoom to viewport. ### 5. Revert temporary CSS changes **`portal/static/viewer.css`** - `.img-wrapper`: revert to `display: inline-block` (the CSS transform handles sizing now, wrapper should shrink-wrap) - `.panel-content img`: revert to `max-width: 100%; max-height: 100%` (viewport zoom handles the rest) Actually — need to think about this. With viewport zoom via CSS transform on the wrapper, the img needs to be at its natural size within the wrapper, and the wrapper gets scaled. So: - `.panel-content img { max-width: 100%; max-height: 100%; }` — image fits panel at 1x - `.img-wrapper { display: inline-block; }` — shrink-wraps the image - CSS transform on wrapper handles zoom + pan This is the original CSS, which works correctly with the transform-based approach. ## Files Modified 1. `lib/dbcore.go` — remove crop from ImageGet, remove NoCrop from ImageOpts 2. `api/api_image.go` — remove `?full=1` handling 3. `lib/dicom.go` — viewport ratios instead of crop pixels 4. `api/api_series.go` — expose viewport in response 5. `portal/static/viewer.js` — viewport zoom + synced scale 6. `portal/static/viewer.css` — revert img-wrapper and img styles to original ## Implementation Order 1. Remove crop from ImageGet + api_image.go (immediate improvement — full images served) 2. Revert CSS to original (inline-block wrapper, max-width/height img) 3. Implement viewport zoom + synced scale in viewer.js 4. Update import to store viewport ratios 5. Expose viewport in series API 6. Deploy and test with Sophia's brain MRIs (2022 SAG T1 vs 2025 SAG T2 THINS) ## Verification - Compare SAG T1 (2022) and SAG T2 THINS (2025) side by side — anatomy should appear at same physical scale - Compare AX T2 from both studies — same - Single panel mode — should zoom to content (viewport) - Zoom in/out should layer on top of the base viewport view - Old series without viewport data should display full image (no zoom-to-content)