src_gui_infoData.js

import {ListenerHandler} from '../utils/listen.js';
import {getReverseOrientation} from '../dicom/dicomParser.js';
import {
  getDateObj,
  getTimeObj,
  getDate
} from '../dicom/dicomDate.js';
import {logger} from '../utils/logger.js';

// doc imports
/* eslint-disable no-unused-vars */
import {App} from '../app/application.js';
import {DataElement} from '../dicom/dataElement.js';
/* eslint-enable no-unused-vars */

/**
 * Info data item class.
 */
export class InfoDataItem {
  /**
   * List of tag keys.
   *
   * @type {string[]|undefined}
   */
  tags;
  /**
   * @type {string|undefined}
   */
  event;

  /**
   * @type {string}
   */
  pos;
  /**
   * @type {string}
   */
  format;
  /**
   * @type {string|undefined}
   */
  precision;

  /**
   * @type {string}
   */
  value;
}

/**
 * Get a number toprecision function with the provided precision.
 *
 * @param {number} precision The precision to achieve.
 * @returns {Function} The to precision function.
 */
function getNumberToPrecision(precision) {
  return function (num) {
    return Number(num).toPrecision(precision);
  };
}

/**
 * Create a default replace format from a given length.
 * For example: '{v0}, {v1}'.
 *
 * @param {number} length The length of the format.
 * @returns {string} A replace format.
 */
function createDefaultReplaceFormat(length) {
  let res = '';
  for (let i = 0; i < length; ++i) {
    if (i !== 0) {
      res += ', ';
    }
    res += '{v' + i + '}';
  }
  return res;
}

/**
 * Replace flags in a input string. Flags are keywords surrounded with curly
 * braces in the form: '{v0}, {v1}'.
 *
 * @param {string} inputStr The input string.
 * @param {Array} values An array of values.
 * @example
 *    var values = ["a", "b"];
 *    var str = "The length is: {v0}. The size is: {v1}";
 *    var res = replaceFlags(str, values);
 *    // "The length is: a. The size is: b"
 * @returns {string} The result string.
 */
function replaceFlags(inputStr, values) {
  let res = inputStr;
  for (let i = 0; i < values.length; ++i) {
    res = res.replace('{v' + i + '}', values[i]);
  }
  return res;
}

/**
 * DICOM Header info data.
 */
export class InfoData {

  /**
   * Associated app.
   *
   * @type {App}
   */
  #app;

  /**
   * Associated data id.
   *
   * @type {string}
   */
  #dataId;

  /**
   * Info data config indexed by modality.
   *
   * @type {Object<string, InfoDataItem[]>}
   */
  #infoConfigs;

  /**
   * List of event used by the config.
   *
   * @type {string[]}
   */
  #eventNames = [];

  /**
   * Flag to know if listening to app.
   *
   * @type {boolean}
   */
  #isListening;

  /**
   * Meta data storage indexed by dataUid.
   *
   * @type {Object<string, InfoDataItem[]>|undefined}
   */
  #infoData = {};

  /**
   * Current data uid: set on pos change.
   *
   * @type {string}
   */
  #currentDataUid;

  /**
   * Listener handler.
   *
   * @type {ListenerHandler}
   */
  #listenerHandler = new ListenerHandler();

  /**
   * @param {App} app The associated application.
   * @param {string} dataId The associated data id.
   * @param {Object<string, InfoDataItem[]>} configs The info data config.
   */
  constructor(app, dataId, configs) {
    this.#app = app;
    this.#dataId = dataId;
    this.#infoConfigs = configs;

    // parse config to get the list of events to listen to
    const keys = Object.keys(this.#infoConfigs);
    for (let i = 0; i < keys.length; ++i) {
      const modalityConfig = this.#infoConfigs[keys[i]];
      for (const item of modalityConfig) {
        const eventType = item.event;
        if (typeof eventType !== 'undefined') {
          if (!this.#eventNames.includes(eventType)) {
            this.#eventNames.push(eventType);
          }
        }
      }
    }
    // add app listeners
    this.addAppListeners();
  }

  /**
   * Reset the data.
   */
  reset() {
    this.#infoData = {};
    this.#currentDataUid = undefined;
  }

  /**
   * Handle a new loaded item event.
   *
   * @param {Object<string, DataElement>} data The item meta data.
   */
  addItemMeta(data) {
    // create and store info data
    let dataUid;
    // check if dicom data (00020010: transfer syntax)
    if (typeof data['00020010'] !== 'undefined') {
      if (typeof data['00080018'] !== 'undefined') {
        // SOP instance UID
        dataUid = data['00080018'].value[0];
        this.#infoData[dataUid] = createInfoData(data, this.#infoConfigs);
      } else {
        logger.warn('Missing DICOM SOP instance UID for info data indexing');
      }
    } else {
      // image file case
      if (typeof data['imageUid'] !== 'undefined') {
        dataUid = data['imageUid'].value[0];
        this.#infoData[dataUid] = createInfoDataForDom(data, this.#infoConfigs);
      } else {
        logger.warn('Missing DOM image UID for info data indexing');
      }
    }
    // store uid
    if (typeof dataUid !== 'undefined') {
      this.#currentDataUid = dataUid;
    }
  }

  /**
   * Handle a changed slice event.
   *
   * @param {object} event The slicechange event.
   */
  onSliceChange = (event) => {
    if (event.dataid !== this.#dataId) {
      return;
    }
    if (typeof event.data !== 'undefined' &&
      typeof event.data.imageUid !== 'undefined' &&
      this.#currentDataUid !== event.data.imageUid) {
      this.#currentDataUid = event.data.imageUid;
      this.#updateData(event);
    }
  };

  /**
   * Update the info data.
   *
   * @param {object} event An event defined by the info map and
   *   registered in toggleListeners.
   */
  #updateData = (event) => {
    if (typeof event.dataid !== 'undefined' &&
      event.dataid !== this.#dataId) {
      return;
    }

    const sliceInfoData = this.#infoData[this.#currentDataUid];
    if (typeof sliceInfoData === 'undefined') {
      console.warn('No slice info data for: ' + this.#currentDataUid);
      return;
    }

    for (const infoDataItem of sliceInfoData) {
      let text = undefined;
      if (typeof infoDataItem.tags !== 'undefined') {
        // update tags only on slice change
        if (event.type === 'positionchange') {
          text = infoDataItem.value;
        }
      } else {
        // update text if the value is an event type
        if (typeof infoDataItem.event !== 'undefined' &&
          infoDataItem.event === event.type) {
          const format = infoDataItem.format;
          let values = event.value;
          // optional number precision
          if (typeof infoDataItem.precision !== 'undefined') {
            let mapFunc = null;
            if (infoDataItem.precision === 'round') {
              mapFunc = Math.round;
            } else {
              mapFunc = getNumberToPrecision(
                parseInt(infoDataItem.precision, 10)
              );
            }
            values = values.map(mapFunc);
          }
          text = replaceFlags(format, values);
        }
      }
      if (typeof text !== 'undefined') {
        infoDataItem.value = text;
      }
    }

    /**
     * Value change event.
     *
     * @event InfoData#valuechange
     * @type {object}
     * @property {string} type The event type.
     * @property {InfoDataItem[]} data The value of the info data.
     */
    this.#fireEvent({
      type: 'valuechange',
      data: sliceInfoData
    });
  };

  /**
   * Is this class listening to app events.
   *
   * @returns {boolean} True is listening to app events.
   */
  isListening() {
    return this.#isListening;
  }

  /**
   * Toggle info listeners.
   */
  addAppListeners() {
    // listen to update tags data
    this.#app.addEventListener('positionchange', this.onSliceChange);
    // add event listeners
    for (const eventName of this.#eventNames) {
      this.#app.addEventListener(eventName, this.#updateData);
    }
    // update flag
    this.#isListening = true;
  }

  /**
   * Toggle info listeners.
   */
  removeAppListeners() {
    // stop listening to update tags data
    this.#app.removeEventListener('positionchange', this.onSliceChange);
    // remove event listeners
    for (const eventName of this.#eventNames) {
      this.#app.removeEventListener(eventName, this.#updateData);
    }
    // update flag
    this.#isListening = false;
  }

  /**
   * Add an event listener to this class.
   *
   * @param {string} type The event type.
   * @param {object} callback The method associated with the provided
   *   event type, will be called with the fired event.
   */
  addEventListener(type, callback) {
    this.#listenerHandler.add(type, callback);
  }

  /**
   * Remove an event listener from this class.
   *
   * @param {string} type The event type.
   * @param {object} callback The method associated with the provided
   *   event type.
   */
  removeEventListener(type, callback) {
    this.#listenerHandler.remove(type, callback);
  }

  /**
   * Fire an event: call all associated listeners with the input event object.
   *
   * @param {object} event The event to fire.
   */
  #fireEvent(event) {
    this.#listenerHandler.fireEvent(event);
  }

} // class InfoData

/**
 * Create info data array for a DICOM image.
 *
 * @param {object} dicomElements DICOM elements of the image.
 * @param {Object<string, InfoDataItem[]>} configs The info data configs.
 * @returns {InfoDataItem[]} Info data array.
 */
function createInfoData(dicomElements, configs) {
  const datas = [];
  let modality;
  const modElement = dicomElements['00080060'];
  if (typeof modElement !== 'undefined') {
    modality = modElement.value[0];
  } else {
    return datas;
  }
  const modalityConfigs = configs[modality] || configs['*'];
  if (!modalityConfigs) {
    return datas;
  }

  // transform a config item into data item by adding a value
  for (const modalityConfig of modalityConfigs) {
    // deep copy
    const dataItem = JSON.parse(JSON.stringify(modalityConfig));

    // add tag values
    const tags = dataItem.tags;
    if (typeof tags !== 'undefined' && tags.length !== 0) {
      // get values
      const values = [];
      for (const tag of tags) {
        const elem = dicomElements[tag];
        if (typeof elem !== 'undefined') {
          if (elem.vr === 'DA') {
            const da = getDate(getDateObj(elem));
            if (typeof da !== 'undefined') {
              values.push(da.toLocaleDateString());
            }
          } else if (elem.vr === 'TM') {
            const baseDateObj = {year: 1900, monthIndex: 0, day: 1};
            const da = getDate(baseDateObj, getTimeObj(elem));
            if (typeof da !== 'undefined') {
              values.push(da.toLocaleTimeString());
            }
          } else {
            values.push(elem.value);
          }
        } else {
          values.push('undefined');
        }
      }
      // format
      if (typeof dataItem.format === 'undefined' || dataItem.format === null) {
        dataItem.format = createDefaultReplaceFormat(values.length);
      }
      dataItem.value = replaceFlags(dataItem.format, values).trim();
    }

    // store
    datas.push(dataItem);
  }

  // (0020,0020) Patient Orientation
  const poElement = dicomElements['00200020'];
  if (typeof poElement !== 'undefined' &&
    poElement.value.length === 2
  ) {
    const po0 = poElement.value[0];
    const po1 = poElement.value[1];
    datas.push({
      pos: 'cr', value: po0, format: '{v0}'
    });
    datas.push({
      pos: 'cl', value: getReverseOrientation(po0), format: '{v0}'
    });
    datas.push({
      pos: 'bc', value: po1, format: '{v0}'
    });
    datas.push({
      pos: 'tc', value: getReverseOrientation(po1), format: '{v0}'
    });
  }

  return datas;
}

/**
 * Create info data array for a DOM image.
 *
 * @param {object} domElements Meta data.
 * @param {Object<string, InfoDataItem[]>} configs The info data configs.
 * @returns {InfoDataItem[]} Info data array.
 */
function createInfoDataForDom(domElements, configs) {
  const datas = [];
  const domConfigs = configs.DOM;
  if (!domConfigs) {
    return datas;
  }

  // transform a config item into data item by adding a value
  for (const domConfig of domConfigs) {
    // deep copy
    const dataItem = JSON.parse(JSON.stringify(domConfig));

    // add tag values
    const tags = dataItem.tags;
    if (typeof tags !== 'undefined' && tags.length !== 0) {
      // get values
      const values = [];
      for (const tag of tags) {
        const elem = domElements[tag];
        if (typeof elem !== 'undefined') {
          values.push(elem.value);
        }
      }
      // format
      if (typeof dataItem.format === 'undefined' || dataItem.format === null) {
        dataItem.format = createDefaultReplaceFormat(values.length);
      }
      dataItem.value = replaceFlags(dataItem.format, values).trim();
    }

    // store
    datas.push(dataItem);
  }

  return datas;
}