Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 0 additions & 39 deletions QualityControl/public/common/downloadRootImageButton.js

This file was deleted.

70 changes: 70 additions & 0 deletions QualityControl/public/common/downloadRootImageDropdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* @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 { SUPPORTED_ROOT_IMAGE_FILE_TYPES } 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 deduplicated = Object.entries(SUPPORTED_ROOT_IMAGE_FILE_TYPES).reduce(
(acc, [key, value]) => {
if (!acc.seen.has(value)) {
acc.seen.add(value);
acc.result[key] = value;
}
return acc;
},
{ seen: new Set(), result: {} },
).result;

return DropdownComponent(
h('button.btn.save-root-as-image-button', { title: 'Save root as image' }, imagE()),
Object.keys(deduplicated).map((filetype) => h('button.btn.d-block.w-100', {
key: `${uniqueIdentifier ?? filename}.${filetype}`,
id: `${uniqueIdentifier ?? filename}.${filetype}`,
title: `Save root as image (${filetype})`,
onclick: async (event) => {
try {
event.target.disabled = true;
await downloadRoot(filename, filetype, root, drawingOptions);
} finally {
event.target.disabled = false;
}
},
}, filetype)),
{ onVisibilityChange },
);
}
26 changes: 26 additions & 0 deletions QualityControl/public/common/enums/rootImageMimes.enum.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* @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 SUPPORTED_ROOT_IMAGE_FILE_TYPES = Object.freeze({
svg: 'image/svg+xml',
png: 'file/png',
jpg: 'file/jpeg',
jpeg: 'file/jpeg',
webp: 'file/webp',
});
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 { SUPPORTED_ROOT_IMAGE_FILE_TYPES } from './enums/rootImageMimes.enum.js';

/* global JSROOT */

/**
* 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 = SUPPORTED_ROOT_IMAGE_FILE_TYPES[filetype];
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, iconArrowTop, 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 @@ -314,6 +315,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 @@ -653,4 +655,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 @@ -20,7 +20,7 @@ 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 { downloadRootImageButton } from '../common/downloadRootImageButton.js';
import { downloadRootImageDropdown } from '../common/downloadRootImageDropdown.js';

/**
* Shows a page to explore though a tree of objects with a preview on the right if clicked
Expand Down Expand Up @@ -104,7 +104,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