From 31c648401c41a94171fca836c578db59b8d43a52 Mon Sep 17 00:00:00 2001 From: noah Date: Wed, 19 Nov 2025 13:04:55 -0800 Subject: [PATCH 01/29] move scatterbrain into vis for realsies - not just as a quick minimal demo. initial thoughts --- packages/geometry/package.json | 9 +- packages/scatterbrain/package.json | 68 ++++++++++ packages/scatterbrain/readme.md | 23 ++++ packages/scatterbrain/src/index.ts | 0 packages/scatterbrain/src/loader.ts | 0 packages/scatterbrain/src/render.ts | 35 +++++ packages/scatterbrain/src/shader.ts | 158 +++++++++++++++++++++++ packages/scatterbrain/src/typed-array.ts | 91 +++++++++++++ packages/scatterbrain/src/types.ts | 81 ++++++++++++ packages/scatterbrain/tsconfig.json | 13 ++ pnpm-lock.yaml | 54 +++++++- 11 files changed, 530 insertions(+), 2 deletions(-) create mode 100644 packages/scatterbrain/package.json create mode 100644 packages/scatterbrain/readme.md create mode 100644 packages/scatterbrain/src/index.ts create mode 100644 packages/scatterbrain/src/loader.ts create mode 100644 packages/scatterbrain/src/render.ts create mode 100644 packages/scatterbrain/src/shader.ts create mode 100644 packages/scatterbrain/src/typed-array.ts create mode 100644 packages/scatterbrain/src/types.ts create mode 100644 packages/scatterbrain/tsconfig.json diff --git a/packages/geometry/package.json b/packages/geometry/package.json index 9a843928..a218c0b0 100644 --- a/packages/geometry/package.json +++ b/packages/geometry/package.json @@ -48,8 +48,15 @@ "type": "git", "url": "https://github.com/AllenInstitute/vis.git" }, + "devDependencies": { + "@parcel/packager-ts": "2.15.4", + "@parcel/transformer-typescript-types": "2.15.4", + "parcel": "2.15.4", + "typescript": "5.9.3", + "vitest": "4.0.6" + }, "publishConfig": { "registry": "https://npm.pkg.github.com/AllenInstitute" }, "packageManager": "pnpm@9.14.2" -} +} \ No newline at end of file diff --git a/packages/scatterbrain/package.json b/packages/scatterbrain/package.json new file mode 100644 index 00000000..968e4671 --- /dev/null +++ b/packages/scatterbrain/package.json @@ -0,0 +1,68 @@ +{ + "name": "@alleninstitute/vis-scatterbrain", + "version": "0.0.1", + "contributors": [ + { + "name": "Lane Sawyer", + "email": "lane.sawyer@alleninstitute.org" + }, + { + "name": "James Gerstenberger", + "email": "james.gerstenberger@alleninstitute.org" + }, + { + "name": "Noah Shepard", + "email": "noah.shepard@alleninstitute.org" + }, + { + "name": "Skyler Moosman", + "email": "skyler.moosman@alleninstitute.org" + }, + { + "name": "Su Li", + "email": "su.li@alleninstitute.org" + }, + { + "name": "Joel Arbuckle", + "email": "joel.arbuckle@alleninstitute.org" + } + ], + "license": "BSD-3-Clause", + "source": "src/index.ts", + "main": "dist/main.js", + "types": "dist/types.d.ts", + "type": "module", + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsc --noEmit", + "build": "parcel build --no-cache", + "dev": "parcel watch --port 1237", + "test": "vitest --watch", + "test:ci": "vitest run", + "coverage": "vitest run --coverage", + "changelog": "git-cliff -o changelog.md" + }, + "repository": { + "type": "git", + "url": "https://github.com/AllenInstitute/vis.git" + }, + "devDependencies": { + "@parcel/packager-ts": "2.15.4", + "@parcel/transformer-typescript-types": "2.15.4", + "parcel": "2.15.4", + "typescript": "5.9.3", + "vitest": "4.0.6" + }, + "dependencies": { + "@alleninstitute/vis-core": "workspace:*", + "@alleninstitute/vis-geometry": "workspace:*", + "regl": "2.1.0", + "ts-pattern": "5.9.0" + }, + "publishConfig": { + "registry": "https://npm.pkg.github.com/AllenInstitute" + }, + "packageManager": "pnpm@9.14.2" +} \ No newline at end of file diff --git a/packages/scatterbrain/readme.md b/packages/scatterbrain/readme.md new file mode 100644 index 00000000..7d033ea6 --- /dev/null +++ b/packages/scatterbrain/readme.md @@ -0,0 +1,23 @@ + +# Scatterbrain + +rendering utilities to render scatter plots, with specific support for 'scatterbrain' style (quad-tree spatial indexed) data sources as used for years now in ABC-atlas + +## Why? ## + +We wrote the ABC-atlas version of Scatterbrain rendering in a hot hot hurry a few years ago. At the time, we were preparing for a lot of variation along certain paths of development, +and as is often the case, those guesses were a bit off the mark. As a result, the flexibility points we built into that version are not helping us much. For example, we take great pains to +generate shaders with readable "column names" as users select different filter settings. However, for reasons, the names are just referenceIds from the backend, and we really never bother to debug the shaders in that way - they either fail up-front, or work fine, or are broken in more subtle ways that tend to have nothing to do with the names of the data. + +## What is the goal here? ## + +The goal is to modernize and simplify the WebGL powered features of ABC-atlas. We'd like to be able to understand the management of rendering resources, have more comprehensible interop +with the rest of the React UI system, and in general reduce the confusingly generic (and needlessly generic) nature of various parts, as well as having less verbose and weird areas around +the actual features we did build (but didn't really prepare for) like: + +hovering to report cell-info to the rest of the system +multiple gene coloring / filtering +slide-view / regular view + +to this end, we are gonna stick to the Renderer<> interface as given in packages/core/src/abstract/types.ts. We've seen that work with both the somewhat Ill-fated renderServer, as well as the new shared-cache +stuff, so it seems like it might be ok. diff --git a/packages/scatterbrain/src/index.ts b/packages/scatterbrain/src/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/scatterbrain/src/loader.ts b/packages/scatterbrain/src/loader.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/scatterbrain/src/render.ts b/packages/scatterbrain/src/render.ts new file mode 100644 index 00000000..8328106a --- /dev/null +++ b/packages/scatterbrain/src/render.ts @@ -0,0 +1,35 @@ +import * as REGL from 'regl' +import { ReglCacheEntry, type Renderer } from '@alleninstitute/vis-core' +import { ScatterbrainDataset, SlideviewScatterbrainDataset } from './types' +// lets get a renderer up and rolling +// then add features from there... + + + +type Settings = {} +type Item = {} +type ScatterbrainRenderer = Renderer +function buildScatterbrainRenderer(regl: REGL.Regl): ScatterbrainRenderer { + + return { + getVisibleItems: function (data: ScatterbrainDataset, settings: Settings): Item[] { + + }, + fetchItemContent: function (item: Item, dataset: ScatterbrainDataset, settings: Settings): Record Promise> { + throw new Error('Function not implemented.') + }, + isPrepared: function (cacheData: Record): cacheData is {} { + throw new Error('Function not implemented.') + }, + renderItem: function (target: REGL.Framebuffer2D | null, item: Item, data: ScatterbrainDataset, settings: Settings, gpuData: {}): void { + throw new Error('Function not implemented.') + }, + cacheKey: function (item: Item, requestKey: string, data: ScatterbrainDataset, settings: Settings): string { + throw new Error('Function not implemented.') + }, + destroy: function (regl: REGL.Regl): void { + throw new Error('Function not implemented.') + } + } + +} \ No newline at end of file diff --git a/packages/scatterbrain/src/shader.ts b/packages/scatterbrain/src/shader.ts new file mode 100644 index 00000000..4ef0c865 --- /dev/null +++ b/packages/scatterbrain/src/shader.ts @@ -0,0 +1,158 @@ + +import { match } from 'ts-pattern' +import { vec4, type Interval } from '@alleninstitute/vis-geometry' +type ScatterplotShaderConfig = { + categoricalFilters: readonly string[][] // the set of column names that have active filters in them. these will be mapped to vertex attributes COLUMN_${index} + // our original approach used the column names as vertex attrib names in-shader code. in addition to requiring sanitization + // this was not any less confusing than generic "COLUMN_X" naming, as the names are ref-ids anyway. + + internalNames: { + lookup: 'lookupTable', + tableSize: 'tableSize', + } +} + + +// this shader... has to support a lot of features! +/* +1. filter by N columns +2. color by a column-lookup, or by a gradient, overridden by filtering status +3. hover - which is powered by the filter-data (we pack extra info into the alpha channel...) +4. exfiltrate data like cell id and expression values via color-out +5. configurable Z-axis settings (high-expression cells in front, filtered out in back, etc) +6. size-change on hover + +*/ +const attrib = (i: number) => `COLUMN_${i.toFixed(0)}` +export function generateShader(config: ScatterplotShaderConfig) { + + + const { categoricalFilters, internalNames } = config; + const { lookup, tableSize } = internalNames + // we do still want to generate shaders - there are going to be a bunch of shaders that are only slightly different from each other + // and so a templated approach can be good + const categoryOrder = categoricalFilters.flat().reduce((acc, cur, i) => ({ ...acc, [cur]: i }), {} as Record) + const readColumnSnippet = (i: number) =>/*glsl*/`texture2D(${lookup},vec2(${i.toFixed(0)}.5/${tableSize}.x , (${attrib(i)}+0.5)/${tableSize}.y))` + const readColumnFilterStatus = (i: number) => `step(0.5,${readColumnSnippet(i)}.a)` + // the snippet that powers filtering - a cell is filtered in if and only if lookup-table[COLUMN_X] has a non-zero alpha value for every "X" + const filteredInSnippet = categoricalFilters.map((OR) => + `(${OR.map((category) => readColumnFilterStatus(categoryOrder[category])).join('+')})`).join('*') + + // returns a string that performs the filtering logic - + // in pseudocode, and example might be (lookup(COL_0))*(lookup(COL_1)+lookup(COL_2)) + + // this computes the CNF-style filter COL_0 AND (COL_1 OR COl_2) + // note that the returned string will be filled with inline GLSL and will look way less legible than the above comment, as it must deal with texture sizes, offsets, etc... + // return filteredInSnippet + + // a dot can be hovered + // it has a radius, depth, and a color, and is either filtered in or out + // we're going to define glsl functions that define all these aspects + // note that they're not all completely independant - for example we'd like to move filtered-out cells to the back + // in cases like that, we would call getIsFilteredIn() from within getDepth() + + + +} + +// of course - the most flexible thing would be to let user's of this system write whatever shader they like +// thats nice, but I think lets try a more "configure based on a few options" path for now + +type DepthMode = { mode: 'quantitative', column: number, gamut: Interval, reverse?: boolean } | { mode: 'constant', value: number } +type CellDepthConfig = { + hovered: DepthMode, + filteredOut: DepthMode, + normal: DepthMode, +} +function getDepth(config: CellDepthConfig) { + const depthValueSnippet = (mode: DepthMode) => + match(mode) + .with({ mode: 'constant' }, (c) => `${c.value}`) + .otherwise(({ column, gamut, reverse }) => /*glsl*/`clamp(-1.0,1.0,${reverse ? '-' : ''}(${attrib(column)}-${gamut.min})/${gamut.max - gamut.min})`) + + const { hovered, filteredOut, normal } = config; + return /*glsl*/`float getDepth(){ + return mix( + ${depthValueSnippet(filteredOut)}, + mix(${depthValueSnippet(normal)},${depthValueSnippet(hovered)},isHovered()), + getIsFilteredIn()); + }` +} +type CellFilterConfig = { + lookup: string; + tableSize: string; + CNFColumns: number[][] // CNF = Clausal normal form, ANDs of ORs, like so: [[3,4],[1,0,2]] reads (3 OR 4) AND (1 OR 0 OR 2). the numbers are the column indexes. +} +// TODO: we have at least 3 different types of filtering +// categorical CNF-style filtering, spatial filtering (the selection box) +// and range-filtering, supporting the interesection of multiple quantitative columns at once +// + +function getIsFilteredIn(config: CellFilterConfig) { + const { lookup, tableSize, CNFColumns } = config; + + // TODO this does not handle Quantitative filters at all! + + const readColumnSnippet = (i: number) =>/*glsl*/`texture2D(${lookup},vec2(${i.toFixed(0)}.5/${tableSize}.x , (${attrib(i)}+0.5)/${tableSize}.y))` + const readColumnFilterStatus = (i: number) => `step(0.1,${readColumnSnippet(i)}.a)` + // the snippet that powers filtering - a cell is filtered in if and only if lookup-table[COLUMN_X] has a non-zero alpha value for every "X" + const filteredInSnippet = CNFColumns.map((OR) => + `(${OR.map((category) => readColumnFilterStatus(category)).join('+')})`).join('*') + + return /*glsl*/`float getIsFilteredIn(){ + return ${filteredInSnippet}; + }` +} +type CellHoverConfig = { + hoverColumn: number; + lookup: string; + tableSize: string; +} +function getIsHovered(config: CellHoverConfig) { + const { hoverColumn, lookup, tableSize } = config; + const readColumnSnippet = (i: number) =>/*glsl*/`texture2D(${lookup},vec2(${i.toFixed(0)}.5/${tableSize}.x , (${attrib(i)}+0.5)/${tableSize}.y))` + // note the 0.5 here - the alpha value > 0.1 implies filtered in (in this category at least) - alpha > 0.5 implies filtered in and hovered + const readColumnFilterStatus = (i: number) => `step(0.5,${readColumnSnippet(i)}.a)` + return /*glsl*/`float getIsHovered(){ + return ${readColumnFilterStatus(hoverColumn)}; + }` +} +type CellRadiusConfig = { + +} +function getRadius(config: CellRadiusConfig) { + return /*glsl*/`float getRadius(){ + return 2.0; // TODO + }` +} + +type ColorMode = { mode: 'categorical', column: number } | { mode: 'constant', value: vec4 } | { mode: 'quantitative', column: number, gamut: string } +// todo: 2 color modes to render ids - either the quantitative value or the color-by category value of the cell + +type CellColorConfig = { + gradient: string; + lookup: string; + tableSize: string; + hovered: ColorMode, + filteredOut: ColorMode, + normal: ColorMode, +} +function getColor(config: CellColorConfig) { + // TODO: handle missing values, which are sometimes encoded as NaN, and sometimes just 0.0 + const { gradient, hovered, filteredOut, normal, tableSize, lookup } = config; + const readColumnSnippet = (i: number) =>/*glsl*/`texture2D(${lookup},vec2(${i.toFixed(0)}.5/${tableSize}.x , (${attrib(i)}+0.5)/${tableSize}.y))` + const readGradientSnippet = (col: number, gamut: string) =>/*glsl*/`texture2D(${gradient}, vec2((${attrib(col)}-${gamut}.x)/(${gamut}.y-${gamut}.x), 0.5))` + + const snippet = (mode: ColorMode) => + match(mode) + .with({ mode: 'categorical' }, (cat) =>/*glsl*/`vec4(${readColumnSnippet(cat.column)}.rgb,1.0)`) + .with({ mode: 'constant' }, (flat) =>/*glsl*/`vec4(${flat.value.map(v => v.toFixed(4)).join(',')})`) + .otherwise(({ column, gamut }) =>/*glsl*/`vec4(${readGradientSnippet(column, gamut)})`) + + return /*glsl*/`vec4 getColor(){ + return mix( + ${snippet(filteredOut)}, + mix(${snippet(normal)}, ${snippet(hovered)}, isHovered()), + getIsFilteredIn()); + }` +} \ No newline at end of file diff --git a/packages/scatterbrain/src/typed-array.ts b/packages/scatterbrain/src/typed-array.ts new file mode 100644 index 00000000..ef008cbe --- /dev/null +++ b/packages/scatterbrain/src/typed-array.ts @@ -0,0 +1,91 @@ +export type WebGLSafeBasicType = 'uint8' | 'uint16' | 'int8' | 'int16' | 'uint32' | 'int32' | 'float'; + +// lets help the compiler to know that these two types are related: +export type TaggedFloat32Array = { + type: 'float'; + data: Float32Array; +}; + +export type TaggedUint32Array = { + type: 'uint32'; + data: Uint32Array; +}; +export type TaggedInt32Array = { + type: 'int32'; + data: Int32Array; +}; + +export type TaggedUint16Array = { + type: 'uint16'; + data: Uint16Array; +}; +export type TaggedInt16Array = { + type: 'int16'; + data: Int16Array; +}; + +export type TaggedUint8Array = { + type: 'uint8'; + data: Uint8Array; +}; +export type TaggedInt8Array = { + type: 'int8'; + data: Int8Array; +}; + +export type TaggedTypedArray = + | TaggedFloat32Array + | TaggedUint32Array + | TaggedInt32Array + | TaggedUint16Array + | TaggedInt16Array + | TaggedUint8Array + | TaggedInt8Array; + +export const BufferConstructors = { + uint8: Uint8Array, + uint16: Uint16Array, + uint32: Uint32Array, + int8: Int8Array, + int16: Int16Array, + int32: Int32Array, + float: Float32Array, +} as const; + +const SizeInBytes = { + uint8: 1, + uint16: 2, + uint32: 4, + int8: 1, + int16: 2, + int32: 4, + float: 4, +} as const; +export function sizeInBytes(type: WebGLSafeBasicType) { + return SizeInBytes[type]; +} +export function MakeTaggedBufferView(type: WebGLSafeBasicType, buffer: ArrayBuffer): TaggedTypedArray { + // note that TS is not smart enough to realize the mapping here, so we have 7 identical, spoonfed + // cases.... + switch (type) { + case 'uint8': + return { type, data: new BufferConstructors[type](buffer) }; + case 'int8': + return { type, data: new BufferConstructors[type](buffer) }; + case 'uint16': + return { type, data: new BufferConstructors[type](buffer) }; + case 'int16': + return { type, data: new BufferConstructors[type](buffer) }; + case 'uint32': + return { type, data: new BufferConstructors[type](buffer) }; + case 'int32': + return { type, data: new BufferConstructors[type](buffer) }; + case 'float': + return { type, data: new BufferConstructors[type](buffer) }; + default: { + // will be a compile error if we ever add any basic types + const unreachable: never = type; + throw new Error(`unsupported type requested: ${unreachable}`); + } + } +} diff --git a/packages/scatterbrain/src/types.ts b/packages/scatterbrain/src/types.ts new file mode 100644 index 00000000..770c00e7 --- /dev/null +++ b/packages/scatterbrain/src/types.ts @@ -0,0 +1,81 @@ +import { WebGLSafeBasicType } from './typed-array'; +// lets get a renderer up and rolling +// then add features from there... + +/// Types describing the metadata that gets loaded from scatterbrain.json files /// +// there are 2 variants, slideview and regular - they are distinguished at runtime +// by checking the parsed metadata for the 'slides' field + +type volumeBound = { + lx: number; + ly: number; + lz: number; + ux: number; + uy: number; + uz: number; +}; +type PointAttribute = { + name: string; + size: number; // elements * elementSize - todo ask Peter to remove + elements: number; // values per point (so a vector xy would have 2) + elementSize: number; // size of an element, given in bytes (for example float would have 4) + type: WebGLSafeBasicType; + description: string; +}; +type TreeNode = { + file: string; + numSpecimens: number; + children: undefined | TreeNode[]; +}; + +type CommonMetadata = { + geneFileEndpoint: string; + metadataFileEndpoint: string; + visualizationReferenceId: string; + spatialColumn: string; + pointAttributes: PointAttribute[]; +} +// scatterbrain distinguishes 2 kinds of datasets - those arranged at the topmost level into slides +// and 'regular' - which is just a simple, 2D point cloud +export type ScatterbrainMetadata = CommonMetadata & { + points: number; + boundingBox: volumeBound; + tightBoundingBox: volumeBound; + root: TreeNode; +}; + +// slideview variant: +type Slide = { + featureTypeValueReferenceId: string; + tree: { + root: TreeNode; + points: number; + boundingBox: volumeBound; + tightBoundingBox: volumeBound; + }; +}; +type SpatialReferenceFrame = { + anatomicalOrigin: string; + direction: string; + unit: string; + minX: number; + maxX: number; + minY: number; + maxY: number; +}; + +export type SlideviewMetadata = CommonMetadata & { + slides: Slide[]; + spatialUnit: SpatialReferenceFrame; +}; + +/// Types related to the rendering of scatterbrain datasets /// + +// a Dataset is the top level entity +// an Item is a chunk of that dataset - esentially a singular, loadable, thing + + +type Settings = {} +type Item = {} +export type SlideviewScatterbrainDataset = { type: 'slideview', metadata: SlideviewMetadata } +export type ScatterbrainDataset = { type: 'normal', metadata: ScatterbrainMetadata } \ No newline at end of file diff --git a/packages/scatterbrain/tsconfig.json b/packages/scatterbrain/tsconfig.json new file mode 100644 index 00000000..d8a6412f --- /dev/null +++ b/packages/scatterbrain/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "paths": { + "~/*": ["./*"] + }, + "moduleResolution": "Bundler", + "module": "es6", + "target": "es2024", + "lib": ["es2024", "DOM"] + }, + "include": ["./src/index.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54fcbbf2..5a6770ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,7 +70,23 @@ importers: specifier: 2.1.0 version: 2.1.0 - packages/geometry: {} + packages/geometry: + devDependencies: + '@parcel/packager-ts': + specifier: 2.15.4 + version: 2.15.4(@parcel/core@2.15.4(@swc/helpers@0.5.17)) + '@parcel/transformer-typescript-types': + specifier: 2.15.4 + version: 2.15.4(@parcel/core@2.15.4(@swc/helpers@0.5.17))(typescript@5.9.3) + parcel: + specifier: 2.15.4 + version: 2.15.4(@swc/helpers@0.5.17) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 4.0.6 + version: 4.0.6(@types/debug@4.1.12)(lightningcss@1.30.2)(yaml@2.8.1) packages/omezarr: dependencies: @@ -90,6 +106,37 @@ importers: specifier: 4.1.12 version: 4.1.12 + packages/scatterbrain: + dependencies: + '@alleninstitute/vis-core': + specifier: workspace:* + version: link:../core + '@alleninstitute/vis-geometry': + specifier: workspace:* + version: link:../geometry + regl: + specifier: 2.1.0 + version: 2.1.0 + ts-pattern: + specifier: 5.9.0 + version: 5.9.0 + devDependencies: + '@parcel/packager-ts': + specifier: 2.15.4 + version: 2.15.4(@parcel/core@2.15.4(@swc/helpers@0.5.17)) + '@parcel/transformer-typescript-types': + specifier: 2.15.4 + version: 2.15.4(@parcel/core@2.15.4(@swc/helpers@0.5.17))(typescript@5.9.3) + parcel: + specifier: 2.15.4 + version: 2.15.4(@swc/helpers@0.5.17) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 4.0.6 + version: 4.0.6(@types/debug@4.1.12)(lightningcss@1.30.2)(yaml@2.8.1) + site: dependencies: '@alleninstitute/vis-core': @@ -3888,6 +3935,9 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-pattern@5.9.0: + resolution: {integrity: sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==} + tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} @@ -8975,6 +9025,8 @@ snapshots: trough@2.2.0: {} + ts-pattern@5.9.0: {} + tsconfck@3.1.6(typescript@5.8.3): optionalDependencies: typescript: 5.8.3 From 214e441e3eae941e58a73dc3060328a9f88df4d6 Mon Sep 17 00:00:00 2001 From: noah Date: Mon, 24 Nov 2025 14:24:54 -0800 Subject: [PATCH 02/29] add visitBFSmaybe, a variant of bfs tree-traversal that lets the visitor decide to continue traversal (or not) in addition to whatever effectful thing it would normally do --- packages/geometry/src/spatialIndexing/tree.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/geometry/src/spatialIndexing/tree.ts b/packages/geometry/src/spatialIndexing/tree.ts index 0cf516c2..623d031a 100644 --- a/packages/geometry/src/spatialIndexing/tree.ts +++ b/packages/geometry/src/spatialIndexing/tree.ts @@ -30,3 +30,24 @@ export function visitBFS( } } } +export function visitBFSMaybe( + tree: Tree, + children: (t: Tree) => ReadonlyArray, + visitor: (tree: Tree) => boolean, +): void { + const frontier: Tree[] = [tree]; + while (frontier.length > 0) { + const cur = frontier.shift(); + if (cur === undefined) { + // TODO: Consider logging a warning or error here, as this should never happen, + // but this package doesn't depend on the package where our logger lives + continue; + } + if (visitor(cur)) { + for (const c of children(cur)) { + frontier.push(c); + } + } + + } +} From 89cd1f90462aa482874aab6da4369cf5144cec1a Mon Sep 17 00:00:00 2001 From: noah Date: Mon, 24 Nov 2025 14:26:28 -0800 Subject: [PATCH 03/29] thinking about impl of scatterbrain in vis - might back some of this out and start smaller... --- packages/scatterbrain/src/render.ts | 127 +++++++++++++++++++++++++--- packages/scatterbrain/src/types.ts | 4 +- 2 files changed, 119 insertions(+), 12 deletions(-) diff --git a/packages/scatterbrain/src/render.ts b/packages/scatterbrain/src/render.ts index 8328106a..93f5f607 100644 --- a/packages/scatterbrain/src/render.ts +++ b/packages/scatterbrain/src/render.ts @@ -1,30 +1,35 @@ +/** biome-ignore-all lint/style/noUselessElse: */ +/** biome-ignore-all lint/complexity/useArrowFunction: */ import * as REGL from 'regl' import { ReglCacheEntry, type Renderer } from '@alleninstitute/vis-core' -import { ScatterbrainDataset, SlideviewScatterbrainDataset } from './types' +import { ScatterbrainDataset, SlideviewScatterbrainDataset, TreeNode, volumeBound } from './types' +import { Box2D, box2D, Box3D, box3D, Vec2, vec2, vec3, Vec3, visitBFS } from '../../geometry/dist/types' // lets get a renderer up and rolling // then add features from there... -type Settings = {} -type Item = {} -type ScatterbrainRenderer = Renderer +type Settings = { camera: { view: box2D, screenResolution: vec2 } } +type Item = { node: TreeNode, bounds: box2D } +type GPUData = {} +type Dataset = ScatterbrainDataset | SlideviewScatterbrainDataset +type ScatterbrainRenderer = Renderer function buildScatterbrainRenderer(regl: REGL.Regl): ScatterbrainRenderer { return { - getVisibleItems: function (data: ScatterbrainDataset, settings: Settings): Item[] { - + getVisibleItems: function (data: Dataset, settings: Settings): Item[] { + return getVisibleItems(data, settings.camera) }, - fetchItemContent: function (item: Item, dataset: ScatterbrainDataset, settings: Settings): Record Promise> { + fetchItemContent: function (item: Item, dataset: Dataset, settings: Settings): Record Promise> { throw new Error('Function not implemented.') }, - isPrepared: function (cacheData: Record): cacheData is {} { + isPrepared: function (cacheData: Record): cacheData is GPUData { throw new Error('Function not implemented.') }, - renderItem: function (target: REGL.Framebuffer2D | null, item: Item, data: ScatterbrainDataset, settings: Settings, gpuData: {}): void { + renderItem: function (target: REGL.Framebuffer2D | null, item: Item, data: Dataset, settings: Settings, gpuData: {}): void { throw new Error('Function not implemented.') }, - cacheKey: function (item: Item, requestKey: string, data: ScatterbrainDataset, settings: Settings): string { + cacheKey: function (item: Item, requestKey: string, data: Dataset, settings: Settings): string { throw new Error('Function not implemented.') }, destroy: function (regl: REGL.Regl): void { @@ -32,4 +37,106 @@ function buildScatterbrainRenderer(regl: REGL.Regl): ScatterbrainRenderer { } } +} +// adapted from Potree createChildAABB +// note that if you do not do indexing in precisely the same order +// as potree octrees, this will not work correctly at all +function getChildBoundsUsingPotreeIndexing(parentBounds: box3D, index: number) { + const min = parentBounds.minCorner; + const size = Vec3.scale(Box3D.size(parentBounds), 0.5); + const offset: vec3 = [ + (index & 0b0100) > 0 ? size[0] : 0, + (index & 0b0010) > 0 ? size[1] : 0, + (index & 0b0001) > 0 ? size[2] : 0, + ]; + const newMin = Vec3.add(min, offset); + return { + minCorner: newMin, + maxCorner: Vec3.add(newMin, size), + }; +} +function children(node: TreeNode) { + return node.children ?? [] +} +function sanitizeName(fileName: string) { + return fileName.replace('.bin', ''); +} +function bounds(rootBounds: box3D, path: string) { + // path is a name like r01373 - indicating a path through the tree, each character is a child index + let bounds = rootBounds + for (let i = 1; i < path.length; i++) { + const ci = Number(path[i]); + bounds = getChildBoundsUsingPotreeIndexing(bounds, ci); + } + return bounds; +} +function dropZ(box: box3D) { + return { + minCorner: Vec3.xy(box.minCorner), + maxCorner: Vec3.xy(box.maxCorner), + }; +} +// todo move me to vis-geometry +export function visitBFSMaybe( + tree: Tree, + children: (t: Tree) => ReadonlyArray, + visitor: (tree: Tree) => boolean, +): void { + const frontier: Tree[] = [tree]; + while (frontier.length > 0) { + const cur = frontier.shift(); + if (cur === undefined) { + // TODO: Consider logging a warning or error here, as this should never happen, + // but this package doesn't depend on the package where our logger lives + continue; + } + if (visitor(cur)) { + for (const c of children(cur)) { + frontier.push(c); + } + } + + } +} + +// figure out that path through the tree, given a TreeNode name +// these names are structured data - so it should always be possible +function getVisibleItemsInTree(dataset: { root: TreeNode, boundingBox: volumeBound }, camera: { view: box2D, screenResolution: vec2 }, limit: number) { + const { root, boundingBox } = dataset + const hits: { node: TreeNode, bounds: box2D }[] = [] + const rootBounds = Box3D.create([boundingBox.lx, boundingBox.ly, boundingBox.lz], [boundingBox.ux, boundingBox.uy, boundingBox.uz]); + visitBFSMaybe(root, children, (t) => { + const B = dropZ(bounds(rootBounds, sanitizeName(t.file))) + if (Box2D.intersection(B, camera.view) && Box2D.size(B)[0] > limit) { + // this node is big enough to render - that means we should check its children as well + hits.push({ node: t, bounds: B }) + return true; + } + return false; + }) + return hits; +} + +function getVisibleItems(dataset: SlideviewScatterbrainDataset | ScatterbrainDataset, camera: { view: box2D, screenResolution: vec2, layout?: Record }) { + if (dataset.type === 'normal') { + return getVisibleItemsInTree(dataset.metadata, camera, 5); + } + // by default, if we pass NO layout info + const size: vec2 = [dataset.metadata.spatialUnit.maxX - dataset.metadata.spatialUnit.minX, dataset.metadata.spatialUnit.maxY - dataset.metadata.spatialUnit.minY] + // then it means we want to draw all the slides on top of each other + const defaultVisibility = camera.layout === undefined + const hits: Item[] = [] + for (const slide of dataset.metadata.slides) { + if (!defaultVisibility && camera.layout?.[slide.featureTypeValueReferenceId] === undefined) { + // the camera has a layout, but this slide isn't in it - dont draw it + continue; + } + const grid = camera.layout?.[slide.featureTypeValueReferenceId] ?? [0, 0] + + const offset = Vec2.mul(grid, size) + // offset the camera by the opposite of the offset + hits.push(...getVisibleItemsInTree(slide.tree, { ...camera, view: Box2D.translate(camera.view, offset) }, 5)) + } + return hits; + } \ No newline at end of file diff --git a/packages/scatterbrain/src/types.ts b/packages/scatterbrain/src/types.ts index 770c00e7..8bdeb781 100644 --- a/packages/scatterbrain/src/types.ts +++ b/packages/scatterbrain/src/types.ts @@ -6,7 +6,7 @@ import { WebGLSafeBasicType } from './typed-array'; // there are 2 variants, slideview and regular - they are distinguished at runtime // by checking the parsed metadata for the 'slides' field -type volumeBound = { +export type volumeBound = { lx: number; ly: number; lz: number; @@ -22,7 +22,7 @@ type PointAttribute = { type: WebGLSafeBasicType; description: string; }; -type TreeNode = { +export type TreeNode = { file: string; numSpecimens: number; children: undefined | TreeNode[]; From 2be308d03c2f020a9e158966c3d76fc63b2e27b8 Mon Sep 17 00:00:00 2001 From: noah Date: Fri, 12 Dec 2025 14:25:23 -0800 Subject: [PATCH 04/29] get the bare bones basics down for the cache client... start to think about how to generate these shaders in a less hard to modify way --- packages/scatterbrain/src/typed-array.ts | 3 +- site/src/examples/scatterbrain/demo.tsx | 27 ++++++ .../scatterbrain/scatterbrain/readme.md | 3 + .../scatterbrain/scatterbrain/renderer.ts | 81 +++++++++++++++++ .../scatterbrain/scatterbrain/types.ts | 88 +++++++++++++++++++ 5 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 site/src/examples/scatterbrain/demo.tsx create mode 100644 site/src/examples/scatterbrain/scatterbrain/readme.md create mode 100644 site/src/examples/scatterbrain/scatterbrain/renderer.ts create mode 100644 site/src/examples/scatterbrain/scatterbrain/types.ts diff --git a/packages/scatterbrain/src/typed-array.ts b/packages/scatterbrain/src/typed-array.ts index ef008cbe..10c264b1 100644 --- a/packages/scatterbrain/src/typed-array.ts +++ b/packages/scatterbrain/src/typed-array.ts @@ -1,4 +1,3 @@ -export type WebGLSafeBasicType = 'uint8' | 'uint16' | 'int8' | 'int16' | 'uint32' | 'int32' | 'float'; // lets help the compiler to know that these two types are related: export type TaggedFloat32Array = { @@ -32,7 +31,6 @@ export type TaggedInt8Array = { type: 'int8'; data: Int8Array; }; - export type TaggedTypedArray = | TaggedFloat32Array | TaggedUint32Array @@ -41,6 +39,7 @@ export type TaggedTypedArray = | TaggedInt16Array | TaggedUint8Array | TaggedInt8Array; +export type WebGLSafeBasicType = TaggedTypedArray['type'] export const BufferConstructors = { uint8: Uint8Array, diff --git a/site/src/examples/scatterbrain/demo.tsx b/site/src/examples/scatterbrain/demo.tsx new file mode 100644 index 00000000..1bea7689 --- /dev/null +++ b/site/src/examples/scatterbrain/demo.tsx @@ -0,0 +1,27 @@ +import type { vec2 } from '@alleninstitute/vis-geometry'; +import { SharedCacheProvider } from '../common/react/priority-cache-provider'; +import { useEffect, useRef } from 'react'; + +const screenSize: vec2 = [800, 800]; + +export function OmezarrDemo() { + return ( + + + + ); +} +type Props = {screenSize:vec2} +function Demo(props:Props) { + const {screenSize} = props; + const cnvs = useRef(null); + // todo handlers, etc + useEffect(()=>{ + // build the renderer + },[cnvs.current]) + return () +} \ No newline at end of file diff --git a/site/src/examples/scatterbrain/scatterbrain/readme.md b/site/src/examples/scatterbrain/scatterbrain/readme.md new file mode 100644 index 00000000..dcf06412 --- /dev/null +++ b/site/src/examples/scatterbrain/scatterbrain/readme.md @@ -0,0 +1,3 @@ +I am making yet another 'example' - but this one will get directly moved into a package all at once at the end +it is a bit unpleasant to develop code in the packages area, as I would need to constantly rebuild the example that refers to them +we have a script to do that - but each package needs a port.. it doesnt always work, - its a pain. I'm used to bun whatever.html as a form of rapid iteration - no bs setup needed diff --git a/site/src/examples/scatterbrain/scatterbrain/renderer.ts b/site/src/examples/scatterbrain/scatterbrain/renderer.ts new file mode 100644 index 00000000..d6c213fd --- /dev/null +++ b/site/src/examples/scatterbrain/scatterbrain/renderer.ts @@ -0,0 +1,81 @@ +import type { Cacheable, CachedVertexBuffer, SharedPriorityCache } from '@alleninstitute/vis-core'; +import type REGL from 'regl'; +import type { ColumnRequest, ScatterbrainDataset, SlideviewScatterbrainDataset, TreeNode } from './types'; +import type { box2D } from '@alleninstitute/vis-geometry'; +import { MakeTaggedBufferView } from 'src/examples/common/typed-array'; +import { reduce } from 'lodash'; +// import type { ColumnRequest } from 'src/examples/common/loaders/scatterplot/scatterbrain-loader'; + +type Item = Readonly<{ + dataset: SlideviewScatterbrainDataset | ScatterbrainDataset + node: TreeNode + bounds: box2D + columns: Record +}> +type Content = {} + +class VBO implements Cacheable { + buffer: CachedVertexBuffer; + constructor(buffer: CachedVertexBuffer) { + this.buffer = buffer; + } + destroy() { + this.buffer.buffer.destroy() + } + sizeInBytes() { + return this.buffer.bytes; + } +} + +export function buildScatterbrainCacheClient(regl: REGL.Regl, cache: SharedPriorityCache) { + const client = cache.registerClient({ + cacheKeys: (item) => { + const { dataset, node, columns } = item; + return reduce, Record>(columns, (acc, col, key) => ({ ...acc, [key]: `${dataset.metadata.metadataFileEndpoint}/${node.file}/${col.name}` }), {}) + }, + fetch: (item) => { + const { dataset, node, columns } = item; + const attrs = dataset.metadata.pointAttributes; + const getColumnUrl = (columnName: string) => `${dataset.metadata.metadataFileEndpoint}${columnName}/${dataset.metadata.visualizationReferenceId}/${node.file}`; + const getGeneUrl = (columnName: string) => `${dataset.metadata.geneFileEndpoint}${columnName}/${dataset.metadata.visualizationReferenceId}/${node.file}`; + const getColumnInfo = (col: ColumnRequest) => + col.type === 'QUANTITATIVE' ? + { url: getGeneUrl(col.name), elements: 1, type: 'float' } as const + : + { url: getColumnUrl(col.name), elements: attrs[col.name].elements, type: attrs[col.name].type } + + const proms = reduce, Record Promise>>(columns, (getters, col, key) => { + const { url, type } = getColumnInfo(col) + return { + ...getters, + [key]: (signal) => fetch(url, { signal }).then(b => b.arrayBuffer().then(buff => { + const typed = MakeTaggedBufferView(type, buff) + return new VBO({ buffer: regl.buffer({ type: type, data: typed.data }), bytes: buff.byteLength, type: 'buffer' }) + })) + } + }, {}) + return proms; + }, + isValue: (v): v is Content => { + // TODO!! + // a problem here - unless we do some pre-cooking of shaders... the set of attrs we pass to the shader + // is only known at runtime, because the user picks a configuration and we generate a shader based on that config + // this is how its done in ABC atlas now, but to be fair - its not that helpful. No one but me reads the shaders, + // the names of attrs are just featureTypeReferenceIds (gibberish) so its not super helpful to generate them in this way. + // I'm considering pre-generating shaders with fixed #'s of vertex attr inputs... however the # of shaders is pretty combinatoric... + // even with just 4 attrs (pos, A,B,C) how A B and C are used is also determined at runtime + // for example, B and C could be genes, and thus need to be filtered with a range, where A could be metadata + // or A could be the gene, B and C could be metadata, and C could be the color-by + // there are so many permutations... + // so thinking that through - we're stuck generating shaders - unless we want to push a TON of speculative + // execution down into the vertex shader, there's no way around it... + return true; + }, + }) + return client; +} + +/* +perhaps the issue is not that we generate the shaders, but the very verbose string-building way in which we generate the shaders +there are alternatives, use.gpu style, or even how thi.ng/umbrella does it with a custom DSL... +*/ \ No newline at end of file diff --git a/site/src/examples/scatterbrain/scatterbrain/types.ts b/site/src/examples/scatterbrain/scatterbrain/types.ts new file mode 100644 index 00000000..b71ac4cd --- /dev/null +++ b/site/src/examples/scatterbrain/scatterbrain/types.ts @@ -0,0 +1,88 @@ +// lets get a renderer up and rolling +// then add features from there... + +/// Types describing the metadata that gets loaded from scatterbrain.json files /// +// there are 2 variants, slideview and regular - they are distinguished at runtime +// by checking the parsed metadata for the 'slides' field +export type WebGLSafeBasicType = 'uint8' | 'uint16' | 'int8' | 'int16' | 'uint32' | 'int32' | 'float'; + +export type volumeBound = { + lx: number; + ly: number; + lz: number; + ux: number; + uy: number; + uz: number; +}; +type PointAttribute = { + name: string; + size: number; // elements * elementSize - todo ask Peter to remove + elements: number; // values per point (so a vector xy would have 2) + elementSize: number; // size of an element, given in bytes (for example float would have 4) + type: WebGLSafeBasicType; + description: string; +}; +export type TreeNode = { + file: string; + numSpecimens: number; + children: undefined | TreeNode[]; +}; + +type MetadataColumn = { + type: 'METADATA'; + name: string; +}; +type QuantitativeColumn = { + type: 'QUANTITATIVE'; + name: string; +}; +export type ColumnRequest = MetadataColumn | QuantitativeColumn; +type CommonMetadata = { + geneFileEndpoint: string; + metadataFileEndpoint: string; + visualizationReferenceId: string; + spatialColumn: string; + pointAttributes: Record; +} +// scatterbrain distinguishes 2 kinds of datasets - those arranged at the topmost level into slides +// and 'regular' - which is just a simple, 2D point cloud +export type ScatterbrainMetadata = CommonMetadata & { + points: number; + boundingBox: volumeBound; + tightBoundingBox: volumeBound; + root: TreeNode; +}; + +// slideview variant: +type Slide = { + featureTypeValueReferenceId: string; + tree: { + root: TreeNode; + points: number; + boundingBox: volumeBound; + tightBoundingBox: volumeBound; + }; +}; +type SpatialReferenceFrame = { + anatomicalOrigin: string; + direction: string; + unit: string; + minX: number; + maxX: number; + minY: number; + maxY: number; +}; + +export type SlideviewMetadata = CommonMetadata & { + slides: Slide[]; + spatialUnit: SpatialReferenceFrame; +}; + +/// Types related to the rendering of scatterbrain datasets /// + +// a Dataset is the top level entity +// an Item is a chunk of that dataset - esentially a singular, loadable, thing + + +export type SlideviewScatterbrainDataset = { type: 'slideview', metadata: SlideviewMetadata } +export type ScatterbrainDataset = { type: 'normal', metadata: ScatterbrainMetadata } \ No newline at end of file From 0c7074763dc8f84f0aaa96eb1f0e2007c70e4bc9 Mon Sep 17 00:00:00 2001 From: noah Date: Wed, 17 Dec 2025 13:46:31 -0800 Subject: [PATCH 05/29] thinking about ways to deal with shader generation... --- pnpm-lock.yaml | 336 ++++++++++++++++++ site/package.json | 3 + .../scatterbrain/scatterbrain/renderer.ts | 44 ++- 3 files changed, 381 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a6770ca..5af5794c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -187,6 +187,15 @@ importers: '@mui/material': specifier: 5.15.15 version: 5.15.15(@emotion/react@11.11.4(@types/react@19.2.2)(react@19.1.0))(@emotion/styled@11.11.5(@emotion/react@11.11.4(@types/react@19.2.2)(react@19.1.0))(@types/react@19.2.2)(react@19.1.0))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@thi.ng/shader-ast': + specifier: 1.1.33 + version: 1.1.33 + '@thi.ng/shader-ast-glsl': + specifier: 1.0.52 + version: 1.0.52 + '@thi.ng/webgl': + specifier: 6.9.91 + version: 6.9.91 '@types/lodash': specifier: 4.17.16 version: 4.17.16 @@ -2144,6 +2153,134 @@ packages: '@swc/types@0.1.25': resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} + '@thi.ng/api@8.12.10': + resolution: {integrity: sha512-JWjQbp6R4uA55CzQdZ2avlWivYZvQYd4IJo7GI+edlQMd9qW59X/jgIDLinOQtdP5e+JygaphPWlKkH3sBazKQ==} + engines: {node: '>=18'} + + '@thi.ng/arrays@2.14.3': + resolution: {integrity: sha512-7Z2DO/EVfqIanRA9L15bGZxo3spVzteUJ348TqbdHOBAZ6OEzFhpYoFJTvKSBKvMzQwPSUcACKY8LH1SCk94RQ==} + engines: {node: '>=18'} + + '@thi.ng/associative@7.1.22': + resolution: {integrity: sha512-uFnwKXfczKVkyERXVX2/uvP7MWehVG+XemY+yUGOWSC/bQ+EzqT5lVEZt5pDlE8+6B52LtkEF2B9sEsScYNFEw==} + engines: {node: '>=18'} + + '@thi.ng/binary@3.5.0': + resolution: {integrity: sha512-RIZqTw0X3pjKChxrO15Nf2W7/Ufab7ITsYSGzWQND05easYR/QswIzPBnZP4YL3gTbqNuywN/RffmBqBZEQzUg==} + engines: {node: '>=18'} + + '@thi.ng/canvas@1.1.3': + resolution: {integrity: sha512-6++Xh5TmjIz5U/LSK6IvVbOUKhyZoFvwgwDysUs6syYnJw+1kZUZFcTi0+lbCKDOmp9uM6xBQ64cx98/8OEDUw==} + engines: {node: '>=18'} + + '@thi.ng/checks@3.8.0': + resolution: {integrity: sha512-iHe53YlKOUIek+SERTs0RK1vZ7NF0Jvn5pT00MTEPTa7JHS1K12yZksTerbPxeVoBooG2gkJcXNBerDGvenIbg==} + engines: {node: '>=18'} + + '@thi.ng/compare@2.4.36': + resolution: {integrity: sha512-Dn2qlPSJrlx3YxjRbEg8lFeIBkoEPCwz83/RaTp0XLefAbp/ESU35FNqf2iWakMyQMJdAb40qTyEhb29X2jNNQ==} + engines: {node: '>=18'} + + '@thi.ng/compose@3.0.47': + resolution: {integrity: sha512-zzHMs+5xcX18UMIYMqWq54U0Z2yKKOzvdAGUTQeWYrhIbnBsTMo75wa77pUX1OhAhvRE5wHsvbB5da0Zx7WNhA==} + engines: {node: '>=18'} + + '@thi.ng/dcons@3.2.178': + resolution: {integrity: sha512-1OMusk6l4xfDYUUY2jYCMfaqRe3hs58eZUK0U35Pmk5hCKctZkZ1Nu+RYDY8i6n0BpibOMWEZN0mkeNSX2wnuA==} + engines: {node: '>=18'} + + '@thi.ng/defmulti@3.0.86': + resolution: {integrity: sha512-pc+VOzlm9IQk6iIn5TOniwoRDsq4Jp5YAJuhR/Tt/ta/ElYr9+RblPNSbcOBW1pucMZyXA4rOY8pBPZVk7YgyA==} + engines: {node: '>=18'} + + '@thi.ng/dgraph@2.1.188': + resolution: {integrity: sha512-kvtAsFG7QT1M3jyDOW7vowTs/v5YiBLBi6GxDCj/b9SnrLPDS2XwDHZbbZRxIbJ9DwMoySV6LPKPMCN0J3UWjA==} + engines: {node: '>=18'} + + '@thi.ng/equiv@2.1.100': + resolution: {integrity: sha512-JehNcAzQnGsjWYVytysSTIoOLsf8WrqzZbrrq7n0SRaESXzpw/XG1wR5puD4dzq5qRidcP3SQ2dbQi7gRqfvtg==} + engines: {node: '>=18'} + + '@thi.ng/errors@2.5.50': + resolution: {integrity: sha512-81I9IHwrCAHpfzEVMaJQpRYaq87MvIXi7YQ/wccKSxtiaz4IpB8dz3zwds33Oy9g+v+6nQbahZ02+syhKFYSXQ==} + engines: {node: '>=18'} + + '@thi.ng/hex@2.4.3': + resolution: {integrity: sha512-KMLJyTJxe0O3F0qZ1tWBDWQ3OVJTQ2WGmSYErXKLfgWIPaxXqLaWhiJille3c27nbfk13My/5Ie272m7SMCTfw==} + engines: {node: '>=18'} + + '@thi.ng/logger@3.2.9': + resolution: {integrity: sha512-BimjsP5b4tHGjQGJQcvwoDcR4tBtQyh6lhf0/+lWmD5VXyOfMQ8lKTxjoklrKxYWRbee+5/PJosV472hjTuemw==} + engines: {node: '>=18'} + + '@thi.ng/malloc@6.1.132': + resolution: {integrity: sha512-aPoqccVEBthiCdEe34us7y7HFvuqq8Fh8kH1F2FhVGncnRXiXQ8fH8BpYTvvn5CfI8csdX/idKtgMJlAxlhoDw==} + engines: {node: '>=18'} + + '@thi.ng/math@5.14.0': + resolution: {integrity: sha512-PNyRWbsEBggStx8+i3WZXaLbA7m1oYl9vrhSeaRDQVAY+xfnbKsoJhdvjlP2MWgqFYrRYEgIWzhlDjjlr552dg==} + engines: {node: '>=18'} + + '@thi.ng/matrices@3.0.31': + resolution: {integrity: sha512-FktH3aXyZn2s/dgsWnl+r3PTfJWuwqfKEvNslR8DM5qXqlJS5WbaGxg1njOJqoLx9rgUJ8tTyrny1CBXaG14HQ==} + engines: {node: '>=18'} + + '@thi.ng/memoize@4.0.34': + resolution: {integrity: sha512-hrzjM+wWzNvQOtlIvzUM46N5oFtUbzwP18bHSCJFNDCe4qiAHn7clc8icyvM9fv6GVvxwuqTIUuwRTN/A4Ov9g==} + engines: {node: '>=18'} + + '@thi.ng/object-utils@1.3.2': + resolution: {integrity: sha512-P1c+RHV9I4CQ8X4UonOs+SaBG6vHTaKmaQSfcnRKGyZWJmD0eeberQY+Uh7QowkZ/m7wl1lYS8wmNsHd+PJUNg==} + engines: {node: '>=18'} + + '@thi.ng/pixel@7.5.19': + resolution: {integrity: sha512-QCrWqpt83Ep2k1DQpKM7Oo45X681o8h7t7Ge3Pu+5v81WJweljmGrCmVo7Qh3/AdxxWcb3eRtOJ2aXf0ojHjug==} + engines: {node: '>=18'} + + '@thi.ng/porter-duff@2.1.122': + resolution: {integrity: sha512-FMksvekq2CWrOfkV/FY/Y71e71d+LeLL2JnNPm0uT1xFiNFWz3nXSy11DyjrcXRxF9CJxJOoi085gmBH0qbBRw==} + engines: {node: '>=18'} + + '@thi.ng/random@4.1.35': + resolution: {integrity: sha512-XJoRYsuF0oWzkQ2Qxp5Jwe4knPH1IjA0c07hqy47DHMa0e7Y3aJpAwS0tTbFBDknvcPoh+2rBybBNpQ0rdjeAQ==} + engines: {node: '>=18'} + + '@thi.ng/shader-ast-glsl@1.0.52': + resolution: {integrity: sha512-mFWwtkQWGCalBiF3ofFtS9qbtCBk+hS2xycjTHFAl05txQIC6P8l2aNhu/EEKNkwiomiQkhuJ1cCZmQxaCsshQ==} + engines: {node: '>=18'} + + '@thi.ng/shader-ast-stdlib@1.0.52': + resolution: {integrity: sha512-EaAKyNwNaFTslBMLVVa4dDxZwk+sNFO7UsL4dSKclbIluvQObcSmS26JDmWpszXOhMlQJLvu0Pk7SfxVFfhQbQ==} + engines: {node: '>=18'} + + '@thi.ng/shader-ast@1.1.33': + resolution: {integrity: sha512-fjtGHj5wYpY+gin0gxwnV0D3yD+QWB41todZ7GZGrD7Vc8EC/3GVaZjxT1t05P7LwiIYI5Hmz91tuOIp9S3SMA==} + engines: {node: '>=18'} + + '@thi.ng/strings@3.9.31': + resolution: {integrity: sha512-kWe6yyXCme5b62Hy2s/XxFwu+RZ2CZQoxV7XOYBgQKjoifFOZZVV9JjdGYk9DnWTVgE/EUsZr8NCG3NSNNWX4A==} + engines: {node: '>=18'} + + '@thi.ng/timestamp@1.1.29': + resolution: {integrity: sha512-Ms55GswdOahvlQIV+utTGxcvRX50FwGQznm7Zw2EhTGVr4lzefVfVsHG+GA1umGhsyKycRA1QcnY3tj7+3jnVg==} + engines: {node: '>=18'} + + '@thi.ng/transducers@9.6.19': + resolution: {integrity: sha512-bOuEezzWiLm+x3GD8fzhQ7b+/9Y8JGCBiKjjn5+qpFYjA+hZ6flzZxL20KRsp/29SXzBB/5rQK6uqt0TzneJfQ==} + engines: {node: '>=18'} + + '@thi.ng/vector-pools@3.2.81': + resolution: {integrity: sha512-Nt1C4i/LvWl4jdIcLu9IjIQv2v2/Z5ect0N/fDIXWAZoUYAuAFFCQChHErO5LfXO5As8rZL9Kxm3JJ99zdQqEA==} + engines: {node: '>=18'} + + '@thi.ng/vectors@8.6.16': + resolution: {integrity: sha512-lR+zYmSvLJUeDUtEOKASdc7DqIrs/5Po5MZgrQHl31EJvG23JXn68tdMTiP41UGVEnY9RPEmsCV7uEHMZPO+VQ==} + engines: {node: '>=18'} + + '@thi.ng/webgl@6.9.91': + resolution: {integrity: sha512-v+1dy3gwopck9+0CAmvBbupoGyli1VfHGxxnyaGe29q8BF20EoHIIJtBCDccgxVocDz7bpRLilYk2jC/pVvl2Q==} + engines: {node: '>=18'} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -6551,6 +6688,205 @@ snapshots: dependencies: '@swc/counter': 0.1.3 + '@thi.ng/api@8.12.10': {} + + '@thi.ng/arrays@2.14.3': + dependencies: + '@thi.ng/api': 8.12.10 + '@thi.ng/checks': 3.8.0 + '@thi.ng/compare': 2.4.36 + '@thi.ng/equiv': 2.1.100 + '@thi.ng/errors': 2.5.50 + '@thi.ng/random': 4.1.35 + + '@thi.ng/associative@7.1.22': + dependencies: + '@thi.ng/api': 8.12.10 + '@thi.ng/arrays': 2.14.3 + '@thi.ng/binary': 3.5.0 + '@thi.ng/checks': 3.8.0 + '@thi.ng/dcons': 3.2.178 + '@thi.ng/equiv': 2.1.100 + '@thi.ng/object-utils': 1.3.2 + '@thi.ng/transducers': 9.6.19 + + '@thi.ng/binary@3.5.0': + dependencies: + '@thi.ng/api': 8.12.10 + + '@thi.ng/canvas@1.1.3': {} + + '@thi.ng/checks@3.8.0': {} + + '@thi.ng/compare@2.4.36': + dependencies: + '@thi.ng/api': 8.12.10 + + '@thi.ng/compose@3.0.47': + dependencies: + '@thi.ng/api': 8.12.10 + '@thi.ng/errors': 2.5.50 + + '@thi.ng/dcons@3.2.178': + dependencies: + '@thi.ng/api': 8.12.10 + '@thi.ng/checks': 3.8.0 + '@thi.ng/compare': 2.4.36 + '@thi.ng/equiv': 2.1.100 + '@thi.ng/errors': 2.5.50 + '@thi.ng/random': 4.1.35 + '@thi.ng/transducers': 9.6.19 + + '@thi.ng/defmulti@3.0.86': + dependencies: + '@thi.ng/api': 8.12.10 + '@thi.ng/errors': 2.5.50 + '@thi.ng/logger': 3.2.9 + + '@thi.ng/dgraph@2.1.188': + dependencies: + '@thi.ng/api': 8.12.10 + '@thi.ng/associative': 7.1.22 + '@thi.ng/equiv': 2.1.100 + '@thi.ng/errors': 2.5.50 + '@thi.ng/transducers': 9.6.19 + + '@thi.ng/equiv@2.1.100': {} + + '@thi.ng/errors@2.5.50': {} + + '@thi.ng/hex@2.4.3': {} + + '@thi.ng/logger@3.2.9': {} + + '@thi.ng/malloc@6.1.132': + dependencies: + '@thi.ng/api': 8.12.10 + '@thi.ng/binary': 3.5.0 + '@thi.ng/checks': 3.8.0 + '@thi.ng/errors': 2.5.50 + + '@thi.ng/math@5.14.0': + dependencies: + '@thi.ng/api': 8.12.10 + + '@thi.ng/matrices@3.0.31': + dependencies: + '@thi.ng/api': 8.12.10 + '@thi.ng/checks': 3.8.0 + '@thi.ng/math': 5.14.0 + '@thi.ng/vectors': 8.6.16 + + '@thi.ng/memoize@4.0.34': + dependencies: + '@thi.ng/api': 8.12.10 + + '@thi.ng/object-utils@1.3.2': + dependencies: + '@thi.ng/api': 8.12.10 + '@thi.ng/checks': 3.8.0 + + '@thi.ng/pixel@7.5.19': + dependencies: + '@thi.ng/api': 8.12.10 + '@thi.ng/canvas': 1.1.3 + '@thi.ng/checks': 3.8.0 + '@thi.ng/errors': 2.5.50 + '@thi.ng/math': 5.14.0 + '@thi.ng/porter-duff': 2.1.122 + + '@thi.ng/porter-duff@2.1.122': + dependencies: + '@thi.ng/api': 8.12.10 + + '@thi.ng/random@4.1.35': + dependencies: + '@thi.ng/api': 8.12.10 + '@thi.ng/errors': 2.5.50 + + '@thi.ng/shader-ast-glsl@1.0.52': + dependencies: + '@thi.ng/api': 8.12.10 + '@thi.ng/checks': 3.8.0 + '@thi.ng/errors': 2.5.50 + '@thi.ng/shader-ast': 1.1.33 + + '@thi.ng/shader-ast-stdlib@1.0.52': + dependencies: + '@thi.ng/api': 8.12.10 + '@thi.ng/shader-ast': 1.1.33 + + '@thi.ng/shader-ast@1.1.33': + dependencies: + '@thi.ng/api': 8.12.10 + '@thi.ng/checks': 3.8.0 + '@thi.ng/defmulti': 3.0.86 + '@thi.ng/dgraph': 2.1.188 + '@thi.ng/errors': 2.5.50 + '@thi.ng/logger': 3.2.9 + + '@thi.ng/strings@3.9.31': + dependencies: + '@thi.ng/api': 8.12.10 + '@thi.ng/errors': 2.5.50 + '@thi.ng/hex': 2.4.3 + '@thi.ng/memoize': 4.0.34 + + '@thi.ng/timestamp@1.1.29': {} + + '@thi.ng/transducers@9.6.19': + dependencies: + '@thi.ng/api': 8.12.10 + '@thi.ng/arrays': 2.14.3 + '@thi.ng/checks': 3.8.0 + '@thi.ng/compare': 2.4.36 + '@thi.ng/compose': 3.0.47 + '@thi.ng/errors': 2.5.50 + '@thi.ng/math': 5.14.0 + '@thi.ng/random': 4.1.35 + '@thi.ng/timestamp': 1.1.29 + + '@thi.ng/vector-pools@3.2.81': + dependencies: + '@thi.ng/api': 8.12.10 + '@thi.ng/binary': 3.5.0 + '@thi.ng/checks': 3.8.0 + '@thi.ng/errors': 2.5.50 + '@thi.ng/logger': 3.2.9 + '@thi.ng/malloc': 6.1.132 + '@thi.ng/transducers': 9.6.19 + '@thi.ng/vectors': 8.6.16 + + '@thi.ng/vectors@8.6.16': + dependencies: + '@thi.ng/api': 8.12.10 + '@thi.ng/binary': 3.5.0 + '@thi.ng/checks': 3.8.0 + '@thi.ng/errors': 2.5.50 + '@thi.ng/math': 5.14.0 + '@thi.ng/memoize': 4.0.34 + '@thi.ng/random': 4.1.35 + '@thi.ng/strings': 3.9.31 + + '@thi.ng/webgl@6.9.91': + dependencies: + '@thi.ng/api': 8.12.10 + '@thi.ng/canvas': 1.1.3 + '@thi.ng/checks': 3.8.0 + '@thi.ng/equiv': 2.1.100 + '@thi.ng/errors': 2.5.50 + '@thi.ng/logger': 3.2.9 + '@thi.ng/matrices': 3.0.31 + '@thi.ng/memoize': 4.0.34 + '@thi.ng/object-utils': 1.3.2 + '@thi.ng/pixel': 7.5.19 + '@thi.ng/shader-ast': 1.1.33 + '@thi.ng/shader-ast-glsl': 1.0.52 + '@thi.ng/shader-ast-stdlib': 1.0.52 + '@thi.ng/transducers': 9.6.19 + '@thi.ng/vector-pools': 3.2.81 + '@thi.ng/vectors': 8.6.16 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.4 diff --git a/site/package.json b/site/package.json index 1aa3b748..82b4f897 100644 --- a/site/package.json +++ b/site/package.json @@ -61,6 +61,9 @@ "@mui/icons-material": "5.15.15", "@mui/lab": "5.0.0-alpha.175", "@mui/material": "5.15.15", + "@thi.ng/shader-ast": "1.1.33", + "@thi.ng/shader-ast-glsl": "1.0.52", + "@thi.ng/webgl": "6.9.91", "@types/lodash": "4.17.16", "@types/react": "^19.1.3", "@types/react-dom": "^19.1.3", diff --git a/site/src/examples/scatterbrain/scatterbrain/renderer.ts b/site/src/examples/scatterbrain/scatterbrain/renderer.ts index d6c213fd..4954d8a2 100644 --- a/site/src/examples/scatterbrain/scatterbrain/renderer.ts +++ b/site/src/examples/scatterbrain/scatterbrain/renderer.ts @@ -4,8 +4,11 @@ import type { ColumnRequest, ScatterbrainDataset, SlideviewScatterbrainDataset, import type { box2D } from '@alleninstitute/vis-geometry'; import { MakeTaggedBufferView } from 'src/examples/common/typed-array'; import { reduce } from 'lodash'; +import { assign, defMain, output, program, uniform, input, vec4, defn, V4, V2, sym, mul, float, ret, type Vec2Sym, type FloatSym } from "@thi.ng/shader-ast"; +import { GLSLVersion, targetGLSL } from "@thi.ng/shader-ast-glsl"; +// import * as glsl from '@thi.ng/shader-ast-glsl' // import type { ColumnRequest } from 'src/examples/common/loaders/scatterplot/scatterbrain-loader'; - +import { defShader } from "@thi.ng/webgl"; type Item = Readonly<{ dataset: SlideviewScatterbrainDataset | ScatterbrainDataset node: TreeNode @@ -78,4 +81,41 @@ export function buildScatterbrainCacheClient(regl: REGL.Regl, cache: SharedPrior /* perhaps the issue is not that we generate the shaders, but the very verbose string-building way in which we generate the shaders there are alternatives, use.gpu style, or even how thi.ng/umbrella does it with a custom DSL... -*/ \ No newline at end of file +*/ + +function whatever(ctx: WebGLRenderingContext) { + const glsl = targetGLSL({ version: GLSLVersion.GLES_100 }) + const x = program([defn(V4, 'neat', [V2, V2], (what, who) => { + let uv: FloatSym + return [ + (uv = sym(mul(float(2), float(3)))), + ret(vec4(uv, uv, uv, float(10))) + ] + })]) + // I feel confident that the above is actually less comprehensible than some GLSL template literals... + + + const wtf = glsl(x) // ok this is the way to actually compile it to a string... + const hey = defShader(ctx, { + vs: (gl, unis, attribs) => [ + defMain(() => + [assign(gl.gl_Position, vec4(1, 2, 3, 0))] + ) + ], + fs: (gl, unis, wat, outs) => [ + defMain(() => + [assign( + outs.fragColor, + vec4(1, 2, 3, 1) + ),] + ) + ], + attribs: { + + }, + uniforms: { + + } + }) + hey.program +} \ No newline at end of file From d48780620e695e10b52b06457c7ef9b43388f280 Mon Sep 17 00:00:00 2001 From: noah Date: Fri, 19 Dec 2025 14:20:51 -0800 Subject: [PATCH 06/29] finally gettin somewhere --- packages/scatterbrain/demo.ts | 42 ++ packages/scatterbrain/index.html | 35 ++ packages/scatterbrain/package.json | 4 + packages/scatterbrain/src/better/dataset.ts | 122 +++++ packages/scatterbrain/src/better/renderer.ts | 137 +++++ .../scatterbrain/src/better/shader.test.ts | 501 ++++++++++++++++++ packages/scatterbrain/src/better/shader.ts | 258 +++++++++ packages/scatterbrain/src/better/types.ts | 88 +++ pnpm-lock.yaml | 244 +++++---- .../scatterbrain/scatterbrain/readme.md | 3 - 10 files changed, 1316 insertions(+), 118 deletions(-) create mode 100644 packages/scatterbrain/demo.ts create mode 100644 packages/scatterbrain/index.html create mode 100644 packages/scatterbrain/src/better/dataset.ts create mode 100644 packages/scatterbrain/src/better/renderer.ts create mode 100644 packages/scatterbrain/src/better/shader.test.ts create mode 100644 packages/scatterbrain/src/better/shader.ts create mode 100644 packages/scatterbrain/src/better/types.ts delete mode 100644 site/src/examples/scatterbrain/scatterbrain/readme.md diff --git a/packages/scatterbrain/demo.ts b/packages/scatterbrain/demo.ts new file mode 100644 index 00000000..dc49e474 --- /dev/null +++ b/packages/scatterbrain/demo.ts @@ -0,0 +1,42 @@ +import REGL from "regl"; +import { buildScatterbrainRenderer } from "./src/better/renderer"; +import { SharedPriorityCache } from '@alleninstitute/vis-core'; +import { type ScatterbrainDataset } from "./src/better/types"; +import { loadDataset } from "./src/better/dataset"; + +const twoGB = 1024 * 1024 * 2000; +const tenx = 'https://bkp-2d-visualizations-stage.s3.amazonaws.com/wmb_tenx_01172024_stage-20240128193624/488I12FURRB8ZY5KJ8T/ScatterBrain.json'; + +async function begin() { + console.log("hi from vite dev!") + const canvas = document.getElementById('canvas') as HTMLCanvasElement + const cache = new SharedPriorityCache(new Map(), twoGB, 10) + const glcanvas = new OffscreenCanvas(10, 10); + const gl = glcanvas.getContext('webgl', { + alpha: true, + preserveDrawingBuffer: false, + antialias: true, + premultipliedAlpha: true, + }); + if (!gl) { + throw new Error('WebGL not supported!'); + } + const regl = REGL({ + gl, + extensions: ['oes_texture_float'] + }); + const dataset = await (await fetch(tenx)).json() + const yay: ScatterbrainDataset = loadDataset(dataset); + console.log('yay data!', yay) + // stuff(client, yay, { view: { minCorner: [0, 0], maxCorner: [10, 10] }, screenResolution: [800, 800] }) + const render = buildScatterbrainRenderer(regl, cache, canvas) + render({ + camera: { view: { minCorner: [0, 0], maxCorner: [10, 10] }, screenResolution: [800, 800] }, + categoricalFilters: {}, + colorBy: { kind: 'quantitative', column: '123', gradient: 'viridis', range: { min: 0, max: 10 } }, + dataset: yay, + quantitativeFilters: {}, + }) +} + +begin(); \ No newline at end of file diff --git a/packages/scatterbrain/index.html b/packages/scatterbrain/index.html new file mode 100644 index 00000000..7eaf4f6a --- /dev/null +++ b/packages/scatterbrain/index.html @@ -0,0 +1,35 @@ + + + + + + + Developer-focused scatterbrain demo - no astro needed? + + + + + + + + + \ No newline at end of file diff --git a/packages/scatterbrain/package.json b/packages/scatterbrain/package.json index 968e4671..a6343e24 100644 --- a/packages/scatterbrain/package.json +++ b/packages/scatterbrain/package.json @@ -39,6 +39,7 @@ "typecheck": "tsc --noEmit", "build": "parcel build --no-cache", "dev": "parcel watch --port 1237", + "demo": "vite", "test": "vitest --watch", "test:ci": "vitest run", "coverage": "vitest run --coverage", @@ -51,13 +52,16 @@ "devDependencies": { "@parcel/packager-ts": "2.15.4", "@parcel/transformer-typescript-types": "2.15.4", + "@types/lodash": "4.17.21", "parcel": "2.15.4", "typescript": "5.9.3", + "vite": "7.3.0", "vitest": "4.0.6" }, "dependencies": { "@alleninstitute/vis-core": "workspace:*", "@alleninstitute/vis-geometry": "workspace:*", + "lodash": "4.17.21", "regl": "2.1.0", "ts-pattern": "5.9.0" }, diff --git a/packages/scatterbrain/src/better/dataset.ts b/packages/scatterbrain/src/better/dataset.ts new file mode 100644 index 00000000..e73ce7c5 --- /dev/null +++ b/packages/scatterbrain/src/better/dataset.ts @@ -0,0 +1,122 @@ +import { Box2D, type box2D, type box3D, Box3D, Vec2, type vec2, type vec3, Vec3 } from "@alleninstitute/vis-geometry"; +import type { ScatterbrainDataset, SlideviewScatterbrainDataset, TreeNode, volumeBound } from "./types"; +import { reduce } from "lodash"; + +type Dataset = ScatterbrainDataset | SlideviewScatterbrainDataset +// figure out that path through the tree, given a TreeNode name +// these names are structured data - so it should always be possible +type NodeWithBounds = { node: TreeNode, bounds: box2D } + +// adapted from Potree createChildAABB +// note that if you do not do indexing in precisely the same order +// as potree octrees, this will not work correctly at all +function getChildBoundsUsingPotreeIndexing(parentBounds: box3D, index: number) { + const min = parentBounds.minCorner; + const size = Vec3.scale(Box3D.size(parentBounds), 0.5); + const offset: vec3 = [ + (index & 0b0100) > 0 ? size[0] : 0, + (index & 0b0010) > 0 ? size[1] : 0, + (index & 0b0001) > 0 ? size[2] : 0, + ]; + const newMin = Vec3.add(min, offset); + return { + minCorner: newMin, + maxCorner: Vec3.add(newMin, size), + }; +} +function children(node: TreeNode) { + return node.children ?? [] +} +function sanitizeName(fileName: string) { + return fileName.replace('.bin', ''); +} +function bounds(rootBounds: box3D, path: string) { + // path is a name like r01373 - indicating a path through the tree, each character is a child index + let bounds = rootBounds + for (let i = 1; i < path.length; i++) { + const ci = Number(path[i]); + bounds = getChildBoundsUsingPotreeIndexing(bounds, ci); + } + return bounds; +} +function dropZ(box: box3D) { + return { + minCorner: Vec3.xy(box.minCorner), + maxCorner: Vec3.xy(box.maxCorner), + }; +} +// todo move me to vis-geometry +export function visitBFSMaybe( + tree: Tree, + children: (t: Tree) => ReadonlyArray, + visitor: (tree: Tree) => boolean, +): void { + const frontier: Tree[] = [tree]; + while (frontier.length > 0) { + const cur = frontier.shift(); + if (cur === undefined) { + // TODO: Consider logging a warning or error here, as this should never happen, + // but this package doesn't depend on the package where our logger lives + continue; + } + if (visitor(cur)) { + for (const c of children(cur)) { + frontier.push(c); + } + } + + } +} + +function getVisibleItemsInTree(dataset: { root: TreeNode, boundingBox: volumeBound }, camera: { view: box2D, screenResolution: vec2 }, limit: number) { + const { root, boundingBox } = dataset + const hits: { node: TreeNode, bounds: box2D }[] = [] + const rootBounds = Box3D.create([boundingBox.lx, boundingBox.ly, boundingBox.lz], [boundingBox.ux, boundingBox.uy, boundingBox.uz]); + visitBFSMaybe(root, children, (t) => { + const B = dropZ(bounds(rootBounds, sanitizeName(t.file))) + if (Box2D.intersection(B, camera.view) && Box2D.size(B)[0] > limit) { + // this node is big enough to render - that means we should check its children as well + hits.push({ node: t, bounds: B }) + return true; + } + return false; + }) + return hits; +} + +export function getVisibleItems(dataset: SlideviewScatterbrainDataset | ScatterbrainDataset, camera: { view: box2D, screenResolution: vec2, layout?: Record }) { + if (dataset.type === 'normal') { + return getVisibleItemsInTree(dataset.metadata, camera, 5); + } + // by default, if we pass NO layout info + const size: vec2 = [dataset.metadata.spatialUnit.maxX - dataset.metadata.spatialUnit.minX, dataset.metadata.spatialUnit.maxY - dataset.metadata.spatialUnit.minY] + // then it means we want to draw all the slides on top of each other + const defaultVisibility = camera.layout === undefined + const hits: NodeWithBounds[] = [] + for (const slide of dataset.metadata.slides) { + if (!defaultVisibility && camera.layout?.[slide.featureTypeValueReferenceId] === undefined) { + // the camera has a layout, but this slide isn't in it - dont draw it + continue; + } + const grid = camera.layout?.[slide.featureTypeValueReferenceId] ?? [0, 0] + + const offset = Vec2.mul(grid, size) + // offset the camera by the opposite of the offset + hits.push(...getVisibleItemsInTree(slide.tree, { ...camera, view: Box2D.translate(camera.view, offset) }, 5)) + } + return hits; + +} + +export function loadDataset(raw: any): SlideviewScatterbrainDataset | ScatterbrainDataset | undefined { + // index point attrs by name - its an array + // TODO zod validation first! + if (raw['pointAttributes']) { + raw = { ...raw, pointAttributes: reduce(raw.pointAttributes as { name: string }[], (acc, attr) => ({ ...acc, [attr.name]: attr }), {}) } + } + if (raw['slides']) { + return { type: 'slideview', metadata: raw } + } else { + return { type: 'normal', metadata: raw } + } +} \ No newline at end of file diff --git a/packages/scatterbrain/src/better/renderer.ts b/packages/scatterbrain/src/better/renderer.ts new file mode 100644 index 00000000..10f242fc --- /dev/null +++ b/packages/scatterbrain/src/better/renderer.ts @@ -0,0 +1,137 @@ +import type { Cacheable, CachedVertexBuffer, SharedPriorityCache } from '@alleninstitute/vis-core'; +import type REGL from 'regl'; +import type { ColumnRequest, ScatterbrainDataset, SlideviewScatterbrainDataset, TreeNode } from './types'; +import type { box2D, Interval, vec2 } from '@alleninstitute/vis-geometry'; +import { MakeTaggedBufferView } from '../typed-array'; +import { isEqual, keys, map, omit, reduce } from 'lodash' +import { getVisibleItems } from './dataset'; +import { buildScatterbrainRenderCommand, buildShaders, type Config, configureShader, type ShaderSettings, VBO } from './shader'; +export type Item = Readonly<{ + dataset: SlideviewScatterbrainDataset | ScatterbrainDataset + node: TreeNode + bounds: box2D + columns: Record +}> +type Content = Record + + +export function buildScatterbrainCacheClient(regl: REGL.Regl, cache: SharedPriorityCache, onDataArrived: () => void) { + const client = cache.registerClient({ + cacheKeys: (item) => { + const { dataset, node, columns } = item; + return reduce, Record>(columns, (acc, col, key) => ({ ...acc, [key]: `${dataset.metadata.metadataFileEndpoint}/${node.file}/${col.name}` }), {}) + }, + fetch: (item) => { + const { dataset, node, columns } = item; + const attrs = dataset.metadata.pointAttributes; + const getColumnUrl = (columnName: string) => `${dataset.metadata.metadataFileEndpoint}${columnName}/${dataset.metadata.visualizationReferenceId}/${node.file}`; + const getGeneUrl = (columnName: string) => `${dataset.metadata.geneFileEndpoint}${columnName}/${dataset.metadata.visualizationReferenceId}/${node.file}`; + const getColumnInfo = (col: ColumnRequest) => + col.type === 'QUANTITATIVE' ? + { url: getGeneUrl(col.name), elements: 1, type: 'float' } as const + : + { url: getColumnUrl(col.name), elements: attrs[col.name].elements, type: attrs[col.name].type } + + const proms = reduce, Record Promise>>(columns, (getters, col, key) => { + const { url, type } = getColumnInfo(col) + return { + ...getters, + [key]: (signal) => fetch(url, { signal }).then(b => b.arrayBuffer().then(buff => { + const typed = MakeTaggedBufferView(type, buff) + return new VBO({ buffer: regl.buffer({ type: type, data: typed.data }), bytes: buff.byteLength, type: 'buffer' }) + })) + } + }, {}) + return proms; + }, + isValue: (v): v is Content => { + // TODO + // figure out the set of columns that would be required given our settings + // check if all those keys are in v, and each one is defined and instanceof VBO + return true; + }, + onDataArrived + }) + return client; +} + +// export function stuff(client: ReturnType, dataset: SlideviewScatterbrainDataset | ScatterbrainDataset, camera: { view: box2D, screenResolution: vec2 }) { +// const visible = getVisibleItems(dataset, camera) +// const items: Item[] = map(visible, (node) => ({ ...node, dataset, columns: { 'position': { type: 'METADATA', name: dataset.metadata.spatialColumn } } })) +// client.setPriorities(items, []); + +// } +type State = ShaderSettings & { + camera: { view: box2D, screenResolution: vec2 } +} + +// function buildRenderCommand(state: State, regl: REGL.Regl) { +// const { dataset } = state; +// const { config, columnNameToShaderName } = configureShader(state); +// const renderer = buildScatterbrainRenderCommand(config, regl) + +// // lets use a fake set of textures for now + + +// } +export function buildScatterbrainRenderer(regl: REGL.Regl, cache: SharedPriorityCache, canvas: HTMLCanvasElement) { + let draw: ReturnType | undefined; + const client = buildScatterbrainCacheClient(regl, cache, () => { + // mega hack for now - if new data shows up, try to just directly invoke the renderer with a stashed copy of the settings... + if (prevSettings) { + render(prevSettings) + } + }); + + const lookup = regl.texture({ width: 10, height: 10, format: 'rgba' }) + const gradientData = new Uint8Array(256 * 4); + for (let i = 0; i < 256; i += 4) { + gradientData[i * 4 + 0] = i; + gradientData[i * 4 + 1] = i; + gradientData[i * 4 + 2] = i; + gradientData[i * 4 + 3] = 255; + } + const gradient = regl.texture({ width: 256, height: 1, format: 'rgba', data: gradientData }) + let prevSettings: State | undefined; + const render = (state: State) => { + if (!draw || !isEqual(prevSettings, omit(state, 'camera'))) { + const { config, columnNameToShaderName } = configureShader(state); + draw = buildScatterbrainRenderCommand(config, regl); + } + const { camera, dataset } = state; + if (draw !== undefined) { + const visible = getVisibleItems(dataset, camera) + // TODO: use the columnNameToShaderName (in reverse) to build + // the set of columns to request (an Item is a request) + // the key will be the shaderName, the value will be a columnRequest that will satisfy that shader attribute. + const items: Item[] = map(visible, (node) => ({ ...node, dataset, columns: { 'position': { type: 'METADATA', name: dataset.metadata.spatialColumn } } })) + client.setPriorities(items, []); + + for (const item of items) { + if (client.has(item)) { + const gpuData = client.get(item) + if (gpuData) { + // draw it now + draw({ + target: null, + camera, + categoricalLookupTable: lookup, + gradient, + offset: [0, 0], + quantitativeRangeFilters: {}, // TODO fill me in - shader names are the keys here + item: { + columnData: gpuData, + count: item.node.numSpecimens, + }, + + }) + } + } + + } + } + prevSettings = state + } + + return render +} \ No newline at end of file diff --git a/packages/scatterbrain/src/better/shader.test.ts b/packages/scatterbrain/src/better/shader.test.ts new file mode 100644 index 00000000..42286c9d --- /dev/null +++ b/packages/scatterbrain/src/better/shader.test.ts @@ -0,0 +1,501 @@ +import { describe, expect, test } from 'vitest'; +import { buildShaders, type Config, configureShader } from './shader'; +import type { ScatterbrainDataset } from './types'; + + +const tenx: ScatterbrainDataset = { + "type": "normal", + "metadata": { + "points": 1494801, + "boundingBox": { + "lx": -11.541528999999999, + "ly": -13.448518, + "lz": 0, + "ux": 24.993372, + "uy": 23.086382999999998, + "uz": 36.534901 + }, + "tightBoundingBox": { + "lx": -11.541528999999999, + "ly": -13.448518, + "lz": 0, + "ux": 24.993372, + "uy": 22.9617, + "uz": 0 + }, + "root": { + "file": "r.bin", + "numSpecimens": 100496, + "children": [ + { + "file": "r0.bin", + "numSpecimens": 33084, + "children": [ + { + "file": "r00.bin", + "numSpecimens": 1004, + "children": [] + }, + { + "file": "r02.bin", + "numSpecimens": 8959, + "children": [] + }, + { + "file": "r04.bin", + "numSpecimens": 18257, + "children": [] + }, + { + "file": "r06.bin", + "numSpecimens": 21375, + "children": [ + { + "file": "r060.bin", + "numSpecimens": 1150, + "children": [] + }, + { + "file": "r062.bin", + "numSpecimens": 818, + "children": [] + }, + { + "file": "r064.bin", + "numSpecimens": 65649, + "children": [] + }, + { + "file": "r066.bin", + "numSpecimens": 5242, + "children": [] + } + ] + } + ] + }, + { + "file": "r2.bin", + "numSpecimens": 83987, + "children": [ + { + "file": "r20.bin", + "numSpecimens": 41058, + "children": [ + { + "file": "r200.bin", + "numSpecimens": 10658, + "children": [] + }, + { + "file": "r202.bin", + "numSpecimens": 37462, + "children": [] + }, + { + "file": "r204.bin", + "numSpecimens": 4385, + "children": [] + }, + { + "file": "r206.bin", + "numSpecimens": 23558, + "children": [] + } + ] + }, + { + "file": "r22.bin", + "numSpecimens": 19404, + "children": [ + { + "file": "r220.bin", + "numSpecimens": 33089, + "children": [] + }, + { + "file": "r224.bin", + "numSpecimens": 33541, + "children": [] + }, + { + "file": "r226.bin", + "numSpecimens": 303, + "children": [] + } + ] + }, + { + "file": "r24.bin", + "numSpecimens": 69249, + "children": [ + { + "file": "r240.bin", + "numSpecimens": 25571, + "children": [] + }, + { + "file": "r242.bin", + "numSpecimens": 32062, + "children": [] + }, + { + "file": "r244.bin", + "numSpecimens": 40682, + "children": [] + }, + { + "file": "r246.bin", + "numSpecimens": 60049, + "children": [ + { + "file": "r2460.bin", + "numSpecimens": 49708, + "children": [] + }, + { + "file": "r2462.bin", + "numSpecimens": 12312, + "children": [] + }, + { + "file": "r2464.bin", + "numSpecimens": 8059, + "children": [] + }, + { + "file": "r2466.bin", + "numSpecimens": 3863, + "children": [] + } + ] + } + ] + }, + { + "file": "r26.bin", + "numSpecimens": 40202, + "children": [ + { + "file": "r260.bin", + "numSpecimens": 18073, + "children": [] + }, + { + "file": "r262.bin", + "numSpecimens": 6172, + "children": [] + }, + { + "file": "r264.bin", + "numSpecimens": 25204, + "children": [] + }, + { + "file": "r266.bin", + "numSpecimens": 5508, + "children": [] + } + ] + } + ] + }, + { + "file": "r4.bin", + "numSpecimens": 41387, + "children": [ + { + "file": "r40.bin", + "numSpecimens": 9924, + "children": [] + }, + { + "file": "r42.bin", + "numSpecimens": 40161, + "children": [] + }, + { + "file": "r44.bin", + "numSpecimens": 1883, + "children": [] + }, + { + "file": "r46.bin", + "numSpecimens": 33158, + "children": [] + } + ] + }, + { + "file": "r6.bin", + "numSpecimens": 74145, + "children": [ + { + "file": "r60.bin", + "numSpecimens": 53224, + "children": [ + { + "file": "r600.bin", + "numSpecimens": 41323, + "children": [ + { + "file": "r6000.bin", + "numSpecimens": 1895, + "children": [] + }, + { + "file": "r6002.bin", + "numSpecimens": 24075, + "children": [] + }, + { + "file": "r6004.bin", + "numSpecimens": 1609, + "children": [] + }, + { + "file": "r6006.bin", + "numSpecimens": 18091, + "children": [] + } + ] + }, + { + "file": "r602.bin", + "numSpecimens": 30526, + "children": [] + }, + { + "file": "r604.bin", + "numSpecimens": 4394, + "children": [] + }, + { + "file": "r606.bin", + "numSpecimens": 21398, + "children": [] + } + ] + }, + { + "file": "r62.bin", + "numSpecimens": 46391, + "children": [ + { + "file": "r620.bin", + "numSpecimens": 9355, + "children": [] + }, + { + "file": "r622.bin", + "numSpecimens": 20763, + "children": [] + }, + { + "file": "r624.bin", + "numSpecimens": 7436, + "children": [] + }, + { + "file": "r626.bin", + "numSpecimens": 11283, + "children": [] + } + ] + }, + { + "file": "r64.bin", + "numSpecimens": 42149, + "children": [] + }, + { + "file": "r66.bin", + "numSpecimens": 20038, + "children": [] + } + ] + } + ] + }, + "spatialColumn": "488I12FURRB8ZY5KJ8TCoordinates", + "visualizationReferenceId": "488I12FURRB8ZY5KJ8T", + "geneFileEndpoint": "https://bkp-2d-visualizations-stage.s3.amazonaws.com/wmb_tenx_01172024_stage-20240128193624/488I12FURRB8ZY5KJ8T/data/", + "metadataFileEndpoint": "https://bkp-2d-visualizations-stage.s3.amazonaws.com/wmb_tenx_01172024_stage-20240128193624/488I12FURRB8ZY5KJ8T/metadata/", + "pointAttributes": { + "FS00DXV0T9R1X9FJ4QE": { + "name": "FS00DXV0T9R1X9FJ4QE", + "size": 2, + "elements": 1, + "elementSize": 2, + "type": "uint16", + "description": "Class" + }, + "QY5S8KMO5HLJUF0P00K": { + "name": "QY5S8KMO5HLJUF0P00K", + "size": 2, + "elements": 1, + "elementSize": 2, + "type": "uint16", + "description": "Subclass" + }, + "15BK47DCIOF1SLLUW9P": { + "name": "15BK47DCIOF1SLLUW9P", + "size": 2, + "elements": 1, + "elementSize": 2, + "type": "uint16", + "description": "Supertype" + }, + "CBGC0U30VV9JPR60TJU": { + "name": "CBGC0U30VV9JPR60TJU", + "size": 2, + "elements": 1, + "elementSize": 2, + "type": "uint16", + "description": "Cluster" + }, + "4MV7HA5DG2XJZ3UD8G9": { + "name": "4MV7HA5DG2XJZ3UD8G9", + "size": 2, + "elements": 1, + "elementSize": 2, + "type": "uint16", + "description": "Neurotransmitter Type" + }, + "Y937CVUSVZC7KYOHWVO": { + "name": "Y937CVUSVZC7KYOHWVO", + "size": 2, + "elements": 1, + "elementSize": 2, + "type": "uint16", + "description": "Dissection Region" + }, + "KRP9GYF002I5OPM7JSR": { + "name": "KRP9GYF002I5OPM7JSR", + "size": 2, + "elements": 1, + "elementSize": 2, + "type": "uint16", + "description": "Donor ID" + }, + "N3YEG845JSIPMS3C0MJ": { + "name": "N3YEG845JSIPMS3C0MJ", + "size": 2, + "elements": 1, + "elementSize": 2, + "type": "uint16", + "description": "Platform" + }, + "O95N6FNAK13WZWEIU5N": { + "name": "O95N6FNAK13WZWEIU5N", + "size": 2, + "elements": 1, + "elementSize": 2, + "type": "uint16", + "description": "Sex" + }, + "Q0LG0S1W23HUAKA2SW3": { + "name": "Q0LG0S1W23HUAKA2SW3", + "size": 2, + "elements": 1, + "elementSize": 2, + "type": "uint16", + "description": "Genotype" + }, + "488I12FURRB8ZY5KJ8TCoordinates": { + "name": "488I12FURRB8ZY5KJ8TCoordinates", + "size": 8, + "elements": 2, + "elementSize": 4, + "type": "float", + "description": "Pallium-Glut" + } + } + } +} +describe('configure', () => { + test('can we generate a sensible shader from settings', () => { + const { config, columnNameToShaderName } = configureShader({ + dataset: tenx, + categoricalFilters: {}, + colorBy: { kind: 'quantitative', column: '123', gradient: 'viridis', range: { min: 0, max: 10 } }, + quantitativeFilters: {}, + }) + const shaders = buildShaders(config); + + const expectedConfig: Config = { + categoricalColumns: [], + categoricalTable: 'lookup', + colorByColumn: 'COLOR_BY_MEASURE', + gradientTable: 'gradient', + mode: 'color', + quantitativeColumns: ['COLOR_BY_MEASURE'], + positionColumn: 'position', + } + const expectedShader = /*glsl*/` + // attribs // + + attribute vec2 position; + + attribute float COLOR_BY_MEASURE; + + // uniforms // + + uniform vec4 view; + uniform vec2 screenSize; + uniform vec2 offset; + uniform sampler2D gradient; + uniform sampler2D lookup; + // quantitative columns each need a range value - its the min,max in a vec2 + uniform vec2 COLOR_BY_MEASURE_range; + + + // utility functions // + + vec4 applyCamera(vec3 dataPos){ + vec2 size = view.zw-view.xy; + vec2 unit = (data.xy-view.xy)/size; + return vec4((unit*2.0)-1.0,0.0,1.0); + } + float rangeParameter(float v, vec2 range){ + return (v-range.x)/(range.y-range.x); + } + + + // per-point interface functions // + float isFilteredIn(){ + return 1.0; + } + float isHovered(){ + return 0.0; + } + vec3 getDataPosition(){ + return vec3(position+offset,0.0); + } + // the primary per-point functions, called directly // + vec4 getClipPosition(){ + return applyCamera(getDataPosition()); + } + float getPointSize(){ + return 2.0; + } + vec4 getColor(){ + + float p = rangeParameter(COLOR_BY_MEASURE,COLOR_BY_MEASURE_range); + return texture2D(gradient,vec2(p,0.5)); + + } + varying vec4 color; + void main(){ + color = getColor(); + gl_PointSize = getPointSize(); + gl_Position = getClipPosition(); + }` + expect(config).toEqual(expectedConfig) + expect(shaders.vs.replace(/\s/g, "")).toEqual(expectedShader.replace(/\s/g, "")) + expect(columnNameToShaderName).toEqual({ + '123': 'COLOR_BY_MEASURE', + "488I12FURRB8ZY5KJ8TCoordinates": "position", + }) + }) +}) diff --git a/packages/scatterbrain/src/better/shader.ts b/packages/scatterbrain/src/better/shader.ts new file mode 100644 index 00000000..120523c3 --- /dev/null +++ b/packages/scatterbrain/src/better/shader.ts @@ -0,0 +1,258 @@ + +// we have to generate a shader, due to the runtime-variable way in which columns of data +// are used for filtering (some in a range, some via a lookup table) + +import REGL from "regl"; +import type { ScatterbrainDataset, SlideviewScatterbrainDataset } from "./types"; +import { keys, mapValues, reduce } from "lodash"; +import { Box2D, type box2D, type Interval, type vec2 } from "@alleninstitute/vis-geometry"; +import { type Cacheable, type CachedVertexBuffer } from "@alleninstitute/vis-core"; + +// the set of columns and what to do with them can vary +// there might be 3 categorical columns and 2 range columns +// each range column (a vertex attrib) uses a uniform vec2 as its filter range +// due to a variety of limitations in WebGL / GLSL 1 - this is about as general as we can +// get without a great deal of extra performance cost + + +// scatterbrain does scatterplot rendering +// its main claim to fame is handling complex filtering +// when generating shaders, most of the variable parts flow through these +// utilities - this type's fields are the names of GLSL functions - the contents +// of the string must be the body of that function. All functions should take NO arguments +// and simply refer to global uniforms / attribs directly. +// as a quick reminder - recursion of any kind is forbidden in GLSL - so be careful +// as it will not be possible to detect this until runtime. +// its not unreasonable for some of these utils to call each other - just be sure to +// avoid recursion! +// note - you dont have to use these! these are just kinda like guide-rails for +// patterns we've seen in our shaders so far! you could easily generate your own +// totally custom shaders! + +type ScatterbrainShaderUtils = { + uniforms: string; // the GLSL declarations of the uniforms for this shader + attributes: string; // the GLSL declarations of the vertex attributes for this shader + commonUtilsGLSL: string; // prepend any GLSL to the final vertex shader + isFilteredIn: string; // ()->float + isHovered: string; // ()->float + getColor: string; // ()-> vec4 + getDataPosition: string; // ()-> vec3 // the position of the point in data-space + getClipPosition: string; // ()-> vec4 // the position of the point in clip space - (hint - apply the camera to data-space) + getPointSize: string; // ()->float +} +export class VBO implements Cacheable { + buffer: CachedVertexBuffer; + constructor(buffer: CachedVertexBuffer) { + this.buffer = buffer; + } + destroy() { + this.buffer.buffer.destroy() + } + sizeInBytes() { + return this.buffer.bytes; + } +} + +function buildVertexShader(utils: ScatterbrainShaderUtils) { + return /*glsl*/` + precision highp float; + // attribs // + ${utils.attributes} + // uniforms // + ${utils.uniforms} + + // utility functions // + ${utils.commonUtilsGLSL} + + // per-point interface functions // + float isFilteredIn(){ + ${utils.isFilteredIn} + } + float isHovered(){ + ${utils.isHovered} + } + vec3 getDataPosition(){ + ${utils.getDataPosition} + } + // the primary per-point functions, called directly // + vec4 getClipPosition(){ + ${utils.getClipPosition} + } + float getPointSize(){ + ${utils.getPointSize} + } + vec4 getColor(){ + ${utils.getColor} + } + varying vec4 color; + void main(){ + color = getColor(); + gl_PointSize = getPointSize(); + gl_Position = getClipPosition(); + } + ` + // note that only getColor, getPointSize, and getPosition are called + // that should make clear that the other fns are indended to simply be useful + // concepts that the other main fns can call +} +export function buildShaders(config: Config) { + return { + vs: buildVertexShader(generate(config)), + fs: /*glsl*/` + precision highp float; + varying vec4 color; + void main(){ + gl_FragColor = color; + } + ` + } +} +export type Config = { + mode: 'color' | 'info'; + quantitativeColumns: string[]; + categoricalColumns: string[]; + categoricalTable: string; + gradientTable: string; + positionColumn: string; + colorByColumn: string; +} +function rangeFor(col: string) { + return `${col}_range`; +} + +export function buildScatterbrainRenderCommand(config: Config, regl: REGL.Regl) { + const prop = (p: string) => regl.prop(p) + const { mode, quantitativeColumns, categoricalColumns, categoricalTable, gradientTable, positionColumn } = config; + const ranges = reduce(quantitativeColumns, (unis, col) => ({ ...unis, [rangeFor(col)]: prop(rangeFor(col)) }), {} as Record>); + const { vs, fs } = buildShaders(config); + const uniforms = { + [categoricalTable]: prop('categoricalLookupTable'), + [gradientTable]: prop('gradient'), + ...ranges, + view: prop('view'), + screenSize: prop('screenSize'), + offset: prop('offset'), + } + const cmd = regl({ + vert: vs, + frag: fs, + attributes: [positionColumn, ...categoricalColumns, ...quantitativeColumns].reduce((attribs, col) => ({ ...attribs, [col]: regl.prop(col) }), {}), + uniforms, + blend: { + enable: false + }, + primitive: 'points', + framebuffer: prop('target'), + count: prop('count') + }); + // + return (props: { + target: REGL.Framebuffer2D | null, + categoricalLookupTable: REGL.Texture2D, + gradient: REGL.Texture2D, + camera: { view: box2D, screenResolution: vec2 }, + offset: vec2, + quantitativeRangeFilters: Record, + item: { + count: number, + columnData: Record + } + }) => { + const { target, gradient, camera, offset, quantitativeRangeFilters, categoricalLookupTable, item } = props + const { view, screenResolution } = camera + const { count, columnData } = item; + const rawBuffers = mapValues(columnData, (vbo) => vbo.buffer.buffer) + cmd({ target, gradient, categoricalLookupTable, offset, count, view: Box2D.toFlatArray(view), screenSize: screenResolution, ...quantitativeRangeFilters, ...rawBuffers }) + } +} + +export function generate(config: Config): ScatterbrainShaderUtils { + const { mode, quantitativeColumns, categoricalColumns, categoricalTable, gradientTable, positionColumn, colorByColumn } = config; + + const uniforms = /*glsl*/` + uniform vec4 view; + uniform vec2 screenSize; + uniform vec2 offset; + uniform sampler2D ${gradientTable}; + uniform sampler2D ${categoricalTable}; + // quantitative columns each need a range value - its the min,max in a vec2 + ${quantitativeColumns.map((col) =>/*glsl*/`uniform vec2 ${rangeFor(col)};`).join('\n')} + ` + + const attributes = /*glsl*/` + attribute vec2 ${positionColumn}; + ${categoricalColumns.map((col) =>/*glsl*/`attribute float ${col};`).join('\n')} + ${quantitativeColumns.map((col) =>/*glsl*/`attribute float ${col};`).join('\n')} + ` + + const commonUtilsGLSL = /*glsl*/` + vec4 applyCamera(vec3 dataPos){ + vec2 size = view.zw-view.xy; + vec2 unit = (dataPos.xy-view.xy)/size; + return vec4((unit*2.0)-1.0,0.0,1.0); + } + float rangeParameter(float v, vec2 range){ + return (v-range.x)/(range.y-range.x); + } + ` + + const isHovered = /*glsl*/`return 0.0;` // todo hovering + const isFilteredIn = /*glsl*/`return 1.0;` // todo filtering! + + const getDataPosition = /*glsl*/`return vec3(${positionColumn}+offset,0.0);` + const getClipPosition = /*glsl*/`return applyCamera(getDataPosition());` + const getPointSize = /*glsl*/`return 2.0;` // todo! + const getColor = /*glsl*/` + float p = rangeParameter(${colorByColumn},${rangeFor(colorByColumn)}); + return texture2D(${gradientTable},vec2(p,0.5)); + ` // for now, lets assume this is a color-by-quantitative shader... + + + return { + attributes, + uniforms, + commonUtilsGLSL, + getClipPosition, + getColor, + getDataPosition, + getPointSize, + isFilteredIn, + isHovered, + } +} + + +// these settings impact how the shader is generated - +// that means changing them may require re-building the renderer (and the shader beneath it) +export type ShaderSettings = { + dataset: ScatterbrainDataset | SlideviewScatterbrainDataset + categoricalFilters: Record> // category-->{value : filteredIn} + quantitativeFilters: Record + colorBy: { kind: 'metadata', column: string } | { kind: 'quantitative', column: string, gradient: 'viridis' | 'inferno', range: Interval } +} + + + +export function configureShader(settings: ShaderSettings): { config: Config, columnNameToShaderName: Record } { + // given settings that make sense to a caller (stuff about the data we want to visualize) + // produce an object that can be used to set up some internal config of the shader that would + // do the visualization + const { dataset, categoricalFilters, quantitativeFilters, colorBy } = settings; + // figure out the columns we care about + // assign them names that are safe to use in the shader (A,B,C, whatever) + + const qAttrs = reduce(keys(quantitativeFilters).toSorted(), (acc, cur, i) => ({ ...acc, [cur]: `MEASURE_${i.toFixed(0)}` }), colorBy.kind === 'metadata' ? {} : { [colorBy.column]: 'COLOR_BY_MEASURE' } as Record); + const cAttrs = reduce(keys(categoricalFilters).toSorted(), (acc, cur, i) => ({ ...acc, [cur]: `CATEGORY_${i.toFixed(0)}` }), colorBy.kind === 'metadata' ? { [colorBy.column]: 'COLOR_BY_CATEGORY' } : {} as Record); + const colToAttribute = { ...qAttrs, ...cAttrs, [dataset.metadata.spatialColumn]: 'position' }; + + const config: Config = { + categoricalColumns: keys(cAttrs).map(columnName => colToAttribute[columnName]), + quantitativeColumns: keys(qAttrs).map(columnName => colToAttribute[columnName]), + categoricalTable: 'lookup', + gradientTable: 'gradient', + colorByColumn: colToAttribute[colorBy.column], + mode: 'color', + positionColumn: 'position', + } + return { config, columnNameToShaderName: colToAttribute } +} \ No newline at end of file diff --git a/packages/scatterbrain/src/better/types.ts b/packages/scatterbrain/src/better/types.ts new file mode 100644 index 00000000..bb696d79 --- /dev/null +++ b/packages/scatterbrain/src/better/types.ts @@ -0,0 +1,88 @@ +// lets get a renderer up and rolling +// then add features from there... + +/// Types describing the metadata that gets loaded from scatterbrain.json files /// +// there are 2 variants, slideview and regular - they are distinguished at runtime +// by checking the parsed metadata for the 'slides' field +export type WebGLSafeBasicType = 'uint8' | 'uint16' | 'int8' | 'int16' | 'uint32' | 'int32' | 'float'; + +export type volumeBound = { + lx: number; + ly: number; + lz: number; + ux: number; + uy: number; + uz: number; +}; +type PointAttribute = { + name: string; + size: number; // elements * elementSize - todo ask Peter to remove + elements: number; // values per point (so a vector xy would have 2) + elementSize: number; // size of an element, given in bytes (for example float would have 4) + type: WebGLSafeBasicType; + description: string; +}; +export type TreeNode = { + file: string; + numSpecimens: number; + children: undefined | TreeNode[]; +}; + +type MetadataColumn = { + type: 'METADATA'; + name: string; +}; +type QuantitativeColumn = { + type: 'QUANTITATIVE'; + name: string; +}; +export type ColumnRequest = MetadataColumn | QuantitativeColumn; +type CommonMetadata = { + geneFileEndpoint: string; + metadataFileEndpoint: string; + visualizationReferenceId: string; + spatialColumn: string; + pointAttributes: Record; +} +// scatterbrain distinguishes 2 kinds of datasets - those arranged at the topmost level into slides +// and 'regular' - which is just a simple, 2D point cloud +export type ScatterbrainMetadata = CommonMetadata & { + points: number; + boundingBox: volumeBound; + tightBoundingBox: volumeBound; + root: TreeNode; +}; + +// slideview variant: +type Slide = { + featureTypeValueReferenceId: string; + tree: { + root: TreeNode; + points: number; + boundingBox: volumeBound; + tightBoundingBox: volumeBound; + }; +}; +type SpatialReferenceFrame = { + anatomicalOrigin: string; + direction: string; + unit: string; + minX: number; + maxX: number; + minY: number; + maxY: number; +}; + +export type SlideviewMetadata = CommonMetadata & { + slides: Slide[]; + spatialUnit: SpatialReferenceFrame; +}; + +/// Types related to the rendering of scatterbrain datasets /// + +// a Dataset is the top level entity +// an Item is a chunk of that dataset - esentially a singular, loadable, thing + + +export type SlideviewScatterbrainDataset = { type: 'slideview', metadata: SlideviewMetadata } +export type ScatterbrainDataset = { type: 'normal', metadata: ScatterbrainMetadata } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5af5794c..0187d1a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,6 +114,9 @@ importers: '@alleninstitute/vis-geometry': specifier: workspace:* version: link:../geometry + lodash: + specifier: 4.17.21 + version: 4.17.21 regl: specifier: 2.1.0 version: 2.1.0 @@ -127,12 +130,18 @@ importers: '@parcel/transformer-typescript-types': specifier: 2.15.4 version: 2.15.4(@parcel/core@2.15.4(@swc/helpers@0.5.17))(typescript@5.9.3) + '@types/lodash': + specifier: 4.17.21 + version: 4.17.21 parcel: specifier: 2.15.4 version: 2.15.4(@swc/helpers@0.5.17) typescript: specifier: 5.9.3 version: 5.9.3 + vite: + specifier: 7.3.0 + version: 7.3.0(lightningcss@1.30.2)(yaml@2.8.1) vitest: specifier: 4.0.6 version: 4.0.6(@types/debug@4.1.12)(lightningcss@1.30.2)(yaml@2.8.1) @@ -594,8 +603,8 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -606,8 +615,8 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -618,8 +627,8 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} engines: {node: '>=18'} cpu: [arm] os: [android] @@ -630,8 +639,8 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} engines: {node: '>=18'} cpu: [x64] os: [android] @@ -642,8 +651,8 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -654,8 +663,8 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -666,8 +675,8 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -678,8 +687,8 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -690,8 +699,8 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -702,8 +711,8 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -714,8 +723,8 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] @@ -726,8 +735,8 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -738,8 +747,8 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -750,8 +759,8 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -762,8 +771,8 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -774,8 +783,8 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -786,8 +795,8 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} engines: {node: '>=18'} cpu: [x64] os: [linux] @@ -798,8 +807,8 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] @@ -810,8 +819,8 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] @@ -822,8 +831,8 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] @@ -834,8 +843,8 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] @@ -846,8 +855,8 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] @@ -858,8 +867,8 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -870,8 +879,8 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -882,8 +891,8 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -894,8 +903,8 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -2326,6 +2335,9 @@ packages: '@types/lodash@4.17.20': resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} + '@types/lodash@4.17.21': + resolution: {integrity: sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -2804,8 +2816,8 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} hasBin: true @@ -4305,8 +4317,8 @@ packages: yaml: optional: true - vite@7.1.12: - resolution: {integrity: sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==} + vite@7.3.0: + resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -5159,157 +5171,157 @@ snapshots: '@esbuild/aix-ppc64@0.25.10': optional: true - '@esbuild/aix-ppc64@0.25.12': + '@esbuild/aix-ppc64@0.27.2': optional: true '@esbuild/android-arm64@0.25.10': optional: true - '@esbuild/android-arm64@0.25.12': + '@esbuild/android-arm64@0.27.2': optional: true '@esbuild/android-arm@0.25.10': optional: true - '@esbuild/android-arm@0.25.12': + '@esbuild/android-arm@0.27.2': optional: true '@esbuild/android-x64@0.25.10': optional: true - '@esbuild/android-x64@0.25.12': + '@esbuild/android-x64@0.27.2': optional: true '@esbuild/darwin-arm64@0.25.10': optional: true - '@esbuild/darwin-arm64@0.25.12': + '@esbuild/darwin-arm64@0.27.2': optional: true '@esbuild/darwin-x64@0.25.10': optional: true - '@esbuild/darwin-x64@0.25.12': + '@esbuild/darwin-x64@0.27.2': optional: true '@esbuild/freebsd-arm64@0.25.10': optional: true - '@esbuild/freebsd-arm64@0.25.12': + '@esbuild/freebsd-arm64@0.27.2': optional: true '@esbuild/freebsd-x64@0.25.10': optional: true - '@esbuild/freebsd-x64@0.25.12': + '@esbuild/freebsd-x64@0.27.2': optional: true '@esbuild/linux-arm64@0.25.10': optional: true - '@esbuild/linux-arm64@0.25.12': + '@esbuild/linux-arm64@0.27.2': optional: true '@esbuild/linux-arm@0.25.10': optional: true - '@esbuild/linux-arm@0.25.12': + '@esbuild/linux-arm@0.27.2': optional: true '@esbuild/linux-ia32@0.25.10': optional: true - '@esbuild/linux-ia32@0.25.12': + '@esbuild/linux-ia32@0.27.2': optional: true '@esbuild/linux-loong64@0.25.10': optional: true - '@esbuild/linux-loong64@0.25.12': + '@esbuild/linux-loong64@0.27.2': optional: true '@esbuild/linux-mips64el@0.25.10': optional: true - '@esbuild/linux-mips64el@0.25.12': + '@esbuild/linux-mips64el@0.27.2': optional: true '@esbuild/linux-ppc64@0.25.10': optional: true - '@esbuild/linux-ppc64@0.25.12': + '@esbuild/linux-ppc64@0.27.2': optional: true '@esbuild/linux-riscv64@0.25.10': optional: true - '@esbuild/linux-riscv64@0.25.12': + '@esbuild/linux-riscv64@0.27.2': optional: true '@esbuild/linux-s390x@0.25.10': optional: true - '@esbuild/linux-s390x@0.25.12': + '@esbuild/linux-s390x@0.27.2': optional: true '@esbuild/linux-x64@0.25.10': optional: true - '@esbuild/linux-x64@0.25.12': + '@esbuild/linux-x64@0.27.2': optional: true '@esbuild/netbsd-arm64@0.25.10': optional: true - '@esbuild/netbsd-arm64@0.25.12': + '@esbuild/netbsd-arm64@0.27.2': optional: true '@esbuild/netbsd-x64@0.25.10': optional: true - '@esbuild/netbsd-x64@0.25.12': + '@esbuild/netbsd-x64@0.27.2': optional: true '@esbuild/openbsd-arm64@0.25.10': optional: true - '@esbuild/openbsd-arm64@0.25.12': + '@esbuild/openbsd-arm64@0.27.2': optional: true '@esbuild/openbsd-x64@0.25.10': optional: true - '@esbuild/openbsd-x64@0.25.12': + '@esbuild/openbsd-x64@0.27.2': optional: true '@esbuild/openharmony-arm64@0.25.10': optional: true - '@esbuild/openharmony-arm64@0.25.12': + '@esbuild/openharmony-arm64@0.27.2': optional: true '@esbuild/sunos-x64@0.25.10': optional: true - '@esbuild/sunos-x64@0.25.12': + '@esbuild/sunos-x64@0.27.2': optional: true '@esbuild/win32-arm64@0.25.10': optional: true - '@esbuild/win32-arm64@0.25.12': + '@esbuild/win32-arm64@0.27.2': optional: true '@esbuild/win32-ia32@0.25.10': optional: true - '@esbuild/win32-ia32@0.25.12': + '@esbuild/win32-ia32@0.27.2': optional: true '@esbuild/win32-x64@0.25.10': optional: true - '@esbuild/win32-x64@0.25.12': + '@esbuild/win32-x64@0.27.2': optional: true '@expressive-code/core@0.41.3': @@ -6941,6 +6953,8 @@ snapshots: '@types/lodash@4.17.20': {} + '@types/lodash@4.17.21': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -7021,13 +7035,13 @@ snapshots: chai: 6.2.0 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.6(vite@7.1.12(lightningcss@1.30.2)(yaml@2.8.1))': + '@vitest/mocker@4.0.6(vite@7.3.0(lightningcss@1.30.2)(yaml@2.8.1))': dependencies: '@vitest/spy': 4.0.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.1.12(lightningcss@1.30.2)(yaml@2.8.1) + vite: 7.3.0(lightningcss@1.30.2)(yaml@2.8.1) '@vitest/pretty-format@4.0.6': dependencies: @@ -7543,34 +7557,34 @@ snapshots: '@esbuild/win32-ia32': 0.25.10 '@esbuild/win32-x64': 0.25.10 - esbuild@0.25.12: + esbuild@0.27.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 escalade@3.2.0: {} @@ -9523,9 +9537,9 @@ snapshots: lightningcss: 1.30.2 yaml: 2.8.1 - vite@7.1.12(lightningcss@1.30.2)(yaml@2.8.1): + vite@7.3.0(lightningcss@1.30.2)(yaml@2.8.1): dependencies: - esbuild: 0.25.12 + esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 @@ -9543,7 +9557,7 @@ snapshots: vitest@4.0.6(@types/debug@4.1.12)(lightningcss@1.30.2)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.6 - '@vitest/mocker': 4.0.6(vite@7.1.12(lightningcss@1.30.2)(yaml@2.8.1)) + '@vitest/mocker': 4.0.6(vite@7.3.0(lightningcss@1.30.2)(yaml@2.8.1)) '@vitest/pretty-format': 4.0.6 '@vitest/runner': 4.0.6 '@vitest/snapshot': 4.0.6 @@ -9560,7 +9574,7 @@ snapshots: tinyexec: 0.3.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.1.12(lightningcss@1.30.2)(yaml@2.8.1) + vite: 7.3.0(lightningcss@1.30.2)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 diff --git a/site/src/examples/scatterbrain/scatterbrain/readme.md b/site/src/examples/scatterbrain/scatterbrain/readme.md deleted file mode 100644 index dcf06412..00000000 --- a/site/src/examples/scatterbrain/scatterbrain/readme.md +++ /dev/null @@ -1,3 +0,0 @@ -I am making yet another 'example' - but this one will get directly moved into a package all at once at the end -it is a bit unpleasant to develop code in the packages area, as I would need to constantly rebuild the example that refers to them -we have a script to do that - but each package needs a port.. it doesnt always work, - its a pain. I'm used to bun whatever.html as a form of rapid iteration - no bs setup needed From 19d734c434487c5f3e6d0dbaffc56c7867eff74d Mon Sep 17 00:00:00 2001 From: noah Date: Sun, 21 Dec 2025 05:49:17 -0800 Subject: [PATCH 07/29] make it actually work, delete false-start stuff --- packages/scatterbrain/demo.ts | 15 +- packages/scatterbrain/src/better/dataset.ts | 2 +- packages/scatterbrain/src/better/renderer.ts | 25 ++- packages/scatterbrain/src/better/shader.ts | 5 +- packages/scatterbrain/src/loader.ts | 0 packages/scatterbrain/src/render.ts | 142 ----------------- packages/scatterbrain/src/shader.ts | 158 ------------------- packages/scatterbrain/src/types.ts | 81 ---------- 8 files changed, 34 insertions(+), 394 deletions(-) delete mode 100644 packages/scatterbrain/src/loader.ts delete mode 100644 packages/scatterbrain/src/render.ts delete mode 100644 packages/scatterbrain/src/shader.ts delete mode 100644 packages/scatterbrain/src/types.ts diff --git a/packages/scatterbrain/demo.ts b/packages/scatterbrain/demo.ts index dc49e474..53a9212a 100644 --- a/packages/scatterbrain/demo.ts +++ b/packages/scatterbrain/demo.ts @@ -5,14 +5,18 @@ import { type ScatterbrainDataset } from "./src/better/types"; import { loadDataset } from "./src/better/dataset"; const twoGB = 1024 * 1024 * 2000; -const tenx = 'https://bkp-2d-visualizations-stage.s3.amazonaws.com/wmb_tenx_01172024_stage-20240128193624/488I12FURRB8ZY5KJ8T/ScatterBrain.json'; - +const tenx = 'https://bkp-2d-visualizations-stage.s3.amazonaws.com/wmb_tenx_01172024_stage-20240128193624/G4I4GFJXJB9ATZ3PTX1/ScatterBrain.json' async function begin() { console.log("hi from vite dev!") const canvas = document.getElementById('canvas') as HTMLCanvasElement + canvas.width = 800; + canvas.height = 800; const cache = new SharedPriorityCache(new Map(), twoGB, 10) - const glcanvas = new OffscreenCanvas(10, 10); - const gl = glcanvas.getContext('webgl', { + // it would be more normal in a shared-cache setup to use an offscreen gl + // canvas, and then copy the pixels out and over to the client of the cache + // but this is a demo, so lets skip that step + // const glcanvas = new OffscreenCanvas(10, 10); + const gl = canvas.getContext('webgl', { alpha: true, preserveDrawingBuffer: false, antialias: true, @@ -28,12 +32,11 @@ async function begin() { const dataset = await (await fetch(tenx)).json() const yay: ScatterbrainDataset = loadDataset(dataset); console.log('yay data!', yay) - // stuff(client, yay, { view: { minCorner: [0, 0], maxCorner: [10, 10] }, screenResolution: [800, 800] }) const render = buildScatterbrainRenderer(regl, cache, canvas) render({ camera: { view: { minCorner: [0, 0], maxCorner: [10, 10] }, screenResolution: [800, 800] }, categoricalFilters: {}, - colorBy: { kind: 'quantitative', column: '123', gradient: 'viridis', range: { min: 0, max: 10 } }, + colorBy: { kind: 'quantitative', column: '27683', gradient: 'viridis', range: { min: 0, max: 10 } }, dataset: yay, quantitativeFilters: {}, }) diff --git a/packages/scatterbrain/src/better/dataset.ts b/packages/scatterbrain/src/better/dataset.ts index e73ce7c5..1a948f19 100644 --- a/packages/scatterbrain/src/better/dataset.ts +++ b/packages/scatterbrain/src/better/dataset.ts @@ -5,7 +5,7 @@ import { reduce } from "lodash"; type Dataset = ScatterbrainDataset | SlideviewScatterbrainDataset // figure out that path through the tree, given a TreeNode name // these names are structured data - so it should always be possible -type NodeWithBounds = { node: TreeNode, bounds: box2D } +export type NodeWithBounds = { node: TreeNode, bounds: box2D } // adapted from Potree createChildAABB // note that if you do not do indexing in precisely the same order diff --git a/packages/scatterbrain/src/better/renderer.ts b/packages/scatterbrain/src/better/renderer.ts index 10f242fc..8da777d7 100644 --- a/packages/scatterbrain/src/better/renderer.ts +++ b/packages/scatterbrain/src/better/renderer.ts @@ -4,7 +4,7 @@ import type { ColumnRequest, ScatterbrainDataset, SlideviewScatterbrainDataset, import type { box2D, Interval, vec2 } from '@alleninstitute/vis-geometry'; import { MakeTaggedBufferView } from '../typed-array'; import { isEqual, keys, map, omit, reduce } from 'lodash' -import { getVisibleItems } from './dataset'; +import { getVisibleItems, type NodeWithBounds } from './dataset'; import { buildScatterbrainRenderCommand, buildShaders, type Config, configureShader, type ShaderSettings, VBO } from './shader'; export type Item = Readonly<{ dataset: SlideviewScatterbrainDataset | ScatterbrainDataset @@ -74,6 +74,21 @@ type State = ShaderSettings & { // } +function columnsForItem(config: Config, col2shader: Record, dataset: ScatterbrainDataset | SlideviewScatterbrainDataset) { + const columns: Record = {} + const s2c = reduce(keys(col2shader), (acc, col) => ({ ...acc, [col2shader[col]]: col }), {} as Record) + + for (const c of config.categoricalColumns) { + columns[c] = { type: 'METADATA', name: s2c[c] } + } + for (const m of config.quantitativeColumns) { + columns[m] = { type: 'QUANTITATIVE', name: s2c[m] } + } + columns[config.positionColumn] = { type: 'METADATA', name: dataset.metadata.spatialColumn } + return (item: T,) => { + return { ...item, dataset, columns } + } +} export function buildScatterbrainRenderer(regl: REGL.Regl, cache: SharedPriorityCache, canvas: HTMLCanvasElement) { let draw: ReturnType | undefined; const client = buildScatterbrainCacheClient(regl, cache, () => { @@ -93,18 +108,20 @@ export function buildScatterbrainRenderer(regl: REGL.Regl, cache: SharedPriority } const gradient = regl.texture({ width: 256, height: 1, format: 'rgba', data: gradientData }) let prevSettings: State | undefined; + let augment: ((node: NodeWithBounds) => Item) | undefined const render = (state: State) => { + const { camera, dataset } = state; if (!draw || !isEqual(prevSettings, omit(state, 'camera'))) { const { config, columnNameToShaderName } = configureShader(state); + augment = columnsForItem(config, columnNameToShaderName, dataset); draw = buildScatterbrainRenderCommand(config, regl); } - const { camera, dataset } = state; if (draw !== undefined) { const visible = getVisibleItems(dataset, camera) // TODO: use the columnNameToShaderName (in reverse) to build // the set of columns to request (an Item is a request) // the key will be the shaderName, the value will be a columnRequest that will satisfy that shader attribute. - const items: Item[] = map(visible, (node) => ({ ...node, dataset, columns: { 'position': { type: 'METADATA', name: dataset.metadata.spatialColumn } } })) + const items: Item[] = map(visible, augment!) client.setPriorities(items, []); for (const item of items) { @@ -118,7 +135,7 @@ export function buildScatterbrainRenderer(regl: REGL.Regl, cache: SharedPriority categoricalLookupTable: lookup, gradient, offset: [0, 0], - quantitativeRangeFilters: {}, // TODO fill me in - shader names are the keys here + quantitativeRangeFilters: { COLOR_BY_MEASURE: [0, 10] }, // TODO fill me in - shader names are the keys here item: { columnData: gpuData, count: item.node.numSpecimens, diff --git a/packages/scatterbrain/src/better/shader.ts b/packages/scatterbrain/src/better/shader.ts index 120523c3..c7803b93 100644 --- a/packages/scatterbrain/src/better/shader.ts +++ b/packages/scatterbrain/src/better/shader.ts @@ -4,7 +4,7 @@ import REGL from "regl"; import type { ScatterbrainDataset, SlideviewScatterbrainDataset } from "./types"; -import { keys, mapValues, reduce } from "lodash"; +import { filter, keys, mapValues, reduce } from "lodash"; import { Box2D, type box2D, type Interval, type vec2 } from "@alleninstitute/vis-geometry"; import { type Cacheable, type CachedVertexBuffer } from "@alleninstitute/vis-core"; @@ -159,10 +159,11 @@ export function buildScatterbrainRenderCommand(config: Config, regl: REGL.Regl) } }) => { const { target, gradient, camera, offset, quantitativeRangeFilters, categoricalLookupTable, item } = props + const filterRanges = reduce(keys(quantitativeRangeFilters), (acc, cur) => ({ ...acc, [rangeFor(cur)]: quantitativeRangeFilters[cur] }), {}) const { view, screenResolution } = camera const { count, columnData } = item; const rawBuffers = mapValues(columnData, (vbo) => vbo.buffer.buffer) - cmd({ target, gradient, categoricalLookupTable, offset, count, view: Box2D.toFlatArray(view), screenSize: screenResolution, ...quantitativeRangeFilters, ...rawBuffers }) + cmd({ target, gradient, categoricalLookupTable, offset, count, view: Box2D.toFlatArray(view), screenSize: screenResolution, ...filterRanges, ...rawBuffers }) } } diff --git a/packages/scatterbrain/src/loader.ts b/packages/scatterbrain/src/loader.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/scatterbrain/src/render.ts b/packages/scatterbrain/src/render.ts deleted file mode 100644 index 93f5f607..00000000 --- a/packages/scatterbrain/src/render.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** biome-ignore-all lint/style/noUselessElse: */ -/** biome-ignore-all lint/complexity/useArrowFunction: */ -import * as REGL from 'regl' -import { ReglCacheEntry, type Renderer } from '@alleninstitute/vis-core' -import { ScatterbrainDataset, SlideviewScatterbrainDataset, TreeNode, volumeBound } from './types' -import { Box2D, box2D, Box3D, box3D, Vec2, vec2, vec3, Vec3, visitBFS } from '../../geometry/dist/types' -// lets get a renderer up and rolling -// then add features from there... - - - -type Settings = { camera: { view: box2D, screenResolution: vec2 } } -type Item = { node: TreeNode, bounds: box2D } -type GPUData = {} -type Dataset = ScatterbrainDataset | SlideviewScatterbrainDataset -type ScatterbrainRenderer = Renderer -function buildScatterbrainRenderer(regl: REGL.Regl): ScatterbrainRenderer { - - return { - getVisibleItems: function (data: Dataset, settings: Settings): Item[] { - return getVisibleItems(data, settings.camera) - }, - fetchItemContent: function (item: Item, dataset: Dataset, settings: Settings): Record Promise> { - throw new Error('Function not implemented.') - }, - isPrepared: function (cacheData: Record): cacheData is GPUData { - throw new Error('Function not implemented.') - }, - renderItem: function (target: REGL.Framebuffer2D | null, item: Item, data: Dataset, settings: Settings, gpuData: {}): void { - throw new Error('Function not implemented.') - }, - cacheKey: function (item: Item, requestKey: string, data: Dataset, settings: Settings): string { - throw new Error('Function not implemented.') - }, - destroy: function (regl: REGL.Regl): void { - throw new Error('Function not implemented.') - } - } - -} -// adapted from Potree createChildAABB -// note that if you do not do indexing in precisely the same order -// as potree octrees, this will not work correctly at all -function getChildBoundsUsingPotreeIndexing(parentBounds: box3D, index: number) { - const min = parentBounds.minCorner; - const size = Vec3.scale(Box3D.size(parentBounds), 0.5); - const offset: vec3 = [ - (index & 0b0100) > 0 ? size[0] : 0, - (index & 0b0010) > 0 ? size[1] : 0, - (index & 0b0001) > 0 ? size[2] : 0, - ]; - const newMin = Vec3.add(min, offset); - return { - minCorner: newMin, - maxCorner: Vec3.add(newMin, size), - }; -} -function children(node: TreeNode) { - return node.children ?? [] -} -function sanitizeName(fileName: string) { - return fileName.replace('.bin', ''); -} -function bounds(rootBounds: box3D, path: string) { - // path is a name like r01373 - indicating a path through the tree, each character is a child index - let bounds = rootBounds - for (let i = 1; i < path.length; i++) { - const ci = Number(path[i]); - bounds = getChildBoundsUsingPotreeIndexing(bounds, ci); - } - return bounds; -} -function dropZ(box: box3D) { - return { - minCorner: Vec3.xy(box.minCorner), - maxCorner: Vec3.xy(box.maxCorner), - }; -} -// todo move me to vis-geometry -export function visitBFSMaybe( - tree: Tree, - children: (t: Tree) => ReadonlyArray, - visitor: (tree: Tree) => boolean, -): void { - const frontier: Tree[] = [tree]; - while (frontier.length > 0) { - const cur = frontier.shift(); - if (cur === undefined) { - // TODO: Consider logging a warning or error here, as this should never happen, - // but this package doesn't depend on the package where our logger lives - continue; - } - if (visitor(cur)) { - for (const c of children(cur)) { - frontier.push(c); - } - } - - } -} - -// figure out that path through the tree, given a TreeNode name -// these names are structured data - so it should always be possible -function getVisibleItemsInTree(dataset: { root: TreeNode, boundingBox: volumeBound }, camera: { view: box2D, screenResolution: vec2 }, limit: number) { - const { root, boundingBox } = dataset - const hits: { node: TreeNode, bounds: box2D }[] = [] - const rootBounds = Box3D.create([boundingBox.lx, boundingBox.ly, boundingBox.lz], [boundingBox.ux, boundingBox.uy, boundingBox.uz]); - visitBFSMaybe(root, children, (t) => { - const B = dropZ(bounds(rootBounds, sanitizeName(t.file))) - if (Box2D.intersection(B, camera.view) && Box2D.size(B)[0] > limit) { - // this node is big enough to render - that means we should check its children as well - hits.push({ node: t, bounds: B }) - return true; - } - return false; - }) - return hits; -} - -function getVisibleItems(dataset: SlideviewScatterbrainDataset | ScatterbrainDataset, camera: { view: box2D, screenResolution: vec2, layout?: Record }) { - if (dataset.type === 'normal') { - return getVisibleItemsInTree(dataset.metadata, camera, 5); - } - // by default, if we pass NO layout info - const size: vec2 = [dataset.metadata.spatialUnit.maxX - dataset.metadata.spatialUnit.minX, dataset.metadata.spatialUnit.maxY - dataset.metadata.spatialUnit.minY] - // then it means we want to draw all the slides on top of each other - const defaultVisibility = camera.layout === undefined - const hits: Item[] = [] - for (const slide of dataset.metadata.slides) { - if (!defaultVisibility && camera.layout?.[slide.featureTypeValueReferenceId] === undefined) { - // the camera has a layout, but this slide isn't in it - dont draw it - continue; - } - const grid = camera.layout?.[slide.featureTypeValueReferenceId] ?? [0, 0] - - const offset = Vec2.mul(grid, size) - // offset the camera by the opposite of the offset - hits.push(...getVisibleItemsInTree(slide.tree, { ...camera, view: Box2D.translate(camera.view, offset) }, 5)) - } - return hits; - -} \ No newline at end of file diff --git a/packages/scatterbrain/src/shader.ts b/packages/scatterbrain/src/shader.ts deleted file mode 100644 index 4ef0c865..00000000 --- a/packages/scatterbrain/src/shader.ts +++ /dev/null @@ -1,158 +0,0 @@ - -import { match } from 'ts-pattern' -import { vec4, type Interval } from '@alleninstitute/vis-geometry' -type ScatterplotShaderConfig = { - categoricalFilters: readonly string[][] // the set of column names that have active filters in them. these will be mapped to vertex attributes COLUMN_${index} - // our original approach used the column names as vertex attrib names in-shader code. in addition to requiring sanitization - // this was not any less confusing than generic "COLUMN_X" naming, as the names are ref-ids anyway. - - internalNames: { - lookup: 'lookupTable', - tableSize: 'tableSize', - } -} - - -// this shader... has to support a lot of features! -/* -1. filter by N columns -2. color by a column-lookup, or by a gradient, overridden by filtering status -3. hover - which is powered by the filter-data (we pack extra info into the alpha channel...) -4. exfiltrate data like cell id and expression values via color-out -5. configurable Z-axis settings (high-expression cells in front, filtered out in back, etc) -6. size-change on hover - -*/ -const attrib = (i: number) => `COLUMN_${i.toFixed(0)}` -export function generateShader(config: ScatterplotShaderConfig) { - - - const { categoricalFilters, internalNames } = config; - const { lookup, tableSize } = internalNames - // we do still want to generate shaders - there are going to be a bunch of shaders that are only slightly different from each other - // and so a templated approach can be good - const categoryOrder = categoricalFilters.flat().reduce((acc, cur, i) => ({ ...acc, [cur]: i }), {} as Record) - const readColumnSnippet = (i: number) =>/*glsl*/`texture2D(${lookup},vec2(${i.toFixed(0)}.5/${tableSize}.x , (${attrib(i)}+0.5)/${tableSize}.y))` - const readColumnFilterStatus = (i: number) => `step(0.5,${readColumnSnippet(i)}.a)` - // the snippet that powers filtering - a cell is filtered in if and only if lookup-table[COLUMN_X] has a non-zero alpha value for every "X" - const filteredInSnippet = categoricalFilters.map((OR) => - `(${OR.map((category) => readColumnFilterStatus(categoryOrder[category])).join('+')})`).join('*') - - // returns a string that performs the filtering logic - - // in pseudocode, and example might be (lookup(COL_0))*(lookup(COL_1)+lookup(COL_2)) - - // this computes the CNF-style filter COL_0 AND (COL_1 OR COl_2) - // note that the returned string will be filled with inline GLSL and will look way less legible than the above comment, as it must deal with texture sizes, offsets, etc... - // return filteredInSnippet - - // a dot can be hovered - // it has a radius, depth, and a color, and is either filtered in or out - // we're going to define glsl functions that define all these aspects - // note that they're not all completely independant - for example we'd like to move filtered-out cells to the back - // in cases like that, we would call getIsFilteredIn() from within getDepth() - - - -} - -// of course - the most flexible thing would be to let user's of this system write whatever shader they like -// thats nice, but I think lets try a more "configure based on a few options" path for now - -type DepthMode = { mode: 'quantitative', column: number, gamut: Interval, reverse?: boolean } | { mode: 'constant', value: number } -type CellDepthConfig = { - hovered: DepthMode, - filteredOut: DepthMode, - normal: DepthMode, -} -function getDepth(config: CellDepthConfig) { - const depthValueSnippet = (mode: DepthMode) => - match(mode) - .with({ mode: 'constant' }, (c) => `${c.value}`) - .otherwise(({ column, gamut, reverse }) => /*glsl*/`clamp(-1.0,1.0,${reverse ? '-' : ''}(${attrib(column)}-${gamut.min})/${gamut.max - gamut.min})`) - - const { hovered, filteredOut, normal } = config; - return /*glsl*/`float getDepth(){ - return mix( - ${depthValueSnippet(filteredOut)}, - mix(${depthValueSnippet(normal)},${depthValueSnippet(hovered)},isHovered()), - getIsFilteredIn()); - }` -} -type CellFilterConfig = { - lookup: string; - tableSize: string; - CNFColumns: number[][] // CNF = Clausal normal form, ANDs of ORs, like so: [[3,4],[1,0,2]] reads (3 OR 4) AND (1 OR 0 OR 2). the numbers are the column indexes. -} -// TODO: we have at least 3 different types of filtering -// categorical CNF-style filtering, spatial filtering (the selection box) -// and range-filtering, supporting the interesection of multiple quantitative columns at once -// - -function getIsFilteredIn(config: CellFilterConfig) { - const { lookup, tableSize, CNFColumns } = config; - - // TODO this does not handle Quantitative filters at all! - - const readColumnSnippet = (i: number) =>/*glsl*/`texture2D(${lookup},vec2(${i.toFixed(0)}.5/${tableSize}.x , (${attrib(i)}+0.5)/${tableSize}.y))` - const readColumnFilterStatus = (i: number) => `step(0.1,${readColumnSnippet(i)}.a)` - // the snippet that powers filtering - a cell is filtered in if and only if lookup-table[COLUMN_X] has a non-zero alpha value for every "X" - const filteredInSnippet = CNFColumns.map((OR) => - `(${OR.map((category) => readColumnFilterStatus(category)).join('+')})`).join('*') - - return /*glsl*/`float getIsFilteredIn(){ - return ${filteredInSnippet}; - }` -} -type CellHoverConfig = { - hoverColumn: number; - lookup: string; - tableSize: string; -} -function getIsHovered(config: CellHoverConfig) { - const { hoverColumn, lookup, tableSize } = config; - const readColumnSnippet = (i: number) =>/*glsl*/`texture2D(${lookup},vec2(${i.toFixed(0)}.5/${tableSize}.x , (${attrib(i)}+0.5)/${tableSize}.y))` - // note the 0.5 here - the alpha value > 0.1 implies filtered in (in this category at least) - alpha > 0.5 implies filtered in and hovered - const readColumnFilterStatus = (i: number) => `step(0.5,${readColumnSnippet(i)}.a)` - return /*glsl*/`float getIsHovered(){ - return ${readColumnFilterStatus(hoverColumn)}; - }` -} -type CellRadiusConfig = { - -} -function getRadius(config: CellRadiusConfig) { - return /*glsl*/`float getRadius(){ - return 2.0; // TODO - }` -} - -type ColorMode = { mode: 'categorical', column: number } | { mode: 'constant', value: vec4 } | { mode: 'quantitative', column: number, gamut: string } -// todo: 2 color modes to render ids - either the quantitative value or the color-by category value of the cell - -type CellColorConfig = { - gradient: string; - lookup: string; - tableSize: string; - hovered: ColorMode, - filteredOut: ColorMode, - normal: ColorMode, -} -function getColor(config: CellColorConfig) { - // TODO: handle missing values, which are sometimes encoded as NaN, and sometimes just 0.0 - const { gradient, hovered, filteredOut, normal, tableSize, lookup } = config; - const readColumnSnippet = (i: number) =>/*glsl*/`texture2D(${lookup},vec2(${i.toFixed(0)}.5/${tableSize}.x , (${attrib(i)}+0.5)/${tableSize}.y))` - const readGradientSnippet = (col: number, gamut: string) =>/*glsl*/`texture2D(${gradient}, vec2((${attrib(col)}-${gamut}.x)/(${gamut}.y-${gamut}.x), 0.5))` - - const snippet = (mode: ColorMode) => - match(mode) - .with({ mode: 'categorical' }, (cat) =>/*glsl*/`vec4(${readColumnSnippet(cat.column)}.rgb,1.0)`) - .with({ mode: 'constant' }, (flat) =>/*glsl*/`vec4(${flat.value.map(v => v.toFixed(4)).join(',')})`) - .otherwise(({ column, gamut }) =>/*glsl*/`vec4(${readGradientSnippet(column, gamut)})`) - - return /*glsl*/`vec4 getColor(){ - return mix( - ${snippet(filteredOut)}, - mix(${snippet(normal)}, ${snippet(hovered)}, isHovered()), - getIsFilteredIn()); - }` -} \ No newline at end of file diff --git a/packages/scatterbrain/src/types.ts b/packages/scatterbrain/src/types.ts deleted file mode 100644 index 8bdeb781..00000000 --- a/packages/scatterbrain/src/types.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { WebGLSafeBasicType } from './typed-array'; -// lets get a renderer up and rolling -// then add features from there... - -/// Types describing the metadata that gets loaded from scatterbrain.json files /// -// there are 2 variants, slideview and regular - they are distinguished at runtime -// by checking the parsed metadata for the 'slides' field - -export type volumeBound = { - lx: number; - ly: number; - lz: number; - ux: number; - uy: number; - uz: number; -}; -type PointAttribute = { - name: string; - size: number; // elements * elementSize - todo ask Peter to remove - elements: number; // values per point (so a vector xy would have 2) - elementSize: number; // size of an element, given in bytes (for example float would have 4) - type: WebGLSafeBasicType; - description: string; -}; -export type TreeNode = { - file: string; - numSpecimens: number; - children: undefined | TreeNode[]; -}; - -type CommonMetadata = { - geneFileEndpoint: string; - metadataFileEndpoint: string; - visualizationReferenceId: string; - spatialColumn: string; - pointAttributes: PointAttribute[]; -} -// scatterbrain distinguishes 2 kinds of datasets - those arranged at the topmost level into slides -// and 'regular' - which is just a simple, 2D point cloud -export type ScatterbrainMetadata = CommonMetadata & { - points: number; - boundingBox: volumeBound; - tightBoundingBox: volumeBound; - root: TreeNode; -}; - -// slideview variant: -type Slide = { - featureTypeValueReferenceId: string; - tree: { - root: TreeNode; - points: number; - boundingBox: volumeBound; - tightBoundingBox: volumeBound; - }; -}; -type SpatialReferenceFrame = { - anatomicalOrigin: string; - direction: string; - unit: string; - minX: number; - maxX: number; - minY: number; - maxY: number; -}; - -export type SlideviewMetadata = CommonMetadata & { - slides: Slide[]; - spatialUnit: SpatialReferenceFrame; -}; - -/// Types related to the rendering of scatterbrain datasets /// - -// a Dataset is the top level entity -// an Item is a chunk of that dataset - esentially a singular, loadable, thing - - -type Settings = {} -type Item = {} -export type SlideviewScatterbrainDataset = { type: 'slideview', metadata: SlideviewMetadata } -export type ScatterbrainDataset = { type: 'normal', metadata: ScatterbrainMetadata } \ No newline at end of file From 09ec6184a1e5c902dcc9fac135f90531b6cdcde8 Mon Sep 17 00:00:00 2001 From: noah Date: Sun, 21 Dec 2025 06:19:09 -0800 Subject: [PATCH 08/29] a list of remaning shader work... --- packages/scatterbrain/demo.ts | 2 +- packages/scatterbrain/src/better/renderer.ts | 26 +++++++++++++++----- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/scatterbrain/demo.ts b/packages/scatterbrain/demo.ts index 53a9212a..75348626 100644 --- a/packages/scatterbrain/demo.ts +++ b/packages/scatterbrain/demo.ts @@ -34,7 +34,7 @@ async function begin() { console.log('yay data!', yay) const render = buildScatterbrainRenderer(regl, cache, canvas) render({ - camera: { view: { minCorner: [0, 0], maxCorner: [10, 10] }, screenResolution: [800, 800] }, + camera: { view: { minCorner: [-17, -17], maxCorner: [26, 26] }, screenResolution: [800, 800] }, categoricalFilters: {}, colorBy: { kind: 'quantitative', column: '27683', gradient: 'viridis', range: { min: 0, max: 10 } }, dataset: yay, diff --git a/packages/scatterbrain/src/better/renderer.ts b/packages/scatterbrain/src/better/renderer.ts index 8da777d7..bd85d50f 100644 --- a/packages/scatterbrain/src/better/renderer.ts +++ b/packages/scatterbrain/src/better/renderer.ts @@ -109,21 +109,24 @@ export function buildScatterbrainRenderer(regl: REGL.Regl, cache: SharedPriority const gradient = regl.texture({ width: 256, height: 1, format: 'rgba', data: gradientData }) let prevSettings: State | undefined; let augment: ((node: NodeWithBounds) => Item) | undefined + let c2s: Record = {} + let configuration: Config | undefined // todo I hate all this fix it const render = (state: State) => { const { camera, dataset } = state; if (!draw || !isEqual(prevSettings, omit(state, 'camera'))) { const { config, columnNameToShaderName } = configureShader(state); + configuration = config; + c2s = columnNameToShaderName augment = columnsForItem(config, columnNameToShaderName, dataset); draw = buildScatterbrainRenderCommand(config, regl); } if (draw !== undefined) { const visible = getVisibleItems(dataset, camera) - // TODO: use the columnNameToShaderName (in reverse) to build - // the set of columns to request (an Item is a request) - // the key will be the shaderName, the value will be a columnRequest that will satisfy that shader attribute. const items: Item[] = map(visible, augment!) client.setPriorities(items, []); - + const filterRanges: Record = reduce(keys(state.quantitativeFilters), + (acc, col) => ({ ...acc, [c2s[col]]: [state.quantitativeFilters[col].min, state.quantitativeFilters[col].max] }), + state.colorBy.kind === 'quantitative' ? { [configuration!.colorByColumn]: [state.colorBy.range.min, state.colorBy.range.max] } : {}) for (const item of items) { if (client.has(item)) { const gpuData = client.get(item) @@ -135,7 +138,7 @@ export function buildScatterbrainRenderer(regl: REGL.Regl, cache: SharedPriority categoricalLookupTable: lookup, gradient, offset: [0, 0], - quantitativeRangeFilters: { COLOR_BY_MEASURE: [0, 10] }, // TODO fill me in - shader names are the keys here + quantitativeRangeFilters: filterRanges, item: { columnData: gpuData, count: item.node.numSpecimens, @@ -151,4 +154,15 @@ export function buildScatterbrainRenderer(regl: REGL.Regl, cache: SharedPriority } return render -} \ No newline at end of file +} + +/* TODO features: +* color by (cat / quant) +* hover -> data out +* highlight color-by value +* categorical filtering +* range filtering +* slide view offsets +* configurable depth settings (quantitative, node-depth, constant) +* filter-out color (constant / transparant) +*/ \ No newline at end of file From cb424a23956530bf822da90b027357bf58f27383 Mon Sep 17 00:00:00 2001 From: noah Date: Sun, 21 Dec 2025 07:00:09 -0800 Subject: [PATCH 09/29] basic filtering exprs. --- packages/scatterbrain/src/better/renderer.ts | 1 + packages/scatterbrain/src/better/shader.ts | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/scatterbrain/src/better/renderer.ts b/packages/scatterbrain/src/better/renderer.ts index bd85d50f..3738ed20 100644 --- a/packages/scatterbrain/src/better/renderer.ts +++ b/packages/scatterbrain/src/better/renderer.ts @@ -160,6 +160,7 @@ export function buildScatterbrainRenderer(regl: REGL.Regl, cache: SharedPriority * color by (cat / quant) * hover -> data out * highlight color-by value +* NaN / Null value handling * categorical filtering * range filtering * slide view offsets diff --git a/packages/scatterbrain/src/better/shader.ts b/packages/scatterbrain/src/better/shader.ts index c7803b93..6902da4f 100644 --- a/packages/scatterbrain/src/better/shader.ts +++ b/packages/scatterbrain/src/better/shader.ts @@ -167,9 +167,22 @@ export function buildScatterbrainRenderCommand(config: Config, regl: REGL.Regl) } } +function rangeFilterExpression(qColumns: readonly string[]) { + return qColumns.map(attrib =>/*glsl*/`within(${attrib},${rangeFor(attrib)})`).join(' * ') +} +function categoricalFilterExpression(cColumns: readonly string[], tableSize: vec2, tableName: string) { + // categorical columns are in order - this array will have the same order as the col in the texture + const [w, h] = tableSize; + return cColumns.map((attr, i) => + /*glsl*/`texture2D(${tableName},vec2(${i.toFixed(0)}.5,${attr}+0.5)/vec2(${w.toFixed(1)},${h.toFixed(1)})).a`) + .join(' * ') +} + export function generate(config: Config): ScatterbrainShaderUtils { const { mode, quantitativeColumns, categoricalColumns, categoricalTable, gradientTable, positionColumn, colorByColumn } = config; + const catFilter = categoricalFilterExpression(categoricalColumns, [10, 10], categoricalTable) + const rangeFilter = rangeFilterExpression(quantitativeColumns) const uniforms = /*glsl*/` uniform vec4 view; uniform vec2 screenSize; @@ -195,10 +208,13 @@ export function generate(config: Config): ScatterbrainShaderUtils { float rangeParameter(float v, vec2 range){ return (v-range.x)/(range.y-range.x); } + float within(float v, vec2 range){ + return step(range.x,v)*step(v,range.y); + } ` const isHovered = /*glsl*/`return 0.0;` // todo hovering - const isFilteredIn = /*glsl*/`return 1.0;` // todo filtering! + const isFilteredIn = /*glsl*/`return ${catFilter.length > 0 ? catFilter : '1.0'}*${rangeFilter.length > 0 ? rangeFilter : '1.0'};` const getDataPosition = /*glsl*/`return vec3(${positionColumn}+offset,0.0);` const getClipPosition = /*glsl*/`return applyCamera(getDataPosition());` From 02711fc89a9085bff63470495a9f87f72a830dcf Mon Sep 17 00:00:00 2001 From: noah Date: Tue, 6 Jan 2026 13:45:59 -0800 Subject: [PATCH 10/29] cleanup some of the simpler features of the shader --- packages/scatterbrain/demo.ts | 1 + packages/scatterbrain/src/better/renderer.ts | 16 ++++-- packages/scatterbrain/src/better/shader.ts | 58 ++++++++++++++------ 3 files changed, 53 insertions(+), 22 deletions(-) diff --git a/packages/scatterbrain/demo.ts b/packages/scatterbrain/demo.ts index 75348626..e3c21f08 100644 --- a/packages/scatterbrain/demo.ts +++ b/packages/scatterbrain/demo.ts @@ -39,6 +39,7 @@ async function begin() { colorBy: { kind: 'quantitative', column: '27683', gradient: 'viridis', range: { min: 0, max: 10 } }, dataset: yay, quantitativeFilters: {}, + filterBox: { minCorner: [-17, -17], maxCorner: [0, 0] } }) } diff --git a/packages/scatterbrain/src/better/renderer.ts b/packages/scatterbrain/src/better/renderer.ts index 3738ed20..801bba41 100644 --- a/packages/scatterbrain/src/better/renderer.ts +++ b/packages/scatterbrain/src/better/renderer.ts @@ -62,7 +62,8 @@ export function buildScatterbrainCacheClient(regl: REGL.Regl, cache: SharedPrior // } type State = ShaderSettings & { - camera: { view: box2D, screenResolution: vec2 } + camera: { view: box2D, screenResolution: vec2 }, + filterBox: box2D, } // function buildRenderCommand(state: State, regl: REGL.Regl) { @@ -112,7 +113,7 @@ export function buildScatterbrainRenderer(regl: REGL.Regl, cache: SharedPriority let c2s: Record = {} let configuration: Config | undefined // todo I hate all this fix it const render = (state: State) => { - const { camera, dataset } = state; + const { camera, dataset, filterBox } = state; if (!draw || !isEqual(prevSettings, omit(state, 'camera'))) { const { config, columnNameToShaderName } = configureShader(state); configuration = config; @@ -137,7 +138,9 @@ export function buildScatterbrainRenderer(regl: REGL.Regl, cache: SharedPriority camera, categoricalLookupTable: lookup, gradient, + spatialFilterBox: filterBox, offset: [0, 0], + filteredOutColor: [.3, .3, .3, 1], quantitativeRangeFilters: filterRanges, item: { columnData: gpuData, @@ -157,13 +160,14 @@ export function buildScatterbrainRenderer(regl: REGL.Regl, cache: SharedPriority } /* TODO features: -* color by (cat / quant) +[x] color by (cat / quant) * hover -> data out * highlight color-by value * NaN / Null value handling -* categorical filtering -* range filtering +[x] categorical filtering +[x] range filtering // should work... test it though +[x] spatial-box filtering * slide view offsets * configurable depth settings (quantitative, node-depth, constant) -* filter-out color (constant / transparant) +[x] filtered-out color (constant / transparant) */ \ No newline at end of file diff --git a/packages/scatterbrain/src/better/shader.ts b/packages/scatterbrain/src/better/shader.ts index 6902da4f..73bf7562 100644 --- a/packages/scatterbrain/src/better/shader.ts +++ b/packages/scatterbrain/src/better/shader.ts @@ -5,7 +5,7 @@ import REGL from "regl"; import type { ScatterbrainDataset, SlideviewScatterbrainDataset } from "./types"; import { filter, keys, mapValues, reduce } from "lodash"; -import { Box2D, type box2D, type Interval, type vec2 } from "@alleninstitute/vis-geometry"; +import { Box2D, type vec4, type box2D, type Interval, type vec2 } from "@alleninstitute/vis-geometry"; import { type Cacheable, type CachedVertexBuffer } from "@alleninstitute/vis-core"; // the set of columns and what to do with them can vary @@ -65,15 +65,16 @@ function buildVertexShader(utils: ScatterbrainShaderUtils) { ${utils.commonUtilsGLSL} // per-point interface functions // - float isFilteredIn(){ - ${utils.isFilteredIn} - } + float isHovered(){ ${utils.isHovered} } vec3 getDataPosition(){ ${utils.getDataPosition} } + float isFilteredIn(){ + ${utils.isFilteredIn} + } // the primary per-point functions, called directly // vec4 getClipPosition(){ ${utils.getClipPosition} @@ -112,6 +113,7 @@ export type Config = { quantitativeColumns: string[]; categoricalColumns: string[]; categoricalTable: string; + tableSize: vec2; gradientTable: string; positionColumn: string; colorByColumn: string; @@ -129,6 +131,8 @@ export function buildScatterbrainRenderCommand(config: Config, regl: REGL.Regl) [categoricalTable]: prop('categoricalLookupTable'), [gradientTable]: prop('gradient'), ...ranges, + spatialFilterBox: prop('spatialFilterBox'), + filteredOutColor: prop('filteredOutColor'), view: prop('view'), screenSize: prop('screenSize'), offset: prop('offset'), @@ -152,18 +156,20 @@ export function buildScatterbrainRenderCommand(config: Config, regl: REGL.Regl) gradient: REGL.Texture2D, camera: { view: box2D, screenResolution: vec2 }, offset: vec2, + filteredOutColor: vec4, + spatialFilterBox: box2D, quantitativeRangeFilters: Record, item: { count: number, columnData: Record } }) => { - const { target, gradient, camera, offset, quantitativeRangeFilters, categoricalLookupTable, item } = props + const { target, spatialFilterBox, filteredOutColor, gradient, camera, offset, quantitativeRangeFilters, categoricalLookupTable, item } = props const filterRanges = reduce(keys(quantitativeRangeFilters), (acc, cur) => ({ ...acc, [rangeFor(cur)]: quantitativeRangeFilters[cur] }), {}) const { view, screenResolution } = camera const { count, columnData } = item; const rawBuffers = mapValues(columnData, (vbo) => vbo.buffer.buffer) - cmd({ target, gradient, categoricalLookupTable, offset, count, view: Box2D.toFlatArray(view), screenSize: screenResolution, ...filterRanges, ...rawBuffers }) + cmd({ target, gradient, filteredOutColor, spatialFilterBox: Box2D.toFlatArray(spatialFilterBox), categoricalLookupTable, offset, count, view: Box2D.toFlatArray(view), screenSize: screenResolution, ...filterRanges, ...rawBuffers }) } } @@ -173,20 +179,23 @@ function rangeFilterExpression(qColumns: readonly string[]) { function categoricalFilterExpression(cColumns: readonly string[], tableSize: vec2, tableName: string) { // categorical columns are in order - this array will have the same order as the col in the texture const [w, h] = tableSize; - return cColumns.map((attr, i) => - /*glsl*/`texture2D(${tableName},vec2(${i.toFixed(0)}.5,${attr}+0.5)/vec2(${w.toFixed(1)},${h.toFixed(1)})).a`) + return cColumns.map((attrib, i) => + /*glsl*/`texture2D(${tableName},vec2(${i.toFixed(0)}.5,${attrib}+0.5)/vec2(${w.toFixed(1)},${h.toFixed(1)})).a`) .join(' * ') } export function generate(config: Config): ScatterbrainShaderUtils { - const { mode, quantitativeColumns, categoricalColumns, categoricalTable, gradientTable, positionColumn, colorByColumn } = config; + const { mode, quantitativeColumns, categoricalColumns, categoricalTable, tableSize, gradientTable, positionColumn, colorByColumn } = config; - const catFilter = categoricalFilterExpression(categoricalColumns, [10, 10], categoricalTable) + const catFilter = categoricalFilterExpression(categoricalColumns, tableSize, categoricalTable) const rangeFilter = rangeFilterExpression(quantitativeColumns) const uniforms = /*glsl*/` uniform vec4 view; uniform vec2 screenSize; uniform vec2 offset; + uniform vec4 spatialFilterBox; + uniform vec4 filteredOutColor; + uniform sampler2D ${gradientTable}; uniform sampler2D ${categoricalTable}; // quantitative columns each need a range value - its the min,max in a vec2 @@ -214,16 +223,31 @@ export function generate(config: Config): ScatterbrainShaderUtils { ` const isHovered = /*glsl*/`return 0.0;` // todo hovering - const isFilteredIn = /*glsl*/`return ${catFilter.length > 0 ? catFilter : '1.0'}*${rangeFilter.length > 0 ? rangeFilter : '1.0'};` + const isFilteredIn = /*glsl*/` + vec3 p = getDataPosition(); + return within(p.x,spatialFilterBox.xz)*within(p.y,spatialFilterBox.yw) + * ${catFilter.length > 0 ? catFilter : '1.0'} + * ${rangeFilter.length > 0 ? rangeFilter : '1.0'}; + ` const getDataPosition = /*glsl*/`return vec3(${positionColumn}+offset,0.0);` const getClipPosition = /*glsl*/`return applyCamera(getDataPosition());` const getPointSize = /*glsl*/`return 2.0;` // todo! - const getColor = /*glsl*/` - float p = rangeParameter(${colorByColumn},${rangeFor(colorByColumn)}); - return texture2D(${gradientTable},vec2(p,0.5)); - ` // for now, lets assume this is a color-by-quantitative shader... + // todo - use config options! + // if the colorByColumn is a categorical column, generate that + // else, use a range-colorby + const categoryColumnIndex = categoricalColumns.indexOf(colorByColumn); + const [w, h] = tableSize; + const colorByCategorical = /*glsl*/` + texture2D(${categoricalTable},vec2(${categoryColumnIndex.toFixed(0)}.5,${colorByColumn}+0.5)/vec2(${w.toFixed(1)},${h.toFixed(1)})).rgb` + const colorByQuantitative = /*glsl*/` + texture2D(${gradientTable},vec2(rangeParameter(${colorByColumn},${rangeFor(colorByColumn)}),0.5)) + ` + const colorize = categoryColumnIndex != -1 ? colorByCategorical : colorByQuantitative + const getColor = /*glsl*/` + return mix(filteredOutColor,${colorize},isFilteredIn()); + ` return { attributes, @@ -257,7 +281,8 @@ export function configureShader(settings: ShaderSettings): { config: Config, col const { dataset, categoricalFilters, quantitativeFilters, colorBy } = settings; // figure out the columns we care about // assign them names that are safe to use in the shader (A,B,C, whatever) - + const numCategories = keys(categoricalFilters).length; + const longest = reduce(keys(categoricalFilters), (highest, cur) => Math.max(highest, keys(categoricalFilters[cur]).length), 0) const qAttrs = reduce(keys(quantitativeFilters).toSorted(), (acc, cur, i) => ({ ...acc, [cur]: `MEASURE_${i.toFixed(0)}` }), colorBy.kind === 'metadata' ? {} : { [colorBy.column]: 'COLOR_BY_MEASURE' } as Record); const cAttrs = reduce(keys(categoricalFilters).toSorted(), (acc, cur, i) => ({ ...acc, [cur]: `CATEGORY_${i.toFixed(0)}` }), colorBy.kind === 'metadata' ? { [colorBy.column]: 'COLOR_BY_CATEGORY' } : {} as Record); const colToAttribute = { ...qAttrs, ...cAttrs, [dataset.metadata.spatialColumn]: 'position' }; @@ -270,6 +295,7 @@ export function configureShader(settings: ShaderSettings): { config: Config, col colorByColumn: colToAttribute[colorBy.column], mode: 'color', positionColumn: 'position', + tableSize: [Math.max(numCategories, 1), Math.max(1, longest)] } return { config, columnNameToShaderName: colToAttribute } } \ No newline at end of file From 5bc468ded7abd9a541fa78233298a09422025ceb Mon Sep 17 00:00:00 2001 From: noah Date: Tue, 27 Jan 2026 14:13:35 -0800 Subject: [PATCH 11/29] much cleanup --- packages/scatterbrain/demo.ts | 77 +++++++- packages/scatterbrain/src/better/renderer.ts | 172 ++++++++++-------- .../scatterbrain/src/better/shader.test.ts | 2 + packages/scatterbrain/src/better/shader.ts | 92 ++++++---- 4 files changed, 218 insertions(+), 125 deletions(-) diff --git a/packages/scatterbrain/demo.ts b/packages/scatterbrain/demo.ts index e3c21f08..29f405dc 100644 --- a/packages/scatterbrain/demo.ts +++ b/packages/scatterbrain/demo.ts @@ -1,8 +1,10 @@ import REGL from "regl"; -import { buildScatterbrainRenderer } from "./src/better/renderer"; +import { buildRenderFrameFn, buildScatterbrainCacheClient, buildScatterbrainRenderer, setCategoricalLookupTableValues } from "./src/better/renderer"; import { SharedPriorityCache } from '@alleninstitute/vis-core'; import { type ScatterbrainDataset } from "./src/better/types"; -import { loadDataset } from "./src/better/dataset"; +import { getVisibleItems, loadDataset } from "./src/better/dataset"; +import { ShaderSettings } from "./src/better/shader"; +import { vec4 } from "@alleninstitute/vis-geometry"; const twoGB = 1024 * 1024 * 2000; const tenx = 'https://bkp-2d-visualizations-stage.s3.amazonaws.com/wmb_tenx_01172024_stage-20240128193624/G4I4GFJXJB9ATZ3PTX1/ScatterBrain.json' @@ -31,16 +33,71 @@ async function begin() { }); const dataset = await (await fetch(tenx)).json() const yay: ScatterbrainDataset = loadDataset(dataset); + const camera = { view: { minCorner: [-17, -17], maxCorner: [26, 26] }, screenResolution: [800, 800] } as const console.log('yay data!', yay) - const render = buildScatterbrainRenderer(regl, cache, canvas) - render({ - camera: { view: { minCorner: [-17, -17], maxCorner: [26, 26] }, screenResolution: [800, 800] }, - categoricalFilters: {}, - colorBy: { kind: 'quantitative', column: '27683', gradient: 'viridis', range: { min: 0, max: 10 } }, + // in a real app - the shader settings changing could require a re-build of the renderer + // compare prior settings to current, call build IF they change + + const makeFakeColors = (n: number) => { + const stuff: Record = {} + for (let i = 0; i < n; i++) { + stuff[i] = { + color: [Math.random(), Math.random(), Math.random(), 1], + // 80% of either category are filtered in, at random: + filteredIn: Math.random() > 0.2 + } + } + return stuff; + } + // fake color and filter tables, as a demo: + const categories = { + '4MV7HA5DG2XJZ3UD8G9': makeFakeColors(40), // nt type + 'FS00DXV0T9R1X9FJ4QE': makeFakeColors(40) // class + } + const settings: ShaderSettings = { + categoricalFilters: { '4MV7HA5DG2XJZ3UD8G9': 40, 'FS00DXV0T9R1X9FJ4QE': 40, }, + colorBy: { kind: 'metadata', column: "FS00DXV0T9R1X9FJ4QE" }, + // colorBy: { kind: 'quantitative', column: '27683', gradient: 'viridis', range: { min: 0, max: 10 } }, dataset: yay, - quantitativeFilters: {}, - filterBox: { minCorner: [-17, -17], maxCorner: [0, 0] } - }) + mode: 'color', + quantitativeFilters: [], + } + + const lookup = regl.texture({ width: 10, height: 10, format: 'rgba' }) + const gradientData = new Uint8Array(256 * 4); + for (let i = 0; i < 256; i += 4) { + gradientData[i * 4 + 0] = i; + gradientData[i * 4 + 1] = i; + gradientData[i * 4 + 2] = i; + gradientData[i * 4 + 3] = 255; + } + const gradient = regl.texture({ width: 256, height: 1, format: 'rgba', data: gradientData }) + + // make up random colors for the coloring, and add random filtering + + setCategoricalLookupTableValues(categories, lookup) + + const render = buildRenderFrameFn(regl, settings); + const doRender = () => { + render({ + client, + camera: { view: { minCorner: [-17, -17], maxCorner: [26, 26] }, screenResolution: [800, 800] }, + categoricalLookupTable: lookup, + dataset: yay, + filteredOutColor: [0, 0, 0, 1], + gradient, + hoveredValue: 22, + offset: [0, 0], + quantitativeRangeFilters: {}, + spatialFilterBox: { minCorner: [-17, -17], maxCorner: [30, 30] }, + target: null, + }) + } + const client = buildScatterbrainCacheClient(regl, cache, () => { + requestAnimationFrame(doRender) + }); + // start it off with a single render call + doRender(); } begin(); \ No newline at end of file diff --git a/packages/scatterbrain/src/better/renderer.ts b/packages/scatterbrain/src/better/renderer.ts index 801bba41..b5f356e2 100644 --- a/packages/scatterbrain/src/better/renderer.ts +++ b/packages/scatterbrain/src/better/renderer.ts @@ -1,11 +1,11 @@ import type { Cacheable, CachedVertexBuffer, SharedPriorityCache } from '@alleninstitute/vis-core'; import type REGL from 'regl'; import type { ColumnRequest, ScatterbrainDataset, SlideviewScatterbrainDataset, TreeNode } from './types'; -import type { box2D, Interval, vec2 } from '@alleninstitute/vis-geometry'; +import type { box2D, Interval, vec2, vec4 } from '@alleninstitute/vis-geometry'; import { MakeTaggedBufferView } from '../typed-array'; -import { isEqual, keys, map, omit, reduce } from 'lodash' +import { filter, isEqual, keys, map, omit, reduce } from 'lodash' import { getVisibleItems, type NodeWithBounds } from './dataset'; -import { buildScatterbrainRenderCommand, buildShaders, type Config, configureShader, type ShaderSettings, VBO } from './shader'; +import { buildScatterbrainRenderCommand, type Config, configureShader, type ShaderSettings, VBO } from './shader'; export type Item = Readonly<{ dataset: SlideviewScatterbrainDataset | ScatterbrainDataset node: TreeNode @@ -55,26 +55,12 @@ export function buildScatterbrainCacheClient(regl: REGL.Regl, cache: SharedPrior return client; } -// export function stuff(client: ReturnType, dataset: SlideviewScatterbrainDataset | ScatterbrainDataset, camera: { view: box2D, screenResolution: vec2 }) { -// const visible = getVisibleItems(dataset, camera) -// const items: Item[] = map(visible, (node) => ({ ...node, dataset, columns: { 'position': { type: 'METADATA', name: dataset.metadata.spatialColumn } } })) -// client.setPriorities(items, []); - -// } type State = ShaderSettings & { camera: { view: box2D, screenResolution: vec2 }, filterBox: box2D, } -// function buildRenderCommand(state: State, regl: REGL.Regl) { -// const { dataset } = state; -// const { config, columnNameToShaderName } = configureShader(state); -// const renderer = buildScatterbrainRenderCommand(config, regl) - -// // lets use a fake set of textures for now - -// } function columnsForItem(config: Config, col2shader: Record, dataset: ScatterbrainDataset | SlideviewScatterbrainDataset) { const columns: Record = {} const s2c = reduce(keys(col2shader), (acc, col) => ({ ...acc, [col2shader[col]]: col }), {} as Record) @@ -90,79 +76,107 @@ function columnsForItem(config: Config, col2shader: Record | undefined; - const client = buildScatterbrainCacheClient(regl, cache, () => { - // mega hack for now - if new data shows up, try to just directly invoke the renderer with a stashed copy of the settings... - if (prevSettings) { - render(prevSettings) - } - }); - const lookup = regl.texture({ width: 10, height: 10, format: 'rgba' }) - const gradientData = new Uint8Array(256 * 4); - for (let i = 0; i < 256; i += 4) { - gradientData[i * 4 + 0] = i; - gradientData[i * 4 + 1] = i; - gradientData[i * 4 + 2] = i; - gradientData[i * 4 + 3] = 255; - } - const gradient = regl.texture({ width: 256, height: 1, format: 'rgba', data: gradientData }) - let prevSettings: State | undefined; - let augment: ((node: NodeWithBounds) => Item) | undefined - let c2s: Record = {} - let configuration: Config | undefined // todo I hate all this fix it - const render = (state: State) => { - const { camera, dataset, filterBox } = state; - if (!draw || !isEqual(prevSettings, omit(state, 'camera'))) { - const { config, columnNameToShaderName } = configureShader(state); - configuration = config; - c2s = columnNameToShaderName - augment = columnsForItem(config, columnNameToShaderName, dataset); - draw = buildScatterbrainRenderCommand(config, regl); +function buildHelperThingy(regl: REGL.Regl, state: ShaderSettings) { + const { dataset } = state; + const { config, columnNameToShaderName } = configureShader(state); + const prepareQtCell = columnsForItem(config, columnNameToShaderName, dataset); + const drawQtCell = buildScatterbrainRenderCommand(config, regl); + return { drawQtCell, prepareQtCell }; +} + +/** + * a helper function that MUTATES ALL the values in the given @param texture + * to set them to the color and filter status as given in the categories record + * note that the texture's maping to categories is based on a lexical sorting of the names of the + * categories + * @param categories + * @param regl + * @param texture + */ +export function setCategoricalLookupTableValues(categories: Record>, + texture: REGL.Texture2D +) { + const categoryKeys = keys(categories).toSorted() + const columns = categoryKeys.length; + const rows = reduce(categoryKeys, (highest, category) => Math.max(highest, keys(categories[category]).length), 1); + const data = new Uint8Array(columns * rows * 4); + let rgbf = [0, 0, 0, 0] + const empty = [0, 0, 0, 0] as const; + // write the rgb of the color, and encode the filter boolean into the alpha channel + for (let columnIndex = 0; columnIndex < columns; columnIndex += 1) { + const category = categories[categoryKeys[columnIndex]] + const nRows = keys(category).length; + for (let rowIndex = 0; rowIndex < nRows; rowIndex += 1) { + const color = category[rowIndex]?.color ?? empty + const filtered = category[rowIndex]?.filteredIn ?? false; + rgbf[0] = color[0] * 255 + rgbf[1] = color[1] * 255 + rgbf[2] = color[2] * 255 + rgbf[3] = filtered ? 255 : 0 + data.set(rgbf, (rowIndex * columns * 4) + columnIndex * 4) } - if (draw !== undefined) { - const visible = getVisibleItems(dataset, camera) - const items: Item[] = map(visible, augment!) - client.setPriorities(items, []); - const filterRanges: Record = reduce(keys(state.quantitativeFilters), - (acc, col) => ({ ...acc, [c2s[col]]: [state.quantitativeFilters[col].min, state.quantitativeFilters[col].max] }), - state.colorBy.kind === 'quantitative' ? { [configuration!.colorByColumn]: [state.colorBy.range.min, state.colorBy.range.max] } : {}) - for (const item of items) { - if (client.has(item)) { - const gpuData = client.get(item) - if (gpuData) { - // draw it now - draw({ - target: null, - camera, - categoricalLookupTable: lookup, - gradient, - spatialFilterBox: filterBox, - offset: [0, 0], - filteredOutColor: [.3, .3, .3, 1], - quantitativeRangeFilters: filterRanges, - item: { - columnData: gpuData, - count: item.node.numSpecimens, - }, + } + // calling a texture as a function is REGL shorthand for total re-init of this texture, capable of resizing if needed + // warning - this is not likely to be fast + texture({ data, width: columns, height: rows }); +} +/** + * same as setCategoricalLookupTableValues, except it only writes a single value update to the texture. + * note that the list of categories given must match those used to construct the texture, and are needed here + * due to the lexical sorting order determining the column order of the @param texture + * @param categories + * @param update + * @param regl + * @param texture + */ +export function updateCategoricalValue(categories: readonly string[], + update: { category: string, row: number, color: vec4, filteredIn: boolean }, + texture: REGL.Texture2D +) { + const { category, row, color, filteredIn } = update; + const col = categories.toSorted().indexOf(category) + if (texture.width <= col || texture.height <= row || row < 0 || col < 0) { + // todo - it might be better to let regl throw the same error... think about it + throw new Error(`attempted to update metadata lookup table with invalid coordinates: row=${row},col=${col} is not within ${texture.width}, ${texture.height}`) + } + const data = new Uint8Array(4); + data[0] = color[0] * 255 + data[1] = color[1] * 255 + data[2] = color[2] * 255 + data[3] = filteredIn ? 255 : 0 + texture.subimage(data, col, row) +} - }) - } +type Props = Omit>[0], 'item'> & { dataset: ScatterbrainDataset | SlideviewScatterbrainDataset, client: ReturnType } +export function buildRenderFrameFn(regl: REGL.Regl, state: ShaderSettings) { + const { drawQtCell, prepareQtCell } = buildHelperThingy(regl, state) + return function render(props: Props) { + const { camera, dataset, client } = props + const visibleQtNodes = getVisibleItems(dataset, camera).map(prepareQtCell) + client.setPriorities(visibleQtNodes, []) + for (const node of visibleQtNodes) { + if (client.has(node)) { + const drawable = client.get(node) + if (drawable) { + drawQtCell({ + ...props, + item: { + columnData: drawable, + count: node.node.numSpecimens, + }, + }) } - } } - prevSettings = state } - - return render } /* TODO features: [x] color by (cat / quant) -* hover -> data out -* highlight color-by value +[x] hover (cat / quant) -> data out +[x] highlight color-by value + - highlight overrides filtering * NaN / Null value handling [x] categorical filtering [x] range filtering // should work... test it though diff --git a/packages/scatterbrain/src/better/shader.test.ts b/packages/scatterbrain/src/better/shader.test.ts index 42286c9d..784f4f29 100644 --- a/packages/scatterbrain/src/better/shader.test.ts +++ b/packages/scatterbrain/src/better/shader.test.ts @@ -418,6 +418,7 @@ describe('configure', () => { const { config, columnNameToShaderName } = configureShader({ dataset: tenx, categoricalFilters: {}, + mode: 'color', colorBy: { kind: 'quantitative', column: '123', gradient: 'viridis', range: { min: 0, max: 10 } }, quantitativeFilters: {}, }) @@ -429,6 +430,7 @@ describe('configure', () => { colorByColumn: 'COLOR_BY_MEASURE', gradientTable: 'gradient', mode: 'color', + tableSize: [1, 1], quantitativeColumns: ['COLOR_BY_MEASURE'], positionColumn: 'position', } diff --git a/packages/scatterbrain/src/better/shader.ts b/packages/scatterbrain/src/better/shader.ts index 73bf7562..4b64b911 100644 --- a/packages/scatterbrain/src/better/shader.ts +++ b/packages/scatterbrain/src/better/shader.ts @@ -121,10 +121,24 @@ export type Config = { function rangeFor(col: string) { return `${col}_range`; } - +export type RenderProps = { + target: REGL.Framebuffer2D | null, + categoricalLookupTable: REGL.Texture2D, + gradient: REGL.Texture2D, + camera: { view: box2D, screenResolution: vec2 }, + offset: vec2, + filteredOutColor: vec4, + spatialFilterBox: box2D, + quantitativeRangeFilters: Record, + hoveredValue: number, + item: { + count: number, + columnData: Record + } +} export function buildScatterbrainRenderCommand(config: Config, regl: REGL.Regl) { const prop = (p: string) => regl.prop(p) - const { mode, quantitativeColumns, categoricalColumns, categoricalTable, gradientTable, positionColumn } = config; + const { quantitativeColumns, categoricalColumns, categoricalTable, gradientTable, positionColumn } = config; const ranges = reduce(quantitativeColumns, (unis, col) => ({ ...unis, [rangeFor(col)]: prop(rangeFor(col)) }), {} as Record>); const { vs, fs } = buildShaders(config); const uniforms = { @@ -134,6 +148,7 @@ export function buildScatterbrainRenderCommand(config: Config, regl: REGL.Regl) spatialFilterBox: prop('spatialFilterBox'), filteredOutColor: prop('filteredOutColor'), view: prop('view'), + hoveredValue: prop('hoveredValue'), screenSize: prop('screenSize'), offset: prop('offset'), } @@ -150,26 +165,13 @@ export function buildScatterbrainRenderCommand(config: Config, regl: REGL.Regl) count: prop('count') }); // - return (props: { - target: REGL.Framebuffer2D | null, - categoricalLookupTable: REGL.Texture2D, - gradient: REGL.Texture2D, - camera: { view: box2D, screenResolution: vec2 }, - offset: vec2, - filteredOutColor: vec4, - spatialFilterBox: box2D, - quantitativeRangeFilters: Record, - item: { - count: number, - columnData: Record - } - }) => { - const { target, spatialFilterBox, filteredOutColor, gradient, camera, offset, quantitativeRangeFilters, categoricalLookupTable, item } = props + return (props: RenderProps) => { + const { target, hoveredValue, spatialFilterBox, filteredOutColor, gradient, camera, offset, quantitativeRangeFilters, categoricalLookupTable, item } = props const filterRanges = reduce(keys(quantitativeRangeFilters), (acc, cur) => ({ ...acc, [rangeFor(cur)]: quantitativeRangeFilters[cur] }), {}) const { view, screenResolution } = camera const { count, columnData } = item; const rawBuffers = mapValues(columnData, (vbo) => vbo.buffer.buffer) - cmd({ target, gradient, filteredOutColor, spatialFilterBox: Box2D.toFlatArray(spatialFilterBox), categoricalLookupTable, offset, count, view: Box2D.toFlatArray(view), screenSize: screenResolution, ...filterRanges, ...rawBuffers }) + cmd({ target, gradient, hoveredValue, filteredOutColor, spatialFilterBox: Box2D.toFlatArray(spatialFilterBox), categoricalLookupTable, offset, count, view: Box2D.toFlatArray(view), screenSize: screenResolution, ...filterRanges, ...rawBuffers }) } } @@ -179,15 +181,17 @@ function rangeFilterExpression(qColumns: readonly string[]) { function categoricalFilterExpression(cColumns: readonly string[], tableSize: vec2, tableName: string) { // categorical columns are in order - this array will have the same order as the col in the texture const [w, h] = tableSize; + // return /*glsl*/`step(0.01,texture2D(${tableName},vec2(0.5,${cColumns[0]}+0.5)/vec2(${w.toFixed(1)},${h.toFixed(1)})).a)` return cColumns.map((attrib, i) => - /*glsl*/`texture2D(${tableName},vec2(${i.toFixed(0)}.5,${attrib}+0.5)/vec2(${w.toFixed(1)},${h.toFixed(1)})).a`) + /*glsl*/`step(0.01,texture2D(${tableName},vec2(${i.toFixed(0)}.5,${attrib}+0.5)/vec2(${w.toFixed(1)},${h.toFixed(1)})).a)`) .join(' * ') } export function generate(config: Config): ScatterbrainShaderUtils { const { mode, quantitativeColumns, categoricalColumns, categoricalTable, tableSize, gradientTable, positionColumn, colorByColumn } = config; - + console.log('tableSize: ', tableSize) const catFilter = categoricalFilterExpression(categoricalColumns, tableSize, categoricalTable) + console.log('cat filter: ', catFilter) const rangeFilter = rangeFilterExpression(quantitativeColumns) const uniforms = /*glsl*/` uniform vec4 view; @@ -195,6 +199,7 @@ export function generate(config: Config): ScatterbrainShaderUtils { uniform vec2 offset; uniform vec4 spatialFilterBox; uniform vec4 filteredOutColor; + uniform float hoveredValue; uniform sampler2D ${gradientTable}; uniform sampler2D ${categoricalTable}; @@ -221,8 +226,11 @@ export function generate(config: Config): ScatterbrainShaderUtils { return step(range.x,v)*step(v,range.y); } ` - - const isHovered = /*glsl*/`return 0.0;` // todo hovering + const categoryColumnIndex = categoricalColumns.indexOf(colorByColumn); + const isCategoricalColor = categoryColumnIndex > -1 + const hoverCategoryExpr = /*glsl*/`1.0-step(0.1,abs(${colorByColumn}-hoveredValue))` + const isHovered = /*glsl*/` + return ${isCategoricalColor ? hoverCategoryExpr : '0.0'};` const isFilteredIn = /*glsl*/` vec3 p = getDataPosition(); return within(p.x,spatialFilterBox.xz)*within(p.y,spatialFilterBox.yw) @@ -232,23 +240,32 @@ export function generate(config: Config): ScatterbrainShaderUtils { const getDataPosition = /*glsl*/`return vec3(${positionColumn}+offset,0.0);` const getClipPosition = /*glsl*/`return applyCamera(getDataPosition());` - const getPointSize = /*glsl*/`return 2.0;` // todo! + const getPointSize = /*glsl*/`return mix(2.0,6.0,isHovered());` // todo! // todo - use config options! // if the colorByColumn is a categorical column, generate that // else, use a range-colorby - const categoryColumnIndex = categoricalColumns.indexOf(colorByColumn); const [w, h] = tableSize; const colorByCategorical = /*glsl*/` - texture2D(${categoricalTable},vec2(${categoryColumnIndex.toFixed(0)}.5,${colorByColumn}+0.5)/vec2(${w.toFixed(1)},${h.toFixed(1)})).rgb` + vec4(texture2D(${categoricalTable},vec2(${categoryColumnIndex.toFixed(0)}.5,${colorByColumn}+0.5)/vec2(${w.toFixed(1)},${h.toFixed(1)})).rgb,1.0)` const colorByQuantitative = /*glsl*/` texture2D(${gradientTable},vec2(rangeParameter(${colorByColumn},${rangeFor(colorByColumn)}),0.5)) ` const colorize = categoryColumnIndex != -1 ? colorByCategorical : colorByQuantitative - const getColor = /*glsl*/` - return mix(filteredOutColor,${colorize},isFilteredIn()); - ` + const colorByCategoricalId = /*glsl*/` + float G = mod(${colorByColumn},256.0); + float R = mod(${colorByColumn}/256.0,256.0); + return vec4(R/255.0,G/255.0,0,1); + ` + const colorByQuantitativeValue = /*glsl*/` + return vec4(0,rangeParameter(${colorByColumn},${rangeFor(colorByColumn)}),0,1); + ` + const getColor = mode === 'color' ? /*glsl*/` + return mix(filteredOutColor,${colorize},isFilteredIn()); + ` : + (categoryColumnIndex === -1 ? colorByQuantitativeValue : colorByCategoricalId) + console.log(getColor) return { attributes, uniforms, @@ -267,8 +284,9 @@ export function generate(config: Config): ScatterbrainShaderUtils { // that means changing them may require re-building the renderer (and the shader beneath it) export type ShaderSettings = { dataset: ScatterbrainDataset | SlideviewScatterbrainDataset - categoricalFilters: Record> // category-->{value : filteredIn} - quantitativeFilters: Record + categoricalFilters: Record // category name -> maximum # of distinct values in that category + quantitativeFilters: readonly string[] // the names of quantitative variables + mode: 'color' | 'info', colorBy: { kind: 'metadata', column: string } | { kind: 'quantitative', column: string, gradient: 'viridis' | 'inferno', range: Interval } } @@ -278,13 +296,15 @@ export function configureShader(settings: ShaderSettings): { config: Config, col // given settings that make sense to a caller (stuff about the data we want to visualize) // produce an object that can be used to set up some internal config of the shader that would // do the visualization - const { dataset, categoricalFilters, quantitativeFilters, colorBy } = settings; + const { dataset, categoricalFilters, quantitativeFilters, colorBy, mode } = settings; + console.log('cat filters...', categoricalFilters) // figure out the columns we care about // assign them names that are safe to use in the shader (A,B,C, whatever) - const numCategories = keys(categoricalFilters).length; - const longest = reduce(keys(categoricalFilters), (highest, cur) => Math.max(highest, keys(categoricalFilters[cur]).length), 0) - const qAttrs = reduce(keys(quantitativeFilters).toSorted(), (acc, cur, i) => ({ ...acc, [cur]: `MEASURE_${i.toFixed(0)}` }), colorBy.kind === 'metadata' ? {} : { [colorBy.column]: 'COLOR_BY_MEASURE' } as Record); - const cAttrs = reduce(keys(categoricalFilters).toSorted(), (acc, cur, i) => ({ ...acc, [cur]: `CATEGORY_${i.toFixed(0)}` }), colorBy.kind === 'metadata' ? { [colorBy.column]: 'COLOR_BY_CATEGORY' } : {} as Record); + const categories = keys(categoricalFilters).toSorted() + const numCategories = categories.length; + const longest = reduce(keys(categoricalFilters), (highest, cur) => Math.max(highest, categoricalFilters[cur]), 0) + const qAttrs = reduce(quantitativeFilters.toSorted(), (acc, cur, i) => ({ ...acc, [cur]: `MEASURE_${i.toFixed(0)}` }), colorBy.kind === 'metadata' ? {} : { [colorBy.column]: 'COLOR_BY_MEASURE' } as Record); + const cAttrs = reduce(categories, (acc, cur, i) => ({ ...acc, [cur]: `CATEGORY_${i.toFixed(0)}` }), colorBy.kind === 'metadata' ? { [colorBy.column]: 'COLOR_BY_CATEGORY' } : {} as Record); const colToAttribute = { ...qAttrs, ...cAttrs, [dataset.metadata.spatialColumn]: 'position' }; const config: Config = { @@ -293,7 +313,7 @@ export function configureShader(settings: ShaderSettings): { config: Config, col categoricalTable: 'lookup', gradientTable: 'gradient', colorByColumn: colToAttribute[colorBy.column], - mode: 'color', + mode, positionColumn: 'position', tableSize: [Math.max(numCategories, 1), Math.max(1, longest)] } From 127da489a0d1e4c6945409c7bd1ebe5d91948737 Mon Sep 17 00:00:00 2001 From: noah Date: Tue, 27 Jan 2026 14:17:48 -0800 Subject: [PATCH 12/29] commentary --- packages/scatterbrain/demo.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/scatterbrain/demo.ts b/packages/scatterbrain/demo.ts index 29f405dc..404e1b37 100644 --- a/packages/scatterbrain/demo.ts +++ b/packages/scatterbrain/demo.ts @@ -57,6 +57,7 @@ async function begin() { const settings: ShaderSettings = { categoricalFilters: { '4MV7HA5DG2XJZ3UD8G9': 40, 'FS00DXV0T9R1X9FJ4QE': 40, }, colorBy: { kind: 'metadata', column: "FS00DXV0T9R1X9FJ4QE" }, + // an alternative color-by setting, swap it to see quantitative coloring // colorBy: { kind: 'quantitative', column: '27683', gradient: 'viridis', range: { min: 0, max: 10 } }, dataset: yay, mode: 'color', From 66e47d69d382fedc5961cb573ff644e846ca6caa Mon Sep 17 00:00:00 2001 From: noah Date: Tue, 27 Jan 2026 14:18:28 -0800 Subject: [PATCH 13/29] move files out of silly folder name --- packages/scatterbrain/demo.ts | 8 ++++---- packages/scatterbrain/src/{better => }/dataset.ts | 2 +- packages/scatterbrain/src/{better => }/renderer.ts | 2 +- packages/scatterbrain/src/{better => }/shader.test.ts | 0 packages/scatterbrain/src/{better => }/shader.ts | 0 packages/scatterbrain/src/{better => }/types.ts | 0 6 files changed, 6 insertions(+), 6 deletions(-) rename packages/scatterbrain/src/{better => }/dataset.ts (99%) rename packages/scatterbrain/src/{better => }/renderer.ts (99%) rename packages/scatterbrain/src/{better => }/shader.test.ts (100%) rename packages/scatterbrain/src/{better => }/shader.ts (100%) rename packages/scatterbrain/src/{better => }/types.ts (100%) diff --git a/packages/scatterbrain/demo.ts b/packages/scatterbrain/demo.ts index 404e1b37..c1933d1c 100644 --- a/packages/scatterbrain/demo.ts +++ b/packages/scatterbrain/demo.ts @@ -1,9 +1,9 @@ import REGL from "regl"; -import { buildRenderFrameFn, buildScatterbrainCacheClient, buildScatterbrainRenderer, setCategoricalLookupTableValues } from "./src/better/renderer"; +import { buildRenderFrameFn, buildScatterbrainCacheClient, buildScatterbrainRenderer, setCategoricalLookupTableValues } from "./src/renderer"; import { SharedPriorityCache } from '@alleninstitute/vis-core'; -import { type ScatterbrainDataset } from "./src/better/types"; -import { getVisibleItems, loadDataset } from "./src/better/dataset"; -import { ShaderSettings } from "./src/better/shader"; +import { type ScatterbrainDataset } from "./src/types"; +import { getVisibleItems, loadDataset } from "./src/dataset"; +import { ShaderSettings } from "./src/shader"; import { vec4 } from "@alleninstitute/vis-geometry"; const twoGB = 1024 * 1024 * 2000; diff --git a/packages/scatterbrain/src/better/dataset.ts b/packages/scatterbrain/src/dataset.ts similarity index 99% rename from packages/scatterbrain/src/better/dataset.ts rename to packages/scatterbrain/src/dataset.ts index 1a948f19..4fd79219 100644 --- a/packages/scatterbrain/src/better/dataset.ts +++ b/packages/scatterbrain/src/dataset.ts @@ -1,5 +1,5 @@ import { Box2D, type box2D, type box3D, Box3D, Vec2, type vec2, type vec3, Vec3 } from "@alleninstitute/vis-geometry"; -import type { ScatterbrainDataset, SlideviewScatterbrainDataset, TreeNode, volumeBound } from "./types"; +import type { ScatterbrainDataset, SlideviewScatterbrainDataset, TreeNode, volumeBound } from "./better/types"; import { reduce } from "lodash"; type Dataset = ScatterbrainDataset | SlideviewScatterbrainDataset diff --git a/packages/scatterbrain/src/better/renderer.ts b/packages/scatterbrain/src/renderer.ts similarity index 99% rename from packages/scatterbrain/src/better/renderer.ts rename to packages/scatterbrain/src/renderer.ts index b5f356e2..1dd62292 100644 --- a/packages/scatterbrain/src/better/renderer.ts +++ b/packages/scatterbrain/src/renderer.ts @@ -2,7 +2,7 @@ import type { Cacheable, CachedVertexBuffer, SharedPriorityCache } from '@alleni import type REGL from 'regl'; import type { ColumnRequest, ScatterbrainDataset, SlideviewScatterbrainDataset, TreeNode } from './types'; import type { box2D, Interval, vec2, vec4 } from '@alleninstitute/vis-geometry'; -import { MakeTaggedBufferView } from '../typed-array'; +import { MakeTaggedBufferView } from './typed-array'; import { filter, isEqual, keys, map, omit, reduce } from 'lodash' import { getVisibleItems, type NodeWithBounds } from './dataset'; import { buildScatterbrainRenderCommand, type Config, configureShader, type ShaderSettings, VBO } from './shader'; diff --git a/packages/scatterbrain/src/better/shader.test.ts b/packages/scatterbrain/src/shader.test.ts similarity index 100% rename from packages/scatterbrain/src/better/shader.test.ts rename to packages/scatterbrain/src/shader.test.ts diff --git a/packages/scatterbrain/src/better/shader.ts b/packages/scatterbrain/src/shader.ts similarity index 100% rename from packages/scatterbrain/src/better/shader.ts rename to packages/scatterbrain/src/shader.ts diff --git a/packages/scatterbrain/src/better/types.ts b/packages/scatterbrain/src/types.ts similarity index 100% rename from packages/scatterbrain/src/better/types.ts rename to packages/scatterbrain/src/types.ts From ddd09609f811516a9b57e5861d06be5602c035c6 Mon Sep 17 00:00:00 2001 From: noah Date: Tue, 27 Jan 2026 14:26:39 -0800 Subject: [PATCH 14/29] export needed utils to build an example in the examples area --- packages/geometry/src/index.ts | 2 +- packages/scatterbrain/demo.ts | 4 ++-- packages/scatterbrain/src/dataset.ts | 29 ++++------------------------ packages/scatterbrain/src/index.ts | 3 +++ 4 files changed, 10 insertions(+), 28 deletions(-) diff --git a/packages/geometry/src/index.ts b/packages/geometry/src/index.ts index 12994db1..7d7c54fd 100644 --- a/packages/geometry/src/index.ts +++ b/packages/geometry/src/index.ts @@ -37,4 +37,4 @@ export { PLANE_XZ, PLANE_YZ, } from './plane'; -export { type SpatialTreeInterface, visitBFS } from './spatialIndexing/tree'; +export { type SpatialTreeInterface, visitBFS, visitBFSMaybe } from './spatialIndexing/tree'; diff --git a/packages/scatterbrain/demo.ts b/packages/scatterbrain/demo.ts index c1933d1c..06441eb8 100644 --- a/packages/scatterbrain/demo.ts +++ b/packages/scatterbrain/demo.ts @@ -1,8 +1,8 @@ import REGL from "regl"; -import { buildRenderFrameFn, buildScatterbrainCacheClient, buildScatterbrainRenderer, setCategoricalLookupTableValues } from "./src/renderer"; +import { buildRenderFrameFn, buildScatterbrainCacheClient, setCategoricalLookupTableValues } from "./src/renderer"; import { SharedPriorityCache } from '@alleninstitute/vis-core'; import { type ScatterbrainDataset } from "./src/types"; -import { getVisibleItems, loadDataset } from "./src/dataset"; +import { loadDataset } from "./src/dataset"; import { ShaderSettings } from "./src/shader"; import { vec4 } from "@alleninstitute/vis-geometry"; diff --git a/packages/scatterbrain/src/dataset.ts b/packages/scatterbrain/src/dataset.ts index 4fd79219..1271cdda 100644 --- a/packages/scatterbrain/src/dataset.ts +++ b/packages/scatterbrain/src/dataset.ts @@ -1,5 +1,5 @@ -import { Box2D, type box2D, type box3D, Box3D, Vec2, type vec2, type vec3, Vec3 } from "@alleninstitute/vis-geometry"; -import type { ScatterbrainDataset, SlideviewScatterbrainDataset, TreeNode, volumeBound } from "./better/types"; +import { Box2D, type box2D, type box3D, Box3D, Vec2, type vec2, type vec3, Vec3, visitBFSMaybe } from "@alleninstitute/vis-geometry"; +import type { ScatterbrainDataset, SlideviewScatterbrainDataset, TreeNode, volumeBound } from "./types"; import { reduce } from "lodash"; type Dataset = ScatterbrainDataset | SlideviewScatterbrainDataset @@ -45,28 +45,7 @@ function dropZ(box: box3D) { maxCorner: Vec3.xy(box.maxCorner), }; } -// todo move me to vis-geometry -export function visitBFSMaybe( - tree: Tree, - children: (t: Tree) => ReadonlyArray, - visitor: (tree: Tree) => boolean, -): void { - const frontier: Tree[] = [tree]; - while (frontier.length > 0) { - const cur = frontier.shift(); - if (cur === undefined) { - // TODO: Consider logging a warning or error here, as this should never happen, - // but this package doesn't depend on the package where our logger lives - continue; - } - if (visitor(cur)) { - for (const c of children(cur)) { - frontier.push(c); - } - } - } -} function getVisibleItemsInTree(dataset: { root: TreeNode, boundingBox: volumeBound }, camera: { view: box2D, screenResolution: vec2 }, limit: number) { const { root, boundingBox } = dataset @@ -84,7 +63,7 @@ function getVisibleItemsInTree(dataset: { root: TreeNode, boundingBox: volumeBou return hits; } -export function getVisibleItems(dataset: SlideviewScatterbrainDataset | ScatterbrainDataset, camera: { view: box2D, screenResolution: vec2, layout?: Record }) { +export function getVisibleItems(dataset: Dataset, camera: { view: box2D, screenResolution: vec2, layout?: Record }) { if (dataset.type === 'normal') { return getVisibleItemsInTree(dataset.metadata, camera, 5); } @@ -108,7 +87,7 @@ export function getVisibleItems(dataset: SlideviewScatterbrainDataset | Scatterb } -export function loadDataset(raw: any): SlideviewScatterbrainDataset | ScatterbrainDataset | undefined { +export function loadDataset(raw: any): Dataset | undefined { // index point attrs by name - its an array // TODO zod validation first! if (raw['pointAttributes']) { diff --git a/packages/scatterbrain/src/index.ts b/packages/scatterbrain/src/index.ts index e69de29b..00002561 100644 --- a/packages/scatterbrain/src/index.ts +++ b/packages/scatterbrain/src/index.ts @@ -0,0 +1,3 @@ +export { buildRenderFrameFn, buildScatterbrainCacheClient, setCategoricalLookupTableValues } from "./renderer"; +export * from './types' +export { getVisibleItems, loadDataset as loadScatterbrainDataset } from './dataset' \ No newline at end of file From f3049fc03f3e7025d40fc3bf39c4a2a8c48fca91 Mon Sep 17 00:00:00 2001 From: noah Date: Tue, 27 Jan 2026 15:06:42 -0800 Subject: [PATCH 15/29] render scatterbrain demo in examples area --- packages/scatterbrain/package.json | 2 +- packages/scatterbrain/src/dataset.ts | 2 +- packages/scatterbrain/src/index.ts | 2 +- packages/scatterbrain/src/renderer.ts | 7 +- packages/scatterbrain/src/shader.ts | 3 +- pnpm-lock.yaml | 3 + site/package.json | 3 +- .../content/docs/examples/scatterbrain.mdx | 8 ++ site/src/examples/scatterbrain/demo.tsx | 88 +++++++++++++++++-- 9 files changed, 104 insertions(+), 14 deletions(-) create mode 100644 site/src/content/docs/examples/scatterbrain.mdx diff --git a/packages/scatterbrain/package.json b/packages/scatterbrain/package.json index a6343e24..441a22e7 100644 --- a/packages/scatterbrain/package.json +++ b/packages/scatterbrain/package.json @@ -38,7 +38,7 @@ "scripts": { "typecheck": "tsc --noEmit", "build": "parcel build --no-cache", - "dev": "parcel watch --port 1237", + "dev": "parcel watch --port 1239", "demo": "vite", "test": "vitest --watch", "test:ci": "vitest run", diff --git a/packages/scatterbrain/src/dataset.ts b/packages/scatterbrain/src/dataset.ts index 1271cdda..75bbd39e 100644 --- a/packages/scatterbrain/src/dataset.ts +++ b/packages/scatterbrain/src/dataset.ts @@ -1,6 +1,6 @@ import { Box2D, type box2D, type box3D, Box3D, Vec2, type vec2, type vec3, Vec3, visitBFSMaybe } from "@alleninstitute/vis-geometry"; import type { ScatterbrainDataset, SlideviewScatterbrainDataset, TreeNode, volumeBound } from "./types"; -import { reduce } from "lodash"; +import reduce from "lodash/reduce"; type Dataset = ScatterbrainDataset | SlideviewScatterbrainDataset // figure out that path through the tree, given a TreeNode name diff --git a/packages/scatterbrain/src/index.ts b/packages/scatterbrain/src/index.ts index 00002561..1f6dbf61 100644 --- a/packages/scatterbrain/src/index.ts +++ b/packages/scatterbrain/src/index.ts @@ -1,3 +1,3 @@ -export { buildRenderFrameFn, buildScatterbrainCacheClient, setCategoricalLookupTableValues } from "./renderer"; +export { buildRenderFrameFn as buildScatterbrainRenderFn, buildScatterbrainCacheClient, setCategoricalLookupTableValues, updateCategoricalValue } from "./renderer"; export * from './types' export { getVisibleItems, loadDataset as loadScatterbrainDataset } from './dataset' \ No newline at end of file diff --git a/packages/scatterbrain/src/renderer.ts b/packages/scatterbrain/src/renderer.ts index 1dd62292..b012988c 100644 --- a/packages/scatterbrain/src/renderer.ts +++ b/packages/scatterbrain/src/renderer.ts @@ -1,9 +1,10 @@ -import type { Cacheable, CachedVertexBuffer, SharedPriorityCache } from '@alleninstitute/vis-core'; +import type { SharedPriorityCache } from '@alleninstitute/vis-core'; import type REGL from 'regl'; import type { ColumnRequest, ScatterbrainDataset, SlideviewScatterbrainDataset, TreeNode } from './types'; -import type { box2D, Interval, vec2, vec4 } from '@alleninstitute/vis-geometry'; +import type { box2D, vec2, vec4 } from '@alleninstitute/vis-geometry'; import { MakeTaggedBufferView } from './typed-array'; -import { filter, isEqual, keys, map, omit, reduce } from 'lodash' +import keys from 'lodash/keys' +import reduce from 'lodash/reduce' import { getVisibleItems, type NodeWithBounds } from './dataset'; import { buildScatterbrainRenderCommand, type Config, configureShader, type ShaderSettings, VBO } from './shader'; export type Item = Readonly<{ diff --git a/packages/scatterbrain/src/shader.ts b/packages/scatterbrain/src/shader.ts index 4b64b911..44cbe580 100644 --- a/packages/scatterbrain/src/shader.ts +++ b/packages/scatterbrain/src/shader.ts @@ -4,7 +4,8 @@ import REGL from "regl"; import type { ScatterbrainDataset, SlideviewScatterbrainDataset } from "./types"; -import { filter, keys, mapValues, reduce } from "lodash"; +import * as lodash from "lodash"; +const { filter, keys, mapValues, reduce } = lodash // ugh import { Box2D, type vec4, type box2D, type Interval, type vec2 } from "@alleninstitute/vis-geometry"; import { type Cacheable, type CachedVertexBuffer } from "@alleninstitute/vis-core"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0187d1a8..8f8bf3f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -160,6 +160,9 @@ importers: '@alleninstitute/vis-omezarr': specifier: workspace:* version: link:../packages/omezarr + '@alleninstitute/vis-scatterbrain': + specifier: workspace:* + version: link:../packages/scatterbrain '@astrojs/check': specifier: 0.9.4 version: 0.9.4(typescript@5.8.3) diff --git a/site/package.json b/site/package.json index 82b4f897..64bc53ce 100644 --- a/site/package.json +++ b/site/package.json @@ -49,6 +49,7 @@ "@alleninstitute/vis-dzi": "workspace:*", "@alleninstitute/vis-geometry": "workspace:*", "@alleninstitute/vis-omezarr": "workspace:*", + "@alleninstitute/vis-scatterbrain": "workspace:*", "@astrojs/check": "0.9.4", "@astrojs/mdx": "4.3.0", "@astrojs/react": "4.3.0", @@ -80,4 +81,4 @@ "zarrita": "0.5.1" }, "packageManager": "pnpm@9.14.2" -} +} \ No newline at end of file diff --git a/site/src/content/docs/examples/scatterbrain.mdx b/site/src/content/docs/examples/scatterbrain.mdx new file mode 100644 index 00000000..34a09de5 --- /dev/null +++ b/site/src/content/docs/examples/scatterbrain.mdx @@ -0,0 +1,8 @@ +--- +title: Scatterbrain +tableOfContents: false +--- + +import { ScatterBrainDemo } from '../../../examples/Scatterbrain/demo.tsx'; + + diff --git a/site/src/examples/scatterbrain/demo.tsx b/site/src/examples/scatterbrain/demo.tsx index 1bea7689..b776739f 100644 --- a/site/src/examples/scatterbrain/demo.tsx +++ b/site/src/examples/scatterbrain/demo.tsx @@ -1,24 +1,100 @@ -import type { vec2 } from '@alleninstitute/vis-geometry'; -import { SharedCacheProvider } from '../common/react/priority-cache-provider'; -import { useEffect, useRef } from 'react'; +import type { vec2, vec4 } from '@alleninstitute/vis-geometry'; +import { SharedCacheContext, SharedCacheProvider } from '../common/react/priority-cache-provider'; +import { useContext, useEffect, useRef, useState } from 'react'; +import { buildScatterbrainRenderFn, buildScatterbrainCacheClient, loadScatterbrainDataset, setCategoricalLookupTableValues, type Dataset, type ShaderSettings } from '@alleninstitute/vis-scatterbrain'; const screenSize: vec2 = [800, 800]; - -export function OmezarrDemo() { +const tenx = 'https://bkp-2d-visualizations-stage.s3.amazonaws.com/wmb_tenx_01172024_stage-20240128193624/G4I4GFJXJB9ATZ3PTX1/ScatterBrain.json' +export function ScatterBrainDemo() { return ( ); } + + const makeFakeColors = (n: number) => { + const stuff: Record = {} + for (let i = 0; i < n; i++) { + stuff[i] = { + color: [Math.random(), Math.random(), Math.random(), 1], + // 80% of either category are filtered in, at random: + filteredIn: Math.random() > 0.2 + } + } + return stuff; + } +// fake color and filter tables, as a demo: + const categories = { + '4MV7HA5DG2XJZ3UD8G9': makeFakeColors(40), // nt type + 'FS00DXV0T9R1X9FJ4QE': makeFakeColors(40) // class + } + const settings: Omit = { + categoricalFilters: { '4MV7HA5DG2XJZ3UD8G9': 40, 'FS00DXV0T9R1X9FJ4QE': 40, }, + colorBy: { kind: 'metadata', column: "FS00DXV0T9R1X9FJ4QE" }, + // an alternative color-by setting, swap it to see quantitative coloring + // colorBy: { kind: 'quantitative', column: '27683', gradient: 'viridis', range: { min: 0, max: 10 } }, + mode: 'color', + quantitativeFilters: [], + } +async function loadRawJson(){ + return await (await fetch(tenx)).json() +} type Props = {screenSize:vec2} function Demo(props:Props) { const {screenSize} = props; const cnvs = useRef(null); + const server = useContext(SharedCacheContext); + const [dataset,setDataset] = useState(undefined) + useEffect(()=>{ + loadRawJson().then(raw=>setDataset(loadScatterbrainDataset(raw))) + },[]) // todo handlers, etc useEffect(()=>{ // build the renderer - },[cnvs.current]) + if(server && dataset && cnvs.current){ + const ctx = cnvs.current.getContext('2d') + const {cache,regl}=server; + const lookup = regl.texture({ width: 10, height: 10, format: 'rgba' }) + const gradientData = new Uint8Array(256 * 4); + for (let i = 0; i < 256; i += 4) { + gradientData[i * 4 + 0] = i; + gradientData[i * 4 + 1] = i; + gradientData[i * 4 + 2] = i; + gradientData[i * 4 + 3] = 255; + } + const gradient = regl.texture({ width: 256, height: 1, format: 'rgba', data: gradientData }) + const tgt = regl.framebuffer(screenSize[0],screenSize[1]) + // make up random colors for the coloring, and add random filtering + + setCategoricalLookupTableValues(categories, lookup) + const render = buildScatterbrainRenderFn(regl as any, {...settings,dataset}); + // this ts error is bogus, dont know why + const renderOneFrame = ()=>{ + render({ + client, + camera: { view: { minCorner: [-17, -17], maxCorner: [26, 26] }, screenResolution: [800, 800] }, + categoricalLookupTable: lookup, + dataset, + filteredOutColor: [0, 0, 0, 1], + gradient, + hoveredValue: 22, + offset: [0, 0], + quantitativeRangeFilters: {}, + spatialFilterBox: { minCorner: [-17, -17], maxCorner: [30, 30] }, + target: tgt, + }) + const bytes = regl.read({framebuffer:tgt}) + const img = new ImageData(new Uint8ClampedArray(bytes),screenSize[0],screenSize[1]); + ctx!.putImageData(img, 0, 0); + } + const client = buildScatterbrainCacheClient(regl as any,cache,()=>{ + requestAnimationFrame(renderOneFrame); + }) + renderOneFrame(); + } + + },[cnvs.current,dataset,server]) return ( Date: Tue, 3 Feb 2026 14:28:20 -0800 Subject: [PATCH 16/29] some more PR cleanup feedback, mostly removing stale comments or files. --- packages/scatterbrain/demo.ts | 104 --------------- packages/scatterbrain/index.html | 35 ----- packages/scatterbrain/package.json | 9 -- packages/scatterbrain/src/renderer.ts | 28 +--- packages/scatterbrain/src/shader.ts | 2 - packages/scatterbrain/src/types.ts | 3 - site/package.json | 3 - .../scatterbrain/scatterbrain/renderer.ts | 121 ------------------ .../scatterbrain/scatterbrain/types.ts | 88 ------------- 9 files changed, 6 insertions(+), 387 deletions(-) delete mode 100644 packages/scatterbrain/demo.ts delete mode 100644 packages/scatterbrain/index.html delete mode 100644 site/src/examples/scatterbrain/scatterbrain/renderer.ts delete mode 100644 site/src/examples/scatterbrain/scatterbrain/types.ts diff --git a/packages/scatterbrain/demo.ts b/packages/scatterbrain/demo.ts deleted file mode 100644 index 06441eb8..00000000 --- a/packages/scatterbrain/demo.ts +++ /dev/null @@ -1,104 +0,0 @@ -import REGL from "regl"; -import { buildRenderFrameFn, buildScatterbrainCacheClient, setCategoricalLookupTableValues } from "./src/renderer"; -import { SharedPriorityCache } from '@alleninstitute/vis-core'; -import { type ScatterbrainDataset } from "./src/types"; -import { loadDataset } from "./src/dataset"; -import { ShaderSettings } from "./src/shader"; -import { vec4 } from "@alleninstitute/vis-geometry"; - -const twoGB = 1024 * 1024 * 2000; -const tenx = 'https://bkp-2d-visualizations-stage.s3.amazonaws.com/wmb_tenx_01172024_stage-20240128193624/G4I4GFJXJB9ATZ3PTX1/ScatterBrain.json' -async function begin() { - console.log("hi from vite dev!") - const canvas = document.getElementById('canvas') as HTMLCanvasElement - canvas.width = 800; - canvas.height = 800; - const cache = new SharedPriorityCache(new Map(), twoGB, 10) - // it would be more normal in a shared-cache setup to use an offscreen gl - // canvas, and then copy the pixels out and over to the client of the cache - // but this is a demo, so lets skip that step - // const glcanvas = new OffscreenCanvas(10, 10); - const gl = canvas.getContext('webgl', { - alpha: true, - preserveDrawingBuffer: false, - antialias: true, - premultipliedAlpha: true, - }); - if (!gl) { - throw new Error('WebGL not supported!'); - } - const regl = REGL({ - gl, - extensions: ['oes_texture_float'] - }); - const dataset = await (await fetch(tenx)).json() - const yay: ScatterbrainDataset = loadDataset(dataset); - const camera = { view: { minCorner: [-17, -17], maxCorner: [26, 26] }, screenResolution: [800, 800] } as const - console.log('yay data!', yay) - // in a real app - the shader settings changing could require a re-build of the renderer - // compare prior settings to current, call build IF they change - - const makeFakeColors = (n: number) => { - const stuff: Record = {} - for (let i = 0; i < n; i++) { - stuff[i] = { - color: [Math.random(), Math.random(), Math.random(), 1], - // 80% of either category are filtered in, at random: - filteredIn: Math.random() > 0.2 - } - } - return stuff; - } - // fake color and filter tables, as a demo: - const categories = { - '4MV7HA5DG2XJZ3UD8G9': makeFakeColors(40), // nt type - 'FS00DXV0T9R1X9FJ4QE': makeFakeColors(40) // class - } - const settings: ShaderSettings = { - categoricalFilters: { '4MV7HA5DG2XJZ3UD8G9': 40, 'FS00DXV0T9R1X9FJ4QE': 40, }, - colorBy: { kind: 'metadata', column: "FS00DXV0T9R1X9FJ4QE" }, - // an alternative color-by setting, swap it to see quantitative coloring - // colorBy: { kind: 'quantitative', column: '27683', gradient: 'viridis', range: { min: 0, max: 10 } }, - dataset: yay, - mode: 'color', - quantitativeFilters: [], - } - - const lookup = regl.texture({ width: 10, height: 10, format: 'rgba' }) - const gradientData = new Uint8Array(256 * 4); - for (let i = 0; i < 256; i += 4) { - gradientData[i * 4 + 0] = i; - gradientData[i * 4 + 1] = i; - gradientData[i * 4 + 2] = i; - gradientData[i * 4 + 3] = 255; - } - const gradient = regl.texture({ width: 256, height: 1, format: 'rgba', data: gradientData }) - - // make up random colors for the coloring, and add random filtering - - setCategoricalLookupTableValues(categories, lookup) - - const render = buildRenderFrameFn(regl, settings); - const doRender = () => { - render({ - client, - camera: { view: { minCorner: [-17, -17], maxCorner: [26, 26] }, screenResolution: [800, 800] }, - categoricalLookupTable: lookup, - dataset: yay, - filteredOutColor: [0, 0, 0, 1], - gradient, - hoveredValue: 22, - offset: [0, 0], - quantitativeRangeFilters: {}, - spatialFilterBox: { minCorner: [-17, -17], maxCorner: [30, 30] }, - target: null, - }) - } - const client = buildScatterbrainCacheClient(regl, cache, () => { - requestAnimationFrame(doRender) - }); - // start it off with a single render call - doRender(); -} - -begin(); \ No newline at end of file diff --git a/packages/scatterbrain/index.html b/packages/scatterbrain/index.html deleted file mode 100644 index 7eaf4f6a..00000000 --- a/packages/scatterbrain/index.html +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - Developer-focused scatterbrain demo - no astro needed? - - - - - - - - - \ No newline at end of file diff --git a/packages/scatterbrain/package.json b/packages/scatterbrain/package.json index 441a22e7..16f5399a 100644 --- a/packages/scatterbrain/package.json +++ b/packages/scatterbrain/package.json @@ -49,15 +49,6 @@ "type": "git", "url": "https://github.com/AllenInstitute/vis.git" }, - "devDependencies": { - "@parcel/packager-ts": "2.15.4", - "@parcel/transformer-typescript-types": "2.15.4", - "@types/lodash": "4.17.21", - "parcel": "2.15.4", - "typescript": "5.9.3", - "vite": "7.3.0", - "vitest": "4.0.6" - }, "dependencies": { "@alleninstitute/vis-core": "workspace:*", "@alleninstitute/vis-geometry": "workspace:*", diff --git a/packages/scatterbrain/src/renderer.ts b/packages/scatterbrain/src/renderer.ts index b012988c..c22979e1 100644 --- a/packages/scatterbrain/src/renderer.ts +++ b/packages/scatterbrain/src/renderer.ts @@ -78,14 +78,6 @@ function columnsForItem(config: Config, col2shader: Record(config, columnNameToShaderName, dataset); - const drawQtCell = buildScatterbrainRenderCommand(config, regl); - return { drawQtCell, prepareQtCell }; -} - /** * a helper function that MUTATES ALL the values in the given @param texture * to set them to the color and filter status as given in the categories record @@ -151,7 +143,12 @@ export function updateCategoricalValue(categories: readonly string[], type Props = Omit>[0], 'item'> & { dataset: ScatterbrainDataset | SlideviewScatterbrainDataset, client: ReturnType } export function buildRenderFrameFn(regl: REGL.Regl, state: ShaderSettings) { - const { drawQtCell, prepareQtCell } = buildHelperThingy(regl, state) + + const { dataset } = state; + const { config, columnNameToShaderName } = configureShader(state); + const prepareQtCell = columnsForItem(config, columnNameToShaderName, dataset); + const drawQtCell = buildScatterbrainRenderCommand(config, regl); + return function render(props: Props) { const { camera, dataset, client } = props const visibleQtNodes = getVisibleItems(dataset, camera).map(prepareQtCell) @@ -173,16 +170,3 @@ export function buildRenderFrameFn(regl: REGL.Regl, state: ShaderSettings) { } } -/* TODO features: -[x] color by (cat / quant) -[x] hover (cat / quant) -> data out -[x] highlight color-by value - - highlight overrides filtering -* NaN / Null value handling -[x] categorical filtering -[x] range filtering // should work... test it though -[x] spatial-box filtering -* slide view offsets -* configurable depth settings (quantitative, node-depth, constant) -[x] filtered-out color (constant / transparant) -*/ \ No newline at end of file diff --git a/packages/scatterbrain/src/shader.ts b/packages/scatterbrain/src/shader.ts index 44cbe580..511da402 100644 --- a/packages/scatterbrain/src/shader.ts +++ b/packages/scatterbrain/src/shader.ts @@ -190,9 +190,7 @@ function categoricalFilterExpression(cColumns: readonly string[], tableSize: vec export function generate(config: Config): ScatterbrainShaderUtils { const { mode, quantitativeColumns, categoricalColumns, categoricalTable, tableSize, gradientTable, positionColumn, colorByColumn } = config; - console.log('tableSize: ', tableSize) const catFilter = categoricalFilterExpression(categoricalColumns, tableSize, categoricalTable) - console.log('cat filter: ', catFilter) const rangeFilter = rangeFilterExpression(quantitativeColumns) const uniforms = /*glsl*/` uniform vec4 view; diff --git a/packages/scatterbrain/src/types.ts b/packages/scatterbrain/src/types.ts index bb696d79..5a7b1ea9 100644 --- a/packages/scatterbrain/src/types.ts +++ b/packages/scatterbrain/src/types.ts @@ -1,6 +1,3 @@ -// lets get a renderer up and rolling -// then add features from there... - /// Types describing the metadata that gets loaded from scatterbrain.json files /// // there are 2 variants, slideview and regular - they are distinguished at runtime // by checking the parsed metadata for the 'slides' field diff --git a/site/package.json b/site/package.json index 64bc53ce..f6fab2a4 100644 --- a/site/package.json +++ b/site/package.json @@ -62,9 +62,6 @@ "@mui/icons-material": "5.15.15", "@mui/lab": "5.0.0-alpha.175", "@mui/material": "5.15.15", - "@thi.ng/shader-ast": "1.1.33", - "@thi.ng/shader-ast-glsl": "1.0.52", - "@thi.ng/webgl": "6.9.91", "@types/lodash": "4.17.16", "@types/react": "^19.1.3", "@types/react-dom": "^19.1.3", diff --git a/site/src/examples/scatterbrain/scatterbrain/renderer.ts b/site/src/examples/scatterbrain/scatterbrain/renderer.ts deleted file mode 100644 index 4954d8a2..00000000 --- a/site/src/examples/scatterbrain/scatterbrain/renderer.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { Cacheable, CachedVertexBuffer, SharedPriorityCache } from '@alleninstitute/vis-core'; -import type REGL from 'regl'; -import type { ColumnRequest, ScatterbrainDataset, SlideviewScatterbrainDataset, TreeNode } from './types'; -import type { box2D } from '@alleninstitute/vis-geometry'; -import { MakeTaggedBufferView } from 'src/examples/common/typed-array'; -import { reduce } from 'lodash'; -import { assign, defMain, output, program, uniform, input, vec4, defn, V4, V2, sym, mul, float, ret, type Vec2Sym, type FloatSym } from "@thi.ng/shader-ast"; -import { GLSLVersion, targetGLSL } from "@thi.ng/shader-ast-glsl"; -// import * as glsl from '@thi.ng/shader-ast-glsl' -// import type { ColumnRequest } from 'src/examples/common/loaders/scatterplot/scatterbrain-loader'; -import { defShader } from "@thi.ng/webgl"; -type Item = Readonly<{ - dataset: SlideviewScatterbrainDataset | ScatterbrainDataset - node: TreeNode - bounds: box2D - columns: Record -}> -type Content = {} - -class VBO implements Cacheable { - buffer: CachedVertexBuffer; - constructor(buffer: CachedVertexBuffer) { - this.buffer = buffer; - } - destroy() { - this.buffer.buffer.destroy() - } - sizeInBytes() { - return this.buffer.bytes; - } -} - -export function buildScatterbrainCacheClient(regl: REGL.Regl, cache: SharedPriorityCache) { - const client = cache.registerClient({ - cacheKeys: (item) => { - const { dataset, node, columns } = item; - return reduce, Record>(columns, (acc, col, key) => ({ ...acc, [key]: `${dataset.metadata.metadataFileEndpoint}/${node.file}/${col.name}` }), {}) - }, - fetch: (item) => { - const { dataset, node, columns } = item; - const attrs = dataset.metadata.pointAttributes; - const getColumnUrl = (columnName: string) => `${dataset.metadata.metadataFileEndpoint}${columnName}/${dataset.metadata.visualizationReferenceId}/${node.file}`; - const getGeneUrl = (columnName: string) => `${dataset.metadata.geneFileEndpoint}${columnName}/${dataset.metadata.visualizationReferenceId}/${node.file}`; - const getColumnInfo = (col: ColumnRequest) => - col.type === 'QUANTITATIVE' ? - { url: getGeneUrl(col.name), elements: 1, type: 'float' } as const - : - { url: getColumnUrl(col.name), elements: attrs[col.name].elements, type: attrs[col.name].type } - - const proms = reduce, Record Promise>>(columns, (getters, col, key) => { - const { url, type } = getColumnInfo(col) - return { - ...getters, - [key]: (signal) => fetch(url, { signal }).then(b => b.arrayBuffer().then(buff => { - const typed = MakeTaggedBufferView(type, buff) - return new VBO({ buffer: regl.buffer({ type: type, data: typed.data }), bytes: buff.byteLength, type: 'buffer' }) - })) - } - }, {}) - return proms; - }, - isValue: (v): v is Content => { - // TODO!! - // a problem here - unless we do some pre-cooking of shaders... the set of attrs we pass to the shader - // is only known at runtime, because the user picks a configuration and we generate a shader based on that config - // this is how its done in ABC atlas now, but to be fair - its not that helpful. No one but me reads the shaders, - // the names of attrs are just featureTypeReferenceIds (gibberish) so its not super helpful to generate them in this way. - // I'm considering pre-generating shaders with fixed #'s of vertex attr inputs... however the # of shaders is pretty combinatoric... - // even with just 4 attrs (pos, A,B,C) how A B and C are used is also determined at runtime - // for example, B and C could be genes, and thus need to be filtered with a range, where A could be metadata - // or A could be the gene, B and C could be metadata, and C could be the color-by - // there are so many permutations... - // so thinking that through - we're stuck generating shaders - unless we want to push a TON of speculative - // execution down into the vertex shader, there's no way around it... - return true; - }, - }) - return client; -} - -/* -perhaps the issue is not that we generate the shaders, but the very verbose string-building way in which we generate the shaders -there are alternatives, use.gpu style, or even how thi.ng/umbrella does it with a custom DSL... -*/ - -function whatever(ctx: WebGLRenderingContext) { - const glsl = targetGLSL({ version: GLSLVersion.GLES_100 }) - const x = program([defn(V4, 'neat', [V2, V2], (what, who) => { - let uv: FloatSym - return [ - (uv = sym(mul(float(2), float(3)))), - ret(vec4(uv, uv, uv, float(10))) - ] - })]) - // I feel confident that the above is actually less comprehensible than some GLSL template literals... - - - const wtf = glsl(x) // ok this is the way to actually compile it to a string... - const hey = defShader(ctx, { - vs: (gl, unis, attribs) => [ - defMain(() => - [assign(gl.gl_Position, vec4(1, 2, 3, 0))] - ) - ], - fs: (gl, unis, wat, outs) => [ - defMain(() => - [assign( - outs.fragColor, - vec4(1, 2, 3, 1) - ),] - ) - ], - attribs: { - - }, - uniforms: { - - } - }) - hey.program -} \ No newline at end of file diff --git a/site/src/examples/scatterbrain/scatterbrain/types.ts b/site/src/examples/scatterbrain/scatterbrain/types.ts deleted file mode 100644 index b71ac4cd..00000000 --- a/site/src/examples/scatterbrain/scatterbrain/types.ts +++ /dev/null @@ -1,88 +0,0 @@ -// lets get a renderer up and rolling -// then add features from there... - -/// Types describing the metadata that gets loaded from scatterbrain.json files /// -// there are 2 variants, slideview and regular - they are distinguished at runtime -// by checking the parsed metadata for the 'slides' field -export type WebGLSafeBasicType = 'uint8' | 'uint16' | 'int8' | 'int16' | 'uint32' | 'int32' | 'float'; - -export type volumeBound = { - lx: number; - ly: number; - lz: number; - ux: number; - uy: number; - uz: number; -}; -type PointAttribute = { - name: string; - size: number; // elements * elementSize - todo ask Peter to remove - elements: number; // values per point (so a vector xy would have 2) - elementSize: number; // size of an element, given in bytes (for example float would have 4) - type: WebGLSafeBasicType; - description: string; -}; -export type TreeNode = { - file: string; - numSpecimens: number; - children: undefined | TreeNode[]; -}; - -type MetadataColumn = { - type: 'METADATA'; - name: string; -}; -type QuantitativeColumn = { - type: 'QUANTITATIVE'; - name: string; -}; -export type ColumnRequest = MetadataColumn | QuantitativeColumn; -type CommonMetadata = { - geneFileEndpoint: string; - metadataFileEndpoint: string; - visualizationReferenceId: string; - spatialColumn: string; - pointAttributes: Record; -} -// scatterbrain distinguishes 2 kinds of datasets - those arranged at the topmost level into slides -// and 'regular' - which is just a simple, 2D point cloud -export type ScatterbrainMetadata = CommonMetadata & { - points: number; - boundingBox: volumeBound; - tightBoundingBox: volumeBound; - root: TreeNode; -}; - -// slideview variant: -type Slide = { - featureTypeValueReferenceId: string; - tree: { - root: TreeNode; - points: number; - boundingBox: volumeBound; - tightBoundingBox: volumeBound; - }; -}; -type SpatialReferenceFrame = { - anatomicalOrigin: string; - direction: string; - unit: string; - minX: number; - maxX: number; - minY: number; - maxY: number; -}; - -export type SlideviewMetadata = CommonMetadata & { - slides: Slide[]; - spatialUnit: SpatialReferenceFrame; -}; - -/// Types related to the rendering of scatterbrain datasets /// - -// a Dataset is the top level entity -// an Item is a chunk of that dataset - esentially a singular, loadable, thing - - -export type SlideviewScatterbrainDataset = { type: 'slideview', metadata: SlideviewMetadata } -export type ScatterbrainDataset = { type: 'normal', metadata: ScatterbrainMetadata } \ No newline at end of file From c4f529d4fd1cde6e20e4129c68751d9e86bdf1a6 Mon Sep 17 00:00:00 2001 From: noah Date: Tue, 3 Feb 2026 14:36:30 -0800 Subject: [PATCH 17/29] use ts-expect-error instead of casting to any, remove devDependencies from geometry, fix commentary about a bit of dead code --- packages/geometry/package.json | 7 ------- packages/geometry/src/spatialIndexing/tree.ts | 3 +-- site/src/examples/scatterbrain/demo.tsx | 11 +++++++++-- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/geometry/package.json b/packages/geometry/package.json index a218c0b0..48a640ef 100644 --- a/packages/geometry/package.json +++ b/packages/geometry/package.json @@ -48,13 +48,6 @@ "type": "git", "url": "https://github.com/AllenInstitute/vis.git" }, - "devDependencies": { - "@parcel/packager-ts": "2.15.4", - "@parcel/transformer-typescript-types": "2.15.4", - "parcel": "2.15.4", - "typescript": "5.9.3", - "vitest": "4.0.6" - }, "publishConfig": { "registry": "https://npm.pkg.github.com/AllenInstitute" }, diff --git a/packages/geometry/src/spatialIndexing/tree.ts b/packages/geometry/src/spatialIndexing/tree.ts index 623d031a..757d0208 100644 --- a/packages/geometry/src/spatialIndexing/tree.ts +++ b/packages/geometry/src/spatialIndexing/tree.ts @@ -16,8 +16,7 @@ export function visitBFS( while (frontier.length > 0) { const cur = frontier.shift(); if (cur === undefined) { - // TODO: Consider logging a warning or error here, as this should never happen, - // but this package doesn't depend on the package where our logger lives + // this case is pretty clearly dead-code - but ts cant tell continue; } visitor(cur); diff --git a/site/src/examples/scatterbrain/demo.tsx b/site/src/examples/scatterbrain/demo.tsx index b776739f..5075d9d3 100644 --- a/site/src/examples/scatterbrain/demo.tsx +++ b/site/src/examples/scatterbrain/demo.tsx @@ -68,7 +68,11 @@ function Demo(props:Props) { // make up random colors for the coloring, and add random filtering setCategoricalLookupTableValues(categories, lookup) - const render = buildScatterbrainRenderFn(regl as any, {...settings,dataset}); + + const render = buildScatterbrainRenderFn( + // @ts-expect-error we'll deal with this later + regl, + {...settings,dataset}); // this ts error is bogus, dont know why const renderOneFrame = ()=>{ render({ @@ -88,7 +92,10 @@ function Demo(props:Props) { const img = new ImageData(new Uint8ClampedArray(bytes),screenSize[0],screenSize[1]); ctx!.putImageData(img, 0, 0); } - const client = buildScatterbrainCacheClient(regl as any,cache,()=>{ + const client = buildScatterbrainCacheClient( + // @ts-expect-error we'll deal with this later + regl, + cache,()=>{ requestAnimationFrame(renderOneFrame); }) renderOneFrame(); From e3281fbfdb83cffaa3a69e051c3ced1807a13cef Mon Sep 17 00:00:00 2001 From: noah Date: Tue, 3 Feb 2026 15:48:31 -0800 Subject: [PATCH 18/29] better words --- packages/scatterbrain/src/shader.ts | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/scatterbrain/src/shader.ts b/packages/scatterbrain/src/shader.ts index 511da402..bdac8ad6 100644 --- a/packages/scatterbrain/src/shader.ts +++ b/packages/scatterbrain/src/shader.ts @@ -165,7 +165,7 @@ export function buildScatterbrainRenderCommand(config: Config, regl: REGL.Regl) framebuffer: prop('target'), count: prop('count') }); - // + return (props: RenderProps) => { const { target, hoveredValue, spatialFilterBox, filteredOutColor, gradient, camera, offset, quantitativeRangeFilters, categoricalLookupTable, item } = props const filterRanges = reduce(keys(quantitativeRangeFilters), (acc, cur) => ({ ...acc, [rangeFor(cur)]: quantitativeRangeFilters[cur] }), {}) @@ -176,14 +176,13 @@ export function buildScatterbrainRenderCommand(config: Config, regl: REGL.Regl) } } -function rangeFilterExpression(qColumns: readonly string[]) { - return qColumns.map(attrib =>/*glsl*/`within(${attrib},${rangeFor(attrib)})`).join(' * ') +function rangeFilterExpression(quantitativeColumns: readonly string[]) { + return quantitativeColumns.map(attrib =>/*glsl*/`within(${attrib},${rangeFor(attrib)})`).join(' * ') } -function categoricalFilterExpression(cColumns: readonly string[], tableSize: vec2, tableName: string) { +function categoricalFilterExpression(categoricalColumns: readonly string[], tableSize: vec2, tableName: string) { // categorical columns are in order - this array will have the same order as the col in the texture const [w, h] = tableSize; - // return /*glsl*/`step(0.01,texture2D(${tableName},vec2(0.5,${cColumns[0]}+0.5)/vec2(${w.toFixed(1)},${h.toFixed(1)})).a)` - return cColumns.map((attrib, i) => + return categoricalColumns.map((attrib, i) => /*glsl*/`step(0.01,texture2D(${tableName},vec2(${i.toFixed(0)}.5,${attrib}+0.5)/vec2(${w.toFixed(1)},${h.toFixed(1)})).a)`) .join(' * ') } @@ -296,16 +295,24 @@ export function configureShader(settings: ShaderSettings): { config: Config, col // produce an object that can be used to set up some internal config of the shader that would // do the visualization const { dataset, categoricalFilters, quantitativeFilters, colorBy, mode } = settings; - console.log('cat filters...', categoricalFilters) // figure out the columns we care about // assign them names that are safe to use in the shader (A,B,C, whatever) const categories = keys(categoricalFilters).toSorted() const numCategories = categories.length; - const longest = reduce(keys(categoricalFilters), (highest, cur) => Math.max(highest, categoricalFilters[cur]), 0) - const qAttrs = reduce(quantitativeFilters.toSorted(), (acc, cur, i) => ({ ...acc, [cur]: `MEASURE_${i.toFixed(0)}` }), colorBy.kind === 'metadata' ? {} : { [colorBy.column]: 'COLOR_BY_MEASURE' } as Record); - const cAttrs = reduce(categories, (acc, cur, i) => ({ ...acc, [cur]: `CATEGORY_${i.toFixed(0)}` }), colorBy.kind === 'metadata' ? { [colorBy.column]: 'COLOR_BY_CATEGORY' } : {} as Record); + const longestCategory = reduce(keys(categoricalFilters), (highest, cur) => Math.max(highest, categoricalFilters[cur]), 0) + + // the goal here is to associate column names with shader-safe names + const initialQuantitativeAttrs: Record = colorBy.kind === 'metadata' ? {} : { [colorBy.column]: 'COLOR_BY_MEASURE' } + const initialCategoricalAttrs: Record = colorBy.kind === 'metadata' ? { [colorBy.column]: 'COLOR_BY_CATEGORY' } : {} + // we map each quantitative filter name to the shader-safe attribute name: MEASURE_{i} + const qAttrs = reduce(quantitativeFilters.toSorted(), (quantAttrs, quantFilter, i) => ({ ...quantAttrs, [quantFilter]: `MEASURE_${i.toFixed(0)}` }), initialQuantitativeAttrs); + // we map each categorical filter's name to the shader-safe attribute name: CATEGORY_{i} + const cAttrs = reduce(categories, (catAttrs, categoricalFilter, i) => ({ ...catAttrs, [categoricalFilter]: `CATEGORY_${i.toFixed(0)}` }), initialCategoricalAttrs); + const colToAttribute = { ...qAttrs, ...cAttrs, [dataset.metadata.spatialColumn]: 'position' }; + + const config: Config = { categoricalColumns: keys(cAttrs).map(columnName => colToAttribute[columnName]), quantitativeColumns: keys(qAttrs).map(columnName => colToAttribute[columnName]), @@ -314,7 +321,7 @@ export function configureShader(settings: ShaderSettings): { config: Config, col colorByColumn: colToAttribute[colorBy.column], mode, positionColumn: 'position', - tableSize: [Math.max(numCategories, 1), Math.max(1, longest)] + tableSize: [Math.max(numCategories, 1), Math.max(1, longestCategory)] } return { config, columnNameToShaderName: colToAttribute } } \ No newline at end of file From b0818deed22e022666a78f44b965de9854bf84e8 Mon Sep 17 00:00:00 2001 From: noah Date: Tue, 3 Feb 2026 15:48:56 -0800 Subject: [PATCH 19/29] package lock updates (lodash types) --- packages/scatterbrain/package.json | 5 ++++- pnpm-lock.yaml | 8 ++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/scatterbrain/package.json b/packages/scatterbrain/package.json index 16f5399a..ffb47b02 100644 --- a/packages/scatterbrain/package.json +++ b/packages/scatterbrain/package.json @@ -59,5 +59,8 @@ "publishConfig": { "registry": "https://npm.pkg.github.com/AllenInstitute" }, - "packageManager": "pnpm@9.14.2" + "packageManager": "pnpm@9.14.2", + "devDependencies": { + "@types/lodash": "4.17.23" + } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b556c00d..d10ceb2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,10 @@ importers: ts-pattern: specifier: 5.9.0 version: 5.9.0 + devDependencies: + '@types/lodash': + specifier: 4.17.23 + version: 4.17.23 site: dependencies: @@ -1233,7 +1237,7 @@ packages: '@mui/base@5.0.0-beta.40': resolution: {integrity: sha512-I/lGHztkCzvwlXpjD2+SNmvNQvB4227xBXhISPjEaJUXGImOQ9f3D2Yj/T3KasSI/h0MLWy74X0J6clhPmsRbQ==} engines: {node: '>=12.0.0'} - deprecated: This package has been replaced by @base-ui/react + deprecated: This package has been replaced by @base-ui-components/react peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 react: ^17.0.0 || ^18.0.0 @@ -1245,7 +1249,7 @@ packages: '@mui/base@5.0.0-beta.40-0': resolution: {integrity: sha512-hG3atoDUxlvEy+0mqdMpWd04wca8HKr2IHjW/fAjlkCHQolSLazhZM46vnHjOf15M4ESu25mV/3PgjczyjVM4w==} engines: {node: '>=12.0.0'} - deprecated: This package has been replaced by @base-ui/react + deprecated: This package has been replaced by @base-ui-components/react peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 From fe037fe51cae6bf1252e7f36b5da6086fc1108a0 Mon Sep 17 00:00:00 2001 From: noah Date: Fri, 6 Feb 2026 11:09:02 -0800 Subject: [PATCH 20/29] fix up important todo --- packages/scatterbrain/src/renderer.ts | 41 ++++++++++++++++--------- site/src/examples/scatterbrain/demo.tsx | 9 ++---- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/packages/scatterbrain/src/renderer.ts b/packages/scatterbrain/src/renderer.ts index c22979e1..18f4a1d5 100644 --- a/packages/scatterbrain/src/renderer.ts +++ b/packages/scatterbrain/src/renderer.ts @@ -16,7 +16,7 @@ export type Item = Readonly<{ type Content = Record -export function buildScatterbrainCacheClient(regl: REGL.Regl, cache: SharedPriorityCache, onDataArrived: () => void) { +export function buildScatterbrainCacheClient(allNeededColumns: readonly string[], regl: REGL.Regl, cache: SharedPriorityCache, onDataArrived: () => void) { const client = cache.registerClient({ cacheKeys: (item) => { const { dataset, node, columns } = item; @@ -46,9 +46,11 @@ export function buildScatterbrainCacheClient(regl: REGL.Regl, cache: SharedPrior return proms; }, isValue: (v): v is Content => { - // TODO - // figure out the set of columns that would be required given our settings - // check if all those keys are in v, and each one is defined and instanceof VBO + for (const column of allNeededColumns) { + if (!(column in v)) { + return false; + } + } return true; }, onDataArrived @@ -56,12 +58,6 @@ export function buildScatterbrainCacheClient(regl: REGL.Regl, cache: SharedPrior return client; } -type State = ShaderSettings & { - camera: { view: box2D, screenResolution: vec2 }, - filterBox: box2D, -} - - function columnsForItem(config: Config, col2shader: Record, dataset: ScatterbrainDataset | SlideviewScatterbrainDataset) { const columns: Record = {} const s2c = reduce(keys(col2shader), (acc, col) => ({ ...acc, [col2shader[col]]: col }), {} as Record) @@ -141,15 +137,24 @@ export function updateCategoricalValue(categories: readonly string[], texture.subimage(data, col, row) } -type Props = Omit>[0], 'item'> & { dataset: ScatterbrainDataset | SlideviewScatterbrainDataset, client: ReturnType } -export function buildRenderFrameFn(regl: REGL.Regl, state: ShaderSettings) { +type ScatterbrainRenderProps = Omit>[0], 'item'> & { dataset: ScatterbrainDataset | SlideviewScatterbrainDataset, client: ReturnType } +/** + * + * @param regl a regl context + * @param settings settings describing the data and how it should be rendered + * @returns a pair of functions: + * render: when called with renderable data, will determine the set of visible data, request that data from the client, and then draw all currently available data + * connectToCache - called to produce a cacheClient, which must be passed to the render function + */ +export function buildRenderFrameFn(regl: REGL.Regl, settings: ShaderSettings) { + + const { dataset } = settings; + const { config, columnNameToShaderName } = configureShader(settings); - const { dataset } = state; - const { config, columnNameToShaderName } = configureShader(state); const prepareQtCell = columnsForItem(config, columnNameToShaderName, dataset); const drawQtCell = buildScatterbrainRenderCommand(config, regl); - return function render(props: Props) { + const render = (props: ScatterbrainRenderProps) => { const { camera, dataset, client } = props const visibleQtNodes = getVisibleItems(dataset, camera).map(prepareQtCell) client.setPriorities(visibleQtNodes, []) @@ -168,5 +173,11 @@ export function buildRenderFrameFn(regl: REGL.Regl, state: ShaderSettings) { } } } + const connectToCache = (cache: SharedPriorityCache, onDataArrived: () => void) => { + const allColumns = [...config.categoricalColumns, ...config.quantitativeColumns, config.positionColumn] + const client = buildScatterbrainCacheClient(allColumns, regl, cache, onDataArrived) + return client; + } + return { render, connectToCache } } diff --git a/site/src/examples/scatterbrain/demo.tsx b/site/src/examples/scatterbrain/demo.tsx index 5075d9d3..9121631d 100644 --- a/site/src/examples/scatterbrain/demo.tsx +++ b/site/src/examples/scatterbrain/demo.tsx @@ -69,7 +69,7 @@ function Demo(props:Props) { setCategoricalLookupTableValues(categories, lookup) - const render = buildScatterbrainRenderFn( + const {render,connectToCache} = buildScatterbrainRenderFn( // @ts-expect-error we'll deal with this later regl, {...settings,dataset}); @@ -92,12 +92,7 @@ function Demo(props:Props) { const img = new ImageData(new Uint8ClampedArray(bytes),screenSize[0],screenSize[1]); ctx!.putImageData(img, 0, 0); } - const client = buildScatterbrainCacheClient( - // @ts-expect-error we'll deal with this later - regl, - cache,()=>{ - requestAnimationFrame(renderOneFrame); - }) + const client = connectToCache(cache,renderOneFrame); renderOneFrame(); } From 9a00456d2605854ef42f09fa69fa6428fbff17e6 Mon Sep 17 00:00:00 2001 From: noah Date: Mon, 9 Feb 2026 09:50:51 -0800 Subject: [PATCH 21/29] replace a placeholder threshold --- packages/scatterbrain/src/dataset.ts | 7 ++++--- packages/scatterbrain/src/renderer.ts | 10 ++++++---- site/src/examples/scatterbrain/demo.tsx | 1 + 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/scatterbrain/src/dataset.ts b/packages/scatterbrain/src/dataset.ts index 75bbd39e..8ec3b9b2 100644 --- a/packages/scatterbrain/src/dataset.ts +++ b/packages/scatterbrain/src/dataset.ts @@ -63,9 +63,10 @@ function getVisibleItemsInTree(dataset: { root: TreeNode, boundingBox: volumeBou return hits; } -export function getVisibleItems(dataset: Dataset, camera: { view: box2D, screenResolution: vec2, layout?: Record }) { +export function getVisibleItems(dataset: Dataset, camera: { view: box2D, screenResolution: vec2, layout?: Record }, visibilitySizeThreshold: number) { + // determine if (dataset.type === 'normal') { - return getVisibleItemsInTree(dataset.metadata, camera, 5); + return getVisibleItemsInTree(dataset.metadata, camera, visibilitySizeThreshold); } // by default, if we pass NO layout info const size: vec2 = [dataset.metadata.spatialUnit.maxX - dataset.metadata.spatialUnit.minX, dataset.metadata.spatialUnit.maxY - dataset.metadata.spatialUnit.minY] @@ -81,7 +82,7 @@ export function getVisibleItems(dataset: Dataset, camera: { view: box2D, screenR const offset = Vec2.mul(grid, size) // offset the camera by the opposite of the offset - hits.push(...getVisibleItemsInTree(slide.tree, { ...camera, view: Box2D.translate(camera.view, offset) }, 5)) + hits.push(...getVisibleItemsInTree(slide.tree, { ...camera, view: Box2D.translate(camera.view, offset) }, visibilitySizeThreshold)) } return hits; diff --git a/packages/scatterbrain/src/renderer.ts b/packages/scatterbrain/src/renderer.ts index 18f4a1d5..0ce1fd4c 100644 --- a/packages/scatterbrain/src/renderer.ts +++ b/packages/scatterbrain/src/renderer.ts @@ -1,7 +1,7 @@ import type { SharedPriorityCache } from '@alleninstitute/vis-core'; import type REGL from 'regl'; import type { ColumnRequest, ScatterbrainDataset, SlideviewScatterbrainDataset, TreeNode } from './types'; -import type { box2D, vec2, vec4 } from '@alleninstitute/vis-geometry'; +import { Box2D, type box2D, type vec2, type vec4 } from '@alleninstitute/vis-geometry'; import { MakeTaggedBufferView } from './typed-array'; import keys from 'lodash/keys' import reduce from 'lodash/reduce' @@ -137,7 +137,7 @@ export function updateCategoricalValue(categories: readonly string[], texture.subimage(data, col, row) } -type ScatterbrainRenderProps = Omit>[0], 'item'> & { dataset: ScatterbrainDataset | SlideviewScatterbrainDataset, client: ReturnType } +type ScatterbrainRenderProps = Omit>[0], 'item'> & { visibilityThresholdPx: number, dataset: ScatterbrainDataset | SlideviewScatterbrainDataset, client: ReturnType } /** * * @param regl a regl context @@ -155,8 +155,10 @@ export function buildRenderFrameFn(regl: REGL.Regl, settings: ShaderSettings) { const drawQtCell = buildScatterbrainRenderCommand(config, regl); const render = (props: ScatterbrainRenderProps) => { - const { camera, dataset, client } = props - const visibleQtNodes = getVisibleItems(dataset, camera).map(prepareQtCell) + const { camera, dataset, client, visibilityThresholdPx } = props + // compute the size of a screen pixel in data-space units + const visibilityThreshold = visibilityThresholdPx * Box2D.size(camera.view)[0] / camera.screenResolution[0] // (units*pixel)/pixel ==> units + const visibleQtNodes = getVisibleItems(dataset, camera, visibilityThreshold).map(prepareQtCell) client.setPriorities(visibleQtNodes, []) for (const node of visibleQtNodes) { if (client.has(node)) { diff --git a/site/src/examples/scatterbrain/demo.tsx b/site/src/examples/scatterbrain/demo.tsx index 9121631d..bf701f25 100644 --- a/site/src/examples/scatterbrain/demo.tsx +++ b/site/src/examples/scatterbrain/demo.tsx @@ -77,6 +77,7 @@ function Demo(props:Props) { const renderOneFrame = ()=>{ render({ client, + visibilityThresholdPx:10, camera: { view: { minCorner: [-17, -17], maxCorner: [26, 26] }, screenResolution: [800, 800] }, categoricalLookupTable: lookup, dataset, From 07826a931a94d6ed0b02adccf33e9c5d8667ef32 Mon Sep 17 00:00:00 2001 From: noah Date: Tue, 10 Feb 2026 05:26:38 -0800 Subject: [PATCH 22/29] add zod schema for loading dataset metadata --- packages/scatterbrain/package.json | 3 +- packages/scatterbrain/src/dataset.ts | 89 +++++++++++++++++++++++++--- packages/scatterbrain/src/types.ts | 2 +- pnpm-lock.yaml | 3 + 4 files changed, 86 insertions(+), 11 deletions(-) diff --git a/packages/scatterbrain/package.json b/packages/scatterbrain/package.json index ffb47b02..523d2eed 100644 --- a/packages/scatterbrain/package.json +++ b/packages/scatterbrain/package.json @@ -54,7 +54,8 @@ "@alleninstitute/vis-geometry": "workspace:*", "lodash": "4.17.21", "regl": "2.1.0", - "ts-pattern": "5.9.0" + "ts-pattern": "5.9.0", + "zod": "4.3.6" }, "publishConfig": { "registry": "https://npm.pkg.github.com/AllenInstitute" diff --git a/packages/scatterbrain/src/dataset.ts b/packages/scatterbrain/src/dataset.ts index 8ec3b9b2..80be448e 100644 --- a/packages/scatterbrain/src/dataset.ts +++ b/packages/scatterbrain/src/dataset.ts @@ -1,6 +1,7 @@ import { Box2D, type box2D, type box3D, Box3D, Vec2, type vec2, type vec3, Vec3, visitBFSMaybe } from "@alleninstitute/vis-geometry"; -import type { ScatterbrainDataset, SlideviewScatterbrainDataset, TreeNode, volumeBound } from "./types"; +import type { PointAttribute, ScatterbrainDataset, SlideviewScatterbrainDataset, TreeNode, volumeBound } from "./types"; import reduce from "lodash/reduce"; +import * as z from "zod"; type Dataset = ScatterbrainDataset | SlideviewScatterbrainDataset // figure out that path through the tree, given a TreeNode name @@ -87,16 +88,86 @@ export function getVisibleItems(dataset: Dataset, camera: { view: box2D, screenR return hits; } - -export function loadDataset(raw: any): Dataset | undefined { - // index point attrs by name - its an array - // TODO zod validation first! - if (raw['pointAttributes']) { - raw = { ...raw, pointAttributes: reduce(raw.pointAttributes as { name: string }[], (acc, attr) => ({ ...acc, [attr.name]: attr }), {}) } +const pointAttrSchema = z.object({ + name: z.string(), + size: z.number(), + elements: z.number(), // values per point (so a vector xy would have 2) + elementSize: z.number(), // size of an element, given in bytes (for example float would have 4) + type: z.union([ + z.literal('uint8'), + z.literal('uint16'), + z.literal('uint32'), + z.literal('int8'), + z.literal('int16'), + z.literal('int32'), + z.literal('float'), + ]), + description: z.string() +}) +const commonMetadataSchema = z.object({ + geneFileEndpoint: z.string(), + metadataFileEndpoint: z.string(), + visualizationReferenceId: z.string(), + spatialColumn: z.string(), + pointAttributes: z.array(pointAttrSchema).transform((attrs) => { + return reduce>(attrs, (acc, attr) => ({ ...acc, [attr.name]: attr }), {}) + }) +}) +const bbSchema = z.object({ + lx: z.number(), + ly: z.number(), + lz: z.number(), + ux: z.number(), + uy: z.number(), + uz: z.number(), +}) +const treeNodeSchema = z.object({ + file: z.string(), + numSpecimens: z.number(), + get children() { + return z.union([z.undefined(), z.array(treeNodeSchema)]) } +}) +const treeSchema = z.object({ + points: z.number(), + boundingBox: bbSchema, + tightBoundingBox: bbSchema, + root: treeNodeSchema +}) +const scatterbrainMetadataSchema = z.object({ + ...treeSchema.shape, + ...commonMetadataSchema.shape +}) +// commonMetadataSchema.extend(treeSchema) +type wtf = z.infer +const slideSchema = z.object({ + featureTypeValueReferenceId: z.string(), + tree: treeSchema +}) +const spatialRefFrameSchema = z.object({ + anatomicalOrigin: z.string(), + direction: z.string(), + unit: z.string(), + minX: z.number(), + maxX: z.number(), + minY: z.number(), + maxY: z.number(), +}) +const slideviewMetadataSchema = z.object({ + ...commonMetadataSchema.shape, + slides: z.array(slideSchema), + spatialUnit: spatialRefFrameSchema +}) +// const scatterbrainDatasetSchema = z.discriminatedUnion('type', [ +// z.object({ type: z.literal('normal'), metadata: scatterbrainMetadataSchema }), +// z.object({ type: z.literal('slideview'), metadata: slideviewMetadataSchema }), +// ]) +export function loadDataset(raw: any): Dataset | undefined { if (raw['slides']) { - return { type: 'slideview', metadata: raw } + const metadata = slideviewMetadataSchema.safeParse(raw) + return metadata.success ? { type: 'slideview', metadata: metadata.data } : undefined } else { - return { type: 'normal', metadata: raw } + const metadata = scatterbrainMetadataSchema.safeParse(raw) + return metadata.success ? { type: 'normal', metadata: metadata.data } : undefined } } \ No newline at end of file diff --git a/packages/scatterbrain/src/types.ts b/packages/scatterbrain/src/types.ts index 5a7b1ea9..b6aa3893 100644 --- a/packages/scatterbrain/src/types.ts +++ b/packages/scatterbrain/src/types.ts @@ -11,7 +11,7 @@ export type volumeBound = { uy: number; uz: number; }; -type PointAttribute = { +export type PointAttribute = { name: string; size: number; // elements * elementSize - todo ask Peter to remove elements: number; // values per point (so a vector xy would have 2) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d10ceb2c..425b883e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: ts-pattern: specifier: 5.9.0 version: 5.9.0 + zod: + specifier: 4.3.6 + version: 4.3.6 devDependencies: '@types/lodash': specifier: 4.17.23 From 07ba25e6331addefa77bf0dac6a8e8fd960a69d0 Mon Sep 17 00:00:00 2001 From: noah Date: Tue, 10 Feb 2026 05:48:58 -0800 Subject: [PATCH 23/29] a little cleanup --- packages/scatterbrain/src/dataset.ts | 7 +---- packages/scatterbrain/src/shader.test.ts | 35 ++++++++++++++++++------ packages/scatterbrain/src/shader.ts | 1 - 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/packages/scatterbrain/src/dataset.ts b/packages/scatterbrain/src/dataset.ts index 80be448e..73028b34 100644 --- a/packages/scatterbrain/src/dataset.ts +++ b/packages/scatterbrain/src/dataset.ts @@ -138,8 +138,6 @@ const scatterbrainMetadataSchema = z.object({ ...treeSchema.shape, ...commonMetadataSchema.shape }) -// commonMetadataSchema.extend(treeSchema) -type wtf = z.infer const slideSchema = z.object({ featureTypeValueReferenceId: z.string(), tree: treeSchema @@ -158,10 +156,7 @@ const slideviewMetadataSchema = z.object({ slides: z.array(slideSchema), spatialUnit: spatialRefFrameSchema }) -// const scatterbrainDatasetSchema = z.discriminatedUnion('type', [ -// z.object({ type: z.literal('normal'), metadata: scatterbrainMetadataSchema }), -// z.object({ type: z.literal('slideview'), metadata: slideviewMetadataSchema }), -// ]) + export function loadDataset(raw: any): Dataset | undefined { if (raw['slides']) { const metadata = slideviewMetadataSchema.safeParse(raw) diff --git a/packages/scatterbrain/src/shader.test.ts b/packages/scatterbrain/src/shader.test.ts index 784f4f29..d3302bba 100644 --- a/packages/scatterbrain/src/shader.test.ts +++ b/packages/scatterbrain/src/shader.test.ts @@ -420,7 +420,7 @@ describe('configure', () => { categoricalFilters: {}, mode: 'color', colorBy: { kind: 'quantitative', column: '123', gradient: 'viridis', range: { min: 0, max: 10 } }, - quantitativeFilters: {}, + quantitativeFilters: [] }) const shaders = buildShaders(config); @@ -435,6 +435,7 @@ describe('configure', () => { positionColumn: 'position', } const expectedShader = /*glsl*/` + precision highp float; // attribs // attribute vec2 position; @@ -446,6 +447,10 @@ describe('configure', () => { uniform vec4 view; uniform vec2 screenSize; uniform vec2 offset; + uniform vec4 spatialFilterBox; + uniform vec4 filteredOutColor; + uniform float hoveredValue; + uniform sampler2D gradient; uniform sampler2D lookup; // quantitative columns each need a range value - its the min,max in a vec2 @@ -456,35 +461,46 @@ describe('configure', () => { vec4 applyCamera(vec3 dataPos){ vec2 size = view.zw-view.xy; - vec2 unit = (data.xy-view.xy)/size; + vec2 unit = (dataPos.xy-view.xy)/size; return vec4((unit*2.0)-1.0,0.0,1.0); } float rangeParameter(float v, vec2 range){ return (v-range.x)/(range.y-range.x); } + float within(float v, vec2 range){ + return step(range.x,v)*step(v,range.y); + } // per-point interface functions // - float isFilteredIn(){ - return 1.0; - } + float isHovered(){ + return 0.0; } vec3 getDataPosition(){ return vec3(position+offset,0.0); + } + float isFilteredIn(){ + + vec3 p = getDataPosition(); + return within(p.x,spatialFilterBox.xz)*within(p.y,spatialFilterBox.yw) + * 1.0 + * within(COLOR_BY_MEASURE,COLOR_BY_MEASURE_range); + } // the primary per-point functions, called directly // vec4 getClipPosition(){ return applyCamera(getDataPosition()); } float getPointSize(){ - return 2.0; + return mix(2.0,6.0,isHovered()); } vec4 getColor(){ - float p = rangeParameter(COLOR_BY_MEASURE,COLOR_BY_MEASURE_range); - return texture2D(gradient,vec2(p,0.5)); + return mix(filteredOutColor, + texture2D(gradient,vec2(rangeParameter(COLOR_BY_MEASURE,COLOR_BY_MEASURE_range),0.5)) + ,isFilteredIn()); } varying vec4 color; @@ -494,6 +510,9 @@ describe('configure', () => { gl_Position = getClipPosition(); }` expect(config).toEqual(expectedConfig) + console.log('~~~~~~~~~~~~~~~~~~~~~~~~~~~') + console.log(shaders.vs) + console.log('~~~~~~~~~~~~~~~~~~~~~~~~~~~') expect(shaders.vs.replace(/\s/g, "")).toEqual(expectedShader.replace(/\s/g, "")) expect(columnNameToShaderName).toEqual({ '123': 'COLOR_BY_MEASURE', diff --git a/packages/scatterbrain/src/shader.ts b/packages/scatterbrain/src/shader.ts index bdac8ad6..c566f5f4 100644 --- a/packages/scatterbrain/src/shader.ts +++ b/packages/scatterbrain/src/shader.ts @@ -263,7 +263,6 @@ export function generate(config: Config): ScatterbrainShaderUtils { return mix(filteredOutColor,${colorize},isFilteredIn()); ` : (categoryColumnIndex === -1 ? colorByQuantitativeValue : colorByCategoricalId) - console.log(getColor) return { attributes, uniforms, From 1a2dc740be2c3d81e11a13d986f70ee36cf9faae Mon Sep 17 00:00:00 2001 From: noah Date: Wed, 11 Feb 2026 08:59:18 -0800 Subject: [PATCH 24/29] Update site/src/examples/scatterbrain/demo.tsx Co-authored-by: Lane Sawyer --- site/src/examples/scatterbrain/demo.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/examples/scatterbrain/demo.tsx b/site/src/examples/scatterbrain/demo.tsx index bf701f25..aae624a7 100644 --- a/site/src/examples/scatterbrain/demo.tsx +++ b/site/src/examples/scatterbrain/demo.tsx @@ -8,7 +8,7 @@ const tenx = 'https://bkp-2d-visualizations-stage.s3.amazonaws.com/wmb_tenx_0117 export function ScatterBrainDemo() { return ( - + ); } From 4785102bd24d4453ab4bfc23ba5775a1e3d92527 Mon Sep 17 00:00:00 2001 From: noah Date: Wed, 11 Feb 2026 10:10:28 -0800 Subject: [PATCH 25/29] minor changes --- packages/geometry/src/spatialIndexing/tree.ts | 14 +++----------- site/src/content/docs/examples/scatterbrain.mdx | 4 ++-- site/src/examples/scatterbrain/demo.tsx | 4 ++-- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/packages/geometry/src/spatialIndexing/tree.ts b/packages/geometry/src/spatialIndexing/tree.ts index 757d0208..d6950e90 100644 --- a/packages/geometry/src/spatialIndexing/tree.ts +++ b/packages/geometry/src/spatialIndexing/tree.ts @@ -14,11 +14,7 @@ export function visitBFS( ): void { const frontier: Tree[] = [tree]; while (frontier.length > 0) { - const cur = frontier.shift(); - if (cur === undefined) { - // this case is pretty clearly dead-code - but ts cant tell - continue; - } + const cur = frontier.shift()!; visitor(cur); for (const c of children(cur)) { if (traversalPredicate?.(c) ?? true) { @@ -36,12 +32,8 @@ export function visitBFSMaybe( ): void { const frontier: Tree[] = [tree]; while (frontier.length > 0) { - const cur = frontier.shift(); - if (cur === undefined) { - // TODO: Consider logging a warning or error here, as this should never happen, - // but this package doesn't depend on the package where our logger lives - continue; - } + const cur = frontier.shift()!; + if (visitor(cur)) { for (const c of children(cur)) { frontier.push(c); diff --git a/site/src/content/docs/examples/scatterbrain.mdx b/site/src/content/docs/examples/scatterbrain.mdx index 34a09de5..538cea8b 100644 --- a/site/src/content/docs/examples/scatterbrain.mdx +++ b/site/src/content/docs/examples/scatterbrain.mdx @@ -3,6 +3,6 @@ title: Scatterbrain tableOfContents: false --- -import { ScatterBrainDemo } from '../../../examples/Scatterbrain/demo.tsx'; +import { scatterBrainDemo } from '../../../examples/Scatterbrain/demo.tsx'; - + diff --git a/site/src/examples/scatterbrain/demo.tsx b/site/src/examples/scatterbrain/demo.tsx index bf701f25..3274f2c4 100644 --- a/site/src/examples/scatterbrain/demo.tsx +++ b/site/src/examples/scatterbrain/demo.tsx @@ -1,11 +1,11 @@ import type { vec2, vec4 } from '@alleninstitute/vis-geometry'; import { SharedCacheContext, SharedCacheProvider } from '../common/react/priority-cache-provider'; import { useContext, useEffect, useRef, useState } from 'react'; -import { buildScatterbrainRenderFn, buildScatterbrainCacheClient, loadScatterbrainDataset, setCategoricalLookupTableValues, type Dataset, type ShaderSettings } from '@alleninstitute/vis-scatterbrain'; +import { buildScatterbrainRenderFn, loadScatterbrainDataset, setCategoricalLookupTableValues, type Dataset, type ShaderSettings } from '@alleninstitute/vis-scatterbrain'; const screenSize: vec2 = [800, 800]; const tenx = 'https://bkp-2d-visualizations-stage.s3.amazonaws.com/wmb_tenx_01172024_stage-20240128193624/G4I4GFJXJB9ATZ3PTX1/ScatterBrain.json' -export function ScatterBrainDemo() { +export function scatterBrainDemo() { return ( From cb5a519a654ffeeb78219b50fbeca3ff128708e7 Mon Sep 17 00:00:00 2001 From: noah Date: Wed, 11 Feb 2026 10:11:08 -0800 Subject: [PATCH 26/29] format --- packages/geometry/package.json | 2 +- packages/geometry/src/spatialIndexing/tree.ts | 1 - packages/scatterbrain/package.json | 2 +- packages/scatterbrain/src/dataset.ts | 128 ++-- packages/scatterbrain/src/index.ts | 11 +- packages/scatterbrain/src/renderer.ts | 201 +++--- packages/scatterbrain/src/shader.test.ts | 631 +++++++++--------- packages/scatterbrain/src/shader.ts | 273 +++++--- packages/scatterbrain/src/typed-array.ts | 3 +- packages/scatterbrain/src/types.ts | 9 +- site/package.json | 2 +- site/src/examples/scatterbrain/demo.tsx | 121 ++-- 12 files changed, 761 insertions(+), 623 deletions(-) diff --git a/packages/geometry/package.json b/packages/geometry/package.json index 48a640ef..9a843928 100644 --- a/packages/geometry/package.json +++ b/packages/geometry/package.json @@ -52,4 +52,4 @@ "registry": "https://npm.pkg.github.com/AllenInstitute" }, "packageManager": "pnpm@9.14.2" -} \ No newline at end of file +} diff --git a/packages/geometry/src/spatialIndexing/tree.ts b/packages/geometry/src/spatialIndexing/tree.ts index d6950e90..1a2edac8 100644 --- a/packages/geometry/src/spatialIndexing/tree.ts +++ b/packages/geometry/src/spatialIndexing/tree.ts @@ -39,6 +39,5 @@ export function visitBFSMaybe( frontier.push(c); } } - } } diff --git a/packages/scatterbrain/package.json b/packages/scatterbrain/package.json index 523d2eed..7568f60c 100644 --- a/packages/scatterbrain/package.json +++ b/packages/scatterbrain/package.json @@ -64,4 +64,4 @@ "devDependencies": { "@types/lodash": "4.17.23" } -} \ No newline at end of file +} diff --git a/packages/scatterbrain/src/dataset.ts b/packages/scatterbrain/src/dataset.ts index 73028b34..2ce7bb46 100644 --- a/packages/scatterbrain/src/dataset.ts +++ b/packages/scatterbrain/src/dataset.ts @@ -1,12 +1,22 @@ -import { Box2D, type box2D, type box3D, Box3D, Vec2, type vec2, type vec3, Vec3, visitBFSMaybe } from "@alleninstitute/vis-geometry"; -import type { PointAttribute, ScatterbrainDataset, SlideviewScatterbrainDataset, TreeNode, volumeBound } from "./types"; -import reduce from "lodash/reduce"; -import * as z from "zod"; +import { + Box2D, + type box2D, + type box3D, + Box3D, + Vec2, + type vec2, + type vec3, + Vec3, + visitBFSMaybe, +} from '@alleninstitute/vis-geometry'; +import type { PointAttribute, ScatterbrainDataset, SlideviewScatterbrainDataset, TreeNode, volumeBound } from './types'; +import reduce from 'lodash/reduce'; +import * as z from 'zod'; -type Dataset = ScatterbrainDataset | SlideviewScatterbrainDataset +type Dataset = ScatterbrainDataset | SlideviewScatterbrainDataset; // figure out that path through the tree, given a TreeNode name // these names are structured data - so it should always be possible -export type NodeWithBounds = { node: TreeNode, bounds: box2D } +export type NodeWithBounds = { node: TreeNode; bounds: box2D }; // adapted from Potree createChildAABB // note that if you do not do indexing in precisely the same order @@ -26,14 +36,14 @@ function getChildBoundsUsingPotreeIndexing(parentBounds: box3D, index: number) { }; } function children(node: TreeNode) { - return node.children ?? [] + return node.children ?? []; } function sanitizeName(fileName: string) { return fileName.replace('.bin', ''); } function bounds(rootBounds: box3D, path: string) { // path is a name like r01373 - indicating a path through the tree, each character is a child index - let bounds = rootBounds + let bounds = rootBounds; for (let i = 1; i < path.length; i++) { const ci = Number(path[i]); bounds = getChildBoundsUsingPotreeIndexing(bounds, ci); @@ -47,46 +57,64 @@ function dropZ(box: box3D) { }; } - -function getVisibleItemsInTree(dataset: { root: TreeNode, boundingBox: volumeBound }, camera: { view: box2D, screenResolution: vec2 }, limit: number) { - const { root, boundingBox } = dataset - const hits: { node: TreeNode, bounds: box2D }[] = [] - const rootBounds = Box3D.create([boundingBox.lx, boundingBox.ly, boundingBox.lz], [boundingBox.ux, boundingBox.uy, boundingBox.uz]); +function getVisibleItemsInTree( + dataset: { root: TreeNode; boundingBox: volumeBound }, + camera: { view: box2D; screenResolution: vec2 }, + limit: number, +) { + const { root, boundingBox } = dataset; + const hits: { node: TreeNode; bounds: box2D }[] = []; + const rootBounds = Box3D.create( + [boundingBox.lx, boundingBox.ly, boundingBox.lz], + [boundingBox.ux, boundingBox.uy, boundingBox.uz], + ); visitBFSMaybe(root, children, (t) => { - const B = dropZ(bounds(rootBounds, sanitizeName(t.file))) + const B = dropZ(bounds(rootBounds, sanitizeName(t.file))); if (Box2D.intersection(B, camera.view) && Box2D.size(B)[0] > limit) { // this node is big enough to render - that means we should check its children as well - hits.push({ node: t, bounds: B }) + hits.push({ node: t, bounds: B }); return true; } return false; - }) + }); return hits; } -export function getVisibleItems(dataset: Dataset, camera: { view: box2D, screenResolution: vec2, layout?: Record }, visibilitySizeThreshold: number) { - // determine +export function getVisibleItems( + dataset: Dataset, + camera: { view: box2D; screenResolution: vec2; layout?: Record }, + visibilitySizeThreshold: number, +) { + // determine if (dataset.type === 'normal') { return getVisibleItemsInTree(dataset.metadata, camera, visibilitySizeThreshold); } // by default, if we pass NO layout info - const size: vec2 = [dataset.metadata.spatialUnit.maxX - dataset.metadata.spatialUnit.minX, dataset.metadata.spatialUnit.maxY - dataset.metadata.spatialUnit.minY] + const size: vec2 = [ + dataset.metadata.spatialUnit.maxX - dataset.metadata.spatialUnit.minX, + dataset.metadata.spatialUnit.maxY - dataset.metadata.spatialUnit.minY, + ]; // then it means we want to draw all the slides on top of each other - const defaultVisibility = camera.layout === undefined - const hits: NodeWithBounds[] = [] + const defaultVisibility = camera.layout === undefined; + const hits: NodeWithBounds[] = []; for (const slide of dataset.metadata.slides) { if (!defaultVisibility && camera.layout?.[slide.featureTypeValueReferenceId] === undefined) { // the camera has a layout, but this slide isn't in it - dont draw it continue; } - const grid = camera.layout?.[slide.featureTypeValueReferenceId] ?? [0, 0] + const grid = camera.layout?.[slide.featureTypeValueReferenceId] ?? [0, 0]; - const offset = Vec2.mul(grid, size) + const offset = Vec2.mul(grid, size); // offset the camera by the opposite of the offset - hits.push(...getVisibleItemsInTree(slide.tree, { ...camera, view: Box2D.translate(camera.view, offset) }, visibilitySizeThreshold)) + hits.push( + ...getVisibleItemsInTree( + slide.tree, + { ...camera, view: Box2D.translate(camera.view, offset) }, + visibilitySizeThreshold, + ), + ); } return hits; - } const pointAttrSchema = z.object({ name: z.string(), @@ -102,17 +130,21 @@ const pointAttrSchema = z.object({ z.literal('int32'), z.literal('float'), ]), - description: z.string() -}) + description: z.string(), +}); const commonMetadataSchema = z.object({ geneFileEndpoint: z.string(), metadataFileEndpoint: z.string(), visualizationReferenceId: z.string(), spatialColumn: z.string(), pointAttributes: z.array(pointAttrSchema).transform((attrs) => { - return reduce>(attrs, (acc, attr) => ({ ...acc, [attr.name]: attr }), {}) - }) -}) + return reduce>( + attrs, + (acc, attr) => ({ ...acc, [attr.name]: attr }), + {}, + ); + }), +}); const bbSchema = z.object({ lx: z.number(), ly: z.number(), @@ -120,28 +152,28 @@ const bbSchema = z.object({ ux: z.number(), uy: z.number(), uz: z.number(), -}) +}); const treeNodeSchema = z.object({ file: z.string(), numSpecimens: z.number(), get children() { - return z.union([z.undefined(), z.array(treeNodeSchema)]) - } -}) + return z.union([z.undefined(), z.array(treeNodeSchema)]); + }, +}); const treeSchema = z.object({ points: z.number(), boundingBox: bbSchema, tightBoundingBox: bbSchema, - root: treeNodeSchema -}) + root: treeNodeSchema, +}); const scatterbrainMetadataSchema = z.object({ ...treeSchema.shape, - ...commonMetadataSchema.shape -}) + ...commonMetadataSchema.shape, +}); const slideSchema = z.object({ featureTypeValueReferenceId: z.string(), - tree: treeSchema -}) + tree: treeSchema, +}); const spatialRefFrameSchema = z.object({ anatomicalOrigin: z.string(), direction: z.string(), @@ -150,19 +182,19 @@ const spatialRefFrameSchema = z.object({ maxX: z.number(), minY: z.number(), maxY: z.number(), -}) +}); const slideviewMetadataSchema = z.object({ ...commonMetadataSchema.shape, slides: z.array(slideSchema), - spatialUnit: spatialRefFrameSchema -}) + spatialUnit: spatialRefFrameSchema, +}); export function loadDataset(raw: any): Dataset | undefined { if (raw['slides']) { - const metadata = slideviewMetadataSchema.safeParse(raw) - return metadata.success ? { type: 'slideview', metadata: metadata.data } : undefined + const metadata = slideviewMetadataSchema.safeParse(raw); + return metadata.success ? { type: 'slideview', metadata: metadata.data } : undefined; } else { - const metadata = scatterbrainMetadataSchema.safeParse(raw) - return metadata.success ? { type: 'normal', metadata: metadata.data } : undefined + const metadata = scatterbrainMetadataSchema.safeParse(raw); + return metadata.success ? { type: 'normal', metadata: metadata.data } : undefined; } -} \ No newline at end of file +} diff --git a/packages/scatterbrain/src/index.ts b/packages/scatterbrain/src/index.ts index 1f6dbf61..bcc39ea3 100644 --- a/packages/scatterbrain/src/index.ts +++ b/packages/scatterbrain/src/index.ts @@ -1,3 +1,8 @@ -export { buildRenderFrameFn as buildScatterbrainRenderFn, buildScatterbrainCacheClient, setCategoricalLookupTableValues, updateCategoricalValue } from "./renderer"; -export * from './types' -export { getVisibleItems, loadDataset as loadScatterbrainDataset } from './dataset' \ No newline at end of file +export { + buildRenderFrameFn as buildScatterbrainRenderFn, + buildScatterbrainCacheClient, + setCategoricalLookupTableValues, + updateCategoricalValue, +} from './renderer'; +export * from './types'; +export { getVisibleItems, loadDataset as loadScatterbrainDataset } from './dataset'; diff --git a/packages/scatterbrain/src/renderer.ts b/packages/scatterbrain/src/renderer.ts index 0ce1fd4c..22256106 100644 --- a/packages/scatterbrain/src/renderer.ts +++ b/packages/scatterbrain/src/renderer.ts @@ -3,46 +3,69 @@ import type REGL from 'regl'; import type { ColumnRequest, ScatterbrainDataset, SlideviewScatterbrainDataset, TreeNode } from './types'; import { Box2D, type box2D, type vec2, type vec4 } from '@alleninstitute/vis-geometry'; import { MakeTaggedBufferView } from './typed-array'; -import keys from 'lodash/keys' -import reduce from 'lodash/reduce' +import keys from 'lodash/keys'; +import reduce from 'lodash/reduce'; import { getVisibleItems, type NodeWithBounds } from './dataset'; import { buildScatterbrainRenderCommand, type Config, configureShader, type ShaderSettings, VBO } from './shader'; export type Item = Readonly<{ - dataset: SlideviewScatterbrainDataset | ScatterbrainDataset - node: TreeNode - bounds: box2D - columns: Record -}> -type Content = Record + dataset: SlideviewScatterbrainDataset | ScatterbrainDataset; + node: TreeNode; + bounds: box2D; + columns: Record; +}>; +type Content = Record; - -export function buildScatterbrainCacheClient(allNeededColumns: readonly string[], regl: REGL.Regl, cache: SharedPriorityCache, onDataArrived: () => void) { +export function buildScatterbrainCacheClient( + allNeededColumns: readonly string[], + regl: REGL.Regl, + cache: SharedPriorityCache, + onDataArrived: () => void, +) { const client = cache.registerClient({ cacheKeys: (item) => { const { dataset, node, columns } = item; - return reduce, Record>(columns, (acc, col, key) => ({ ...acc, [key]: `${dataset.metadata.metadataFileEndpoint}/${node.file}/${col.name}` }), {}) + return reduce, Record>( + columns, + (acc, col, key) => ({ + ...acc, + [key]: `${dataset.metadata.metadataFileEndpoint}/${node.file}/${col.name}`, + }), + {}, + ); }, fetch: (item) => { const { dataset, node, columns } = item; const attrs = dataset.metadata.pointAttributes; - const getColumnUrl = (columnName: string) => `${dataset.metadata.metadataFileEndpoint}${columnName}/${dataset.metadata.visualizationReferenceId}/${node.file}`; - const getGeneUrl = (columnName: string) => `${dataset.metadata.geneFileEndpoint}${columnName}/${dataset.metadata.visualizationReferenceId}/${node.file}`; + const getColumnUrl = (columnName: string) => + `${dataset.metadata.metadataFileEndpoint}${columnName}/${dataset.metadata.visualizationReferenceId}/${node.file}`; + const getGeneUrl = (columnName: string) => + `${dataset.metadata.geneFileEndpoint}${columnName}/${dataset.metadata.visualizationReferenceId}/${node.file}`; const getColumnInfo = (col: ColumnRequest) => - col.type === 'QUANTITATIVE' ? - { url: getGeneUrl(col.name), elements: 1, type: 'float' } as const - : - { url: getColumnUrl(col.name), elements: attrs[col.name].elements, type: attrs[col.name].type } + col.type === 'QUANTITATIVE' + ? ({ url: getGeneUrl(col.name), elements: 1, type: 'float' } as const) + : { url: getColumnUrl(col.name), elements: attrs[col.name].elements, type: attrs[col.name].type }; - const proms = reduce, Record Promise>>(columns, (getters, col, key) => { - const { url, type } = getColumnInfo(col) - return { - ...getters, - [key]: (signal) => fetch(url, { signal }).then(b => b.arrayBuffer().then(buff => { - const typed = MakeTaggedBufferView(type, buff) - return new VBO({ buffer: regl.buffer({ type: type, data: typed.data }), bytes: buff.byteLength, type: 'buffer' }) - })) - } - }, {}) + const proms = reduce, Record Promise>>( + columns, + (getters, col, key) => { + const { url, type } = getColumnInfo(col); + return { + ...getters, + [key]: (signal) => + fetch(url, { signal }).then((b) => + b.arrayBuffer().then((buff) => { + const typed = MakeTaggedBufferView(type, buff); + return new VBO({ + buffer: regl.buffer({ type: type, data: typed.data }), + bytes: buff.byteLength, + type: 'buffer', + }); + }), + ), + }; + }, + {}, + ); return proms; }, isValue: (v): v is Content => { @@ -53,25 +76,33 @@ export function buildScatterbrainCacheClient(allNeededColumns: readonly string[] } return true; }, - onDataArrived - }) + onDataArrived, + }); return client; } -function columnsForItem(config: Config, col2shader: Record, dataset: ScatterbrainDataset | SlideviewScatterbrainDataset) { - const columns: Record = {} - const s2c = reduce(keys(col2shader), (acc, col) => ({ ...acc, [col2shader[col]]: col }), {} as Record) +function columnsForItem( + config: Config, + col2shader: Record, + dataset: ScatterbrainDataset | SlideviewScatterbrainDataset, +) { + const columns: Record = {}; + const s2c = reduce( + keys(col2shader), + (acc, col) => ({ ...acc, [col2shader[col]]: col }), + {} as Record, + ); for (const c of config.categoricalColumns) { - columns[c] = { type: 'METADATA', name: s2c[c] } + columns[c] = { type: 'METADATA', name: s2c[c] }; } for (const m of config.quantitativeColumns) { - columns[m] = { type: 'QUANTITATIVE', name: s2c[m] } - } - columns[config.positionColumn] = { type: 'METADATA', name: dataset.metadata.spatialColumn } - return (item: T,) => { - return { ...item, dataset, columns } + columns[m] = { type: 'QUANTITATIVE', name: s2c[m] }; } + columns[config.positionColumn] = { type: 'METADATA', name: dataset.metadata.spatialColumn }; + return (item: T) => { + return { ...item, dataset, columns }; + }; } /** @@ -79,31 +110,32 @@ function columnsForItem(config: Config, col2shader: Record>, - texture: REGL.Texture2D +export function setCategoricalLookupTableValues( + categories: Record>, + texture: REGL.Texture2D, ) { - const categoryKeys = keys(categories).toSorted() + const categoryKeys = keys(categories).toSorted(); const columns = categoryKeys.length; const rows = reduce(categoryKeys, (highest, category) => Math.max(highest, keys(categories[category]).length), 1); const data = new Uint8Array(columns * rows * 4); - let rgbf = [0, 0, 0, 0] + let rgbf = [0, 0, 0, 0]; const empty = [0, 0, 0, 0] as const; // write the rgb of the color, and encode the filter boolean into the alpha channel for (let columnIndex = 0; columnIndex < columns; columnIndex += 1) { - const category = categories[categoryKeys[columnIndex]] + const category = categories[categoryKeys[columnIndex]]; const nRows = keys(category).length; for (let rowIndex = 0; rowIndex < nRows; rowIndex += 1) { - const color = category[rowIndex]?.color ?? empty + const color = category[rowIndex]?.color ?? empty; const filtered = category[rowIndex]?.filteredIn ?? false; - rgbf[0] = color[0] * 255 - rgbf[1] = color[1] * 255 - rgbf[2] = color[2] * 255 - rgbf[3] = filtered ? 255 : 0 - data.set(rgbf, (rowIndex * columns * 4) + columnIndex * 4) + rgbf[0] = color[0] * 255; + rgbf[1] = color[1] * 255; + rgbf[2] = color[2] * 255; + rgbf[3] = filtered ? 255 : 0; + data.set(rgbf, rowIndex * columns * 4 + columnIndex * 4); } } // calling a texture as a function is REGL shorthand for total re-init of this texture, capable of resizing if needed @@ -114,40 +146,46 @@ export function setCategoricalLookupTableValues(categories: Record>[0], 'item'> & { visibilityThresholdPx: number, dataset: ScatterbrainDataset | SlideviewScatterbrainDataset, client: ReturnType } +type ScatterbrainRenderProps = Omit>[0], 'item'> & { + visibilityThresholdPx: number; + dataset: ScatterbrainDataset | SlideviewScatterbrainDataset; + client: ReturnType; +}; /** - * + * * @param regl a regl context * @param settings settings describing the data and how it should be rendered - * @returns a pair of functions: + * @returns a pair of functions: * render: when called with renderable data, will determine the set of visible data, request that data from the client, and then draw all currently available data * connectToCache - called to produce a cacheClient, which must be passed to the render function */ export function buildRenderFrameFn(regl: REGL.Regl, settings: ShaderSettings) { - const { dataset } = settings; const { config, columnNameToShaderName } = configureShader(settings); @@ -155,14 +193,14 @@ export function buildRenderFrameFn(regl: REGL.Regl, settings: ShaderSettings) { const drawQtCell = buildScatterbrainRenderCommand(config, regl); const render = (props: ScatterbrainRenderProps) => { - const { camera, dataset, client, visibilityThresholdPx } = props + const { camera, dataset, client, visibilityThresholdPx } = props; // compute the size of a screen pixel in data-space units - const visibilityThreshold = visibilityThresholdPx * Box2D.size(camera.view)[0] / camera.screenResolution[0] // (units*pixel)/pixel ==> units - const visibleQtNodes = getVisibleItems(dataset, camera, visibilityThreshold).map(prepareQtCell) - client.setPriorities(visibleQtNodes, []) + const visibilityThreshold = (visibilityThresholdPx * Box2D.size(camera.view)[0]) / camera.screenResolution[0]; // (units*pixel)/pixel ==> units + const visibleQtNodes = getVisibleItems(dataset, camera, visibilityThreshold).map(prepareQtCell); + client.setPriorities(visibleQtNodes, []); for (const node of visibleQtNodes) { if (client.has(node)) { - const drawable = client.get(node) + const drawable = client.get(node); if (drawable) { drawQtCell({ ...props, @@ -170,16 +208,15 @@ export function buildRenderFrameFn(regl: REGL.Regl, settings: ShaderSettings) { columnData: drawable, count: node.node.numSpecimens, }, - }) + }); } } } - } + }; const connectToCache = (cache: SharedPriorityCache, onDataArrived: () => void) => { - const allColumns = [...config.categoricalColumns, ...config.quantitativeColumns, config.positionColumn] - const client = buildScatterbrainCacheClient(allColumns, regl, cache, onDataArrived) + const allColumns = [...config.categoricalColumns, ...config.quantitativeColumns, config.positionColumn]; + const client = buildScatterbrainCacheClient(allColumns, regl, cache, onDataArrived); return client; - } - return { render, connectToCache } + }; + return { render, connectToCache }; } - diff --git a/packages/scatterbrain/src/shader.test.ts b/packages/scatterbrain/src/shader.test.ts index d3302bba..9fa6d578 100644 --- a/packages/scatterbrain/src/shader.test.ts +++ b/packages/scatterbrain/src/shader.test.ts @@ -2,417 +2,418 @@ import { describe, expect, test } from 'vitest'; import { buildShaders, type Config, configureShader } from './shader'; import type { ScatterbrainDataset } from './types'; - const tenx: ScatterbrainDataset = { - "type": "normal", - "metadata": { - "points": 1494801, - "boundingBox": { - "lx": -11.541528999999999, - "ly": -13.448518, - "lz": 0, - "ux": 24.993372, - "uy": 23.086382999999998, - "uz": 36.534901 + type: 'normal', + metadata: { + points: 1494801, + boundingBox: { + lx: -11.541528999999999, + ly: -13.448518, + lz: 0, + ux: 24.993372, + uy: 23.086382999999998, + uz: 36.534901, }, - "tightBoundingBox": { - "lx": -11.541528999999999, - "ly": -13.448518, - "lz": 0, - "ux": 24.993372, - "uy": 22.9617, - "uz": 0 + tightBoundingBox: { + lx: -11.541528999999999, + ly: -13.448518, + lz: 0, + ux: 24.993372, + uy: 22.9617, + uz: 0, }, - "root": { - "file": "r.bin", - "numSpecimens": 100496, - "children": [ + root: { + file: 'r.bin', + numSpecimens: 100496, + children: [ { - "file": "r0.bin", - "numSpecimens": 33084, - "children": [ + file: 'r0.bin', + numSpecimens: 33084, + children: [ { - "file": "r00.bin", - "numSpecimens": 1004, - "children": [] + file: 'r00.bin', + numSpecimens: 1004, + children: [], }, { - "file": "r02.bin", - "numSpecimens": 8959, - "children": [] + file: 'r02.bin', + numSpecimens: 8959, + children: [], }, { - "file": "r04.bin", - "numSpecimens": 18257, - "children": [] + file: 'r04.bin', + numSpecimens: 18257, + children: [], }, { - "file": "r06.bin", - "numSpecimens": 21375, - "children": [ + file: 'r06.bin', + numSpecimens: 21375, + children: [ { - "file": "r060.bin", - "numSpecimens": 1150, - "children": [] + file: 'r060.bin', + numSpecimens: 1150, + children: [], }, { - "file": "r062.bin", - "numSpecimens": 818, - "children": [] + file: 'r062.bin', + numSpecimens: 818, + children: [], }, { - "file": "r064.bin", - "numSpecimens": 65649, - "children": [] + file: 'r064.bin', + numSpecimens: 65649, + children: [], }, { - "file": "r066.bin", - "numSpecimens": 5242, - "children": [] - } - ] - } - ] + file: 'r066.bin', + numSpecimens: 5242, + children: [], + }, + ], + }, + ], }, { - "file": "r2.bin", - "numSpecimens": 83987, - "children": [ + file: 'r2.bin', + numSpecimens: 83987, + children: [ { - "file": "r20.bin", - "numSpecimens": 41058, - "children": [ + file: 'r20.bin', + numSpecimens: 41058, + children: [ { - "file": "r200.bin", - "numSpecimens": 10658, - "children": [] + file: 'r200.bin', + numSpecimens: 10658, + children: [], }, { - "file": "r202.bin", - "numSpecimens": 37462, - "children": [] + file: 'r202.bin', + numSpecimens: 37462, + children: [], }, { - "file": "r204.bin", - "numSpecimens": 4385, - "children": [] + file: 'r204.bin', + numSpecimens: 4385, + children: [], }, { - "file": "r206.bin", - "numSpecimens": 23558, - "children": [] - } - ] + file: 'r206.bin', + numSpecimens: 23558, + children: [], + }, + ], }, { - "file": "r22.bin", - "numSpecimens": 19404, - "children": [ + file: 'r22.bin', + numSpecimens: 19404, + children: [ { - "file": "r220.bin", - "numSpecimens": 33089, - "children": [] + file: 'r220.bin', + numSpecimens: 33089, + children: [], }, { - "file": "r224.bin", - "numSpecimens": 33541, - "children": [] + file: 'r224.bin', + numSpecimens: 33541, + children: [], }, { - "file": "r226.bin", - "numSpecimens": 303, - "children": [] - } - ] + file: 'r226.bin', + numSpecimens: 303, + children: [], + }, + ], }, { - "file": "r24.bin", - "numSpecimens": 69249, - "children": [ + file: 'r24.bin', + numSpecimens: 69249, + children: [ { - "file": "r240.bin", - "numSpecimens": 25571, - "children": [] + file: 'r240.bin', + numSpecimens: 25571, + children: [], }, { - "file": "r242.bin", - "numSpecimens": 32062, - "children": [] + file: 'r242.bin', + numSpecimens: 32062, + children: [], }, { - "file": "r244.bin", - "numSpecimens": 40682, - "children": [] + file: 'r244.bin', + numSpecimens: 40682, + children: [], }, { - "file": "r246.bin", - "numSpecimens": 60049, - "children": [ + file: 'r246.bin', + numSpecimens: 60049, + children: [ { - "file": "r2460.bin", - "numSpecimens": 49708, - "children": [] + file: 'r2460.bin', + numSpecimens: 49708, + children: [], }, { - "file": "r2462.bin", - "numSpecimens": 12312, - "children": [] + file: 'r2462.bin', + numSpecimens: 12312, + children: [], }, { - "file": "r2464.bin", - "numSpecimens": 8059, - "children": [] + file: 'r2464.bin', + numSpecimens: 8059, + children: [], }, { - "file": "r2466.bin", - "numSpecimens": 3863, - "children": [] - } - ] - } - ] + file: 'r2466.bin', + numSpecimens: 3863, + children: [], + }, + ], + }, + ], }, { - "file": "r26.bin", - "numSpecimens": 40202, - "children": [ + file: 'r26.bin', + numSpecimens: 40202, + children: [ { - "file": "r260.bin", - "numSpecimens": 18073, - "children": [] + file: 'r260.bin', + numSpecimens: 18073, + children: [], }, { - "file": "r262.bin", - "numSpecimens": 6172, - "children": [] + file: 'r262.bin', + numSpecimens: 6172, + children: [], }, { - "file": "r264.bin", - "numSpecimens": 25204, - "children": [] + file: 'r264.bin', + numSpecimens: 25204, + children: [], }, { - "file": "r266.bin", - "numSpecimens": 5508, - "children": [] - } - ] - } - ] + file: 'r266.bin', + numSpecimens: 5508, + children: [], + }, + ], + }, + ], }, { - "file": "r4.bin", - "numSpecimens": 41387, - "children": [ + file: 'r4.bin', + numSpecimens: 41387, + children: [ { - "file": "r40.bin", - "numSpecimens": 9924, - "children": [] + file: 'r40.bin', + numSpecimens: 9924, + children: [], }, { - "file": "r42.bin", - "numSpecimens": 40161, - "children": [] + file: 'r42.bin', + numSpecimens: 40161, + children: [], }, { - "file": "r44.bin", - "numSpecimens": 1883, - "children": [] + file: 'r44.bin', + numSpecimens: 1883, + children: [], }, { - "file": "r46.bin", - "numSpecimens": 33158, - "children": [] - } - ] + file: 'r46.bin', + numSpecimens: 33158, + children: [], + }, + ], }, { - "file": "r6.bin", - "numSpecimens": 74145, - "children": [ + file: 'r6.bin', + numSpecimens: 74145, + children: [ { - "file": "r60.bin", - "numSpecimens": 53224, - "children": [ + file: 'r60.bin', + numSpecimens: 53224, + children: [ { - "file": "r600.bin", - "numSpecimens": 41323, - "children": [ + file: 'r600.bin', + numSpecimens: 41323, + children: [ { - "file": "r6000.bin", - "numSpecimens": 1895, - "children": [] + file: 'r6000.bin', + numSpecimens: 1895, + children: [], }, { - "file": "r6002.bin", - "numSpecimens": 24075, - "children": [] + file: 'r6002.bin', + numSpecimens: 24075, + children: [], }, { - "file": "r6004.bin", - "numSpecimens": 1609, - "children": [] + file: 'r6004.bin', + numSpecimens: 1609, + children: [], }, { - "file": "r6006.bin", - "numSpecimens": 18091, - "children": [] - } - ] + file: 'r6006.bin', + numSpecimens: 18091, + children: [], + }, + ], }, { - "file": "r602.bin", - "numSpecimens": 30526, - "children": [] + file: 'r602.bin', + numSpecimens: 30526, + children: [], }, { - "file": "r604.bin", - "numSpecimens": 4394, - "children": [] + file: 'r604.bin', + numSpecimens: 4394, + children: [], }, { - "file": "r606.bin", - "numSpecimens": 21398, - "children": [] - } - ] + file: 'r606.bin', + numSpecimens: 21398, + children: [], + }, + ], }, { - "file": "r62.bin", - "numSpecimens": 46391, - "children": [ + file: 'r62.bin', + numSpecimens: 46391, + children: [ { - "file": "r620.bin", - "numSpecimens": 9355, - "children": [] + file: 'r620.bin', + numSpecimens: 9355, + children: [], }, { - "file": "r622.bin", - "numSpecimens": 20763, - "children": [] + file: 'r622.bin', + numSpecimens: 20763, + children: [], }, { - "file": "r624.bin", - "numSpecimens": 7436, - "children": [] + file: 'r624.bin', + numSpecimens: 7436, + children: [], }, { - "file": "r626.bin", - "numSpecimens": 11283, - "children": [] - } - ] + file: 'r626.bin', + numSpecimens: 11283, + children: [], + }, + ], }, { - "file": "r64.bin", - "numSpecimens": 42149, - "children": [] + file: 'r64.bin', + numSpecimens: 42149, + children: [], }, { - "file": "r66.bin", - "numSpecimens": 20038, - "children": [] - } - ] - } - ] + file: 'r66.bin', + numSpecimens: 20038, + children: [], + }, + ], + }, + ], }, - "spatialColumn": "488I12FURRB8ZY5KJ8TCoordinates", - "visualizationReferenceId": "488I12FURRB8ZY5KJ8T", - "geneFileEndpoint": "https://bkp-2d-visualizations-stage.s3.amazonaws.com/wmb_tenx_01172024_stage-20240128193624/488I12FURRB8ZY5KJ8T/data/", - "metadataFileEndpoint": "https://bkp-2d-visualizations-stage.s3.amazonaws.com/wmb_tenx_01172024_stage-20240128193624/488I12FURRB8ZY5KJ8T/metadata/", - "pointAttributes": { - "FS00DXV0T9R1X9FJ4QE": { - "name": "FS00DXV0T9R1X9FJ4QE", - "size": 2, - "elements": 1, - "elementSize": 2, - "type": "uint16", - "description": "Class" + spatialColumn: '488I12FURRB8ZY5KJ8TCoordinates', + visualizationReferenceId: '488I12FURRB8ZY5KJ8T', + geneFileEndpoint: + 'https://bkp-2d-visualizations-stage.s3.amazonaws.com/wmb_tenx_01172024_stage-20240128193624/488I12FURRB8ZY5KJ8T/data/', + metadataFileEndpoint: + 'https://bkp-2d-visualizations-stage.s3.amazonaws.com/wmb_tenx_01172024_stage-20240128193624/488I12FURRB8ZY5KJ8T/metadata/', + pointAttributes: { + FS00DXV0T9R1X9FJ4QE: { + name: 'FS00DXV0T9R1X9FJ4QE', + size: 2, + elements: 1, + elementSize: 2, + type: 'uint16', + description: 'Class', }, - "QY5S8KMO5HLJUF0P00K": { - "name": "QY5S8KMO5HLJUF0P00K", - "size": 2, - "elements": 1, - "elementSize": 2, - "type": "uint16", - "description": "Subclass" + QY5S8KMO5HLJUF0P00K: { + name: 'QY5S8KMO5HLJUF0P00K', + size: 2, + elements: 1, + elementSize: 2, + type: 'uint16', + description: 'Subclass', }, - "15BK47DCIOF1SLLUW9P": { - "name": "15BK47DCIOF1SLLUW9P", - "size": 2, - "elements": 1, - "elementSize": 2, - "type": "uint16", - "description": "Supertype" + '15BK47DCIOF1SLLUW9P': { + name: '15BK47DCIOF1SLLUW9P', + size: 2, + elements: 1, + elementSize: 2, + type: 'uint16', + description: 'Supertype', }, - "CBGC0U30VV9JPR60TJU": { - "name": "CBGC0U30VV9JPR60TJU", - "size": 2, - "elements": 1, - "elementSize": 2, - "type": "uint16", - "description": "Cluster" + CBGC0U30VV9JPR60TJU: { + name: 'CBGC0U30VV9JPR60TJU', + size: 2, + elements: 1, + elementSize: 2, + type: 'uint16', + description: 'Cluster', }, - "4MV7HA5DG2XJZ3UD8G9": { - "name": "4MV7HA5DG2XJZ3UD8G9", - "size": 2, - "elements": 1, - "elementSize": 2, - "type": "uint16", - "description": "Neurotransmitter Type" + '4MV7HA5DG2XJZ3UD8G9': { + name: '4MV7HA5DG2XJZ3UD8G9', + size: 2, + elements: 1, + elementSize: 2, + type: 'uint16', + description: 'Neurotransmitter Type', }, - "Y937CVUSVZC7KYOHWVO": { - "name": "Y937CVUSVZC7KYOHWVO", - "size": 2, - "elements": 1, - "elementSize": 2, - "type": "uint16", - "description": "Dissection Region" + Y937CVUSVZC7KYOHWVO: { + name: 'Y937CVUSVZC7KYOHWVO', + size: 2, + elements: 1, + elementSize: 2, + type: 'uint16', + description: 'Dissection Region', }, - "KRP9GYF002I5OPM7JSR": { - "name": "KRP9GYF002I5OPM7JSR", - "size": 2, - "elements": 1, - "elementSize": 2, - "type": "uint16", - "description": "Donor ID" + KRP9GYF002I5OPM7JSR: { + name: 'KRP9GYF002I5OPM7JSR', + size: 2, + elements: 1, + elementSize: 2, + type: 'uint16', + description: 'Donor ID', }, - "N3YEG845JSIPMS3C0MJ": { - "name": "N3YEG845JSIPMS3C0MJ", - "size": 2, - "elements": 1, - "elementSize": 2, - "type": "uint16", - "description": "Platform" + N3YEG845JSIPMS3C0MJ: { + name: 'N3YEG845JSIPMS3C0MJ', + size: 2, + elements: 1, + elementSize: 2, + type: 'uint16', + description: 'Platform', }, - "O95N6FNAK13WZWEIU5N": { - "name": "O95N6FNAK13WZWEIU5N", - "size": 2, - "elements": 1, - "elementSize": 2, - "type": "uint16", - "description": "Sex" + O95N6FNAK13WZWEIU5N: { + name: 'O95N6FNAK13WZWEIU5N', + size: 2, + elements: 1, + elementSize: 2, + type: 'uint16', + description: 'Sex', }, - "Q0LG0S1W23HUAKA2SW3": { - "name": "Q0LG0S1W23HUAKA2SW3", - "size": 2, - "elements": 1, - "elementSize": 2, - "type": "uint16", - "description": "Genotype" + Q0LG0S1W23HUAKA2SW3: { + name: 'Q0LG0S1W23HUAKA2SW3', + size: 2, + elements: 1, + elementSize: 2, + type: 'uint16', + description: 'Genotype', }, - "488I12FURRB8ZY5KJ8TCoordinates": { - "name": "488I12FURRB8ZY5KJ8TCoordinates", - "size": 8, - "elements": 2, - "elementSize": 4, - "type": "float", - "description": "Pallium-Glut" - } - } - } -} + '488I12FURRB8ZY5KJ8TCoordinates': { + name: '488I12FURRB8ZY5KJ8TCoordinates', + size: 8, + elements: 2, + elementSize: 4, + type: 'float', + description: 'Pallium-Glut', + }, + }, + }, +}; describe('configure', () => { test('can we generate a sensible shader from settings', () => { const { config, columnNameToShaderName } = configureShader({ @@ -420,8 +421,8 @@ describe('configure', () => { categoricalFilters: {}, mode: 'color', colorBy: { kind: 'quantitative', column: '123', gradient: 'viridis', range: { min: 0, max: 10 } }, - quantitativeFilters: [] - }) + quantitativeFilters: [], + }); const shaders = buildShaders(config); const expectedConfig: Config = { @@ -433,8 +434,8 @@ describe('configure', () => { tableSize: [1, 1], quantitativeColumns: ['COLOR_BY_MEASURE'], positionColumn: 'position', - } - const expectedShader = /*glsl*/` + }; + const expectedShader = /*glsl*/ ` precision highp float; // attribs // @@ -508,15 +509,15 @@ describe('configure', () => { color = getColor(); gl_PointSize = getPointSize(); gl_Position = getClipPosition(); - }` - expect(config).toEqual(expectedConfig) - console.log('~~~~~~~~~~~~~~~~~~~~~~~~~~~') - console.log(shaders.vs) - console.log('~~~~~~~~~~~~~~~~~~~~~~~~~~~') - expect(shaders.vs.replace(/\s/g, "")).toEqual(expectedShader.replace(/\s/g, "")) + }`; + expect(config).toEqual(expectedConfig); + console.log('~~~~~~~~~~~~~~~~~~~~~~~~~~~'); + console.log(shaders.vs); + console.log('~~~~~~~~~~~~~~~~~~~~~~~~~~~'); + expect(shaders.vs.replace(/\s/g, '')).toEqual(expectedShader.replace(/\s/g, '')); expect(columnNameToShaderName).toEqual({ '123': 'COLOR_BY_MEASURE', - "488I12FURRB8ZY5KJ8TCoordinates": "position", - }) - }) -}) + '488I12FURRB8ZY5KJ8TCoordinates': 'position', + }); + }); +}); diff --git a/packages/scatterbrain/src/shader.ts b/packages/scatterbrain/src/shader.ts index c566f5f4..ef28535d 100644 --- a/packages/scatterbrain/src/shader.ts +++ b/packages/scatterbrain/src/shader.ts @@ -1,13 +1,12 @@ - // we have to generate a shader, due to the runtime-variable way in which columns of data // are used for filtering (some in a range, some via a lookup table) -import REGL from "regl"; -import type { ScatterbrainDataset, SlideviewScatterbrainDataset } from "./types"; -import * as lodash from "lodash"; -const { filter, keys, mapValues, reduce } = lodash // ugh -import { Box2D, type vec4, type box2D, type Interval, type vec2 } from "@alleninstitute/vis-geometry"; -import { type Cacheable, type CachedVertexBuffer } from "@alleninstitute/vis-core"; +import REGL from 'regl'; +import type { ScatterbrainDataset, SlideviewScatterbrainDataset } from './types'; +import * as lodash from 'lodash'; +const { filter, keys, mapValues, reduce } = lodash; // ugh +import { Box2D, type vec4, type box2D, type Interval, type vec2 } from '@alleninstitute/vis-geometry'; +import { type Cacheable, type CachedVertexBuffer } from '@alleninstitute/vis-core'; // the set of columns and what to do with them can vary // there might be 3 categorical columns and 2 range columns @@ -15,7 +14,6 @@ import { type Cacheable, type CachedVertexBuffer } from "@alleninstitute/vis-cor // due to a variety of limitations in WebGL / GLSL 1 - this is about as general as we can // get without a great deal of extra performance cost - // scatterbrain does scatterplot rendering // its main claim to fame is handling complex filtering // when generating shaders, most of the variable parts flow through these @@ -24,14 +22,14 @@ import { type Cacheable, type CachedVertexBuffer } from "@alleninstitute/vis-cor // and simply refer to global uniforms / attribs directly. // as a quick reminder - recursion of any kind is forbidden in GLSL - so be careful // as it will not be possible to detect this until runtime. -// its not unreasonable for some of these utils to call each other - just be sure to +// its not unreasonable for some of these utils to call each other - just be sure to // avoid recursion! // note - you dont have to use these! these are just kinda like guide-rails for // patterns we've seen in our shaders so far! you could easily generate your own // totally custom shaders! type ScatterbrainShaderUtils = { - uniforms: string; // the GLSL declarations of the uniforms for this shader + uniforms: string; // the GLSL declarations of the uniforms for this shader attributes: string; // the GLSL declarations of the vertex attributes for this shader commonUtilsGLSL: string; // prepend any GLSL to the final vertex shader isFilteredIn: string; // ()->float @@ -40,14 +38,14 @@ type ScatterbrainShaderUtils = { getDataPosition: string; // ()-> vec3 // the position of the point in data-space getClipPosition: string; // ()-> vec4 // the position of the point in clip space - (hint - apply the camera to data-space) getPointSize: string; // ()->float -} +}; export class VBO implements Cacheable { buffer: CachedVertexBuffer; constructor(buffer: CachedVertexBuffer) { this.buffer = buffer; } destroy() { - this.buffer.buffer.destroy() + this.buffer.buffer.destroy(); } sizeInBytes() { return this.buffer.bytes; @@ -55,7 +53,7 @@ export class VBO implements Cacheable { } function buildVertexShader(utils: ScatterbrainShaderUtils) { - return /*glsl*/` + return /*glsl*/ ` precision highp float; // attribs // ${utils.attributes} @@ -92,7 +90,7 @@ function buildVertexShader(utils: ScatterbrainShaderUtils) { gl_PointSize = getPointSize(); gl_Position = getClipPosition(); } - ` + `; // note that only getColor, getPointSize, and getPosition are called // that should make clear that the other fns are indended to simply be useful // concepts that the other main fns can call @@ -100,14 +98,14 @@ function buildVertexShader(utils: ScatterbrainShaderUtils) { export function buildShaders(config: Config) { return { vs: buildVertexShader(generate(config)), - fs: /*glsl*/` + fs: /*glsl*/ ` precision highp float; varying vec4 color; void main(){ gl_FragColor = color; } - ` - } + `, + }; } export type Config = { mode: 'color' | 'info'; @@ -118,29 +116,33 @@ export type Config = { gradientTable: string; positionColumn: string; colorByColumn: string; -} +}; function rangeFor(col: string) { return `${col}_range`; } export type RenderProps = { - target: REGL.Framebuffer2D | null, - categoricalLookupTable: REGL.Texture2D, - gradient: REGL.Texture2D, - camera: { view: box2D, screenResolution: vec2 }, - offset: vec2, - filteredOutColor: vec4, - spatialFilterBox: box2D, - quantitativeRangeFilters: Record, - hoveredValue: number, + target: REGL.Framebuffer2D | null; + categoricalLookupTable: REGL.Texture2D; + gradient: REGL.Texture2D; + camera: { view: box2D; screenResolution: vec2 }; + offset: vec2; + filteredOutColor: vec4; + spatialFilterBox: box2D; + quantitativeRangeFilters: Record; + hoveredValue: number; item: { - count: number, - columnData: Record - } -} + count: number; + columnData: Record; + }; +}; export function buildScatterbrainRenderCommand(config: Config, regl: REGL.Regl) { - const prop = (p: string) => regl.prop(p) + const prop = (p: string) => regl.prop(p); const { quantitativeColumns, categoricalColumns, categoricalTable, gradientTable, positionColumn } = config; - const ranges = reduce(quantitativeColumns, (unis, col) => ({ ...unis, [rangeFor(col)]: prop(rangeFor(col)) }), {} as Record>); + const ranges = reduce( + quantitativeColumns, + (unis, col) => ({ ...unis, [rangeFor(col)]: prop(rangeFor(col)) }), + {} as Record>, + ); const { vs, fs } = buildShaders(config); const uniforms = { [categoricalTable]: prop('categoricalLookupTable'), @@ -152,46 +154,89 @@ export function buildScatterbrainRenderCommand(config: Config, regl: REGL.Regl) hoveredValue: prop('hoveredValue'), screenSize: prop('screenSize'), offset: prop('offset'), - } + }; const cmd = regl({ vert: vs, frag: fs, - attributes: [positionColumn, ...categoricalColumns, ...quantitativeColumns].reduce((attribs, col) => ({ ...attribs, [col]: regl.prop(col) }), {}), + attributes: [positionColumn, ...categoricalColumns, ...quantitativeColumns].reduce( + (attribs, col) => ({ ...attribs, [col]: regl.prop(col) }), + {}, + ), uniforms, blend: { - enable: false + enable: false, }, primitive: 'points', framebuffer: prop('target'), - count: prop('count') + count: prop('count'), }); return (props: RenderProps) => { - const { target, hoveredValue, spatialFilterBox, filteredOutColor, gradient, camera, offset, quantitativeRangeFilters, categoricalLookupTable, item } = props - const filterRanges = reduce(keys(quantitativeRangeFilters), (acc, cur) => ({ ...acc, [rangeFor(cur)]: quantitativeRangeFilters[cur] }), {}) - const { view, screenResolution } = camera + const { + target, + hoveredValue, + spatialFilterBox, + filteredOutColor, + gradient, + camera, + offset, + quantitativeRangeFilters, + categoricalLookupTable, + item, + } = props; + const filterRanges = reduce( + keys(quantitativeRangeFilters), + (acc, cur) => ({ ...acc, [rangeFor(cur)]: quantitativeRangeFilters[cur] }), + {}, + ); + const { view, screenResolution } = camera; const { count, columnData } = item; - const rawBuffers = mapValues(columnData, (vbo) => vbo.buffer.buffer) - cmd({ target, gradient, hoveredValue, filteredOutColor, spatialFilterBox: Box2D.toFlatArray(spatialFilterBox), categoricalLookupTable, offset, count, view: Box2D.toFlatArray(view), screenSize: screenResolution, ...filterRanges, ...rawBuffers }) - } + const rawBuffers = mapValues(columnData, (vbo) => vbo.buffer.buffer); + cmd({ + target, + gradient, + hoveredValue, + filteredOutColor, + spatialFilterBox: Box2D.toFlatArray(spatialFilterBox), + categoricalLookupTable, + offset, + count, + view: Box2D.toFlatArray(view), + screenSize: screenResolution, + ...filterRanges, + ...rawBuffers, + }); + }; } function rangeFilterExpression(quantitativeColumns: readonly string[]) { - return quantitativeColumns.map(attrib =>/*glsl*/`within(${attrib},${rangeFor(attrib)})`).join(' * ') + return quantitativeColumns.map((attrib) => /*glsl*/ `within(${attrib},${rangeFor(attrib)})`).join(' * '); } function categoricalFilterExpression(categoricalColumns: readonly string[], tableSize: vec2, tableName: string) { // categorical columns are in order - this array will have the same order as the col in the texture const [w, h] = tableSize; - return categoricalColumns.map((attrib, i) => - /*glsl*/`step(0.01,texture2D(${tableName},vec2(${i.toFixed(0)}.5,${attrib}+0.5)/vec2(${w.toFixed(1)},${h.toFixed(1)})).a)`) - .join(' * ') + return categoricalColumns + .map( + (attrib, i) => + /*glsl*/ `step(0.01,texture2D(${tableName},vec2(${i.toFixed(0)}.5,${attrib}+0.5)/vec2(${w.toFixed(1)},${h.toFixed(1)})).a)`, + ) + .join(' * '); } export function generate(config: Config): ScatterbrainShaderUtils { - const { mode, quantitativeColumns, categoricalColumns, categoricalTable, tableSize, gradientTable, positionColumn, colorByColumn } = config; - const catFilter = categoricalFilterExpression(categoricalColumns, tableSize, categoricalTable) - const rangeFilter = rangeFilterExpression(quantitativeColumns) - const uniforms = /*glsl*/` + const { + mode, + quantitativeColumns, + categoricalColumns, + categoricalTable, + tableSize, + gradientTable, + positionColumn, + colorByColumn, + } = config; + const catFilter = categoricalFilterExpression(categoricalColumns, tableSize, categoricalTable); + const rangeFilter = rangeFilterExpression(quantitativeColumns); + const uniforms = /*glsl*/ ` uniform vec4 view; uniform vec2 screenSize; uniform vec2 offset; @@ -202,16 +247,16 @@ export function generate(config: Config): ScatterbrainShaderUtils { uniform sampler2D ${gradientTable}; uniform sampler2D ${categoricalTable}; // quantitative columns each need a range value - its the min,max in a vec2 - ${quantitativeColumns.map((col) =>/*glsl*/`uniform vec2 ${rangeFor(col)};`).join('\n')} - ` + ${quantitativeColumns.map((col) => /*glsl*/ `uniform vec2 ${rangeFor(col)};`).join('\n')} + `; - const attributes = /*glsl*/` + const attributes = /*glsl*/ ` attribute vec2 ${positionColumn}; - ${categoricalColumns.map((col) =>/*glsl*/`attribute float ${col};`).join('\n')} - ${quantitativeColumns.map((col) =>/*glsl*/`attribute float ${col};`).join('\n')} - ` + ${categoricalColumns.map((col) => /*glsl*/ `attribute float ${col};`).join('\n')} + ${quantitativeColumns.map((col) => /*glsl*/ `attribute float ${col};`).join('\n')} + `; - const commonUtilsGLSL = /*glsl*/` + const commonUtilsGLSL = /*glsl*/ ` vec4 applyCamera(vec3 dataPos){ vec2 size = view.zw-view.xy; vec2 unit = (dataPos.xy-view.xy)/size; @@ -223,46 +268,50 @@ export function generate(config: Config): ScatterbrainShaderUtils { float within(float v, vec2 range){ return step(range.x,v)*step(v,range.y); } - ` + `; const categoryColumnIndex = categoricalColumns.indexOf(colorByColumn); - const isCategoricalColor = categoryColumnIndex > -1 - const hoverCategoryExpr = /*glsl*/`1.0-step(0.1,abs(${colorByColumn}-hoveredValue))` - const isHovered = /*glsl*/` - return ${isCategoricalColor ? hoverCategoryExpr : '0.0'};` - const isFilteredIn = /*glsl*/` + const isCategoricalColor = categoryColumnIndex > -1; + const hoverCategoryExpr = /*glsl*/ `1.0-step(0.1,abs(${colorByColumn}-hoveredValue))`; + const isHovered = /*glsl*/ ` + return ${isCategoricalColor ? hoverCategoryExpr : '0.0'};`; + const isFilteredIn = /*glsl*/ ` vec3 p = getDataPosition(); return within(p.x,spatialFilterBox.xz)*within(p.y,spatialFilterBox.yw) * ${catFilter.length > 0 ? catFilter : '1.0'} * ${rangeFilter.length > 0 ? rangeFilter : '1.0'}; - ` + `; - const getDataPosition = /*glsl*/`return vec3(${positionColumn}+offset,0.0);` - const getClipPosition = /*glsl*/`return applyCamera(getDataPosition());` - const getPointSize = /*glsl*/`return mix(2.0,6.0,isHovered());` // todo! + const getDataPosition = /*glsl*/ `return vec3(${positionColumn}+offset,0.0);`; + const getClipPosition = /*glsl*/ `return applyCamera(getDataPosition());`; + const getPointSize = /*glsl*/ `return mix(2.0,6.0,isHovered());`; // todo! // todo - use config options! // if the colorByColumn is a categorical column, generate that // else, use a range-colorby const [w, h] = tableSize; - const colorByCategorical = /*glsl*/` - vec4(texture2D(${categoricalTable},vec2(${categoryColumnIndex.toFixed(0)}.5,${colorByColumn}+0.5)/vec2(${w.toFixed(1)},${h.toFixed(1)})).rgb,1.0)` + const colorByCategorical = /*glsl*/ ` + vec4(texture2D(${categoricalTable},vec2(${categoryColumnIndex.toFixed(0)}.5,${colorByColumn}+0.5)/vec2(${w.toFixed(1)},${h.toFixed(1)})).rgb,1.0)`; - const colorByQuantitative = /*glsl*/` + const colorByQuantitative = /*glsl*/ ` texture2D(${gradientTable},vec2(rangeParameter(${colorByColumn},${rangeFor(colorByColumn)}),0.5)) - ` - const colorize = categoryColumnIndex != -1 ? colorByCategorical : colorByQuantitative + `; + const colorize = categoryColumnIndex != -1 ? colorByCategorical : colorByQuantitative; - const colorByCategoricalId = /*glsl*/` + const colorByCategoricalId = /*glsl*/ ` float G = mod(${colorByColumn},256.0); float R = mod(${colorByColumn}/256.0,256.0); return vec4(R/255.0,G/255.0,0,1); - ` - const colorByQuantitativeValue = /*glsl*/` + `; + const colorByQuantitativeValue = /*glsl*/ ` return vec4(0,rangeParameter(${colorByColumn},${rangeFor(colorByColumn)}),0,1); - ` - const getColor = mode === 'color' ? /*glsl*/` + `; + const getColor = + mode === 'color' + ? /*glsl*/ ` return mix(filteredOutColor,${colorize},isFilteredIn()); - ` : - (categoryColumnIndex === -1 ? colorByQuantitativeValue : colorByCategoricalId) + ` + : categoryColumnIndex === -1 + ? colorByQuantitativeValue + : colorByCategoricalId; return { attributes, uniforms, @@ -273,54 +322,68 @@ export function generate(config: Config): ScatterbrainShaderUtils { getPointSize, isFilteredIn, isHovered, - } + }; } - // these settings impact how the shader is generated - // that means changing them may require re-building the renderer (and the shader beneath it) export type ShaderSettings = { - dataset: ScatterbrainDataset | SlideviewScatterbrainDataset - categoricalFilters: Record // category name -> maximum # of distinct values in that category - quantitativeFilters: readonly string[] // the names of quantitative variables - mode: 'color' | 'info', - colorBy: { kind: 'metadata', column: string } | { kind: 'quantitative', column: string, gradient: 'viridis' | 'inferno', range: Interval } -} - - + dataset: ScatterbrainDataset | SlideviewScatterbrainDataset; + categoricalFilters: Record; // category name -> maximum # of distinct values in that category + quantitativeFilters: readonly string[]; // the names of quantitative variables + mode: 'color' | 'info'; + colorBy: + | { kind: 'metadata'; column: string } + | { kind: 'quantitative'; column: string; gradient: 'viridis' | 'inferno'; range: Interval }; +}; -export function configureShader(settings: ShaderSettings): { config: Config, columnNameToShaderName: Record } { +export function configureShader(settings: ShaderSettings): { + config: Config; + columnNameToShaderName: Record; +} { // given settings that make sense to a caller (stuff about the data we want to visualize) // produce an object that can be used to set up some internal config of the shader that would // do the visualization const { dataset, categoricalFilters, quantitativeFilters, colorBy, mode } = settings; // figure out the columns we care about // assign them names that are safe to use in the shader (A,B,C, whatever) - const categories = keys(categoricalFilters).toSorted() + const categories = keys(categoricalFilters).toSorted(); const numCategories = categories.length; - const longestCategory = reduce(keys(categoricalFilters), (highest, cur) => Math.max(highest, categoricalFilters[cur]), 0) + const longestCategory = reduce( + keys(categoricalFilters), + (highest, cur) => Math.max(highest, categoricalFilters[cur]), + 0, + ); // the goal here is to associate column names with shader-safe names - const initialQuantitativeAttrs: Record = colorBy.kind === 'metadata' ? {} : { [colorBy.column]: 'COLOR_BY_MEASURE' } - const initialCategoricalAttrs: Record = colorBy.kind === 'metadata' ? { [colorBy.column]: 'COLOR_BY_CATEGORY' } : {} + const initialQuantitativeAttrs: Record = + colorBy.kind === 'metadata' ? {} : { [colorBy.column]: 'COLOR_BY_MEASURE' }; + const initialCategoricalAttrs: Record = + colorBy.kind === 'metadata' ? { [colorBy.column]: 'COLOR_BY_CATEGORY' } : {}; // we map each quantitative filter name to the shader-safe attribute name: MEASURE_{i} - const qAttrs = reduce(quantitativeFilters.toSorted(), (quantAttrs, quantFilter, i) => ({ ...quantAttrs, [quantFilter]: `MEASURE_${i.toFixed(0)}` }), initialQuantitativeAttrs); + const qAttrs = reduce( + quantitativeFilters.toSorted(), + (quantAttrs, quantFilter, i) => ({ ...quantAttrs, [quantFilter]: `MEASURE_${i.toFixed(0)}` }), + initialQuantitativeAttrs, + ); // we map each categorical filter's name to the shader-safe attribute name: CATEGORY_{i} - const cAttrs = reduce(categories, (catAttrs, categoricalFilter, i) => ({ ...catAttrs, [categoricalFilter]: `CATEGORY_${i.toFixed(0)}` }), initialCategoricalAttrs); + const cAttrs = reduce( + categories, + (catAttrs, categoricalFilter, i) => ({ ...catAttrs, [categoricalFilter]: `CATEGORY_${i.toFixed(0)}` }), + initialCategoricalAttrs, + ); const colToAttribute = { ...qAttrs, ...cAttrs, [dataset.metadata.spatialColumn]: 'position' }; - - const config: Config = { - categoricalColumns: keys(cAttrs).map(columnName => colToAttribute[columnName]), - quantitativeColumns: keys(qAttrs).map(columnName => colToAttribute[columnName]), + categoricalColumns: keys(cAttrs).map((columnName) => colToAttribute[columnName]), + quantitativeColumns: keys(qAttrs).map((columnName) => colToAttribute[columnName]), categoricalTable: 'lookup', gradientTable: 'gradient', colorByColumn: colToAttribute[colorBy.column], mode, positionColumn: 'position', - tableSize: [Math.max(numCategories, 1), Math.max(1, longestCategory)] - } - return { config, columnNameToShaderName: colToAttribute } -} \ No newline at end of file + tableSize: [Math.max(numCategories, 1), Math.max(1, longestCategory)], + }; + return { config, columnNameToShaderName: colToAttribute }; +} diff --git a/packages/scatterbrain/src/typed-array.ts b/packages/scatterbrain/src/typed-array.ts index 10c264b1..9edb675b 100644 --- a/packages/scatterbrain/src/typed-array.ts +++ b/packages/scatterbrain/src/typed-array.ts @@ -1,4 +1,3 @@ - // lets help the compiler to know that these two types are related: export type TaggedFloat32Array = { type: 'float'; @@ -39,7 +38,7 @@ export type TaggedTypedArray = | TaggedInt16Array | TaggedUint8Array | TaggedInt8Array; -export type WebGLSafeBasicType = TaggedTypedArray['type'] +export type WebGLSafeBasicType = TaggedTypedArray['type']; export const BufferConstructors = { uint8: Uint8Array, diff --git a/packages/scatterbrain/src/types.ts b/packages/scatterbrain/src/types.ts index b6aa3893..50a442ec 100644 --- a/packages/scatterbrain/src/types.ts +++ b/packages/scatterbrain/src/types.ts @@ -1,5 +1,5 @@ /// Types describing the metadata that gets loaded from scatterbrain.json files /// -// there are 2 variants, slideview and regular - they are distinguished at runtime +// there are 2 variants, slideview and regular - they are distinguished at runtime // by checking the parsed metadata for the 'slides' field export type WebGLSafeBasicType = 'uint8' | 'uint16' | 'int8' | 'int16' | 'uint32' | 'int32' | 'float'; @@ -40,7 +40,7 @@ type CommonMetadata = { visualizationReferenceId: string; spatialColumn: string; pointAttributes: Record; -} +}; // scatterbrain distinguishes 2 kinds of datasets - those arranged at the topmost level into slides // and 'regular' - which is just a simple, 2D point cloud export type ScatterbrainMetadata = CommonMetadata & { @@ -80,6 +80,5 @@ export type SlideviewMetadata = CommonMetadata & { // a Dataset is the top level entity // an Item is a chunk of that dataset - esentially a singular, loadable, thing - -export type SlideviewScatterbrainDataset = { type: 'slideview', metadata: SlideviewMetadata } -export type ScatterbrainDataset = { type: 'normal', metadata: ScatterbrainMetadata } +export type SlideviewScatterbrainDataset = { type: 'slideview'; metadata: SlideviewMetadata }; +export type ScatterbrainDataset = { type: 'normal'; metadata: ScatterbrainMetadata }; diff --git a/site/package.json b/site/package.json index f6fab2a4..4cc55578 100644 --- a/site/package.json +++ b/site/package.json @@ -78,4 +78,4 @@ "zarrita": "0.5.1" }, "packageManager": "pnpm@9.14.2" -} \ No newline at end of file +} diff --git a/site/src/examples/scatterbrain/demo.tsx b/site/src/examples/scatterbrain/demo.tsx index 3274f2c4..654857b0 100644 --- a/site/src/examples/scatterbrain/demo.tsx +++ b/site/src/examples/scatterbrain/demo.tsx @@ -1,10 +1,17 @@ import type { vec2, vec4 } from '@alleninstitute/vis-geometry'; import { SharedCacheContext, SharedCacheProvider } from '../common/react/priority-cache-provider'; import { useContext, useEffect, useRef, useState } from 'react'; -import { buildScatterbrainRenderFn, loadScatterbrainDataset, setCategoricalLookupTableValues, type Dataset, type ShaderSettings } from '@alleninstitute/vis-scatterbrain'; +import { + buildScatterbrainRenderFn, + loadScatterbrainDataset, + setCategoricalLookupTableValues, + type Dataset, + type ShaderSettings, +} from '@alleninstitute/vis-scatterbrain'; const screenSize: vec2 = [800, 800]; -const tenx = 'https://bkp-2d-visualizations-stage.s3.amazonaws.com/wmb_tenx_01172024_stage-20240128193624/G4I4GFJXJB9ATZ3PTX1/ScatterBrain.json' +const tenx = + 'https://bkp-2d-visualizations-stage.s3.amazonaws.com/wmb_tenx_01172024_stage-20240128193624/G4I4GFJXJB9ATZ3PTX1/ScatterBrain.json'; export function scatterBrainDemo() { return ( @@ -13,49 +20,49 @@ export function scatterBrainDemo() { ); } - const makeFakeColors = (n: number) => { - const stuff: Record = {} - for (let i = 0; i < n; i++) { - stuff[i] = { - color: [Math.random(), Math.random(), Math.random(), 1], - // 80% of either category are filtered in, at random: - filteredIn: Math.random() > 0.2 - } - } - return stuff; +const makeFakeColors = (n: number) => { + const stuff: Record = {}; + for (let i = 0; i < n; i++) { + stuff[i] = { + color: [Math.random(), Math.random(), Math.random(), 1], + // 80% of either category are filtered in, at random: + filteredIn: Math.random() > 0.2, + }; } + return stuff; +}; // fake color and filter tables, as a demo: - const categories = { - '4MV7HA5DG2XJZ3UD8G9': makeFakeColors(40), // nt type - 'FS00DXV0T9R1X9FJ4QE': makeFakeColors(40) // class - } - const settings: Omit = { - categoricalFilters: { '4MV7HA5DG2XJZ3UD8G9': 40, 'FS00DXV0T9R1X9FJ4QE': 40, }, - colorBy: { kind: 'metadata', column: "FS00DXV0T9R1X9FJ4QE" }, - // an alternative color-by setting, swap it to see quantitative coloring - // colorBy: { kind: 'quantitative', column: '27683', gradient: 'viridis', range: { min: 0, max: 10 } }, - mode: 'color', - quantitativeFilters: [], - } -async function loadRawJson(){ - return await (await fetch(tenx)).json() +const categories = { + '4MV7HA5DG2XJZ3UD8G9': makeFakeColors(40), // nt type + FS00DXV0T9R1X9FJ4QE: makeFakeColors(40), // class +}; +const settings: Omit = { + categoricalFilters: { '4MV7HA5DG2XJZ3UD8G9': 40, FS00DXV0T9R1X9FJ4QE: 40 }, + colorBy: { kind: 'metadata', column: 'FS00DXV0T9R1X9FJ4QE' }, + // an alternative color-by setting, swap it to see quantitative coloring + // colorBy: { kind: 'quantitative', column: '27683', gradient: 'viridis', range: { min: 0, max: 10 } }, + mode: 'color', + quantitativeFilters: [], +}; +async function loadRawJson() { + return await (await fetch(tenx)).json(); } -type Props = {screenSize:vec2} -function Demo(props:Props) { - const {screenSize} = props; +type Props = { screenSize: vec2 }; +function Demo(props: Props) { + const { screenSize } = props; const cnvs = useRef(null); const server = useContext(SharedCacheContext); - const [dataset,setDataset] = useState(undefined) - useEffect(()=>{ - loadRawJson().then(raw=>setDataset(loadScatterbrainDataset(raw))) - },[]) + const [dataset, setDataset] = useState(undefined); + useEffect(() => { + loadRawJson().then((raw) => setDataset(loadScatterbrainDataset(raw))); + }, []); // todo handlers, etc - useEffect(()=>{ + useEffect(() => { // build the renderer - if(server && dataset && cnvs.current){ - const ctx = cnvs.current.getContext('2d') - const {cache,regl}=server; - const lookup = regl.texture({ width: 10, height: 10, format: 'rgba' }) + if (server && dataset && cnvs.current) { + const ctx = cnvs.current.getContext('2d'); + const { cache, regl } = server; + const lookup = regl.texture({ width: 10, height: 10, format: 'rgba' }); const gradientData = new Uint8Array(256 * 4); for (let i = 0; i < 256; i += 4) { gradientData[i * 4 + 0] = i; @@ -63,21 +70,22 @@ function Demo(props:Props) { gradientData[i * 4 + 2] = i; gradientData[i * 4 + 3] = 255; } - const gradient = regl.texture({ width: 256, height: 1, format: 'rgba', data: gradientData }) - const tgt = regl.framebuffer(screenSize[0],screenSize[1]) + const gradient = regl.texture({ width: 256, height: 1, format: 'rgba', data: gradientData }); + const tgt = regl.framebuffer(screenSize[0], screenSize[1]); // make up random colors for the coloring, and add random filtering - setCategoricalLookupTableValues(categories, lookup) - - const {render,connectToCache} = buildScatterbrainRenderFn( + setCategoricalLookupTableValues(categories, lookup); + + const { render, connectToCache } = buildScatterbrainRenderFn( // @ts-expect-error we'll deal with this later regl, - {...settings,dataset}); + { ...settings, dataset }, + ); // this ts error is bogus, dont know why - const renderOneFrame = ()=>{ + const renderOneFrame = () => { render({ client, - visibilityThresholdPx:10, + visibilityThresholdPx: 10, camera: { view: { minCorner: [-17, -17], maxCorner: [26, 26] }, screenResolution: [800, 800] }, categoricalLookupTable: lookup, dataset, @@ -88,19 +96,14 @@ function Demo(props:Props) { quantitativeRangeFilters: {}, spatialFilterBox: { minCorner: [-17, -17], maxCorner: [30, 30] }, target: tgt, - }) - const bytes = regl.read({framebuffer:tgt}) - const img = new ImageData(new Uint8ClampedArray(bytes),screenSize[0],screenSize[1]); + }); + const bytes = regl.read({ framebuffer: tgt }); + const img = new ImageData(new Uint8ClampedArray(bytes), screenSize[0], screenSize[1]); ctx!.putImageData(img, 0, 0); - } - const client = connectToCache(cache,renderOneFrame); + }; + const client = connectToCache(cache, renderOneFrame); renderOneFrame(); } - - },[cnvs.current,dataset,server]) - return () -} \ No newline at end of file + }, [cnvs.current, dataset, server]); + return ; +} From 5e6eb8e403c784a11a1b9225e7a40f06d91ba3a1 Mon Sep 17 00:00:00 2001 From: noah Date: Wed, 11 Feb 2026 10:27:48 -0800 Subject: [PATCH 27/29] lint --- packages/scatterbrain/src/dataset.ts | 10 ++++++---- packages/scatterbrain/src/renderer.ts | 2 +- packages/scatterbrain/src/shader.ts | 22 +++++++++++----------- site/src/examples/scatterbrain/demo.tsx | 2 +- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/scatterbrain/src/dataset.ts b/packages/scatterbrain/src/dataset.ts index 2ce7bb46..f664d33d 100644 --- a/packages/scatterbrain/src/dataset.ts +++ b/packages/scatterbrain/src/dataset.ts @@ -189,12 +189,14 @@ const slideviewMetadataSchema = z.object({ spatialUnit: spatialRefFrameSchema, }); +// biome-ignore lint/suspicious/noExplicitAny: this fn is intended to accept the return value of JSON.parse() - any is appropriate here export function loadDataset(raw: any): Dataset | undefined { - if (raw['slides']) { + if (typeof raw !== 'object' || !raw) return undefined + + if (raw.slides) { const metadata = slideviewMetadataSchema.safeParse(raw); return metadata.success ? { type: 'slideview', metadata: metadata.data } : undefined; - } else { - const metadata = scatterbrainMetadataSchema.safeParse(raw); - return metadata.success ? { type: 'normal', metadata: metadata.data } : undefined; } + const metadata = scatterbrainMetadataSchema.safeParse(raw); + return metadata.success ? { type: 'normal', metadata: metadata.data } : undefined; } diff --git a/packages/scatterbrain/src/renderer.ts b/packages/scatterbrain/src/renderer.ts index 22256106..e79634c3 100644 --- a/packages/scatterbrain/src/renderer.ts +++ b/packages/scatterbrain/src/renderer.ts @@ -122,7 +122,7 @@ export function setCategoricalLookupTableValues( const columns = categoryKeys.length; const rows = reduce(categoryKeys, (highest, category) => Math.max(highest, keys(categories[category]).length), 1); const data = new Uint8Array(columns * rows * 4); - let rgbf = [0, 0, 0, 0]; + const rgbf = [0, 0, 0, 0]; const empty = [0, 0, 0, 0] as const; // write the rgb of the color, and encode the filter boolean into the alpha channel for (let columnIndex = 0; columnIndex < columns; columnIndex += 1) { diff --git a/packages/scatterbrain/src/shader.ts b/packages/scatterbrain/src/shader.ts index ef28535d..71d47d1b 100644 --- a/packages/scatterbrain/src/shader.ts +++ b/packages/scatterbrain/src/shader.ts @@ -1,12 +1,12 @@ -// we have to generate a shader, due to the runtime-variable way in which columns of data -// are used for filtering (some in a range, some via a lookup table) +/** biome-ignore-all lint/style/noUnusedTemplateLiteral: not at all helpful*/ -import REGL from 'regl'; +import type REGL from 'regl'; import type { ScatterbrainDataset, SlideviewScatterbrainDataset } from './types'; +import type { Cacheable, CachedVertexBuffer } from '@alleninstitute/vis-core'; +import { Box2D, type box2D, type Interval, type vec2, type vec4 } from '@alleninstitute/vis-geometry'; import * as lodash from 'lodash'; -const { filter, keys, mapValues, reduce } = lodash; // ugh -import { Box2D, type vec4, type box2D, type Interval, type vec2 } from '@alleninstitute/vis-geometry'; -import { type Cacheable, type CachedVertexBuffer } from '@alleninstitute/vis-core'; +const { keys, mapValues, reduce } = lodash; + // the set of columns and what to do with them can vary // there might be 3 categorical columns and 2 range columns @@ -294,7 +294,7 @@ export function generate(config: Config): ScatterbrainShaderUtils { const colorByQuantitative = /*glsl*/ ` texture2D(${gradientTable},vec2(rangeParameter(${colorByColumn},${rangeFor(colorByColumn)}),0.5)) `; - const colorize = categoryColumnIndex != -1 ? colorByCategorical : colorByQuantitative; + const colorize = categoryColumnIndex !== -1 ? colorByCategorical : colorByQuantitative; const colorByCategoricalId = /*glsl*/ ` float G = mod(${colorByColumn},256.0); @@ -310,8 +310,8 @@ export function generate(config: Config): ScatterbrainShaderUtils { return mix(filteredOutColor,${colorize},isFilteredIn()); ` : categoryColumnIndex === -1 - ? colorByQuantitativeValue - : colorByCategoricalId; + ? colorByQuantitativeValue + : colorByCategoricalId; return { attributes, uniforms, @@ -333,8 +333,8 @@ export type ShaderSettings = { quantitativeFilters: readonly string[]; // the names of quantitative variables mode: 'color' | 'info'; colorBy: - | { kind: 'metadata'; column: string } - | { kind: 'quantitative'; column: string; gradient: 'viridis' | 'inferno'; range: Interval }; + | { kind: 'metadata'; column: string } + | { kind: 'quantitative'; column: string; gradient: 'viridis' | 'inferno'; range: Interval }; }; export function configureShader(settings: ShaderSettings): { diff --git a/site/src/examples/scatterbrain/demo.tsx b/site/src/examples/scatterbrain/demo.tsx index 654857b0..30481a65 100644 --- a/site/src/examples/scatterbrain/demo.tsx +++ b/site/src/examples/scatterbrain/demo.tsx @@ -15,7 +15,7 @@ const tenx = export function scatterBrainDemo() { return ( - + ); } From 656cb279ff6a6b2e3c471426d5dfed1d685b2d51 Mon Sep 17 00:00:00 2001 From: noah Date: Wed, 11 Feb 2026 10:44:03 -0800 Subject: [PATCH 28/29] fmt again --- packages/scatterbrain/src/dataset.ts | 2 +- packages/scatterbrain/src/shader.ts | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/scatterbrain/src/dataset.ts b/packages/scatterbrain/src/dataset.ts index f664d33d..98d00044 100644 --- a/packages/scatterbrain/src/dataset.ts +++ b/packages/scatterbrain/src/dataset.ts @@ -191,7 +191,7 @@ const slideviewMetadataSchema = z.object({ // biome-ignore lint/suspicious/noExplicitAny: this fn is intended to accept the return value of JSON.parse() - any is appropriate here export function loadDataset(raw: any): Dataset | undefined { - if (typeof raw !== 'object' || !raw) return undefined + if (typeof raw !== 'object' || !raw) return undefined; if (raw.slides) { const metadata = slideviewMetadataSchema.safeParse(raw); diff --git a/packages/scatterbrain/src/shader.ts b/packages/scatterbrain/src/shader.ts index 71d47d1b..f27a2482 100644 --- a/packages/scatterbrain/src/shader.ts +++ b/packages/scatterbrain/src/shader.ts @@ -7,7 +7,6 @@ import { Box2D, type box2D, type Interval, type vec2, type vec4 } from '@allenin import * as lodash from 'lodash'; const { keys, mapValues, reduce } = lodash; - // the set of columns and what to do with them can vary // there might be 3 categorical columns and 2 range columns // each range column (a vertex attrib) uses a uniform vec2 as its filter range @@ -310,8 +309,8 @@ export function generate(config: Config): ScatterbrainShaderUtils { return mix(filteredOutColor,${colorize},isFilteredIn()); ` : categoryColumnIndex === -1 - ? colorByQuantitativeValue - : colorByCategoricalId; + ? colorByQuantitativeValue + : colorByCategoricalId; return { attributes, uniforms, @@ -333,8 +332,8 @@ export type ShaderSettings = { quantitativeFilters: readonly string[]; // the names of quantitative variables mode: 'color' | 'info'; colorBy: - | { kind: 'metadata'; column: string } - | { kind: 'quantitative'; column: string; gradient: 'viridis' | 'inferno'; range: Interval }; + | { kind: 'metadata'; column: string } + | { kind: 'quantitative'; column: string; gradient: 'viridis' | 'inferno'; range: Interval }; }; export function configureShader(settings: ShaderSettings): { From 050e7301228876316f9a090bcfdf6fd4208d2ef4 Mon Sep 17 00:00:00 2001 From: noah Date: Wed, 11 Feb 2026 10:47:40 -0800 Subject: [PATCH 29/29] lint --- packages/scatterbrain/src/renderer.ts | 2 +- packages/scatterbrain/src/shader.test.ts | 3 --- site/src/examples/scatterbrain/demo.tsx | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/scatterbrain/src/renderer.ts b/packages/scatterbrain/src/renderer.ts index e79634c3..10a9e983 100644 --- a/packages/scatterbrain/src/renderer.ts +++ b/packages/scatterbrain/src/renderer.ts @@ -1,7 +1,7 @@ import type { SharedPriorityCache } from '@alleninstitute/vis-core'; import type REGL from 'regl'; import type { ColumnRequest, ScatterbrainDataset, SlideviewScatterbrainDataset, TreeNode } from './types'; -import { Box2D, type box2D, type vec2, type vec4 } from '@alleninstitute/vis-geometry'; +import { Box2D, type box2D, type vec4 } from '@alleninstitute/vis-geometry'; import { MakeTaggedBufferView } from './typed-array'; import keys from 'lodash/keys'; import reduce from 'lodash/reduce'; diff --git a/packages/scatterbrain/src/shader.test.ts b/packages/scatterbrain/src/shader.test.ts index 9fa6d578..948f9777 100644 --- a/packages/scatterbrain/src/shader.test.ts +++ b/packages/scatterbrain/src/shader.test.ts @@ -511,9 +511,6 @@ describe('configure', () => { gl_Position = getClipPosition(); }`; expect(config).toEqual(expectedConfig); - console.log('~~~~~~~~~~~~~~~~~~~~~~~~~~~'); - console.log(shaders.vs); - console.log('~~~~~~~~~~~~~~~~~~~~~~~~~~~'); expect(shaders.vs.replace(/\s/g, '')).toEqual(expectedShader.replace(/\s/g, '')); expect(columnNameToShaderName).toEqual({ '123': 'COLOR_BY_MEASURE', diff --git a/site/src/examples/scatterbrain/demo.tsx b/site/src/examples/scatterbrain/demo.tsx index 30481a65..f154221c 100644 --- a/site/src/examples/scatterbrain/demo.tsx +++ b/site/src/examples/scatterbrain/demo.tsx @@ -104,6 +104,6 @@ function Demo(props: Props) { const client = connectToCache(cache, renderOneFrame); renderOneFrame(); } - }, [cnvs.current, dataset, server]); + }, [dataset, server, screenSize]); return ; }