diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fb7d895..fc2578e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,33 +19,34 @@ Set the `upstream` remote to the base `vizarr` repository: git remote add upstream https://github.com/hms-dbmi/vizarr.git ``` -Install the dependencies to develop and build `vizarr` via `npm`. +Install the dependencies to develop and build `vizarr` via `pnpm`. ```bash -npm install +pnpm install ``` -> Note: You need to have [Node.js](https://nodejs.org/en/) (v15.0 or later) to build -> and develop `vizarr`. I recommend using [`nvm`](https://github.com/nvm-sh/nvm) or +> Note: You need to have [Node.js](https://nodejs.org/en/) (v20.0 or later) to build +> and develop `vizarr`. I recommend using [`nvm`](https://github.com/nvm-sh/nvm) or > [`fnm`](https://github.com/Schniz/fnm) to manage different version of Node.js > on your machine. ### Running the development server ```bash -npm start +pnpm dev ``` -The `start` command will start a development server on `http://localhost:8080` which you can navigate -to in your web browser. You can "live" edit the contents of any of the files within `src/` or `public/` -when running this server; changes are reflected instantly in the browser. Stop the development -server when you are done making changes. +The `dev` command will start a development server on `http://localhost:5173` +which you can navigate to in your web browser. You can "live" edit the contents +of any of the files within `src/` or `public/` when running this server; +changes are reflected instantly in the browser. Stop the development server +when you are done making changes. - `src/` - contains all TypeScript source code - `public/` - contains all static assets required for the site Have a look at the other `script` commands in `package.json` for the project. These are standard to any JS -build and can be executed by running `npm run `. +build and can be executed by running `pnpm `. ### Making changes @@ -65,9 +66,9 @@ Update your remote branch: git push -u origin your-feature-branch-name ``` -You can then make a pull-request to `vizarr`'s `main` branch. When making a pull-request, -your code will be checked for linting with `prettier`. Please run `npm run format` -to automatically format your code when making a pull-request. +You can then make a pull-request to `vizarr`'s `main` branch. When making a +pull-request, your code will be checked for linting with `biome`. Please run +`pnpm fix` to automatically format your code when making a pull-request. ### (Note to self) Building and publishing `vizarr` @@ -75,7 +76,7 @@ to automatically format your code when making a pull-request. Build a production version of the site: ```bash -npm version [ | major | minor | patch] -npm publish +pnpm version [ | major | minor | patch] +pnpm publish ``` diff --git a/biome.json b/biome.json index 296bbd2..ea70492 100644 --- a/biome.json +++ b/biome.json @@ -11,7 +11,7 @@ "lineWidth": 120 }, "linter": { - "enabled": false, + "enabled": true, "rules": { "recommended": true, "style": { diff --git a/main.ts b/main.ts index 4c553a0..b35da4a 100644 --- a/main.ts +++ b/main.ts @@ -1,7 +1,10 @@ import debounce from "just-debounce-it"; import * as vizarr from "./src/index"; -function initStandaloneApp(viewer: vizarr.VizarrViewer) { +async function main() { + console.log(`vizarr v${vizarr.version}: https://github.com/hms-dbmi/vizarr`); + // biome-ignore lint/style/noNonNullAssertion: We know the element exists + const viewer = await vizarr.createViewer(document.querySelector("#root")!); const url = new URL(window.location.href); if (!url.searchParams.has("source")) { @@ -9,8 +12,9 @@ function initStandaloneApp(viewer: vizarr.VizarrViewer) { } // see if we have initial viewState - if (url.searchParams.has("viewState")) { - const viewState = JSON.parse(url.searchParams.get("viewState")!); + const viewStateString = url.searchParams.get("viewState"); + if (viewStateString) { + const viewState = JSON.parse(viewStateString); viewer.setViewState(viewState); } @@ -26,9 +30,11 @@ function initStandaloneApp(viewer: vizarr.VizarrViewer) { ); // parse image config - const config: any = {}; + // @ts-expect-error - TODO: validate config + const config: vizarr.ImageLayerConfig = {}; for (const [key, value] of url.searchParams) { + // @ts-expect-error - TODO: validate config config[key] = value; } @@ -43,10 +49,4 @@ function initStandaloneApp(viewer: vizarr.VizarrViewer) { } } -async function main() { - console.log(`vizarr v${vizarr.version}: https://github.com/hms-dbmi/vizarr`); - const viewer = await vizarr.createViewer(document.querySelector("#root")!); - initStandaloneApp(viewer); -} - main(); diff --git a/src/components/LayerController/AxisSlider.tsx b/src/components/LayerController/AxisSlider.tsx index a73600c..9eeea3f 100644 --- a/src/components/LayerController/AxisSlider.tsx +++ b/src/components/LayerController/AxisSlider.tsx @@ -2,8 +2,8 @@ import { Divider, Grid, Typography } from "@material-ui/core"; import { Slider } from "@material-ui/core"; import { withStyles } from "@material-ui/styles"; import { useAtom, useAtomValue } from "jotai"; +import * as React from "react"; import type { ChangeEvent } from "react"; -import React, { useState, useEffect } from "react"; import type { ControllerProps } from "../../state"; import DimensionOptions from "./AxisOptions"; @@ -32,13 +32,13 @@ function AxisSlider({ sourceAtom, layerAtom, axisIndex, max }: ControllerProps

{ + React.useEffect(() => { // Use first channel to get initial value of slider - can be undefined on first render setValue(layer.layerProps.selections[0] ? layer.layerProps.selections[0][axisIndex] : 1); - }, [layer.layerProps.selections]); + }, [layer.layerProps.selections, axisIndex]); const handleRelease = () => { setLayer((prev) => { diff --git a/src/components/Viewer.tsx b/src/components/Viewer.tsx index 93cb5eb..f2fef3f 100644 --- a/src/components/Viewer.tsx +++ b/src/components/Viewer.tsx @@ -4,11 +4,16 @@ import { type WritableAtom, useAtom } from "jotai"; import { useAtomValue } from "jotai"; import * as React from "react"; -import type { LayerState, ViewState } from "../state"; +import type { LayerProps } from "@deck.gl/core/lib/layer"; +import type { ZarrPixelSource } from "../ZarrPixelSource"; +import type { ViewState } from "../state"; import { layerAtoms } from "../state"; import { fitBounds, isInterleaved } from "../utils"; -function getLayerSize(props: LayerState["layerProps"]) { +type Data = { loader: ZarrPixelSource; rows: number; columns: number }; +type VizarrLayer = Layer & Data>; + +function getLayerSize(props: Data) { const { loader } = props; const [base, maxZoom] = Array.isArray(loader) ? [loader[0], loader.length] : [loader, 0]; const interleaved = isInterleaved(base.shape); @@ -24,17 +29,18 @@ function getLayerSize(props: LayerState["layerProps"]) { } function WrappedViewStateDeck(props: { - layers: Layer[]; + layers: Array; viewStateAtom: WritableAtom; }) { const [viewState, setViewState] = useAtom(props.viewStateAtom); const deckRef = React.useRef(null); + const firstLayerProps = props.layers[0]?.props; // If viewState hasn't been updated, use the first loader to guess viewState // TODO: There is probably a better place / way to set the intital view and this is a hack. - if (deckRef.current && !viewState && props.layers[0]?.props?.loader) { + if (deckRef.current && !viewState && firstLayerProps?.loader) { const { deck } = deckRef.current; - const { width, height, maxZoom } = getLayerSize(props.layers[0].props); + const { width, height, maxZoom } = getLayerSize(firstLayerProps); const padding = deck.width < 400 ? 10 : deck.width < 600 ? 30 : 50; // Adjust depending on viewport width. const bounds = fitBounds([width, height], [deck.width, deck.height], maxZoom, padding); setViewState(bounds); @@ -59,10 +65,11 @@ function WrappedViewStateDeck(props: { function Viewer({ viewStateAtom }: { viewStateAtom: WritableAtom }) { const layerConstructors = useAtomValue(layerAtoms); - const layers = layerConstructors.map((layer) => { + // @ts-expect-error - Viv types are giving up an issue + const layers: Array = layerConstructors.map((layer) => { return !layer.on ? null : new layer.Layer(layer.layerProps); }); - return []} />; + return ; } export default Viewer; diff --git a/src/gridLayer.ts b/src/gridLayer.ts index 4510564..7fe0a3f 100644 --- a/src/gridLayer.ts +++ b/src/gridLayer.ts @@ -1,8 +1,10 @@ import type { CompositeLayerProps } from "@deck.gl/core/lib/composite-layer"; -import { CompositeLayer, SolidPolygonLayer, TextLayer } from "deck.gl"; +import { CompositeLayer, type PickInfo, SolidPolygonLayer, TextLayer } from "deck.gl"; import pMap from "p-map"; +import type { SolidPolygonLayerProps, TextLayerProps } from "@deck.gl/layers"; import { ColorPaletteExtension, XRLayer } from "@hms-dbmi/viv"; +import type { SupportedTypedArray } from "@vivjs/types"; import type { ZarrPixelSource } from "./ZarrPixelSource"; import type { BaseLayerProps } from "./state"; import { assert } from "./utils"; @@ -14,8 +16,10 @@ export interface GridLoader { name: string; } +type Polygon = Array<[number, number]>; + export interface GridLayerProps - extends Omit, "modelMatrix" | "opacity" | "onClick" | "id">, + extends Omit, "modelMatrix" | "opacity" | "onClick" | "id">, BaseLayerProps { loaders: GridLoader[]; rows: number; @@ -26,7 +30,8 @@ export interface GridLayerProps } const defaultProps = { - ...(XRLayer as any).defaultProps, + // @ts-expect-error - XRLayer props are not typed + ...XRLayer.defaultProps, // Special grid props loaders: { type: "array", value: [], compare: true }, spacer: { type: "number", value: 5, compare: true }, @@ -51,10 +56,10 @@ function validateWidthHeight(d: { data: { width: number; height: number } }[]) { // Return early if no grid data. Maybe throw an error? const { width, height } = first.data; // Verify that all grid data is same shape (ignoring undefined) - d.forEach(({ data }) => { - if (!data) return; + for (const { data } of d) { + if (!data) continue; assert(data.width === width && data.height === height, "Grid data is not same shape."); - }); + } return { width, height }; } @@ -81,7 +86,7 @@ function refreshGridData(props: GridLayerProps) { return pMap(loaders, mapper, { concurrency }); } -export default class GridLayer

extends CompositeLayer { +export default class GridLayer extends CompositeLayer & GridLayerProps> { initializeState() { this.state = { gridData: [], width: 0, height: 0 }; refreshGridData(this.props).then((gridData) => { @@ -90,7 +95,17 @@ export default class GridLayer

extend }); } - updateState({ props, oldProps, changeFlags }: { props: GridLayerProps; oldProps: GridLayerProps; changeFlags: any }) { + updateState({ + props, + oldProps, + changeFlags, + }: { + props: GridLayerProps; + oldProps: GridLayerProps; + changeFlags: { + propsChanged: string | boolean | null; + }; + }) { const { propsChanged } = changeFlags; const loaderChanged = typeof propsChanged === "string" && propsChanged.includes("props.loaders"); const loaderSelectionChanged = props.selections !== oldProps.selections; @@ -102,7 +117,7 @@ export default class GridLayer

extend } } - getPickingInfo({ info }: { info: any }) { + getPickingInfo({ info }: { info: PickInfo }) { // provide Grid row and column info for mouse events (hover & click) if (!info.coordinate) { return info; @@ -112,8 +127,10 @@ export default class GridLayer

extend const [x, y] = info.coordinate; const row = Math.floor(y / (height + spacer)); const column = Math.floor(x / (width + spacer)); - info.gridCoord = { row, column }; // add custom property - return info; + return { + ...info, + gridCoord: { row, column }, + }; } renderLayers() { @@ -121,7 +138,8 @@ export default class GridLayer

extend if (width === 0 || height === 0) return null; // early return if no data const { rows, columns, spacer = 0, id = "" } = this.props; - const layers = gridData.map((d: any) => { + type Data = { row: number; col: number; loader: Pick; data: Array }; + const layers = gridData.map((d: Data) => { const y = d.row * (height + spacer); const x = d.col * (width + spacer); const layerProps = { @@ -132,44 +150,47 @@ export default class GridLayer

extend pickable: false, extensions: [new ColorPaletteExtension()], }; - return new (XRLayer as any)({ ...this.props, ...layerProps }); + // @ts-expect-error - XRLayer props are not well typed + return new XRLayer({ ...this.props, ...layerProps }); }); if (this.props.pickable) { - const [top, left] = [0, 0]; + type Data = { polygon: Polygon }; const bottom = rows * (height + spacer); const right = columns * (width + spacer); const polygon = [ - [left, top], - [right, top], + [0, 0], + [right, 0], [right, bottom], - [left, bottom], - ]; + [0, bottom], + ] satisfies Polygon; const layerProps = { data: [{ polygon }], - getPolygon: (d: any) => d.polygon, + getPolygon: (d) => d.polygon, getFillColor: [0, 0, 0, 0], // transparent getLineColor: [0, 0, 0, 0], pickable: true, // enable picking id: `${id}-GridLayer-picking`, - } as any; // I was having an issue with typescript here.... - const pickableLayer = new SolidPolygonLayer({ ...this.props, ...layerProps }); - layers.push(pickableLayer); + } satisfies SolidPolygonLayerProps; + // @ts-expect-error - SolidPolygonLayer props are not well typed + const layer = new SolidPolygonLayer>({ ...this.props, ...layerProps }); + layers.push(layer); } if (this.props.text) { - const textLayer = new TextLayer({ + type Data = { col: number; row: number; name: string }; + const layer = new TextLayer>({ id: `${id}-GridLayer-text`, data: gridData, - getPosition: (d: any) => [d.col * (width + spacer), d.row * (height + spacer)], - getText: (d: any) => d.name, + getPosition: (d) => [d.col * (width + spacer), d.row * (height + spacer)], + getText: (d) => d.name, getColor: [255, 255, 255, 255], getSize: 16, getAngle: 0, getTextAnchor: "start", getAlignmentBaseline: "top", }); - layers.push(textLayer); + layers.push(layer); } return layers; diff --git a/src/index.tsx b/src/index.tsx index 3f1ca3e..4f2114c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -46,7 +46,7 @@ export function createViewer(element: HTMLElement, options: { menuOpen?: boolean on: emitter.on.bind(emitter), destroy: () => root.unmount(), }), - [], + [setViewState, addImage], ); React.useEffect(() => { if (ref.current) { diff --git a/src/ome.ts b/src/ome.ts index 9fe8465..66f0cd9 100644 --- a/src/ome.ts +++ b/src/ome.ts @@ -1,7 +1,7 @@ import type { Readable } from "@zarrita/storage"; import pMap from "p-map"; import * as zarr from "zarrita"; -import type { ImageLayerConfig, SourceData } from "./state"; +import type { ImageLayerConfig, OnClickData, SourceData } from "./state"; import { ZarrPixelSource } from "./ZarrPixelSource"; import * as utils from "./utils"; @@ -76,7 +76,7 @@ export async function loadWell( }); }); - let meta; + let meta: Meta; if (utils.isOmeroMultiscales(imgAttrs)) { meta = parseOmeroMeta(imgAttrs.omero, axes); } else { @@ -105,7 +105,7 @@ export async function loadWell( sourceData.rows = rows; sourceData.columns = cols; - sourceData.onClick = (info: any) => { + sourceData.onClick = (info: OnClickData) => { let gridCoord = info.gridCoord; if (!gridCoord) { return; @@ -198,7 +198,7 @@ export async function loadPlate( loader: new ZarrPixelSource(d[1], { labels: axis_labels, tileSize }), }; }); - let meta; + let meta: Meta; if ("omero" in imgAttrs) { meta = parseOmeroMeta(imgAttrs.omero, axes); } else { @@ -222,7 +222,7 @@ export async function loadPlate( columns: columns.length, }; // Us onClick from image config or Open Well in new window - sourceData.onClick = (info: any) => { + sourceData.onClick = (info: OnClickData) => { let gridCoord = info.gridCoord; if (!gridCoord) { return; @@ -270,7 +270,17 @@ export async function loadOmeroMultiscales( }; } -async function defaultMeta(loader: ZarrPixelSource, axis_labels: string[]) { +type Meta = { + name: string | undefined; + names: Array; + colors: Array; + contrast_limits: Array<[number, number] | undefined>; + visibilities: Array; + channel_axis: number | null; + defaultSelection: Array; +}; + +async function defaultMeta(loader: ZarrPixelSource, axis_labels: string[]): Promise { const channel_axis = axis_labels.indexOf("c"); const channel_count = channel_axis === -1 ? 1 : loader.shape[channel_axis]; const visibilities = utils.getDefaultVisibilities(channel_count); @@ -287,7 +297,7 @@ async function defaultMeta(loader: ZarrPixelSource, axis_labels: strin }; } -function parseOmeroMeta({ rdefs, channels, name }: Ome.Omero, axes: Ome.Axis[]) { +function parseOmeroMeta({ rdefs, channels, name }: Ome.Omero, axes: Ome.Axis[]): Meta { const t = rdefs?.defaultT ?? 0; const z = rdefs?.defaultZ ?? 0; diff --git a/src/state.ts b/src/state.ts index 6706f62..4eefd0a 100644 --- a/src/state.ts +++ b/src/state.ts @@ -38,7 +38,7 @@ interface BaseConfig { opacity?: number; acquisition?: string; model_matrix?: string | number[]; - onClick?: (e: any) => void; + onClick?: (e: unknown) => void; } export interface MultichannelConfig extends BaseConfig { @@ -57,6 +57,10 @@ export interface SingleChannelConfig extends BaseConfig { export type ImageLayerConfig = MultichannelConfig | SingleChannelConfig; +export type OnClickData = Record & { + gridCoord?: { row: number; column: number }; +}; + export type SourceData = { loader: ZarrPixelSource[]; loaders?: GridLoader[]; // for OME plates @@ -77,7 +81,7 @@ export type SourceData = { }; model_matrix: Matrix4; axis_labels: string[]; - onClick?: (e: any) => void; + onClick?: (e: OnClickData) => void; }; export type VivProps = ConstructorParameters[0]; @@ -92,7 +96,7 @@ export interface BaseLayerProps { selections: number[][]; modelMatrix: Matrix4; contrastLimitsRange: [min: number, max: number][]; - onClick?: (e: any) => void; + onClick?: (e: OnClickData) => void; } interface MultiscaleImageLayerProps extends BaseLayerProps { @@ -109,7 +113,8 @@ type LayerMap = { grid: [GridLayer, { loader: ZarrPixelSource | ZarrPixelSource[] } & GridLayerProps]; }; -export type LayerCtr = new (...args: any[]) => T; +// biome-ignore lint/suspicious/noExplicitAny: Need a catch all for layer types +export type LayerCtr = new (...args: Array) => T; export type LayerState = { Layer: LayerCtr; layerProps: LayerMap[T][1]; @@ -118,7 +123,7 @@ export type LayerState = T & { id: string }; -export type ControllerProps = { +export type ControllerProps = { sourceAtom: PrimitiveAtom>; layerAtom: PrimitiveAtom>; } & T; diff --git a/src/utils.ts b/src/utils.ts index a5ed9c0..7d8e983 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -28,15 +28,14 @@ async function normalizeStore(source: string | Readable): Promise res.json()), ]); store = ReferenceStore.fromSpec(json); } else { const url = new URL(source); - // @ts-expect-error - pathname always starts with '/' - path = url.pathname; + // grab the path and then set the URL to the root + path = ensureAbosolutePath(url.pathname); url.pathname = "/"; store = new zarr.FetchStore(url.href); } @@ -48,6 +47,12 @@ async function normalizeStore(source: string | Readable): Promise rstrip(s as string, "/")) + .filter((s) => s !== undefined) + .map((s) => rstrip(s, "/")) .join("/"); } @@ -226,7 +231,6 @@ export function fitBounds( return { zoom, target: [width / 2, height / 2] }; } -// prettier-ignore type Array16 = [ number, number, @@ -319,7 +323,7 @@ export async function calcConstrastLimits( */ export function defer() { let resolve: (value: T | PromiseLike) => void; - let reject: (reason?: any) => void; + let reject: (reason?: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; diff --git a/tsconfig.json b/tsconfig.json index e389862..feac920 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,18 +3,15 @@ "compilerOptions": { "module": "esnext", "target": "esnext", - "moduleResolution": "node", - "jsx": "preserve", - "baseUrl": "./", + "moduleResolution": "bundler", "noEmit": true, "strict": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, "verbatimModuleSyntax": true, + "jsx": "preserve", "paths": { - "*": ["*", "node_modules/@danmarshall/deckgl-typings/*"] + "*": ["./node_modules/@danmarshall/deckgl-typings/*"] } } }