tests_pacs_viewer.ui.segment.js

import {DicomWriter} from '../../src/dicom/dicomWriter.js';
import {
  rgbToHex,
  hexToRgb,
} from '../../src/utils/colour.js';
import {logger} from '../../src/utils/logger.js';
import {i18n} from '../../src/utils/i18n.js';
import {getSegmentationCode} from '../../src/dicom/dicomCode.js';
import {getReferencedSeriesUID} from '../../src/dicom/dicomImage.js';
import {MaskFactory} from '../../src/image/maskFactory.js';
import {MaskSegmentHelper} from '../../src/image/maskSegmentHelper.js';
import {MaskSegmentViewHelper} from '../../src/image/maskSegmentViewHelper.js';
import {
  ChangeSegmentColourCommand
} from '../../src/image/changeSegmentColourCommand.js';
import {
  DeleteSegmentCommand
} from '../../src/image/deleteSegmentCommand.js';

import {
  getHtmlId,
  getRootFromHtmlId
} from './viewer.ui.js';
import {
  getButton,
  setButtonPressed,
  isButtonPressed
} from './viewer.ui.icons.js';

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

/**
 * Segmentation type.
 *
 * @typedef Segmentation
 * @property {number} dataId The segmentation data ID.
 * @property {object[]} labels The segmentation labels.
 * @property {boolean} hasNewSegments If the segmentation is new.
 * @property {object[]} segments The segmentation segments.
 * @property {MaskSegmentViewHelper} viewHelper A view helper.
 */

// global vars
const _colours = [
  {r: 255, g: 0, b: 0},
  {r: 0, g: 255, b: 0},
  {r: 0, g: 0, b: 255},
  {r: 0, g: 255, b: 255},
  {r: 255, g: 0, b: 255},
  {r: 255, g: 255, b: 0},
  {r: 255, g: 255, b: 255},
  {r: 255, g: 128, b: 128},
  {r: 128, g: 255, b: 128},
  {r: 128, g: 128, b: 255},
  {r: 128, g: 255, b: 255},
  {r: 255, g: 128, b: 255},
  {r: 255, g: 255, b: 128},
  {r: 128, g: 128, b: 128},
  {r: 255, g: 64, b: 64},
  {r: 64, g: 255, b: 64},
  {r: 64, g: 64, b: 255},
  {r: 64, g: 255, b: 255},
  {r: 255, g: 64, b: 255},
  {r: 255, g: 255, b: 64},
  {r: 64, g: 64, b: 64},
];
// colour array to pick from
let _coloursPick = _colours.slice();
// segmentation
/** @type {Segmentation[]} */
const _segmentations = [];

/**
 * Get a segment from a segment list.
 *
 * @param {number} segmentNumber The segment number.
 * @param {object[]} segments The list to search.
 * @returns {object|undefined} The found segment.
 */
function getSegment(segmentNumber, segments) {
  return segments.find(function (item) {
    return item.number === segmentNumber;
  });
}

/**
 * Get the next available colour from the colour list.
 *
 * @returns {object} The colour as {r,g,b}.
 */
function nextColour() {
  // recreate if empty
  if (_coloursPick.length === 0) {
    console.log('Regenerating colours...');
    _coloursPick = _colours.slice();
  }
  // pick first in list
  const colour = _coloursPick[0];
  // remove picked
  _coloursPick.splice(0, 1);
  // return first
  return colour;
}

/**
 * Get a new segment.
 *
 * @param {number} number The segment number.
 * @returns {object} The new segment.
 */
function getNewSegment(number) {
  return {
    number,
    algorithmType: 'MANUAL',
    algorithmName: undefined,
    label: 's' + number,
    displayRGBValue: nextColour(),
    displayValue: undefined,
    propertyCategoryCode: getSegmentationCode(),
    propertyTypeCode: getSegmentationCode(),
    trackingId: undefined,
    trackingUid: undefined
  };
}

/**
 * HTML element id prefixes.
 */
const prefixes = {
  segmentation: 'segmentation',
  segment: 'segment',
  span: 'span-',
  select: 'select-',
  colour: 'colour-',
  view: 'view-',
  delete: 'delete-',
  addSegment: 'add-segment-',
  selectEraser: 'select-eraser-',
  save: 'save-',
  volumes: 'span-volumes-',
  info: 'info-',
  li: 'li-'
};

/**
 * Get the HTML id of a segmentation.
 *
 * @param {number} segmentationIndex The segmentation index.
 * @returns {string} The segmentation HTML id.
 */
function getSegmentationHtmlId(segmentationIndex) {
  return getHtmlId(prefixes.segmentation, segmentationIndex);
}

/**
 * Get a segmentation index from an HTML id.
 *
 * @param {string} segmentationName The segmentation HTML id.
 * @returns {number} The segmentation index.
 */
function splitSegmentationHtmlId(segmentationName) {
  const indexStr = getRootFromHtmlId(
    prefixes.segmentation, segmentationName);
  return parseInt(indexStr, 10);
}

/**
 * Get the HTML id of a segment.
 *
 * @param {number} segmentNumber The segment number.
 * @param {number} segmentationIndex The segmentation index.
 * @returns {string} The segment HTML id.
 */
function getSegmentHtmlId(segmentNumber, segmentationIndex) {
  const segmentName = getHtmlId(prefixes.segment, segmentNumber);
  const segmentationName = getSegmentationHtmlId(segmentationIndex);
  return segmentName + '-' + segmentationName;
}

/**
 * Get a segment index and number from an HTML id.
 *
 * @param {string} segmentId The segment id.
 * @returns {object} The segment index and number.
 */
function splitSegmentHtmlId(segmentId) {
  const split = segmentId.split('-');
  const numberStr = getRootFromHtmlId(prefixes.segment, split[0]);
  return {
    segmentNumber: parseInt(numberStr, 10),
    segmentationIndex: splitSegmentationHtmlId(split[1])
  };
}

/**
 * Segmentation UI.
 */
export class SegmentationUI {

  /**
   * The associated application.
   *
   * @type {App}
   */
  #app;

  /**
   * The root document.
   *
   * @type {Document}
   */
  #rootDoc = document;

  /**
   * With overlap check flag.
   *
   * @type {boolean}
   */
  #withOverlapCheck = true;

  /**
   * @param {App} app The associated application.
   * @param {Document} [rootDoc] Optional root document,
   *   defaults to `window.document`.
   */
  constructor(app, rootDoc) {
    this.#app = app;
    if (typeof rootDoc !== 'undefined') {
      this.#rootDoc = rootDoc;
    }
  }

  /**
   * Bind app to ui.
   */
  registerListeners() {
    this.#app.addEventListener('dataadd', this.#onDataAdd);
    this.#app.addEventListener('labelschanged', this.#onLabelsChanged);
  };

  /**
   * Setup the container div.
   */
  #setupContainerDiv() {
    // fieldset
    const legend = document.createElement('legend');
    legend.appendChild(document.createTextNode('Segmentations'));

    const fieldset = document.createElement('fieldset');
    fieldset.id = 'segmentations-fieldset';
    fieldset.appendChild(legend);

    // main div
    const line = document.createElement('div');
    line.id = 'segmentations-line';
    line.className = 'line';
    line.appendChild(fieldset);

    // insert
    const detailsEl = this.#rootDoc.getElementById('layersdetails');
    detailsEl.parentElement.insertBefore(line, detailsEl);
  }

  /**
   * Get the container div.
   *
   * @returns {HTMLDivElement} The element.
   */
  #getContainerDiv() {
    return this.#rootDoc.getElementById('segmentations-fieldset');
  }

  /**
   * Calculate mask labels.
   *
   * @param {number} dataId The data id.
   */
  #calculateLabels(dataId) {
    const maskData = this.#app.getData(dataId);
    if (!maskData) {
      throw new Error(
        'No data to calculate labels for dataId: ' + dataId
      );
    }
    const image = maskData.image;
    image.recalculateLabels();
  }

  /**
   * Handle a labels changed event.
   *
   * @param {object} event The change event.
   */
  #onLabelsChanged = (event) => {
    const segmentation =
      _segmentations.find(
        (seg) => {
          return seg.dataId === event.dataid;
        }
      );

    if (typeof segmentation !== 'undefined') {
      segmentation.labels = event.labels;
    }
  };

  /**
   * Add a segment HTML to the main HTML.
   *
   * @param {object} segmentation The segmentation.
   */
  #addSegmentationHtml(segmentation) {
    // segmentation as html
    const item =
      this.#getSegmentationHtml(segmentation, _segmentations.length - 1);

    // add segmentation item
    const addItem = this.#rootDoc.getElementById('addsegmentationitem');
    // remove and add after to make it last item
    addItem.remove();

    // update list
    const segList = this.#rootDoc.getElementById('segmentation-list');
    segList.appendChild(item);
    segList.appendChild(addItem);

    if (this.#withOverlapCheck) {
      this.#addOverlapCheckerSelection(segmentation, _segmentations.length - 1);
    }
  }

  /**
   * Handle a dataadd event.
   *
   * @param {object} event The dataadd event.
   */
  #onDataAdd = (event) => {
    const dataId = event.dataid;
    const maskImage = this.#app.getData(dataId).image;

    if (typeof maskImage !== 'undefined' &&
      maskImage.getMeta().Modality === 'SEG') {
      // setup html if needed
      if (!this.#rootDoc.getElementById('segmentation-list')) {
        this.#setupHtml();
      }

      const segHelper = new MaskSegmentHelper(maskImage);
      if (segHelper.getNumberOfSegments() === 0) {
        // manually created segmentation with no segments
        const selectSegmentCheckedId = this.#getSelectSegmentCheckedId();
        if (typeof selectSegmentCheckedId === 'undefined') {
          // default segment created at first brush
          const segmentNumber = 1;
          const segment = getNewSegment(segmentNumber);
          segHelper.addSegment(segment);
          // default segmentation
          const segmentation = {
            dataId: dataId,
            labels: [],
            hasNewSegments: false,
            segments: [segment],
            selectedSegmentNumber: segmentNumber,
            viewHelper: new MaskSegmentViewHelper()
          };
          // add to list
          _segmentations.push(segmentation);
          // add to html
          this.#addSegmentationHtml(segmentation);
        } else {
          const indices = splitSegmentHtmlId(
            getRootFromHtmlId(prefixes.select, selectSegmentCheckedId));
          const segmentation = _segmentations[indices.segmentationIndex];
          // segmentation created with add segmentation
          if (typeof segmentation.dataId === 'undefined') {
            segmentation.dataId = dataId;
            for (const segment of segmentation.segments) {
              segHelper.addSegment(segment);
            }
          }
          segmentation.hasNewSegments = false;
        }
      } else {
        // segmentation from loaded file, pass segments to ui
        const imgMeta = maskImage.getMeta();
        if (typeof imgMeta !== 'undefined') {
          // loaded segmentation
          const segmentation = {
            dataId: dataId,
            labels: [],
            hasNewSegments: true,
            segments: imgMeta.custom.segments,
            viewHelper: new MaskSegmentViewHelper()
          };
          // calculate labels
          this.#calculateLabels(dataId);
          // add to list
          _segmentations.push(segmentation);
          // add to html
          this.#addSegmentationHtml(segmentation);

          // remove colour from colour pick
          for (const segment of imgMeta.custom.segments) {
            const index = _coloursPick.findIndex((item) =>
              item.r === segment.displayRGBValue.r &&
              item.g === segment.displayRGBValue.g &&
              item.b === segment.displayRGBValue.b);
            if (index !== -1) {
              _coloursPick.splice(index, 1);
            }
          }
        }
      }
    }
  };

  /**
   * Setup the html for the segmentation list.
   */
  #setupHtml() {
    // segmentation list
    const segList = document.createElement('ul');
    segList.id = 'segmentation-list';
    segList.className = 'data-list';

    // loop on segmentations
    for (let i = 0; i < _segmentations.length; ++i) {
      const segmentationItem = this.#getSegmentationHtml(_segmentations[i], i);
      segList.appendChild(segmentationItem);
    }

    // extra item for add segmentation button
    const addItem = document.createElement('li');
    addItem.id = 'addsegmentationitem';
    const addSegmentationButton = document.createElement('button');
    addSegmentationButton.appendChild(
      document.createTextNode('Add segmentation'));
    addSegmentationButton.onclick = (/*event*/) => {
      // new segmentation
      const segmentation = {
        dataId: undefined,
        labels: [],
        hasNewSegments: true,
        segments: [getNewSegment(1)],
        viewHelper: new MaskSegmentViewHelper()
      };
      // add to list
      _segmentations.push(segmentation);
      // add to html
      this.#addSegmentationHtml(segmentation);
    };
    addItem.appendChild(addSegmentationButton);
    segList.appendChild(addItem);


    // setup and append
    this.#setupContainerDiv();
    this.#getContainerDiv().appendChild(segList);

    if (this.#withOverlapCheck) {
      const overlapChecker = this.#getSegmentationOverlapHtml();
      this.#getContainerDiv().appendChild(overlapChecker);
    }
  }

  /**
   * Select a segment in the brush tool.
   *
   * @param {number} segmentNumber The segment number.
   * @param {object} segmentation The segmentation.
   */
  #appSelectSegment(segmentNumber, segmentation) {
    segmentation.selectedSegmentNumber = segmentNumber;

    // add segment if not present
    const data = this.#app.getData(segmentation.dataId);
    if (typeof data !== 'undefined') {
      const maskImage = data.image;
      const segHelper = new MaskSegmentHelper(maskImage);
      // add segment to mask
      if (!segHelper.hasSegment(segmentNumber)) {
        console.log('Add segment', segmentNumber);
        segHelper.addSegment(getSegment(
          segmentNumber, segmentation.segments
        ));
      }
    }

    // app features
    const features = {
      brushMode: 'add',
      selectedSegmentNumber: segmentNumber,
      maskDataId: undefined,
      createMask: false
    };
    if (typeof segmentation.dataId !== 'undefined') {
      features.maskDataId = segmentation.dataId;
    } else {
      features.createMask = true;
    }
    console.log('set tool features [add]', features);
    this.#app.setToolFeatures(features);
  }

  /**
   * Select the erase in the brush tool.
   *
   * @param {object} segmentation The segmentation.
   */
  #appSelectEraser(segmentation) {
    // app features
    const features = {
      brushMode: 'del',
      maskDataId: undefined
    };
    if (typeof segmentation.dataId !== 'undefined') {
      features.maskDataId = segmentation.dataId;
    }
    console.log('set tool features [del]', features);
    this.#app.setToolFeatures(features);
  }

  /**
   * Handle a segment select from UI.
   *
   * @param {Event} event HTML event.
   */
  #onSegmentSelect = (event) => {
    const target = event.target;
    // get segment
    const indices = splitSegmentHtmlId(
      getRootFromHtmlId(prefixes.select, target.id));
    const segmentation = _segmentations[indices.segmentationIndex];
    // select it
    this.#appSelectSegment(indices.segmentNumber, segmentation);
  };

  /**
   * Handle a segment colour change from UI.
   *
   * @param {Event} event HTML event.
   */
  #onSegmentColourChange = (event) => {
    const target = event.target;
    const newHexColour = target.value;
    // get segment
    const indices = splitSegmentHtmlId(
      getRootFromHtmlId(prefixes.colour, target.id));
    const segmentation = _segmentations[indices.segmentationIndex];
    const segment = getSegment(indices.segmentNumber, segmentation.segments);
    const segmentHexColour = rgbToHex(segment.displayRGBValue);

    if (newHexColour !== segmentHexColour) {
      // update colours
      const newRgbColour = hexToRgb(newHexColour);
      // get segment and mask
      const maskData = this.#app.getData(segmentation.dataId);
      // change if possible
      if (typeof maskData !== 'undefined') {
        // create change colour command
        const previousColour = segment.displayRGBValue;
        const chgCmd = new ChangeSegmentColourCommand(
          maskData.image, segment, newRgbColour);
        chgCmd.onExecute = function (/*event*/) {
          // not needed the first time but on undo/redo
          target.value = newHexColour;
        };
        chgCmd.onUndo = function () {
          // not needed the first time but on undo/redo
          target.value = rgbToHex(previousColour);
        };
        // execute command
        if (chgCmd.isValid()) {
          chgCmd.execute();
          this.#app.addToUndoStack(chgCmd);
        }
      }

      // update segment
      segment.displayRGBValue = newRgbColour;
      // pass updated color to brush
      this.#appSelectSegment(indices.segmentNumber, segmentation);
    }
  };

  /**
   * Handle a goto segment.
   *
   * @param {MouseEvent} event HTML event.
   */
  #onGotoSegment = (event) => {
    const target = event.currentTarget;
    // get segment
    const indices = splitSegmentHtmlId(
      getRootFromHtmlId(prefixes.li, target.id));
    const segmentation = _segmentations[indices.segmentationIndex];
    const segment = getSegment(indices.segmentNumber, segmentation.segments);

    // Find the first label for this segment
    const label =
      segmentation.labels.find((item) => {
        return item.id === segment.number;
      });

    if (typeof label !== 'undefined') {
      const dataId = segmentation.dataId;
      const drawLayers = this.#app.getViewLayersByDataId(dataId);
      for (const layer of drawLayers) {
        layer.setCurrentPosition(label.centroid);
      }
    } else {
      console.log('No label for this segment');
    }
  };

  /**
   * Handle a segment view change from UI.
   *
   * @param {MouseEvent} event HTML event.
   */
  #onSegmentViewChange = (event) => {
    // do not propagate to parent (triggers goto)
    event.stopPropagation();

    const target = event.target;
    // get segment
    const indices = splitSegmentHtmlId(
      getRootFromHtmlId(prefixes.view, target.id));
    const segmentation = _segmentations[indices.segmentationIndex];
    const segment = getSegment(indices.segmentNumber, segmentation.segments);
    // toggle hidden
    const segViewHelper = segmentation.viewHelper;
    if (isButtonPressed(target)) {
      setButtonPressed(target, false);
      segViewHelper.removeFromHidden(segment.number);
    } else {
      setButtonPressed(target, true);
      segViewHelper.addToHidden(segment.number);
    }
    // apply hidden
    const vls = this.#app.getViewLayersByDataId(segmentation.dataId);
    if (vls.length === 0) {
      console.warn('No layers to show/hide seg');
    }
    for (const vl of vls) {
      const vc = vl.getViewController();
      vc.setMaskViewHelper(segViewHelper);
    }
  };

  /**
   * Handle a segment delete from UI.
   *
   * @param {MouseEvent} event HTML event.
   */
  #onSegmentDelete = (event) => {
    // do not propagate to parent (triggers goto)
    event.stopPropagation();

    const target = event.target;
    // get segment
    const indices = splitSegmentHtmlId(
      getRootFromHtmlId(prefixes.delete, target.id));
    const segmentation = _segmentations[indices.segmentationIndex];
    const segmentId = getSegmentHtmlId(
      indices.segmentNumber, indices.segmentationIndex);

    // get segment divs
    const listItem = this.#rootDoc.getElementById(
      getHtmlId(prefixes.li, segmentId)
    );
    if (!listItem) {
      throw new Error('No segment item');
    }
    const parent = listItem.parentNode;
    if (!parent) {
      throw new Error('No delete span parent');
    }
    const nextItem = listItem.nextSibling;

    // get mask
    const data = this.#app.getData(segmentation.dataId);
    // delete if possible
    if (typeof data !== 'undefined') {
      const segment =
        getSegment(indices.segmentNumber, segmentation.segments);
      // create delete command
      const delCmd = new DeleteSegmentCommand(data.image, segment);
      delCmd.onExecute = function () {
        listItem.remove();
        if (segmentation.viewHelper.isHidden(segment.number)) {
          segmentation.viewHelper.removeFromHidden(segment);
        }
      };
      delCmd.onUndo = function () {
        parent.insertBefore(listItem, nextItem);
      };
      // execute command
      if (delCmd.isValid()) {
        delCmd.execute();
        this.#app.addToUndoStack(delCmd);
      }
    } else {
      listItem.remove();
    }

    // update labels
    this.#calculateLabels(segmentation.dataId);

    // select first segment
    const spanChildren = parent.childNodes;
    for (const spanNode of spanChildren) {
      if (spanNode.nodeName === 'LI') {
        const spanNodeChildren = spanNode.childNodes;
        for (const node of spanNodeChildren) {
          if (node.nodeName === 'INPUT') {
            const input = node;
            input.checked = true;
            break;
          }
        }
        break;
      }
    }
  };

  /**
   * Get the id of the select segment checked input.
   *
   * @returns {string} The input id.
   */
  #getSelectSegmentCheckedId() {
    let id;
    const selectInputs = document.querySelectorAll(
      'input[type=\'radio\'][name=\'select-segment\']'
    );
    for (const input of selectInputs) {
      if (input.checked) {
        id = input.id;
        break;
      }
    }
    return id;
  }

  /**
   * Get the HTML span element for a segment.
   *
   * @param {object} segment The segment.
   * @param {number} segmentationIndex The segmentation index.
   * @returns {HTMLLiElement} THe HTML element.
   */
  #getSegmentHtml(segment, segmentationIndex) {
    const segmentId = getSegmentHtmlId(segment.number, segmentationIndex);

    // segment select
    const selectInput = document.createElement('input');
    selectInput.type = 'radio';
    selectInput.name = 'select-segment';
    selectInput.id = getHtmlId(prefixes.select, segmentId);
    selectInput.title = segmentId;
    selectInput.onchange = this.#onSegmentSelect;

    if (segment.number === 1) {
      selectInput.checked = true;
      this.#appSelectSegment(segment.number, _segmentations[segmentationIndex]);
    }

    const selectLabel = document.createElement('label');
    selectLabel.htmlFor = selectInput.id;
    selectLabel.title = selectInput.title;
    selectLabel.appendChild(document.createTextNode(segment.label));

    const infoButton = getButton('Info');
    infoButton.id = getHtmlId(prefixes.info, segmentId);
    infoButton.title = 'Information';
    infoButton.onclick = (event) => {
      // do not propagate to parent that triggers goto
      event.stopPropagation();
      const target = event.target;
      // get segment
      const indices = splitSegmentHtmlId(
        getRootFromHtmlId(prefixes.info, target.id));
      const segmentation = _segmentations[indices.segmentationIndex];
      const segment = getSegment(indices.segmentNumber, segmentation.segments);

      const labelsInfo = this.#getLabelsInfo(segment, segmentationIndex);

      let qStr = 'Quantification:\n';
      let i = 0;
      for (const labelInfo of labelsInfo) {
        qStr += '- label #' + i + '\n';
        const keys = Object.keys(labelInfo);
        for (const key of keys) {
          const quant = labelInfo[key];
          qStr += '  - ' + key + ': ' +
            quant.value.toPrecision(4) +
            i18n.t(quant.unit);
          qStr += '\n';
        }
        ++i;
      }
      alert(qStr);
    };

    // segment colour
    const colourInput = document.createElement('input');
    colourInput.type = 'color';
    colourInput.title = 'Change segment colour';
    colourInput.id = getHtmlId(prefixes.colour, segmentId);
    colourInput.value = rgbToHex(segment.displayRGBValue);
    colourInput.onchange = this.#onSegmentColourChange;
    colourInput.onclick = (event) => {
      // do not propagate to parent that triggers goto
      event.stopPropagation();
    };

    // segment view
    const viewButton = getButton('View');
    setButtonPressed(viewButton, false);
    viewButton.id = getHtmlId(prefixes.view, segmentId);
    viewButton.title = 'Show/hide segment';
    viewButton.onclick = this.#onSegmentViewChange;

    // segment delete
    const deleteButton = getButton('Delete');
    deleteButton.id = getHtmlId(prefixes.delete, segmentId);
    deleteButton.title = 'Delete segment';
    deleteButton.onclick = this.#onSegmentDelete;

    // content
    const contentDiv = document.createElement('div');
    contentDiv.className = 'data-item-list-item-content';
    contentDiv.appendChild(selectInput);
    contentDiv.appendChild(selectLabel);

    // actions
    const actionsDiv = document.createElement('div');
    actionsDiv.className = 'data-item-list-item-actions';
    actionsDiv.appendChild(infoButton);
    actionsDiv.appendChild(colourInput);
    actionsDiv.appendChild(viewButton);
    actionsDiv.appendChild(deleteButton);

    // list item
    const item = document.createElement('li');
    item.id = getHtmlId(prefixes.li, segmentId);
    item.className = 'data-item-list-item';
    item.title = 'Go to segment';
    item.appendChild(contentDiv);
    item.appendChild(actionsDiv);

    // click on li to go to annotation
    item.addEventListener('click', (event) => {
      const target = event.currentTarget;

      // remove selected class from other rows
      const mainlist = this.#rootDoc.getElementById('segmentation-list');
      const items = mainlist.querySelectorAll('.data-item-list-item');
      items.forEach(item => item.classList.remove('selected'));
      // mark this row as selected
      target.classList.add('selected');

      this.#onGotoSegment(event);
    });

    return item;
  }

  /**
   * Handle an eraser select from UI.
   *
   * @param {Event} event HTML event.
   */
  #onEraserSelect = (event) => {
    const target = event.target;
    // get segmentation
    const segmentationIndex = splitSegmentationHtmlId(
      getRootFromHtmlId(prefixes.selectEraser, target.id));
    const segmentation = _segmentations[segmentationIndex];
    // select eraser
    this.#appSelectEraser(segmentation);
  };

  /**
   * Handle a segment add from UI.
   *
   * @param {MouseEvent} event HTML event.
   */
  #onSegmentAdd = (event) => {
    const target = event.target;
    // get segmentation
    const segmentationIndex = splitSegmentationHtmlId(
      getRootFromHtmlId(prefixes.addSegment, target.id));
    const segmentation = _segmentations[segmentationIndex];
    const segments = segmentation.segments;

    // create new segment
    const newSegment = getNewSegment(segments.length + 1);
    // add to list
    segments.push(newSegment);
    // update flag
    segmentation.hasNewSegments = true;

    // add item to list
    const listDivId = getSegmentationHtmlId(segmentationIndex) + '-list';
    const listDiv = this.#rootDoc.getElementById(listDivId);
    listDiv.appendChild(this.#getSegmentHtml(newSegment, segmentationIndex));
  };

  /**
   * Handle a segmentation save from UI.
   *
   * @param {MouseEvent} event HTML event.
   */
  #onSegmentationSave = (event) => {
    const target = event.target;
    // get segmentation
    const segmentationIndex = splitSegmentationHtmlId(
      getRootFromHtmlId(prefixes.save, target.id));
    const segmentationName = getSegmentationHtmlId(segmentationIndex);
    const segmentation = _segmentations[segmentationIndex];
    const dataId = segmentation.dataId;

    // get data
    const maskData = this.#app.getData(dataId);
    if (typeof maskData === 'undefined') {
      throw new Error('Cannot save without mask image');
    }
    const refSeriesUID = getReferencedSeriesUID(maskData.meta);
    if (typeof refSeriesUID === 'undefined') {
      throw new Error('Cannot save without referenced UID');
    }
    const sourceId = this.#app.getDataIdFromSeriesUid(refSeriesUID);
    if (typeof sourceId === 'undefined') {
      throw new Error('Cannot save without referenced ID');
    }
    const sourceData = this.#app.getData(sourceId);
    if (typeof sourceData === 'undefined') {
      throw new Error('Cannot save without source image');
    }
    // dicom elements
    const fac = new MaskFactory();
    const dicomElements = fac.toDicom(
      maskData.image,
      maskData.image.getMeta().custom.segments,
      sourceData.image,
      {
        MediaStorageSOPInstanceUID: '1.2.3.4.5.6',
        SeriesInstanceUID: '1.2.3.4.5.6',
        SeriesNumber: '1',
        SOPInstanceUID: '1.2.3.4.5.6.1000',
      }
    );
    // create writer with default rules
    const writer = new DicomWriter();
    let dicomBuffer;
    try {
      dicomBuffer = writer.getBuffer(dicomElements);
    } catch (error) {
      logger.error(error);
      alert(error.message);
    }
    if (dicomBuffer !== undefined) {
      // view as Blob to allow download
      const blob = new Blob([dicomBuffer], {type: 'application/dicom'});
      // update generate button
      const element = document.createElement('a');
      element.href = window.URL.createObjectURL(blob);
      element.download = segmentationName + '.dcm';
      // trigger download
      element.click();
      URL.revokeObjectURL(element.href);
    }
  };

  /**
   * Get labels info.
   *
   * @param {object} segment The segment.
   * @param {number} segmentationIndex The segmentation index.
   * @returns {object[]} The labels info.
   */
  #getLabelsInfo(segment, segmentationIndex) {
    // get the labels info strings
    const segmentation = _segmentations[segmentationIndex];
    const labelsInfo = [];
    for (const label of segmentation.labels) {
      if (label.id === segment.number) {
        const labelInfo = {};
        labelInfo.volume = label.volume;

        if (typeof label.diameters !== 'undefined') {
          if (typeof label.diameters.major.diameter.value !== 'undefined') {
            labelInfo.majorDiameter = label.diameters.major.diameter;
          }
          if (typeof label.diameters.minor.diameter.value !== 'undefined') {
            labelInfo.minorDiameter = label.diameters.minor.diameter;
          }
          if (typeof label.height !== 'undefined') {
            labelInfo.height = label.height;
          }
        }

        labelsInfo.push(labelInfo);
      }
    }
    return labelsInfo;
  }

  /**
   * Get the HTML list element for a segmentation.
   *
   * @param {object} segmentation The segmentation.
   * @param {number} segmentationIndex The segmentation index.
   * @returns {HTMLLIElement} The HTML element.
   */
  #getSegmentationHtml(segmentation, segmentationIndex) {
    const segmentationName = getSegmentationHtmlId(segmentationIndex);

    // name
    const nameDiv = document.createElement('span');
    nameDiv.id = segmentationName + '-name';
    nameDiv.className = 'data-item-name';
    nameDiv.appendChild(document.createTextNode(segmentationName));

    // save button
    const saveButton = getButton('Save');
    saveButton.title = 'Save segmentation';
    saveButton.id = getHtmlId(prefixes.save, segmentationName);
    saveButton.onclick = this.#onSegmentationSave;

    // add button
    const addButton = getButton('Add');
    addButton.title = 'Add segment';
    addButton.id = getHtmlId(prefixes.addSegment, segmentationName);
    addButton.onclick = this.#onSegmentAdd;

    // actions
    const actionGroupDiv = document.createElement('div');
    actionGroupDiv.id = segmentationName + '-actions';
    actionGroupDiv.className = 'data-item-actions';
    actionGroupDiv.appendChild(saveButton);
    actionGroupDiv.appendChild(addButton);

    // segment list
    const listDiv = document.createElement('ul');
    listDiv.id = segmentationName + '-list';
    listDiv.className = 'data-item-list';
    for (const segment of segmentation.segments) {
      listDiv.appendChild(this.#getSegmentHtml(segment, segmentationIndex));
    }

    // data-item-header
    const headerDiv = document.createElement('div');
    headerDiv.id = segmentationName + '-header';
    headerDiv.className = 'data-item-header';
    headerDiv.appendChild(nameDiv);
    headerDiv.appendChild(actionGroupDiv);

    // data-item-content
    const contentDiv = document.createElement('div');
    contentDiv.id = segmentationName + '-content';
    contentDiv.className = 'data-item-content';
    contentDiv.appendChild(listDiv);

    // segment eraser
    const eraserInput = document.createElement('input');
    eraserInput.type = 'radio';
    eraserInput.name = 'select-segment';
    eraserInput.title = 'Eraser';
    eraserInput.id = getHtmlId(prefixes.selectEraser, segmentationName);
    eraserInput.onchange = this.#onEraserSelect;

    const eraserLabel = document.createElement('label');
    eraserLabel.htmlFor = eraserInput.id;
    eraserLabel.title = eraserInput.title;
    eraserLabel.appendChild(document.createTextNode('Eraser'));

    // action span
    const postActionsDiv = document.createElement('span');
    postActionsDiv.id = 'span-action-' + segmentationName;
    postActionsDiv.appendChild(eraserInput);
    postActionsDiv.appendChild(eraserLabel);

    // append span to item
    contentDiv.appendChild(postActionsDiv);

    // segmentation item
    const segmentationItem = document.createElement('li');
    segmentationItem.id = segmentationName;
    segmentationItem.className = 'data-item';
    segmentationItem.appendChild(headerDiv);
    segmentationItem.appendChild(contentDiv);

    return segmentationItem;
  }

  /**
   * Add to the list of segmentations on the overlap checker.
   *
   * @param {object} segmentation The segmentation to add.
   * @param {number} segmentationIndex The segmentation index.
   */
  #addOverlapCheckerSelection(segmentation, segmentationIndex) {
    const overlapSelect0 =
      this.#rootDoc.getElementById('overlap-checker-select-list-0');
    const overlapSelect1 =
      this.#rootDoc.getElementById('overlap-checker-select-list-1');

    const newOption0 = document.createElement('option');
    newOption0.innerHTML = getSegmentationHtmlId(segmentationIndex);
    newOption0.value = segmentation.dataId;

    const newOption1 = document.createElement('option');
    newOption1.innerHTML = getSegmentationHtmlId(segmentationIndex);
    newOption1.value = segmentation.dataId;

    overlapSelect0.appendChild(newOption0);
    overlapSelect1.appendChild(newOption1);
  }

  /**
   * Get the HTML for the segmentation overlap chacker.
   *
   * @returns {HTMLLIElement} The HTML element.
   */
  #getSegmentationOverlapHtml() {
    // create overlap checker
    const overlapChecker = document.createElement('div');
    overlapChecker.id = 'overlap-checker';

    // label
    const overlapLabel = document.createElement('label');
    overlapLabel.innerHTML = 'Overlap:';
    overlapChecker.appendChild(overlapLabel);

    // dropdowns
    const overlapSelect0 = document.createElement('select');
    const overlapSelect1 = document.createElement('select');
    overlapSelect0.id = 'overlap-checker-select-list-0';
    overlapSelect1.id = 'overlap-checker-select-list-1';
    overlapChecker.appendChild(overlapSelect0);
    overlapChecker.appendChild(overlapSelect1);

    // button
    const overlapButton = document.createElement('button');
    overlapButton.innerHTML = 'Check Overlap';
    overlapChecker.appendChild(overlapButton);

    // result holder
    const overlapResult = document.createElement('div');
    overlapChecker.appendChild(overlapResult);

    overlapButton.onclick = (/*event*/) => {
      const segment0 = overlapSelect0.value;
      const segment1 = overlapSelect1.value;
      const maskImage0 = this.#app.getData(segment0).image;
      const maskImage1 = this.#app.getData(segment1).image;
      const segHelper0 = new MaskSegmentHelper(maskImage0);
      const segHelper1 = new MaskSegmentHelper(maskImage1);
      const overlap = segHelper0.findOverlap(segHelper1);

      overlapResult.innerHTML = ''; // clear old results
      const overlapList = document.createElement('ul');
      for (
        const [/*segmentNumber0*/, segmentOverlap] of
        Object.entries(overlap)
      ) {
        // title (segment that these overlaps are for)
        const segmentLi = document.createElement('li');
        segmentLi.innerHTML = segmentOverlap.label +
          ' (' + segmentOverlap.count + ' voxels): ';

        // list of overlaps
        let first = true;
        for (
          const [/*segmentNumber1*/, count] of
          Object.entries(segmentOverlap.overlap)
        ) {
          if (first) {
            first = false;
          } else {
            segmentLi.innerHTML += ', ';
          }
          segmentLi.innerHTML += count.label + ' (' +
            count.count + ' voxels, ' +
            (count.percentage).toPrecision(4) + '%)';
        }

        overlapList.appendChild(segmentLi);
      }
      overlapResult.appendChild(overlapList);
    };

    return overlapChecker;
  }
}; // SegmentationUI