diff --git a/omero_figure/views.py b/omero_figure/views.py index f1ca56f0f..04dd9d2e8 100644 --- a/omero_figure/views.py +++ b/omero_figure/views.py @@ -124,6 +124,7 @@ def index(request, file_id=None, conn=None, **kwargs): 'const BASE_OMEROWEB_URL = "%s";' % omeroweb_index) html = html.replace('const APP_ROOT_URL = "";', 'const APP_ROOT_URL = "%s";' % figure_index) + # Replace various other placeholder values with OMERO data/configs html = html.replace('const USER_ID = 0;', 'const USER_ID = %s' % user.id) html = html.replace('const PING_URL = "";', 'const PING_URL = "%s";' % ping_url) @@ -403,9 +404,13 @@ def save_web_figure(request, conn=None, **kwargs): try: json_data = json.loads(figure_json) for panel in json_data['panels']: - image_ids.append(panel['imageId']) + try: + image_ids.append(int(panel['imageId'])) + except ValueError: + # For NGFF images, the imageId is a string + pass if len(image_ids) > 0: - first_img_id = int(image_ids[0]) + first_img_id = image_ids[0] # remove duplicates image_ids = list(set(image_ids)) # pretty-print json @@ -442,7 +447,7 @@ def save_web_figure(request, conn=None, **kwargs): if file_id is None: # Create new file # Try to set Group context to the same as first image - curr_gid = conn.SERVICE_OPTS.getOmeroGroup() + curr_gid = conn.getEventContext().groupId i = None if first_img_id: i = conn.getObject("Image", first_img_id) diff --git a/package-lock.json b/package-lock.json index 4752b289b..2c759add0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,10 +17,12 @@ "jquery": "^3.6.0", "marked": "^4.2.12", "mousetrap": "^1.6.5", + "ome-zarr.js": "^0.0.17", "raphael": "^2.3.0", "sortablejs": "^1.15.2", "underscore": "^1.13.8", - "vite-plugin-html-inject": "^1.1.2" + "vite-plugin-html-inject": "^1.1.2", + "zarrita": "^0.5.4" }, "devDependencies": { "sass": "^1.54.1", @@ -826,6 +828,16 @@ "license": "MIT", "optional": true }, + "node_modules/@zarrita/storage": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.1.3.tgz", + "integrity": "sha512-ZyCMYN3LuCNtKxro9876r/KyHyXV+ie2Bhk1qYsJR4Jp+sAjoVRRNNSJPsJxk64ZgFFezayO5S2hCu88/1Odwg==", + "license": "MIT", + "dependencies": { + "reference-spec-reader": "^0.2.0", + "unzipit": "^1.4.3" + } + }, "node_modules/anymatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", @@ -987,6 +999,12 @@ "resolved": "https://registry.npmjs.org/eve-raphael/-/eve-raphael-0.5.0.tgz", "integrity": "sha512-jrxnPsCGqng1UZuEp9DecX/AuSyAszATSjf4oEcRxvfxa1Oux4KkIPKBAAWWnpdwfARtr+Q0o9aPYWjsROD7ug==" }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1132,6 +1150,24 @@ "node": ">=0.10.0" } }, + "node_modules/numcodecs": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz", + "integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==", + "license": "MIT", + "dependencies": { + "fflate": "^0.8.0" + } + }, + "node_modules/ome-zarr.js": { + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/ome-zarr.js/-/ome-zarr.js-0.0.17.tgz", + "integrity": "sha512-5t10obZtLX1Lwgo7kpqGdkPYJOAmRmTojCjhEiOlyDJ66zyyj7PCFN5CkUWsCQSvpWomZ9JwLsjEhv9rhJS4fQ==", + "license": "BSD", + "dependencies": { + "zarrita": "^0.5.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1200,6 +1236,12 @@ "node": ">=8.10.0" } }, + "node_modules/reference-spec-reader": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz", + "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -1342,6 +1384,24 @@ "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", "license": "MIT" }, + "node_modules/unzipit": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-1.4.3.tgz", + "integrity": "sha512-gsq2PdJIWWGhx5kcdWStvNWit9FVdTewm4SEG7gFskWs+XCVaULt9+BwuoBtJiRE8eo3L1IPAOrbByNLtLtIlg==", + "license": "MIT", + "dependencies": { + "uzip-module": "^1.0.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/uzip-module": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/uzip-module/-/uzip-module-1.0.3.tgz", + "integrity": "sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA==", + "license": "MIT" + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", @@ -1467,6 +1527,16 @@ "bin": { "watch": "cli.js" } + }, + "node_modules/zarrita": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.5.4.tgz", + "integrity": "sha512-i88iN2+HqIQ+uiCEWLfhjbYNXAJD7IrM4h3lFwFclfqEOOhxp10amRWtqmgN5jbuy3+h0LwdyLVVzk4y9rTLgg==", + "license": "MIT", + "dependencies": { + "@zarrita/storage": "^0.1.3", + "numcodecs": "^0.3.2" + } } } } diff --git a/package.json b/package.json index 81560a36d..96784287d 100644 --- a/package.json +++ b/package.json @@ -39,9 +39,11 @@ "jquery": "^3.6.0", "marked": "^4.2.12", "mousetrap": "^1.6.5", + "ome-zarr.js": "^0.0.17", "raphael": "^2.3.0", "sortablejs": "^1.15.2", "underscore": "^1.13.8", - "vite-plugin-html-inject": "^1.1.2" + "vite-plugin-html-inject": "^1.1.2", + "zarrita": "^0.5.4" } } diff --git a/src/js/models/figure_model.js b/src/js/models/figure_model.js index bf564e30c..8f030a754 100644 --- a/src/js/models/figure_model.js +++ b/src/js/models/figure_model.js @@ -7,9 +7,10 @@ import { recoverFigureFromStorage, clearFigureFromStorage, figureConfirmDialog, - getJson, + getJsonWithCredentials, saveFigureToStorage, normalizeZProjectionBounds} from "../views/util"; + import { loadZarrForPanel } from "./zarr_utils"; // Version of the json file we're saving. // This only needs to increment when we make breaking changes (not linked to release versions.) @@ -66,7 +67,7 @@ var load_url = BASE_WEBFIGURE_URL + "load_web_figure/" + fileId + "/", self = this; - getJson(load_url).then(data => { + getJsonWithCredentials(load_url).then(data => { data.fileId = fileId; self.load_from_JSON(data); self.set('unsaved', false); @@ -310,7 +311,7 @@ if (iids.length > 0) { var ptUrl = BASE_WEBFIGURE_URL + 'pixels_type/'; ptUrl += '?image=' + iids.join('&image='); - getJson(ptUrl).then(data => { + getJsonWithCredentials(ptUrl).then(data => { // Update all panels // NB: By the time that this callback runs, the panels will have been created self.panels.forEach(function(p){ @@ -533,6 +534,11 @@ // new image panels appropriately in a grid. var invalidIds = []; for (var i=0; i${fmt} not currently supported.

+

Please + open in the OME-NGFF Validator to choose a single Image url.

+ `; + } + } + if (zarrErr.includes("File not found")) { + zarrErr = `Error loading Zarr from
${zarrUrl}: +

File not found (No zarr.json or .zattrs)

`; + } + figureConfirmDialog( + "Zarr Load Error", + zarrErr, + ["OK"] + ); + // alert(`Error loading Zarr ${zarrUrl}: ${panel_json.Error}`); + this.set('loading_count', this.get('loading_count') - 1); + return; + } + + // coords (px, py etc) are incremented for each panel added + this.updateCoordsAndPanelCoords(panel_json, coords, index) + + this.set('loading_count', this.get('loading_count') - 1); + // create Panel (and select it) + // We do some additional processing in Panel.parse() + this.panels.create(panel_json, {'parse': true}).set('selected', true); + this.notifySelectionChange(); + }, + importImage: function(imgDataUrl, coords, baseUrl, index) { var self = this, @@ -580,23 +653,6 @@ return; } - coords.spacer = coords.spacer || data.size.width/20; - var full_width = (coords.colCount * (data.size.width + coords.spacer)) - coords.spacer, - full_height = (coords.rowCount * (data.size.height + coords.spacer)) - coords.spacer; - coords.scale = coords.paper_width / (full_width + (2 * coords.spacer)); - coords.scale = Math.min(coords.scale, 1); // only scale down - // For the FIRST IMAGE ONLY (coords.px etc undefined), we - // need to work out where to start (px,py) now that we know size of panel - // (assume all panels are same size) - coords.px = coords.px || coords.c.x - (full_width * coords.scale)/2; - coords.py = coords.py || coords.c.y - (full_height * coords.scale)/2; - - // calculate panel coordinates from index... - var row = parseInt(index / coords.colCount, 10); - var col = index % coords.colCount; - var panelX = coords.px + ((data.size.width + coords.spacer) * coords.scale * col); - var panelY = coords.py + ((data.size.height + coords.spacer) * coords.scale * row); - // ****** This is the Data Model ****** //------------------------------------- // Any changes here will create a new version @@ -617,8 +673,8 @@ 'channels': data.channels, 'orig_width': data.size.width, 'orig_height': data.size.height, - 'x': panelX, - 'y': panelY, + 'x': 0, + 'y': 0, 'datasetName': data.meta.datasetName, 'datasetId': data.meta.datasetId, 'pixel_size_x': data.pixel_size.valueX, @@ -636,6 +692,10 @@ if (baseUrl) { n.baseUrl = baseUrl; } + + // coords (px, py etc) are incremented for each panel added + self.updateCoordsAndPanelCoords(n, coords, index); + // create Panel (and select it) // We do some additional processing in Panel.parse() self.panels.create(n, {'parse': true}).set('selected', true); diff --git a/src/js/models/panel_model.js b/src/js/models/panel_model.js index ba4d626e7..7efd6ed2d 100644 --- a/src/js/models/panel_model.js +++ b/src/js/models/panel_model.js @@ -3,6 +3,7 @@ import _ from "underscore"; import $ from "jquery"; import { rotatePoint, figureConfirmDialog, normalizeZProjectionBounds } from "../views/util"; + import {renderZarrToSrc} from "./zarr_utils"; // Corresponds to css - allows us to calculate size of labels var LINE_HEIGHT = 1.43; @@ -1032,7 +1033,33 @@ return this.get('orig_width') * this.get('orig_height') > MAX_PLANE_SIZE; }, - get_img_src: function(force_no_padding) { + get_zarr_img_src: async function(force_no_padding, targetSize) { + var rect; + if (this.is_big_image()) { + rect = this.getViewportAsRect(); + if (!force_no_padding) { + var length = Math.max(rect.width, rect.height) * 1.5; + rect.x = rect.x - ((length - rect.width) / 2); + rect.y = rect.y - ((length - rect.height) / 2); + rect.width = length; + rect.height = length; + } + } else { + // Since we render the whole plane every time (no 'rect' here), caching will prevent reloading on pan/zoom. + // as long as targetSize doesn't change. So we can afford to render at higher resolution... + targetSize = Math.max(targetSize, 2000); + } + return renderZarrToSrc(this.get('imageId'), this.get('zarr'), this.get('theZ'), this.get('theT'), this.get('channels'), rect, targetSize); + }, + + get_img_src: async function(force_no_padding) { + // async function since zarr src is the rendered image data + if (this.get("zarr")) { + // use current size to choose resolution level... + let targetSize = 4 * Math.max(this.get('width'), this.get('height')); + targetSize = Math.max(targetSize, 500); + return this.get_zarr_img_src(force_no_padding, targetSize); + } var chs = this.get('channels'); var cStrings = chs.map(function(c, i){ return (c.active ? '' : '-') + (1+i) + "|" + c.window.start + ":" + c.window.end + "$" + c.color; @@ -1500,7 +1527,11 @@ createLabelsFromTags: function(options) { // Loads Tags for selected images and creates labels - var image_ids = this.map(function(s){return s.get('imageId')}) + var image_ids = this.map(function(s){return s.get('imageId')}); + image_ids = _.uniq(image_ids).filter(id => !isNaN(id)); // ignore zarr images + if (image_ids.length === 0) { + return; + } image_ids = "image=" + image_ids.join("&image="); // TODO: Use /api/ when annotations is supported var url = WEBINDEX_URL + "api/annotations/?type=tag&parents=true&limit=1000&" + image_ids; diff --git a/src/js/models/zarr_utils.js b/src/js/models/zarr_utils.js new file mode 100644 index 000000000..520bf910c --- /dev/null +++ b/src/js/models/zarr_utils.js @@ -0,0 +1,483 @@ +import * as zarr from "zarrita"; +import * as omezarr from "ome-zarr.js"; +import { slice } from "zarrita"; +import _ from 'underscore'; +import { getJson, FILE_NOT_FOUND } from "../views/util.js"; + +const ZARRITA_ARRAY_CACHE = {}; +const ZARR_DATA_CACHE = {}; + +export async function loadZarrForPanel(zarrUrl) { + + // first check if we have a zarr image... + let zarrJson; + try { + zarrJson = await getJson(zarrUrl + "/zarr.json"); + zarrJson = zarrJson.attributes?.ome; // zarr v3 + } catch (error) { + console.log("Error loading zarr.json:", error); + if (error.toString().includes(FILE_NOT_FOUND)) { + try { + zarrJson = await getJson(zarrUrl + "/.zattrs"); + } catch (error2) { + return {"Error": error2.toString()}; + } + } else { + return {"Error": error.toString()}; + } + } + if (!zarrJson) { + return {"Error": "Failed to load Zarr metadata"}; + } + if (zarrJson["bioformats2raw.layout"]) { + return {"Error": "bioformats2raw.layout is not currently supported"}; + } + if (zarrJson.plate) { + return {"Error": "OME-Zarr Plates are not currently supported"}; + } + + let store = new zarr.FetchStore(zarrUrl); + + // we load smallest array. Only need it for min/max values if not in 'omero' metadata + let datasetIndex = -1; + let msWithArray; + try { + msWithArray = await omezarr.getMultiscaleWithArray(store, datasetIndex); + } catch (error) { + console.error("Error loading Zarr:", error); + return {"Error": error.toString()}; + } + console.log("msWithArray", msWithArray); + const {arr, multiscale, omero, scales, zarr_version} = msWithArray; + let zarrName = zarrUrl.split("/").pop(); + console.log("multiscale", multiscale); + if (!multiscale) { + return {"Error": `Failed to load multiscale`}; + } + + let imgName = multiscale.name || zarrName; + let axes = multiscale.axes; + let axesNames = axes?.map((axis) => axis.name) || ["t", "c", "z", "y", "x"]; + + let datasets = multiscale.datasets; + let zarrays = {}; + // 'consolidate' the metadata for all arrays + for (let ds of datasets) { + let path = ds.path; + let ds_array = await omezarr.getArray(store, path, zarr_version); + zarrays[path] = {shape: ds_array.shape, dtype: ds_array.dtype}; + } + // store under 'arrays' key + let zarr_attrs = { + multiscales: [multiscale], + } + zarr_attrs["arrays"] = zarrays; + zarr_attrs["zarr_version"] = zarr_version; + + if (multiscale.version) { + zarr_attrs["version"] = multiscale.version; + } else if (zarr_version == "3") { + zarr_attrs["version"] = zarrJson.version; + } + + let zarray = zarrays[datasets[0].path]; + console.log("zarray", zarray); + + let dtype = zarray.dtype; + let shape = zarray.shape; + let dims = shape.length; + let sizeX = shape[dims - 1]; + let sizeY = shape[dims - 2]; + let sizeZ = 1; + let sizeT = 1; + let sizeC = 1; + if (axesNames.includes("z")) { + sizeZ = shape[axesNames.indexOf("z")]; + } + let defaultZ = parseInt(sizeZ / 2); + if (axesNames.includes("t")) { + sizeT = shape[axesNames.indexOf("t")]; + } + let defaultT = parseInt(sizeT / 2); + if (axesNames.includes("c")) { + sizeC = shape[axesNames.indexOf("c")]; + } + + // channels... + // if no omero data, need to construct channels! + let default_colors = [ + "FF0000", + "00FF00", + "0000FF", + "FFFF00", + "00FFFF", + "FFFFFF", + ]; + let chs = omero?.channels; + let indices = {}; + if (axesNames.includes("z")) { + indices["z"] = defaultZ; + } + // placeholder minmax values + let minMaxs = _.range(sizeC).map((idx) => [0, 255]); + let minMaxProvided = chs && chs.every((ch) => ch.window && ch.window.min != undefined && ch.window.max != undefined); + if (!minMaxProvided) { + // load smallest array to get min/max values for every channel + let slices = omezarr.getSlices(_.range(sizeC), arr.shape, axesNames, indices, shape); + let promises = slices.map((chSlice) => zarr.get(arr, chSlice)); + let ndChunks = await Promise.all(promises); + + minMaxs = _.range(sizeC).map((idx) => { + return omezarr.getMinMaxValues(ndChunks[idx]); + }); + } + + // use channel metadata if provided, otherwise default values or values from smallest array + let channels = _.range(sizeC).map((idx) => { + let ch = chs ? chs[idx] : null; + let mm = [ch?.window?.min ?? minMaxs[idx][0], ch?.window?.max ?? minMaxs[idx][1]]; + + return { + label: ch?.label || "Ch" + idx, + active: ch?.active !== undefined ? ch.active : true, + color: ch?.color || default_colors[idx % default_colors.length], + window: { + min: mm[0], + max: mm[1], + start: ch?.window?.start ?? mm[0], + end: ch?.window?.end ?? mm[1], + }, + }; + }); + + let deltaT = []; + if (axesNames.includes("t")) { + // if we have time units... + let timeAxis = axes?.find((a) => a.name == "t"); + if (timeAxis && timeAxis.unit) { + let secsIncrement = 1; + let scaleTransform0 = multiscale.datasets[0].coordinateTransformations?.find( + (ct) => ct.type == "scale" + ); + if (scaleTransform0) { + secsIncrement = scaleTransform0.scale[axesNames.indexOf("t")]; + } + const LENGTH_UNITS = { + "nanosecond": 1e-9, + "microsecond": 1e-6, + "millisecond": 1e-3, + "second": 1, + "minute": 60, + "hour": 3600, + }; + if (LENGTH_UNITS[timeAxis.unit]) { + secsIncrement = secsIncrement * LENGTH_UNITS[timeAxis.unit]; + for(let t = 0; t < sizeT; t++) { + deltaT.push(t * secsIncrement); + } + } + } + } + + let panelX = 0; + let panelY = 0; + let coords = {scale: 1}; + + // ****** This is the Data Model ****** + //------------------------------------- + // Any changes here will create a new version + // of the model and will also have to be applied + // to the 'version_transform()' function so that + // older files can be brought up to date. + // Also check 'previewSetId()' for changes. + var n = { + imageId: zarrUrl, + name: imgName, + width: sizeX * coords.scale, + height: sizeY * coords.scale, + sizeZ: sizeZ, + theZ: defaultZ, + sizeT: sizeT, + theT: defaultT, + rdefs: { model: "-" }, + channels: channels, + orig_width: sizeX, + orig_height: sizeY, + x: panelX, + y: panelY, + // 'deltaT': data.deltaT, + pixelsType: dtypeToPixelsType(dtype), + // 'pixel_range': data.pixel_range, + // let's dump the zarr data into the panel + zarr: zarr_attrs, + }; + + if (deltaT.length > 0) { + n['deltaT'] = deltaT; + } + + // handle pixel sizes if available + // Use 'scale' from first 'coordinateTransforms' if available + let datasetScale = multiscale.datasets[0].coordinateTransformations?.find( + (ct) => ct.type == "scale" + ); + let msScale = multiscale.coordinateTransformations?.find( + (ct) => ct.type == "scale" + ); + if (datasetScale) { + for (let dimName of ["x", "y", "z"]) { + axes.forEach((axis, idx) => { + if (axis.name == dimName) { + let dimScale = datasetScale.scale[idx]; + if (msScale) { + // also apply any 'scale' on multiscale.coordinateTransformations + dimScale = dimScale * msScale.scale[idx]; + } + // One OF: angstrom, attometer, centimeter, decimeter, exameter, femtometer, foot, gigameter, hectometer, inch, kilometer, megameter, meter, micrometer, mile, millimeter, nanometer, parsec, petameter, picometer, terameter, yard, yoctometer, yottameter, zeptometer, zettameter + if (axis.unit) { + let unitKey = axis.unit.toUpperCase(); + n['pixel_size_' + dimName] = dimScale; + n['pixel_size_' + dimName + '_unit'] = unitKey; + if (LENGTH_UNITS[unitKey]) { + n['pixel_size_' + dimName + '_symbol'] = LENGTH_UNITS[unitKey].symbol; + } + } + } + }); + } + } + + console.log("Zarr Panel Model...", n); + + return n; +} + +// e.g. " "uint8" +function dtypeToPixelsType(dtype) { + let dt = ""; + if (dtype.includes("u")) { + dt += "uint"; + } else if (dtype.includes("i")) { + dt += "int"; + } else if (dtype.includes("f")) { + dt += "float"; + } + if (dtype.includes("8")) { + dt += "64"; + } else if (dtype.includes("4")) { + dt += "32"; + } else if (dtype.includes("2")) { + dt += "16"; + } else if (dtype.includes("1")) { + dt += "8"; + } + return dt; +}; + +export async function renderZarrToSrc(source, attrs, theZ, theT, channels, rect, targetSize=500) { + let paths = attrs.multiscales[0].datasets.map((d) => d.path); + // for v0.3 each axes is a string, for v0.4+ it is an object + let axes = attrs.multiscales[0].axes?.map((a) => a.name || a); + // If no axes (v0.1, v0.2) it must be 5D + axes = axes || ["t", "c", "z", "y", "x"]; + let zarrays = attrs.arrays; + + // Pick resolution where crop size is closest to targetSize + // use the arrays themselves to determine 'scale', since we might + // not have 'coordinateTransforms' for pre-v0.4 etc. + let path; + + // size of full-size image + let fullShape = zarrays[paths[0]].shape; + + let region_x = rect?.x || 0; + let region_y = rect?.y || 0; + let region_width = rect?.width || fullShape.at(-1); + let region_height = rect?.height || fullShape.at(-2); + let region_max_size = Math.max(region_width, region_height); + + let dsScales = paths.map((p) => zarrays[p].shape.at(-1) / fullShape.at(-1)); + // E.g. if dataset shape is 1/2 of fullShape then crop size will be half + let cropSizes = dsScales.map((s) => region_max_size * s); + + // find the closest matching size... + let targetScale; + for (let i = 0; i < cropSizes.length; i++) { + // if we've gone small enough, or at last one... + if (cropSizes[i] <= targetSize || i == cropSizes.length - 1) { + if (Math.abs(cropSizes[i] - targetSize) < Math.abs(cropSizes[Math.max(0, i - 1)] - targetSize)) { + path = paths[i]; + targetScale = dsScales[i]; + } else { + path = paths[Math.max(0, i - 1)]; + targetScale = dsScales[Math.max(0, i - 1)]; + } + break; + } + } + let array_rect = { + x: Math.floor(region_x * targetScale), + y: Math.floor(region_y * targetScale), + width: Math.floor(region_width * targetScale), + height: Math.floor(region_height * targetScale), + }; + + // Handle any Z-downsampling... + let sizeZ = zarrays[paths[0]].shape[axes.indexOf("z")]; + let cropZ = zarrays[path].shape[axes.indexOf("z")]; + theZ = Math.floor(theZ *cropZ / sizeZ); + + // We can create canvas of the size of the array_rect + const canvas = document.createElement("canvas"); + canvas.width = array_rect.width; + canvas.height = array_rect.height; + + if (!path) { + console.error( + `Lowest resolution too large for rendering: > ${targetSize} x ${targetSize}` + ); + return; + } + + let storeArrayPath = source + "/" + path; + let arr; + if (ZARRITA_ARRAY_CACHE[storeArrayPath]) { + arr = ZARRITA_ARRAY_CACHE[storeArrayPath]; + } else { + let store = new zarr.FetchStore(source + "/" + path); + arr = await zarr.open(store, { kind: "array" }); + ZARRITA_ARRAY_CACHE[storeArrayPath] = arr; + } + + let chDim = axes.indexOf("c"); + let shape = zarrays[path].shape; + let dims = shape.length; + + let activeChIndicies = []; + let colors = []; + let minMaxValues = []; + let luts = []; + let inverteds = []; + channels.forEach((ch, index) => { + if (ch.active) { + activeChIndicies.push(index); + colors.push(hexToRGB(ch.color)); + minMaxValues.push([ch.window.start, ch.window.end]); + luts.push(ch.color.endsWith(".lut") ? ch.color : undefined); + inverteds.push(ch.reverseIntensity); + } + }); + + // Need same logic as https://github.com/ome/omero-figure/blob/9cc36cde05bde4def7b62b07c2e9ae5f66712e96/omero_figure/views.py#L310 + // if we have a crop region outside the bounds of the image + let array_shape = zarrays[path].shape; + let size_x = array_shape.at(-1); + let size_y = array_shape.at(-2); + let paste_x = 0; + let paste_y = 0; + // let {x, y, width, height} = array_rect; + if (array_rect.x < 0) { + paste_x = - array_rect.x + array_rect.width += array_rect.x; + array_rect.x = 0; + } + if (array_rect.y < 0) { + paste_y = - array_rect.y; + array_rect.height += array_rect.y; + array_rect.y = 0; + } + if (array_rect.x + array_rect.width > size_x) { + array_rect.width = size_x - array_rect.x; + } + if (array_rect.y + array_rect.height > size_y) { + array_rect.height = size_y - array_rect.y; + } + + let promises = activeChIndicies.map((chIndex) => { + let sliceKey = []; + let slices = shape.map((dimSize, index) => { + // channel + if (index == chDim) { + sliceKey.push("" + chIndex); + return chIndex; + } + // x and y - crop to rect + if (axes[index] == "x") { + sliceKey.push(`${array_rect.x}:${array_rect.x + array_rect.width}`); + return slice(array_rect.x, array_rect.x + array_rect.width); + } + if (axes[index] == "y") { + sliceKey.push(`${array_rect.y}:${array_rect.y + array_rect.height}`); + return slice(array_rect.y, array_rect.y + array_rect.height); + } + // z: TODO: handle case where lower resolution is downsampled in Z + if (axes[index] == "z") { + sliceKey.push("" + theZ); + return theZ; + } + if (axes[index] == "t") { + sliceKey.push("" + theT); + return theT; + } + return 0; + }); + let cacheKey = `${source}/${path}/${sliceKey.join(",")}`; + // If we have requested slice in cache, return that instead of loading chunks... + if (ZARR_DATA_CACHE[cacheKey]) { + if (ZARR_DATA_CACHE[cacheKey] !== "pending") { + console.log("RETURN cache!", ZARR_DATA_CACHE[cacheKey]); + return ZARR_DATA_CACHE[cacheKey]; + } else { + // data is pending... + console.log("PENDING cache...", cacheKey); + // wait until data is populated, check every 100ms + return new Promise((resolve, reject) => { + let checkInterval = setInterval(() => { + if (ZARR_DATA_CACHE[cacheKey] && ZARR_DATA_CACHE[cacheKey] !== "pending") { + console.log("RESOLVE pending cache!", ZARR_DATA_CACHE[cacheKey]); + clearInterval(checkInterval); + resolve(ZARR_DATA_CACHE[cacheKey]); + } + }, 100); + }); + } + } + + // "pending" flag to avoid duplicate loads + ZARR_DATA_CACHE[cacheKey] = "pending"; + return zarr.get(arr, slices).then((data) => { + console.log("populate cache..."); + ZARR_DATA_CACHE[cacheKey] = data; + return data; + }); + }); + + let ndChunks = await Promise.all(promises); + let start = new Date().getTime(); + let rbgData = omezarr.renderTo8bitArray( + ndChunks, + minMaxValues, + colors, + luts, + inverteds + ); + console.log("renderTo8bitArray took", new Date().getTime() - start, "ms"); + + let chunk_width = ndChunks[0].shape.at(-1); + let chunk_height = ndChunks[0].shape.at(-2); + + const context = canvas.getContext("2d"); + context.fillStyle = "#ddd"; + context.fillRect(0, 0, canvas.width, canvas.height); + context.putImageData(new ImageData(rbgData, chunk_width, chunk_height), paste_x, paste_y); + let dataUrl = canvas.toDataURL("image/png"); + return dataUrl; +} + +export function hexToRGB(hex) { + if (hex.startsWith("#")) hex = hex.slice(1); + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + return [r, g, b]; +} diff --git a/src/js/views/channel_slider_view.js b/src/js/views/channel_slider_view.js index 505cc6701..a5253b9b6 100644 --- a/src/js/views/channel_slider_view.js +++ b/src/js/views/channel_slider_view.js @@ -137,6 +137,9 @@ var ChannelSliderView = Backbone.View.extend({ return false; } var c = this.hexToRgb(color); + if (!c) { + return false; + } var min, max, delta; var v, s, h; min = Math.min(c.r, c.g, c.b); diff --git a/src/js/views/chgrp_modal_view.js b/src/js/views/chgrp_modal_view.js index eab664664..984a16a86 100644 --- a/src/js/views/chgrp_modal_view.js +++ b/src/js/views/chgrp_modal_view.js @@ -5,7 +5,11 @@ import _ from "underscore"; import FigureModel from "../models/figure_model"; -import {figureConfirmDialog, hideModal, getJson} from "./util"; +import { + figureConfirmDialog, + hideModal, + getJsonWithCredentials +} from "./util"; export const ChgrpModalView = Backbone.View.extend({ @@ -41,7 +45,7 @@ export const ChgrpModalView = Backbone.View.extend({ loadImageDetails: function() { var imgIds = this.model.panels.pluck('imageId'); var url = `${BASE_WEBFIGURE_URL}images_details/?image=${_.uniq(imgIds).join(',')}`; - getJson(url).then(data => { + getJsonWithCredentials(url).then(data => { // Sort images by Group this.imagesByGroup = data.data.reduce(function(prev, img){ if (!prev[img.group.id]) { @@ -56,7 +60,7 @@ export const ChgrpModalView = Backbone.View.extend({ loadGroups: function() { var url = `${API_BASE_URL_V0}m/experimenters/${USER_ID}/experimentergroups/`; - getJson(url).then(data => { + getJsonWithCredentials(url).then(data => { this.omeroGroups = data.data.map(group => {return {id: group['@id'], name: group.Name}}) .filter(group => group.name != 'user'); this.render(); diff --git a/src/js/views/crop_modal_view.js b/src/js/views/crop_modal_view.js index 47e4c26be..d6d3e27bb 100644 --- a/src/js/views/crop_modal_view.js +++ b/src/js/views/crop_modal_view.js @@ -6,7 +6,7 @@ import Raphael from "raphael"; import FigureModel from "../models/figure_model"; import RectView from "./raphael-rect"; -import {figureConfirmDialog, hideModal, getJson, rotatePoint} from "./util"; +import {figureConfirmDialog, hideModal, getJsonWithCredentials, rotatePoint} from "./util"; import crop_modal_roi_template from '../../templates/modal_dialogs/crop_modal_roi.template.html?raw'; @@ -399,7 +399,7 @@ export const CropModalView = Backbone.View.extend({ iid = self.m.get('imageId'); var offset = this.roisPageSize * this.roisPage; var url = BASE_WEBFIGURE_URL + 'roiRectangles/' + iid + '/?limit=' + self.roisPageSize + '&offset=' + offset; - getJson(url).then(rsp => { + getJsonWithCredentials(url).then(rsp => { var data = rsp.data; self.roisLoaded += data.length; self.roisPage += 1; @@ -513,7 +513,6 @@ export const CropModalView = Backbone.View.extend({ let rotation = rect.rotation || 0; if (rect.theT > -1) this.m.set('theT', rect.theT, {'silent': true}); if (rect.theZ > -1) this.m.set('theZ', rect.theZ, {'silent': true}); - src = this.m.get_img_src(true); if (rect.width > rect.height) { div_w = size; div_h = (rect.height/rect.width) * div_w; @@ -529,8 +528,12 @@ export const CropModalView = Backbone.View.extend({ rect.theT = rect.theT !== undefined ? rect.theT : origT; rect.theZ = rect.theZ !== undefined ? rect.theZ : origZ; let css = this.m._viewport_css(left, top, img_w, img_h, size, size, rotation); + let random_id = "rect_" + Math.random(); + this.m.get_img_src(true) + .then(src => document.getElementById(random_id).src = src); var json = { + 'id': random_id, 'msg': msg, 'src': src, 'rect': rect, @@ -586,7 +589,8 @@ export const CropModalView = Backbone.View.extend({ this.m.set('zoom', 100); this.m.set('width', newW); this.m.set('height', newH); - var src = this.m.get_img_src(true); + this.m.get_img_src(true) + .then(src => this.$cropImg.attr('src', src)); var rotation = this.m.get('rotation') || 0; // Calculate bounding box for rotated image diff --git a/src/js/views/info_panel_view.js b/src/js/views/info_panel_view.js index ab5ab9e90..4b9c66387 100644 --- a/src/js/views/info_panel_view.js +++ b/src/js/views/info_panel_view.js @@ -164,22 +164,24 @@ var InfoPanelView = Backbone.View.extend({ this.$el.append(this.xywh_template(json)); }, - getImageLinks: function(remoteUrl, imageIds, imageNames) { + getImageLinks: function(imageIds, imageNames) { // Link if we have a single remote image, E.g. http://jcb-dataviewer.rupress.org/jcb/img_detail/625679/ + var selectedObjs = imageIds.map((id, index) => {return {id, name: imageNames[index], type: 'image'}}) + .filter(function(idWithName){ + return !isNaN(idWithName.id); + }); + var zarrIds = imageIds.filter(function(id){ + return typeof id === "string" && id.indexOf('zarr') > -1; + }); var imageLinks = []; - if (remoteUrl) { - if (imageIds.length == 1) { - imageLinks.push({'text': 'Image viewer', 'url': remoteUrl}); - } - // OR all the images are local... - } else { - imageLinks.push({'text': 'Webclient', 'url': WEBINDEX_URL + "?show=image-" + imageIds.join('|image-')}); + + // Handle Image ID links to OMERO... + if (selectedObjs.length > 0) { + var numberIds = selectedObjs.map(idWithName => idWithName.id); + imageLinks.push({'text': 'Webclient', 'url': WEBINDEX_URL + "?show=image-" + numberIds.join('|image-')}); // Handle other 'Open With' options OPEN_WITH.forEach(function(v){ - var selectedObjs = imageIds.map(function(id, i){ - return {'id': id, 'name': imageNames[i], 'type': 'image'}; - }); var enabled = false; if (typeof v.isEnabled === "function") { enabled = v.isEnabled(selectedObjs); @@ -204,6 +206,10 @@ var InfoPanelView = Backbone.View.extend({ imageLinks.push({'text': label, 'url': url}); }); } + // Handle Zarr links to ome-ngff-validator + if (zarrIds.length == 1) { + imageLinks.push({'text': "ome-ngff-validator", 'url': "https://ome.github.io/ome-ngff-validator/?source=" + zarrIds[0]}); + } return imageLinks; }, @@ -216,16 +222,11 @@ var InfoPanelView = Backbone.View.extend({ // Flag to ignore blur events caused by $el.html() below this.rendering = true; var json, - title = this.models.length + " Panels Selected...", - remoteUrl; + title = this.models.length + " Panels Selected..."; var imageIds = this.models.pluck('imageId'); var imageNames = this.models.pluck('name'); this.models.forEach(function(m) { - if (m.get('baseUrl')) { - // only used when a single image is selected - remoteUrl = m.get('baseUrl') + "/img_detail/" + m.get('imageId') + "/"; - } // start with json data from first Panel var this_json = m.toJSON(); // Format floating point values @@ -274,7 +275,7 @@ var InfoPanelView = Backbone.View.extend({ json.export_dpi = Math.max(json.export_dpi, json.min_export_dpi); } - json.imageLinks = this.getImageLinks(remoteUrl, imageIds, imageNames); + json.imageLinks = this.getImageLinks(imageIds, imageNames); // all setId if we have a single Id json.setImageId = _.uniq(imageIds).length == 1; diff --git a/src/js/views/labels_from_maps_modal.js b/src/js/views/labels_from_maps_modal.js index 74b9252e9..857dc4bad 100644 --- a/src/js/views/labels_from_maps_modal.js +++ b/src/js/views/labels_from_maps_modal.js @@ -4,7 +4,7 @@ import $ from "jquery"; import _ from "underscore"; import FigureModel from "../models/figure_model"; -import { getJson, hideModal } from "./util"; +import { getJsonWithCredentials, hideModal } from "./util"; export var LabelFromMapsModal = Backbone.View.extend({ @@ -39,13 +39,19 @@ export var LabelFromMapsModal = Backbone.View.extend({ */ loadMapAnns() { let imageIds = this.model.getSelected().map(function(m){return m.get('imageId')}); + imageIds = _.uniq(imageIds).filter(id => !isNaN(id)); // ignore zarr images + if (imageIds.length === 0) { + this.annotations = []; + this.render(); + return; + } this.isLoading = true; $('select', this.el).html(""); var url = WEBINDEX_URL + "api/annotations/?type=map&parents=true&image="; url += imageIds.join("&image="); - getJson(url).then(data => { + getJsonWithCredentials(url).then(data => { if (data.hasOwnProperty('parents')){ data.parents.annotations.forEach(function(ann) { let class_ = ann.link.parent.class; diff --git a/src/js/views/lutpicker.js b/src/js/views/lutpicker.js index 471fa730e..304eb137e 100644 --- a/src/js/views/lutpicker.js +++ b/src/js/views/lutpicker.js @@ -23,7 +23,7 @@ import _ from 'underscore'; import * as bootstrap from "bootstrap" import lut_picker_template from '../../templates/lut_picker.template.html?raw'; -import { showModal, getJson } from "./util"; +import { showModal } from "./util"; // Need to handle dev vv built (omero-web) paths import lutsPng from "../../images/luts_10.png"; diff --git a/src/js/views/modal_views.js b/src/js/views/modal_views.js index a5b8b630f..ae760ca68 100644 --- a/src/js/views/modal_views.js +++ b/src/js/views/modal_views.js @@ -522,42 +522,13 @@ import { hideModal } from "./util"; if (!idInput || idInput.length === 0) return; // test for E.g: http://localhost:8000/webclient/?show=image-25|image-26|image-27 - if (idInput.indexOf('?') > 10) { + if (idInput.indexOf('?show') > 10) { iIds = idInput.split('image-').slice(1); - } else if (idInput.indexOf('img_detail') > 0) { - // url of image viewer... - this.importFromRemote(idInput); - return; } else { + // iIds could be Zarr URLs - NB: this will fail if a Zarr URL contains ',' iIds = idInput.split(','); } this.model.addImages(iIds); }, - - importFromRemote: function(img_detail_url) { - var iid = parseInt(img_detail_url.split('img_detail/')[1], 10), - baseUrl = img_detail_url.split('/img_detail')[0], - // http://jcb-dataviewer.rupress.org/jcb/imgData/25069/ - imgDataUrl = baseUrl + '/imgData/' + iid + "/"; - - var colCount = 1, - rowCount = 1, - paper_width = this.model.get('paper_width'), - c = this.figureView.getCentre(), - col = 0, - row = 0, - px, py, spacer, scale, - coords = {'px': px, - 'py': py, - 'c': c, - 'spacer': spacer, - 'colCount': colCount, - 'rowCount': rowCount, - 'col': col, - 'row': row, - 'paper_width': paper_width}; - - this.model.importImage(imgDataUrl, coords, baseUrl); - }, }); diff --git a/src/js/views/panel_view.js b/src/js/views/panel_view.js index 93d475e49..8886f8282 100644 --- a/src/js/views/panel_view.js +++ b/src/js/views/panel_view.js @@ -194,15 +194,18 @@ // But we don't want the previous image showing while we wait... if (this.model.is_big_image()) { this.$img_panel.hide(); - $(".image_panel_spinner", this.$el).show(); } + let timeoutId = setTimeout(() => { + $(".image_panel_spinner", this.$el).show(); + }, 100); // Show spinner only if image load takes longer than 100ms this.$img_panel.one("load", function(){ + clearTimeout(timeoutId); $(".image_panel_spinner", this.$el).hide(); this.$img_panel.show(); }.bind(this)); - var src = this.model.get_img_src(); - this.$img_panel.attr('src', src); + this.model.get_img_src() + .then(src => this.$img_panel.attr('src', src)); // if a 'reasonable' dpi is set, we don't pixelate if (this.model.get('min_export_dpi') > 100) { diff --git a/src/js/views/right_panel_view.js b/src/js/views/right_panel_view.js index 9b1282cf3..38e4c3f0a 100644 --- a/src/js/views/right_panel_view.js +++ b/src/js/views/right_panel_view.js @@ -1173,9 +1173,16 @@ var frame_h = this.full_size / wh; } this.models.forEach(function(m){ - var src = m.get_img_src(); + var img_id = "image_" + Math.random(); + // src is async, so we set an id instead. When src returns we + // use the ID to find the element and set the src. + m.get_img_src().then(src => { + document.getElementById(img_id).src = src; + }); var img_css = m.get_vp_img_css(m.get('zoom'), frame_w, frame_h); - img_css.src = src; + // placeholder transparent gif + img_css.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" + img_css.id = img_id; // if a 'reasonable' dpi is set, we don't pixelate var dpiSet = m.get('min_export_dpi') > 100; img_css.pixelated = !dpiSet; diff --git a/src/js/views/roi_modal_view.js b/src/js/views/roi_modal_view.js index 1ead692eb..b66ac4d93 100644 --- a/src/js/views/roi_modal_view.js +++ b/src/js/views/roi_modal_view.js @@ -13,7 +13,7 @@ import shape_toolbar_template from '../../templates/shapes/shape_toolbar.templat import shape_sidebar_template from '../../templates/shapes/shape_sidebar.template.html?raw'; import roi_zt_buttons from '../../templates/modal_dialogs/roi_zt_buttons.template.html?raw'; import RoiLoaderView from './roi_loader_view'; -import { hideModal, getJson } from "./util"; +import { hideModal, getJsonWithCredentials } from "./util"; const TEXT_ANCHOR_ICONS = { "start": "bi-text-left", @@ -182,7 +182,7 @@ export const RoiModalView = Backbone.View.extend({ .attr({'disabled': 'disabled'}); $btn.parent().attr('title', 'Checking for ROIs...'); // title on parent div - still works if btn disabled - getJson(url).then((data) => { + getJsonWithCredentials(url).then((data) => { this.omeroRoiCount = data.roi; this.renderSidebar(); }); @@ -222,7 +222,7 @@ export const RoiModalView = Backbone.View.extend({ var iid = this.m.get('imageId'); let url = BASE_OMEROWEB_URL + 'api/v0/m/rois/'; var roiUrl = url + '?image=' + iid + '&limit=' + this.roisPageSize + '&offset=' + (this.roisPageSize * this.roisPage); - getJson(roiUrl).then((data) => { + getJsonWithCredentials(roiUrl).then((data) => { this.Rois.set(data.data); $("#loadRois").prop('disabled', false); $("#roiModalRoiList table").empty(); @@ -551,48 +551,9 @@ export const RoiModalView = Backbone.View.extend({ $("#roiModalSidebar", this.$el).html(this.sidebar_template(json)); }, - // for rendering bounding-box viewports for shapes - getBboxJson: function(bbox, theZ, theT) { - var size = 50; // longest side - var orig_width = this.m.get('orig_width'), - orig_height = this.m.get('orig_height'); - // origT = this.m.get('theT'), - // origZ = this.m.get('theZ'); - // theT = (theT !== undefined ? theT : this.m.get('theT')) - var div_w, div_h; - // get src for image by temp setting Z & T - if (theT !== undefined) this.m.set('theT', bbox.theT, {'silent': true}); - if (theZ !== undefined) this.m.set('theZ', bbox.theZ, {'silent': true}); - var src = this.m.get_img_src(); - if (bbox.width > bbox.height) { - div_w = size; - div_h = (bbox.height/bbox.width) * div_w; - } else { - div_h = size; - div_w = (bbox.width/bbox.height) * div_h; - } - var zoom = div_w/bbox.width; - var img_w = orig_width * zoom; - var img_h = orig_height * zoom; - var top = -(zoom * bbox.y); - var left = -(zoom * bbox.x); - // bbox.theT = bbox.theT !== undefined ? bbox.theT : origT; - // bbox.theZ = bbox.theZ !== undefined ? bbox.theZ : origZ; - - return { - 'src': src, - 'w': div_w, - 'h': div_h, - 'top': top, - 'left': left, - 'img_w': img_w, - 'img_h': img_h - }; - }, - renderImagePlane: function() { - var src = this.m.get_img_src(); - this.$roiImg.attr('src', src); + this.m.get_img_src() + .then(src => this.$roiImg.attr('src', src)); var orig_model = this.model.getSelected().head(); var json = {'theZ': this.m.get('theZ'), diff --git a/src/js/views/util.js b/src/js/views/util.js index 4551b4793..05af24088 100644 --- a/src/js/views/util.js +++ b/src/js/views/util.js @@ -256,11 +256,57 @@ export function hideModal(modalId) { thisModal.hide(); } -export async function getJson (url) { +export async function getJsonWithCredentials (url) { + // $.getJSON() also works for OMERO.web requests let cors_headers = { mode: 'cors', credentials: 'include' }; return fetch(url, cors_headers).then(rsp => rsp.json()); } +export const FILE_NOT_FOUND = "File not found"; +export async function getJson(url) { + // This will throw an Error if 404 or CORS fails etc + return fetchHandleError(url).then(rsp => rsp.json()); +} + +async function fetchHandleError(url) { + let msg = `Error Loading ${url}:`; + let rsp; + try { + rsp = await fetch(url).then(function (response) { + if (!response.ok) { + // make the promise be rejected if we didn't get a 2xx response + // NB. statusText could be "Not Found" or "File not found" depending on server + // Standardise based on response.status + if (response.status == 404) { + msg += ` ${FILE_NOT_FOUND}`; + } else { + msg += ` ${response.statusText}`; + } + } else { + return response; + } + }); + } catch (error) { + console.log("check for CORS..."); + console.log(error); + try { + let corsRsp = await fetch(url, { mode: "no-cors" }); + console.log("corsRsp", corsRsp); + // If the 'no-cors' mode allows this to return, then we + // likely failed due to CORS in the original request + msg += " Failed due to CORS issues."; + } catch (anotherError) { + console.log("Even `no-cors` request failed!", anotherError); + // return the original error (same as anotherError?) + msg += ` ${error}`; + } + } + if (rsp) { + return rsp; + } + throw Error(msg); +} + export const RANDOM_NUMBER_RANGE = 100000000; export function getRandomId() { diff --git a/src/templates/figure_panel.template.html b/src/templates/figure_panel.template.html index 65a808962..087acdcde 100644 --- a/src/templates/figure_panel.template.html +++ b/src/templates/figure_panel.template.html @@ -1,6 +1,6 @@
-
+
diff --git a/src/templates/info_panel.template.html b/src/templates/info_panel.template.html index 7d97cb8b0..5154d290b 100644 --- a/src/templates/info_panel.template.html +++ b/src/templates/info_panel.template.html @@ -14,6 +14,12 @@ <% }) %> + <% if (isNaN(imageId)) { %> +
URL:
+
+ <%= imageId %> +
+ <% } else { %>
Image ID:
<%= imageId %> @@ -24,6 +30,7 @@ Edit ID
+ <% } %>
Dimensions (XY):
<%= orig_width %> x <%= orig_height %>
diff --git a/src/templates/modal_dialogs/crop_modal_roi.template.html b/src/templates/modal_dialogs/crop_modal_roi.template.html index 53ecab399..1901cb504 100644 --- a/src/templates/modal_dialogs/crop_modal_roi.template.html +++ b/src/templates/modal_dialogs/crop_modal_roi.template.html @@ -2,7 +2,8 @@
- - + import * as zarr from "https://cdn.jsdelivr.net/npm/zarrita@next/+esm"; + const store = new zarr.FetchStore("https://raw.githubusercontent.com/zarr-developers/zarr_implementations/5dc998ac72/examples/zarr.zr/blosc"); + const arr = await zarr.open(store, { kind: "array" }); + console.log(arr); + // { + // store: FetchStore, + // path: "/", + // dtype: "uint8", + // shape: [512, 512, 3], + // chunks: [100, 100, 1], + // } + const view = await zarr.get(arr, [null, null, 0]); + // { + // data: Uint8Array, + // shape: [512, 512], + // stride: [512, 1], + // } + console.log(view); + + + + Test + \ No newline at end of file