Whole-slide tiles (NVSlide, HTTP range)
Slides (NVSlide tile viewer)
Code
slides.js
// Whole-slide-image tile viewer built on the NVSlide core class.
//
// NVSlide owns the manifest/levels/tiles model, the viewport (pan/zoom), HTTP
// Range-request tile streaming, the ImageBitmap cache and request stats. This
// demo just wires DOM controls to it and draws it with a backend-selected
// renderer: SlideRenderer (WebGL2) or SlideRendererGPU (WebGPU). Add
// `?backend=webgpu` to the URL to use the WebGPU renderer.
//
// The synthetic pyramid (public/tile-range-poc/) streams one tile's worth of
// bytes at a time over HTTP 206 range requests, so a multi-gigabyte slide never
// has to be downloaded whole.
import {
buildDrawingLut,
DziSource,
drawingBitmapToRGBA,
lookupColorMap,
NVSlide,
SlideDrawing,
SlideRenderer,
SlideRendererGPU,
SlideVectorLayer,
} from '@niivue/niivue'
import { decodeJp2 } from './openjpeg-decoder.js'
import { createTiffSource } from './tiff-source.js'
import { urlParamSelect } from './urlParamSelect.js'
// DZI sources construct via NVSlide.fromSource(DziSource.fromUrl(...)) rather
// than a manifest URL. This OpenSeadragon example (overlap 2) is CORS-enabled.
const DZI_URLS = {
'dzi-highsmith':
'https://openseadragon.github.io/example-images/highsmith/highsmith.dzi',
}
// TIFF/SVS sources build a geotiff-backed SlideTileSource. The OpenSlide server
// has no CORS, so the file is served from the streaming media base below.
const TIFF_URLS = {
'tiff-cmu1': 'svs/CMU-1-Small-Region.svs',
}
// NVSlide core ships no JPEG 2000 codec; register an OpenJPEG WASM decoder so
// image/jp2 tiles (OpenSlide DICOM JP2K archives) decode in-browser.
NVSlide.registerTileDecoder('image/jp2', decodeJp2)
const SYNTHETIC_MANIFEST_PATH = 'tile-range-poc/tiles.json'
// DICOM-WSI manifests use dicom-wsi-range-v1: per-level JPEG tiles addressed
// by byte offset, which NVSlide fetches over HTTP Range and decodes in-browser.
// Manifests and binaries resolve from the streaming bases below.
const DICOM_WSI_PATH = 'dicom-wsi/cptac-brca/manifest.json'
// Manifest path per source-dropdown value. The OpenSlide DICOM-WSI archives
// and manifests are hosted with Git LFS; only JPEG TILED_FULL sources are
// loadable in-browser (see scripts/fetch-openslide-dicom.ts).
const MANIFEST_PATHS = {
synthetic: SYNTHETIC_MANIFEST_PATH,
'dicom-wsi': DICOM_WSI_PATH,
'openslide-3dhistech-1': 'dicom-wsi/3dhistech-1/manifest.json',
// hamamatsu-2 omitted: dicom-parser raises a buffer overrun building its
// manifest (see scripts/fetch-openslide-dicom.ts), so it never loads.
'openslide-jp2k-33003-1': 'dicom-wsi/jp2k-33003-1/manifest.json',
}
const MAX_CACHE_BYTES = 96 * 1024 * 1024
const TARGET_SCREEN_PIXELS_PER_TILE_PIXEL = 0.75
const RANGE_LOG_LENGTH = 24
function el(id) {
const node = document.getElementById(id)
if (!node) throw new Error(`Missing #${id}`)
return node
}
const els = {
backendSelect: el('backendSelect'),
source: el('source'),
levelMode: el('levelMode'),
zoomOut: el('zoomOut'),
zoomIn: el('zoomIn'),
fit: el('fit'),
showGrid: el('showGrid'),
canvas: el('tileCanvas'),
overlay: el('drawOverlay'),
drawBtn: el('drawBtn'),
drawTool: el('drawTool'),
wandTol: el('wandTol'),
penValue: el('penValue'),
drawUndo: el('drawUndo'),
drawClear: el('drawClear'),
exportSvg: el('exportSvg'),
hud: el('hud'),
rangeLog: el('rangeLog'),
fallback: el('fallback'),
}
// Wired before anything loads, so the menu works even if loading fails (#488).
urlParamSelect(els.backendSelect, 'backend', backendFromUrl())
let view = null
let slide = null
let animationFrame = 0
let hasFit = false
let drag = null
// Slide-space drawing: a SlideDrawing raster (painted with the shared pen tools,
// in slide pixels) shown via a stacked Canvas2D overlay mapped through NVSlide's
// screen<->slide transform, so the annotation stays registered under pan/zoom.
const DRAW_RASTER_CAP = 1536
let drawing = null
let drawMode = false
let drawStroke = null // { pointerId } while a stroke is in progress
let lastRasterPt = null
let overlayDirty = false
let rasterCanvas = null
let rasterCtx = null
let rasterImageData = null
// Vector annotation layer (slide-pixel coords) + in-progress freehand polygon.
let vector = null
let vecPoints = null
let penValue = 1
let drawTool = 'pen' // pen | eraser | bucket | filled | wand
let wandTolerance = 40
// The shared "draw" label colormap, so annotation colors match the 3D demo and
// each pen value paints a distinct label color. Resolved lazily (Vite glob).
let drawLut = null
const DRAW_OPACITY = 0.85
function backendFromUrl() {
const raw = new URLSearchParams(window.location.search).get('backend')
return raw === 'webgpu' ? 'webgpu' : 'webgl2'
}
function resolveDemoUrl(path) {
const base = import.meta.env.BASE_URL || '/'
const normalized = base.endsWith('/') ? base : `${base}/`
return new URL(`${normalized}${path}`, window.location.href).toString()
}
// Streaming slides (DICOM-WSI, OpenSlide DICOM, SVS) come from the Git LFS
// repository the Pages build uses, so `bun run dev` loads them with no fetch
// step: manifests from raw, binaries from the LFS media endpoint (raw returns
// LFS pointers). VITE_STREAMING_ASSET_BASE=/ serves local copies instead
// (scripts/fetch-dicom-wsi.ts, scripts/fetch-openslide-dicom.ts, public/svs/).
const STREAMING_REPO = 'niivue/niivue-demo-images-lfs/main/streaming/'
const STREAMING_ASSET_BASE =
import.meta.env.VITE_STREAMING_ASSET_BASE ||
`https://raw.githubusercontent.com/${STREAMING_REPO}`
const STREAMING_MEDIA_BASE =
import.meta.env.VITE_STREAMING_MEDIA_BASE ||
import.meta.env.VITE_STREAMING_ASSET_BASE ||
`https://media.githubusercontent.com/media/${STREAMING_REPO}`
const isLocalStreaming = !/^https?:/.test(STREAMING_ASSET_BASE)
function resolveStreamingUrl(path) {
const base = STREAMING_ASSET_BASE
const normalized = base.endsWith('/') ? base : `${base}/`
return new URL(`${normalized}${path}`, window.location.href).toString()
}
function resolveStreamingMediaUrl(path) {
const base = STREAMING_MEDIA_BASE
const normalized = base.endsWith('/') ? base : `${base}/`
return new URL(`${normalized}${path}`, window.location.href).toString()
}
function manifestUrl() {
const path = MANIFEST_PATHS[els.source.value] ?? SYNTHETIC_MANIFEST_PATH
return els.source.value === 'synthetic'
? resolveDemoUrl(path)
: resolveStreamingUrl(path)
}
function manifestBinaryBaseUrl() {
const path = MANIFEST_PATHS[els.source.value] ?? SYNTHETIC_MANIFEST_PATH
return els.source.value === 'synthetic'
? resolveDemoUrl(path)
: resolveStreamingMediaUrl(path)
}
async function slideFromManifest(opts) {
const url = manifestUrl()
const binaryBaseUrl = manifestBinaryBaseUrl()
if (binaryBaseUrl === url) return NVSlide.fromManifestUrl(url, opts)
const response = await fetch(url)
if (!response.ok) throw new Error(`Slide manifest HTTP ${response.status}`)
return new NVSlide(await response.json(), {
...opts,
manifestUrl: binaryBaseUrl,
})
}
function html(value) {
return value
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''')
}
function formatBytes(bytes) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
}
function screenForCanvas() {
const rect = els.canvas.getBoundingClientRect()
return {
widthCss: rect.width,
heightCss: rect.height,
devicePixelRatio: window.devicePixelRatio || 1,
}
}
function resizeCanvas(screen) {
const dpr = screen.devicePixelRatio ?? 1
const width = Math.max(1, Math.floor(screen.widthCss * dpr))
const height = Math.max(1, Math.floor(screen.heightCss * dpr))
if (els.canvas.width !== width || els.canvas.height !== height) {
els.canvas.width = width
els.canvas.height = height
}
}
async function createView() {
if (backendFromUrl() === 'webgpu' && 'gpu' in navigator) {
const renderer = await SlideRendererGPU.create(els.canvas)
if (renderer) return { kind: 'gpu', renderer }
console.warn('WebGPU unavailable; falling back to WebGL2')
}
const gl = els.canvas.getContext('webgl2', { alpha: false, antialias: false })
if (!gl) throw new Error('WebGL2 is not available')
const renderer = new SlideRenderer()
renderer.init(gl)
return { kind: 'gl', gl, renderer }
}
function drawView(screen) {
if (!view || !slide) return
const slides = [slide]
if (view.kind === 'gpu') {
view.renderer.render(slides, screen)
} else {
view.renderer.draw(view.gl, slides, screen)
}
}
function requestRender() {
if (animationFrame !== 0) return
animationFrame = window.requestAnimationFrame(() => {
animationFrame = 0
render()
})
}
function render() {
const screen = screenForCanvas()
resizeCanvas(screen)
if (slide && !hasFit) {
slide.fitToScreen(screen)
hasFit = true
}
if (slide) slide.clampViewport(screen)
drawView(screen)
drawOverlay(screen)
updateHud(screen)
updateRangeLog()
updateExportEnabled()
}
// The SVG export only has content when the VECTOR tool has drawn shapes (a raster
// pen drawing is not exportable as SVG). Disable the button otherwise so clicking
// it isn't a silent no-op that looks broken mid-demo. Cheap to re-evaluate per
// frame; the state tracks add/undo/clear/source-switch without extra wiring.
function updateExportEnabled() {
els.exportSvg.disabled = !slide || !vector || vector.shapes.length === 0
}
// Build (or rebuild) the slide-space drawing raster for the active slide.
function ensureDrawing() {
if (!slide) {
drawing = null
return
}
const sw = slide.manifest.width
const sh = slide.manifest.height
const scale = Math.min(1, DRAW_RASTER_CAP / Math.max(1, sw, sh))
const rw = Math.max(1, Math.round(sw * scale))
const rh = Math.max(1, Math.round(sh * scale))
drawing = new SlideDrawing(rw, rh)
vector = new SlideVectorLayer()
vecPoints = null
lastRasterPt = null
rasterCanvas = document.createElement('canvas')
rasterCanvas.width = rw
rasterCanvas.height = rh
rasterCtx = rasterCanvas.getContext('2d')
rasterImageData = rasterCtx.createImageData(rw, rh)
overlayDirty = true
}
// Repaint the offscreen raster from the label bitmap via the "draw" LUT, so
// each pen value shows its label color (same palette as the 3D slide demo).
function rebuildRaster() {
if (!drawing || !rasterCtx || !rasterImageData) return
if (!drawLut) {
const cm = lookupColorMap('draw')
if (cm) drawLut = buildDrawingLut(cm)
}
const data = rasterImageData.data
if (drawLut) {
const rgba = drawingBitmapToRGBA(
drawing.img,
drawLut.lut,
drawLut.min ?? 0,
DRAW_OPACITY,
)
data.set(rgba)
} else {
// Fallback if the colormap isn't available: single color.
const img = drawing.img
for (let i = 0; i < img.length; i++) {
const o = i * 4
if (img[i]) {
data[o] = 235
data[o + 1] = 45
data[o + 2] = 55
data[o + 3] = 210
} else {
data[o + 3] = 0
}
}
}
rasterCtx.putImageData(rasterImageData, 0, 0)
}
// Inverse of NVSlide.screenToSlide: slide base pixel -> canvas CSS coords.
function slideToCss(sx, sy, screen) {
const vp = slide.viewport
const sc = vp.scale
const leftSlide = vp.centerX - screen.widthCss / (2 * sc)
const xCss = (sx - leftSlide) * sc
const yCss = slide.isYAxisUp()
? screen.heightCss / 2 - (sy - vp.centerY) * sc
: (sy - (vp.centerY - screen.heightCss / (2 * sc))) * sc
return [xCss, yCss]
}
// Draw the annotation raster over the tiles, mapped to the current viewport.
function drawOverlay(screen) {
const ov = els.overlay
if (ov.width !== els.canvas.width || ov.height !== els.canvas.height) {
ov.width = els.canvas.width
ov.height = els.canvas.height
}
const octx = ov.getContext('2d')
octx.clearRect(0, 0, ov.width, ov.height)
if (!slide || !drawing) return
if (overlayDirty) {
rebuildRaster()
overlayDirty = false
}
const dpr = screen.devicePixelRatio ?? 1
const sw = slide.manifest.width
const sh = slide.manifest.height
const [x0, y0] = slideToCss(0, 0, screen)
const [x1, y1] = slideToCss(sw, sh, screen)
const dx = Math.min(x0, x1) * dpr
const dy = Math.min(y0, y1) * dpr
const dW = Math.abs(x1 - x0) * dpr
const dH = Math.abs(y1 - y0) * dpr
octx.imageSmoothingEnabled = false
if (y0 > y1) {
// yAxis up: slide row 0 is at the bottom — flip vertically.
octx.save()
octx.translate(0, dy + dH)
octx.scale(1, -1)
octx.drawImage(rasterCanvas, dx, 0, dW, dH)
octx.restore()
} else {
octx.drawImage(rasterCanvas, dx, dy, dW, dH)
}
// Vector annotations (slide-space polygons) stroked over the raster.
const strokePath = (pts, color, closed) => {
if (!pts || pts.length < 2) return
octx.beginPath()
for (let i = 0; i < pts.length; i++) {
const [cx, cy] = slideToCss(pts[i][0], pts[i][1], screen)
if (i === 0) octx.moveTo(cx * dpr, cy * dpr)
else octx.lineTo(cx * dpr, cy * dpr)
}
if (closed) octx.closePath()
octx.lineWidth = Math.max(1, 2 * dpr)
octx.lineJoin = 'round'
octx.lineCap = 'round'
octx.strokeStyle = color
octx.stroke()
}
if (vector) {
for (const s of vector.shapes) {
strokePath(s.points, s.color, s.kind === 'polygon')
}
}
if (vecPoints) strokePath(vecPoints, penColorCss(), false) // in-progress
}
// Convert a pointer event to a slide-drawing raster pixel, or null if off-slide.
function eventToRaster(event) {
if (!slide || !drawing) return null
const rect = els.canvas.getBoundingClientRect()
const sp = slide.screenToSlide(
event.clientX - rect.left,
event.clientY - rect.top,
screenForCanvas(),
)
const rx = Math.round((sp.x / slide.manifest.width) * drawing.width)
const ry = Math.round((sp.y / slide.manifest.height) * drawing.height)
if (rx < 0 || ry < 0 || rx >= drawing.width || ry >= drawing.height) {
return null
}
return [rx, ry]
}
function penSizeForRaster() {
return Math.max(2, Math.round(drawing.width / 300))
}
// Pointer event -> slide base-pixel coords (resolution-independent; for vectors).
function eventToSlide(event) {
if (!slide) return null
const rect = els.canvas.getBoundingClientRect()
return slide.screenToSlide(
event.clientX - rect.left,
event.clientY - rect.top,
screenForCanvas(),
)
}
// CSS hex for the current pen label, from the "draw" LUT (matches the raster).
function penColorCss() {
if (!drawLut) {
const cm = lookupColorMap('draw')
if (cm) drawLut = buildDrawingLut(cm)
}
const lut = drawLut?.lut
const min = drawLut?.min ?? 0
const i = (penValue - min) * 4
if (!lut || i < 0 || i + 2 >= lut.length) return '#e62d37'
const hex = (n) => n.toString(16).padStart(2, '0')
return `#${hex(lut[i])}${hex(lut[i + 1])}${hex(lut[i + 2])}`
}
// Build an RGBA colour reference for the magic wand at the drawing-raster
// resolution by compositing the cached tiles of the most detailed pyramid level
// that fits the raster. Rebuilt per use so tiles that stream in are picked up;
// returns whatever is cached (partial = some black, improves on a later click).
function buildWandReference() {
if (!slide || !drawing) return null
const levels = slide.manifest.levels
let level = levels[levels.length - 1]
for (const lv of levels) {
if (lv.width <= drawing.width && lv.width > level.width) level = lv
}
const tw = level.tileWidth ?? slide.manifest.tileSize ?? 256
const th = level.tileHeight ?? slide.manifest.tileSize ?? 256
const sx = drawing.width / level.width
const sy = drawing.height / level.height
const c = document.createElement('canvas')
c.width = drawing.width
c.height = drawing.height
const cx = c.getContext('2d')
for (const t of level.tiles) {
slide.requestTile(level, t) // prime for a sharper re-click
const bmp = slide.cachedTileBitmap(`L${level.index}/${t.x}/${t.y}`)
if (!bmp) continue
cx.drawImage(bmp, t.x * tw * sx, t.y * th * sy, t.width * sx, t.height * sy)
}
return cx.getImageData(0, 0, c.width, c.height).data
}
function updateHud(screen) {
if (!slide) {
els.hud.innerHTML = '<div class="title">Loading tile manifest</div>'
return
}
const level = slide.selectLevel()
if (!level) {
els.hud.innerHTML = '<div class="title">Loading tile manifest</div>'
return
}
const s = slide.stats
const mode = slide.levelChoice === 'auto' ? 'auto' : 'fixed'
const scale = slide.viewport.scale
const zoom =
scale >= 1 ? `${scale.toFixed(2)}x` : `1:${(1 / scale).toFixed(1)}`
const visible = slide.visibleTiles(screen).tiles.length
const codec = level.codec ?? 'raw-rgba'
const backendLabel = view?.kind === 'gpu' ? 'WebGPU' : 'WebGL2'
els.hud.innerHTML = `
<div class="title">${html(slide.manifest.name)}</div>
<div class="row"><span class="key">backend</span><span>${backendLabel}</span></div>
<div class="row"><span class="key">source</span><span>${html(slide.sourceLabelForLevel(level))}</span></div>
<div class="row"><span class="key">codec</span><span>${html(codec)}</span></div>
<div class="row"><span class="key">level</span><span>L${level.index} ${mode} ${level.width}x${level.height}</span></div>
<div class="row"><span class="key">zoom</span><span>${zoom}</span></div>
<div class="row"><span class="key">tiles</span><span>${visible} visible, ${slide.pendingCount} pending</span></div>
<div class="row"><span class="key">requests</span><span>${s.completed}/${s.requested} done</span></div>
<div class="row"><span class="key">range</span><span class="ok">${s.rangeHits} HTTP 206</span></div>
<div class="row"><span class="key">fallback</span><span class="${s.fullFileFallbacks > 0 ? 'warn' : ''}">${s.fullFileFallbacks} HTTP 200</span></div>
<div class="row"><span class="key">wire</span><span>${formatBytes(s.wireBytes)}</span></div>
<div class="row"><span class="key">cache</span><span>${formatBytes(s.cacheBytes)} / ${s.cacheHits} hits</span></div>
<div class="row"><span class="key">failures</span><span class="${s.failures > 0 ? 'bad' : ''}">${s.failures}</span></div>
`
}
function updateRangeLog() {
const events = slide?.stats.lastRequests ?? []
const spans = []
for (let i = 0; i < RANGE_LOG_LENGTH - events.length; i++) {
spans.push(document.createElement('span'))
}
for (const event of events) {
const span = document.createElement('span')
span.className = event.status === 'hit' ? 'hit' : event.status
span.title = event.label
spans.push(span)
}
els.rangeLog.replaceChildren(...spans)
}
function populateLevels(manifest) {
els.levelMode.replaceChildren()
const auto = document.createElement('option')
auto.value = 'auto'
auto.textContent = 'auto LOD'
els.levelMode.appendChild(auto)
for (const level of manifest.levels) {
const option = document.createElement('option')
option.value = String(level.index)
option.textContent = `L${level.index} ${level.width}x${level.height}`
els.levelMode.appendChild(option)
}
els.levelMode.value = 'auto'
}
function showFallback(message) {
els.fallback.textContent = message
els.fallback.setAttribute('aria-hidden', 'false')
}
// Let the user drag a floating panel (the HUD) out of the way of a tile.
// Switches the element to left/top positioning and clamps it to the viewport.
function makeDraggable(node) {
let dragging = null
node.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return
const rect = node.getBoundingClientRect()
dragging = {
pointerId: event.pointerId,
offsetX: event.clientX - rect.left,
offsetY: event.clientY - rect.top,
}
node.setPointerCapture(event.pointerId)
node.classList.add('dragging')
})
node.addEventListener('pointermove', (event) => {
if (!dragging || dragging.pointerId !== event.pointerId) return
const width = node.offsetWidth
const height = node.offsetHeight
const maxX = Math.max(0, window.innerWidth - width)
const maxY = Math.max(0, window.innerHeight - height)
const x = Math.min(maxX, Math.max(0, event.clientX - dragging.offsetX))
const y = Math.min(maxY, Math.max(0, event.clientY - dragging.offsetY))
node.style.left = `${x}px`
node.style.top = `${y}px`
node.style.right = 'auto'
node.style.bottom = 'auto'
})
const end = (event) => {
if (!dragging || dragging.pointerId !== event.pointerId) return
dragging = null
node.classList.remove('dragging')
}
node.addEventListener('pointerup', end)
node.addEventListener('pointercancel', end)
}
function zoomCentered(factor) {
if (!slide) return
const screen = screenForCanvas()
slide.zoomBy(factor, screen.widthCss / 2, screen.heightCss / 2, screen)
}
els.canvas.addEventListener('pointerdown', (event) => {
// NB: do NOT setPointerCapture here. Capturing on every press makes the next
// click elsewhere (e.g. a header link) get swallowed clearing the capture
// ("link needs several clicks"). Capture is taken on the first move instead
// (a real drag) and released on pointerup.
// Draw mode paints the slide instead of panning.
if (drawMode && drawing) {
// Vector tool draws a freehand polygon in slide coords (not the raster).
if (drawTool === 'vector') {
const sp = eventToSlide(event)
vecPoints = sp ? [[sp.x, sp.y]] : []
drawStroke = { pointerId: event.pointerId, tool: 'vector' }
requestRender()
return
}
const pt = eventToRaster(event)
drawing.beginStroke() // snapshot for undo (all raster tools)
drawStroke = { pointerId: event.pointerId, tool: drawTool, points: [] }
if (drawTool === 'bucket') {
if (pt) {
drawing.bucketFill(pt[0], pt[1], penValue, true)
overlayDirty = true
requestRender()
}
return
}
if (drawTool === 'wand') {
if (pt) {
const ref = buildWandReference()
if (ref) {
drawing.magicWand(ref, pt[0], pt[1], wandTolerance, penValue, true)
overlayDirty = true
requestRender()
}
}
return
}
// pen / eraser / filled: paint the first dab (filled also accrues points).
const pv = drawTool === 'eraser' ? 0 : penValue
if (pt) {
drawing.point(pt[0], pt[1], pv, penSizeForRaster(), true)
drawStroke.points.push(pt)
lastRasterPt = pt
overlayDirty = true
requestRender()
}
return
}
drag = {
pointerId: event.pointerId,
lastX: event.clientX,
lastY: event.clientY,
}
})
els.canvas.addEventListener('pointermove', (event) => {
if (drawStroke && drawStroke.pointerId === event.pointerId) {
if (drawStroke.tool === 'bucket' || drawStroke.tool === 'wand') return // single click
if (drawStroke.tool === 'vector') {
const sp = eventToSlide(event)
if (sp && vecPoints) {
vecPoints.push([sp.x, sp.y])
requestRender()
}
return
}
const pt = eventToRaster(event)
if (pt) {
const pv = drawStroke.tool === 'eraser' ? 0 : penValue
if (lastRasterPt) {
drawing.line(
lastRasterPt[0],
lastRasterPt[1],
pt[0],
pt[1],
pv,
penSizeForRaster(),
true,
)
} else {
drawing.point(pt[0], pt[1], pv, penSizeForRaster(), true)
}
// Filled pen also records the outline to fill on release.
if (drawStroke.tool === 'filled') drawStroke.points.push(pt)
lastRasterPt = pt
overlayDirty = true
requestRender()
}
return
}
if (!drag || drag.pointerId !== event.pointerId || !slide) return
const dx = event.clientX - drag.lastX
const dy = event.clientY - drag.lastY
drag.lastX = event.clientX
drag.lastY = event.clientY
slide.panByScreenDelta(dx, dy, screenForCanvas())
})
// On window (not the canvas) so a release outside the canvas still ends the
// drag/stroke — we no longer setPointerCapture (capture swallows the next click
// on header links). Pan/draw track via canvas pointermove while over the slide.
window.addEventListener('pointerup', (event) => {
if (drawStroke?.pointerId === event.pointerId) {
// Filled pen: close the recorded outline and fill its interior on release.
if (drawStroke.tool === 'filled' && drawStroke.points.length >= 2) {
drawing.fillPen(drawStroke.points, penValue, true)
overlayDirty = true
requestRender()
}
// Vector: commit the freehand polygon to the vector layer.
if (drawStroke.tool === 'vector' && vecPoints) {
vector?.addPolygon(vecPoints, penColorCss())
vecPoints = null
requestRender()
}
drawStroke = null
lastRasterPt = null
}
if (drag?.pointerId === event.pointerId) drag = null
})
window.addEventListener('pointercancel', (event) => {
if (drawStroke?.pointerId === event.pointerId) {
drawStroke = null
lastRasterPt = null
}
if (drag?.pointerId === event.pointerId) drag = null
})
els.drawBtn.addEventListener('click', () => {
drawMode = !drawMode
els.drawBtn.textContent = `Draw: ${drawMode ? 'on' : 'off'}`
els.canvas.style.cursor = drawMode ? 'crosshair' : 'grab'
})
els.drawTool.addEventListener('change', () => {
drawTool = els.drawTool.value
})
els.wandTol.addEventListener('change', () => {
wandTolerance = Number(els.wandTol.value) || 40
})
els.penValue.addEventListener('change', () => {
penValue = Number(els.penValue.value) || 1
})
els.drawUndo.addEventListener('click', () => {
// Vector tool undoes the last polygon; other tools undo the raster.
if (drawTool === 'vector') {
if (vector?.removeLast()) requestRender()
return
}
if (drawing?.undo()) {
overlayDirty = true
requestRender()
}
})
els.drawClear.addEventListener('click', () => {
drawing?.clear()
vector?.clear()
overlayDirty = true
requestRender()
})
els.exportSvg.addEventListener('click', () => {
if (!slide || !vector || vector.shapes.length === 0) return
const svg = vector.toSVG(slide.manifest.width, slide.manifest.height)
const blob = new Blob([svg], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${slide.manifest.name || 'slide'}-annotations.svg`
a.click()
URL.revokeObjectURL(url)
})
els.canvas.addEventListener(
'wheel',
(event) => {
event.preventDefault()
if (!slide) return
const rect = els.canvas.getBoundingClientRect()
slide.zoomBy(
Math.exp(-event.deltaY * 0.0013),
event.clientX - rect.left,
event.clientY - rect.top,
screenForCanvas(),
)
},
{ passive: false },
)
els.levelMode.addEventListener('change', () => {
slide?.setLevelChoice(
els.levelMode.value === 'auto' ? 'auto' : Number(els.levelMode.value),
)
})
els.showGrid.addEventListener('change', () => {
slide?.setTileGridVisible(els.showGrid.checked)
})
els.fit.addEventListener('click', () => slide?.fitToScreen(screenForCanvas()))
els.zoomIn.addEventListener('click', () => zoomCentered(1.45))
els.zoomOut.addEventListener('click', () => zoomCentered(1 / 1.45))
els.source.addEventListener('change', () => {
void loadSlide()
})
window.addEventListener('resize', requestRender)
// Bumped on each load so a slower source (e.g. TIFF) that started earlier cannot
// resolve after a newer selection and desync the visible slide from the dropdown.
let loadSeq = 0
// Turn a load failure into an actionable hint. Most demo sources need a
// gitignored fixture fetched by a script, or an external host to be reachable;
// without this a missing SVS or an offline DZI shows only a raw fetch error.
function hintForSource(src) {
if (src === 'dzi-highsmith') {
return ` Fetched from ${new URL(DZI_URLS[src]).host}; check your network/CORS (this external DZI must be online).`
}
if (src === 'synthetic') return ''
if (!isLocalStreaming) {
return ` Fetched from ${new URL(STREAMING_ASSET_BASE, location.href).host}; check your network.`
}
if (src.startsWith('openslide-')) {
return ` Did you run scripts/fetch-openslide-dicom.ts --slide=${src.replace('openslide-', '')}?`
}
if (src === 'dicom-wsi') return ' Did you run scripts/fetch-dicom-wsi.ts?'
if (src === 'tiff-cmu1') {
return ` Serve ${TIFF_URLS[src]} same-origin under public/ (the .svs is gitignored; the OpenSlide host has no CORS).`
}
return ''
}
async function loadSlide() {
const seq = ++loadSeq
els.fallback.setAttribute('aria-hidden', 'true')
els.fallback.textContent = ''
// Tear down the outgoing slide before loading the next: drop its render
// listener, dispose its bitmap cache + in-flight fetches, and clear the
// renderer's per-tile texture cache so the new source cannot inherit stale
// tiles keyed by the same L/x/y (ghost tiles).
const prev = slide
slide = null
hasFit = false
prev?.removeEventListener('change', requestRender)
prev?.dispose?.()
view?.renderer?.clearTextures?.()
try {
const opts = {
maxCacheBytes: MAX_CACHE_BYTES,
targetScreenPixelsPerTilePixel: TARGET_SCREEN_PIXELS_PER_TILE_PIXEL,
showTileGrid: els.showGrid.checked,
}
const dziUrl = DZI_URLS[els.source.value]
const tiffUrl = TIFF_URLS[els.source.value]
let next
if (dziUrl) {
next = NVSlide.fromSource(await DziSource.fromUrl(dziUrl), opts)
} else if (tiffUrl) {
next = NVSlide.fromSource(
await createTiffSource(resolveStreamingMediaUrl(tiffUrl)),
opts,
)
} else {
next = await slideFromManifest(opts)
}
if (seq !== loadSeq) {
// A newer selection superseded this load; discard it.
next.dispose?.()
return
}
next.addEventListener('change', requestRender)
slide = next
ensureDrawing()
populateLevels(slide.manifest)
requestRender()
} catch (err) {
if (seq !== loadSeq) return
console.error(err)
showFallback(
(err instanceof Error ? err.message : String(err)) +
hintForSource(els.source.value),
)
}
}
async function main() {
makeDraggable(els.hud)
view = await createView()
els.backendSelect.value = view.kind === 'gpu' ? 'webgpu' : 'webgl2'
await loadSlide()
}
main().catch((err) => {
console.error(err)
showFallback(err instanceof Error ? err.message : String(err))
})