From 031149a407bea9dc19db259d1b3741bfaf0c6f1a Mon Sep 17 00:00:00 2001 From: noah Date: Mon, 13 May 2024 13:55:22 -0700 Subject: [PATCH] Noah/layers pt2 (#23) * delete ye olde build script * add some react for UI - think about if I like it or it will make my life nice * fix a perf bug in which we consider rendering every slice in a grid regardless of zoom level. some (better?) smoke and mirrors trickery to try to obscure redraw flickering when panning/zooming * working but messy impl of a worker pool that handles decoding zarr chunks for us # Conflicts: # apps/layers/package.json * play around with WW pool size, and fix a bug in which the optional render-working budget parameters were being ignored (for the slice renderer specifically) * move a shocking amount of code in to render slide-view style annotations # Conflicts: # apps/layers/package.json * good enough for now # Conflicts: # apps/layers/src/demo.ts * respect queue params * enable screenshots, with a default output resolution of roughly 85MP # Conflicts: # apps/layers/package.json # apps/layers/src/demo.ts # pnpm-lock.yaml * start thinking about upside down data... * its all upside down now great * minor tidy, plus a rough attempt at a less flickery stand-in algorithm * tools to add layers during demotime # Conflicts: # apps/layers/src/demo.ts * add a versa layer * add scatterplot-slideview real quick # Conflicts: # apps/layers/src/demo.ts * start some cleanup so I can merge this... # Conflicts: # apps/layers/src/demo.ts * Merge branch 'noah/layered-demo' into noah/layered-with-react-whynot # Conflicts: # apps/layers/src/demo.ts * quickly change the underlying cache type for scatterplots for much better perf (gpu buffer not client-buffer) * try out sds components for quick hacky ui fun - delete old ui code * add a bunch of per-layer ui elements * prev/next layer buttons * take a snapshot button * quickly re-enable drawing layers * a bit hacky, but non-flickering drawings are worth it for a demo * change moduleResolution in the apps tsconfig to make the zarr library that we use extensively get resolved correctly. this is an issue on their end: https://github.com/gzuidhof/zarr.js/issues/152 * cleanup some increasingly scary cherrypicks, and finally tidy up those last little demo ts errors. * clean up a bunch of low hanging fruit * fix up the scatterplot (standalone) demo * readme and demo script * a little more * copy in the latest and greatest annotation stuff in * minor cleanups * fix wrongly named example --- apps/common/{nono.json => package.json} | 0 .../src/loaders/ome-zarr/fetchSlice.worker.ts | 30 + .../src/loaders/ome-zarr/sliceWorkerPool.ts | 75 + apps/common/src/loaders/ome-zarr/zarr-data.ts | 73 - apps/common/src/loaders/scatterplot/data.ts | 38 +- .../scatterplot/scatterbrain-loader.ts | 9 + apps/layers/package.json | 26 +- apps/layers/readme.md | 44 + apps/layers/src/app.tsx | 44 + .../src/data-renderers/annotation-renderer.ts | 199 +++ .../dynamicGridSlideRenderer.ts | 57 +- .../src/data-renderers/mesh-renderer.ts | 145 ++ ...enderer.ts => simpleAnnotationRenderer.ts} | 9 +- apps/layers/src/data-renderers/types.ts | 5 - .../src/data-renderers/volumeSliceRenderer.ts | 105 +- .../annotation/annotation-codec.ts | 44 + .../annotation/annotation-grid.ts | 32 + .../annotation/annotation-schema-type.ts | 35 + .../annotation/annotation-to-mesh.ts | 237 +++ .../annotation/fetch-annotation.ts | 31 + .../src/data-sources/annotation/types.ts | 36 + .../src/data-sources/ome-zarr/planar-slice.ts | 1 - .../src/data-sources/ome-zarr/slice-grid.ts | 2 +- .../data-sources/scatterplot/dynamic-grid.ts | 51 +- apps/layers/src/demo.ts | 609 +++++--- apps/layers/src/layer.ts | 32 +- apps/layers/src/types.ts | 37 +- apps/layers/src/ui/annotation-grid.tsx | 22 + apps/layers/src/ui/annotation-ui.ts | 12 - apps/layers/src/ui/contact-sheet.tsx | 49 + apps/layers/src/ui/layer-list.ts | 12 - apps/layers/src/ui/scatterplot-ui.tsx | 35 + apps/layers/src/ui/slice-ui.tsx | 59 + apps/layers/src/ui/utils.ts | 6 - apps/layers/src/ui/volume-slice-layer.ts | 12 - apps/layers/tsconfig.json | 1 + apps/omezarr-viewer/src/camera.ts | 5 +- apps/omezarr-viewer/src/versa-renderer.ts | 29 +- apps/scatterplot/src/demo.ts | 25 +- apps/scatterplot/src/renderer.ts | 27 +- apps/scatterplot/tsconfig.json | 9 +- apps/tsconfig.json | 2 +- packages/scatterbrain/src/render-queue.ts | 2 - pnpm-lock.yaml | 1300 +++++++++-------- 44 files changed, 2604 insertions(+), 1009 deletions(-) rename apps/common/{nono.json => package.json} (100%) create mode 100644 apps/common/src/loaders/ome-zarr/fetchSlice.worker.ts create mode 100644 apps/common/src/loaders/ome-zarr/sliceWorkerPool.ts create mode 100644 apps/layers/readme.md create mode 100644 apps/layers/src/app.tsx create mode 100644 apps/layers/src/data-renderers/annotation-renderer.ts create mode 100644 apps/layers/src/data-renderers/mesh-renderer.ts rename apps/layers/src/data-renderers/{annotationRenderer.ts => simpleAnnotationRenderer.ts} (78%) create mode 100644 apps/layers/src/data-sources/annotation/annotation-codec.ts create mode 100644 apps/layers/src/data-sources/annotation/annotation-grid.ts create mode 100644 apps/layers/src/data-sources/annotation/annotation-schema-type.ts create mode 100644 apps/layers/src/data-sources/annotation/annotation-to-mesh.ts create mode 100644 apps/layers/src/data-sources/annotation/fetch-annotation.ts create mode 100644 apps/layers/src/data-sources/annotation/types.ts create mode 100644 apps/layers/src/ui/annotation-grid.tsx delete mode 100644 apps/layers/src/ui/annotation-ui.ts create mode 100644 apps/layers/src/ui/contact-sheet.tsx delete mode 100644 apps/layers/src/ui/layer-list.ts create mode 100644 apps/layers/src/ui/scatterplot-ui.tsx create mode 100644 apps/layers/src/ui/slice-ui.tsx delete mode 100644 apps/layers/src/ui/utils.ts delete mode 100644 apps/layers/src/ui/volume-slice-layer.ts diff --git a/apps/common/nono.json b/apps/common/package.json similarity index 100% rename from apps/common/nono.json rename to apps/common/package.json diff --git a/apps/common/src/loaders/ome-zarr/fetchSlice.worker.ts b/apps/common/src/loaders/ome-zarr/fetchSlice.worker.ts new file mode 100644 index 0000000..41b0072 --- /dev/null +++ b/apps/common/src/loaders/ome-zarr/fetchSlice.worker.ts @@ -0,0 +1,30 @@ + +// a web-worker which fetches slices of data, decodes them, and returns the result as a flat float32 array, using transferables +import type { NestedArray, TypedArray } from 'zarr' +import { getSlice, type ZarrDataset, type ZarrRequest } from "./zarr-data"; + +const ctx = self; +type ZarrSliceRequest = { + id: string; + type: 'ZarrSliceRequest' + metadata: ZarrDataset + req: ZarrRequest, + layerIndex: number +} +function isSliceRequest(payload: any): payload is ZarrSliceRequest { + return typeof payload === 'object' && payload['type'] === 'ZarrSliceRequest'; +} +ctx.onmessage = (msg: MessageEvent) => { + const { data } = msg; + if (isSliceRequest(data)) { + const { metadata, req, layerIndex, id } = data; + getSlice(metadata, req, layerIndex).then((result: { + shape: number[], + buffer: NestedArray + }) => { + const { shape, buffer } = result; + const R = new Float32Array(buffer.flatten()); + ctx.postMessage({ type: 'slice', id, shape, data: R }, { transfer: [R.buffer] }) + }) + } +} \ No newline at end of file diff --git a/apps/common/src/loaders/ome-zarr/sliceWorkerPool.ts b/apps/common/src/loaders/ome-zarr/sliceWorkerPool.ts new file mode 100644 index 0000000..0f4302a --- /dev/null +++ b/apps/common/src/loaders/ome-zarr/sliceWorkerPool.ts @@ -0,0 +1,75 @@ +import { uniqueId } from "lodash"; +import type { ZarrDataset, ZarrRequest } from "./zarr-data"; + +type PromisifiedMessage = { + requestCacheKey: string; + resolve: (t: Slice) => void; + reject: (reason: unknown) => void; + promise?: Promise | undefined; +}; +type ExpectedResultSlice = { + type: 'slice', + id: string; +} & Slice; +type Slice = { + data: Float32Array; + shape: number[] +} +function isExpectedResult(obj: any): obj is ExpectedResultSlice { + return (typeof obj === 'object' && 'type' in obj && obj.type === 'slice') +} +export class SliceWorkerPool { + private workers: Worker[]; + private promises: Record; + private which: number; + constructor(size: number) { + this.workers = new Array(size); + for (let i = 0; i < size; i++) { + this.workers[i] = new Worker(new URL('./fetchSlice.worker.ts', import.meta.url), { type: 'module' }); + this.workers[i].onmessage = (msg) => this.handleResponse(msg) + } + this.promises = {}; + this.which = 0; + } + + handleResponse(msg: MessageEvent) { + const { data: payload } = msg; + if (isExpectedResult(payload)) { + const prom = this.promises[payload.id]; + if (prom) { + const { data, shape } = payload + prom.resolve({ data, shape }); + delete this.promises[payload.id] + } + } + } + private roundRobin() { + this.which = (this.which + 1) % this.workers.length + } + requestSlice(dataset: ZarrDataset, req: ZarrRequest, layerIndex: number) { + const reqId = uniqueId('rq'); + const cacheKey = JSON.stringify({ url: dataset.url, req, layerIndex }); + // TODO caching I guess... + const eventually = new Promise((resolve, reject) => { + this.promises[reqId] = { + requestCacheKey: cacheKey, + resolve, + reject, + promise: undefined, // ill get added to the map once I am fully defined! + }; + this.workers[this.which].postMessage({ id: reqId, type: 'ZarrSliceRequest', metadata: dataset, req, layerIndex }); + this.roundRobin(); + }); + this.promises[reqId].promise = eventually; + return eventually; + } +} + +// a singleton... +let slicePool: SliceWorkerPool; +export function getSlicePool() { + if (!slicePool) { + slicePool = new SliceWorkerPool(16); + } + return slicePool; +} \ No newline at end of file diff --git a/apps/common/src/loaders/ome-zarr/zarr-data.ts b/apps/common/src/loaders/ome-zarr/zarr-data.ts index 3a6a784..a48664d 100644 --- a/apps/common/src/loaders/ome-zarr/zarr-data.ts +++ b/apps/common/src/loaders/ome-zarr/zarr-data.ts @@ -42,47 +42,6 @@ type ZarrAttrs = { multiscales: ReadonlyArray; }; -// function getSpatialDimensionShape(dataset: DatasetWithShape, axes: readonly AxisDesc[]) { -// const dims = axes.reduce( -// (shape, ax, i) => (ax.type === "spatial" ? { ...shape, [ax.name]: dataset.shape[i] } : shape), -// {} as Record -// ); -// return dims; -// } -// function getSpatialOrdering -// function getBoundsInMillimeters(data: ZarrDataset) { -// if (data.multiscales.length !== 1) { -// throw new Error("cant support multi-scene zarr file..."); -// } -// const scene = data.multiscales[0]; -// const { axes, datasets } = scene; -// if (datasets.length < 1) { -// throw new Error("malformed dataset - no voxels!"); -// } -// const dataset = datasets[0]; -// const spatialResolution = getSpatialDimensionShape(dataset, axes); -// // apply transforms -// dataset.coordinateTransformations.forEach((trn) => {}); -// const dimensions = getNumVoxelsInXYZ(getXYZIndexing(axes), dataset.shape); - -// let bounds: box3D = Box3D.create([0, 0, 0], dimensions); -// dataset.coordinateTransformations.forEach((trn) => { -// // specification for coordinate transforms given here: https://ngff.openmicroscopy.org/latest/#trafo-md -// // from the above doc, its not super clear if the given transformation is in the order of the axes metadata (https://ngff.openmicroscopy.org/latest/#axes-md) -// // or some other order -// // all files I've seen so far have both in xyz order, so its a bit ambiguous. -// if (isScaleTransform(trn) && trn.scale.length >= 3) { -// bounds = applyScaleToXYZBounds(bounds, trn, axes); -// } else { -// throw new Error(`unsupported coordinate transformation type - please implement`); -// } -// }); -// // finally - convert whatever the axes units are to millimeters, or risk crashing into mars -// // get the units of each axis in xyz order... - -// return Box3D.map(bounds, (corner) => unitsToMillimeters(corner, axes)); -// } - async function getRawInfo(store: HTTPStore) { const group = await openGroup(store); // TODO HACK ALERT: I am once again doing the thing that I hate, in which I promise to my friend Typescript that @@ -125,18 +84,6 @@ export function uvForPlane(plane: AxisAlignedPlane) { export function sliceDimensionForPlane(plane: AxisAlignedPlane) { return sliceDimension[plane]; } -// function sizeOnScreen(full: box2D, relativeView: box2D, screen: vec2) { -// const pxView = Box2D.scale(relativeView, Box2D.size(full)); -// const onScreen = Box2D.intersection(pxView, full); -// if (!onScreen) return [0, 0]; - -// const effective = Box2D.size(onScreen); -// // as a parameter, how much is on screen? -// const p = Vec2.div(effective, Box2D.size(full)); -// const limit = Vec2.mul(p, screen); - -// return limit[0] * limit[1] < effective[0] * effective[1] ? limit : effective -// } export type ZarrRequest = Record; export function pickBestScale( dataset: ZarrDataset, @@ -173,8 +120,6 @@ export function pickBestScale( : bestSoFar, datasets[0] ); - // console.log('choose layer: ', choice.path); - // console.log('---->', planeSizeInVoxels(plane, axes, choice)) return choice ?? datasets[datasets.length - 1]; } function indexFor(dim: OmeDimension, axes: readonly AxisDesc[]) { @@ -287,24 +232,6 @@ export async function getSlice(metadata: ZarrDataset, r: ZarrRequest, layerIndex buffer: result, }; } -// export async function getRGBSlice(metadata: ZarrDataset, r: ZarrRequest, layerIndex: number) { -// dieIfMalformed(r); -// // put the request in native order -// const store = new HTTPStore(metadata.url); -// const scene = metadata.multiscales[0]; -// const { axes } = scene; -// const level = scene.datasets[layerIndex] ?? scene.datasets[scene.datasets.length - 1]; -// const arr = await openArray({ store, path: level.path, mode: "r" }); -// const result = await arr.get(buildQuery(r, axes, level.shape)); -// if (typeof result == "number" || result.shape.length !== 2) { -// throw new Error("oh noes, slice came back all weird"); -// } -// return { -// shape: result.shape as unknown as vec2, -// buffer: result.flatten(), -// }; -// } - export async function load(url: string) { const store = new HTTPStore(url); return loadMetadata(store, await getRawInfo(store)); diff --git a/apps/common/src/loaders/scatterplot/data.ts b/apps/common/src/loaders/scatterplot/data.ts index c698d36..68e273b 100644 --- a/apps/common/src/loaders/scatterplot/data.ts +++ b/apps/common/src/loaders/scatterplot/data.ts @@ -2,14 +2,18 @@ // todo rename this file import { Box2D, visitBFS, type box2D, type vec2 } from "@alleninstitute/vis-geometry"; -import { fetchColumn, type ColumnarTree, type SlideViewDataset, type loadDataset } from "./scatterbrain-loader"; +import { fetchColumn, type ColumnData, type ColumnRequest, type ColumnarNode, type ColumnarTree, type SlideViewDataset, type loadDataset } from "./scatterbrain-loader"; import REGL from 'regl' export type Dataset = ReturnType export type RenderSettings = { dataset: Dataset; view: box2D; + colorBy: ColumnRequest; + pointSize: number; target: REGL.Framebuffer2D | null; + regl: REGL.Regl; } + function isVisible(view: box2D, sizeLimit: number, tree: ColumnarTree) { const { bounds } = tree.content; return Box2D.size(bounds)[0] > sizeLimit && !!Box2D.intersection(view, tree.content.bounds); @@ -23,12 +27,12 @@ export function getVisibleItems(dataset: Dataset, view: box2D, sizeLimit: number (tree) => isVisible(view, sizeLimit, tree)); return hits; } -export function getVisibleItemsInSlide(dataset: SlideViewDataset, slide: string, view: box2D, sizeLimit: number){ - const theSlide = dataset.slides[slide]; - if(!theSlide){ +export function getVisibleItemsInSlide(dataset: SlideViewDataset, slide: string, view: box2D, sizeLimit: number) { + const theSlide = dataset.slides[slide]; + if (!theSlide) { console.log('nope', Object.keys(dataset.slides)) return [] - } + } const hits: ColumnarTree[] = [] const tree = theSlide.tree; @@ -38,19 +42,23 @@ export function getVisibleItemsInSlide(dataset: SlideViewDataset, slide: string, (tree) => isVisible(view, sizeLimit, tree)); return hits; } +function toReglBuffer(c: ColumnData, regl: REGL.Regl) { + return { + type: 'vbo', + data: regl.buffer(c) + } as const; +} +function fetchAndUpload(settings: { dataset: Dataset, regl: REGL.Regl }, node: ColumnarNode, req: ColumnRequest, signal?: AbortSignal | undefined) { + const { dataset, regl } = settings; + return fetchColumn(node, dataset, req, signal).then(cd => toReglBuffer(cd, regl)) +} export function fetchItem(item: ColumnarTree, settings: RenderSettings, signal?: AbortSignal) { - const { dataset } = settings; - const position = () => fetchColumn(item.content, settings.dataset, { - name: dataset.spatialColumn, - type: 'METADATA', - }, signal); - const color = () => fetchColumn(item.content, settings.dataset, { - type: 'QUANTITATIVE', - name: '442' - }, signal); + const { dataset, colorBy } = settings; + const position = () => fetchAndUpload(settings, item.content, { type: 'METADATA', name: dataset.spatialColumn }, signal); + const color = () => fetchAndUpload(settings, item.content, colorBy, signal) return { position, color - } + } as const } diff --git a/apps/common/src/loaders/scatterplot/scatterbrain-loader.ts b/apps/common/src/loaders/scatterplot/scatterbrain-loader.ts index a49803e..7e868d5 100644 --- a/apps/common/src/loaders/scatterplot/scatterbrain-loader.ts +++ b/apps/common/src/loaders/scatterplot/scatterbrain-loader.ts @@ -1,5 +1,6 @@ import { Box2D, Box3D, Vec3, type box, type box3D, type vec2, type vec3 } from "@alleninstitute/vis-geometry"; import { MakeTaggedBufferView, type TaggedTypedArray, type WebGLSafeBasicType } from "../../typed-array"; +import type REGL from "regl"; type volumeBound = { lx: number; @@ -244,9 +245,17 @@ type QuantitativeColumn = { name: string; }; export type ColumnRequest = MetadataColumn | QuantitativeColumn; +export type ColumnBuffer = { + type: 'vbo', + data: REGL.Buffer +} export type ColumnData = TaggedTypedArray & { elements: number; // per vector entry - for example 'xy' would have elements: 2 }; +export async function loadScatterbrainJson(url: string) { + // obviously, we should check or something + return fetch(url).then(stuff => stuff.json() as unknown as ColumnarMetadata) +} export async function fetchColumn( node: ColumnarNode>, diff --git a/apps/layers/package.json b/apps/layers/package.json index ef44706..150d506 100644 --- a/apps/layers/package.json +++ b/apps/layers/package.json @@ -30,28 +30,38 @@ "private": true, "scripts": { "preinstall": "npx only-allow pnpm", - "demo": "esbuild src/demo.ts --bundle --outfile=dst/demo.js && cp public/* dst/", "typecheck": "tsc --noEmit", "start": "parcel public/demo.html" }, "devDependencies": { - "esbuild": "^0.19.12", "@parcel/packager-ts": "^2.12.0", "@parcel/transformer-typescript-types": "^2.12.0", + "@types/file-saver": "^2.0.7", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "esbuild": "^0.19.12", "parcel": "2.12.0", "typescript": "^5.3.3" }, "dependencies": { "@alleninstitute/vis-geometry": "workspace:*", "@alleninstitute/vis-scatterbrain": "workspace:*", - "@thi.ng/imgui": "^2.2.54", - "@thi.ng/layout": "^3.0.36", - "@thi.ng/rdom-canvas": "^0.5.83", - "@thi.ng/rstream": "^8.4.0", - "@thi.ng/rstream-gestures": "^5.0.70", + "@czi-sds/components": "^20.0.1", + "@emotion/css": "^11.11.2", + "@emotion/react": "^11.11.4", + "@emotion/styled": "^11.11.5", + "@mui/base": "5.0.0-beta.40", + "@mui/icons-material": "^5.15.15", + "@mui/lab": "5.0.0-alpha.170", + "@mui/material": "^5.15.15", "@types/lodash": "^4.14.202", + "file-saver": "^2.0.5", "json5": "^2.2.3", + "kiwi-schema": "^0.5.0", "lodash": "^4.17.21", - "regl": "^2.1.0" + "regl": "^2.1.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "zarr": "^0.6.2" } } \ No newline at end of file diff --git a/apps/layers/readme.md b/apps/layers/readme.md new file mode 100644 index 0000000..6b4b782 --- /dev/null +++ b/apps/layers/readme.md @@ -0,0 +1,44 @@ +# Layered Rendering Example App +## Run it +1. download/clone this repository. +2. in the root directory (vis/) run `pnpm install` +3. run `pnpm build` +4. `cd apps/layers` +5. `pnpm run start` +6. navigate to the running app (default `localhost://1234`) + +## Why? +the goal of this (rather complicated) example app is not to show off a cool app - rather its goal is to show that we can build complexity by composing simple, focused modules. As we (the AIBS Apps team) have developed ABC-Atlas, we've tried to make sure our visualization code stays general, and that each part does as little as possible. The result is that it was fairly easy to combine those components into this new app, which mixes a (terrible) UI, scatter-plot rendering, polygon-mesh rendering (for annotations) and multi-channel volumetric rendering into independent layers. Although each of these data types appear different, our caching, fetching, visibility determination, and render-scheduling code is the same regardless of the datatype to be rendered. All that is required is to fill in a simple interface, and provide a low-level "renderer" plugin in order to add a new "layer" type. +## Demo Script +### Programmatic Configuration +After starting the app in a browser, you'll be greeted by a blank screen. We're going to demonstrate programmatic access to the features of this demo. The goal here is not to make users invoke command-line arguments, but rather just an easy way for interested parties to "peak under the hood". All the visualizations are configured here via simple json objects - it would not be a stretch to read these configuration options at initialization-time via URL parameters for example. + +Open your browser's developer tools via ctrl+shift+i, ctrl+opt+i, or F12, and go to the developer console tab. This console is a running REPL environment with direct access to the demo. You can explore what features are available by typing "demo." at which point you should be given some auto-complete options, corresponding to the methods available on the demo object. The first thing we should take a look at is `demo.addLayer`, which accepts a configuration object for some data to be viewed as a layer in the app. A list of pre-constructed objects is provided in the `examples` object. lets run `examples.reconstructed` to take a peek:
+```{ + "type": "ScatterPlotGridConfig", + "colorBy": { + "name": "88", + "type": "QUANTITATIVE" + }, + "url": "https://.../4STCSZBXHYOI0JUUA3M/ScatterBrain.json" +} +``` +note - the above config has been slightly altered here for readability! + +Some things to observe about this config object:
+1. its simple json +2. the "type" tag tells the app what fields to expect next. +3. this data is a grid of scatter-plots (slide view!), its colored by a quantitative feature (gene #88). +4. lastly - do not take this structure too seriously! I made up all the layer-config types in a hurry, and we can easily make new ones, compose existing ones, or change anything at all! + +### Explore a few layers +Lets continue by adding one layer: +`demo.addLayer(examples.reconstructed)`
+now, we should be able to navigate the (previously blank) view with the mouse, and we can watch the data load as it becomes visible (and thus, worth fetching & caching). Note also that the UI populates, with very simple (ugly?) controls to change settings, in this case only the gene by which to color can be edited. This ui took about 60 seconds to write, and I would not hesitate to throw it away. +Lets add a second layer: `demo.addLayer(examples.structureAnnotation)`
+This data is the CCF annotations (the regions to which the scatterplot data is registered), and should line up with the scatterplot grid. This layer type has only a few simple options for altering the opacity of the polygons.
+Lets add another layer: `demo.addLayer(examples.tissuecyte396)`
+This should load up a contact-sheet style view, with 142 slices. You should note that the slices are not expected to line up with those in the other two layers (the slide-view scatterplot) - the data in each are in different coordinate systems. Perhaps obviously, the whole point of having multiple layers in one view would be to observe interesting relationships between them, and in a (non-demo) app, it would be important to allow configurations to specify mappings / transforms / etc. to get things to "line up". You should also see some double-post, "invlerp" sliders that control how the raw channels of information get mapped to visible-light color. You should be able to move the min & max for each channel, although do note that there seems to be very little data in the 3rd channel, mapped to the color blue. + +### Take a picture +At the top left of the UI, you might notice a little 📸 button. This will take a low-resolution snapshot of the current view. If you'd like to be a little patient, enter the dev console again, and run `demo.requestSnapshot(10000)` which, depending on the aspect ratio of your browser window, will (slowly!) capture an ~80 million pixel image of the current screen - the main reason its slow is because, to properly serve that higher resolution, the rendering system must fetch many, many chunks of high-resolution data to render. Note that making changes to the UI during the snapshot process may cause those changes to take partial effect in the final snapshot output. This is a well understood class of bugs that are easy to address when planning to "do software correctly" - however this is a quick demo so I took a bunch of shortcuts! \ No newline at end of file diff --git a/apps/layers/src/app.tsx b/apps/layers/src/app.tsx new file mode 100644 index 0000000..cf57847 --- /dev/null +++ b/apps/layers/src/app.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { SliceViewLayer } from './ui/slice-ui'; +import type { Demo } from './demo'; +import { AnnotationGrid } from './ui/annotation-grid'; +import { ContactSheetUI } from './ui/contact-sheet'; +import { ScatterplotUI } from './ui/scatterplot-ui'; +import { Button } from '@czi-sds/components'; + +export function AppUi(props: { demo: Demo }) { + const {demo}=props; + return (
+ + + + + +
) +} +function LayerUi(props: { demo: Demo }) { + const {demo}=props; + const layer = demo.layers[demo.selectedLayer]; + if(layer){ + switch(layer.type){ + case 'annotationGrid': + return + case 'volumeGrid': + return + case 'volumeSlice': + return + case 'scatterplot': + case 'scatterplotGrid': + return + default: + return null; + } + } + return ; +} diff --git a/apps/layers/src/data-renderers/annotation-renderer.ts b/apps/layers/src/data-renderers/annotation-renderer.ts new file mode 100644 index 0000000..5f9ba44 --- /dev/null +++ b/apps/layers/src/data-renderers/annotation-renderer.ts @@ -0,0 +1,199 @@ +import type REGL from "regl"; +import type { RenderCallback } from "./types"; +import { Box2D, Vec2, type box2D, type vec2, type vec4 } from "@alleninstitute/vis-geometry"; +import type { AnnotationMesh, GPUAnnotationMesh } from "src/data-sources/annotation/types"; +import type { buildLoopRenderer, buildMeshRenderer } from "./mesh-renderer"; +import type { OptionalTransform } from "src/data-sources/types"; +import { AsyncDataCache, beginLongRunningFrame, type FrameLifecycle } from "@alleninstitute/vis-scatterbrain"; +import { fetchAnnotation } from "src/data-sources/annotation/fetch-annotation"; +import { MeshFromAnnotation } from "src/data-sources/annotation/annotation-to-mesh"; +import { type Camera } from "../../../omezarr-viewer/src/camera"; +import type { AnnotationGrid } from "src/data-sources/annotation/annotation-grid"; + +type SlideId = string; + +type SlideAnnotations = { + annotationBaseUrl: string; + levelFeature: string; + gridFeature: SlideId; + bounds: box2D; +} & OptionalTransform; + +export type LoopRenderer = ReturnType; +export type MeshRenderer = ReturnType; +export type CacheContentType = { type: 'mesh', data: GPUAnnotationMesh } +type Settings = { + regl: REGL.Regl; + loopRenderer: LoopRenderer, + meshRenderer: MeshRenderer, + stencilMeshRenderer: MeshRenderer, + camera: Camera; + viewport: REGL.BoundingBox; + target: REGL.Framebuffer | null; + stroke: { + width: number; + overrideColor?: vec4; + opacity: number; // set to zero to disable stroke-rendering + }; + fill: { + overrideColor?: vec4; + opacity: number; // set to zero to disable fill-rendering + }; +}; + +function isMesh(obj: object | undefined): obj is CacheContentType { + return !!(obj && 'type' in obj && obj.type === 'mesh') +} +function fetchAnnotationsForSlide( + item: SlideAnnotations, + settings: Settings, + _abort: AbortSignal | undefined +): Record Promise> { + const { regl } = settings; + const toCacheEntry = (m: AnnotationMesh | undefined): CacheContentType | undefined => + m + ? { + type: 'mesh', data: { + points: regl.buffer(m.points), + annotation: m + } + } + : undefined; + const getMesh = () => { + return fetchAnnotation(item).then((anno) => anno ? MeshFromAnnotation(anno) : undefined).then(toCacheEntry) + }; + return { mesh: getMesh }; +} + +type RProps = Parameters>[0]; + +function renderSlideAnnotations(item: SlideAnnotations, settings: Settings, columns: Record) { + const { camera, viewport, target, regl, loopRenderer, meshRenderer, stencilMeshRenderer } = settings; + // const { view } = camera.projection === 'webImage' ? flipY(camera) : camera + const { view } = camera; + const offset = item.toModelSpace?.offset ?? [0, 0]; + const flatView = Box2D.toFlatArray(view); + // gather all the props into an array for batching + const { mesh } = columns; + if (!mesh || !isMesh(mesh)) return; + + if (mesh.data.annotation.closedPolygons.length < 1) return; + const { annotation, points } = mesh.data; + const { closedPolygons: polygons } = annotation; + const fadedColor = (clr: vec4, opacity: number) => [clr[0], clr[1], clr[2], opacity] as vec4; + if (settings.fill.opacity > 0.0) { + polygons.forEach((polygon) => { + const color = settings.fill.overrideColor + ? fadedColor(settings.fill.overrideColor, settings.fill.opacity) + : fadedColor(polygon.color, settings.fill.opacity); + if (polygon.loops.length > 0) { + regl.clear({ stencil: 0, framebuffer: target }); + const stencilBatch = polygon.loops.map((fan) => ({ + target, + viewport, + view: flatView, + count: fan.length, + position: { buffer: points, offset: fan.start * 8 }, + color, + offset, + })); + stencilMeshRenderer(...stencilBatch); + meshRenderer(...stencilBatch); + } + }); + } + + if (settings.stroke.opacity > 0.0) { + const batched: RProps = []; + for (const polygon of polygons) { + for (const loop of polygon.loops) { + const color = settings.stroke.overrideColor + ? fadedColor(settings.stroke.overrideColor, settings.stroke.opacity) + : fadedColor(polygon.color, settings.stroke.opacity); + batched.push({ + color, + count: loop.length - 1, + offset, + position: { buffer: points, offset: loop.start * 8 }, + target, + view: flatView, + viewport, + }); + } + } + loopRenderer(batched); + } +} + +export type RenderSettings = { + camera: Camera; + regl: REGL.Regl + cache: AsyncDataCache; + renderers: { + loopRenderer: LoopRenderer, + meshRenderer: MeshRenderer, + stencilMeshRenderer: MeshRenderer, + }, + callback: RenderCallback, + concurrentTasks?: number, + queueInterval?: number, + cpuLimit?: number, +} + + +export function renderAnnotationGrid( + target: REGL.Framebuffer2D | null, grid: AnnotationGrid, settings: RenderSettings): FrameLifecycle { + const { dataset, annotationBaseUrl, levelFeature, stroke, fill } = grid; + const { regl, cache, camera: { view, screen }, renderers: { loopRenderer, meshRenderer, stencilMeshRenderer }, callback } = settings; + let { camera, concurrentTasks, queueInterval, cpuLimit } = settings; + + concurrentTasks = concurrentTasks ? Math.abs(concurrentTasks) : 5 + queueInterval = queueInterval ? Math.abs(queueInterval) : 33 + cpuLimit = cpuLimit ? Math.abs(cpuLimit) : undefined + const items: SlideAnnotations[] = []; + const rowSize = Math.floor(Math.sqrt(Object.keys(dataset.slides).length)); + + Object.keys(dataset.slides).forEach((slideId, i) => { + const gridIndex: vec2 = [i % rowSize, Math.floor(i / rowSize)] + const { bounds } = dataset; + const offset = Vec2.mul(gridIndex, Box2D.size(bounds)) + const realBounds = Box2D.translate(bounds, offset) + if (Box2D.intersection(view, realBounds)) { + items.push({ + annotationBaseUrl, gridFeature: slideId, levelFeature, bounds, toModelSpace: { + offset, + scale: [1, 1] + } + }) + } + }) + const frame = beginLongRunningFrame( + concurrentTasks, + queueInterval, + items, + cache, + { + ...settings, + loopRenderer, + meshRenderer, + stencilMeshRenderer, + regl, + stroke, + fill, + target, + viewport: { + x: 0, + y: 0, + width: screen[0], + height: screen[1], + }, + camera + }, + fetchAnnotationsForSlide, + renderSlideAnnotations, + callback, + (rq: string, item: SlideAnnotations, _settings: Settings) => `${rq}_${item.gridFeature}_${item.levelFeature}`, + cpuLimit + ); + return frame; +} diff --git a/apps/layers/src/data-renderers/dynamicGridSlideRenderer.ts b/apps/layers/src/data-renderers/dynamicGridSlideRenderer.ts index a6ce9c2..8076319 100644 --- a/apps/layers/src/data-renderers/dynamicGridSlideRenderer.ts +++ b/apps/layers/src/data-renderers/dynamicGridSlideRenderer.ts @@ -1,40 +1,77 @@ -import { beginLongRunningFrame, type AsyncDataCache, type NormalStatus } from "@alleninstitute/vis-scatterbrain"; +import { beginLongRunningFrame, type AsyncDataCache } from "@alleninstitute/vis-scatterbrain"; import type REGL from "regl"; import { buildRenderer as buildScatterplotRenderer } from "../../../scatterplot/src/renderer"; -import { Box2D, Vec2, type box2D, type vec2 } from "@alleninstitute/vis-geometry"; +import { Box2D, Vec2, type vec2 } from "@alleninstitute/vis-geometry"; import { fetchItem, getVisibleItemsInSlide } from "Common/loaders/scatterplot/data"; -import type { ColumnData } from "Common/loaders/scatterplot/scatterbrain-loader"; +import type { ColumnRequest, ColumnarTree } from "Common/loaders/scatterplot/scatterbrain-loader"; import { applyOptionalTrn } from "./utils"; -import type { DynamicGridSlide } from "../data-sources/scatterplot/dynamic-grid"; -import type { Camera, RenderCallback } from "./types"; -type CacheContentType = ColumnData +import type { DynamicGrid, DynamicGridSlide } from "../data-sources/scatterplot/dynamic-grid"; +import type { RenderCallback } from "./types"; +import { type Camera } from "../../../omezarr-viewer/src/camera"; +type CacheContentType = { + type: 'vbo', + data: REGL.Buffer; +} type Renderer = ReturnType export type RenderSettings = { camera: Camera; cache: AsyncDataCache; renderer: Renderer, + regl: REGL.Regl, callback: RenderCallback, concurrentTasks?: number, queueInterval?: number, cpuLimit?: number, } +const cacheKey = (reqKey: string, item: ColumnarTree, settings: { colorBy: ColumnRequest }) => `${reqKey}:${item.content.name}:${settings.colorBy.name}|${settings.colorBy.type}` export function renderSlide(target: REGL.Framebuffer2D | null, slide: DynamicGridSlide, settings: RenderSettings) { - const { cache, camera: { view, screen }, renderer, callback } = settings; + const { cache, camera: { view, screen }, renderer, callback, regl } = settings; let { camera, concurrentTasks, queueInterval, cpuLimit } = settings; concurrentTasks = concurrentTasks ? Math.abs(concurrentTasks) : 5 queueInterval = queueInterval ? Math.abs(queueInterval) : 33 cpuLimit = cpuLimit ? Math.abs(cpuLimit) : undefined - const { dataset } = slide; + const { dataset, colorBy, pointSize } = slide; const unitsPerPixel = Vec2.div(Box2D.size(view), screen); camera = { ...camera, view: applyOptionalTrn(camera.view, slide.toModelSpace, true) } + // camera = camera.projection === 'webImage' ? flipY(camera) : camera; const items = getVisibleItemsInSlide(slide.dataset, slide.slideId, settings.camera.view, 10 * unitsPerPixel[0]) // make the frame, return some junk return beginLongRunningFrame(concurrentTasks, queueInterval, items, cache, - { view, dataset, target }, fetchItem, renderer, callback, - (reqKey, item, _settings) => `${reqKey}:${item.content.name}`, + { view, dataset, target, colorBy, regl, pointSize }, fetchItem, renderer, callback, + cacheKey, + cpuLimit); +} + +export function renderDynamicGrid(target: REGL.Framebuffer2D | null, grid: DynamicGrid, settings: RenderSettings) { + const { cache, camera: { view, screen }, renderer, callback, regl } = settings; + let { camera, concurrentTasks, queueInterval, cpuLimit } = settings; + + concurrentTasks = concurrentTasks ? Math.abs(concurrentTasks) : 5 + queueInterval = queueInterval ? Math.abs(queueInterval) : 33 + cpuLimit = cpuLimit ? Math.abs(cpuLimit) : undefined + const items: ColumnarTree[] = [] + const { dataset, pointSize } = grid; + const unitsPerPixel = Vec2.div(Box2D.size(view), screen); + const rowSize = Math.floor(Math.sqrt(Object.keys(dataset.slides).length)); + camera = { ...camera, view: applyOptionalTrn(camera.view, grid.toModelSpace, true) } + Object.keys(dataset.slides).forEach((slideId, i) => { + const slide = dataset.slides[slideId]; + const gridIndex: vec2 = [i % rowSize, Math.floor(i / rowSize)] + const { bounds } = dataset; + const offset = Vec2.mul(gridIndex, Box2D.size(bounds)) + const realBounds = Box2D.translate(bounds, offset) + if (Box2D.intersection(view, realBounds)) { + items.push(...getVisibleItemsInSlide(grid.dataset, slide.id, settings.camera.view, 10 * unitsPerPixel[0]).map(t => ({ ...t, offset }))) + } + }) + const { colorBy } = grid + // make the frame, return some junk + return beginLongRunningFrame(concurrentTasks, queueInterval, items, cache, + { view, dataset, target, colorBy, regl, pointSize }, fetchItem, renderer, callback, + cacheKey, cpuLimit); } \ No newline at end of file diff --git a/apps/layers/src/data-renderers/mesh-renderer.ts b/apps/layers/src/data-renderers/mesh-renderer.ts new file mode 100644 index 0000000..1a4440c --- /dev/null +++ b/apps/layers/src/data-renderers/mesh-renderer.ts @@ -0,0 +1,145 @@ +import type { vec4, vec2 } from '@alleninstitute/vis-geometry'; +import REGL from 'regl'; + +const frag = ` +precision highp float; +uniform vec4 color; +void main(){ + gl_FragColor = vec4(color.rgb*color.a,color.a); +} +`; +const vert = ` +precision highp float; +attribute vec2 position; +uniform vec4 view; +uniform vec2 offset; + +vec2 unitToClip(vec2 u){ + return (u-0.5)*2.0; +} + +void main(){ + vec2 size = view.zw-view.xy; + vec2 pos= ((position+offset)-view.xy)/size; + gl_Position = vec4(unitToClip(pos),0.0,1.0); +} +`; + +type Uniforms = { + view: vec4; + offset: vec2; + color: vec4; +}; +type Attribs = { + position: REGL.AttributeConfig; +}; + +type Props = { + view: vec4; + offset: vec2; + position: REGL.AttributeConfig; + count: number; + viewport: REGL.BoundingBox; + color: vec4; + target: REGL.Framebuffer | null; +}; + +type LoopProps = { count: number } & Omit; + +export function buildLoopRenderer(regl: REGL.Regl) { + const cmd = regl({ + vert, + frag, + primitive: 'line loop', + uniforms: { + view: regl.prop('view'), + offset: regl.prop('offset'), + color: regl.prop('color'), + }, + attributes: { position: regl.prop('position') }, + viewport: regl.prop('viewport'), + framebuffer: regl.prop('target'), + count: regl.prop('count'), + depth: { + mask: false, + enable: false, + }, + blend: { + enable: true, + }, + }); + return (args: LoopProps[]) => { + cmd(args); + }; +} +export function buildMeshRenderer(regl: REGL.Regl, mode: 'draw-stencil' | 'use-stencil') { + const cmd = regl({ + vert, + frag, + primitive: 'triangle fan', + uniforms: { + view: regl.prop('view'), + offset: regl.prop('offset'), + color: regl.prop('color'), + }, + attributes: { position: regl.prop('position') }, + count: regl.prop('count'), + viewport: regl.prop('viewport'), + framebuffer: regl.prop('target'), + depth: { + mask: false, + enable: false, + }, + blend: { + enable: true, + }, + ...(mode === 'draw-stencil' + ? { + colorMask: [false, false, false, false], + stencil: { + enable: true, + mask: -1, + func: { + cmp: 'always', + mask: -1, + ref: 0, + }, + op: { + fail: 'invert', // cmp is always - thus never fails... + zfail: 'invert', + zpass: 'invert', + }, + }, + } + : { + // use-stencil + stencil: { + enable: true, + mask: 0, + func: { + cmp: 'lequal', + mask: -1, + ref: 1, + }, + op: { + fail: 'keep', + zfail: 'keep', + zpass: 'keep', + }, + }, + }), + }); + return ( + ...batches: { + target: REGL.Framebuffer | null; + viewport: REGL.BoundingBox; + view: vec4; + count: number; + position: REGL.AttributeConfig; + color: vec4; + offset: vec2; + }[] + ) => { + cmd(batches); + }; +} diff --git a/apps/layers/src/data-renderers/annotationRenderer.ts b/apps/layers/src/data-renderers/simpleAnnotationRenderer.ts similarity index 78% rename from apps/layers/src/data-renderers/annotationRenderer.ts rename to apps/layers/src/data-renderers/simpleAnnotationRenderer.ts index c4b8879..4ebdc6e 100644 --- a/apps/layers/src/data-renderers/annotationRenderer.ts +++ b/apps/layers/src/data-renderers/simpleAnnotationRenderer.ts @@ -1,14 +1,13 @@ -import { cacheKeyFactory, getVisibleTiles, requestsForTile, type VoxelSliceRenderSettings, type VoxelTile, type buildVolumeSliceRenderer } from "../../../omezarr-viewer/src/slice-renderer"; import type REGL from "regl"; import { beginLongRunningFrame, type AsyncDataCache } from "@alleninstitute/vis-scatterbrain"; -import type { Camera, RenderCallback } from "./types"; +import type { RenderCallback } from "./types"; import type { ColumnData } from "Common/loaders/scatterplot/scatterbrain-loader"; -import { Box2D, type box2D, type vec2, type vec4 } from "@alleninstitute/vis-geometry"; -import type { Path, buildLineRenderer, buildPathRenderer } from "./lineRenderer"; -import type { TaggedFloat32Array } from "Common/typed-array"; +import { Box2D, type box2D } from "@alleninstitute/vis-geometry"; +import type { Path, buildPathRenderer } from "./lineRenderer"; import { flatten } from "lodash"; import type { OptionalTransform } from "../data-sources/types"; +import type { Camera } from "../../../omezarr-viewer/src/camera"; type Renderer = ReturnType; diff --git a/apps/layers/src/data-renderers/types.ts b/apps/layers/src/data-renderers/types.ts index 487c3cf..7bec7f8 100644 --- a/apps/layers/src/data-renderers/types.ts +++ b/apps/layers/src/data-renderers/types.ts @@ -7,11 +7,6 @@ import type { ZarrDataset } from "Common/loaders/ome-zarr/zarr-data"; import type { SlideViewDataset, ColumnRequest } from "Common/loaders/scatterplot/scatterbrain-loader"; import type { AxisAlignedPlane } from "../../../omezarr-viewer/src/slice-renderer"; -export type Camera = { - view: box2D; - screen: vec2; -} - diff --git a/apps/layers/src/data-renderers/volumeSliceRenderer.ts b/apps/layers/src/data-renderers/volumeSliceRenderer.ts index 41f7793..b30d41c 100644 --- a/apps/layers/src/data-renderers/volumeSliceRenderer.ts +++ b/apps/layers/src/data-renderers/volumeSliceRenderer.ts @@ -1,12 +1,13 @@ import type REGL from "regl"; import { beginLongRunningFrame, type AsyncDataCache } from "@alleninstitute/vis-scatterbrain"; -import type { Camera, RenderCallback } from "./types"; -import { cacheKeyFactory, getVisibleTiles, requestsForTile, type buildVersaRenderer, type VoxelSliceRenderSettings, type VoxelTile } from "../../../omezarr-viewer/src/versa-renderer"; +import type { RenderCallback } from "./types"; +import { cacheKeyFactory, getVisibleTiles, requestsForTile, type AxisAlignedPlane, type buildVersaRenderer, type VoxelSliceRenderSettings, type VoxelTile } from "../../../omezarr-viewer/src/versa-renderer"; import { pickBestScale, sizeInUnits, sizeInVoxels, sliceDimensionForPlane, uvForPlane } from "Common/loaders/ome-zarr/zarr-data"; import { applyOptionalTrn } from "./utils"; -import { Vec2, type vec2 } from "@alleninstitute/vis-geometry"; +import { Box2D, Vec2, type vec2 } from "@alleninstitute/vis-geometry"; import type { AxisAlignedZarrSlice } from "../data-sources/ome-zarr/planar-slice"; import type { AxisAlignedZarrSliceGrid } from "../data-sources/ome-zarr/slice-grid"; +import { type Camera } from "../../../omezarr-viewer/src/camera"; type Renderer = ReturnType; type CacheContentType = { type: 'texture2D', data: REGL.Texture2D }; @@ -22,6 +23,34 @@ export type RenderSettings = { cpuLimit?: number, } + +function preferCachedEntries(grid: AxisAlignedZarrSliceGrid, settings: VoxelSliceRenderSettings, offset: vec2, cache: AsyncDataCache, camera: Camera, location: { + plane: AxisAlignedPlane, + planeIndex: number +}) { + const { plane, planeIndex } = location; + const idealTiles = getVisibleTiles(camera, plane, planeIndex, grid.dataset, offset); + const fakes: VoxelTile[] = []; + + for (const tile of idealTiles.tiles) { + const isCached = (t: VoxelTile) => { + const requests = requestsForTile(t, settings); + const cacheKeys = Object.keys(requests).map(rq => cacheKeyFactory(rq, t, settings)) + return cache.areKeysAllCached(cacheKeys) + } + if (!isCached(tile)) { + // search a different layer for a stand-in in this area... feels pretty slow... + // for now just stick with the most low-res layer... + const lowerLOD = getVisibleTiles({ ...camera, view: tile.realBounds, screen: [1, 1] }, plane, planeIndex, grid.dataset, offset); + fakes.push(...lowerLOD.tiles) + } + } + return { fake: fakes, ideal: idealTiles } +} +// todo: write a helper function that makes much smarter descisions about +// what (already cached) tiles to use for this frame, given the view, the dataset, +// and the cache (and the cache-key-factory...) + export function renderGrid(target: REGL.Framebuffer2D | null, grid: AxisAlignedZarrSliceGrid, settings: RenderSettings) { const { cache, renderer, callback, regl } = settings; let { camera, concurrentTasks, queueInterval, cpuLimit } = settings; @@ -30,45 +59,54 @@ export function renderGrid(target: REGL.F concurrentTasks = concurrentTasks ? Math.abs(concurrentTasks) : 5 queueInterval = queueInterval ? Math.abs(queueInterval) : 33 cpuLimit = cpuLimit ? Math.abs(cpuLimit) : undefined - const halfRes = Vec2.scale(camera.screen, 0.5); const rowSize = Math.floor(Math.sqrt(slices)); const allItems: VoxelTile[] = []; const smokeAndMirrors: VoxelTile[] = [] - const best = pickBestScale(dataset, uvForPlane(plane), camera.view, halfRes); + const best = pickBestScale(dataset, uvForPlane(plane), camera.view, camera.screen); + + const renderSettings = + { + dataset, + gamut, + regl, + rotation: grid.rotation, + target, + view: camera.view, + viewport: { + x: 0, y: 0, + width: camera.screen[0], + height: camera.screen[1] + }, + }; + for (let i = 0; i < slices; i++) { const gridIndex: vec2 = [i % rowSize, Math.floor(i / rowSize)] let param = i / slices; const slice: AxisAlignedZarrSlice = { ...grid, type: 'AxisAlignedZarrSlice', planeParameter: param } const curCam = { ...camera, view: applyOptionalTrn(camera.view, slice.toModelSpace, true) } - const dim = sizeInVoxels(sliceDimensionForPlane(plane), axes, best); const realSize = sizeInUnits(plane, axes, best)!; const offset = Vec2.mul(gridIndex, realSize) + // the bounds of this slice might not even be in view! + // if we did this a bit different... we could know from the index, without having to conditionally test... TODO + if (Box2D.intersection(curCam.view, Box2D.translate(Box2D.create([0, 0], realSize), offset))) { + const planeIndex = Math.round(param * (dim ?? 0)) + const { fake, ideal } = preferCachedEntries(grid, renderSettings, offset, cache, curCam, { + plane, + planeIndex + }); + // get all the items for the lowest level of detail: + smokeAndMirrors.push(...fake) + allItems.push(...ideal.tiles) + + } - const planeIndex = Math.round(param * (dim ?? 0)) - // get all the items for the lowest level of detail: - const lowResItems = getVisibleTiles({ ...curCam, screen: [1, 1] }, plane, planeIndex, dataset, offset); - smokeAndMirrors.push(...lowResItems.tiles) - const items = getVisibleTiles({ ...curCam, screen: halfRes }, plane, planeIndex, dataset, offset); - allItems.push(...items.tiles) } - console.log(`start a frame on layer ${best.path} with ${allItems.length} tiles`) - const frame = beginLongRunningFrame(5, 33, - [...smokeAndMirrors, ...allItems], cache, - { - dataset, - gamut, - regl, - rotation: grid.rotation, - target, - view: camera.view, - viewport: { - x: 0, y: 0, - width: camera.screen[0], - height: camera.screen[1] - }, - }, requestsForTile, renderer, callback, cacheKeyFactory); + const frame = beginLongRunningFrame(concurrentTasks, queueInterval, + [...smokeAndMirrors, ...allItems], + cache, + renderSettings, requestsForTile, renderer, callback, cacheKeyFactory, cpuLimit) return frame; } @@ -79,17 +117,16 @@ export function renderSlice(target: REGL. concurrentTasks = concurrentTasks ? Math.abs(concurrentTasks) : 5 queueInterval = queueInterval ? Math.abs(queueInterval) : 33 cpuLimit = cpuLimit ? Math.abs(cpuLimit) : undefined - const halfRes = Vec2.scale(camera.screen, 0.5); - // TODO: handle optional transform! + const desiredResolution = camera.screen; // convert planeParameter to planeIndex - which requires knowing the bounds of the appropriate dimension camera = { ...camera, view: applyOptionalTrn(camera.view, slice.toModelSpace, true) } - const best = pickBestScale(dataset, uvForPlane(plane), camera.view, halfRes); + const best = pickBestScale(dataset, uvForPlane(plane), camera.view, desiredResolution); const axes = dataset.multiscales[0].axes; const dim = sizeInVoxels(sliceDimensionForPlane(plane), axes, best); const planeIndex = Math.round(planeParameter * (dim ?? 0)) - const items = getVisibleTiles({ ...camera, screen: halfRes }, plane, planeIndex, dataset); - const frame = beginLongRunningFrame(5, 33, + const items = getVisibleTiles({ ...camera, screen: desiredResolution }, plane, planeIndex, dataset); + const frame = beginLongRunningFrame(concurrentTasks, queueInterval, items.tiles, cache, { dataset, @@ -103,6 +140,6 @@ export function renderSlice(target: REGL. width: camera.screen[0], height: camera.screen[1] }, - }, requestsForTile, renderer, callback, cacheKeyFactory); + }, requestsForTile, renderer, callback, cacheKeyFactory, cpuLimit); return frame; } \ No newline at end of file diff --git a/apps/layers/src/data-sources/annotation/annotation-codec.ts b/apps/layers/src/data-sources/annotation/annotation-codec.ts new file mode 100644 index 0000000..ca73315 --- /dev/null +++ b/apps/layers/src/data-sources/annotation/annotation-codec.ts @@ -0,0 +1,44 @@ +import type { AnnotationCodec } from "./annotation-schema-type"; +import { parseSchema, compileSchema } from 'kiwi-schema'; + +export const AnnotationSchema = ` +enum PathCommandType { + MoveTo = 0; + LineTo = 1; + CurveTo= 2; + ClosePolygon=3; +} + +struct Color { + byte red; + byte green; + byte blue; +} + +struct PathCommand { + PathCommandType type; + float[] data; +} + +message Path { + int ftvIndex=1; + string hoverText=2; + Color color=3; + PathCommand[] commands=4; +} + +message Annotation { + Path[] closedPolygons=1; +}`; + +let codec: AnnotationCodec | undefined; +export function getAnnotationCodec() { + if (!codec) { + try { + codec = compileSchema(parseSchema(AnnotationSchema)); + } catch (err) { + return undefined; + } + } + return codec; +} \ No newline at end of file diff --git a/apps/layers/src/data-sources/annotation/annotation-grid.ts b/apps/layers/src/data-sources/annotation/annotation-grid.ts new file mode 100644 index 0000000..2ee8d95 --- /dev/null +++ b/apps/layers/src/data-sources/annotation/annotation-grid.ts @@ -0,0 +1,32 @@ +import type { vec4 } from "@alleninstitute/vis-geometry"; +import type { SlideViewDataset } from "Common/loaders/scatterplot/scatterbrain-loader"; + +export type AnnotationGridConfig = { + type: 'AnnotationGridConfig'; + url: string; + annotationUrl: string; + levelFeature: string; + stroke: { + overrideColor?: vec4, + opacity: number, + }; + fill: { + overrideColor?: vec4, + opacity: number + }; +} +export type AnnotationGrid = { + type: 'AnnotationGrid', + dataset: SlideViewDataset; + annotationBaseUrl: string; + levelFeature: string; + stroke: { + overrideColor?: vec4, + opacity: number, + width: number, + }, + fill: { + overrideColor?: vec4, + opacity: number + } +} \ No newline at end of file diff --git a/apps/layers/src/data-sources/annotation/annotation-schema-type.ts b/apps/layers/src/data-sources/annotation/annotation-schema-type.ts new file mode 100644 index 0000000..653190f --- /dev/null +++ b/apps/layers/src/data-sources/annotation/annotation-schema-type.ts @@ -0,0 +1,35 @@ +export type PathCommandType = 'MoveTo' | 'LineTo' | 'CurveTo' | 'ClosePolygon'; + +export interface Color { + red: number; + green: number; + blue: number; +} + +export interface PathCommand { + type: PathCommandType; + data: number[]; +} + +export interface Path { + ftvIndex?: number; + hoverText?: string; + color?: Color; + commands?: PathCommand[]; +} + +export interface Annotation { + closedPolygons?: Path[]; +} + +export interface AnnotationCodec { + PathCommandType: PathCommandType; + encodeColor(message: Color): Uint8Array; + decodeColor(buffer: Uint8Array): Color; + encodePathCommand(message: PathCommand): Uint8Array; + decodePathCommand(buffer: Uint8Array): PathCommand; + encodePath(message: Path): Uint8Array; + decodePath(buffer: Uint8Array): Path; + encodeAnnotation(message: Annotation): Uint8Array; + decodeAnnotation(buffer: Uint8Array): Annotation; +} diff --git a/apps/layers/src/data-sources/annotation/annotation-to-mesh.ts b/apps/layers/src/data-sources/annotation/annotation-to-mesh.ts new file mode 100644 index 0000000..bcb8e2a --- /dev/null +++ b/apps/layers/src/data-sources/annotation/annotation-to-mesh.ts @@ -0,0 +1,237 @@ +import { type box2D, type vec2, Box2D, Vec2, Vec4 } from '@alleninstitute/vis-geometry'; +import type { Annotation, Path, PathCommand } from './annotation-schema-type'; +import type { AnnotationMesh, AnnotationPolygon, ClosedLoop } from './types'; +// a helper function, which does a first path over commands, grouping them into closed loops +function groupLoops(path: Path) { + // collect each closed polygon from the path - because path commands are very flexible, + // there could be multiple overlapping polygons in a single path! + const { commands } = path; + const closed = commands?.reduce( + (loops: PathCommand[][], command) => { + const curLoop = loops[loops.length - 1]; + switch (command.type) { + case 'ClosePolygon': + curLoop.push(command); + // start a new loop + loops.push([]); + break; + case 'LineTo': + case 'MoveTo': + case 'CurveTo': + curLoop.push(command); + break; + default: + break; + } + return loops; + }, + [[]] as PathCommand[][] + ) ?? [] + return closed.filter((loop) => loop.length > 0); +} +// helper function for computing a bounding box of a bunch of uncertain stuff in a reasonably performant way +function accumulateBounds(curBounds: box2D | vec2 | undefined, curPoint: vec2 | box2D): box2D { + if (!curBounds) { + return Box2D.isBox2D(curPoint) ? curPoint : Box2D.create(curPoint, curPoint); + } + if (Box2D.isBox2D(curBounds)) { + return Box2D.union(curBounds, Box2D.isBox2D(curPoint) ? curPoint : Box2D.create(curPoint, curPoint)); + } + if (Box2D.isBox2D(curPoint)) { + return accumulateBounds(curPoint, curBounds); + } + return Box2D.create(Vec2.min(curPoint, curBounds), Vec2.max(curPoint, curBounds)); +} +// given a set of path commands, which we assume has been pre-processed to contain only one closed loop, +// accumulate the bounds of that loop, and merge all the points into a single a data array for convenience later +// TODO someday support curve-to +function closedPolygon(loop: PathCommand[]) { + if (loop.length < 1) return undefined; + if (loop[0].data.length < 2) return undefined; + + const firstPoint: vec2 = [loop[0].data[0], loop[0].data[1]]; + const initialState: { data: number[]; bounds: box2D } = { data: [], bounds: Box2D.create(firstPoint, firstPoint) }; + + return loop.reduce((acc, command) => { + const data: number[] = acc.data; + let { bounds } = acc; + switch (command.type) { + case 'ClosePolygon': + data.push(...firstPoint); + return { data, bounds }; + case 'LineTo': + case 'MoveTo': + for (let i = 0; i < command.data.length - 1; i += 2) { + bounds = accumulateBounds(bounds, [command.data[i], command.data[i + 1]]); + } + data.push(...command.data); + return { data, bounds }; + case 'CurveTo': + throw new Error('Error: developers must support curve-to commands in annotation shape paths'); + default: + } + return acc; + }, initialState) +} +function onlyDefined(collection: ReadonlyArray): ReadonlyArray { + return collection.reduce((defined, cur) => { + return cur !== undefined ? [...defined, cur] : defined + }, [] as ReadonlyArray); +} + +// intersection stuff: + + +type line = { a: vec2; b: vec2 }; + +/** + * Given two line segments, determine if they intersect. If they do, we return a 1, otherwise we return a 0. This + * is so we can count up how many hits there are across a number of lines to determine if a point is inside + * a polygon. + * + * This is accomplished by using determinants to compare the two lines in an efficient manner. We don't need + * the actual point of intersection, just whether or not the lines intersect, so we do not do the final step in the + * wikipedia article linked below. + * See more here: https://en.wikipedia.org/wiki/Line%E2%80%93line_intersection#Given_two_points_on_each_line_segment + * + * @param firstLine First line to compare + * @param secondLine Second line to compare + * @returns One if the lines intersect, zero otherwise + */ +function linesIntersect(firstLine: line, secondLine: line): 1 | 0 { + // given line segments a->b and c->d: + // make a vec for each point: + const { a: A, b: B } = firstLine; + const { a: C, b: D } = secondLine; + + const AB = Vec2.sub(A, B); + const CD = Vec2.sub(C, D); + const AC = Vec2.sub(A, C); + + // from the wikipedia link: + // - 1s and 2s are A and B + // - 3s and 4s are C and D + // now use vec2.sub to group the points into vectors: + // this is the common denominator: + const BAxDC = Vec2.det(AB, CD); + const t = Vec2.det(AC, CD) / BAxDC; + const u = Vec2.det(AC, AB) / BAxDC; + + // Once we have t and u, we know that the lines intersect if t and u are both between 0 and 1 + return t >= 0 && t <= 1 && u >= 0 && u <= 1 ? 1 : 0; +} + +/** + * Find the "first hit" of a point against a given annotation mesh. If the point is inside the annotation, + * we return that annotation, otherwise we return undefined. + * + * An explanation of the algorithm and a diagram can be found in the function body. + * + * @param annotation The annotation mesh to search for a hit + * @param p The coordinates that we want to use for finding the right annotation + * @returns The annotation polygon that contains the point, or undefined if no polygon contains the point + */ +export function findFirstHit(annotation: AnnotationMesh, p: vec2): AnnotationPolygon | undefined { + // return the first polygon in annotation that contains p, + // accounting for all the holes that may have been cut out! + // check out this rad ascii for a diagram of the problem at hand: + /* --- o = miss x = hit! + o / x \___ + / _____ \ + | / o \ | + o \ x\_____/x/ o + | x / + \_______/ o + */ + // we can compute this by drawing a line to our test point, p + // the line starts out-side the bounds of the polygon as a whole, + // starting at zero, every time this line hits an edge, increment a counter + // once you reach the test point p, if the count is odd, then its a hit. its a miss otherwise! + + const start: vec2 = [annotation.bounds.minCorner[0] - 10, p[1]]; + const testLine: line = { a: start, b: p }; + for (const poly of annotation.closedPolygons) { + if (Box2D.containsPoint(poly.bounds, p)) { + let intersections = 0; + // worth looking closer + for (const loop of poly.loops) { + // TODO: consider adding bounds to each loop - we could + // skip this inner loop if its bounds dont contain p! + // IMPORTANT: i stops at loop.length - 1, otherwise B will overflow into the next loop and cause chaos + for (let i = 0; i < loop.length - 1; i += 1) { + const A = (loop.start + i) * 2; + const B = A + 2; + const a: vec2 = [annotation.points[A], annotation.points[A + 1]]; + const b: vec2 = [annotation.points[B], annotation.points[B + 1]]; + const hit = linesIntersect(testLine, { a, b }); + intersections += hit; + } + } + if (intersections % 2 !== 0) { + return poly; + } + } + } + return undefined; +} + + + +export function MeshFromAnnotation(annotation: Annotation): AnnotationMesh | undefined { + const groups = + annotation.closedPolygons?.map((path) => ({ path, loops: onlyDefined(groupLoops(path).map(closedPolygon)) })) ?? []; + + if (groups.length < 1) { + return { + bounds: Box2D.create([1, 1], [-1, -1]), + closedPolygons: [], + points: new Float32Array(), + }; + } + // we have to pre-allocate a big pile of 32-bit floats, so we have to count all the lengths: + const totalNumbers = groups.reduce( + (sum, group) => sum + group.loops.reduce((total, loop) => total + (loop?.data.length ?? 0), 0), + 0 + ); + + const points = new Float32Array(totalNumbers); + + const groupBounds = (group: { loops: readonly { bounds: box2D }[] }) => + group.loops.reduce((bounds, cur) => Box2D.union(bounds, cur.bounds), group.loops[0].bounds); + + let outIndex = 0; + let totalBounds: box2D | undefined; + const closedPolygons: AnnotationPolygon[] = []; + // accumulation of several things at ounce: + // the total bounds, the counting outIndex, and polygons with potentially multiple loops + for (const group of groups) { + const { color } = group.path; + const loops: ClosedLoop[] = []; + if (group.loops.length < 1) continue; + + for (const loop of group.loops) { + if (!loop) continue + + const closedLoop: ClosedLoop = { + start: outIndex / 2, + length: loop.data.length / 2, + }; + loops.push(closedLoop); + points.set(loop.data, outIndex); + outIndex += loop.data.length; + } + const bounds = groupBounds(group); + totalBounds = accumulateBounds(totalBounds, bounds); + closedPolygons.push({ + bounds, + color: color ? Vec4.scale([color.red, color.green, color.blue, 255], 1 / 255) : [0, 0, 0, 1], + loops, + }); + } + + return totalBounds === undefined ? undefined : { + bounds: totalBounds, + closedPolygons, + points, + }; +} diff --git a/apps/layers/src/data-sources/annotation/fetch-annotation.ts b/apps/layers/src/data-sources/annotation/fetch-annotation.ts new file mode 100644 index 0000000..eae8fdc --- /dev/null +++ b/apps/layers/src/data-sources/annotation/fetch-annotation.ts @@ -0,0 +1,31 @@ +import { getAnnotationCodec } from "./annotation-codec"; + +export async function fetchAnnotation(payload: { + annotationBaseUrl: string; + gridFeature: string; + levelFeature: string; +}) { + const { annotationBaseUrl, gridFeature, levelFeature } = payload; + + // annotationBaseUrl contains a slash at the end + const url = new URL(`${annotationBaseUrl}annotation.bin`); + + if (gridFeature) { + url.searchParams.append('gridFeatureFtvReferenceId', gridFeature); + } + if (levelFeature) { + url.searchParams.append('annotationFeatureReferenceId', levelFeature); + } + + try { + const buffer = await (await fetch(url)).arrayBuffer(); + const codec = getAnnotationCodec(); + if (codec && buffer) { + const annotation = codec.decodeAnnotation(new Uint8Array(buffer)); + return annotation; + } + } finally { + /* empty */ + } + return undefined; +} \ No newline at end of file diff --git a/apps/layers/src/data-sources/annotation/types.ts b/apps/layers/src/data-sources/annotation/types.ts new file mode 100644 index 0000000..ff05454 --- /dev/null +++ b/apps/layers/src/data-sources/annotation/types.ts @@ -0,0 +1,36 @@ +import type { vec4, box2D } from "@alleninstitute/vis-geometry"; +import type REGL from "regl"; + +export type ClosedLoop = { + start: number; + length: number; +}; +export type AnnotationPolygon = { + color: vec4; + loops: readonly ClosedLoop[]; + bounds: box2D; + hoverText?: string; +}; +export type Mesh = { + points: Float32Array; + // a 'polygons' in this case refers to how we're drawing closed polygons - with the triangle-fan pattern + // the start is the index into the points array, where the fan-locus lives. length is then the number + // of verts in the polygon - note that the second and final points are duplicates of each other: + /* a ----- b + | \ / \ + | \ / \ + | x------c + | / \ / + | / \ / + e ----- d + */ + // the above polygon would be in memory in this order: [x a b c d e a ...many polygons may share a buffer...] + closedPolygons: ReadonlyArray; + bounds: box2D; +}; +export type AnnotationMesh = Mesh; + +export type GPUAnnotationMesh = { + points: REGL.Buffer; + annotation: AnnotationMesh; +}; diff --git a/apps/layers/src/data-sources/ome-zarr/planar-slice.ts b/apps/layers/src/data-sources/ome-zarr/planar-slice.ts index 34c5e8a..b3c9d83 100644 --- a/apps/layers/src/data-sources/ome-zarr/planar-slice.ts +++ b/apps/layers/src/data-sources/ome-zarr/planar-slice.ts @@ -2,7 +2,6 @@ import { load, type ZarrDataset } from "Common/loaders/ome-zarr/zarr-data"; import type { AxisAlignedPlane } from "../../../../omezarr-viewer/src/versa-renderer"; import type { ColorMapping } from "../../data-renderers/types"; import type { OptionalTransform, Simple2DTransform } from "../types"; -type MaybePromise = T | Promise; export type ZarrSliceConfig = { type: 'zarrSliceConfig', url: string; diff --git a/apps/layers/src/data-sources/ome-zarr/slice-grid.ts b/apps/layers/src/data-sources/ome-zarr/slice-grid.ts index de2354b..588cedc 100644 --- a/apps/layers/src/data-sources/ome-zarr/slice-grid.ts +++ b/apps/layers/src/data-sources/ome-zarr/slice-grid.ts @@ -1,7 +1,7 @@ import { load, type ZarrDataset } from "Common/loaders/ome-zarr/zarr-data"; import type { AxisAlignedPlane } from "../../../../omezarr-viewer/src/versa-renderer"; import type { ColorMapping } from "../../data-renderers/types"; -import type { MaybePromise, OptionalTransform, Simple2DTransform } from "../types"; +import type { OptionalTransform, Simple2DTransform } from "../types"; export type ZarrSliceGridConfig = { diff --git a/apps/layers/src/data-sources/scatterplot/dynamic-grid.ts b/apps/layers/src/data-sources/scatterplot/dynamic-grid.ts index 5c754c0..1d2e5da 100644 --- a/apps/layers/src/data-sources/scatterplot/dynamic-grid.ts +++ b/apps/layers/src/data-sources/scatterplot/dynamic-grid.ts @@ -1,6 +1,12 @@ -import { isSlideViewData, loadDataset, type ColumnRequest, type ColumnarMetadata, type SlideViewDataset } from "Common/loaders/scatterplot/scatterbrain-loader"; +import { isSlideViewData, loadDataset, loadScatterbrainJson, type ColumnRequest, type ColumnarMetadata, type SlideViewDataset } from "Common/loaders/scatterplot/scatterbrain-loader"; import type { OptionalTransform, Simple2DTransform } from "../types"; -type MaybePromise = T | Promise; + +export type ScatterplotGridConfig = { + type: 'ScatterPlotGridConfig'; + colorBy: ColumnRequest; + url: string; + trn?: Simple2DTransform | undefined; +} export type ScatterPlotGridSlideConfig = { type: 'ScatterPlotGridSlideConfig'; slideId: string; @@ -14,31 +20,58 @@ export type DynamicGridSlide = { dataset: SlideViewDataset; slideId: string; colorBy: ColumnRequest; + pointSize: number; +} & OptionalTransform; + + +export type DynamicGrid = { + type: 'DynamicGrid' + dataset: SlideViewDataset; + colorBy: ColumnRequest; + pointSize: number; } & OptionalTransform; -async function loadJSON(url: string) { - // obviously, we should check or something - return fetch(url).then(stuff => stuff.json() as unknown as ColumnarMetadata) -} // create the real deal from the config -function assembleSlideDatasetonfig(config: ScatterPlotGridSlideConfig, dataset: SlideViewDataset): DynamicGridSlide { +function assembleSlideConfig(config: ScatterPlotGridSlideConfig, dataset: SlideViewDataset): DynamicGridSlide { const { colorBy, slideId, trn } = config return { type: 'DynamicGridSlide', colorBy, dataset, slideId, + pointSize: 4, toModelSpace: trn, } } export function createSlideDataset(config: ScatterPlotGridSlideConfig,): Promise { const { url } = config - return loadJSON(url).then((metadata) => { + return loadScatterbrainJson(url).then((metadata) => { + if (isSlideViewData(metadata)) { + const dataset = loadDataset(metadata, url) as SlideViewDataset; + return assembleSlideConfig(config, dataset) + } + return undefined; + }); +} + +function assembleGridConfig(config: ScatterplotGridConfig, dataset: SlideViewDataset): DynamicGrid { + const { colorBy, trn } = config + return { + type: 'DynamicGrid', + colorBy, + dataset, + pointSize: 4, + toModelSpace: trn, + } +} +export function createGridDataset(config: ScatterplotGridConfig): Promise { + const { url } = config + return loadScatterbrainJson(url).then((metadata) => { if (isSlideViewData(metadata)) { const dataset = loadDataset(metadata, url) as SlideViewDataset; - return assembleSlideDatasetonfig(config, dataset) + return assembleGridConfig(config, dataset) } return undefined; }); diff --git a/apps/layers/src/demo.ts b/apps/layers/src/demo.ts index 2b9376b..04eb169 100644 --- a/apps/layers/src/demo.ts +++ b/apps/layers/src/demo.ts @@ -1,48 +1,55 @@ import { Box2D, Vec2, type box2D, type vec2 } from "@alleninstitute/vis-geometry"; -import { type ColumnRequest, type ColumnarMetadata } from "Common/loaders/scatterplot/scatterbrain-loader"; +import { type ColumnRequest } from "Common/loaders/scatterplot/scatterbrain-loader"; import REGL from "regl"; -import { AsyncDataCache, type NormalStatus } from "@alleninstitute/vis-scatterbrain"; +import { AsyncDataCache, type FrameLifecycle, type NormalStatus } from "@alleninstitute/vis-scatterbrain"; import { buildRenderer } from "../../scatterplot/src/renderer"; import { buildImageRenderer } from "../../omezarr-viewer/src/image-renderer"; import { ReglLayer2D } from "./layer"; -import { renderSlide, type RenderSettings as SlideRenderSettings } from "./data-renderers/dynamicGridSlideRenderer"; +import { renderDynamicGrid, renderSlide, type RenderSettings as SlideRenderSettings } from "./data-renderers/dynamicGridSlideRenderer"; import { renderGrid, renderSlice, type RenderSettings as SliceRenderSettings } from "./data-renderers/volumeSliceRenderer"; -import { renderAnnotationLayer, type RenderSettings as AnnotationRenderSettings, type SimpleAnnotation } from "./data-renderers/annotationRenderer"; +import { renderAnnotationLayer, type RenderSettings as AnnotationRenderSettings, type SimpleAnnotation } from "./data-renderers/simpleAnnotationRenderer"; import { buildPathRenderer } from "./data-renderers/lineRenderer"; -// gui stuff.... -import { DEFAULT_THEME, defGUI } from "@thi.ng/imgui"; -import { gridLayout } from "@thi.ng/layout"; -import { $canvas } from "@thi.ng/rdom-canvas"; -import { fromDOMEvent, fromRAF, } from "@thi.ng/rstream"; -import { gestureStream } from "@thi.ng/rstream-gestures"; -import { layerListUI } from "./ui/layer-list"; -import { volumeSliceLayer } from "./ui/volume-slice-layer"; -import { annotationUi } from "./ui/annotation-ui"; import { buildVersaRenderer, type AxisAlignedPlane } from "../../omezarr-viewer/src/versa-renderer"; import type { ColorMapping, RenderCallback } from "./data-renderers/types"; -import { createZarrSlice, type AxisAlignedZarrSlice } from "./data-sources/ome-zarr/planar-slice"; -import { createSlideDataset, type DynamicGridSlide } from "./data-sources/scatterplot/dynamic-grid"; +import { createZarrSlice, type AxisAlignedZarrSlice, type ZarrSliceConfig } from "./data-sources/ome-zarr/planar-slice"; +import { createGridDataset, createSlideDataset, type DynamicGrid, type DynamicGridSlide, type ScatterPlotGridSlideConfig, type ScatterplotGridConfig } from "./data-sources/scatterplot/dynamic-grid"; import type { OptionalTransform } from "./data-sources/types"; import type { CacheEntry, AnnotationLayer, Layer } from "./types"; -import { createZarrSliceGrid, type AxisAlignedZarrSliceGrid } from "./data-sources/ome-zarr/slice-grid"; +import { AppUi } from "./app"; +import { createRoot } from "react-dom/client"; +import { createZarrSliceGrid, type AxisAlignedZarrSliceGrid, type ZarrSliceGridConfig } from "./data-sources/ome-zarr/slice-grid"; +import { renderAnnotationGrid, type LoopRenderer, type MeshRenderer, type RenderSettings as AnnotationGridRenderSettings } from "./data-renderers/annotation-renderer"; +import { buildLoopRenderer, buildMeshRenderer } from "./data-renderers/mesh-renderer"; +import type { Camera } from "../../omezarr-viewer/src/camera"; +import { saveAs } from 'file-saver' +import type { AnnotationGrid, AnnotationGridConfig } from "./data-sources/annotation/annotation-grid"; +import { sizeInUnits } from "Common/loaders/ome-zarr/zarr-data"; const KB = 1000; const MB = 1000 * KB; -async function loadJSON(url: string) { - // obviously, we should check or something - return fetch(url).then(stuff => stuff.json() as unknown as ColumnarMetadata) +declare global { + interface Window { + examples: Record; + demo: Demo; + } } - - function destroyer(item: CacheEntry) { - if (item.type === 'texture2D') { - item.data.destroy(); + switch (item.type) { + case 'texture2D': + case 'vbo': + item.data.destroy(); + break; + case 'mesh': + item.data.points.destroy(); + break; + default: + // @ts-expect-error + console.error(item.data, 'implement a destroyer for this case!') + break; } - // other types are GC'd like normal, no special destruction needed } function sizeOf(item: CacheEntry) { - // todo: care about bytes later! return 1; } function appendPoint(layer: AnnotationLayer, p: vec2) { @@ -60,21 +67,11 @@ function startStroke(layer: AnnotationLayer, p: vec2) { points: [p] }) } -class Demo { +export class Demo { - setSlice(what: number) { - for (const layer of this.layers) { - if (layer.type === 'volumeSlice') { - layer.data = { ...layer.data, planeParameter: what } - this.onCameraChanged(); - break; - } - } - } - camera: { - view: box2D; - screen: vec2; - } + + + camera: Camera; layers: Layer[] regl: REGL.Regl; selectedLayer: number; @@ -87,10 +84,13 @@ class Demo { plotRenderer: ReturnType; sliceRenderer: ReturnType; pathRenderer: ReturnType + loopRenderer: LoopRenderer; + meshRenderer: MeshRenderer; + stencilMeshRenderer: MeshRenderer; private refreshRequested: number = 0; + private redrawRequested: number = 0; constructor(canvas: HTMLCanvasElement, regl: REGL.Regl) { this.canvas = canvas; - // this.ctx = canvas.getContext('2d')!; this.mouse = 'up' this.regl = regl; this.mousePos = [0, 0] @@ -101,11 +101,17 @@ class Demo { this.plotRenderer = buildRenderer(regl); this.imgRenderer = buildImageRenderer(regl); this.sliceRenderer = buildVersaRenderer(regl); + this.meshRenderer = buildMeshRenderer(regl, 'use-stencil'); + this.stencilMeshRenderer = buildMeshRenderer(regl, 'draw-stencil'); + this.loopRenderer = buildLoopRenderer(regl); + this.refreshRequested = 0; + this.redrawRequested = 0; const [w, h] = [canvas.clientWidth, canvas.clientHeight]; this.camera = { view: Box2D.create([0, 0], [(10 * w) / h, 10]), - screen: [w, h] + screen: [w, h], + projection: 'webImage' } this.initHandlers(canvas); // each entry in the cache is about 250 kb - so 4000 means we get 1GB of data @@ -122,72 +128,185 @@ class Demo { uiChange() { this.onCameraChanged(); } + setOpacity(what: 'fill' | 'stroke', value: number) { + const layer = this.layers[this.selectedLayer]; + if (layer && layer.type === 'annotationGrid') { + layer.data[what].opacity = value; + this.uiChange(); + } + } + setGamutChannel(channel: keyof ColorMapping, value: number[]) { + const layer = this.layers[this.selectedLayer]; + if (layer && (layer.type === 'volumeSlice' || layer.type === 'volumeGrid')) { + layer.data.gamut[channel].gamut.min = value[0]; + layer.data.gamut[channel].gamut.max = value[1]; + this.uiChange(); + } + } + setSlice(param: number) { + const layer = this.layers[this.selectedLayer]; + if (layer && layer.type === 'volumeSlice') { + layer.data = { ...layer.data, planeParameter: param } + this.uiChange(); + } + } + setPlane(param: AxisAlignedPlane) { + const layer = this.layers[this.selectedLayer]; + if (layer && (layer.type === 'volumeSlice' || layer.type === 'volumeGrid')) { + layer.data.plane = param; + this.uiChange(); + } + } + setPointSize(s: number) { + const layer = this.layers[this.selectedLayer]; + if (layer && (layer.type === 'scatterplot' || layer.type === 'scatterplotGrid')) { + layer.data.pointSize = s; + this.uiChange(); + } + } + setColorByIndex(s: number) { + const layer = this.layers[this.selectedLayer]; + if (layer && (layer.type === 'scatterplot' || layer.type === 'scatterplotGrid')) { + layer.data.colorBy.name = `${s.toFixed(0)}`; + this.uiChange(); + } + } + addDynamicGrid(config: ScatterplotGridConfig) { + return createGridDataset(config).then((data) => { + if (data) { + const [w, h] = this.camera.screen + const layer = new ReglLayer2D>( + this.regl, this.imgRenderer, renderDynamicGrid, [w, h] + ); + this.layers.push({ + type: 'scatterplotGrid', + data, + render: layer + }); + this.camera = { ...this.camera, view: data.dataset.bounds } + this.uiChange(); + } + }) + } + selectLayer(layer: number) { + this.selectedLayer = Math.min(this.layers.length - 1, Math.max(0, layer)); + const yay = this.layers[this.selectedLayer]; + console.log('selected:', yay.data) + this.uiChange(); + } + deleteSelectedLayer() { + const removed = this.layers.splice(this.selectedLayer, 1) + removed.forEach(l => l.render.destroy()) + this.uiChange(); + } + addLayer(config: ScatterplotGridConfig | ZarrSliceConfig | ZarrSliceGridConfig | ScatterPlotGridSlideConfig | AnnotationGridConfig) { + switch (config.type) { + case 'AnnotationGridConfig': + return this.addAnnotationGrid(config); + case 'ScatterPlotGridConfig': + return this.addDynamicGrid(config); + case 'ScatterPlotGridSlideConfig': + return this.addScatterplot(config) + case 'ZarrSliceGridConfig': + return this.addVolumeGrid(config); + case 'zarrSliceConfig': + return this.addVolumeSlice(config); + } + } addAnnotation(data: SimpleAnnotation) { const [w, h] = this.camera.screen this.layers.push({ type: 'annotationLayer', data, render: new ReglLayer2D( - this.regl, renderAnnotationLayer, [w, h] + this.regl, this.imgRenderer, renderAnnotationLayer, [w, h] ) }) + this.uiChange(); } - addScatterplot(url: string, slideId: string, color: ColumnRequest) { - return createSlideDataset({ - colorBy: color, - slideId, - type: 'ScatterPlotGridSlideConfig', - url, - }).then((data) => { + addEmptyAnnotation() { + const [w, h] = this.camera.screen + this.layers.push({ + type: 'annotationLayer', + data: { + paths: [] + }, + render: new ReglLayer2D( + this.regl, this.imgRenderer, renderAnnotationLayer, [w, h] + ) + }) + this.uiChange(); + } + private addScatterplot(config: ScatterPlotGridSlideConfig) { + return createSlideDataset(config).then((data) => { if (data) { const [w, h] = this.camera.screen const layer = new ReglLayer2D>( - this.regl, renderSlide, [w, h] + this.regl, this.imgRenderer, renderSlide, [w, h] ); this.layers.push({ type: 'scatterplot', data, render: layer }); + this.camera = { ...this.camera, view: data.dataset.bounds } + this.uiChange(); } }) + } - addVolumeSlice(url: string, plane: AxisAlignedPlane, param: number, gamut: ColorMapping, rotation: number, trn?: { offset: vec2, scale: vec2 }) { + private addVolumeSlice(config: ZarrSliceConfig) { const [w, h] = this.camera.screen - return createZarrSlice({ - type: 'zarrSliceConfig', - gamut, - plane, - planeParameter: param, - url, - rotation, - trn - }).then((data) => { + return createZarrSlice(config).then((data) => { const layer = new ReglLayer2D, 'target'>>( - this.regl, renderSlice, [w, h] + this.regl, this.imgRenderer, renderSlice, [w, h] ); this.layers.push({ type: 'volumeSlice', data, render: layer }); + const s = sizeInUnits(data.plane, data.dataset.multiscales[0].axes, data.dataset.multiscales[0].datasets[0]) + this.camera = { ...this.camera, view: Box2D.create([0, 0], s!) } + this.uiChange(); }) } - addVolumeGrid(url: string, plane: AxisAlignedPlane, slices: number, gamut: ColorMapping, rotation: number, trn?: { offset: vec2, scale: vec2 }) { - const [w, h] = this.camera.screen - return createZarrSliceGrid({ - gamut, - plane, - slices, - type: 'ZarrSliceGridConfig', - url, - rotation, - trn + private addAnnotationGrid(config: AnnotationGridConfig) { + return createSlideDataset({ + slideId: slide32, + colorBy: colorByGene, + type: 'ScatterPlotGridSlideConfig', + url: config.url, }).then((data) => { + if (data) { + const { stroke, fill, levelFeature, annotationUrl } = config; + const [w, h] = this.camera.screen + const grid: AnnotationGrid = { + dataset: data?.dataset, + levelFeature, + annotationBaseUrl: annotationUrl, + stroke: { ...stroke, width: 1 }, fill, + type: 'AnnotationGrid', + } + this.layers.push({ + type: 'annotationGrid', + data: grid, + render: new ReglLayer2D, 'target'>>( + this.regl, this.imgRenderer, renderAnnotationGrid, [w, h]) + }) + // look at it! + this.camera = { ...this.camera, view: data.dataset.bounds } + this.uiChange(); + } + }) + } + private addVolumeGrid(config: ZarrSliceGridConfig) { + const [w, h] = this.camera.screen + return createZarrSliceGrid(config).then((data) => { const layer = new ReglLayer2D, 'target'>>( - this.regl, renderGrid, [w, h] + this.regl, this.imgRenderer, renderGrid, [w, h] ); this.layers.push({ type: 'volumeGrid', @@ -198,7 +317,106 @@ class Demo { }) } - private onCameraChanged() { + async requestSnapshot(pxWidth: number) { + // TODO: using a canvas to build a png is very fast (the browser does it for us) + // however, it does require that the whole image be in memory at once - if you want truely high-res snapshots, + // we should trade out some speed and use pngjs, which lets us pass in as little as a single ROW of pixels at a time + // this would let us go slow(er), but use WAAAY less memory (consider the cost of a 12000x8000 pixel image is (before compression)) about 300 MB... + const w = Math.max(8, Math.min(16000, Math.abs(Number.isFinite(pxWidth) ? pxWidth : 4000))); + const { view, screen, projection } = this.camera; + const aspect = screen[1] / screen[0]; + const h = w * aspect; + // make it be upside down! + const pixels = await this.takeSnapshot({ view, screen: [w, h], projection: projection === 'webImage' ? 'cartesian' : 'webImage' }, this.layers) + // create an offscreen canvas... + const cnvs = new OffscreenCanvas(w, h); + const imgData = new ImageData(new Uint8ClampedArray(pixels.buffer), w, h); + const ctx = cnvs.getContext('2d'); + ctx?.putImageData(imgData, 0, 0); + const blob = await cnvs.convertToBlob(); + saveAs(blob, 'neat.png'); + } + private takeSnapshot(camera: Camera, layers: readonly Layer[]) { + // render each layer, in order, given a snapshot buffer + // once done, regl.read the whole thing, turn it to a png + return new Promise((resolve, reject) => { + + const [width, height] = camera.screen + const target = this.regl.framebuffer(width, height); + this.regl.clear({ framebuffer: target, color: [0, 0, 0, 1], depth: 1 }) + const renderers = { + volumeSlice: this.sliceRenderer, + scatterplot: this.plotRenderer, + annotationLayer: this.pathRenderer, + volumeGrid: this.sliceRenderer, + scatterplotGrid: this.plotRenderer, + annotationGrid: { + loopRenderer: this.loopRenderer, + meshRenderer: this.meshRenderer, + stencilMeshRenderer: this.stencilMeshRenderer + } + } + + + const layerPromises: Array<() => FrameLifecycle> = [] + const nextLayerWhenFinished: RenderCallback = (e: { status: NormalStatus } | { status: 'error', error: unknown }) => { + const { status } = e; + switch (status) { + case 'cancelled': + reject('one of the layer tasks was cancelled') + break; + case 'progress': + if (Math.random() > 0.7) { + console.log('...') + } + break; + case 'finished': + case 'finished_synchronously': + // start the next layer + const next = layerPromises.shift() + if (!next) { + // do the final read! + const bytes = this.regl.read({ framebuffer: target }) + resolve(bytes); + } else { + // do the next layer + next(); + } + } + } + const settings = { + cache: this.cache, camera, callback: nextLayerWhenFinished, regl: this.regl + } + for (const layer of layers) { + switch (layer.type) { + case 'volumeGrid': + layerPromises.push(() => renderGrid(target, layer.data, { ...settings, renderer: renderers[layer.type] })) + break; + case 'annotationGrid': + layerPromises.push(() => renderAnnotationGrid(target, layer.data, { ...settings, renderers: renderers[layer.type] })); + break; + case 'volumeSlice': + layerPromises.push(() => renderSlice(target, layer.data, { ...settings, renderer: renderers[layer.type] })); + break; + case 'scatterplot': + layerPromises.push(() => renderSlide(target, layer.data, { ...settings, renderer: renderers[layer.type] })); + break; + case 'annotationLayer': + layerPromises.push(() => renderAnnotationLayer(target, layer.data, { ...settings, renderer: renderers[layer.type] })) + break; + case 'scatterplotGrid': + layerPromises.push(() => renderDynamicGrid(target, layer.data, { ...settings, renderer: renderers[layer.type] })) + break; + } + } + // start it up! + const first = layerPromises.shift(); + if (first) { + first(); + } + }) + } + private doReRender() { const { cache, camera } = this; const drawOnProgress: RenderCallback = (e: { status: NormalStatus } | { status: 'error', error: unknown }) => { const { status } = e; @@ -214,7 +432,18 @@ class Demo { const settings = { cache, camera, callback: drawOnProgress, regl: this.regl } - const renderers = { volumeSlice: this.sliceRenderer, scatterplot: this.plotRenderer, annotationLayer: this.pathRenderer, volumeGrid: this.sliceRenderer, } + const renderers = { + volumeSlice: this.sliceRenderer, + scatterplot: this.plotRenderer, + annotationLayer: this.pathRenderer, + volumeGrid: this.sliceRenderer, + scatterplotGrid: this.plotRenderer, + annotationGrid: { + loopRenderer: this.loopRenderer, + meshRenderer: this.meshRenderer, + stencilMeshRenderer: this.stencilMeshRenderer + } + } for (const layer of this.layers) { // TODO all cases are identical - dry it up! if (layer.type === 'scatterplot') { @@ -222,6 +451,7 @@ class Demo { data: layer.data, settings: { ...settings, + renderer: renderers[layer.type], } }) @@ -240,23 +470,54 @@ class Demo { ...settings, renderer: renderers[layer.type], } - }) + }, this.mode === 'pan') // dont cancel while drawing } else if (layer.type === 'volumeGrid') { layer.render.onChange({ data: layer.data, settings: { ...settings, + concurrentTasks: 3, + cpuLimit: 25, + renderer: renderers[layer.type], + } + }) + } else if (layer.type === 'annotationGrid') { + layer.render.onChange({ + data: layer.data, + settings: { + ...settings, + concurrentTasks: 2, + renderers: renderers[layer.type], + } + }) + } else if (layer.type === 'scatterplotGrid') { + layer.render.onChange({ + data: layer.data, + settings: { + ...settings, + concurrentTasks: 3, + cpuLimit: 25, renderer: renderers[layer.type], } }) } } } + onCameraChanged() { + if (this.redrawRequested === 0) { + this.redrawRequested = window.requestAnimationFrame(() => { + this.doReRender(); + this.redrawRequested = 0; + }) + } + this.requestReRender(); + } requestReRender() { if (this.refreshRequested === 0) { this.refreshRequested = window.requestAnimationFrame(() => { this.refreshScreen(); this.refreshRequested = 0; + uiroot?.render(AppUi({ demo: this })) }) } } @@ -268,8 +529,9 @@ class Demo { } } private toDataspace(px: vec2) { - const { screen, view } = this.camera; - const p = Vec2.div(px, [this.canvas.clientWidth, this.canvas.clientHeight]); + const { view } = this.camera; + const o: vec2 = [px[0], this.canvas.clientHeight - px[1]]; + const p = Vec2.div(o, [this.canvas.clientWidth, this.canvas.clientHeight]); const c = Vec2.mul(p, Box2D.size(view)); return Vec2.add(view.minCorner, c); } @@ -281,7 +543,7 @@ class Demo { const { screen, view } = this.camera; const p = Vec2.div(delta, [this.canvas.clientWidth, this.canvas.clientHeight]); const c = Vec2.mul(p, Box2D.size(view)); - this.camera = { view: Box2D.translate(view, c), screen }; + this.camera = { ...this.camera, view: Box2D.translate(view, c), screen }; this.onCameraChanged(); } } else if (curLayer && curLayer.type === 'annotationLayer') { @@ -297,6 +559,7 @@ class Demo { const { view, screen } = this.camera; const m = Box2D.midpoint(view); this.camera = { + ...this.camera, view: Box2D.translate(Box2D.scale(Box2D.translate(view, Vec2.scale(m, -1)), [scale, scale]), m), screen, }; @@ -311,15 +574,37 @@ class Demo { }; canvas.onmousemove = (e: MouseEvent) => { // account for gl-origin vs. screen origin: - this.mouseMove([-e.movementX, e.movementY], [e.offsetX, canvas.clientHeight - e.offsetY]); + this.mouseMove([-e.movementX, -e.movementY], [e.offsetX, canvas.clientHeight - e.offsetY]); }; canvas.onwheel = (e: WheelEvent) => { this.zoom(e.deltaY > 0 ? 1.1 : 0.9); }; + window.onkeyup = (e: KeyboardEvent) => { + const layer = this.layers[this.selectedLayer]; + if (e.key === ' ') { + if (layer && layer.type === 'annotationLayer') { + // toggle the mode + this.mode = this.mode === 'draw' ? 'pan' : 'draw'; + this.uiChange(); + } + } if (e.key === 'd') { + // start a new drawing! + if (this.layers.length === 0 || (layer && layer.type !== 'annotationLayer')) { + this.addEmptyAnnotation(); + this.selectLayer(this.layers.length - 1); + this.mode = 'draw'; + this.uiChange(); + } + } + } } refreshScreen() { - console.log('update screen!') + const flipBox = (box: box2D): box2D => { + const { minCorner, maxCorner } = box; + return { minCorner: [minCorner[0], maxCorner[1]], maxCorner: [maxCorner[0], minCorner[1]] }; + } + const flipped = Box2D.toFlatArray(flipBox(this.camera.view)) this.regl.clear({ framebuffer: null, color: [0, 0, 0, 1], depth: 1 }) for (const layer of this.layers) { const src = layer.render.getRenderResults('prev') @@ -328,10 +613,12 @@ class Demo { box: Box2D.toFlatArray(src.bounds), img: src.texture, target: null, - view: Box2D.toFlatArray(this.camera.view) + view: flipped }) } - if (layer.render.renderingInProgress()) { + // annotations are often transparent and dont do well... + if ( + layer.render.renderingInProgress() && layer.type !== 'annotationGrid') { // draw our incoming frame overtop the old! const cur = layer.render.getRenderResults('cur') if (cur.bounds) { @@ -339,7 +626,7 @@ class Demo { box: Box2D.toFlatArray(cur.bounds), img: cur.texture, target: null, - view: Box2D.toFlatArray(this.camera.view) + view: flipped }) } } @@ -371,14 +658,25 @@ function demoTime(thing: HTMLCanvasElement) { gl, extensions: ["ANGLE_instanced_arrays", "OES_texture_float", "WEBGL_color_buffer_float"], }); - const pretend = { min: 0, max: 500 } theDemo = new Demo(thing, regl); - theDemo.addVolumeGrid(scottpoc, 'xy', 142, { - R: { index: 0, gamut: pretend }, - G: { index: 1, gamut: pretend }, - B: { index: 2, gamut: pretend } - }, 0 * Math.PI); + window['demo'] = theDemo; + setupExampleData(); + uiroot.render(AppUi({ demo: theDemo })) + +} +function setupExampleData() { + // add a bunch of pre-selected layers to the window object for selection during demo time + window.examples = {}; + const prep = (key: string, thing: any) => { + window.examples[key] = thing; + } + prep('structureAnnotation', structureAnnotation); + prep('tissuecyte396', tissuecyte396); + prep('slide32', oneSlide) + prep('versa1', versa1) + prep('reconstructed', reconstructed); + prep('tissuecyte', tissueCyteSlice) } const slide32 = 'MQ1B9QBZFIPXQO6PETJ' @@ -388,79 +686,64 @@ const ccf = 'https://neuroglancer-vis-prototype.s3.amazonaws.com/mouse3/230524_t const tissuecyte = "https://tissuecyte-visualizations.s3.amazonaws.com/data/230105/tissuecyte/1111175209/green/" const tenx = 'https://bkp-2d-visualizations-stage.s3.amazonaws.com/wmb_tenx_01172024_stage-20240128193624/488I12FURRB8ZY5KJ8T/ScatterBrain.json' const scottpoc = 'https://tissuecyte-ome-zarr-poc.s3.amazonaws.com/40_128_128/1145081396' - -function buildGui(demo: Demo, sidebar: HTMLElement) { - const gui = defGUI({ - theme: { - ...DEFAULT_THEME, - font: "16px 'IBM Plex Mono', monospace", - baseLine: 6, - focus: "#000", - }, - }); - - const initGUI = (el: HTMLCanvasElement) => { - // unified mouse & touch event handling - gestureStream(el).subscribe({ - next(e: any) { - gui.setMouse(e.pos, e.buttons); - }, - }); - }; - - const updateGUI = () => { - // create grid layout using https://thi.ng/layout - // position grid centered in window - const rowHeight = 32; - const gap = 4; - const grid = gridLayout( - // start X position - 16, - // start Y position (centered) - (sidebar.clientHeight - (2 * rowHeight + gap)) / 2, - // layout width - sidebar.clientWidth - 32, - // single column - 1, - rowHeight, - gap - ); - // prep GUI for next frame - gui.begin(); - layerListUI(gui, grid, demo.selectedLayer, demo.layers, (i) => demo.pickLayer(i)); - const curLayer = demo.layers[demo.selectedLayer]; - if (curLayer) { - switch (curLayer.type) { - case 'volumeSlice': - volumeSliceLayer(gui, grid, curLayer, - (i: number) => { curLayer.data = { ...curLayer.data, planeParameter: i }; demo.uiChange() }, - (p: AxisAlignedPlane) => { curLayer.data = { ...curLayer.data, plane: p }; demo.uiChange() }) - break; - case 'annotationLayer': - annotationUi(gui, grid, demo.mode, curLayer, - (p: 'draw' | 'pan') => { demo.mode = p; demo.uiChange() }) - break; - } - } - // end frame - gui.end(); - - return gui; - }; - const windowSize = fromDOMEvent(window, "resize", false, { - init: {}, - }).map(() => [sidebar.clientWidth, sidebar.clientHeight]); - - // canvas component - $canvas(fromRAF().map(updateGUI), windowSize, { - // execute above init handler when canvas has been mounted - onmount: initGUI, - style: { - background: gui.theme.globalBg, - // update cursor value each frame - cursor: fromRAF().map(() => gui.cursor), - }, - ...gui.attribs, - }).mount(sidebar); +const pretend = { min: 0, max: 500 } +const reconstructed: ScatterplotGridConfig = { + colorBy: colorByGene, + type: 'ScatterPlotGridConfig', + url: 'https://bkp-2d-visualizations-stage.s3.amazonaws.com/wmb_ccf_04112024-20240419205547/4STCSZBXHYOI0JUUA3M/ScatterBrain.json', } +const oneSlide: ScatterPlotGridSlideConfig = { + colorBy: colorByGene, + slideId: slide32, + type: 'ScatterPlotGridSlideConfig', + url: 'https://bkp-2d-visualizations-stage.s3.amazonaws.com/wmb_ccf_04112024-20240419205547/4STCSZBXHYOI0JUUA3M/ScatterBrain.json', +} +const tissuecyte396: ZarrSliceGridConfig = { + type: 'ZarrSliceGridConfig', + gamut: { + R: { index: 0, gamut: { max: 600, min: 0 } }, + G: { index: 1, gamut: { max: 500, min: 0 } }, + B: { index: 2, gamut: { max: 400, min: 0 } } + }, + plane: 'xy', + slices: 142, + url: scottpoc +} +const tissueCyteSlice: ZarrSliceConfig = { + type: 'zarrSliceConfig', + gamut: { + R: { index: 0, gamut: { max: 600, min: 0 } }, + G: { index: 1, gamut: { max: 500, min: 0 } }, + B: { index: 2, gamut: { max: 400, min: 0 } } + }, + plane: 'xy', + planeParameter: 0.5, + url: scottpoc +} +const versa1: ZarrSliceGridConfig = { + url: "https://neuroglancer-vis-prototype.s3.amazonaws.com/VERSA/scratch/0500408166/", + type: 'ZarrSliceGridConfig', + gamut: { + R: { index: 0, gamut: { max: 20, min: 0 } }, + G: { index: 1, gamut: { max: 20, min: 0 } }, + B: { index: 2, gamut: { max: 20, min: 0 } } + }, + plane: 'xy', + slices: 4, +} +const structureAnnotation: AnnotationGridConfig = { + type: 'AnnotationGridConfig', + url: 'https://bkp-2d-visualizations-stage.s3.amazonaws.com/wmb_ccf_04112024-20240419205547/4STCSZBXHYOI0JUUA3M/ScatterBrain.json', + levelFeature: '73GVTDXDEGE27M2XJMT', + annotationUrl: 'https://stage-sfs.brain.devlims.org/api/v1/Annotation/4STCSZBXHYOI0JUUA3M/v3/TLOKWCL95RU03D9PETG/', + stroke: { + opacity: 1, + overrideColor: [1, 0, 0, 1] as const, + }, + fill: { + opacity: 0.7 + } +} +const uiroot = createRoot(document.getElementById('sidebar')!); + demoTime(document.getElementById('glCanvas') as HTMLCanvasElement) \ No newline at end of file diff --git a/apps/layers/src/layer.ts b/apps/layers/src/layer.ts index 39ebe88..7cec024 100644 --- a/apps/layers/src/layer.ts +++ b/apps/layers/src/layer.ts @@ -4,10 +4,11 @@ import { swapBuffers, type BufferPair } from "../../common/src/bufferPair"; import type { Image } from "./types"; import type { FrameLifecycle, NormalStatus } from "@alleninstitute/vis-scatterbrain"; import type { Camera } from "./data-renderers/types"; +import type { buildImageRenderer } from "../../omezarr-viewer/src/image-renderer"; type RenderFn = (target: REGL.Framebuffer2D | null, thing: Readonly, settings: Readonly) => FrameLifecycle; - +type ImageRenderer = ReturnType type RenderCallback = (event: { status: NormalStatus } | { status: 'error', error: unknown }) => void; type EventType = Parameters[0] type RequiredSettings = { camera: Camera, callback: RenderCallback } @@ -19,16 +20,22 @@ export class ReglLayer2D { private renderFn: RenderFn private runningFrame: FrameLifecycle | null; private regl: REGL.Regl; - - constructor(regl: REGL.Regl, renderFn: RenderFn, resolution: vec2) { + private renderImg: ImageRenderer + constructor(regl: REGL.Regl, imgRenderer: ImageRenderer, renderFn: RenderFn, resolution: vec2) { this.buffers = { readFrom: { texture: regl.framebuffer(...resolution), bounds: undefined }, writeTo: { texture: regl.framebuffer(...resolution), bounds: undefined } }; + this.renderImg = imgRenderer this.regl = regl; this.runningFrame = null; this.renderFn = renderFn; } + destroy() { + this.runningFrame?.cancelFrame("destroy this layer"); + this.buffers.readFrom.texture.destroy(); + this.buffers.writeTo.texture.destroy(); + } renderingInProgress() { return this.runningFrame !== null } getRenderResults(stage: 'prev' | 'cur') { @@ -37,11 +44,21 @@ export class ReglLayer2D { onChange(props: { readonly data: Readonly; readonly settings: Readonly - }) { + }, cancel:boolean=true) { - if (this.runningFrame) { + if (cancel && this.runningFrame) { this.runningFrame.cancelFrame(); this.runningFrame = null; + const { readFrom, writeTo } = this.buffers; + // copy our work to the prev-buffer... + if (readFrom.bounds && writeTo.bounds && Box2D.intersection(readFrom.bounds, writeTo.bounds)) { + this.renderImg({ + box: Box2D.toFlatArray(writeTo.bounds), + img: writeTo.texture, + target: readFrom.texture, + view: Box2D.toFlatArray(readFrom.bounds) + }) + } this.regl.clear({ framebuffer: this.buffers.writeTo.texture, color: [0, 0, 0, 0], depth: 1 }) } const { data, settings } = props; @@ -58,7 +75,10 @@ export class ReglLayer2D { case 'finished': case 'finished_synchronously': this.buffers = swapBuffers(this.buffers); - this.regl.clear({ framebuffer: this.buffers.writeTo.texture, color: [0, 0, 0, 0], depth: 1 }) + // only erase... if we would have cancelled... + if(cancel){ + this.regl.clear({ framebuffer: this.buffers.writeTo.texture, color: [0, 0, 0, 0], depth: 1 }) + } this.runningFrame = null; break; } diff --git a/apps/layers/src/types.ts b/apps/layers/src/types.ts index f4855a8..e0a1bca 100644 --- a/apps/layers/src/types.ts +++ b/apps/layers/src/types.ts @@ -1,24 +1,28 @@ -import type { box2D, vec2 } from "@alleninstitute/vis-geometry"; -import type { NormalStatus } from "@alleninstitute/vis-scatterbrain"; +import type { box2D } from "@alleninstitute/vis-geometry"; import type REGL from "regl"; import type { ReglLayer2D } from "./layer"; -import { renderSlide, type RenderSettings as SlideRenderSettings } from "./data-renderers/dynamicGridSlideRenderer"; -import { renderSlice, type RenderSettings as SliceRenderSettings } from "./data-renderers/volumeSliceRenderer"; -import { renderAnnotationLayer, type RenderSettings as AnnotationRenderSettings, type SimpleAnnotation } from "./data-renderers/annotationRenderer"; -import type { ColumnData, ColumnRequest } from "Common/loaders/scatterplot/scatterbrain-loader"; -import type { AxisAlignedPlane } from "../../omezarr-viewer/src/slice-renderer"; +import { type RenderSettings as SlideRenderSettings } from "./data-renderers/dynamicGridSlideRenderer"; +import { type RenderSettings as SliceRenderSettings } from "./data-renderers/volumeSliceRenderer"; +import { type RenderSettings as AnnotationRenderSettings, type SimpleAnnotation } from "./data-renderers/simpleAnnotationRenderer"; import type { AxisAlignedZarrSlice } from "./data-sources/ome-zarr/planar-slice"; -import type { DynamicGridSlide } from "./data-sources/scatterplot/dynamic-grid"; +import type { DynamicGrid, DynamicGridSlide } from "./data-sources/scatterplot/dynamic-grid"; import type { AxisAlignedZarrSliceGrid } from "./data-sources/ome-zarr/slice-grid"; +import type { RenderSettings as AnnotationGridRenderSettings, CacheContentType as GpuMesh } from "./data-renderers/annotation-renderer"; +import type { AnnotationGrid } from "./data-sources/annotation/annotation-grid"; // note: right now, all layers should be considered 2D, and WebGL only... export type Image = { texture: REGL.Framebuffer2D bounds: box2D | undefined; // if undefined, it means we allocated the texture, but its empty and should not be used (except to fill it) } +type ColumnBuffer = { + type: 'vbo'; + data: REGL.Buffer; +} export type CacheEntry = { type: 'texture2D'; data: REGL.Texture2D -} | ColumnData; +} | ColumnBuffer + | GpuMesh @@ -28,6 +32,11 @@ export type ScatterPlotLayer = { data: DynamicGridSlide, render: ReglLayer2D> }; +export type ScatterPlotGridLayer = { + type: 'scatterplotGrid' + data: DynamicGrid, + render: ReglLayer2D> +}; export type VolumetricSliceLayer = { type: 'volumeSlice' @@ -44,4 +53,12 @@ export type VolumetricGridLayer = { data: AxisAlignedZarrSliceGrid; render: ReglLayer2D> } -export type Layer = ScatterPlotLayer | VolumetricSliceLayer | AnnotationLayer | VolumetricGridLayer; \ No newline at end of file +export type SlideViewAnnotations = { + type: 'annotationGrid', + data: AnnotationGrid, + render: ReglLayer2D> +} +export type Layer = + ScatterPlotLayer | ScatterPlotGridLayer + | VolumetricSliceLayer | VolumetricGridLayer + | SlideViewAnnotations | AnnotationLayer \ No newline at end of file diff --git a/apps/layers/src/ui/annotation-grid.tsx b/apps/layers/src/ui/annotation-grid.tsx new file mode 100644 index 0000000..2e51622 --- /dev/null +++ b/apps/layers/src/ui/annotation-grid.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import type { Demo } from 'src/demo'; +import { InputSlider } from '@czi-sds/components'; +export function AnnotationGrid(props: { demo: Demo }) { + const { demo } = props; + // control the gamut with some sliders + const l = demo.layers[demo.selectedLayer]; + if (l && l.type === 'annotationGrid') { + return ( + { + demo.setOpacity('fill', value as number); + }} + /> + ); + } + return null; +} diff --git a/apps/layers/src/ui/annotation-ui.ts b/apps/layers/src/ui/annotation-ui.ts deleted file mode 100644 index b2580b4..0000000 --- a/apps/layers/src/ui/annotation-ui.ts +++ /dev/null @@ -1,12 +0,0 @@ - -import { IMGUI, radio } from "@thi.ng/imgui"; -import { whenDefined } from "./utils"; -import type { IGridLayout } from "@thi.ng/layout"; -import type { AnnotationLayer, VolumetricSliceLayer } from "../types"; -import type { AxisAlignedPlane } from "../../../omezarr-viewer/src/slice-renderer"; - -const planes = ['draw', 'pan'] as const; -export function annotationUi(gui: IMGUI, grid: IGridLayout, mode: 'draw' | 'pan', layer: AnnotationLayer, setMode: (m: 'draw' | 'pan') => void) { - whenDefined(radio(gui, grid, 'plane', true, planes.indexOf(mode), false, planes as any), (i) => setMode(planes[i])); - -} \ No newline at end of file diff --git a/apps/layers/src/ui/contact-sheet.tsx b/apps/layers/src/ui/contact-sheet.tsx new file mode 100644 index 0000000..3f66202 --- /dev/null +++ b/apps/layers/src/ui/contact-sheet.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import type { Demo } from 'src/demo'; +import { InputSlider, Button } from '@czi-sds/components'; +export function ContactSheetUI(props: { demo: Demo }) { + const { demo } = props; + // control the gamut with some sliders + const l = demo.layers[demo.selectedLayer]; + if (l && l.type === 'volumeGrid') { + return ( +
+ + { + demo.setGamutChannel('R', value as number[]); + }} + /> + { + demo.setGamutChannel('G', value as number[]); + }} + /> + { + demo.setGamutChannel('B', value as number[]); + }} + /> + + + +
+ ); + } + return null; +} diff --git a/apps/layers/src/ui/layer-list.ts b/apps/layers/src/ui/layer-list.ts deleted file mode 100644 index 82c7f10..0000000 --- a/apps/layers/src/ui/layer-list.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IMGUI, radio } from "@thi.ng/imgui"; -import type { IGridLayout } from "@thi.ng/layout"; -import type { Layer } from "../types"; -import { whenDefined } from "./utils"; - - -export function layerListUI(gui: IMGUI, grid: IGridLayout, selectedIndex: number, layers: Layer[], pickLayer: (i: number) => void) { - const names = layers.map(l => l.type) - if (names.length > 0) { - whenDefined(radio(gui, grid, 'layers', false, selectedIndex, false, names), pickLayer); - } -} \ No newline at end of file diff --git a/apps/layers/src/ui/scatterplot-ui.tsx b/apps/layers/src/ui/scatterplot-ui.tsx new file mode 100644 index 0000000..e38524a --- /dev/null +++ b/apps/layers/src/ui/scatterplot-ui.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import type { Demo } from 'src/demo'; +import { InputSlider } from '@czi-sds/components'; +export function ScatterplotUI(props: { demo: Demo }) { + const { demo } = props; + // control the gamut with some sliders + const l = demo.layers[demo.selectedLayer]; + if ((l && l.type === 'scatterplot') || l.type === 'scatterplotGrid') { + return ( +
+ + { + demo.setPointSize(value as number); + }} + /> + + { + demo.setColorByIndex(value as number); + }} + /> +
+ ); + } + return null; +} diff --git a/apps/layers/src/ui/slice-ui.tsx b/apps/layers/src/ui/slice-ui.tsx new file mode 100644 index 0000000..b2a42f7 --- /dev/null +++ b/apps/layers/src/ui/slice-ui.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import type { Demo } from 'src/demo'; +import { InputSlider, Button } from '@czi-sds/components'; +export function SliceViewLayer(props: { demo: Demo }) { + const { demo } = props; + // control the gamut with some sliders + const l = demo.layers[demo.selectedLayer]; + if (l && l.type === 'volumeSlice') { + return ( +
+ + { + demo.setGamutChannel('R', value as number[]); + }} + /> + { + demo.setGamutChannel('G', value as number[]); + }} + /> + { + demo.setGamutChannel('B', value as number[]); + }} + /> + + { + demo.setSlice(value as number); + }} + /> + + + +
+ ); + } + return null; +} diff --git a/apps/layers/src/ui/utils.ts b/apps/layers/src/ui/utils.ts deleted file mode 100644 index e53efbc..0000000 --- a/apps/layers/src/ui/utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function whenDefined(result: T | undefined, action: (result: T) => void): T | undefined { - if (result !== undefined) { - Promise.resolve().then(() => action(result)) - } - return result; -} \ No newline at end of file diff --git a/apps/layers/src/ui/volume-slice-layer.ts b/apps/layers/src/ui/volume-slice-layer.ts deleted file mode 100644 index e726734..0000000 --- a/apps/layers/src/ui/volume-slice-layer.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IMGUI, radio, sliderH, } from "@thi.ng/imgui"; -import { whenDefined } from "./utils"; -import type { IGridLayout } from "@thi.ng/layout"; -import type { VolumetricSliceLayer } from "../types"; -import type { AxisAlignedPlane } from "../../../omezarr-viewer/src/slice-renderer"; - -const planes: AxisAlignedPlane[] = ['xy', 'xz', 'yz'] as const; -export function volumeSliceLayer(gui: IMGUI, grid: IGridLayout, layer: VolumetricSliceLayer, changeSlice: (i: number) => void, pickDim: (p: AxisAlignedPlane) => void) { - whenDefined(sliderH(gui, grid, 'slice param', 0, 1.0, 0.01, layer.data.planeParameter, 'slice'), changeSlice); - whenDefined(radio(gui, grid, 'plane', true, planes.indexOf(layer.data.plane), false, planes), (i) => pickDim(planes[i])); - -} \ No newline at end of file diff --git a/apps/layers/tsconfig.json b/apps/layers/tsconfig.json index e7f3c18..8fb771d 100644 --- a/apps/layers/tsconfig.json +++ b/apps/layers/tsconfig.json @@ -4,6 +4,7 @@ "../scatterplot/src/**/*", "../omezarr-viewer/src/**/*"], "compilerOptions": { + "jsx": "react", "baseUrl": "./", "paths": { "Common/*":["../common/src/*"] diff --git a/apps/omezarr-viewer/src/camera.ts b/apps/omezarr-viewer/src/camera.ts index d310821..5cda0b2 100644 --- a/apps/omezarr-viewer/src/camera.ts +++ b/apps/omezarr-viewer/src/camera.ts @@ -2,7 +2,8 @@ import type { box2D, vec2 } from "@alleninstitute/vis-geometry" // a basic camera, for viewing slices export type Camera = { - view: box2D; // a view in 'data space' - screen: vec2; // what that view projects to in display space, aka pixels + readonly view: box2D; // a view in 'data space' + readonly screen: vec2; // what that view projects to in display space, aka pixels + readonly projection: 'webImage' | 'cartesian' } diff --git a/apps/omezarr-viewer/src/versa-renderer.ts b/apps/omezarr-viewer/src/versa-renderer.ts index 229cc6b..6ce6605 100644 --- a/apps/omezarr-viewer/src/versa-renderer.ts +++ b/apps/omezarr-viewer/src/versa-renderer.ts @@ -4,6 +4,7 @@ import { Box2D, type Interval, Vec2, type box2D, type vec2, type vec4 } from "@a import { omit, slice } from "lodash"; import type { Camera } from "./camera"; import type { NestedArray, TypedArray } from "zarr"; +import { getSlicePool } from "Common/loaders/ome-zarr/sliceWorkerPool"; type Props = { target: Framebuffer2D | null; @@ -58,7 +59,6 @@ export function buildVersaRenderer(regl: REGL.Regl) { } void main(){ vec2 tileSize = tile.zw-tile.xy; - texCoord = rotateTextureCoordinates(pos,rot); vec2 obj = rotateObj((pos.xy*tileSize+tile.xy),rot); @@ -87,6 +87,7 @@ export function buildVersaRenderer(regl: REGL.Regl) { texture2D(G, texCoord).r, texture2D(B, texCoord).r )-mins) /span; + gl_FragColor = vec4(color, 1.0); }`, framebuffer: regl.prop("target"), @@ -195,14 +196,17 @@ export function cacheKeyFactory(col: string, item: VoxelTile, settings: VoxelSli return `${settings.dataset.url}_${JSON.stringify(omit(item, "desiredResolution"))}_ch=${settings.gamut[col as "R" | "G" | "B"].index }`; } + +function reqSlice(dataset: ZarrDataset, req: ZarrRequest, layerIndex: number) { + return getSlicePool().requestSlice(dataset, req, layerIndex) +} const LUMINANCE = "luminance"; export function requestsForTile(tile: VoxelTile, settings: VoxelSliceRenderSettings, signal?: AbortSignal) { const { dataset, regl } = settings; - const handleResponse = (vxl: Awaited>) => { - const { shape, buffer } = vxl; - const R = new Float32Array(buffer.flatten());// buffer.dtype === ').flatten(); + const handleResponse = (vxl: Awaited>) => { + const { shape, data } = vxl; const r = regl.texture({ - data: R, // new Float32Array(buffer), + data, width: shape[1], height: shape[0], // TODO this swap is sus format: LUMINANCE, @@ -212,15 +216,15 @@ export function requestsForTile(tile: VoxelTile, settings: VoxelSliceRenderSetti // lets hope the browser caches our 3x repeat calls to teh same data... return { R: async () => { - const vxl = await getSlice(dataset, toZarrRequest(tile, settings.gamut.R.index), tile.layerIndex); + const vxl = await reqSlice(dataset, toZarrRequest(tile, settings.gamut.R.index), tile.layerIndex); return { type: 'texture2D', data: handleResponse(vxl) } }, G: async () => { - const vxl = await getSlice(dataset, toZarrRequest(tile, settings.gamut.G.index), tile.layerIndex); + const vxl = await reqSlice(dataset, toZarrRequest(tile, settings.gamut.G.index), tile.layerIndex); return { type: 'texture2D', data: handleResponse(vxl) } }, B: async () => { - const vxl = await getSlice(dataset, toZarrRequest(tile, settings.gamut.B.index), tile.layerIndex); + const vxl = await reqSlice(dataset, toZarrRequest(tile, settings.gamut.B.index), tile.layerIndex); return { type: 'texture2D', data: handleResponse(vxl) } }, }; @@ -248,7 +252,6 @@ const sliceDimension = { yz: "x", } as const; - export function getVisibleTiles( camera: Camera, plane: AxisAlignedPlane, @@ -263,17 +266,17 @@ export function getVisibleTiles( dataset.multiscales[0].axes, dataset.multiscales[0].datasets[0] )!; - const thingy = pickBestScale( + const layer = pickBestScale( dataset, uvTable[plane], camera.view, camera.screen ); // TODO: open the array, look at its chunks, use that size for the size of the tiles I request! - const layerIndex = dataset.multiscales[0].datasets.indexOf(thingy); + const layerIndex = dataset.multiscales[0].datasets.indexOf(layer); - const size = planeSizeInVoxels(uvTable[plane], dataset.multiscales[0].axes, thingy); - const realSize = sizeInUnits(uvTable[plane], dataset.multiscales[0].axes, thingy); + const size = planeSizeInVoxels(uvTable[plane], dataset.multiscales[0].axes, layer); + const realSize = sizeInUnits(uvTable[plane], dataset.multiscales[0].axes, layer); if (!size || !realSize) return { layer: layerIndex, view: Box2D.create([0, 0], [1, 1]), tiles: [] }; const scale = Vec2.div(realSize, size); // to go from a voxel-box to a real-box (easier than you think, as both have an origin at 0,0, because we only support scale...) diff --git a/apps/scatterplot/src/demo.ts b/apps/scatterplot/src/demo.ts index 69572a2..0595a3b 100644 --- a/apps/scatterplot/src/demo.ts +++ b/apps/scatterplot/src/demo.ts @@ -1,7 +1,7 @@ import { Box2D, Vec2, type box2D, type vec2 } from "@alleninstitute/vis-geometry"; import { beginLongRunningFrame, AsyncDataCache, type FrameLifecycle } from "@alleninstitute/vis-scatterbrain"; import { getVisibleItems, type Dataset, type RenderSettings, fetchItem } from 'Common/loaders/scatterplot/data' -import { loadDataset, type ColumnarMetadata, type ColumnData, type ColumnarTree } from "Common/loaders/scatterplot/scatterbrain-loader"; +import { loadDataset, loadScatterbrainJson, type ColumnarMetadata, type ColumnarTree, type ColumnBuffer } from "Common/loaders/scatterplot/scatterbrain-loader"; import REGL from "regl"; import { buildRenderer } from "./renderer"; @@ -20,7 +20,7 @@ class Demo { renderer: ReturnType; mouse: 'up' | 'down' mousePos: vec2; - cache: AsyncDataCache; + cache: AsyncDataCache; curFrame: FrameLifecycle | null; constructor(canvas: HTMLCanvasElement, regl: REGL.Regl, url: string) { const [w, h] = [canvas.clientWidth, canvas.clientHeight]; @@ -29,11 +29,11 @@ class Demo { screen: [w, h] } this.curFrame = null; - this.cache = new AsyncDataCache((_data) => { - // no op destroyer - GC will clean up for us - }, (data: ColumnData) => data.data.byteLength, 500 * MB); + this.cache = new AsyncDataCache((entry) => { + entry.data.destroy(); + }, (_data: ColumnBuffer) => 1, 1000); - loadJSON(url).then((metadata) => { + loadScatterbrainJson(url).then((metadata) => { this.dataset = loadDataset(metadata, url) this.rerender(); }) @@ -77,11 +77,17 @@ class Demo { // lets only draw a box of points if its 90px wide: const sizeThreshold = 90 * px; const items = getVisibleItems(this.dataset, this.camera.view, sizeThreshold); - this.curFrame = beginLongRunningFrame, RenderSettings>( + this.curFrame = beginLongRunningFrame, RenderSettings>( 5, 33, items, this.cache, { dataset: this.dataset, view: this.camera.view, target: null, + colorBy: { + name: '88', + type: 'QUANTITATIVE', + }, + pointSize: 3, + regl: this.regl }, fetchItem, this.renderer, @@ -147,8 +153,5 @@ function setupEventHandlers(canvas: HTMLCanvasElement, demo: Demo) { }; } -async function loadJSON(url: string) { - // obviously, we should check or something - return fetch(url).then(stuff => stuff.json() as unknown as ColumnarMetadata) -} + demoTime(); \ No newline at end of file diff --git a/apps/scatterplot/src/renderer.ts b/apps/scatterplot/src/renderer.ts index c902129..fc5a530 100644 --- a/apps/scatterplot/src/renderer.ts +++ b/apps/scatterplot/src/renderer.ts @@ -1,5 +1,5 @@ import REGL, { type Framebuffer2D } from "regl"; -import type { ColumnData, ColumnarTree } from "Common/loaders/scatterplot/scatterbrain-loader"; +import type { ColumnBuffer, ColumnarTree } from "Common/loaders/scatterplot/scatterbrain-loader"; import type { RenderSettings } from "Common/loaders/scatterplot/data"; import { Box2D, type box2D, type vec2, type vec4 } from "@alleninstitute/vis-geometry"; @@ -7,26 +7,31 @@ type Props = { view: vec4; itemDepth: number; count: number; + pointSize: number; position: Float32Array, - color: Float32Array + color: Float32Array, + offset?: vec2 | undefined, target: Framebuffer2D | null; } export function buildRenderer(regl: REGL.Regl) { // build the regl command first - const cmd = regl<{ view: vec4, itemDepth: number }, { position: Float32Array, color: Float32Array }, Props>({ + const cmd = regl<{ view: vec4, itemDepth: number, offset: vec2, pointSize: number }, { position: Float32Array, color: Float32Array }, Props>({ vert: ` precision highp float; attribute vec2 position; attribute float color; - + + uniform float pointSize; uniform vec4 view; uniform float itemDepth; - varying vec4 clr; + uniform vec2 offset; + varying vec4 clr; + void main(){ - gl_PointSize=4.0; + gl_PointSize=pointSize; vec2 size = view.zw-view.xy; - vec2 pos = (position-view.xy)/size; + vec2 pos = ((position+offset)-view.xy)/size; vec2 clip = (pos*2.0)-1.0; // todo: gradients are cool @@ -48,6 +53,8 @@ export function buildRenderer(regl: REGL.Regl) { uniforms: { itemDepth: regl.prop("itemDepth"), view: regl.prop("view"), + offset: regl.prop("offset"), + pointSize: regl.prop("pointSize"), }, blend: { @@ -57,17 +64,19 @@ export function buildRenderer(regl: REGL.Regl) { count: regl.prop('count'), primitive: "points", }) - const renderDots = (item: ColumnarTree, settings: RenderSettings, columns: Record) => { + const renderDots = (item: ColumnarTree & { offset?: vec2 | undefined }, settings: RenderSettings, columns: Record) => { const { color, position } = columns; const count = item.content.count; const itemDepth = item.content.depth - if (color && position && 'type' in color && 'type' in position && color.type === 'float' && position.type === 'float') { + if (color && position && 'type' in color && 'type' in position && color.type === 'vbo' && position.type === 'vbo') { cmd({ view: Box2D.toFlatArray(settings.view), count, itemDepth, position: position.data, + pointSize: settings.pointSize, color: color.data, + offset: item.offset ?? [0, 0], target: settings.target }) } else { diff --git a/apps/scatterplot/tsconfig.json b/apps/scatterplot/tsconfig.json index e6b45f0..d1537fd 100644 --- a/apps/scatterplot/tsconfig.json +++ b/apps/scatterplot/tsconfig.json @@ -1,5 +1,10 @@ { "extends": "../tsconfig.json", - "include": ["./src/**/*", "../common/src/**/*"] - + "include": ["./src/**/*", "../common/src/**/*"], + "compilerOptions": { + "baseUrl": "./", + "paths": { + "Common/*":["../common/src/*"] + } + } } \ No newline at end of file diff --git a/apps/tsconfig.json b/apps/tsconfig.json index a71fcb2..060e202 100644 --- a/apps/tsconfig.json +++ b/apps/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../tsconfig.base.json", "compilerOptions": { "strict": true, - "moduleResolution": "Bundler", + "moduleResolution": "Node", "module": "ES2022", "target": "ES2022", "lib": [ diff --git a/packages/scatterbrain/src/render-queue.ts b/packages/scatterbrain/src/render-queue.ts index 233eaeb..b3239f7 100644 --- a/packages/scatterbrain/src/render-queue.ts +++ b/packages/scatterbrain/src/render-queue.ts @@ -129,7 +129,6 @@ export function beginLongRunningFrame( // pass the error somewhere better: lifecycleCallback({ status: 'error', error: err }); }; - while (mutableCache.getNumPendingTasks() < Math.max(maximumInflightAsyncTasks, 1)) { if (queue.length < 1) { // we cant add anything to the in-flight staging area, the final task @@ -152,7 +151,6 @@ export function beginLongRunningFrame( // put this cancel callback in a list where we can invoke if something goes wrong // note that it is harmless to cancel a task that was completed taskCancelCallbacks.push(result); - result } } catch (err) { cleanupOnError(err); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 134a95e..b33bf6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,27 @@ importers: specifier: ^5.3.3 version: 5.3.3 + apps/common: + dependencies: + '@alleninstitute/vis-geometry': + specifier: workspace:* + version: link:../../packages/geometry + '@alleninstitute/vis-scatterbrain': + specifier: workspace:* + version: link:../../packages/scatterbrain + '@types/lodash': + specifier: ^4.14.202 + version: 4.14.202 + lodash: + specifier: ^4.17.21 + version: 4.17.21 + regl: + specifier: ^2.1.0 + version: 2.1.0 + zarr: + specifier: ^0.6.2 + version: 0.6.2 + apps/layers: dependencies: '@alleninstitute/vis-geometry': @@ -35,33 +56,57 @@ importers: '@alleninstitute/vis-scatterbrain': specifier: workspace:* version: link:../../packages/scatterbrain - '@thi.ng/imgui': - specifier: ^2.2.54 - version: 2.2.54 - '@thi.ng/layout': - specifier: ^3.0.36 - version: 3.0.36 - '@thi.ng/rdom-canvas': - specifier: ^0.5.83 - version: 0.5.83 - '@thi.ng/rstream': - specifier: ^8.4.0 - version: 8.4.0 - '@thi.ng/rstream-gestures': - specifier: ^5.0.70 - version: 5.0.70 + '@czi-sds/components': + specifier: ^20.0.1 + version: 20.0.1(@emotion/core@11.0.0)(@emotion/css@11.11.2)(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@mui/base@5.0.0-beta.40)(@mui/icons-material@5.15.15)(@mui/lab@5.0.0-alpha.170)(@mui/material@5.15.15)(react-dom@18.3.0)(react@18.3.0) + '@emotion/css': + specifier: ^11.11.2 + version: 11.11.2 + '@emotion/react': + specifier: ^11.11.4 + version: 11.11.4(@types/react@18.3.0)(react@18.3.0) + '@emotion/styled': + specifier: ^11.11.5 + version: 11.11.5(@emotion/react@11.11.4)(@types/react@18.3.0)(react@18.3.0) + '@mui/base': + specifier: 5.0.0-beta.40 + version: 5.0.0-beta.40(@types/react@18.3.0)(react-dom@18.3.0)(react@18.3.0) + '@mui/icons-material': + specifier: ^5.15.15 + version: 5.15.15(@mui/material@5.15.15)(@types/react@18.3.0)(react@18.3.0) + '@mui/lab': + specifier: 5.0.0-alpha.170 + version: 5.0.0-alpha.170(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@mui/material@5.15.15)(@types/react@18.3.0)(react-dom@18.3.0)(react@18.3.0) + '@mui/material': + specifier: ^5.15.15 + version: 5.15.15(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@types/react@18.3.0)(react-dom@18.3.0)(react@18.3.0) '@types/lodash': specifier: ^4.14.202 version: 4.14.202 + file-saver: + specifier: ^2.0.5 + version: 2.0.5 json5: specifier: ^2.2.3 version: 2.2.3 + kiwi-schema: + specifier: ^0.5.0 + version: 0.5.0 lodash: specifier: ^4.17.21 version: 4.17.21 + react: + specifier: ^18.3.0 + version: 18.3.0 + react-dom: + specifier: ^18.3.0 + version: 18.3.0(react@18.3.0) regl: specifier: ^2.1.0 version: 2.1.0 + zarr: + specifier: ^0.6.2 + version: 0.6.2 devDependencies: '@parcel/packager-ts': specifier: ^2.12.0 @@ -69,6 +114,15 @@ importers: '@parcel/transformer-typescript-types': specifier: ^2.12.0 version: 2.12.0(@parcel/core@2.12.0)(typescript@5.3.3) + '@types/file-saver': + specifier: ^2.0.7 + version: 2.0.7 + '@types/react': + specifier: ^18.3.0 + version: 18.3.0 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.0 esbuild: specifier: ^0.19.12 version: 0.19.12 @@ -189,12 +243,27 @@ packages: dependencies: '@babel/highlight': 7.23.4 chalk: 2.4.2 - dev: true + + /@babel/helper-module-imports@7.24.3: + resolution: {integrity: sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.5 + dev: false + + /@babel/helper-string-parser@7.24.1: + resolution: {integrity: sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==} + engines: {node: '>=6.9.0'} + dev: false /@babel/helper-validator-identifier@7.22.20: resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==} engines: {node: '>=6.9.0'} - dev: true + + /@babel/helper-validator-identifier@7.24.5: + resolution: {integrity: sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA==} + engines: {node: '>=6.9.0'} + dev: false /@babel/highlight@7.23.4: resolution: {integrity: sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==} @@ -203,7 +272,178 @@ packages: '@babel/helper-validator-identifier': 7.22.20 chalk: 2.4.2 js-tokens: 4.0.0 - dev: true + + /@babel/runtime@7.24.5: + resolution: {integrity: sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.1 + dev: false + + /@babel/types@7.24.5: + resolution: {integrity: sha512-6mQNsaLeXTw0nxYUYu+NSa4Hx4BlF1x1x8/PMFbiR+GBSr+2DkECc69b8hgy2frEodNcvPffeH8YfWd3LI6jhQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.24.1 + '@babel/helper-validator-identifier': 7.24.5 + to-fast-properties: 2.0.0 + dev: false + + /@czi-sds/components@20.0.1(@emotion/core@11.0.0)(@emotion/css@11.11.2)(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@mui/base@5.0.0-beta.40)(@mui/icons-material@5.15.15)(@mui/lab@5.0.0-alpha.170)(@mui/material@5.15.15)(react-dom@18.3.0)(react@18.3.0): + resolution: {integrity: sha512-vB3gGl+tzxDmV00J8ioLr/LIj1WU26448Pot9orgyGeZy+AaJM7WMT/qsSpPSCGEKLN5ykcIUBIDIGK1E/JZmQ==} + peerDependencies: + '@emotion/core': ^11.0.0 + '@emotion/css': ^11.11.2 + '@emotion/react': ^11.11.3 + '@emotion/styled': ^11.11.0 + '@mui/base': ^5.0.0-beta.30 + '@mui/icons-material': ^5.15.3 + '@mui/lab': ^5.0.0-alpha.159 + '@mui/material': ^5.15.3 + react: '>=17.0.1' + react-dom: '>=17.0.1' + dependencies: + '@emotion/core': 11.0.0 + '@emotion/css': 11.11.2 + '@emotion/react': 11.11.4(@types/react@18.3.0)(react@18.3.0) + '@emotion/styled': 11.11.5(@emotion/react@11.11.4)(@types/react@18.3.0)(react@18.3.0) + '@mui/base': 5.0.0-beta.40(@types/react@18.3.0)(react-dom@18.3.0)(react@18.3.0) + '@mui/icons-material': 5.15.15(@mui/material@5.15.15)(@types/react@18.3.0)(react@18.3.0) + '@mui/lab': 5.0.0-alpha.170(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@mui/material@5.15.15)(@types/react@18.3.0)(react-dom@18.3.0)(react@18.3.0) + '@mui/material': 5.15.15(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@types/react@18.3.0)(react-dom@18.3.0)(react@18.3.0) + react: 18.3.0 + react-dom: 18.3.0(react@18.3.0) + dev: false + + /@emotion/babel-plugin@11.11.0: + resolution: {integrity: sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==} + dependencies: + '@babel/helper-module-imports': 7.24.3 + '@babel/runtime': 7.24.5 + '@emotion/hash': 0.9.1 + '@emotion/memoize': 0.8.1 + '@emotion/serialize': 1.1.4 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + dev: false + + /@emotion/cache@11.11.0: + resolution: {integrity: sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==} + dependencies: + '@emotion/memoize': 0.8.1 + '@emotion/sheet': 1.2.2 + '@emotion/utils': 1.2.1 + '@emotion/weak-memoize': 0.3.1 + stylis: 4.2.0 + dev: false + + /@emotion/core@11.0.0: + resolution: {integrity: sha512-w4sE3AmHmyG6RDKf6mIbtHpgJUSJ2uGvPQb8VXFL7hFjMPibE8IiehG8cMX3Ztm4svfCQV6KqusQbeIOkurBcA==} + dev: false + + /@emotion/css@11.11.2: + resolution: {integrity: sha512-VJxe1ucoMYMS7DkiMdC2T7PWNbrEI0a39YRiyDvK2qq4lXwjRbVP/z4lpG+odCsRzadlR+1ywwrTzhdm5HNdew==} + dependencies: + '@emotion/babel-plugin': 11.11.0 + '@emotion/cache': 11.11.0 + '@emotion/serialize': 1.1.4 + '@emotion/sheet': 1.2.2 + '@emotion/utils': 1.2.1 + dev: false + + /@emotion/hash@0.9.1: + resolution: {integrity: sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==} + dev: false + + /@emotion/is-prop-valid@1.2.2: + resolution: {integrity: sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==} + dependencies: + '@emotion/memoize': 0.8.1 + dev: false + + /@emotion/memoize@0.8.1: + resolution: {integrity: sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==} + dev: false + + /@emotion/react@11.11.4(@types/react@18.3.0)(react@18.3.0): + resolution: {integrity: sha512-t8AjMlF0gHpvvxk5mAtCqR4vmxiGHCeJBaQO6gncUSdklELOgtwjerNY2yuJNfwnc6vi16U/+uMF+afIawJ9iw==} + peerDependencies: + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.5 + '@emotion/babel-plugin': 11.11.0 + '@emotion/cache': 11.11.0 + '@emotion/serialize': 1.1.4 + '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.3.0) + '@emotion/utils': 1.2.1 + '@emotion/weak-memoize': 0.3.1 + '@types/react': 18.3.0 + hoist-non-react-statics: 3.3.2 + react: 18.3.0 + dev: false + + /@emotion/serialize@1.1.4: + resolution: {integrity: sha512-RIN04MBT8g+FnDwgvIUi8czvr1LU1alUMI05LekWB5DGyTm8cCBMCRpq3GqaiyEDRptEXOyXnvZ58GZYu4kBxQ==} + dependencies: + '@emotion/hash': 0.9.1 + '@emotion/memoize': 0.8.1 + '@emotion/unitless': 0.8.1 + '@emotion/utils': 1.2.1 + csstype: 3.1.3 + dev: false + + /@emotion/sheet@1.2.2: + resolution: {integrity: sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==} + dev: false + + /@emotion/styled@11.11.5(@emotion/react@11.11.4)(@types/react@18.3.0)(react@18.3.0): + resolution: {integrity: sha512-/ZjjnaNKvuMPxcIiUkf/9SHoG4Q196DRl1w82hQ3WCsjo1IUR8uaGWrC6a87CrYAW0Kb/pK7hk8BnLgLRi9KoQ==} + peerDependencies: + '@emotion/react': ^11.0.0-rc.0 + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.5 + '@emotion/babel-plugin': 11.11.0 + '@emotion/is-prop-valid': 1.2.2 + '@emotion/react': 11.11.4(@types/react@18.3.0)(react@18.3.0) + '@emotion/serialize': 1.1.4 + '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.3.0) + '@emotion/utils': 1.2.1 + '@types/react': 18.3.0 + react: 18.3.0 + dev: false + + /@emotion/unitless@0.8.1: + resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==} + dev: false + + /@emotion/use-insertion-effect-with-fallbacks@1.0.1(react@18.3.0): + resolution: {integrity: sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==} + peerDependencies: + react: '>=16.8.0' + dependencies: + react: 18.3.0 + dev: false + + /@emotion/utils@1.2.1: + resolution: {integrity: sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==} + dev: false + + /@emotion/weak-memoize@0.3.1: + resolution: {integrity: sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==} + dev: false /@esbuild/aix-ppc64@0.19.12: resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} @@ -619,6 +859,34 @@ packages: dev: true optional: true + /@floating-ui/core@1.6.1: + resolution: {integrity: sha512-42UH54oPZHPdRHdw6BgoBD6cg/eVTmVrFcgeRDM3jbO7uxSoipVcmcIGFcA5jmOHO5apcyvBhkSKES3fQJnu7A==} + dependencies: + '@floating-ui/utils': 0.2.2 + dev: false + + /@floating-ui/dom@1.6.4: + resolution: {integrity: sha512-0G8R+zOvQsAG1pg2Q99P21jiqxqGBW1iRe/iXHsBRBxnpXKFI8QwbB4x5KmYLggNO5m34IQgOIu9SCRfR/WWiQ==} + dependencies: + '@floating-ui/core': 1.6.1 + '@floating-ui/utils': 0.2.2 + dev: false + + /@floating-ui/react-dom@2.0.9(react-dom@18.3.0)(react@18.3.0): + resolution: {integrity: sha512-q0umO0+LQK4+p6aGyvzASqKbKOJcAHJ7ycE9CuUvfx3s9zTHWmGJTPOIlM/hmSBfUfg/XfY5YhLBLR/LHwShQQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@floating-ui/dom': 1.6.4 + react: 18.3.0 + react-dom: 18.3.0(react@18.3.0) + dev: false + + /@floating-ui/utils@0.2.2: + resolution: {integrity: sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==} + dev: false + /@jest/schemas@29.6.3: resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -745,6 +1013,217 @@ packages: dev: true optional: true + /@mui/base@5.0.0-beta.40(@types/react@18.3.0)(react-dom@18.3.0)(react@18.3.0): + resolution: {integrity: sha512-I/lGHztkCzvwlXpjD2+SNmvNQvB4227xBXhISPjEaJUXGImOQ9f3D2Yj/T3KasSI/h0MLWy74X0J6clhPmsRbQ==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.5 + '@floating-ui/react-dom': 2.0.9(react-dom@18.3.0)(react@18.3.0) + '@mui/types': 7.2.14(@types/react@18.3.0) + '@mui/utils': 5.15.14(@types/react@18.3.0)(react@18.3.0) + '@popperjs/core': 2.11.8 + '@types/react': 18.3.0 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 18.3.0 + react-dom: 18.3.0(react@18.3.0) + dev: false + + /@mui/core-downloads-tracker@5.15.15: + resolution: {integrity: sha512-aXnw29OWQ6I5A47iuWEI6qSSUfH6G/aCsW9KmW3LiFqr7uXZBK4Ks+z8G+qeIub8k0T5CMqlT2q0L+ZJTMrqpg==} + dev: false + + /@mui/icons-material@5.15.15(@mui/material@5.15.15)(@types/react@18.3.0)(react@18.3.0): + resolution: {integrity: sha512-kkeU/pe+hABcYDH6Uqy8RmIsr2S/y5bP2rp+Gat4CcRjCcVne6KudS1NrZQhUCRysrTDCAhcbcf9gt+/+pGO2g==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@mui/material': ^5.0.0 + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.5 + '@mui/material': 5.15.15(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@types/react@18.3.0)(react-dom@18.3.0)(react@18.3.0) + '@types/react': 18.3.0 + react: 18.3.0 + dev: false + + /@mui/lab@5.0.0-alpha.170(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@mui/material@5.15.15)(@types/react@18.3.0)(react-dom@18.3.0)(react@18.3.0): + resolution: {integrity: sha512-0bDVECGmrNjd3+bLdcLiwYZ0O4HP5j5WSQm5DV6iA/Z9kr8O6AnvZ1bv9ImQbbX7Gj3pX4o43EKwCutj3EQxQg==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@mui/material': '>=5.15.0' + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.5 + '@emotion/react': 11.11.4(@types/react@18.3.0)(react@18.3.0) + '@emotion/styled': 11.11.5(@emotion/react@11.11.4)(@types/react@18.3.0)(react@18.3.0) + '@mui/base': 5.0.0-beta.40(@types/react@18.3.0)(react-dom@18.3.0)(react@18.3.0) + '@mui/material': 5.15.15(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@types/react@18.3.0)(react-dom@18.3.0)(react@18.3.0) + '@mui/system': 5.15.15(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@types/react@18.3.0)(react@18.3.0) + '@mui/types': 7.2.14(@types/react@18.3.0) + '@mui/utils': 5.15.14(@types/react@18.3.0)(react@18.3.0) + '@types/react': 18.3.0 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 18.3.0 + react-dom: 18.3.0(react@18.3.0) + dev: false + + /@mui/material@5.15.15(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@types/react@18.3.0)(react-dom@18.3.0)(react@18.3.0): + resolution: {integrity: sha512-3zvWayJ+E1kzoIsvwyEvkTUKVKt1AjchFFns+JtluHCuvxgKcLSRJTADw37k0doaRtVAsyh8bz9Afqzv+KYrIA==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.5 + '@emotion/react': 11.11.4(@types/react@18.3.0)(react@18.3.0) + '@emotion/styled': 11.11.5(@emotion/react@11.11.4)(@types/react@18.3.0)(react@18.3.0) + '@mui/base': 5.0.0-beta.40(@types/react@18.3.0)(react-dom@18.3.0)(react@18.3.0) + '@mui/core-downloads-tracker': 5.15.15 + '@mui/system': 5.15.15(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@types/react@18.3.0)(react@18.3.0) + '@mui/types': 7.2.14(@types/react@18.3.0) + '@mui/utils': 5.15.14(@types/react@18.3.0)(react@18.3.0) + '@types/react': 18.3.0 + '@types/react-transition-group': 4.4.10 + clsx: 2.1.1 + csstype: 3.1.3 + prop-types: 15.8.1 + react: 18.3.0 + react-dom: 18.3.0(react@18.3.0) + react-is: 18.2.0 + react-transition-group: 4.4.5(react-dom@18.3.0)(react@18.3.0) + dev: false + + /@mui/private-theming@5.15.14(@types/react@18.3.0)(react@18.3.0): + resolution: {integrity: sha512-UH0EiZckOWcxiXLX3Jbb0K7rC8mxTr9L9l6QhOZxYc4r8FHUkefltV9VDGLrzCaWh30SQiJvAEd7djX3XXY6Xw==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.5 + '@mui/utils': 5.15.14(@types/react@18.3.0)(react@18.3.0) + '@types/react': 18.3.0 + prop-types: 15.8.1 + react: 18.3.0 + dev: false + + /@mui/styled-engine@5.15.14(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(react@18.3.0): + resolution: {integrity: sha512-RILkuVD8gY6PvjZjqnWhz8fu68dVkqhM5+jYWfB5yhlSQKg+2rHkmEwm75XIeAqI3qwOndK6zELK5H6Zxn4NHw==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.4.1 + '@emotion/styled': ^11.3.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + dependencies: + '@babel/runtime': 7.24.5 + '@emotion/cache': 11.11.0 + '@emotion/react': 11.11.4(@types/react@18.3.0)(react@18.3.0) + '@emotion/styled': 11.11.5(@emotion/react@11.11.4)(@types/react@18.3.0)(react@18.3.0) + csstype: 3.1.3 + prop-types: 15.8.1 + react: 18.3.0 + dev: false + + /@mui/system@5.15.15(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(@types/react@18.3.0)(react@18.3.0): + resolution: {integrity: sha512-aulox6N1dnu5PABsfxVGOZffDVmlxPOVgj56HrUnJE8MCSh8lOvvkd47cebIVQQYAjpwieXQXiDPj5pwM40jTQ==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.5 + '@emotion/react': 11.11.4(@types/react@18.3.0)(react@18.3.0) + '@emotion/styled': 11.11.5(@emotion/react@11.11.4)(@types/react@18.3.0)(react@18.3.0) + '@mui/private-theming': 5.15.14(@types/react@18.3.0)(react@18.3.0) + '@mui/styled-engine': 5.15.14(@emotion/react@11.11.4)(@emotion/styled@11.11.5)(react@18.3.0) + '@mui/types': 7.2.14(@types/react@18.3.0) + '@mui/utils': 5.15.14(@types/react@18.3.0)(react@18.3.0) + '@types/react': 18.3.0 + clsx: 2.1.1 + csstype: 3.1.3 + prop-types: 15.8.1 + react: 18.3.0 + dev: false + + /@mui/types@7.2.14(@types/react@18.3.0): + resolution: {integrity: sha512-MZsBZ4q4HfzBsywtXgM1Ksj6HDThtiwmOKUXH1pKYISI9gAVXCNHNpo7TlGoGrBaYWZTdNoirIN7JsQcQUjmQQ==} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.3.0 + dev: false + + /@mui/utils@5.15.14(@types/react@18.3.0)(react@18.3.0): + resolution: {integrity: sha512-0lF/7Hh/ezDv5X7Pry6enMsbYyGKjADzvHyo3Qrc/SSlTsQ1VkbDMbH0m2t3OR5iIVLwMoxwM7yGd+6FCMtTFA==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.5 + '@types/prop-types': 15.7.12 + '@types/react': 18.3.0 + prop-types: 15.8.1 + react: 18.3.0 + react-is: 18.2.0 + dev: false + /@parcel/bundler-default@2.12.0(@parcel/core@2.12.0): resolution: {integrity: sha512-3ybN74oYNMKyjD6V20c9Gerdbh7teeNvVMwIoHIQMzuIFT6IGX53PyOLlOKRLbjxMc0TMimQQxIt2eQqxR5LsA==} engines: {node: '>= 12.0.0', parcel: ^2.12.0} @@ -1669,6 +2148,10 @@ packages: nullthrows: 1.1.1 dev: true + /@popperjs/core@2.11.8: + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + dev: false + /@rollup/rollup-android-arm-eabi@4.14.1: resolution: {integrity: sha512-fH8/o8nSUek8ceQnT7K4EQbSiV7jgkHq81m9lWZFIXjJ7lJzpWXbQFpT/Zh6OZYnpFykvzC3fbEvEAFZu03dPA==} cpu: [arm] @@ -1923,553 +2406,6 @@ packages: resolution: {integrity: sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==} dev: true - /@thi.ng/api@8.10.1: - resolution: {integrity: sha512-US1MziG0YlyacscmMBGitQhxjnd3wna/zzMPiIkiXLXT7T8h66E36ySLsg2Qve+4QK82tCzaYzAS1PusOPSSbw==} - engines: {node: '>=18'} - dev: false - - /@thi.ng/arrays@2.9.3: - resolution: {integrity: sha512-kblEIAMyuS/mWXgjvKNDWLdKx5J+tbGWnriH6KUwS5j37lqBuqLN0LRI2IMb/AcWchPcHJmgeKQM/s7s5oUWgw==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/checks': 3.6.1 - '@thi.ng/compare': 2.3.2 - '@thi.ng/equiv': 2.1.55 - '@thi.ng/errors': 2.5.4 - '@thi.ng/random': 3.7.3 - dev: false - - /@thi.ng/associative@6.3.56: - resolution: {integrity: sha512-cG6Y48SWqQFFrxYmTYMifJwl8pNGBeLfBCK7BDyOLM4luDUNESSmtv/JdUtnSvgdmY5v4JnTXVUANt43WgyDlw==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/arrays': 2.9.3 - '@thi.ng/binary': 3.4.22 - '@thi.ng/checks': 3.6.1 - '@thi.ng/compare': 2.3.2 - '@thi.ng/dcons': 3.2.109 - '@thi.ng/equiv': 2.1.55 - '@thi.ng/errors': 2.5.4 - '@thi.ng/random': 3.7.3 - '@thi.ng/transducers': 9.0.1 - tslib: 2.6.2 - dev: false - - /@thi.ng/atom@5.2.43: - resolution: {integrity: sha512-PrWlTdF1IY67e0lGPfM+rRJ/FEFd1jTnia/YTHHYXgczcdjivrGvViPKV7MRSHLBmogB+zSTeN89SJ8y1f07RA==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/equiv': 2.1.55 - '@thi.ng/errors': 2.5.4 - '@thi.ng/paths': 5.1.78 - tslib: 2.6.2 - dev: false - - /@thi.ng/binary@3.4.22: - resolution: {integrity: sha512-4gZfVqbG97RZlnsiiH+L966Wvqu5Oyigao+OLEa7L/HyDoWBPXvmZ/W8L+onVst6mk1Vg6CdzE5kIUmYRwVMgg==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - dev: false - - /@thi.ng/canvas@0.2.16: - resolution: {integrity: sha512-0emIObw4nF6q3KBrrrTruoCIjtHpD9l9gL84VgHnNt3nHxl8IW2c9c96GqsW99vP2zncK63jZKcgsN8PNf8ZfA==} - engines: {node: '>=18'} - dev: false - - /@thi.ng/checks@3.6.1: - resolution: {integrity: sha512-Q8ZnXmjSvxyhjOORDXdCNMe1M0ceSdhTlYi1CE0kS5KtZwTN7bsYwj7p5pX9iEZLxQMox7aISV1PcL5u+Laklg==} - engines: {node: '>=18'} - dependencies: - tslib: 2.6.2 - dev: false - - /@thi.ng/color@5.6.41: - resolution: {integrity: sha512-CYtj4T5lNTOnhru34IgeMIgHiWZir/FF0oe0HZYFKLBCmhWK4LN5ETTyogDPuU3DCzDkO9Qe+SJDbO+wzshD9g==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/arrays': 2.9.3 - '@thi.ng/binary': 3.4.22 - '@thi.ng/checks': 3.6.1 - '@thi.ng/compare': 2.3.2 - '@thi.ng/compose': 3.0.1 - '@thi.ng/defmulti': 3.0.36 - '@thi.ng/errors': 2.5.4 - '@thi.ng/math': 5.10.10 - '@thi.ng/random': 3.7.3 - '@thi.ng/strings': 3.7.30 - '@thi.ng/transducers': 9.0.1 - '@thi.ng/vectors': 7.10.27 - dev: false - - /@thi.ng/compare@2.3.2: - resolution: {integrity: sha512-u/GFWkSU8mARqDD6cPMb8DfQKTcsCA8Wxd0t11Es+mJlZmaRisRSngNdRwOtOaPM6KnHezfH113RyYRHuq4spw==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - dev: false - - /@thi.ng/compose@3.0.1: - resolution: {integrity: sha512-CFm9OBo07K2sYM3YzAVGunFZd4RqPMhZu8xnqZN65HKIhWhRIR3zfHdirxZ1QAFix4ZGSPUJ008nLerk/gwXoA==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/errors': 2.5.4 - dev: false - - /@thi.ng/dcons@3.2.109: - resolution: {integrity: sha512-XxzDyLZMDJRl3TjjLU1QbMMpBwIWKB24ylU4huF3mOQyQzbdUcbpqdRxE7JuMlEsgpRa2394RuVgdaCcyMIsrg==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/checks': 3.6.1 - '@thi.ng/compare': 2.3.2 - '@thi.ng/equiv': 2.1.55 - '@thi.ng/errors': 2.5.4 - '@thi.ng/random': 3.7.3 - '@thi.ng/transducers': 9.0.1 - dev: false - - /@thi.ng/defmulti@3.0.36: - resolution: {integrity: sha512-mVTuzmJ5lGjSN1ZeBCyRrd3cWDzzDeQ9GKdT5QztOqh5gKzKmW4vnFcWpwU8wMuBq8Ow0tjd/syK5YEyO7i8Cg==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/errors': 2.5.4 - '@thi.ng/logger': 3.0.9 - dev: false - - /@thi.ng/distance@2.4.69: - resolution: {integrity: sha512-EAiAA2W0k2hohnlFDaAc2nULzTGSATfCznq4FsgBZN0c2LiUrBAub6ExEHl0OYK+hRmFGLI/Skbu71bchMtEUw==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/checks': 3.6.1 - '@thi.ng/errors': 2.5.4 - '@thi.ng/heaps': 2.1.71 - '@thi.ng/math': 5.10.10 - '@thi.ng/vectors': 7.10.27 - dev: false - - /@thi.ng/equiv@2.1.55: - resolution: {integrity: sha512-fTY7c2cXEbWdNf7oLIb5yWXMHRyZtHAf1MS639QKOQXbMrSFS7WB4NKAiOXHGh2HIFoJLjRXjzE0KPVQf3XQxA==} - engines: {node: '>=18'} - dev: false - - /@thi.ng/errors@2.5.4: - resolution: {integrity: sha512-ZwlKNchTWg5vKWFZ+YgjLm509JCrKnxCwVTCY0NFpu/KbEXAvjdObfY8bGXI3rBj9rRlzJ3HAQGNYB8ieSuj4w==} - engines: {node: '>=18'} - dev: false - - /@thi.ng/geom-api@4.0.6: - resolution: {integrity: sha512-Y09bBNnTCMwbYh84SV/PMXrKuJ9cjRGQkAOPYOPT5HIEbHzmEYASQ1/UQwUF9vt/xUKIlvVg8/0VROWZ33wfsg==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/vectors': 7.10.27 - dev: false - - /@thi.ng/geom-arc@2.1.128: - resolution: {integrity: sha512-Dz3I36kNc9U9hZtcZ45JHzW4hchbgXkB47BxaXQsskdV5aNh43Nq15w8CWrgCnLF07W3/gy+JFF+b853dehumQ==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/checks': 3.6.1 - '@thi.ng/geom-api': 4.0.6 - '@thi.ng/geom-resample': 2.3.49 - '@thi.ng/math': 5.10.10 - '@thi.ng/vectors': 7.10.27 - dev: false - - /@thi.ng/geom-clip-line@2.3.85: - resolution: {integrity: sha512-33cBaH9Ls+4bixlW5iQvM7CBmy2OjJztgf7YwwoVjnnYNamcBA3l8b/LPmHQYN2+h9fHvz+Vz8wH+o647eWitw==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/geom-isec': 3.0.6 - '@thi.ng/vectors': 7.10.27 - dev: false - - /@thi.ng/geom-clip-poly@2.1.127: - resolution: {integrity: sha512-8bngXnKEKXGS7/KXB4KaeKrId2g9Oobn+1fCTbk00GP7PKV/pDy1NmPOcm1HmU7nzhWi3LnX71KM7lS3qGjm9A==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/geom-isec': 3.0.6 - '@thi.ng/geom-poly-utils': 2.3.111 - '@thi.ng/math': 5.10.10 - '@thi.ng/vectors': 7.10.27 - dev: false - - /@thi.ng/geom-closest-point@2.1.123: - resolution: {integrity: sha512-PEHWNk54S45jdV7JHA6zeOW4Pj17rA3nU1Jl7pE27JAmEJc7otJuivNEL0eUtlWW2wTa1zUCMsEbo0Uot4IxYA==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/math': 5.10.10 - '@thi.ng/vectors': 7.10.27 - dev: false - - /@thi.ng/geom-hull@2.1.123: - resolution: {integrity: sha512-TNkihoyA2E8aX6SyFaqDq2B/GXzlrIkAqhhrNhsXIGge6toYB0NhalfZjekX76r0/Dupv7MsJsAonKPPgJ6zeg==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/math': 5.10.10 - '@thi.ng/vectors': 7.10.27 - dev: false - - /@thi.ng/geom-isec@3.0.6: - resolution: {integrity: sha512-4V6eEtyFMBZatI6NaANRu8KRbB8Y/y2EZTZN/RLRrMCXfJ/SdhRZFeKRN9z9hiArFSdnGB4koYFeFm7YIBfiyg==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/geom-api': 4.0.6 - '@thi.ng/geom-closest-point': 2.1.123 - '@thi.ng/math': 5.10.10 - '@thi.ng/vectors': 7.10.27 - dev: false - - /@thi.ng/geom-poly-utils@2.3.111: - resolution: {integrity: sha512-n1SwT6ronOJob4aHexLbEQbzp4oYJO9m615plxjNvj4SV7NItAp7yJpDHrK40lTvlaZVNeCgXpr2rjB30xjH/A==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/errors': 2.5.4 - '@thi.ng/geom-api': 4.0.6 - '@thi.ng/math': 5.10.10 - '@thi.ng/vectors': 7.10.27 - dev: false - - /@thi.ng/geom-resample@2.3.49: - resolution: {integrity: sha512-LggjPH0/kS+IPjZZUpn6XsFh2dJ1CfL14WnQ97EilCC1kJ2ok+GrBl1VDCCdk/3J55u0Tpyrqne9RwXZNg7www==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/checks': 3.6.1 - '@thi.ng/geom-api': 4.0.6 - '@thi.ng/geom-closest-point': 2.1.123 - '@thi.ng/geom-isec': 3.0.6 - '@thi.ng/math': 5.10.10 - '@thi.ng/vectors': 7.10.27 - dev: false - - /@thi.ng/geom-splines@2.2.102: - resolution: {integrity: sha512-JHzERhMDcsFwZqMUTBHu/EL4tmoPdsmIM0N5fmNQMZFkLwTry4KMhNLt/yDsPRDMBV4jC0NE611GUmnKHanvzA==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/checks': 3.6.1 - '@thi.ng/geom-api': 4.0.6 - '@thi.ng/geom-arc': 2.1.128 - '@thi.ng/geom-resample': 2.3.49 - '@thi.ng/math': 5.10.10 - '@thi.ng/vectors': 7.10.27 - dev: false - - /@thi.ng/geom-subdiv-curve@2.1.127: - resolution: {integrity: sha512-wJTyl0jKYtgi2mDb62CXG+ghw71yj/PO/QWz+d/GGvl5uWGtn6Xg1hAhKQLVgK/G4YJWnuWSLkWwpL0BRbdyag==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/geom-api': 4.0.6 - '@thi.ng/transducers': 9.0.1 - '@thi.ng/vectors': 7.10.27 - dev: false - - /@thi.ng/geom-tessellate@2.1.128: - resolution: {integrity: sha512-5lDDrJrN9xd0p1qcfKAKLC6tM8AxmiPabQJ76IMIYVxpoXvC8rAZXiwluz6U8w9rqMJjBUMnTtvNQ0uiclBRGg==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/checks': 3.6.1 - '@thi.ng/geom-api': 4.0.6 - '@thi.ng/geom-isec': 3.0.6 - '@thi.ng/geom-poly-utils': 2.3.111 - '@thi.ng/transducers': 9.0.1 - '@thi.ng/vectors': 7.10.27 - dev: false - - /@thi.ng/geom@6.1.6: - resolution: {integrity: sha512-GZQ+GLnrNcLgRzewPnEafvJSlRWpdDn4EEg0XMcQkRUzg7PGnBKMCs78dy2NowF4lzYZ1408wDYmZ7QKL3v2jQ==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/arrays': 2.9.3 - '@thi.ng/associative': 6.3.56 - '@thi.ng/checks': 3.6.1 - '@thi.ng/defmulti': 3.0.36 - '@thi.ng/equiv': 2.1.55 - '@thi.ng/errors': 2.5.4 - '@thi.ng/geom-api': 4.0.6 - '@thi.ng/geom-arc': 2.1.128 - '@thi.ng/geom-clip-line': 2.3.85 - '@thi.ng/geom-clip-poly': 2.1.127 - '@thi.ng/geom-closest-point': 2.1.123 - '@thi.ng/geom-hull': 2.1.123 - '@thi.ng/geom-isec': 3.0.6 - '@thi.ng/geom-poly-utils': 2.3.111 - '@thi.ng/geom-resample': 2.3.49 - '@thi.ng/geom-splines': 2.2.102 - '@thi.ng/geom-subdiv-curve': 2.1.127 - '@thi.ng/geom-tessellate': 2.1.128 - '@thi.ng/hiccup': 5.1.28 - '@thi.ng/hiccup-svg': 5.2.33 - '@thi.ng/math': 5.10.10 - '@thi.ng/matrices': 2.3.33 - '@thi.ng/random': 3.7.3 - '@thi.ng/strings': 3.7.30 - '@thi.ng/transducers': 9.0.1 - '@thi.ng/vectors': 7.10.27 - dev: false - - /@thi.ng/heaps@2.1.71: - resolution: {integrity: sha512-PoDb7heemmzXghZRBkDBHYh7bHI01TpDeMdzlbNKbL6500s8iDabSpLj+OjdhOZd7SCZhzfUbrqj5ytp8ihDOg==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/compare': 2.3.2 - '@thi.ng/equiv': 2.1.55 - dev: false - - /@thi.ng/hex@2.3.43: - resolution: {integrity: sha512-DHMY+TqifDPPW4BCF1ftVPjXx1rvOJRPFGtwOsmg3TBbPtVqT93qZivb1/vCapPRsS3wXehNRmaJ87xiZWGwKw==} - engines: {node: '>=18'} - dev: false - - /@thi.ng/hiccup-canvas@2.5.35: - resolution: {integrity: sha512-sagNB02I/11HKx18Irsy8qF1tV6tGtkjt0d7wThiIm/cg2ZVODujWjUpf+5iLmORd6CcgsP+1ahhcIVfjwEAxg==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/checks': 3.6.1 - '@thi.ng/color': 5.6.41 - '@thi.ng/math': 5.10.10 - '@thi.ng/pixel': 6.1.28 - '@thi.ng/vectors': 7.10.27 - dev: false - - /@thi.ng/hiccup-svg@5.2.33: - resolution: {integrity: sha512-iv67d0WEQdwM7gTV4HYvWusDKoHFYdfN8p7HWdZj7wVFxMpIxwu69fgTcCRa4ZwCO2Rf1Yy7TH4jpZgiLwXsHA==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/checks': 3.6.1 - '@thi.ng/color': 5.6.41 - '@thi.ng/prefixes': 2.3.16 - dev: false - - /@thi.ng/hiccup@5.1.28: - resolution: {integrity: sha512-ZBfxkMV/oOGvUponw9dcT6rbmxajrSH9y2TsYSJ3vIOLlOCDBneFgtIhew291cpatFQIQXmpQ9Vl0hWjXda5wg==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/checks': 3.6.1 - '@thi.ng/errors': 2.5.4 - '@thi.ng/strings': 3.7.30 - dev: false - - /@thi.ng/imgui@2.2.54: - resolution: {integrity: sha512-duPdPMNjZ90+1x8WFKB3CUF3w11+IdpLNxdu2WzeSmu3eaQ4IwwX/jt52HMwupMfRbv4rf1WuRfBlBLLSc6jcQ==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/checks': 3.6.1 - '@thi.ng/geom': 6.1.6 - '@thi.ng/geom-api': 4.0.6 - '@thi.ng/geom-isec': 3.0.6 - '@thi.ng/geom-tessellate': 2.1.128 - '@thi.ng/layout': 3.0.36 - '@thi.ng/math': 5.10.10 - '@thi.ng/transducers': 9.0.1 - '@thi.ng/vectors': 7.10.27 - dev: false - - /@thi.ng/k-means@0.6.84: - resolution: {integrity: sha512-Yh7uoJKkSREgOHM4hsrKGIjcYx0E6RQTuSwh8tvnWAc95xRwctPaDqemx97YS0K71NPEimmKQcB/3pR+kil06A==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/distance': 2.4.69 - '@thi.ng/errors': 2.5.4 - '@thi.ng/random': 3.7.3 - '@thi.ng/vectors': 7.10.27 - dev: false - - /@thi.ng/layout@3.0.36: - resolution: {integrity: sha512-XOb0oMOfy/cy1Htg8TzLwTpMATnIVpcZTy0/uqKbaPSgppXSjEppxzIk/ErNp8VM/ozhUBah1QuFwdLuXkRZTQ==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/arrays': 2.9.3 - '@thi.ng/checks': 3.6.1 - dev: false - - /@thi.ng/logger@3.0.9: - resolution: {integrity: sha512-XtCfseT6Fzz1IzJQSq5pAfuASmaf/TF25YF/Q3E0yy+d3SXQqHIDepDzX4lOgXm6DH4ThQSjF0IAr/+I0Rj13A==} - engines: {node: '>=18'} - dev: false - - /@thi.ng/math@5.10.10: - resolution: {integrity: sha512-ZG5s/qDwUfVbPYIk+6xHJW+XWfmKOw9MiJFnFRltiOONmOlHV3em7ixXNxX6Q5oESyFDEnuiXDhn15OK252fuw==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - dev: false - - /@thi.ng/matrices@2.3.33: - resolution: {integrity: sha512-HIxB/JzqsIhrfNhkXImGIlZkzud17R40U1+HaRRrV0jwvtXgleK4Sud7RmkBMW7zVfaIl3TGHq/OGkywUT7jgw==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/checks': 3.6.1 - '@thi.ng/math': 5.10.10 - '@thi.ng/vectors': 7.10.27 - dev: false - - /@thi.ng/memoize@3.3.1: - resolution: {integrity: sha512-r28l1mMet32cEg14W9yEnc70tnpMUV6bm9iKlpZDG48hyBL5cMRKByGVufveCTQ9HQovTF80gCR+4G9DcO/pJg==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - dev: false - - /@thi.ng/paths@5.1.78: - resolution: {integrity: sha512-7TFrkXqIXR6EEdr2sYP6E3+m6y5rqprqbGjN7+N8bYEo/Xeyy1OFv9a/uQEe9l7eA3AnSeRTseBYu/ZPBCqDkQ==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/checks': 3.6.1 - '@thi.ng/errors': 2.5.4 - dev: false - - /@thi.ng/pixel@6.1.28: - resolution: {integrity: sha512-FXdsYnEPOsAHXp1O1WSVT+bMYRVUNV5wfgQ6UtrAYPw4NOd/EjFj8c1U/JAmI5Gpy54xj3P5mQ6ZgEYOhTDWxw==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/binary': 3.4.22 - '@thi.ng/canvas': 0.2.16 - '@thi.ng/checks': 3.6.1 - '@thi.ng/distance': 2.4.69 - '@thi.ng/errors': 2.5.4 - '@thi.ng/k-means': 0.6.84 - '@thi.ng/math': 5.10.10 - '@thi.ng/porter-duff': 2.1.76 - dev: false - - /@thi.ng/porter-duff@2.1.76: - resolution: {integrity: sha512-cJx4VUiUZsZiHD2xIpKNdqDNhUGBOPpqigA7MPjHAs7FShypsjRgynvb/1CFhIG931TTHIjSwTg2u/29xmKgnw==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/math': 5.10.10 - dev: false - - /@thi.ng/prefixes@2.3.16: - resolution: {integrity: sha512-Oti+spEuglFAlqRQTLa39ZUELbiA0zfGM/87pwUUUHZaVY8Rlj/9Iiu1TDteU8hm6q+IxA69KYtse749PrRo3w==} - engines: {node: '>=18'} - dev: false - - /@thi.ng/random@3.7.3: - resolution: {integrity: sha512-h6JjpqtMVSxqSXZt7lzuUCQ6yfoEgkotgyLOgUDWq7usBWKDLMRBh3XqUum6bR+3/tJSkx3Bn3XQAVxIAkco/w==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/checks': 3.6.1 - '@thi.ng/errors': 2.5.4 - '@thi.ng/hex': 2.3.43 - dev: false - - /@thi.ng/rdom-canvas@0.5.83: - resolution: {integrity: sha512-5CmjyPELAiBLmcRnIQxTOATuMs3erDtrjOAl0Jvx7zUgHtx2ahbUvYEmzbhTAs92khxy5kXXpm19PyyVPEbb+Q==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/associative': 6.3.56 - '@thi.ng/canvas': 0.2.16 - '@thi.ng/checks': 3.6.1 - '@thi.ng/hiccup-canvas': 2.5.35 - '@thi.ng/rdom': 1.2.0 - '@thi.ng/rstream': 8.4.0 - dev: false - - /@thi.ng/rdom@1.2.0: - resolution: {integrity: sha512-leDgMjtJx2wRGkondebhrQpZ4UEMf+2rSTXSNdX+YsgVzGkF41CV4PLpSAZtyC/FAN5uD0ibi7RjUYtKf/QGkQ==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/checks': 3.6.1 - '@thi.ng/errors': 2.5.4 - '@thi.ng/hiccup': 5.1.28 - '@thi.ng/paths': 5.1.78 - '@thi.ng/prefixes': 2.3.16 - '@thi.ng/rstream': 8.4.0 - '@thi.ng/strings': 3.7.30 - dev: false - - /@thi.ng/rstream-gestures@5.0.70: - resolution: {integrity: sha512-TAgo6YgkX1L1JSvOkdD6Pna8TdWOVStT5Hz5agcilGs+dtMHgjp0Nphj7lyrIQGsBP6ntwYnG6kISbIhKKli/A==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/checks': 3.6.1 - '@thi.ng/math': 5.10.10 - '@thi.ng/rstream': 8.4.0 - '@thi.ng/transducers': 9.0.1 - dev: false - - /@thi.ng/rstream@8.4.0: - resolution: {integrity: sha512-qYh6pJp0MKDX+Pnko0aESsaW0xREJnelyDPkIvxLrXj5kiVzjMJvCITFiTdIPZvKumFipvx27F4zPk0lcjI7bw==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/arrays': 2.9.3 - '@thi.ng/associative': 6.3.56 - '@thi.ng/atom': 5.2.43 - '@thi.ng/checks': 3.6.1 - '@thi.ng/errors': 2.5.4 - '@thi.ng/logger': 3.0.9 - '@thi.ng/transducers': 9.0.1 - dev: false - - /@thi.ng/strings@3.7.30: - resolution: {integrity: sha512-74UtXYTwuseyI1cr3YVghoN1K7FOQ1N+dFlhT0TrzBs6WKxjQHEhGU3MFAUZULkBou4VVaUo/b55o+gSiplWXA==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/errors': 2.5.4 - '@thi.ng/hex': 2.3.43 - '@thi.ng/memoize': 3.3.1 - dev: false - - /@thi.ng/transducers@9.0.1: - resolution: {integrity: sha512-oI7dOvYxo/l2NTqcFmuM4xbgl0JrW+OKuD3XUuKMhNVzxkm60TnxQZ+DrORJX+Ot0VHIYuv1vk5ha7fPZysDxg==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/arrays': 2.9.3 - '@thi.ng/checks': 3.6.1 - '@thi.ng/compare': 2.3.2 - '@thi.ng/compose': 3.0.1 - '@thi.ng/errors': 2.5.4 - '@thi.ng/math': 5.10.10 - '@thi.ng/random': 3.7.3 - dev: false - - /@thi.ng/vectors@7.10.27: - resolution: {integrity: sha512-zjTkQY7nbOQnGiNPOcZnwpNF5ExgS17k7q2GXPotFbjiYa0JkxENoyFLxxUCtFe2K1apZ35XvAGDPJtTyOkepw==} - engines: {node: '>=18'} - dependencies: - '@thi.ng/api': 8.10.1 - '@thi.ng/binary': 3.4.22 - '@thi.ng/checks': 3.6.1 - '@thi.ng/equiv': 2.1.55 - '@thi.ng/errors': 2.5.4 - '@thi.ng/math': 5.10.10 - '@thi.ng/memoize': 3.3.1 - '@thi.ng/random': 3.7.3 - '@thi.ng/strings': 3.7.30 - '@thi.ng/transducers': 9.0.1 - dev: false - /@trysound/sax@0.2.0: resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -2483,9 +2419,38 @@ packages: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} dev: true + /@types/file-saver@2.0.7: + resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==} + dev: true + /@types/lodash@4.14.202: resolution: {integrity: sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==} + /@types/parse-json@4.0.2: + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + dev: false + + /@types/prop-types@15.7.12: + resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} + + /@types/react-dom@18.3.0: + resolution: {integrity: sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==} + dependencies: + '@types/react': 18.3.0 + dev: true + + /@types/react-transition-group@4.4.10: + resolution: {integrity: sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==} + dependencies: + '@types/react': 18.3.0 + dev: false + + /@types/react@18.3.0: + resolution: {integrity: sha512-DiUcKjzE6soLyln8NNZmyhcQjVv+WsUIFSqetMN0p8927OztKT4VTfFTqsbAi5oAGIcgOmOajlfBqyptDDjZRw==} + dependencies: + '@types/prop-types': 15.7.12 + csstype: 3.1.3 + /@vitest/expect@1.4.0: resolution: {integrity: sha512-Jths0sWCJZ8BxjKe+p+eKsoqev1/T8lYcrjavEaz8auEJ4jAVY0GwW3JKmdVU4mmNPLPHixh4GNXP7GFtAiDHA==} dependencies: @@ -2551,7 +2516,6 @@ packages: engines: {node: '>=4'} dependencies: color-convert: 1.9.3 - dev: true /ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} @@ -2573,6 +2537,15 @@ packages: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} dev: true + /babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + dependencies: + '@babel/runtime': 7.24.5 + cosmiconfig: 7.1.0 + resolve: 1.22.8 + dev: false + /base-x@3.0.9: resolution: {integrity: sha512-H7JU6iBHTal1gp56aKoaa//YUxEaAOUiydvrV/pILqIHXTtqxSkATOnDA2u+jZ/61sD+L/412+7kzXRtWukhpQ==} dependencies: @@ -2620,7 +2593,6 @@ packages: /callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - dev: true /caniuse-lite@1.0.30001581: resolution: {integrity: sha512-whlTkwhqV2tUmP3oYhtNfaWGYHDdS3JYFQBKXxcUR9qqPWsRhFHhoISO2Xnl/g0xyKzht9mI1LZpiNWfMzHixQ==} @@ -2646,7 +2618,6 @@ packages: ansi-styles: 3.2.1 escape-string-regexp: 1.0.5 supports-color: 5.5.0 - dev: true /chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -2672,11 +2643,15 @@ packages: engines: {node: '>=0.8'} dev: true + /clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + dev: false + /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: color-name: 1.1.3 - dev: true /color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} @@ -2687,7 +2662,6 @@ packages: /color-name@1.1.3: resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} - dev: true /color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} @@ -2698,6 +2672,21 @@ packages: engines: {node: '>= 10'} dev: true + /convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + dev: false + + /cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.0 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.2 + dev: false + /cosmiconfig@8.3.6(typescript@5.3.3): resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} engines: {node: '>=14'} @@ -2753,6 +2742,9 @@ packages: css-tree: 1.1.3 dev: true + /csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + /debug@4.3.4: resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} engines: {node: '>=6.0'} @@ -2788,6 +2780,13 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dev: true + /dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dependencies: + '@babel/runtime': 7.24.5 + csstype: 3.1.3 + dev: false + /dom-serializer@1.4.1: resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} dependencies: @@ -2841,7 +2840,6 @@ packages: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} dependencies: is-arrayish: 0.2.1 - dev: true /esbuild@0.19.12: resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} @@ -2913,7 +2911,11 @@ packages: /escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} - dev: true + + /escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + dev: false /estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -2940,6 +2942,10 @@ packages: strip-final-newline: 3.0.0 dev: true + /file-saver@2.0.5: + resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==} + dev: false + /fill-range@7.0.1: resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} engines: {node: '>=8'} @@ -2947,6 +2953,10 @@ packages: to-regex-range: 5.0.1 dev: true + /find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + dev: false + /fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2955,6 +2965,10 @@ packages: dev: true optional: true + /function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + dev: false + /get-func-name@2.0.2: resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} dev: true @@ -2979,13 +2993,25 @@ packages: /has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} - dev: true /has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} dev: true + /hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + dependencies: + function-bind: 1.1.2 + dev: false + + /hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + dependencies: + react-is: 16.13.1 + dev: false + /htmlnano@2.1.0(svgo@2.8.0)(typescript@5.3.3): resolution: {integrity: sha512-jVGRE0Ep9byMBKEu0Vxgl8dhXYOUk0iNQ2pjsG+BcRB0u0oDF5A9p/iBGMg/PGKYUyMD0OAGu8dVT5Lzj8S58g==} peerDependencies: @@ -3047,11 +3073,15 @@ packages: dependencies: parent-module: 1.0.1 resolve-from: 4.0.0 - dev: true /is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - dev: true + + /is-core-module@2.13.1: + resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==} + dependencies: + hasown: 2.0.2 + dev: false /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} @@ -3085,7 +3115,6 @@ packages: /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - dev: true /js-tokens@9.0.0: resolution: {integrity: sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ==} @@ -3100,7 +3129,6 @@ packages: /json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - dev: true /json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} @@ -3111,6 +3139,11 @@ packages: resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==} dev: true + /kiwi-schema@0.5.0: + resolution: {integrity: sha512-X+FpfU0yTEtc6aTHS7VwbOpvQwRt70+pXXWRI5fd6CvWhe7pSVC854TVo4Zo0x5/wwcWj+/9KUlXpdcP0dY9AA==} + hasBin: true + dev: false + /lightningcss-darwin-arm64@1.24.0: resolution: {integrity: sha512-rTNPkEiynOu4CfGdd5ZfVOQe2gd2idfQd4EfX1l2ZUUwd+2SwSdbb7cG4rlwfnZckbzCAygm85xkpekRE5/wFw==} engines: {node: '>= 12.0.0'} @@ -3211,7 +3244,6 @@ packages: /lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - dev: true /lmdb@2.8.5: resolution: {integrity: sha512-9bMdFfc80S+vSldBmG3HOuLVHnxRdNTlpzR6QDnzqCQtCzGUEAGTzBKYMeIM+I/sU4oZfgbcbS7X7F65/z/oxQ==} @@ -3244,6 +3276,13 @@ packages: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} dev: false + /loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + dependencies: + js-tokens: 4.0.0 + dev: false + /loupe@2.3.7: resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} dependencies: @@ -3375,6 +3414,11 @@ packages: engines: {node: '>=12'} dev: false + /object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + dev: false + /onetime@6.0.0: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} @@ -3410,9 +3454,6 @@ packages: resolution: {integrity: sha512-W+gxAq7aQ9dJIg/XLKGcRT0cvnStFAQHPaI0pvD0U2l6IVLueUAm3nwN7lkY62zZNmlvNx6jNtE4wlbS+CyqSg==} engines: {node: '>= 12.0.0'} hasBin: true - peerDependenciesMeta: - '@parcel/core': - optional: true dependencies: '@parcel/config-default': 2.12.0(@parcel/core@2.12.0)(typescript@5.3.3) '@parcel/core': 2.12.0 @@ -3445,7 +3486,6 @@ packages: engines: {node: '>=6'} dependencies: callsites: 3.1.0 - dev: true /parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} @@ -3455,7 +3495,6 @@ packages: error-ex: 1.3.2 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 - dev: true /path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} @@ -3467,10 +3506,13 @@ packages: engines: {node: '>=12'} dev: true + /path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + dev: false + /path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} - dev: true /pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -3553,23 +3595,69 @@ packages: engines: {node: '>= 0.6.0'} dev: true + /prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + dev: false + + /react-dom@18.3.0(react@18.3.0): + resolution: {integrity: sha512-zaKdLBftQJnvb7FtDIpZtsAIb2MZU087RM8bRDZU8LVCCFYjPTsDZJNFUWPcVz3HFSN1n/caxi0ca4B/aaVQGQ==} + peerDependencies: + react: ^18.3.0 + dependencies: + loose-envify: 1.4.0 + react: 18.3.0 + scheduler: 0.23.1 + dev: false + /react-error-overlay@6.0.9: resolution: {integrity: sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==} dev: true + /react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + dev: false + /react-is@18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} - dev: true /react-refresh@0.9.0: resolution: {integrity: sha512-Gvzk7OZpiqKSkxsQvO/mbTN1poglhmAV7gR/DdIrRrSMXraRQQlfikRJOr3Nb9GTMPC5kof948Zy6jJZIFtDvQ==} engines: {node: '>=0.10.0'} dev: true + /react-transition-group@4.4.5(react-dom@18.3.0)(react@18.3.0): + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + dependencies: + '@babel/runtime': 7.24.5 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.3.0 + react-dom: 18.3.0(react@18.3.0) + dev: false + + /react@18.3.0: + resolution: {integrity: sha512-RPutkJftSAldDibyrjuku7q11d3oy6wKOyPe5K1HA/HwwrXcEqBdHsLypkC2FFYjP7bPUa6gbzSBhw4sY2JcDg==} + engines: {node: '>=0.10.0'} + dependencies: + loose-envify: 1.4.0 + dev: false + /regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} dev: true + /regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + dev: false + /regl@2.1.0: resolution: {integrity: sha512-oWUce/aVoEvW5l2V0LK7O5KJMzUSKeiOwFuJehzpSFd43dO5spP9r+sSUfhKtsky4u6MCqWJaRL+abzExynfTg==} dev: false @@ -3577,7 +3665,15 @@ packages: /resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} - dev: true + + /resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + hasBin: true + dependencies: + is-core-module: 2.13.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: false /rollup@4.14.1: resolution: {integrity: sha512-4LnHSdd3QK2pa1J6dFbfm1HN0D7vSK/ZuZTsdyUAlA6Rr1yTouUTL13HaDOGJVgby461AhrNGBS7sCGXXtT+SA==} @@ -3608,6 +3704,12 @@ packages: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} dev: true + /scheduler@0.23.1: + resolution: {integrity: sha512-5GKS5JGfiah1O38Vfa9srZE4s3wdHbwjlCrvIookrg2FO9aIwKLOJXuJQFlEfNcVSOXuaL2hzDeY20uVXcUtrw==} + dependencies: + loose-envify: 1.4.0 + dev: false + /semver@7.5.4: resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} engines: {node: '>=10'} @@ -3642,6 +3744,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + dev: false + /source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -3676,12 +3783,15 @@ packages: js-tokens: 9.0.0 dev: true + /stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + dev: false + /supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} dependencies: has-flag: 3.0.0 - dev: true /supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} @@ -3690,6 +3800,11 @@ packages: has-flag: 4.0.0 dev: true + /supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + dev: false + /svgo@2.8.0: resolution: {integrity: sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==} engines: {node: '>=10.13.0'} @@ -3727,6 +3842,11 @@ packages: engines: {node: '>=14.0.0'} dev: true + /to-fast-properties@2.0.0: + resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} + engines: {node: '>=4'} + dev: false + /to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3736,6 +3856,7 @@ packages: /tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + dev: true /type-detect@4.0.8: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} @@ -3909,6 +4030,11 @@ packages: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} dev: true + /yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + dev: false + /yocto-queue@1.0.0: resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} engines: {node: '>=12.20'}