diff --git a/src/atlasViewer/atlasViewer.constantService.service.spec.ts b/src/atlasViewer/atlasViewer.constantService.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..71c9ecd02371900c4e3403e024f9d6e9c648a153 --- /dev/null +++ b/src/atlasViewer/atlasViewer.constantService.service.spec.ts @@ -0,0 +1,110 @@ +import { encodeNumber, decodeToNumber } from './atlasViewer.constantService.service' +import {} from 'jasmine' + +const FLOAT_PRECISION = 6 + +describe('encodeNumber/decodeToNumber', () => { + + + const getCompareOriginal = (original: number[]) => (element:string, index: number) => + original[index].toString().length >= element.length + + + const lengthShortened = (original: number[], encodedString: string[]) => + encodedString.every(getCompareOriginal(original)) + + it('should encode/decode positive integer as expected', () => { + + const positiveInt = [ + 0, + 1, + 99999999999, + 12347 + ] + + const encodedString = positiveInt.map(n => encodeNumber(n)) + const decodedString = encodedString.map(s => decodeToNumber(s)) + expect(decodedString).toEqual(positiveInt) + + expect(lengthShortened(positiveInt, encodedString)).toBe(true) + }) + + it('should encode/decode ANY positive integer as expected', () => { + const posInt = Array(1000).fill(null).map(() => { + const numDig = Math.ceil(Math.random() * 7) + return Math.floor(Math.random() * Math.pow(10, numDig)) + }) + const encodedString = posInt.map(n => encodeNumber(n)) + const decodedNumber = encodedString.map(s => decodeToNumber(s)) + expect(decodedNumber).toEqual(posInt) + + expect(lengthShortened(posInt, encodedString)).toBe(true) + }) + + + it('should encode/decode signed integer as expected', () => { + + const signedInt = [ + 0, + -0, + -1, + 1, + 128, + -54 + ] + + const encodedString = signedInt.map(n => encodeNumber(n)) + const decodedNumber = encodedString.map(s => decodeToNumber(s)) + + /** + * -0 will be converted to 0 by the encode/decode process, but does not deep equal, according to jasmine + */ + expect(decodedNumber).toEqual(signedInt.map(v => v === 0 ? 0 : v)) + + expect(lengthShortened(signedInt, encodedString)).toBe(true) + }) + + it('should encode/decode ANY signed integer as expected', () => { + + const signedInt = Array(1000).fill(null).map(() => { + const numDig = Math.ceil(Math.random() * 7) + return Math.floor(Math.random() * Math.pow(10, numDig)) * (Math.random() > 0.5 ? 1 : -1) + }) + const encodedString = signedInt.map(n => encodeNumber(n)) + const decodedNumber = encodedString.map(s => decodeToNumber(s)) + + /** + * -0 will be converted to 0 by the encode/decode process, but does not deep equal, according to jasmine + */ + expect(decodedNumber).toEqual(signedInt.map(v => v === 0 ? 0 : v)) + + expect(lengthShortened(signedInt, encodedString)).toBe(true) + }) + + + it('should encode/decode float as expected', () => { + const floatNum = [ + 0.111, + 12.23, + 1723.0 + ] + + const encodedString = floatNum.map(f => encodeNumber(f, { float: true })) + const decodedNumber = encodedString.map(s => decodeToNumber(s, { float: true })) + expect(decodedNumber.map(n => n.toFixed(FLOAT_PRECISION))).toEqual(floatNum.map(n => n.toFixed(FLOAT_PRECISION))) + }) + + it('should encode/decode ANY float as expected', () => { + const floatNums = Array(1000).fill(null).map(() => { + const numDig = Math.ceil(Math.random() * 7) + return (Math.random() > 0.5 ? 1 : -1) * Math.floor( + Math.random() * Math.pow(10, numDig) + ) + }) + + const encodedString = floatNums.map(f => encodeNumber(f, { float: true })) + const decodedNumber = encodedString.map(s => decodeToNumber(s, { float: true })) + + expect(floatNums.map(v => v.toFixed(FLOAT_PRECISION))).toEqual(decodedNumber.map(n => n.toFixed(FLOAT_PRECISION))) + }) +}) \ No newline at end of file diff --git a/src/atlasViewer/atlasViewer.constantService.service.ts b/src/atlasViewer/atlasViewer.constantService.service.ts index 75a157e0c68ae679c2ce4dde61b83179972a5125..a452c83b0d81b948ff923994f2fa5952ab9e9146 100644 --- a/src/atlasViewer/atlasViewer.constantService.service.ts +++ b/src/atlasViewer/atlasViewer.constantService.service.ts @@ -290,4 +290,98 @@ export const SUPPORT_LIBRARY_MAP : Map<string,HTMLElement> = new Map([ ['vue@2.5.16',parseURLToElement('https://cdn.jsdelivr.net/npm/vue@2.5.16/dist/vue.js')], ['preact@8.4.2',parseURLToElement('https://cdn.jsdelivr.net/npm/preact@8.4.2/dist/preact.min.js')], ['d3@5.7.0',parseURLToElement('https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js')] -]) \ No newline at end of file +]) + +/** + * First attempt at encoding int (e.g. selected region, navigation location) from number (loc info density) to b64 (higher info density) + * The constraint is that the cipher needs to be commpatible with URI encoding + * and a URI compatible separator is required. + * + * The implementation below came from + * https://stackoverflow.com/a/6573119/6059235 + * + * While a faster solution exist in the same post, this operation is expected to be done: + * - once per 1 sec frequency + * - on < 1000 numbers + * + * So performance is not really that important (Also, need to learn bitwise operation) + */ + +const cipher = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-' +export const separator = "." +const negString = '~' + +const encodeInt = (number: number) => { + if (number % 1 !== 0) throw 'cannot encodeInt on a float. Ensure float flag is set' + if (isNaN(Number(number)) || number === null || number === Number.POSITIVE_INFINITY) + throw 'The input is not valid' + + let rixit // like 'digit', only in some non-decimal radix + let residual + let result = '' + + if (number < 0) { + result += negString + residual = Math.floor(number * -1) + } else { + residual = Math.floor(number) + } + + while (true) { + rixit = residual % 64 + // console.log("rixit : " + rixit) + // console.log("result before : " + result) + result = cipher.charAt(rixit) + result + // console.log("result after : " + result) + // console.log("residual before : " + residual) + residual = Math.floor(residual / 64) + // console.log("residual after : " + residual) + + if (residual == 0) + break; + } + return result +} + +interface B64EncodingOption { + float: boolean +} + +const defaultB64EncodingOption = { + float: false +} + +export const encodeNumber: (number:number, option?: B64EncodingOption) => string = (number: number, { float = false }: B64EncodingOption = defaultB64EncodingOption) => { + if (!float) return encodeInt(number) + else { + const floatArray = new Float32Array(1) + floatArray[0] = number + const intArray = new Uint32Array(floatArray.buffer) + const castedInt = intArray[0] + return encodeInt(castedInt) + } +} + +const decodetoInt = (encodedString: string) => { + let _encodedString, negFlag = false + if (encodedString.slice(-1) === negString) { + negFlag = true + _encodedString = encodedString.slice(0, -1) + } else { + _encodedString = encodedString + } + return (negFlag ? -1 : 1) * [..._encodedString].reduce((acc,curr) => { + return acc * 64 + cipher.indexOf(curr) + }, 0) +} + +export const decodeToNumber: (encodedString:string, option?: B64EncodingOption) => number = (encodedString: string, {float = false} = defaultB64EncodingOption) => { + if (!float) return decodetoInt(encodedString) + else { + const _int = decodetoInt(encodedString) + const intArray = new Uint32Array(1) + intArray[0] = _int + const castedFloat = new Float32Array(intArray.buffer) + return castedFloat[0] + } +} \ No newline at end of file diff --git a/src/atlasViewer/atlasViewer.urlService.service.ts b/src/atlasViewer/atlasViewer.urlService.service.ts index b548c96f292bfa89fa808beccd7b2c5b16778a2a..8506cf25f696ab09fbe69dd4647c8d650b5dc6de 100644 --- a/src/atlasViewer/atlasViewer.urlService.service.ts +++ b/src/atlasViewer/atlasViewer.urlService.service.ts @@ -1,11 +1,11 @@ import { Injectable } from "@angular/core"; import { Store, select } from "@ngrx/store"; -import { ViewerStateInterface, isDefined, NEWVIEWER, CHANGE_NAVIGATION, ADD_NG_LAYER, generateLabelIndexId } from "../services/stateStore.service"; +import { ViewerStateInterface, isDefined, NEWVIEWER, CHANGE_NAVIGATION, ADD_NG_LAYER } from "../services/stateStore.service"; import { PluginInitManifestInterface } from 'src/services/state/pluginState.store' import { Observable,combineLatest } from "rxjs"; import { filter, map, scan, distinctUntilChanged, skipWhile, take } from "rxjs/operators"; import { PluginServices } from "./atlasViewer.pluginService.service"; -import { AtlasViewerConstantsServices } from "./atlasViewer.constantService.service"; +import { AtlasViewerConstantsServices, encodeNumber, separator, decodeToNumber } from "./atlasViewer.constantService.service"; import { ToastService } from "src/services/toastService.service"; import { SELECT_REGIONS_WITH_ID } from "src/services/state/viewerState.store"; @@ -148,6 +148,9 @@ export class AtlasViewerURLService{ /** * either or both parcellationToLoad and .regions maybe empty */ + /** + * backwards compatibility + */ const selectedRegionsParam = searchparams.get('regionsSelected') if(selectedRegionsParam){ const ids = selectedRegionsParam.split('_') @@ -157,6 +160,34 @@ export class AtlasViewerURLService{ selectRegionIds: ids }) } + + const cRegionsSelectedParam = searchparams.get('cRegionsSelected') + if (cRegionsSelectedParam) { + try { + const json = JSON.parse(cRegionsSelectedParam) + + const selectRegionIds = [] + + for (let ngId in json) { + const val = json[ngId] + const labelIndicies = val.split(separator).map(n =>decodeToNumber(n)) + for (let labelIndex of labelIndicies) { + selectRegionIds.push(`${ngId}#${labelIndex}`) + } + } + + this.store.dispatch({ + type: SELECT_REGIONS_WITH_ID, + selectRegionIds + }) + + } catch (e) { + /** + * parsing cRegionSelected error + */ + console.log('parsing cRegionSelected error', e) + } + } } /* now that the parcellation is loaded, load the navigation state */ @@ -175,6 +206,26 @@ export class AtlasViewerURLService{ }) } + const cViewerState = searchparams.get('cNavigation') + if (cViewerState) { + const [ cO, cPO, cPZ, cP, cZ ] = cViewerState.split(`${separator}${separator}`) + const o = cO.split(separator).map(s => decodeToNumber(s, {float: true})) + const po = cPO.split(separator).map(s => decodeToNumber(s, {float: true})) + const pz = decodeToNumber(cPZ) + const p = cP.split(separator).map(s => decodeToNumber(s)) + const z = decodeToNumber(cZ) + this.store.dispatch({ + type : CHANGE_NAVIGATION, + navigation : { + orientation: o, + perspectiveOrientation: po, + perspectiveZoom: pz, + position: p, + zoom: z + } + }) + } + const niftiLayers = searchparams.get('niftiLayers') if(niftiLayers){ const layers = niftiLayers.split('__') @@ -213,18 +264,56 @@ export class AtlasViewerURLService{ isDefined(state[key].position) && isDefined(state[key].zoom) ){ - _[key] = [ - state[key].orientation.join('_'), - state[key].perspectiveOrientation.join('_'), - state[key].perspectiveZoom, - state[key].position.join('_'), - state[key].zoom - ].join('__') + const { + orientation, + perspectiveOrientation, + perspectiveZoom, + position, + zoom + } = state[key] + + _['cNavigation'] = [ + orientation.map(n => encodeNumber(n, {float: true})).join(separator), + perspectiveOrientation.map(n => encodeNumber(n, {float: true})).join(separator), + encodeNumber(Math.floor(perspectiveZoom)), + Array.from(position).map((v:number) => Math.floor(v)).map(n => encodeNumber(n)).join(separator), + encodeNumber(Math.floor(zoom)) + ].join(`${separator}${separator}`) + + _[key] = null } break; - case 'regionsSelected': - _[key] = state[key].map(({ ngId, labelIndex })=> generateLabelIndexId({ ngId,labelIndex })).join('_') + case 'regionsSelected': { + // _[key] = state[key].map(({ ngId, labelIndex })=> generateLabelIndexId({ ngId,labelIndex })).join('_') + const ngIdLabelIndexMap : Map<string, number[]> = state[key].reduce((acc, curr) => { + const returnMap = new Map(acc) + const { ngId, labelIndex } = curr + const existingArr = (returnMap as Map<string, number[]>).get(ngId) + if (existingArr) { + existingArr.push(labelIndex) + } else { + returnMap.set(ngId, [labelIndex]) + } + return returnMap + }, new Map()) + + if (ngIdLabelIndexMap.size === 0) { + _['cRegionsSelected'] = null + _[key] = null + break; + } + + const returnObj = {} + + for (let entry of ngIdLabelIndexMap) { + const [ ngId, labelIndicies ] = entry + returnObj[ngId] = labelIndicies.map(n => encodeNumber(n)).join(separator) + } + + _['cRegionsSelected'] = JSON.stringify(returnObj) + _[key] = null break; + } case 'templateSelected': case 'parcellationSelected': _[key] = state[key].name