tests_pacs_viewer.js

import {logger} from '../../src/utils/logger.js';
import {precisionRound} from '../../src/utils/string.js';
import {custom} from '../../src/app/custom.js';
import {
  AppOptions,
  App
} from '../../src/app/application.js';
import {WindowLevel} from '../../src/image/windowLevel.js';
import {getAsSimpleElements} from '../../src/dicom/dicomTag.js';
import {getSRContent} from '../../src/dicom/dicomSRContent.js';
import {getDwvVersion} from '../../src/dicom/dicomParser.js';
import {Point} from '../../src/math/point.js';

import {
  getViewConfig,
  getLayerGroupDivIds
} from './viewer.ui.js';
import {DataTableUI} from './viewer.ui.datatable.js';
import {setupRenderTests} from './viewer.rendertest.js';
import {AnnotationUI} from './viewer.ui.annot.js';
import {SegmentationUI} from './viewer.ui.segment.js';
import {DrawToolUI} from './viewer.ui.draw.js';
import {BrushToolUI} from './viewer.ui.brush.js';

// global vars

/**
 * @type {App}
 */
let _app;

let _tools;
const _toolFeaturesUI = {};
let _layout = 'one';

/**
 * Setup simple dwv app.
 */
function viewerSetup() {
  // logger level (optional)
  logger.level = logger.levels.DEBUG;

  // example wl preset override
  custom.wlPresets = {
    PT: {
      'suv5-10': new WindowLevel(5, 10),
      'suv6-8': new WindowLevel(6, 8)
    }
  };

  // // example labelText override
  // custom.labelTexts = {
  //   rectangle: {
  //     '*': '{surface}!',
  //     MR: '{surface}!!'
  //   }
  // };

  // // example private logic for roi dialog
  // custom.openRoiDialog = function (meta, cb) {
  //   console.log('roi dialog', meta);
  //   const textExpr = prompt('[Custom dialog] Label', meta.textExpr);
  //   if (textExpr !== null) {
  //     meta.textExpr = textExpr;
  //     cb(meta);
  //   }
  // };

  // // example private logic for time value retrieval
  // custom.getTagTime = function (elements) {
  //   let value;
  //   const element = elements['ABCD0123'];
  //   if (typeof element !== 'undefined') {
  //     value = parseInt(element.value[0], 10);
  //   }
  //   return value;
  // };

  // // example private logic for pixel unit value retrieval
  // custom.getTagPixelUnit = function (/*elements*/) {
  //   return 'MyPixelUnit';
  // };

  // stage options
  let viewOnFirstLoadItem = true;

  // load counters
  let numberOfDataToLoad = 0;
  let numberOfLoadendData = 0;
  const dataLoadProgress = [];

  // add layer groups div
  const numberOfLayerGroups = getNumberOfLayerGroups();
  addLayerGroupsDiv(numberOfLayerGroups);

  // special MPR
  if (_layout === 'mpr') {
    viewOnFirstLoadItem = false;
  }

  // tools
  _tools = {
    Scroll: {},
    WindowLevel: {},
    ZoomAndPan: {},
    Opacity: {},
    Draw: {options: [
      'Arrow',
      'Ruler',
      'Circle',
      'Ellipse',
      'Rectangle',
      'Protractor',
      'Roi'
    ]},
    Brush: {},
    Floodfill: {},
    Livewire: {},
    Filter: {options: [
      'Sharpen'
    ]}
  };

  // app config
  const options = new AppOptions();
  options.tools = _tools;
  options.viewOnFirstLoadItem = viewOnFirstLoadItem;
  // app
  _app = new App();
  _app.init(options);

  // abort shortcut handler
  const abortShortcut = function (event) {
    if (event.key === 'a') {
      _app.abortAllLoads();
    }
  };

  // bind events
  _app.addEventListener('error', function (event) {
    console.error('load error', event, event.error);
    // abort load
    _app.abortLoad(event.dataid);
  });
  _app.addEventListener('loadstart', function (event) {
    console.log('%c----------------', 'color: teal;');
    console.log('load source', event.source);
    // timer
    console.time('load-data-' + event.dataid);
    // update load counters
    if (numberOfDataToLoad === numberOfLoadendData) {
      numberOfDataToLoad = 0;
      numberOfLoadendData = 0;
      // reset progress array
      dataLoadProgress.length = 0;
    }
    ++numberOfDataToLoad;
    // add abort shortcut
    window.addEventListener('keydown', abortShortcut);
    // remove post-load listeners
    if (_app.getDataIds().length !== 0) {
      removePostLoadListeners();
    }
    // add new data view config
    addDataViewConfig(event.dataid);
  });
  const sumReducer = function (sum, value) {
    return sum + value;
  };
  _app.addEventListener('loadprogress', function (event) {
    if (typeof event.lengthComputable !== 'undefined' &&
      event.lengthComputable) {
      dataLoadProgress[event.dataid] =
        Math.ceil((event.loaded / event.total) * 100);
      const progressElement = document.getElementById('loadprogress');
      progressElement.value =
        dataLoadProgress.reduce(sumReducer) / numberOfDataToLoad;
    }
  });
  _app.addEventListener('loaditem', function (event) {
    if (typeof event.warn !== 'undefined') {
      console.warn('load-warn', event.warn);
    }
  });
  _app.addEventListener('loadend', function (event) {
    console.timeEnd('load-data-' + event.dataid);
    // update load counter
    ++numberOfLoadendData;
    // remove abort shortcut
    window.removeEventListener('keydown', abortShortcut);
  });
  _app.addEventListener('load', function (event) {
    // render if not done yet
    if (!viewOnFirstLoadItem) {
      _app.render(event.dataid);
    }
    // update sliders with new data info
    // (has to be after full load)
    initSliders();
    // add post-load listeners
    addPostLoadListeners();
    // log meta data
    logMetaData(event.dataid, event.loadtype);
  });

  // update UI at first render of first data
  const onRenderEnd = function (/*event*/) {
    if (_app.getDataIds().length === 1) {
      // set app tool
      setAppTool();
      // update html
      const toolsFieldset = document.getElementById('tools');
      toolsFieldset.disabled = false;
      const changeLayoutSelect = document.getElementById('changelayout');
      changeLayoutSelect.disabled = false;
      const resetViewsButton = document.getElementById('resetviews');
      resetViewsButton.disabled = false;
      const smoothingChk = document.getElementById('changesmoothing');
      smoothingChk.disabled = false;
      // remove handler
      _app.removeEventListener('renderend', onRenderEnd);
    }
  };
  // add handler (will be removed at first success)
  _app.addEventListener('renderend', onRenderEnd);

  _app.addEventListener('positionchange', function (event) {
    const input = document.getElementById('position');
    const values = event.value[1];
    let text = '(index: ' + event.value[0] + ')';
    if (event.value.length > 2) {
      text += ' value: ' + event.value[2];
    }
    input.value = values.map(getPrecisionRound(2));
    // index as small text
    const span = document.getElementById('positionspan');
    if (span) {
      span.innerHTML = text;
    }
    // update sliders' value
    updateSliders();
  });

  _app.addEventListener('filterrun', function (event) {
    console.log('filterrun', event);
  });
  _app.addEventListener('filterundo', function (event) {
    console.log('filterundo', event);
  });

  _app.addEventListener('warn', function (event) {
    console.log('warn', event);
  });

  // default keyboard shortcuts
  window.addEventListener('keydown', function (event) {
    _app.defaultOnKeydown(event);
    // use ctrl to avoid html input events
    if (event.ctrlKey) {
      // mask segment related: has to be number
      if (!isNaN(parseInt(event.key, 10))) {
        const lg = _app.getActiveLayerGroup();
        const vl = lg.getActiveViewLayer();
        if (typeof vl === 'undefined') {
          return;
        }
        const vc = vl.getViewController();
        if (!vc.isMask()) {
          return;
        }
        const number = parseInt(event.key, 10);
        const segHelper = vc.getMaskSegmentHelper();
        if (segHelper.hasSegment(number)) {
          const segment = segHelper.getSegment(number);
          if (event.altKey) {
            // CTRL + ALT + number
            console.log('Delete segment: ' + segment.label);
            // delete
            vc.deleteSegment(number, _app.addToUndoStack);
          } else {
            // CTRL + number
            console.log('Show/hide segment: ' + segment.label);
            // show/hide the selected segment
            if (segHelper.isHidden(number)) {
              segHelper.removeFromHidden(number);
            } else {
              segHelper.addToHidden(number);
            }
            vc.applyHiddenSegments();
          }
        }
      }
    }
    // filter
    if (getSelectedToolName() === 'Filter' &&
      event.altKey && event.key === 'r') {
      // run the sharpen filter
      _app.setToolFeatures({
        filterName: 'Sharpen',
        runArgs: {
          dataId: _app.getDataIds()[0]
        },
        run: true
      });
    }
  });
  // default on resize
  window.addEventListener('resize', function () {
    _app.onResize();
  });

  // tool features UI
  const toolsUI = {
    Draw: DrawToolUI,
    Brush: BrushToolUI
  };

  for (const toolName in _tools) {
    if (typeof toolsUI[toolName] !== 'undefined') {
      const toolUI = new toolsUI[toolName](_app, _tools[toolName]);
      _toolFeaturesUI[toolName] = toolUI;
    }
  }

  // data model UI
  const dataModelUI = {
    annotation: AnnotationUI,
    segmentation: SegmentationUI
  };

  for (const dmName in dataModelUI) {
    const dmUI = new dataModelUI[dmName](_app);
    dmUI.registerListeners();
  }

  const uriOptions = {};
  // uriOptions.batchSize = 100;
  // special dicom web cookie
  if (document.cookie) {
    const cookies = document.cookie.split('; ');
    // accept
    const acceptItem = cookies.find((item) => item.startsWith('accept='));
    if (typeof acceptItem !== 'undefined') {
      // accept is encoded in dcmweb.js (allows for ';')
      const accept = decodeURIComponent(acceptItem.split('=')[1]);
      if (typeof accept !== 'undefined' && accept.length !== 0) {
        uriOptions.requestHeaders = [];
        uriOptions.requestHeaders.push({
          name: 'Accept',
          value: accept
        });
      }
      // clean up
      document.cookie = 'accept=';
    }
    // token
    const tokenItem = cookies.find((item) => item.startsWith('access_token='));
    if (typeof tokenItem !== 'undefined') {
      const token = tokenItem.split('=')[1];
      if (typeof token !== 'undefined' && token.length !== 0) {
        if (typeof uriOptions.requestHeaders === 'undefined') {
          uriOptions.requestHeaders = [];
        }
        uriOptions.requestHeaders.push({
          name: 'Authorization',
          value: 'Bearer ' + token
        });
      }
      // clean up
      document.cookie = 'access_token=';
    }
  }
  // load from window location
  _app.loadFromUri(window.location.href, uriOptions);
}

/**
 * Log meta data.
 *
 * @param {string} dataId The data ID.
 * @param {string} loadType The load type.
 */
function logMetaData(dataId, loadType) {
  // meta data
  const meta = _app.getMetaData(dataId);

  // log tags for data with transfer syntax (dicom)
  if (typeof meta['00020010'] !== 'undefined') {
    console.log('metadata', getAsSimpleElements(meta));
  } else {
    console.log('metadata', meta);
  }

  // get modality
  let modality;
  if (loadType === 'image' &&
    typeof meta['00080060'] !== 'undefined') {
    modality = meta['00080060'].value[0];
  }
  // log DICOM SEG
  if (modality === 'SEG') {
    logFramePosPats(meta);
  }
  // log DICOM SR
  if (modality === 'SR') {
    console.log('DICOM SR');
    const srContent = getSRContent(meta);
    console.log(srContent.toString());
  }
}

/**
 * Init individual slider on layer related event.
 * WARNING: needs to be called with the final geometry.
 *
 * @param {object} event The layer event.
 */
function initSliderOnEvent(event) {
  initSlider(event.layergroupid);
}

/**
 * Add post-load event listeners.
 */
function addPostLoadListeners() {
  // post-load since sliders need the full geometry
  _app.addEventListener('viewlayeradd', initSliderOnEvent);
  _app.addEventListener('drawlayeradd', initSliderOnEvent);
  _app.addEventListener('layerremove', initSliderOnEvent);
}

/**
 * Remove post-load event listeners.
 */
function removePostLoadListeners() {
  _app.removeEventListener('viewlayeradd', initSliderOnEvent);
  _app.removeEventListener('drawlayeradd', initSliderOnEvent);
  _app.removeEventListener('layerremove', initSliderOnEvent);
}

/**
 * Get the number of layer groups according to layout.
 *
 * @returns {nunmber} The number.
 */
function getNumberOfLayerGroups() {
  let number;
  if (_layout === 'one') {
    number = 1;
  } else if (_layout === 'side') {
    number = 2;
  } else if (_layout === 'mpr') {
    number = 3;
  }
  return number;
}

/**
 * Setup.
 */
function setup() {
  // setup
  viewerSetup();

  const dataTable = new DataTableUI(_app);
  dataTable.registerListeners(_layout);

  const positionInput = document.getElementById('position');
  positionInput.addEventListener('change', function (event) {
    const vls = _app.getViewLayersByDataId('0');
    const vc = vls[0].getViewController();
    const element = event.target;
    const values = element.value.split(',');
    vc.setCurrentPosition(new Point([
      parseFloat(values[0]), parseFloat(values[1]), parseFloat(values[2])
    ])
    );
  });

  const resetViewsButton = document.getElementById('resetviews');
  resetViewsButton.disabled = true;
  resetViewsButton.addEventListener('click', function () {
    _app.resetZoomPan();
  });

  const changeLayoutSelect = document.getElementById('changelayout');
  changeLayoutSelect.disabled = true;
  changeLayoutSelect.addEventListener('change', function (event) {
    const selectElement = event.target;
    const layout = selectElement.value;
    if (layout !== 'one' &&
      layout !== 'side' &&
      layout !== 'mpr') {
      throw new Error('Unknown layout: ' + layout);
    }
    _layout = layout;

    // add layer groups div
    const numberOfLayerGroups = getNumberOfLayerGroups();
    addLayerGroupsDiv(numberOfLayerGroups);

    // get configs
    let configs;
    const dataIds = _app.getDataIds();
    if (layout === 'one') {
      configs = getOnebyOneDataViewConfig(dataIds);
    } else if (layout === 'side') {
      configs = getOnebyTwoDataViewConfig(dataIds);
    } else if (layout === 'mpr') {
      configs = getMPRDataViewConfig(dataIds);
    }
    if (typeof configs === 'undefined') {
      return;
    }

    // clear data table
    dataTable.clearDataTable();

    // set config (deletes previous layers)
    _app.setDataViewConfigs(configs);

    // render data (creates layers)
    for (let i = 0; i < dataIds.length; ++i) {
      _app.render(dataIds[i]);
    }

    // show crosshair depending on layout
    if (layout !== 'one') {
      const divIds = getLayerGroupDivIds(configs);
      for (const divId of divIds) {
        _app.getLayerGroupByDivId(divId).setShowCrosshair(true);
      }
    }

    // need to set tool after config change
    setAppTool();
  });

  const smoothingChk = document.getElementById('changesmoothing');
  smoothingChk.checked = false;
  smoothingChk.disabled = true;
  smoothingChk.addEventListener('change', function (event) {
    const inputElement = event.target;
    _app.setImageSmoothing(inputElement.checked);
  });

  // setup
  setupBindersCheckboxes();
  setupToolsCheckboxes();
  setupRenderTests(_app);
  setupAbout();

  // bind app to input files
  const fileinput = document.getElementById('fileinput');
  fileinput.addEventListener('change', function (event) {
    const files = event.target.files;
    if (files.length !== 0) {
      _app.loadFiles(files);
    } else {
      throw new Error('No files to load');
    }
  });
}

/**
 * Get the slider for a given layer group.
 *
 * @param {number} layerGroupDivId The div id.
 * @returns {HTMLInputElement} The slider as html range.
 */
function getSlider(layerGroupDivId) {
  const range = document.createElement('input');
  range.type = 'range';
  range.className = 'vertical-slider';
  range.id = layerGroupDivId + '-slider';
  range.min = 0;
  range.max = 0;
  range.disabled = true;
  // update app on slider change
  range.oninput = function () {
    const lg = _app.getLayerGroupByDivId(layerGroupDivId);
    const ph = lg.getPositionHelper();
    const pos = ph.getCurrentPositionAtScrollValue(this.value);
    ph.setCurrentPosition(pos);
  };
  return range;
}

/**
 * Init sliders: show them and set max.
 */
function initSliders() {
  const numberOfLayerGroups = getNumberOfLayerGroups();
  for (let i = 0; i < numberOfLayerGroups; ++i) {
    initSlider('layerGroup' + i);
  }
}

/**
 * Init individual slider.
 * WARNING: needs to be called with the final geometry.
 *
 * @param {string} layerGroupId The id of the layer group.
 */
function initSlider(layerGroupId) {
  const slider = document.getElementById(layerGroupId + '-slider');
  if (slider) {
    // disabled by default
    slider.disabled = true;
    // init if possible
    const lg = _app.getLayerGroupByDivId(layerGroupId);
    if (typeof lg !== 'undefined') {
      const ph = lg.getPositionHelper();
      if (typeof ph !== 'undefined') {
        const max = ph.getMaximumScrollValue();
        if (max !== 0) {
          slider.disabled = false;
          slider.max = max;
          slider.value = ph.getCurrentPositionScrollValue();
        }
      }
    }
  }
}

/**
 * Update sliders: set the slider value to the current scroll index.
 */
function updateSliders() {
  const numberOfLayerGroups = getNumberOfLayerGroups();
  for (let i = 0; i < numberOfLayerGroups; ++i) {
    const lgId = 'layerGroup' + i;
    const slider = document.getElementById(lgId + '-slider');
    if (slider) {
      const lg = _app.getLayerGroupByDivId(lgId);
      if (typeof lg !== 'undefined') {
        const ph = lg.getPositionHelper();
        slider.value = ph.getCurrentPositionScrollValue();
      }
    }
  }
}

/**
 * Append a layer div in the root 'dwv' one.
 *
 * @param {string} id The id of the layer.
 */
function addLayerGroupDiv(id) {
  const layerDiv = document.createElement('div');
  layerDiv.id = id;
  layerDiv.className = 'layerGroup';

  const root = document.getElementById('dwv');
  root.appendChild(layerDiv);
  root.appendChild(getSlider(id));
}

/**
 * Add Layer Groups div.
 */
function addLayerGroupsDiv() {
  // clean up
  const dwvDiv = document.getElementById('dwv');
  if (dwvDiv) {
    dwvDiv.innerHTML = '';
  }
  // add div
  const numberOfLayerGroups = getNumberOfLayerGroups();
  for (let i = 0; i < numberOfLayerGroups; ++i) {
    addLayerGroupDiv('layerGroup' + i);
  }
}

/**
 * Add data view config for the input data.
 *
 * @param {string} dataId The data ID.
 */
function addDataViewConfig(dataId) {
  const dataIds = [dataId];
  let configs;
  if (_layout === 'one') {
    configs = getOnebyOneDataViewConfig(dataIds);
  } else if (_layout === 'side') {
    configs = getOnebyTwoDataViewConfig(dataIds);
  } else if (_layout === 'mpr') {
    configs = getMPRDataViewConfig(dataIds);
  }
  const viewConfigs = configs[dataId];
  for (let i = 0; i < viewConfigs.length; ++i) {
    _app.addDataViewConfig(dataId, viewConfigs[i]);
  }
}

/**
 * Merge a data config into the first input one.
 * Copies all but the divId and orientation property.
 *
 * @param {object} config The config where to merge.
 * @param {object} configToMerge The config to merge.
 * @returns {object} The updated config.
 */
function mergeConfigs(config, configToMerge) {
  for (const key in configToMerge) {
    if (key !== 'divId' &&
      key !== 'orientation') {
      config[key] = configToMerge[key];
    }
  }
  return config;
}

/**
 * Get the first view config for a data id.
 *
 * @param {string} dataId The data id.
 * @returns {object} The view config.
 */
function getAppViewConfig(dataId) {
  let res;
  const appConfigs = _app.getViewConfigs(dataId);
  if (appConfigs.length !== 0) {
    res = appConfigs[0];
  }
  return res;
}

/**
 * Get the orientation of the first view config for a div id.
 *
 * @param {string} divId The div id.
 * @returns {object} The orientation.
 */
function getAppViewConfigOrientation(divId) {
  let orientation;
  const appDataViewConfigs = _app.getDataViewConfigs();
  let appDivIdConfig;
  for (const key in appDataViewConfigs) {
    const dataViewConfigs = appDataViewConfigs[key];
    appDivIdConfig = dataViewConfigs.find(function (item) {
      return item.divId === divId;
    });
    if (typeof appDivIdConfig !== 'undefined') {
      orientation = appDivIdConfig.orientation;
      break;
    }
  }
  return orientation;
}

/**
 * Create 1*1 view config(s).
 *
 * @param {Array} dataIds The list of dataIds.
 * @returns {object} The view config.
 */
function getOnebyOneDataViewConfig(dataIds) {
  const orientation = getAppViewConfigOrientation('layerGroup0');
  const configs = {};
  for (const dataId of dataIds) {
    const newConfig = getViewConfig('one', 'layerGroup0');
    // merge possibly existing app config with the new one to
    // keed window level for example
    const appConfig = getAppViewConfig(dataId);
    if (typeof appConfig !== 'undefined') {
      mergeConfigs(newConfig, appConfig);
    }
    // if available use first orientation for all
    if (typeof orientation !== 'undefined') {
      newConfig.orientation = orientation;
    }
    // store
    configs[dataId] = [newConfig];
  }
  return configs;
}

/**
 * Create 1*2 view config(s).
 *
 * @param {Array} dataIds The list of dataIds.
 * @returns {object} The view config.
 */
function getOnebyTwoDataViewConfig(dataIds) {
  const configs = {};
  for (let i = 0; i < dataIds.length; ++i) {
    const dataId = dataIds[i];
    let newConfig;
    if (i % 2 === 0) {
      newConfig = getViewConfig('side', 'layerGroup0');
    } else {
      newConfig = getViewConfig('side', 'layerGroup1');
    }
    // merge possibly existing app config with the new one to
    // keed window level for example
    const appConfig = getAppViewConfig(dataId);
    if (typeof appConfig !== 'undefined') {
      mergeConfigs(newConfig, appConfig);
    }
    // store
    configs[dataIds[i]] = [newConfig];
  }
  return configs;
}

/**
 * Get MPR view config(s).
 *
 * @param {Array} dataIds The list of dataIds.
 * @returns {object} The view config.
 */
function getMPRDataViewConfig(dataIds) {
  const configs = {};
  for (const dataId of dataIds) {
    const newConfig0 = getViewConfig('mpr', 'layerGroup0');
    const newConfig1 = getViewConfig('mpr', 'layerGroup1');
    const newConfig2 = getViewConfig('mpr', 'layerGroup2');
    // merge possibly existing app config with the new one to
    // keed window level for example
    const appConfig = getAppViewConfig(dataId);
    if (typeof appConfig !== 'undefined') {
      mergeConfigs(newConfig0, appConfig);
      mergeConfigs(newConfig1, appConfig);
      mergeConfigs(newConfig2, appConfig);
    }
    // store
    configs[dataId] = [newConfig0, newConfig1, newConfig2];
  }
  return configs;
}

/**
 * Setup the binders checkboxes.
 */
function setupBindersCheckboxes() {
  const propList = [
    'WindowLevel',
    'Position',
    'Zoom',
    'Offset',
    'Opacity',
    'ColourMap'
  ];
  const binders = [];
  // add all binders at startup
  for (let b = 0; b < propList.length; ++b) {
    binders.push(propList[b] + 'Binder');
  }
  _app.setLayerGroupsBinders(binders);

  /**
   * Add a binder.
   *
   * @param {string} propName The name of the property to bind.
   */
  function addBinder(propName) {
    binders.push(propName + 'Binder');
    _app.setLayerGroupsBinders(binders);
  }
  /**
   * Remove a binder.
   *
   * @param {string} propName The name of the property to bind.
   */
  function removeBinder(propName) {
    const index = binders.indexOf(propName + 'Binder');
    if (index !== -1) {
      binders.splice(index, 1);
    }
    _app.setLayerGroupsBinders(binders);
  }
  /**
   * Get the input change handler for a binder.
   *
   * @param {string} propName The name of the property to bind.
   * @returns {object} The handler.
   */
  function getOnInputChange(propName) {
    return function (event) {
      const inputElement = event.target;
      if (inputElement.checked) {
        addBinder(propName);
      } else {
        removeBinder(propName);
      }
    };
  }

  const fieldset = document.getElementById('binders');

  // individual binders
  for (let i = 0; i < propList.length; ++i) {
    const propName = propList[i];

    const input = document.createElement('input');
    input.id = 'binder-' + i;
    input.type = 'checkbox';
    input.checked = true;
    input.onchange = getOnInputChange(propName);

    const label = document.createElement('label');
    label.htmlFor = input.id;
    label.appendChild(document.createTextNode(propName));

    fieldset.appendChild(input);
    fieldset.appendChild(label);
  }

  // check all
  const allInput = document.createElement('input');
  allInput.id = 'binder-all';
  allInput.type = 'checkbox';
  allInput.checked = true;
  allInput.onchange = function () {
    for (let j = 0; j < propList.length; ++j) {
      document.getElementById('binder-' + j).click();
    }
  };
  const allLabel = document.createElement('label');
  allLabel.htmlFor = allInput.id;
  allLabel.appendChild(document.createTextNode('all'));
  fieldset.appendChild(allInput);
  fieldset.appendChild(allLabel);
}

/**
 * Setup the tools checkboxes.
 */
function setupToolsCheckboxes() {
  const getChangeTool = function (tool) {
    return function () {
      setAppTool(tool);
    };
  };

  const getKeyCheck = function (char, input) {
    return function (event) {
      if (!event.ctrlKey &&
        !event.altKey &&
        !event.shiftKey &&
        event.key === char) {
        input.click();
      }
    };
  };

  const fieldset = document.getElementById('tools');
  const keys = Object.keys(_tools);
  for (let i = 0; i < keys.length; ++i) {
    const key = keys[i];

    const input = document.createElement('input');
    input.name = 'tools';
    input.type = 'radio';
    input.id = 'tool-' + i;
    input.title = key;
    input.onchange = getChangeTool(key);

    // select first one
    if (i === 0) {
      input.checked = true;
    }

    const label = document.createElement('label');
    label.htmlFor = input.id;
    label.title = input.title;
    label.appendChild(document.createTextNode(input.title));

    fieldset.appendChild(input);
    fieldset.appendChild(label);

    // keyboard shortcut
    const shortcut = key[0].toLowerCase();
    window.addEventListener('keydown', getKeyCheck(shortcut, input));
  }

  // tool options
  const div = document.createElement('div');
  div.id = 'toolOptions';
  fieldset.appendChild(div);
}

/**
 * Set the app tool.
 *
 * @param {string} [toolName] The tool to set.
 */
function setAppTool(toolName) {
  // find the tool name if not provided
  if (typeof toolName === 'undefined') {
    const toolsInput = document.getElementsByName('tools');
    for (let j = 0; j < toolsInput.length; ++j) {
      const toolInput = toolsInput[j];
      if (toolInput.checked) {
        toolName = toolInput.title;
        break;
      }
    }
    if (typeof toolName === 'undefined') {
      console.warn('Cannot find tool to set the app with...');
      return;
    }
  }

  // set tool for app
  _app.setTool(toolName);

  // force window level non strict mode
  if (toolName === 'WindowLevel') {
    _app.setToolFeatures({
      strictViewLayer: false
    });
  }

  // clear options html
  const toolOptionsEl = document.getElementById('toolOptions');
  if (toolOptionsEl !== null) {
    toolOptionsEl.innerHTML = '';
  }
  // tool features
  const featuresUI = _toolFeaturesUI[toolName];
  if (toolOptionsEl !== null &&
    typeof featuresUI !== 'undefined') {
    // setup html
    const featuresHtml = featuresUI.getHtml();
    if (typeof featuresHtml !== 'undefined') {
      toolOptionsEl.appendChild(featuresHtml);
    }
    // pass value to app
    const features = featuresUI.getValue();
    if (typeof features !== 'undefined') {
      _app.setToolFeatures(features);
    }
  }
}

/**
 * Get the selected tool name.
 *
 * @returns {string|undefined} The tool name.
 */
function getSelectedToolName() {
  let res;
  const element = document.querySelector('input[name="tools"]:checked');
  if (element) {
    res = element.title;
  }
  return res;
}

/**
 * Compare two pos pat keys.
 *
 * @param {string} a The key of the first item.
 * @param {string} b The key of the second item.
 * @returns {number} Negative if a<b, positive if a>b.
 */
function comparePosPat(a, b) {
  const za = a.split('\\').at(-1);
  const zb = b.split('\\').at(-1);
  let res = 0;
  if (typeof za !== 'undefined' &&
    typeof zb !== 'undefined') {
    res = parseFloat(za) - parseFloat(zb);
  }
  return res;
}

/**
 * Sort an object with pos pat string keys.
 *
 * @param {object} obj The object to sort.
 * @returns {object} The sorted object.
 */
function sortByPosPatKey(obj) {
  const keys = Object.keys(obj);
  keys.sort(comparePosPat);
  const sorted = new Map();
  for (let i = 0; i < keys.length; i++) {
    const key = keys[i];
    sorted.set(key, obj[key]);
  }
  return sorted;
}

/**
 * Get a rounding function for a specific precision.
 *
 * @param {number} precision The rounding precision.
 * @returns {Function} The rounding function.
 */
function getPrecisionRound(precision) {
  return function (x) {
    return precisionRound(x, precision);
  };
}

/**
 * Log the DICCOM seg segments ordered by frame position patients.
 *
 * @param {object} elements The DICOM seg elements.
 */
function logFramePosPats(elements) {
  // PerFrameFunctionalGroupsSequence
  const perFrame = elements['52009230'].value;
  const perPos = {};
  for (let i = 0; i < perFrame.length; ++i) {
    // PlanePositionSequence
    const posSq = perFrame[i]['00209113'].value;
    // ImagePositionPatient
    const pos = posSq[0]['00200032'].value;
    if (typeof perPos[pos] === 'undefined') {
      perPos[pos] = [];
    }
    // FrameContentSequence
    const frameSq = perFrame[i]['00209111'].value;
    // DimensionIndexValues
    const dim = frameSq[0]['00209157'].value;
    perPos[pos].push(dim);
  }
  console.log('DICOM SEG Segments', sortByPosPatKey(perPos));
}

/**
 * Setup about line.
 */
function setupAbout() {
  const testsDiv = document.getElementById('about');
  const link = document.createElement('a');
  link.href = 'https://github.com/ivmartel/dwv';
  link.appendChild(document.createTextNode('dwv'));
  const text = document.createTextNode(
    ' v' + getDwvVersion() +
    ' on ' + navigator.userAgent);

  testsDiv.appendChild(link);
  testsDiv.appendChild(text);
}

// ---------------------------------------------

// launch
setup();