diff --git a/packages/core/src/camera.ts b/packages/core/src/camera.ts new file mode 100644 index 00000000..24fa1fda --- /dev/null +++ b/packages/core/src/camera.ts @@ -0,0 +1,21 @@ +import { Box2D, Vec2, type box2D, type vec2 } from '@alleninstitute/vis-geometry'; + +/** + * Zooms relative to the current mouse position + */ +export function zoom(view: box2D, screenSize: vec2, zoomScale: number, mousePos: vec2): box2D { + const zoomPoint: vec2 = Vec2.add(view.minCorner, Vec2.mul(Vec2.div(mousePos, screenSize), Box2D.size(view))); + return Box2D.translate( + Box2D.scale(Box2D.translate(view, Vec2.scale(zoomPoint, -1)), [zoomScale, zoomScale]), + zoomPoint, + ); +} + +/** + * Pans by a pixel delta in screen space + */ +export function pan(view: box2D, screenSize: vec2, delta: vec2): box2D { + const relative = Vec2.div(Vec2.mul(delta, [-1, -1]), screenSize); + const offset = Vec2.mul(relative, Box2D.size(view)); + return Box2D.translate(view, offset); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 016aba79..c3dda095 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,3 +20,4 @@ export type { export { RenderServer } from './abstract/render-server'; export { Logger, logger } from './logger'; +export { pan, zoom } from './camera'; diff --git a/packages/omezarr/src/index.ts b/packages/omezarr/src/index.ts index 854e3729..4d2fafc3 100644 --- a/packages/omezarr/src/index.ts +++ b/packages/omezarr/src/index.ts @@ -11,6 +11,7 @@ export { type VoxelTile, defaultDecoder, getVisibleTiles, + makeZarrSettings, } from './sliceview/loader'; export { buildTileRenderer, buildRGBTileRenderer } from './sliceview/tile-renderer'; export { diff --git a/packages/omezarr/src/sliceview/loader.ts b/packages/omezarr/src/sliceview/loader.ts index af05bc71..e661ad55 100644 --- a/packages/omezarr/src/sliceview/loader.ts +++ b/packages/omezarr/src/sliceview/loader.ts @@ -5,11 +5,13 @@ import { type box2D, type OrthogonalCartesianAxes, type vec2, + type Interval, + PLANE_XY, } from '@alleninstitute/vis-geometry'; import type { Chunk } from 'zarrita'; import type { ZarrRequest } from '../zarr/loading'; import { loadSlice, pickBestScale, planeSizeInVoxels, sizeInUnits } from '../zarr/loading'; -import type { VoxelTileImage } from './slice-renderer'; +import type { RenderSettings, RenderSettingsChannels, VoxelTileImage } from './slice-renderer'; import type { OmeZarrMetadata, OmeZarrShapedDataset } from '../zarr/types'; export type VoxelTile = { @@ -123,3 +125,36 @@ export const defaultDecoder = ( return { shape, data: new Float32Array(buffer.data) }; }); }; + +const defaultInterval: Interval = { min: 0, max: 80 }; + +export function makeZarrSettings( + omezarr: OmeZarrMetadata, + screenSize: vec2, + view: box2D, + plane: CartesianPlane, + orthoVal: number, +): RenderSettings { + const omezarrChannels = omezarr.colorChannels.reduce((acc, val, index) => { + acc[val.label ?? `${index}`] = { + rgb: val.rgb, + gamut: val.range, + index, + }; + return acc; + }, {} as RenderSettingsChannels); + + const fallbackChannels: RenderSettingsChannels = { + R: { rgb: [1.0, 0, 0], gamut: defaultInterval, index: 0 }, + G: { rgb: [0, 1.0, 0], gamut: defaultInterval, index: 1 }, + B: { rgb: [0, 0, 1.0], gamut: defaultInterval, index: 2 }, + }; + + return { + camera: { screenSize, view }, + plane, + orthoVal, + tileSize: 256, + channels: Object.keys(omezarrChannels).length > 0 ? omezarrChannels : fallbackChannels, + }; +} diff --git a/packages/web-components/package.json b/packages/web-components/package.json new file mode 100644 index 00000000..9be28e88 --- /dev/null +++ b/packages/web-components/package.json @@ -0,0 +1,39 @@ +{ + "name": "@alleninstitute/vis-web-components", + "version": "0.0.1", + "contributors": [ + { + "name": "Lane Sawyer", + "email": "lane.sawyer@alleninstitute.org" + } + ], + "license": "BSD-3-Clause", + "source": "src/index.ts", + "main": "dist/main.js", + "module": "dist/module.js", + "types": "dist/types.d.ts", + "files": ["dist"], + "scripts": { + "typecheck": "tsc --noEmit", + "build": "parcel build --no-cache", + "dev": "parcel watch --port 1239", + "test": "vitest --watch", + "test:ci": "vitest run", + "coverage": "vitest run --coverage" + }, + "repository": { + "type": "git", + "url": "https://github.com/AllenInstitute/vis.git" + }, + "publishConfig": { + "registry": "https://npm.pkg.github.com/AllenInstitute" + }, + "dependencies": { + "@alleninstitute/vis-core": "workspace:*", + "@alleninstitute/vis-geometry": "workspace:*", + "@alleninstitute/vis-omezarr": "workspace:*", + "regl": "2.1.1", + "@alleninstitute/vis-dzi": "workspace:*" + }, + "packageManager": "pnpm@9.14.2" +} diff --git a/packages/web-components/src/base-viewer.ts b/packages/web-components/src/base-viewer.ts new file mode 100644 index 00000000..03b70aca --- /dev/null +++ b/packages/web-components/src/base-viewer.ts @@ -0,0 +1,103 @@ +import { Logger, type RenderServer } from '@alleninstitute/vis-core'; +import { RENDER_SERVER_READY, RENDER_SERVER_TAG_NAME } from './render-server-provider'; + +export const REQUEST_RENDER_SERVER = 'request-render-server'; + +/** + * Base viewer class that provides common functionality for all viewers. + * It's primary job is requesting the RenderServer and setting up the canvas (including width and height). + * + * Concrete implementations should extend this class and implement the `onServerReady` method to + * start the rendering process. + */ +export abstract class BaseViewer extends HTMLElement { + protected canvas = document.createElement('canvas'); + protected renderServer: RenderServer | null = null; + // TODO: Change to warn once I'm done working on the viewer components + protected logger = new Logger(this.tagName, 'info'); + + constructor() { + super(); + + // make host a positioned block so shadow children size correctly + this.style.display = 'block'; + this.style.position = 'relative'; + this.logger.info(`Creating component`); + // build shadow DOM: canvas + plugin slot + const shadow = this.attachShadow({ mode: 'closed' }); + // render surface + this.canvas.style.position = 'relative'; + this.canvas.style.top = '0'; + this.canvas.style.left = '0'; + this.canvas.style.width = '100%'; + this.canvas.style.height = '100%'; + shadow.appendChild(this.canvas); + // plugin slot for overlays (SVG, annotations, controls, etc.) + const slot = document.createElement('slot'); + slot.name = 'plugin'; + shadow.appendChild(slot); + } + + private renderServerReadyListener(e: Event) { + this.renderServer = (e as CustomEvent).detail; + this.onServerReady(); + } + + static get observedAttributes() { + return ['width', 'height']; + } + + connectedCallback() { + this.logger.info('Connected'); + + if (!customElements.get(RENDER_SERVER_TAG_NAME)) { + this.logger.error('Render Server Provider does not exist. Please make sure to include it in the DOM'); + } + + this.updateSize(); + + this.addEventListener(RENDER_SERVER_READY, this.renderServerReadyListener, { once: true }); + this.dispatchEvent( + new CustomEvent(REQUEST_RENDER_SERVER, { + // Has to bubble so we can catch it in the RenderServerProvider + bubbles: true, + }), + ); + } + + disconnectedCallback() { + this.logger.info('Disconnected'); + + // Clean references (no need to remove event listener, it will be removed automatically due to `once`) + this.renderServer?.destroyClient(this.canvas); + this.renderServer = null; + this.canvas.remove(); + } + + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { + if (oldValue === newValue) { + return; + } + + if (name === 'width' || name === 'height') { + this.updateSize(); + } + } + + protected updateSize() { + const w = this.getAttribute('width') || '100'; + const h = this.getAttribute('height') || '100'; + this.canvas.width = parseInt(w, 10); + this.canvas.height = parseInt(h, 10); + this.canvas.style.width = `${this.canvas.width}px`; + this.canvas.style.height = `${this.canvas.height}px`; + // size host element to match canvas + this.style.width = `${this.canvas.width}px`; + this.style.height = `${this.canvas.height}px`; + } + + /** + * Used to set up the concrete implementation of a viewer once the RenderServer has been provided. + */ + protected abstract onServerReady(): void; +} diff --git a/packages/web-components/src/camera-sync.ts b/packages/web-components/src/camera-sync.ts new file mode 100644 index 00000000..0354fb9f --- /dev/null +++ b/packages/web-components/src/camera-sync.ts @@ -0,0 +1,51 @@ +import type { DziViewer } from './dzi'; + +/** + * CameraSync pairs sibling viewers by listening for one viewer's `camera-change` event + * and applying its camera settings to all other viewers under this element. + * + * TODO: Consider more than just the DZI and 2D use cases + */ +export class CameraSync extends HTMLElement { + connectedCallback() { + this.addEventListener('camera-change', this.handleCameraChange); + } + + disconnectedCallback() { + this.removeEventListener('camera-change', this.handleCameraChange); + } + + private handleCameraChange(event: Event) { + const custom = event as CustomEvent & { + detail: { view: any; screenSize?: [number, number]; __sync?: boolean }; + }; + // ignore events originating from sync to prevent loops + if (custom.detail.__sync) { + return; + } + custom.stopPropagation(); + const camera = custom.detail; + const source = event.target as HTMLElement; + // Apply camera settings to all sibling DziViewers + this.querySelectorAll('dzi-viewer').forEach((v) => { + if (v !== source) { + // Apply settings without re-emitting core event + const target = v as DziViewer; + target.setRenderSettings?.({ camera }, false); + // dispatch sync camera-change for plugins + const syncDetail = { ...camera, __sync: true }; + target.dispatchEvent( + new CustomEvent('camera-change', { + detail: syncDetail, + bubbles: true, + composed: true, + }), + ); + } + }); + } +} + +if (!customElements.get('camera-sync')) { + customElements.define('camera-sync', CameraSync); +} diff --git a/packages/web-components/src/dzi.ts b/packages/web-components/src/dzi.ts new file mode 100644 index 00000000..f4cb591f --- /dev/null +++ b/packages/web-components/src/dzi.ts @@ -0,0 +1,194 @@ +import type { DziImage, DziRenderSettings } from '@alleninstitute/vis-dzi'; +import { buildAsyncDziRenderer, fetchDziMetadata } from '@alleninstitute/vis-dzi'; +import { type RenderFrameFn } from '@alleninstitute/vis-core'; +import { BaseViewer } from './base-viewer'; +import { zoom, pan } from '@alleninstitute/vis-core'; +import { Box2D } from '@alleninstitute/vis-geometry'; + +/** + * DziViewer is a custom web component for rendering DZI (Deep Zoom Image) files. + */ +export class DziViewer extends BaseViewer { + private renderer: ReturnType | null = null; + private dziImage: DziImage | null = null; + private settings: DziRenderSettings | null = null; + + // camera state for built-in pan/zoom + private view = Box2D.create([0, 0], [1, 1]); + private screenSize: [number, number] = [100, 100]; + private dragging = false; + private lastPos: [number, number] = [0, 0]; + private static readonly ZOOM_STEP = 0.1; + private static readonly ZOOM_IN = 1 / (1 - DziViewer.ZOOM_STEP); + private static readonly ZOOM_OUT = 1 - DziViewer.ZOOM_STEP; + + static get observedAttributes() { + return super.observedAttributes.concat(['url']); + } + + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { + super.attributeChangedCallback(name, oldValue, newValue); + + if (oldValue === newValue) { + return; + } + + if (name === 'url') { + this.logger.info(`URL changed from ${oldValue} to ${newValue}`); + this.loadData(); + } + } + + public setRenderSettings(settings: DziRenderSettings, emitEvent: boolean = true) { + this.settings = settings; + this.beginRendering(); + // emit camera-change so sync wrappers can listen + if (emitEvent) { + this.dispatchEvent( + new CustomEvent('camera-change', { + detail: settings.camera, + bubbles: true, + composed: true, + }), + ); + } + } + + protected onServerReady() { + if (!this.renderServer) { + this.logger.error('Render server is not ready, but onServerReady was called'); + return; + } + this.renderer = buildAsyncDziRenderer(this.renderServer.regl); + } + + private async loadData() { + const url = this.getAttribute('url'); + if (!url) { + this.logger.error('loadData failed: No URL provided for DZI metadata'); + return; + } + + this.logger.info(`Loading DZI metadata for ${url}`); + + const data = await fetchDziMetadata(url); + if (!data) { + this.logger.error(`Failed to load DZI metadata from ${url}`); + return; + } + + this.logger.info(`Successfully loaded DZI metadata from ${url}`); + this.dziImage = data; + + // adjust display height to maintain image aspect ratio + // preserve width attribute, recompute height based on metadata + if (this.dziImage) { + const widthPx = Number(this.getAttribute('width')) || this.canvas.width; + const ratio = this.dziImage.size.height / this.dziImage.size.width; + const newHeight = Math.round(widthPx * ratio); + // setting attribute will trigger updateSize() + this.setAttribute('height', newHeight.toString()); + } + + this.beginRendering(); + } + + private beginRendering() { + if (!this.renderServer || !this.renderer || !this.dziImage || !this.settings) { + this.logger.info('Tried to render, but missing required data'); + return; + } + const renderFrame: RenderFrameFn = (target, cache, callback) => { + if (!this.renderer || !this.dziImage || !this.settings) { + this.logger.error('Renderer, DziImage, or settings are not set.'); + return null; + } + return this.renderer(this.dziImage, this.settings, callback, target, cache); + }; + // renderServer handles scheduling and composition + this.renderServer.beginRendering( + renderFrame, + (e) => { + switch (e.status) { + case 'begin': { + this.logger.info('Rendering started'); + this.renderServer?.regl?.clear({ + framebuffer: e.target, + color: [0, 0, 0, 0], + depth: 1, + }); + break; + } + case 'progress': { + this.logger.info('Rendering progress'); + e.server.copyToClient((ctx, image) => { + ctx.putImageData(image, 0, 0); + }); + break; + } + case 'finished': { + this.logger.info('Rendering finished'); + e.server.copyToClient((ctx, image) => { + ctx.putImageData(image, 0, 0); + }); + break; + } + case 'cancelled': { + this.logger.info('Rendering cancelled'); + break; + } + default: { + this.logger.warn(`Unknown render status: ${e.status}`); + } + } + }, + this.canvas, + ); + } + + connectedCallback() { + super.connectedCallback(); + // initialize camera with actual canvas size + this.screenSize = [this.canvas.width, this.canvas.height]; + // set initial settings + this.setRenderSettings({ camera: { view: this.view, screenSize: this.screenSize } }); + // wire pan/zoom on the canvas + this.canvas.addEventListener('wheel', this.handleWheel); + this.canvas.addEventListener('mousedown', this.handleMouseDown); + this.canvas.addEventListener('mouseup', this.handleMouseUp); + this.canvas.addEventListener('mousemove', this.handleMouseMove); + } + + private handleWheel = (e: WheelEvent) => { + e.preventDefault(); + const factor = e.deltaY > 0 ? DziViewer.ZOOM_IN : DziViewer.ZOOM_OUT; + this.view = zoom(this.view, this.screenSize, factor, [e.offsetX, e.offsetY]); + this.setRenderSettings({ camera: { view: this.view, screenSize: this.screenSize } }); + }; + + private handleMouseDown = (e: MouseEvent) => { + this.dragging = true; + this.lastPos = [e.offsetX, e.offsetY]; + }; + + private handleMouseUp = () => { + this.dragging = false; + }; + + private handleMouseMove = (e: MouseEvent) => { + if (!this.dragging) { + return; + } + + const dx = e.offsetX - this.lastPos[0]; + const dy = e.offsetY - this.lastPos[1]; + this.lastPos = [e.offsetX, e.offsetY]; + this.view = pan(this.view, this.screenSize, [dx, dy]); + this.setRenderSettings({ camera: { view: this.view, screenSize: this.screenSize } }); + }; +} + +// Define the custom element if not already defined +if (!customElements.get('dzi-viewer')) { + customElements.define('dzi-viewer', DziViewer); +} diff --git a/packages/web-components/src/index.ts b/packages/web-components/src/index.ts new file mode 100644 index 00000000..71599ceb --- /dev/null +++ b/packages/web-components/src/index.ts @@ -0,0 +1,13 @@ +// TODO: Better import setup + +// RenderServerProvider must be first, as it needs to run before any other components that depend on it +// for the events to work correctly. +export { RenderServerProvider } from './render-server-provider'; + +// Viewers +export { OmeZarrViewer } from './ome-zarr'; +export { DziViewer } from './dzi'; + +// Utils +export { CameraSync } from './camera-sync'; +export { SvgRenderer } from './svg-renderer'; diff --git a/packages/web-components/src/ome-zarr.ts b/packages/web-components/src/ome-zarr.ts new file mode 100644 index 00000000..187c891a --- /dev/null +++ b/packages/web-components/src/ome-zarr.ts @@ -0,0 +1,245 @@ +import { pan, zoom, type RenderFrameFn, type WebResource } from '@alleninstitute/vis-core'; +import { + type OmeZarrMetadata, + type RenderSettings, + type VoxelTile, + buildAsyncOmezarrRenderer, + defaultDecoder, + loadMetadata, + makeZarrSettings, + sizeInUnits, +} from '@alleninstitute/vis-omezarr'; +import { BaseViewer } from './base-viewer'; +import { Box2D, PLANE_XY, type vec2 } from '@alleninstitute/vis-geometry'; + +const URL_REGEX = /^(s3|https):\/\/.*/; + +export class OmeZarrViewer extends BaseViewer { + private renderer: ReturnType | null = null; + private omeZarrMetadata: OmeZarrMetadata | null = null; + private settings: RenderSettings | null = null; + + // camera state for built-in pan/zoom + private view = Box2D.create([0, 0], [1, 1]); + private screenSize: [number, number] = [100, 100]; + private dragging = false; + private lastPos: [number, number] = [0, 0]; + private static readonly ZOOM_STEP = 0.1; + private static readonly ZOOM_IN = 1 / (1 - OmeZarrViewer.ZOOM_STEP); + private static readonly ZOOM_OUT = 1 - OmeZarrViewer.ZOOM_STEP; + + constructor() { + super(); + } + + static get observedAttributes() { + return super.observedAttributes.concat(['id', 'url']); + } + + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { + super.attributeChangedCallback(name, oldValue, newValue); + + if (oldValue === newValue) { + return; + } + + if (name === 'url') { + this.loadData(); + } + } + + public setRenderSettings(settings: RenderSettings) { + this.settings = settings; + this.beginRendering(); + } + + // TODO: Take loading out of this, maybe call it in onServerReady? + protected async onServerReady() { + this.logger.info('Render server is ready'); + if (!this.renderServer) { + this.logger.error('Render server is not ready, but onServerReady was called'); + return; + } + + // If we have the settings, we can start rendering immediately, otherwise it's on the dev + // to call setSettings with the appropriate parameters later. + if (this.settings) { + this.beginRendering(); + } + } + + private async loadData() { + this.logger.info('Loading data'); + const url = this.getAttribute('url'); + + if (!url) { + this.logger.error('No URL provided.'); + return; + } + + const urlToWebResource = (url: string, region = 'us-west-2'): WebResource | undefined => { + if (!URL_REGEX.test(url)) { + this.logger.error('cannot load resource: invalid URL'); + return; + } + const isS3 = url.slice(0, 5) === 's3://'; + const resource: WebResource = isS3 ? { type: 's3', url, region } : { type: 'https', url }; + + return resource; + }; + const webResource = urlToWebResource(url); + + if (!webResource) { + this.logger.error('Invalid URL provided.'); + return; + } + + this.logger.info('Loading metadata from URL:', url); + + const metadata = await loadMetadata(webResource); + + this.logger.info('Metadata loaded:', metadata); + this.omeZarrMetadata = metadata; + + const dataset = metadata.getFirstShapedDataset(0); + if (!dataset) { + throw new Error('Dataset 0 does not exist!'); + } + const size = sizeInUnits(PLANE_XY, metadata.attrs.multiscales[0].axes, dataset); + if (size) { + const aspectRatio = this.screenSize[0] / this.screenSize[1]; + const adjustedSize: vec2 = [size[0], size[0] / aspectRatio]; + this.view = Box2D.create([0, 0], adjustedSize); + } + + if (!this.renderServer) { + this.logger.error('No render server set.'); + return; + } + const numChannels = this.omeZarrMetadata.colorChannels.length || 3; + this.renderer = buildAsyncOmezarrRenderer(this.renderServer.regl, defaultDecoder, { + numChannels, + queueOptions: { maximumInflightAsyncTasks: 2 }, + }); + const newSettings = makeZarrSettings(this.omeZarrMetadata, this.screenSize, this.view, PLANE_XY, 0); + this.setRenderSettings(newSettings); + this.logger.info('Renderer created'); + } + + private compose(ctx: CanvasRenderingContext2D, image: ImageData) { + ctx.putImageData(image, 0, 0); + } + + private beginRendering() { + this.logger.info('OmeZarrViewer: Beginning rendering'); + const renderFrame: RenderFrameFn = (target, cache, callback) => { + this.logger.info('Render frame called!'); + if (this.renderer && this.omeZarrMetadata && this.settings) { + // if we had a stashed buffer of the previous frame... + // we could pre-load it into target, right here! + this.logger.info('Rendering with renderer'); + return this.renderer(this.omeZarrMetadata, this.settings, callback, target, cache); + } + return null; + }; + + this.renderServer?.beginRendering( + renderFrame, + (e) => { + switch (e.status) { + case 'begin': { + this.logger.info('begin rendering'); + this.renderServer?.regl?.clear({ + framebuffer: e.target, + color: [0, 0, 0, 0], + depth: 1, + }); + // lowResPreview(e.target, server.cache, (_e) => {})?.cancelFrame( + // 'lowres preview beneath actual frame', + // ); + // if (imgRenderer.current && stash.current) { + // imgRenderer.current({ + // box: Box2D.toFlatArray(stash.current.camera.view), + // img: stash.current.image, + // depth: 1, + // target: e.target, + // view: Box2D.toFlatArray(settings.camera.view), + // }); + // e.server.copyToClient(compose); + // } + break; + } + case 'progress': { + this.logger.info('progress rendering'); + e.server.copyToClient(this.compose); + // if (e.target !== null && server) { + // stashProgress(server, e.target); + // } + break; + } + case 'finished': { + this.logger.info('finished rendering'); + e.server.copyToClient(this.compose); + // // stash our nice image... do this all the time? + // if (e.target !== null && server) { + // stashProgress(server, e.target); + // } + break; + } + case 'cancelled': { + this.logger.info('cancelled rendering'); + break; + } + } + }, + this.canvas, + ); + } + + connectedCallback() { + super.connectedCallback(); + this.screenSize = [this.canvas.width, this.canvas.height]; + + // wire pan/zoom on the canvas + this.canvas.addEventListener('wheel', this.handleWheel); + this.canvas.addEventListener('mousedown', this.handleMouseDown); + this.canvas.addEventListener('mouseup', this.handleMouseUp); + this.canvas.addEventListener('mousemove', this.handleMouseMove); + } + + private handleWheel = (e: WheelEvent) => { + if (!this.omeZarrMetadata) { + return; + } + e.preventDefault(); + const scale = e.deltaY > 0 ? OmeZarrViewer.ZOOM_IN : OmeZarrViewer.ZOOM_OUT; + this.view = zoom(this.view, this.screenSize, scale, [e.offsetX, e.offsetY]); + const newSettings = makeZarrSettings(this.omeZarrMetadata, this.screenSize, this.view, PLANE_XY, 0); + this.setRenderSettings(newSettings); + }; + + private handleMouseDown = (e: MouseEvent) => { + this.dragging = true; + this.lastPos = [e.offsetX, e.offsetY]; + }; + + private handleMouseUp = () => { + this.dragging = false; + }; + + private handleMouseMove = (e: MouseEvent) => { + if (!this.dragging || !this.omeZarrMetadata) { + return; + } + const dx = e.offsetX - this.lastPos[0]; + const dy = e.offsetY - this.lastPos[1]; + this.lastPos = [e.offsetX, e.offsetY]; + this.view = pan(this.view, this.screenSize, [dx, dy]); + const newSettings = makeZarrSettings(this.omeZarrMetadata, this.screenSize, this.view, PLANE_XY, 0); + this.setRenderSettings(newSettings); + }; +} + +if (!customElements.get('ome-zarr-viewer')) { + customElements.define('ome-zarr-viewer', OmeZarrViewer); +} diff --git a/packages/web-components/src/render-server-provider.ts b/packages/web-components/src/render-server-provider.ts new file mode 100644 index 00000000..024522e0 --- /dev/null +++ b/packages/web-components/src/render-server-provider.ts @@ -0,0 +1,50 @@ +import { Logger, RenderServer } from '@alleninstitute/vis-core'; +import { REQUEST_RENDER_SERVER } from './base-viewer'; + +export const RENDER_SERVER_TAG_NAME = 'render-server-provider'; +export const RENDER_SERVER_READY = 'render-server-ready'; + +export const DEFAULT_MAX_SIZE = 4096; + +export class RenderServerProvider extends HTMLElement { + private renderServer: RenderServer; + // TODO: Change to warn once I'm done working on the viewer components + private logger = new Logger(RENDER_SERVER_TAG_NAME, 'info'); + + constructor() { + super(); + this.logger.info('Created'); + + const oes_texture_float = this.getAttribute('oes_texture_float'); + const extensions = oes_texture_float ? ['oes_texture_float'] : []; + + this.logger.info(`Using extensions: ${extensions.join(', ')}`); + + this.renderServer = new RenderServer([DEFAULT_MAX_SIZE, DEFAULT_MAX_SIZE], extensions); + } + + static get observedAttributes() { + return ['oes_texture_float']; + } + + connectedCallback() { + this.logger.info('Connected'); + // Listen for requests from child components + this.addEventListener(REQUEST_RENDER_SERVER, (event: Event) => { + event.stopPropagation(); + this.logger.info('Received request for render server'); + const targetEl = event.target as HTMLElement; + const responseEvent = new CustomEvent(RENDER_SERVER_READY, { + detail: this.renderServer, + bubbles: true, + composed: true, + }); + targetEl.dispatchEvent(responseEvent); + }); + } +} + +// Define the custom element if not already defined +if (!customElements.get(RENDER_SERVER_TAG_NAME)) { + customElements.define(RENDER_SERVER_TAG_NAME, RenderServerProvider); +} diff --git a/packages/web-components/src/svg-renderer.ts b/packages/web-components/src/svg-renderer.ts new file mode 100644 index 00000000..ce8db655 --- /dev/null +++ b/packages/web-components/src/svg-renderer.ts @@ -0,0 +1,94 @@ +import { Logger } from '@alleninstitute/vis-core'; +import { Box2D } from '@alleninstitute/vis-geometry'; + +/** + * SVGRenderer is a plugin for BaseViewer: drop it into a viewer with slot="plugin" and it will + * fetch and overlay an external SVG URL atop the viewer canvas. + */ +export class SvgRenderer extends HTMLElement { + private overlaySvg: SVGSVGElement | null = null; + private intrinsicSize: [number, number] = [0, 0]; + private logger = new Logger('SvgRenderer', 'info'); + + static get observedAttributes() { + return ['src']; + } + + constructor() { + super(); + // Slot this element into the viewer's plugin slot + this.setAttribute('slot', 'plugin'); + } + + connectedCallback() { + // listen for camera-change on the viewer host (direct parent) + const host = this.parentElement as HTMLElement | null; + host?.addEventListener('camera-change', this.onCameraChange as EventListener); + // load initial overlay + const src = this.getAttribute('src'); + if (src) this.loadSvg(src); + } + + disconnectedCallback() { + const host = this.parentElement as HTMLElement | null; + host?.removeEventListener('camera-change', this.onCameraChange as EventListener); + } + + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { + if (name === 'src' && newValue && newValue !== oldValue) { + this.loadSvg(newValue); + } + } + + private onCameraChange = (evt: Event) => { + const { view } = (evt as CustomEvent<{ view: any }>).detail; + if (!this.overlaySvg) return; + const [w0, h0] = this.intrinsicSize; + // get fractional view size + const [vw, vh] = Box2D.size(view); + // compute pixel coords in the intrinsic SVG space + const x = view.minCorner[0] * w0; + const y = view.minCorner[1] * h0; + const w = vw * w0; + const h = vh * h0; + this.overlaySvg.setAttribute('viewBox', `${x} ${y} ${w} ${h}`); + }; + + private async loadSvg(url: string) { + // use an overlay so it fits the viewer bounds + try { + const text = await (await fetch(url)).text(); + const doc = new DOMParser().parseFromString(text, 'image/svg+xml'); + const svg = doc.documentElement as unknown as SVGSVGElement; + // determine intrinsic coordinate space: from viewBox or width/height + const vb = svg.getAttribute('viewBox'); + if (vb) { + const [, , w0, h0] = vb.split(/[ ,]+/).map(Number); + this.intrinsicSize = [w0, h0]; + } else { + const w0 = parseFloat(svg.getAttribute('width') || '0'); + const h0 = parseFloat(svg.getAttribute('height') || '0'); + this.intrinsicSize = [w0, h0]; + svg.setAttribute('viewBox', `0 0 ${w0} ${h0}`); + } + svg.setAttribute('preserveAspectRatio', 'none'); + // style overlay to fill host + svg.style.position = 'absolute'; + svg.style.top = '0'; + svg.style.left = '0'; + svg.style.width = '100%'; + svg.style.height = '100%'; + svg.style.pointerEvents = 'none'; + if (this.overlaySvg) this.removeChild(this.overlaySvg); + this.overlaySvg = svg; + this.appendChild(svg); + } catch (err) { + console.error('SvgRenderer failed to load overlay', err); + } + } +} + +// register custom element +if (!customElements.get('svg-renderer')) { + customElements.define('svg-renderer', SvgRenderer); +} diff --git a/packages/web-components/tsconfig.json b/packages/web-components/tsconfig.json new file mode 100644 index 00000000..baaee9ee --- /dev/null +++ b/packages/web-components/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "paths": { + "~/*": ["./*"] + }, + "moduleResolution": "Bundler", + "module": "ES2022", + "target": "ES2022", + "lib": ["es2022", "DOM"] + }, + "include": ["./src/index.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8f06d4e..12249a4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,24 @@ importers: specifier: 3.25.50 version: 3.25.50 + packages/web-components: + dependencies: + '@alleninstitute/vis-core': + specifier: workspace:* + version: link:../core + '@alleninstitute/vis-dzi': + specifier: workspace:* + version: link:../dzi + '@alleninstitute/vis-geometry': + specifier: workspace:* + version: link:../geometry + '@alleninstitute/vis-omezarr': + specifier: workspace:* + version: link:../omezarr + regl: + specifier: 2.1.1 + version: 2.1.1 + site: dependencies: '@alleninstitute/vis-core': @@ -98,6 +116,9 @@ importers: '@alleninstitute/vis-omezarr': specifier: workspace:* version: link:../packages/omezarr + '@alleninstitute/vis-web-components': + specifier: workspace:* + version: link:../packages/web-components '@astrojs/check': specifier: 0.9.4 version: 0.9.4(typescript@5.8.3) diff --git a/site/package.json b/site/package.json index 8ab39355..590a9155 100644 --- a/site/package.json +++ b/site/package.json @@ -48,6 +48,7 @@ "@alleninstitute/vis-dzi": "workspace:*", "@alleninstitute/vis-geometry": "workspace:*", "@alleninstitute/vis-omezarr": "workspace:*", + "@alleninstitute/vis-web-components": "workspace:*", "@astrojs/check": "0.9.4", "@astrojs/mdx": "4.3.0", "@astrojs/react": "4.3.0", diff --git a/site/src/content/docs/examples/web-components.mdx b/site/src/content/docs/examples/web-components.mdx new file mode 100644 index 00000000..279fe043 --- /dev/null +++ b/site/src/content/docs/examples/web-components.mdx @@ -0,0 +1,15 @@ +--- +title: Web Components +tableOfContents: false +--- + +import WebComponents from '../../../examples/web-components.astro'; +import DziExample from '../../../examples/dzi/dzi-web-component.astro'; +import OmeZarrExample from '../../../examples/omezarr/ome-zarr-web-component.astro'; + + + + + + + diff --git a/site/src/examples/dzi/dzi-web-component.astro b/site/src/examples/dzi/dzi-web-component.astro new file mode 100644 index 00000000..195bade8 --- /dev/null +++ b/site/src/examples/dzi/dzi-web-component.astro @@ -0,0 +1,19 @@ + +
+ + + + + + +
+
diff --git a/site/src/examples/omezarr/ome-zarr-web-component.astro b/site/src/examples/omezarr/ome-zarr-web-component.astro new file mode 100644 index 00000000..ee2058cf --- /dev/null +++ b/site/src/examples/omezarr/ome-zarr-web-component.astro @@ -0,0 +1,6 @@ + diff --git a/site/src/examples/omezarr/omezarr-demo.tsx b/site/src/examples/omezarr/omezarr-demo.tsx index 1733a9a7..0fa11d07 100644 --- a/site/src/examples/omezarr/omezarr-demo.tsx +++ b/site/src/examples/omezarr/omezarr-demo.tsx @@ -8,6 +8,8 @@ import { pan, zoom } from '../common/camera'; import { RenderServerProvider } from '../common/react/render-server-provider'; import { OmezarrViewer } from './omezarr-viewer'; import { SliceView } from './sliceview'; +import { makeZarrSettings } from './utils'; + type DemoOption = { value: string; label: string; res: WebResource }; const demoOptions: DemoOption[] = [ @@ -45,34 +47,8 @@ const demoOptions: DemoOption[] = [ }, ]; -const screenSize: vec2 = [800, 800]; - -const defaultInterval: Interval = { min: 0, max: 80 }; - -function makeZarrSettings(screenSize: vec2, view: box2D, orthoVal: number, omezarr: OmeZarrMetadata): RenderSettings { - const omezarrChannels = omezarr.colorChannels.reduce((acc, val, index) => { - acc[val.label ?? `${index}`] = { - rgb: val.rgb, - gamut: val.range, - index, - }; - return acc; - }, {} as RenderSettingsChannels); - - const fallbackChannels: RenderSettingsChannels = { - R: { rgb: [1.0, 0, 0], gamut: defaultInterval, index: 0 }, - G: { rgb: [0, 1.0, 0], gamut: defaultInterval, index: 1 }, - B: { rgb: [0, 0, 1.0], gamut: defaultInterval, index: 2 }, - }; - - return { - camera: { screenSize, view }, - orthoVal, - plane: PLANE_XY, - tileSize: 256, - channels: Object.keys(omezarrChannels).length > 0 ? omezarrChannels : fallbackChannels, - }; -} +const screenSize: vec2 = [500, 500]; +const zoomStep = 0.1; export function OmezarrDemo() { const [customUrl, setCustomUrl] = useState(''); @@ -99,8 +75,10 @@ export function OmezarrDemo() { } const size = sizeInUnits(PLANE_XY, v.attrs.multiscales[0].axes, dataset); if (size) { - logger.info('size', size); - setView(Box2D.create([0, 0], size)); + logger.info('dataset size', size); + const aspectRatio = screenSize[0] / screenSize[1]; + const adjustedSize: vec2 = [size[0], size[0] / aspectRatio]; + setView(Box2D.create([0, 0], adjustedSize)); } }); }; @@ -135,10 +113,11 @@ export function OmezarrDemo() { setPlaneIndex((prev) => Math.max(0, Math.min(prev + next, (omezarr?.maxOrthogonal(PLANE_XY) ?? 1) - 1))); }; - const handleZoom = (e: WheelEvent) => { - e.preventDefault(); - const zoomScale = e.deltaY > 0 ? 1.1 : 0.9; - const v = zoom(view, screenSize, zoomScale, [e.offsetX, e.offsetY]); + const handleZoom = (e: React.WheelEvent) => { + const zoomInFactor = 1 / (1 - zoomStep); + const zoomOutFactor = 1 - zoomStep; + const zoomScale = e.deltaY > 0 ? zoomInFactor : zoomOutFactor; + const v = zoom(view, screenSize, zoomScale, [e.nativeEvent.offsetX, e.nativeEvent.offsetY]); setView(v); }; @@ -213,7 +192,6 @@ export function OmezarrDemo() { void; onMouseDown?: (e: React.MouseEvent) => void; diff --git a/site/src/examples/omezarr/utils.ts b/site/src/examples/omezarr/utils.ts new file mode 100644 index 00000000..123ed914 --- /dev/null +++ b/site/src/examples/omezarr/utils.ts @@ -0,0 +1,34 @@ +import { type vec2, type box2D, PLANE_XY, type Interval } from '@alleninstitute/vis-geometry'; +import type { OmeZarrMetadata, RenderSettings, RenderSettingsChannels } from '@alleninstitute/vis-omezarr'; + +const defaultInterval: Interval = { min: 0, max: 80 }; + +export function makeZarrSettings( + screenSize: vec2, + view: box2D, + orthoVal: number, + omezarr: OmeZarrMetadata, +): RenderSettings { + const omezarrChannels = omezarr.colorChannels.reduce((acc, val, index) => { + acc[val.label ?? `${index}`] = { + rgb: val.rgb, + gamut: val.range, + index, + }; + return acc; + }, {} as RenderSettingsChannels); + + const fallbackChannels: RenderSettingsChannels = { + R: { rgb: [1.0, 0, 0], gamut: defaultInterval, index: 0 }, + G: { rgb: [0, 1.0, 0], gamut: defaultInterval, index: 1 }, + B: { rgb: [0, 0, 1.0], gamut: defaultInterval, index: 2 }, + }; + + return { + camera: { screenSize, view }, + orthoVal, + plane: PLANE_XY, + tileSize: 256, + channels: Object.keys(omezarrChannels).length > 0 ? omezarrChannels : fallbackChannels, + }; +} diff --git a/site/src/examples/web-components.astro b/site/src/examples/web-components.astro new file mode 100644 index 00000000..7a9e9a83 --- /dev/null +++ b/site/src/examples/web-components.astro @@ -0,0 +1,4 @@ + +