src_image_annotation.js

import {logger} from '../utils/logger.js';
import {getFlags, replaceFlags} from '../utils/string.js';
import {Point} from '../math/point.js';
import {getOrientationName} from '../math/orientation.js';
import {defaultToolOptions, toolOptions} from '../tools/index.js';
import {guid} from '../math/stats.js';
import {getUID} from '../dicom/dicomWriter.js';

// doc imports
/* eslint-disable no-unused-vars */
import {Point2D, Point3D} from '../math/point.js';
import {Index} from '../math/index.js';
import {ViewController} from '../app/viewController.js';
import {PlaneHelper} from './planeHelper.js';
import {DicomCode} from '../dicom/dicomCode.js';
/* eslint-enable no-unused-vars */

/**
 * Image annotation.
 */
export class Annotation {
  /**
   * Tracking id, unique within domain.
   *
   * @type {string}
   */
  trackingId;

  /**
   * Tracking Unique id.
   *
   * @type {string}
   */
  trackingUid;

  /**
   * Referenced image SOP isntance UID.
   *
   * @type {string}
   */
  referencedSopInstanceUID;

  /**
   * Referenced image SOP class UID.
   *
   * @type {string}
   */
  referencedSopClassUID;

  /**
   * Referenced frame number.
   *
   * @type {number|undefined}
   */
  referencedFrameNumber;

  /**
   * Mathematical shape.
   *
   * @type {object}
   */
  mathShape;

  /**
   * Additional points used to define the annotation.
   *
   * @type {Point2D[]|undefined}
   */
  referencePoints;

  /**
   * Colour: for example 'green', '#00ff00' or 'rgb(0,255,0)'.
   *
   * @type {string}
   */
  colour = '#ffff80';

  /**
   * Annotation quantification.
   *
   * @type {object|undefined}
   */
  quantification;

  /**
   * Text expression. Can contain variables surrounded with '{}' that will
   *   be extracted from the quantification object.
   *
   * @type {string}
   */
  textExpr = '';

  /**
   * Label position. If undefined, the default shape
   *   label position will be used.
   *
   * @type {Point2D|undefined}
   */
  labelPosition;

  /**
   * Plane origin: 3D position of index [0, 0, k].
   *
   * @type {Point3D|undefined}
   */
  planeOrigin;

  /**
   * A couple of points that help define the annotation plane.
   *
   * @type {Point3D[]|undefined}
   */
  planePoints;

  /**
   * Associated view controller: needed for quantification and label.
   *
   * @type {ViewController|undefined}
   */
  #viewController;

  /**
   * Annotation meta data. Array of {concept:DicomCode, value:DicomCode}
   *   or {concept:DicomCode, value:string}.
   *
   * @type {object}
   */
  #meta = {};

  /**
   * Constructor: set default annotation id and uid.
   */
  constructor() {
    this.trackingId = guid();
    this.trackingUid = getUID('TrackingUniqueIdentifier');
  }

  /**
   * Get the concepts ids of the annotation meta data.
   *
   * @returns {string[]} The ids.
   */
  getMetaConceptIds() {
    return Object.keys(this.#meta);
  }

  /**
   * Get an annotation meta data.
   *
   * @param {string} conceptId The value of the concept dicom code.
   * @returns {object|undefined} The corresponding meta data item
   *   as {concept, value} or undefined.
   */
  getMetaItem(conceptId) {
    return this.#meta[conceptId];
  }

  /**
   * Add annotation meta data.
   *
   * @param {DicomCode} concept The concept code.
   * @param {DicomCode|string} value The value code.
   */
  addMetaItem(concept, value) {
    const conceptId = concept.value;
    if (typeof this.#meta[conceptId] !== 'undefined') {
      logger.warn('Overwriting annotation meta with id=' + conceptId);
    }
    this.#meta[concept.value] = {
      concept: concept,
      value: value
    };
  }

  /**
   * Remove an annotation meta data.
   *
   * @param {string} conceptId The value of the concept dicom code.
   */
  removeMetaItem(conceptId) {
    if (typeof this.#meta[conceptId] !== 'undefined') {
      delete this.#meta[conceptId];
    }
  }

  /**
   * Get the orientation name for this annotation.
   *
   * @returns {string|undefined} The orientation name,
   *   undefined if same as reference data.
   */
  getOrientationName() {
    let res;
    if (typeof this.planePoints !== 'undefined') {
      const cosines = this.planePoints[1].getValues().concat(
        this.planePoints[2].getValues()
      );
      res = getOrientationName(cosines);
    }
    return res;
  }

  /**
   * Initialise the annotation.
   *
   * @param {ViewController} viewController The associated view controller.
   */
  init(viewController) {
    if (typeof this.referencedSopInstanceUID !== 'undefined') {
      logger.debug('Cannot initialise annotation twice');
      return;
    }

    this.#viewController = viewController;
    const currentPosition = viewController.getCurrentPosition();
    // set UID
    this.referencedSopInstanceUID = viewController.getCurrentImageUid();
    this.referencedSopClassUID = viewController.getSopClassUid();
    if (currentPosition.length() > 3) {
      this.referencedFrameNumber = currentPosition.get(3);
    }
    // set plane origin (not saved with file)
    this.planeOrigin =
      viewController.getOriginForImageUid(this.referencedSopInstanceUID);
    // set plane points if not aquisition orientation
    // (planePoints are saved with file if present)
    if (!viewController.isAquisitionOrientation()) {
      this.planePoints = viewController.getPlanePoints(currentPosition);
    }
  }

  /**
   * Check if the annotation can be displayed: true if it has
   * an associated view controller.
   *
   * @returns {boolean} True if the annotation can be displayed.
   */
  canView() {
    return typeof this.#viewController !== 'undefined';
  }

  /**
   * Check if an input view is compatible with the annotation.
   *
   * @param {PlaneHelper} planeHelper The input view to check.
   * @returns {boolean} True if compatible view.
   */
  isCompatibleView(planeHelper) {
    let res = false;

    // TODO: add check for referenceSopUID

    if (typeof this.planePoints === 'undefined') {
      // non oriented view
      if (planeHelper.isAquisitionOrientation()) {
        res = true;
      }
    } else {
      // oriented view: compare cosines (independent of slice index)
      const cosines = planeHelper.getCosines();
      const cosine1 = new Point3D(cosines[0], cosines[1], cosines[2]);
      const cosine2 = new Point3D(cosines[3], cosines[4], cosines[5]);

      if (cosine1.equals(this.planePoints[1]) &&
        cosine2.equals(this.planePoints[2])) {
        res = true;
      }
    }
    return res;
  }

  /**
   * Set the associated view controller if it is compatible.
   *
   * @param {ViewController} viewController The view controller.
   */
  setViewController(viewController) {
    // check uid
    if (!viewController.includesImageUid(this.referencedSopInstanceUID)) {
      logger.warn('Cannot view annotation with reference UID: ' +
        this.referencedSopInstanceUID);
      return;
    }
    // check if same view
    if (!this.isCompatibleView(viewController.getPlaneHelper())) {
      return;
    }
    this.#viewController = viewController;

    // set plane origin (not saved with file)
    this.planeOrigin =
      viewController.getOriginForImageUid(this.referencedSopInstanceUID);
  }

  /**
   * Get the index of the plane origin.
   *
   * @returns {Index|undefined} The index.
   */
  #getOriginIndex() {
    let res;
    if (typeof this.#viewController !== 'undefined') {
      let origin = this.planeOrigin;
      if (typeof this.planePoints !== 'undefined') {
        origin = this.planePoints[0];
      }
      const values = [origin.getX(), origin.getY(), origin.getZ()];
      if (typeof this.referencedFrameNumber !== 'undefined') {
        values.push(this.referencedFrameNumber);
      }
      const originPoint = new Point(values);
      res = this.#viewController.getIndexFromPosition(originPoint);
    }
    return res;
  }

  /**
   * Get the centroid of the math shape.
   *
   * @returns {Point|undefined} The 3D centroid point.
   */
  getCentroid() {
    let res;
    if (typeof this.#viewController !== 'undefined' &&
      typeof this.mathShape.getCentroid !== 'undefined' &&
      typeof this.mathShape.getCentroid() !== 'undefined') {
      // find the slice index of the annotation origin
      const originIndex = this.#getOriginIndex();
      const scrollDimIndex = this.#viewController.getScrollDimIndex();
      const k = originIndex.getValues()[scrollDimIndex];
      // shape center converted to 3D
      const planePoint = this.mathShape.getCentroid();
      res = this.#viewController.getPositionFromPlanePoint(planePoint, k);
      // add frame number if defined
      if (typeof this.referencedFrameNumber !== 'undefined') {
        const values = res.getValues();
        values[3] = this.referencedFrameNumber;
        res = new Point(values);
      }
    }
    return res;
  }

  /**
   * Set the annotation text expression.
   *
   * @param {Object.<string, string>} labelText The list of label
   *   texts indexed by modality.
   */
  setTextExpr(labelText) {
    if (typeof this.#viewController !== 'undefined') {
      const modality = this.#viewController.getModality();

      if (typeof labelText[modality] !== 'undefined') {
        this.textExpr = labelText[modality];
      } else {
        this.textExpr = labelText['*'];
      }
    } else {
      logger.warn('Cannot set text expr without a view controller');
    }
  }

  /**
   * Get the annotation label text by applying the
   *   text expression on the current quantification.
   *
   * @returns {string} The resulting text.
   */
  getText() {
    return replaceFlags(this.textExpr, this.quantification);
  }

  /**
   * Update the annotation quantification.
   */
  updateQuantification() {
    if (typeof this.#viewController !== 'undefined' &&
      typeof this.mathShape.quantify !== 'undefined') {
      this.quantification = this.mathShape.quantify(
        this.#viewController,
        this.#getOriginIndex(),
        getFlags(this.textExpr)
      );
    }
  }

  /**
   * Get the math shape associated draw factory.
   *
   * @returns {object|undefined} The factory.
   */
  getFactory() {
    let fac;
    // no factory if no mathShape
    if (typeof this.mathShape === 'undefined') {
      return fac;
    }
    // check in user provided factories
    if (typeof toolOptions.draw !== 'undefined') {
      for (const factoryName in toolOptions.draw) {
        const factory = toolOptions.draw[factoryName];
        if (factory.supports(this.mathShape)) {
          fac = new factory();
          break;
        }
      }
    }
    // check in default factories
    if (typeof fac === 'undefined') {
      for (const factoryName in defaultToolOptions.draw) {
        const factory = defaultToolOptions.draw[factoryName];
        if (factory.supports(this.mathShape)) {
          fac = new factory();
          break;
        }
      }
    }
    if (typeof fac === 'undefined') {
      logger.warn('No shape factory found for math shape');
    }
    return fac;
  }
}