Skip to content
Open
39 changes: 0 additions & 39 deletions QualityControl/public/common/downloadRootImageButton.js

This file was deleted.

67 changes: 67 additions & 0 deletions QualityControl/public/common/downloadRootImageDropdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* @license
* Copyright 2019-2020 CERN and copyright holders of ALICE O2.
* See http://alice-o2.web.cern.ch/copyright for details of the copyright holders.
* All rights not expressly granted are reserved.
*
* This software is distributed under the terms of the GNU General Public
* License v3 (GPL Version 3), copied verbatim in the file "COPYING".
*
* In applying this license CERN does not waive the privileges and immunities
* granted to it by virtue of its status as an Intergovernmental Organization
* or submit itself to any jurisdiction.
*/

import { h, DropdownComponent, imagE } from '/js/src/index.js';
import { downloadRoot } from './utils.js';
import { isObjectOfTypeChecker } from '../../library/qcObject/utils.js';
import { RootImageDownloadExtensions } from './enums/rootImageMimes.enum.js';

/**
* Download root image button.
* @param {string} filename - The name of the downloaded file excluding its file extension.
* @param {RootObject} root - The JSROOT RootObject to render.
* @param {string[]} [drawingOptions=[]] - Optional array of JSROOT drawing options.
* @param {(visible: boolean) => void} [onVisibilityChange=()=>{}] - Callback for any change in
* visibility of the dropdown.
* @param {string|undefined} [uniqueIdentifier=undefined] - An unique identifier for the dropdown,
* or the `filename` if `undefined`.
* @returns {vnode|undefined} - Download root image button element.
*/
export function downloadRootImageDropdown(
filename,
root,
drawingOptions = [],
onVisibilityChange = () => {},
uniqueIdentifier = undefined,
) {
if (isObjectOfTypeChecker(root)) {
return undefined;
}

const dropdownComponent = DropdownComponent(
h('button.btn.save-root-as-image-button', {
title: 'Save root as image',
}, imagE()),
h('#download-root-image-dropdown', [
RootImageDownloadExtensions()
.map((fileExtension) => h('button.btn.d-block.w-100', {
key: `${uniqueIdentifier ?? filename}.${fileExtension}`,
id: `${uniqueIdentifier ?? filename}.${fileExtension}`,
title: `Save root as image (${fileExtension})`,
onclick: async (event) => {
try {
event.target.disabled = true;
await downloadRoot(filename, fileExtension, root, drawingOptions);
} finally {
event.target.disabled = false;
dropdownComponent.state.hidePopover();
}
},
}, fileExtension)),
]),
{ onVisibilityChange },
);

return dropdownComponent;
}
37 changes: 37 additions & 0 deletions QualityControl/public/common/enums/rootImageMimes.enum.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* @license
* Copyright 2019-2020 CERN and copyright holders of ALICE O2.
* See http://alice-o2.web.cern.ch/copyright for details of the copyright holders.
* All rights not expressly granted are reserved.
*
* This software is distributed under the terms of the GNU General Public
* License v3 (GPL Version 3), copied verbatim in the file "COPYING".
*
* In applying this license CERN does not waive the privileges and immunities
* granted to it by virtue of its status as an Intergovernmental Organization
* or submit itself to any jurisdiction.
*/

/**
* Enumeration for allowed `ROOT.makeImage` file extensions to MIME types
* @enum {string}
* @readonly
*/
export const RootImageDownloadSupportedTypes = Object.freeze({
SVG: 'image/svg+xml',
PNG: 'file/png',
JPG: 'file/jpeg',
JPEG: 'file/jpeg',
WEBP: 'file/webp',
});

/**
* Get the list of unique supported ROOT image download extensions
* @returns {string[]} - Array of supported ROOT image download extensions
*/
export const RootImageDownloadExtensions = () => {
const extensions = new Set();
Object.keys(RootImageDownloadSupportedTypes)
.forEach((ext) => extensions.add(ext.toLowerCase()));
return Array.from(extensions);
};
57 changes: 32 additions & 25 deletions QualityControl/public/common/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,10 @@

import { isUserRoleSufficient } from '../../../../library/userRole.enum.js';
import { generateDrawingOptionString } from '../../library/qcObject/utils.js';
import { RootImageDownloadSupportedTypes } from './enums/rootImageMimes.enum.js';

/* global JSROOT BOOKKEEPING */

/**
* Map of allowed `ROOT.makeImage` file extensions to MIME types
* @type {Map<string, string>}
*/
const SUPPORTED_ROOT_IMAGE_FILE_TYPES = new Map([
['svg', 'image/svg+xml'],
['png', 'file/png'],
['jpg', 'file/jpeg'],
['jpeg', 'file/jpeg'],
['webp', 'file/webp'],
]);

/**
* Generates a new ObjectId
* @returns {string} 16 random chars, base 16
Expand All @@ -47,6 +36,32 @@ export function clone(obj) {
return JSON.parse(JSON.stringify(obj));
}

// Map storing timers per key
const simpleDebouncerTimers = new Map();

/**
* Produces a debounced function that uses a key to manage timers.
* Each key has its own debounce timer, so calls with different keys
* are debounced independently.
* @template PrimitiveKey extends unknown
* @param {PrimitiveKey} key - The key for this call.
* @param {(key: PrimitiveKey) => void} fn - Function to debounce.
* @param {number} time - Debounce delay in milliseconds.
* @returns {undefined}
*/
export function simpleDebouncer(key, fn, time) {
if (simpleDebouncerTimers.has(key)) {
clearTimeout(simpleDebouncerTimers.get(key));
}

const timerId = setTimeout(() => {
fn(key);
simpleDebouncerTimers.delete(key);
}, time);

simpleDebouncerTimers.set(key, timerId);
}

/**
* Produces a lambda function waiting `time` ms before calling fn.
* No matter how many calls are done to lambda, the last call is the waiting starting point.
Expand Down Expand Up @@ -178,14 +193,6 @@ export const camelToTitleCase = (text) => {
return titleCase;
};

/**
* Get the file extension from a filename
* @param {string} filename - The file name including the file extension
* @returns {string} - the file extension
*/
export const getFileExtensionFromName = (filename) =>
filename.substring(filename.lastIndexOf('.') + 1).toLowerCase().trim();

/**
* Helper to trigger a download for a file
* @param {string} url - The URL to the file source
Expand Down Expand Up @@ -216,14 +223,14 @@ export const downloadFile = (file, filename) => {

/**
* Generates a rasterized image of a JSROOT RootObject and triggers download.
* @param {string} filename - The name of the downloaded file including its extension.
* @param {string} filename - The name of the downloaded file excluding the file extension.
* @param {string} filetype - The file extension of the downloaded file.
* @param {RootObject} root - The JSROOT RootObject to render.
* @param {string[]} [drawingOptions=[]] - Optional array of JSROOT drawing options.
* @returns {undefined}
*/
export const downloadRoot = async (filename, root, drawingOptions = []) => {
const filetype = getFileExtensionFromName(filename);
const mime = SUPPORTED_ROOT_IMAGE_FILE_TYPES.get(filetype);
export const downloadRoot = async (filename, filetype, root, drawingOptions = []) => {
const mime = RootImageDownloadSupportedTypes[filetype.toLocaleUpperCase()];
if (!mime) {
throw new Error(`The file extension (${filetype}) is not supported`);
}
Expand All @@ -235,7 +242,7 @@ export const downloadRoot = async (filename, root, drawingOptions = []) => {
as_buffer: true,
});
const blob = new Blob([image], { type: mime });
downloadFile(blob, filename);
downloadFile(blob, `${filename}.${filetype}`);
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { downloadButton } from '../../../common/downloadButton.js';
import { isOnLeftSideOfViewport } from '../../../common/utils.js';
import { defaultRowAttributes, qcObjectInfoPanel } from './../../../common/object/objectInfoCard.js';
import { h, iconResizeBoth, info } from '/js/src/index.js';
import { downloadRootImageButton } from '../../../common/downloadRootImageButton.js';
import { downloadRootImageDropdown } from '../../../common/downloadRootImageDropdown.js';

/**
* Builds 2 actionable buttons which are to be placed on top of a JSROOT plot
Expand All @@ -40,8 +40,9 @@ export const objectInfoResizePanel = (model, tabObject) => {
const toUseDrawingOptions = Array.from(new Set(ignoreDefaults
? drawingOptions
: [...drawingOptions, ...displayHints, ...drawOptions]));
const visibility = object.getExtraObjectData(tabObject.id)?.saveImageDropdownOpen ? 'visible' : 'hidden';
return h('.text-right.resize-element.item-action-row.flex-row.g1', {
style: 'visibility: hidden; padding: .25rem .25rem 0rem .25rem;',
style: `visibility: ${visibility}; padding: .25rem .25rem 0rem .25rem;`,
}, [

h('.dropdown', { class: isSelectedOpen ? 'dropdown-open' : '',
Expand Down Expand Up @@ -69,10 +70,14 @@ export const objectInfoResizePanel = (model, tabObject) => {
),
]),
objectRemoteData.isSuccess() && [
downloadRootImageButton(
`${objectRemoteData.payload.name}.png`,
downloadRootImageDropdown(
objectRemoteData.payload.name,
objectRemoteData.payload.qcObject.root,
toUseDrawingOptions,
(isDropdownOpen) => {
object.appendExtraObjectData(tabObject.id, { saveImageDropdownOpen: isDropdownOpen });
},
tabObject.id,
),
downloadButton({
href: model.objectViewModel.getDownloadQcdbObjectUrl(objectRemoteData.payload.id),
Expand Down
49 changes: 48 additions & 1 deletion QualityControl/public/object/QCObject.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import { RemoteData, iconCaretTop, BrowserStorage } from '/js/src/index.js';
import ObjectTree from './ObjectTree.class.js';
import { prettyFormatDate, setBrowserTabTitle } from './../common/utils.js';
import { simpleDebouncer, prettyFormatDate, setBrowserTabTitle } from './../common/utils.js';
import { isObjectOfTypeChecker } from './../library/qcObject/utils.js';
import { BaseViewModel } from '../common/abstracts/BaseViewModel.js';
import { StorageKeysEnum } from '../common/enums/storageKeys.enum.js';
Expand All @@ -39,6 +39,7 @@ export default class QCObject extends BaseViewModel {
this.selected = null; // Object - { name; createTime; lastModified; }
this.selectedOpen = false;
this.objects = {}; // ObjectName -> RemoteData.payload -> plot
this._extraObjectData = {};

this.searchInput = ''; // String - content of input search
this.searchResult = []; // Array<object> - result list of search
Expand Down Expand Up @@ -303,6 +304,7 @@ export default class QCObject extends BaseViewModel {
async loadObjects(objectsName) {
this.objectsRemote = RemoteData.loading();
this.objects = {}; // Remove any in-memory loaded objects
this._extraObjectData = {}; // Remove any in-memory extra object data
this.model.services.object.objectsLoadedMap = {}; // TODO not here
this.notify();
if (!objectsName || !objectsName.length) {
Expand Down Expand Up @@ -642,4 +644,49 @@ export default class QCObject extends BaseViewModel {
}
this.loadList();
}

/**
* Returns the extra data associated with a given object name.
* @param {string} objectName The name of the object whose extra data should be retrieved.
* @returns {object | undefined} The extra data associated with the given object name, or undefined if none exists.
*/
getExtraObjectData(objectName) {
return this._extraObjectData[objectName];
}

/**
* Appends extra data to an existing object entry.
* Existing keys are preserved unless overwritten by the provided data. If no data exists, a new entry is created.
* @param {string} objectName The name of the object to which extra data should be appended.
* @param {object} data The extra data to merge into the existing object data.
* @returns {undefined}
*/
appendExtraObjectData(objectName, data) {
this._extraObjectData[objectName] = { ...this._extraObjectData[objectName] ?? {}, ...data };
// debounce notify by 1ms
simpleDebouncer('QCObject.appendExtraObjectData', () => this.notify(), 1);
}

/**
* Sets (overwrites) the extra data for a given object name.
* Any previously stored data for the object is replaced entirely.
* @param {string} objectName The name of the object whose extra data should be set.
* @param {object | undefined} data The extra data to associate with the object.
* @returns {undefined}
*/
setExtraObjectData(objectName, data) {
this._extraObjectData[objectName] = data;
// debounce notify by 1ms
simpleDebouncer('QCObject.setExtraObjectData', () => this.notify(), 1);
}

/**
* Clears all stored extra object data.
* After calling this method, no extra data will be associated with any object name.
* @returns {undefined}
*/
clearAllExtraObjectData() {
this._extraObjectData = {};
this.notify();
}
}
4 changes: 2 additions & 2 deletions QualityControl/public/object/objectTreePage.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ import virtualTable from './virtualTable.js';
import { defaultRowAttributes, qcObjectInfoPanel } from '../common/object/objectInfoCard.js';
import { downloadButton } from '../common/downloadButton.js';
import { resizableDivider } from '../common/resizableDivider.js';
import { downloadRootImageDropdown } from '../common/downloadRootImageDropdown.js';
import { SortDirectionsEnum } from '../common/enums/columnSort.enum.js';
import { sortableTableHead } from '../common/sortButton.js';
import { downloadRootImageButton } from '../common/downloadRootImageButton.js';

/**
* Shows a page to explore though a tree of objects with a preview on the right if clicked
Expand Down Expand Up @@ -120,7 +120,7 @@ const drawPlot = (model, object) => {
: `?page=objectView&objectName=${name}`;
return h('', { style: 'height:100%; display: flex; flex-direction: column' }, [
h('.item-action-row.flex-row.g1.p1', [
downloadRootImageButton(`${name}.png`, root, ['stat']),
downloadRootImageDropdown(name, root, ['stat']),
downloadButton({
href: model.objectViewModel.getDownloadQcdbObjectUrl(id),
title: 'Download root object',
Expand Down
4 changes: 2 additions & 2 deletions QualityControl/public/pages/objectView/ObjectViewPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { dateSelector } from '../../common/object/dateSelector.js';
import { defaultRowAttributes, qcObjectInfoPanel } from '../../common/object/objectInfoCard.js';
import { downloadButton } from '../../common/downloadButton.js';
import { visibilityToggleButton } from '../../common/visibilityButton.js';
import { downloadRootImageButton } from '../../common/downloadRootImageButton.js';
import { downloadRootImageDropdown } from '../../common/downloadRootImageDropdown.js';

/**
* Shows a page to view an object on the whole page
Expand Down Expand Up @@ -66,7 +66,7 @@ const objectPlotAndInfo = (objectViewModel) =>
),
),
h('.item-action-row.flex-row.g1.p2', [
downloadRootImageButton(`${qcObject.name}.png`, qcObject.qcObject.root, drawingOptions),
downloadRootImageDropdown(qcObject.name, qcObject.qcObject.root, drawingOptions),
downloadButton({
href: objectViewModel.getDownloadQcdbObjectUrl(qcObject.id),
title: 'Download root object',
Expand Down
Loading
Loading