diff --git a/docs/releases/v2.13.4.md b/docs/releases/v2.13.4.md new file mode 100644 index 0000000000000000000000000000000000000000..d91f822860eae0e66df37e5343a397043cec012f --- /dev/null +++ b/docs/releases/v2.13.4.md @@ -0,0 +1,16 @@ +# v2.13.4 + +## Feature + +- Properly navigate to volume of interest based on volume meta: + - properly calculate the orientation + - properly calculate enclosed, and navigate to closest point if outside + +## Bugfix + +- Fixed expected siibra-api version + +## Behind the scenes + +- Temporary workaround for volume meta +- More efficient caching of meta retrieval diff --git a/mkdocs.yml b/mkdocs.yml index 6693def5a5de6dd4dd664a5ff073f6a16b5c66bc..dd014bb59f47efbe81010951fd0be4d184404be6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -33,6 +33,7 @@ nav: - Fetching datasets: 'advanced/datasets.md' - Display non-atlas volumes: 'advanced/otherVolumes.md' - Release notes: + - v2.13.4: 'releases/v2.13.4.md' - v2.13.3: 'releases/v2.13.3.md' - v2.13.2: 'releases/v2.13.2.md' - v2.13.1: 'releases/v2.13.1.md' diff --git a/package.json b/package.json index f66269cfca6562943068403b7a67a4d53e9a7ac4..1bbda561272633686c7c388c68220cff4f83c4ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "siibra-explorer", - "version": "2.13.3", + "version": "2.13.4", "description": "siibra-explorer - explore brain atlases. Based on humanbrainproject/nehuba & google/neuroglancer. Built with angular", "scripts": { "lint": "eslint src --ext .ts", diff --git a/src/atlasComponents/sapi/sapi.service.ts b/src/atlasComponents/sapi/sapi.service.ts index c3543e08c2ed155d20de261fc0eb74321dcf9bfd..f2b2050ff889ab7d1590ab57280cb5e050831f5d 100644 --- a/src/atlasComponents/sapi/sapi.service.ts +++ b/src/atlasComponents/sapi/sapi.service.ts @@ -1,7 +1,7 @@ import { Injectable } from "@angular/core"; import { HttpClient } from '@angular/common/http'; import { catchError, map, shareReplay, switchMap, take, tap } from "rxjs/operators"; -import { getExportNehuba, noop } from "src/util/fn"; +import { CachedFunction, getExportNehuba, noop } from "src/util/fn"; import { MatSnackBar } from "@angular/material/snack-bar"; import { AtlasWorkerService } from "src/atlasViewer/atlasViewer.workerService.service"; import { EnumColorMapName } from "src/util/colorMaps"; @@ -22,7 +22,7 @@ export const useViewer = { } as const export const SIIBRA_API_VERSION_HEADER_KEY='x-siibra-api-version' -export const EXPECTED_SIIBRA_API_VERSION = '0.3.13' +export const EXPECTED_SIIBRA_API_VERSION = '0.3.14' let BS_ENDPOINT_CACHED_VALUE: Observable<string> = null @@ -34,6 +34,7 @@ type PaginatedResponse<T> = { pages?: number } +const serialization = (sxplr: SxplrTemplate) => sxplr?.id @Injectable({ providedIn: 'root' @@ -208,6 +209,9 @@ export class SAPI{ }) } + @CachedFunction({ + serialization: (id, params) => `featDetail:${id}:${Object.entries(params || {}).map(([key, val]) => `${key},${val}`).join('.')}` + }) getV3FeatureDetailWithId(id: string, params: Record<string, string> = {}) { return this.v3Get("/feature/{feature_id}", { path: { @@ -215,7 +219,8 @@ export class SAPI{ }, query_param: params } as any).pipe( - switchMap(val => translateV3Entities.translateFeature(val)) + switchMap(val => translateV3Entities.translateFeature(val)), + shareReplay(1), ) } @@ -446,7 +451,10 @@ export class SAPI{ }).toPromise() } - public useViewer(template: SxplrTemplate) { + // useViewer is debouncely called everytime user updates the url + // caches the response + @CachedFunction({ serialization }) + useViewer(template: SxplrTemplate) { if (!template) { return of(null as keyof typeof useViewer) } diff --git a/src/atlasComponents/sapi/translateV3.ts b/src/atlasComponents/sapi/translateV3.ts index dabec3badf48d08d9e8a739459ebe5b1a5985f97..666f24b5246b56d704451a9090feb5f2b3a233e9 100644 --- a/src/atlasComponents/sapi/translateV3.ts +++ b/src/atlasComponents/sapi/translateV3.ts @@ -14,6 +14,92 @@ const BIGBRAIN_XZ = [ [68.533, 62.222], ] +const TMP_META_REGISTRY: Record<string, MetaV1Schema> = { + "https://data-proxy.ebrains.eu/api/v1/public/buckets/tanner-test/fullSharded_v1": { + version: 1, + preferredColormap: ["greyscale"], + data: { + type: "image/1d", + range: [{ + min: 0.2, + max: 0.4 + }] + }, + transform: [[-1,0,0,5662500],[0,0,1,-6562500],[0,-1,0,3962500],[0,0,0,1]] + }, + "https://1um.brainatlas.eu/pli-bigbrain/fom/precomputed": { + version: 1, + data: { + type: "image/3d" + }, + bestViewPoints: [{ + type: "enclosed", + points: [{ + type: "point", + value: [-16.625, -80.813, 41.801] + },{ + type: "point", + value: [-16.625, -64.293, -66.562] + },{ + type: "point", + value: [-16.625, 86.557, -42.685] + },{ + type: "point", + value: [-16.625, 69.687, 63.015] + }] + }], + transform: [[7.325973427896315e-8,2.866510051546811e-8,-1,-16600000],[-0.9899035692214966,0.14174138009548187,-6.845708355740499e-8,70884888],[-0.14174138009548187,-0.9899035692214966,-3.875962661936683e-8,64064704],[0,0,0,1]] + }, + "https://1um.brainatlas.eu/cyto_reconstructions/ebrains_release/BB_1um/VOI_1/precomputed": { + version: 1, + data: { + type: "image/1d" + }, + preferredColormap: ["greyscale"], + bestViewPoints: [{ + type: "enclosed", + points: [{ + type: "point", + value: [-11.039, -58.450, 4.311] + },{ + type: "point", + value: [-9.871, -58.450, -1.649] + },{ + type: "point", + value: [-3.947, -58.450, -0.377] + },{ + type: "point", + value: [-5.079, -58.450, 5.496] + }] + }], + transform: [[0.9986788630485535,0.1965026557445526,0.27269935607910156,-11887736],[0,0,1,-61450000],[0.20538295805454254,-0.9990047812461853,0.052038706839084625,4165836.25],[0,0,0,1]] + }, + "https://1um.brainatlas.eu/cyto_reconstructions/ebrains_release/BB_1um/VOI_2/precomputed": { + version: 1, + data: { + type: "image/1d" + }, + preferredColormap: ["greyscale"], + bestViewPoints: [{ + type: "enclosed", + points: [{ + type: "point", + value: [-10.011, -58.450, -2.879] + },{ + type: "point", + value: [-8.707, -58.450, -8.786] + },{ + type: "point", + value: [-3.305, -58.450, -7.728] + },{ + type: "point", + value: [-4.565, -58.450, -1.703] + }] + }], + transform: [[0.9199221134185791,0.22926874458789825,0.2965584993362427,-10976869],[0,0,1,-61450000],[0.18267445266246796,-1.0079853534698486,0.01068924367427826,-2853557],[0,0,0,1]] + }, +} + class TranslateV3 { #atlasMap: Map<string, PathReturn<"/atlases/{atlas_id}">> = new Map() @@ -365,6 +451,9 @@ class TranslateV3 { } async fetchMeta(url: string): Promise<MetaV1Schema|null> { + if (url in TMP_META_REGISTRY) { + return TMP_META_REGISTRY[url] + } const is1umRegisteredSlices = url.startsWith("https://1um.brainatlas.eu/registered_sections/bigbrain") if (is1umRegisteredSlices) { const found = /B20_([0-9]{4})/.exec(url) diff --git a/src/util/colorMaps.ts b/src/util/colorMaps.ts index e62b785434757f3e36bf1c28cc92e401fa9b2a50..ff2a9454644ff79443482d0a4cbf02f591e414d8 100644 --- a/src/util/colorMaps.ts +++ b/src/util/colorMaps.ts @@ -22,6 +22,13 @@ export enum EnumColorMapName{ RGB="rgb (3 channel)" } +export const CMByName: Record<string, EnumColorMapName> = {} + +for (const [key, value] of Object.entries(EnumColorMapName)) { + CMByName[key] = value + CMByName[value as string] = value +} + interface IColorMap{ /** * header diff --git a/src/util/constants.ts b/src/util/constants.ts index da2e383309c9b2493780cb429a65b669ccb65941..2023510adb1789be8083d72880400c3569e8f6ad 100644 --- a/src/util/constants.ts +++ b/src/util/constants.ts @@ -71,48 +71,8 @@ export const getHttpHeader: () => HttpHeaders = () => { } export const COLORMAP_IS_JET = `// iav-colormap-is-jet` -import { EnumColorMapName, mapKeyColorMap } from './colorMaps' import { InjectionToken } from "@angular/core" -export const getShader = ({ - colormap = EnumColorMapName.GREYSCALE, - lowThreshold = 0, - highThreshold = 1, - brightness = 0, - contrast = 0, - removeBg = false -} = {}): string => { - const { header, main, premain, override } = mapKeyColorMap.get(colormap) || (() => { - return mapKeyColorMap.get(EnumColorMapName.GREYSCALE) - })() - - if (override) { - return override() - } - - // so that if lowthreshold is defined to be 0, at least some background removal will be done - const _lowThreshold = lowThreshold + 1e-10 - return `${header} -${premain} -void main() { - float raw_x = toNormalized(getDataValue()); - float x = (raw_x - ${_lowThreshold.toFixed(10)}) / (${highThreshold - _lowThreshold}) ${ brightness > 0 ? '+' : '-' } ${Math.abs(brightness).toFixed(10)}; - - ${ removeBg ? 'if(x>1.0){emitTransparent();}else if(x<0.0){emitTransparent();}else{' : '' } - vec3 rgb; - ${main} - emitRGB(rgb*exp(${contrast.toFixed(10)})); - ${ removeBg ? '}' : '' } -} -` -} - -export const PMAP_DEFAULT_CONFIG = { - colormap: EnumColorMapName.VIRIDIS, - lowThreshold: 0.05, - removeBg: true -} - export const CYCLE_PANEL_MESSAGE = `[spacebar] to cycle through views` export const UNSUPPORTED_PREVIEW = [{ diff --git a/src/util/fn.ts b/src/util/fn.ts index 1508dc6303509477d45e744e28f23d5e1ae9d1eb..c8467e55d899bf5e65f7b6dafcb8196e2d470a0b 100644 --- a/src/util/fn.ts +++ b/src/util/fn.ts @@ -1,5 +1,7 @@ import { interval, Observable, of } from 'rxjs' import { filter, mapTo, take } from 'rxjs/operators' +import { CMByName, EnumColorMapName, mapKeyColorMap } from './colorMaps' +import { MetaV1Schema } from 'src/atlasComponents/sapi/typeV3' // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -443,3 +445,139 @@ export async function waitFor(predicate: () => boolean) { await wait(16) } } +/** + * copied from ng-layer-tune@0.0.22 + * TODO export to an individual component/library + * OR use monorepo + */ +const cmEncodingVersion = 'encodedCmState-0.1' + +type TGetShaderCfg = { + colormap: EnumColorMapName + lowThreshold: number + highThreshold: number + brightness: number + contrast: number + removeBg: boolean + hideZero: boolean + opacity: number +} + +function encodeBool(...flags: boolean[]) { + if (flags.length > 8) { + throw new Error(`encodeBool can only handle upto 8 bools`) + } + let rValue = 0 + flags.forEach((flag, idx) => { + if (flag) { + rValue += (1 << idx) + } + }) + return rValue +} + + +export function encodeState(cfg: TGetShaderCfg): string { + const { + brightness, + colormap, + contrast, + hideZero, + highThreshold, + lowThreshold, + opacity, + removeBg + } = cfg + + /** + * encode Enum as key of Enum + */ + const cmstring = Object.keys(EnumColorMapName).find(v => EnumColorMapName[v] === colormap) + + const array = new Float32Array([ + brightness, + contrast, + lowThreshold, + highThreshold, + opacity, + encodeBool(hideZero, removeBg) + ]) + + const encodedVal = window.btoa(new Uint8Array(array.buffer).reduce((data, v) => data + String.fromCharCode(v), '')) + return `${cmEncodingVersion}:${cmstring}:${encodedVal}` +} + +export const getShader = ({ + colormap = EnumColorMapName.GREYSCALE, + lowThreshold = 0, + highThreshold = 1, + brightness = 0, + contrast = 0, + removeBg = false +} = {}): string => { + const { header, main, premain, override } = mapKeyColorMap.get(colormap) || (() => { + return mapKeyColorMap.get(EnumColorMapName.GREYSCALE) + })() + + const encodedStr = encodeState({ + colormap, + brightness, + highThreshold, + lowThreshold, + contrast, + hideZero: false, + opacity: 1.0, + removeBg: false + }) + + if (override) { + return `// ${encodedStr}\n${override()}` + } + + // so that if lowthreshold is defined to be 0, at least some background removal will be done + const _lowThreshold = lowThreshold + 1e-10 + return `// ${encodedStr} +${header} +${premain} +void main() { + float raw_x = toNormalized(getDataValue()); + float x = (raw_x - ${_lowThreshold.toFixed(10)}) / (${highThreshold - _lowThreshold}) ${ brightness > 0 ? '+' : '-' } ${Math.abs(brightness).toFixed(10)}; + + ${ removeBg ? 'if(x>1.0){emitTransparent();}else if(x<0.0){emitTransparent();}else{' : '' } + vec3 rgb; + ${main} + emitRGB(rgb*exp(${contrast.toFixed(10)})); + ${ removeBg ? '}' : '' } +} +` +} + +export function getShaderFromMeta(meta: MetaV1Schema){ + let colormap = EnumColorMapName.GREYSCALE + + if (meta?.data?.type === "image/3d") { + colormap = EnumColorMapName.RGB + } else { + for (const _colormap of meta?.preferredColormap || []) { + if (_colormap in CMByName) { + colormap = CMByName[_colormap] + break + } + } + } + + let low: number = 0 + let high: number = 1 + + if (meta?.data?.type === "image/1d") { + const { max, min } = meta.data.range?.[0] || { min: 0, max: 1 } + low = min + high = max + } + + return getShader({ + colormap, + lowThreshold: low, + highThreshold: high + }) +} diff --git a/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.effects.ts b/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.effects.ts index d0f4bd4c9b4a8cd93c5eb724dc74fb4f4170faf2..eece084e4da29edeebd662ffc53811db49557e67 100644 --- a/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.effects.ts +++ b/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.effects.ts @@ -8,7 +8,7 @@ import { SAPI } from "src/atlasComponents/sapi" import { atlasAppearance, atlasSelection } from "src/state"; import { arrayEqual } from "src/util/array"; import { EnumColorMapName } from "src/util/colorMaps"; -import { getShader } from "src/util/constants"; +import { getShader } from "src/util/fn"; import { PMAP_LAYER_NAME } from "../constants"; import { QuickHash } from "src/util/fn"; import { getParcNgId } from "../config.service"; diff --git a/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.service.ts b/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.service.ts index dffa37da9a7f919967e83b9a3af061b1d3ce72a9..38d24aea975caaec215434de964f940741640c33 100644 --- a/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.service.ts +++ b/src/viewerModule/nehuba/layerCtrl.service/layerCtrl.service.ts @@ -10,7 +10,7 @@ import { arrayEqual } from "src/util/array"; import { SxplrRegion } from "src/atlasComponents/sapi/sxplrTypes"; import { AnnotationLayer } from "src/atlasComponents/annotations"; import { PMAP_LAYER_NAME } from "../constants" -import { getShader } from "src/util/constants"; +import { getShader } from "src/util/fn"; import { BaseService } from "../base.service/base.service"; export const BACKUP_COLOR = { diff --git a/src/viewerModule/nehuba/ngLayerCtlModule/ngLayerCtl/ngLayerCtrl.component.ts b/src/viewerModule/nehuba/ngLayerCtlModule/ngLayerCtl/ngLayerCtrl.component.ts index 9ddf114f71c86ee55bfbcf0c9b85d186bb7c496b..42c0c7264c3b5d63b9a87e7cb41ece949b3db698 100644 --- a/src/viewerModule/nehuba/ngLayerCtlModule/ngLayerCtl/ngLayerCtrl.component.ts +++ b/src/viewerModule/nehuba/ngLayerCtlModule/ngLayerCtl/ngLayerCtrl.component.ts @@ -5,9 +5,7 @@ import { CONST } from "common/constants" import { Observable } from "rxjs"; import { atlasAppearance, atlasSelection } from "src/state"; import { NehubaViewerUnit, NEHUBA_INSTANCE_INJTKN } from "src/viewerModule/nehuba"; -import { getExportNehuba } from "src/util/fn"; -import { getShader } from "src/util/constants"; -import { EnumColorMapName } from "src/util/colorMaps"; +import { getExportNehuba, getShaderFromMeta } from "src/util/fn"; import { MetaV1Schema, isEnclosed } from "src/atlasComponents/sapi/typeV3"; type Vec4 = [number, number, number, number] @@ -122,17 +120,7 @@ export class NgLayerCtrlCmp implements OnChanges, OnDestroy{ this.removeLayer() this.removeLayer = null } - try { - const resp = await fetch(`${this.source}/meta.json`) - const metaJson = await resp.json() - const is3D = metaJson?.data?.type === "image/3d" - if (is3D) { - this.shader = getShader({ - colormap: EnumColorMapName.RGB - }) - } - // eslint-disable-next-line no-empty - } catch (e) {} + this.shader = getShaderFromMeta(this.meta) this.store.dispatch( atlasAppearance.actions.addCustomLayer({ @@ -187,21 +175,70 @@ export class NgLayerCtrlCmp implements OnChanges, OnDestroy{ const pt2 = vec3.fromValues(...enclosed.points[2].value) vec3.sub(pt1, pt1, pt0) vec3.sub(pt2, pt2, pt0) + + /** + * pt1 and pt2 now unit vectors going from pt0 to pt1 and pt0 to pt2 respectively + */ vec3.normalize(pt1, pt1) vec3.normalize(pt2, pt2) + /** + * calculate rotation first + */ + const z0 = vec3.fromValues(0, 0, 1) + const cross = vec3.cross(vec3.create(), z0, pt1) + const w = Math.sqrt(2) + vec3.dot(z0, pt1) + quat.set(q, ...cross, w) + quat.normalize(q, q) + + /** + * curr is now vector going from pt0 to current navigation position + */ vec3.sub(curr, curr, pt0) - vec3.mul(pt1, pt1, curr) - vec3.mul(pt2, pt2, curr) + const normal = vec3.cross(vec3.create(), pt1, pt2) + vec3.normalize(normal, normal) + vec3.mul(normal, normal, curr) + + const inPlaneDisplacement = vec3.sub(vec3.create(), curr, normal) + + /** + * check enclosedness + * also caches the closes point + */ + const ipdCoord = vec3.add(vec3.create(), pt0, inPlaneDisplacement) + const allPoints = enclosed.points.map(v => vec3.fromValues(...v.value)) + let sum = 0 + let minDist: number = Number.POSITIVE_INFINITY + let pos: any + for (let i = 0; i < allPoints.length; i++) { + const a = vec3.sub(vec3.create(), allPoints[i], ipdCoord) + const b = vec3.sub(vec3.create(), allPoints[(i + 1) % allPoints.length], ipdCoord) + const angle = vec3.angle(a, b) + sum += angle - const resultant = vec3.add(vec3.create(), pt1, pt2) - vec3.add(resultant, resultant, pt0) + const dist = vec3.length(a) + if (dist < minDist) { + minDist = dist + pos = allPoints[i] + } + } + + /** + * Since inPlaneDisplacement is a point on the plane + * If the sum of all points == PI, then the point is enclosed + * Assuming simple concave shapes + */ + const isEnclosed = Math.abs(sum - (2 * Math.PI)) < 0.05 + + const resultant = isEnclosed + ? vec3.add(vec3.create(), inPlaneDisplacement, pt0) + : pos vec3.scale(resultant, resultant, 1e6) position = Array.from(resultant) } - + this.store.dispatch( atlasSelection.actions.navigateTo({ navigation: { diff --git a/src/viewerModule/nehuba/userLayers/service.ts b/src/viewerModule/nehuba/userLayers/service.ts index 65bbcebb2e1a705310769f949217d6badf9bfe6e..5f9c1e130ce2ea589b26293292ccefe2be0ae915 100644 --- a/src/viewerModule/nehuba/userLayers/service.ts +++ b/src/viewerModule/nehuba/userLayers/service.ts @@ -1,8 +1,8 @@ import { Injectable, OnDestroy } from "@angular/core" import { MatDialog } from "@angular/material/dialog" import { select, Store } from "@ngrx/store" -import { concat, from, of, Subscription } from "rxjs" -import { catchError, map, pairwise, switchMap } from "rxjs/operators" +import { concat, forkJoin, from, of, Subscription } from "rxjs" +import { map, pairwise, switchMap } from "rxjs/operators" import { linearTransform, TVALID_LINEAR_XFORM_DST, @@ -12,9 +12,11 @@ import { AtlasWorkerService } from "src/atlasViewer/atlasViewer.workerService.se import { RouterService } from "src/routerModule/router.service" import * as atlasAppearance from "src/state/atlasAppearance" import { EnumColorMapName } from "src/util/colorMaps" -import { getShader } from "src/util/constants" +import { getShader, getShaderFromMeta } from "src/util/fn" import { getExportNehuba, getUuid } from "src/util/fn" import { UserLayerInfoCmp } from "./userlayerInfo/userlayerInfo.component" +import { translateV3Entities } from "src/atlasComponents/sapi/translateV3" +import { MetaV1Schema } from "src/atlasComponents/sapi/typeV3" type OmitKeys = "clType" | "id" | "source" type Meta = { @@ -214,17 +216,36 @@ export class UserLayerService implements OnDestroy { * if so, try to fetch it, and set it as transform */ if (!curr) { - return of([prev, curr, null]) + return of({ prev, curr, meta: null as MetaV1Schema }) } if (!curr.startsWith("precomputed://")) { - return of([prev, curr, null]) + return of({ prev, curr, meta: null as MetaV1Schema }) } - return from(fetch(`${curr.replace('precomputed://', '')}/transform.json`).then(res => res.json())).pipe( - catchError(() => of([prev, curr, null])), - map(transform => [prev, curr, transform]) + const url = curr.replace("precomputed://", "") + + + return forkJoin({ + transform: fetch(`${url}/transform.json`) + .then(res => res.json() as Promise<MetaV1Schema["transform"]>) + .catch(_e => null as MetaV1Schema["transform"]), + meta: from( + translateV3Entities.fetchMeta(url) + .catch(_e => null as MetaV1Schema) + ) + }).pipe( + map(({ transform, meta }) => { + return { + prev, + curr, + meta: { + ...meta, + transform: meta?.transform || transform + } + } + }), ) }) - ).subscribe(([prev, curr, transform]) => { + ).subscribe(({ prev, curr, meta }) => { if (prev) { this.removeUserLayer(prev) } @@ -236,10 +257,8 @@ export class UserLayerService implements OnDestroy { message: `Overlay layer populated in URL`, }, { - shader: getShader({ - colormap: EnumColorMapName.MAGMA, - }), - transform + shader: getShaderFromMeta(meta), + transform: meta.transform } ) }