view ViewerPlugin/WebApplication/viewer.js @ 613:617f25e606d6 default tip

back to mainline
author Sebastien Jodogne <s.jodogne@gmail.com>
date Thu, 10 Sep 2026 10:35:58 +0200
parents e447a2b8a8f2
children
line wrap: on
line source

/**
 * Orthanc - A Lightweight, RESTful DICOM Store
 * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
 * Department, University Hospital of Liege, Belgium
 * Copyright (C) 2017-2023 Osimis S.A., Belgium
 * Copyright (C) 2024-2026 Orthanc Team SRL, Belgium
 * Copyright (C) 2021-2026 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
 *
 * This program is free software: you can redistribute it and/or
 * modify it under the terms of the GNU Affero General Public License
 * as published by the Free Software Foundation, either version 3 of
 * the License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * Affero General Public License for more details.
 * 
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
 **/


var app = new Vue({
  el: '#app',

  data() {
    return {
      projectId: '',
      level: '',
      resourceId: '',
      frameNumber: 0,
      brightness: 0, // In the range between [-100,100]
      contrast: 0,   // In the range between [-100,100]
      saturation: 0, // In the range between [-100,100]
      // hue: 0,        // Degrees, in the range between [-180,180]

      // Main state for annotations
      workspaceInfo: {},
      imageDescription: '',
      userLayers: [],
      importedLayers: [],
      activeUserLayerId: null,
      scaling: true,

      // UI state
      toolbarsVisible: false,
      panelOpen: false,
      mapBackground: '',
      rotationDeg: 0,
      activeDrawTool: null,
      showMagnificationButtons: false,

      // Bootstrap modals
      modalDeleteUserLayer: null,
      modalDeleteImportedLayer: null,
      modalDeleteAnnotation: null,
      pendingDelete: null,

      // Loading/saving using the backend
      isPendingChange: false,
      isSaving: false,
      showSpinner: false,

      // Annotation selection panel
      annotationProperties: [],
      selectedFeature: null,

      // OpenLayers objects
      map: null,
      drawSource: null,
      drawLayer: null,  // Used in HTML
      drawImportedSource: null,
      drawImportedLayer: null,
      drawLine: null,
      drawPoint: null,
      drawCircle: null,
      drawRectangle: null,
      drawClosedPolygon: null,
      drawFreehand: null,
      drawFreehandLine: null,
      drawArrow: null,
      moveFeature: null,
      modifyFeature: null,
      modifyFeatureCollection: null,
      selectAnnotation: null,

      /**
       * Magnification at full-resolution image pixels, convention
       * commonly used for pathology WSI: 40x scan = 0.25 µm/pixel
       **/
      referenceMagnification: 40,  // TODO - Could be read from pyramid

      // Share layer modal
      modalShareUserLayer: null,
      shareLayerTarget: {},
      shareLayerPublic: false,
      shareLayerUsers: [],
      shareLayerSearchQuery: '',
      shareLayerSearchResults: [],

      // Import layer modal
      modalImportLayer: null,
      importAvailableUsers: [],
      importUserSearchQuery: '',
      importUserSearchResults: [],
      importSelectedUser: '',
      importSelectedLayer: '',
      importAvailableLayers: []
    };
  },

  computed: {
    shareLayerCanAddLearner: function() {
      return (this.workspaceInfo.is_instructor === true ||
              (this.workspaceInfo.is_learner === true &&
               this.workspaceInfo.learner_to_learner_sharing === true));
    }
  },

  watch: {
    activeUserLayerId: function() {
      if (this.activeDrawTool === 'modify') {
        this.RefreshModifyFeatureCollection();
      }
    }
  },

  mounted: function() {
    this.InitializePanelAnimation();

    this.modalDeleteUserLayer = new bootstrap.Modal(document.getElementById('modal-delete-user-layer'));
    this.modalDeleteImportedLayer = new bootstrap.Modal(document.getElementById('modal-delete-imported-layer'));
    this.modalDeleteAnnotation = new bootstrap.Modal(document.getElementById('modal-delete-annotation'));
    this.modalShareUserLayer = new bootstrap.Modal(document.getElementById('modal-share-user-layer'));
    this.modalImportLayer = new bootstrap.Modal(document.getElementById('modal-import-layer'));

    document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function(el) {
      new bootstrap.Tooltip(el, { trigger: 'hover' });
    });

    // document.getElementById('right-panel-toggle').click();  // Open the side menu at startup

    const params = new URLSearchParams(document.location.search);

    if (params.has('project')) {
      this.projectId = params.get('project');
    }

    if (params.has('description')) {
      this.imageDescription = params.get('description');
    }

    if (params.has('no-scaling')) {
      this.scaling = false;
    }

    if (params.has('series')) {
      this.level = 'series';
      this.resourceId = params.get('series');
    } else if (params.has('instance')) {
      this.level = 'instance';
      this.resourceId = params.get('instance');

      if (params.has('frame')) {
        this.frameNumber = params.get('frame');
      }
    } else {
      alert('Error - No series ID and no instance ID specified!');
      return;
    }

    this.LoadPyramid();
  },

  methods: {

    // -----------------------------------------------------------------------
    // Persistence of layers and annotations
    // -----------------------------------------------------------------------

    CreatePostPayload: function(args) {
      args['project'] = this.projectId;
      args['level'] = this.level;
      args['resource'] = this.resourceId;
      args['frame'] = this.frameNumber;
      return JSON.stringify(args);
    },

    LoadLayers: function(activeLayerId) {
      var that = this;
      axios.post('../api/list-user-layers',
                 this.CreatePostPayload({}))
        .then(function(response) {
          that.userLayers = response.data['user-layers'];
          that.importedLayers = response.data['imported-layers'];

          if (that.userLayers.length == 0) {
            that.CreateUserLayer();
          } else if (activeLayerId !== undefined) {
            that.activeUserLayerId = activeLayerId;
          } else {
            that.activeUserLayerId = that.userLayers[0].id;
          }

          that.LoadUserFeatures();
          that.ReloadImportedFeatures();
        })
        .catch(function() {
          console.error('Cannot load the saved annotations');
        });
    },

    CreateUserLayer: function() {
      var that = this;
      axios.post('../api/create-user-layer',
                 this.CreatePostPayload({}))
        .then(function(response) {
          that.LoadLayers(response.data.id);
        })
        .catch(function() {
          console.error('Cannot create a new user layer');
        });
    },

    SaveUserLayer: function(layer) {
      var that = this;
      axios.post('../api/save-user-layer',
                 this.CreatePostPayload({
                   'layer': layer
                 }))
        .catch(function() {
          console.error('Cannot save user layer');
        });
    },

    LoadUserFeatures: function() {
      console.assert(this.drawSource !== null);  // InitializeAnnotations() must have been invoked
      console.assert(this.workspaceInfo.enabled !== undefined);  // LoadLayers() must have been invoked

      if (!this.workspaceInfo.enabled) {
        return;
      }

      this.showSpinner = true;

      var that = this;
      axios.post('../api/load-user-features',
                 this.CreatePostPayload({}))
        .then(function(response) {
          that.drawSource.clear();

          // We check that the original layer is still available (could have been some write error)
          var availableLayerIds = [];
          for (let i = 0; i < that.userLayers.length; i++) {
            availableLayerIds.push(that.userLayers[i].id);
          }

          for (let i = 0; i < response.data.features.length; i++) {
            var layerId = response.data.features[i]['layer-id'];

            if (availableLayerIds.includes(layerId)) {
              UnserializeFeatureOntoMap(that.drawSource, response.data.features[i]);
            }
          }

          // Now that the user features are loaded, we can install the save callback
          that.drawSource.on('addfeature', function (e) {
            that.SaveUserFeatures();
          });
          that.drawSource.on('removefeature', function (e) {
            that.SaveUserFeatures();
          });
        })
        .catch(function() {
          console.error('Cannot load user features');
        })
        .finally(function() {
          that.showSpinner = false;
        });
    },

    SaveUserFeatures: function()
    {
      var that = this;

      function Execute()
      {
        var features = [];

        that.drawSource.getFeatures().forEach(function (feature, index) {
          var item = SerializeFeature(feature);

          if (item !== null) {
            item['layer-id'] = feature.get('layer-id');

            var label = feature.get('label');
            if (label !== undefined) {
              item['label'] = label;
            }

            var date = feature.get('creation-datetime');
            if (date !== undefined) {
              item['creation-datetime'] = date.getTime();
            }

            features.push(item);
          }
        });

        that.isPendingChange = false;
        that.isSaving = true;
        that.showSpinner = true;
        window.addEventListener('beforeunload', BeforeUnloadHandler);

        axios.post('../api/save-user-features',
                   that.CreatePostPayload({
                     'features': features
                   }))
          .then(function() {
            // Success
          })
          .catch(function() {
            console.error('Cannot save the annotations');
          })
          .finally(function() {
            console.assert(that.isSaving === true);

            if (that.isPendingChange) {
              Execute();
            } else {
              that.isSaving = false;
              that.showSpinner = false;
              window.removeEventListener('beforeunload', BeforeUnloadHandler);
            }
          });
      }

      this.isPendingChange = true;

      if (!this.isSaving) {
        Execute();
      }
    },

    DeleteUserLayer: function(id) {
      this.pendingDelete = id;
      this.modalDeleteUserLayer.show();
    },

    UserLayerDeleteConfirmed: function() {
      var layerId = this.pendingDelete;   // The ID of the layer to be removed

      this.modalDeleteUserLayer.hide();
      var that = this;
      axios.post('../api/delete-user-layer',
                 this.CreatePostPayload({
                   'layer-id': layerId
                 })
                )
        .then(function(response) {
          that.LoadLayers();

          // Remove the features that were part of this layer
          that.drawSource.getFeatures().forEach(function(feature) {
            if (feature.get('layer-id') === layerId) {
              that.drawSource.removeFeature(feature);
            }
          });
        })
        .catch(function(error) {
          console.error('Cannot delete the layer');
        });
    },

    TakeScreenshot: function() {
      modernScreenshot.domToBlob(document.body, {
        filter: function (element) {
          if (element.classList === undefined) {
            return true;
          } else {
            return (element.id !== 'toolbar-top' &&
                    element.id !== 'toolbar-left' &&
                    !element.classList.contains('tooltip') &&
                    !element.classList.contains('ol-control'));  // "+", "-", and "rotate" buttons
          }
        }
      })
        .then(function (blob) {
          navigator.clipboard.write([
            new ClipboardItem({
              'image/png': blob
            })
          ])
            .then(function () {
              alert('Screenshot copied to clipboard!');
            })
            .catch(function (error) {
              alert('Could not copy screenshot\n\n(' + error + ')');
            });
        });
    },

    LoadAnnotationsInfo: function() {
      var that = this;
      axios.post('../api/workspace-info',
                 this.CreatePostPayload({}))
        .then(function(response) {
          that.workspaceInfo = response.data;

          if (that.workspaceInfo.enabled) {
            that.LoadLayers();
          }
        })
        .catch(function(error) {
        });
    },

    // -----------------------------------------------------------------------
    // Annotation selection panel
    // -----------------------------------------------------------------------

    AddReadOnlyProperty: function(label, value) {
      this.annotationProperties.push({
        type: 'readonly',
        label: label,
        value: value
      });
    },

    AddReadOnlyTextAreaProperty: function(label, value) {
      this.annotationProperties.push({
        type: 'readonly-textarea',
        label: label,
        value: value
      });
    },

    AddEditableProperty: function(label, value, featureProp) {
      this.annotationProperties.push({
        type: 'editable',
        label: label,
        value: value,
        featureProp: featureProp
      });
    },

    AddTextAreaProperty: function(label, value, featureProp) {
      this.annotationProperties.push({
        type: 'editable-textarea',
        label: label,
        value: value,
        featureProp: featureProp
      });
    },

    AddDropdownProperty: function(label, options, selectedValue, featureProp) {
      this.annotationProperties.push({
        type: 'dropdown',
        label: label,
        value: selectedValue,
        options: options,
        featureProp: featureProp
      });
    },

    ClearAnnotationSelection: function(clearOlSelection) {
      if (clearOlSelection === undefined) {
        clearOlSelection = true;
      }

      if (clearOlSelection && this.selectAnnotation !== null) {
        this.selectAnnotation.getFeatures().clear();
      }

      this.selectedFeature = null;
      this.annotationProperties = [];
    },

    UpdateAnnotationProperty: function(prop) {
      if (this.selectedFeature &&
          prop.featureProp &&
          this.drawSource !== null &&
          this.drawSource.hasFeature(this.selectedFeature)) {
        this.selectedFeature.set(prop.featureProp, prop.value);
        this.SaveUserFeatures();
      }
    },

    FocusAnnotation: function() {
      if (this.selectedFeature) {
        bootstrap.Offcanvas.getOrCreateInstance(document.getElementById('right-panel')).hide();
        this.map.getView().fit(this.selectedFeature.getGeometry().getExtent(), { padding: [40, 40, 40, 40], duration: 300 });
      }
    },

    // -----------------------------------------------------------------------
    // Rotation controls (driven from the popover)
    // -----------------------------------------------------------------------

    ResetRotation: function() {
      this.rotationDeg = 0;
      this.SetMapRotation();
    },

    RotateBy: function(deg) {
      this.rotationDeg = parseInt(this.rotationDeg) + deg;

      while (this.rotationDeg > 180) {
        this.rotationDeg -= 360;
      }

      while (this.rotationDeg < -180) {
        this.rotationDeg += 360;
      }

      this.SetMapRotation();
    },

    SetMapRotation: function() {
      this.map.getView().setRotation(this.rotationDeg / 180 * Math.PI);
    },

    // -----------------------------------------------------------------------
    // Draw tool activation
    // -----------------------------------------------------------------------

    DeactivateAll: function() {
      this.map.removeInteraction(this.drawLine);
      this.map.removeInteraction(this.drawPoint);
      this.map.removeInteraction(this.drawCircle);
      this.map.removeInteraction(this.drawRectangle);
      this.map.removeInteraction(this.drawClosedPolygon);
      this.map.removeInteraction(this.drawFreehand);
      this.map.removeInteraction(this.drawFreehandLine);
      this.map.removeInteraction(this.drawArrow);
      this.map.removeInteraction(this.moveFeature);
      this.map.removeInteraction(this.modifyFeature);
      this.map.removeInteraction(this.selectAnnotation);

      this.activeDrawTool = null;
      this.map.getViewport().style.cursor = '';
      this.ClearAnnotationSelection(true);
    },

    ToggleSelectTool: function() {
      var wasActive = this.activeDrawTool === 'select';
      this.DeactivateAll();
      if (!wasActive) {
        this.map.addInteraction(this.selectAnnotation);
        this.activeDrawTool = 'select';
        this.map.getViewport().style.cursor = 'pointer';
      }
    },

    ToggleDrawTool: function(toolName) {
      var interactions = {
        'line':           this.drawLine,
        'point':          this.drawPoint,
        'circle':         this.drawCircle,
        'rectangle':      this.drawRectangle,
        'closed-polygon': this.drawClosedPolygon,
        'freehand':       this.drawFreehand,
        'freehand-line':  this.drawFreehandLine,
        'arrow':          this.drawArrow,
        'move':           this.moveFeature,
        'modify':         this.modifyFeature
      };

      var cursors = {
        'freehand':      'crosshair',
        'freehand-line': 'crosshair'
      };

      // Draw tools that keep selectAnnotation active to highlight the newly drawn feature
      var drawTools = ['line', 'point', 'circle', 'rectangle', 'closed-polygon', 'freehand', 'freehand-line'];

      var wasActive = this.activeDrawTool === toolName;
      this.DeactivateAll();
      if (!wasActive) {
        if (toolName === 'modify') {
          this.RefreshModifyFeatureCollection();
        }
        this.map.addInteraction(interactions[toolName]);
        if (drawTools.indexOf(toolName) !== -1) {
          this.map.addInteraction(this.selectAnnotation);  // kept active to show blue highlight
        }
        this.activeDrawTool = toolName;
        var cursor = cursors[toolName];
        if (cursor) {
          this.map.getViewport().style.cursor = cursor;
        }
      }
    },

    RefreshModifyFeatureCollection: function() {
      if (this.modifyFeatureCollection !== null &
          this.drawSource !== null) {
        var activeLayerId = this.activeUserLayerId;
        this.modifyFeatureCollection.clear();
        this.drawSource.getFeatures().forEach(function(feature) {
          if (feature.get('layer-id') === activeLayerId) {
            app.modifyFeatureCollection.push(feature);
          }
        });
      }
    },

    DeleteSelectedAnnotation: function() {
      var selected = this.selectAnnotation.getFeatures();
      if (selected.getLength() > 0 &&
          this.drawSource !== null &&
          this.drawSource.hasFeature(selected.item(0))) {
        this.modalDeleteAnnotation.show();
      }
    },

    ConfirmDeleteAnnotation: function() {
      var selected = this.selectAnnotation.getFeatures();

      var that = this;
      selected.forEach(function(feature) {
        that.drawSource.removeFeature(feature);
      });

      selected.clear();
      this.ClearAnnotationSelection(false);
      this.modalDeleteAnnotation.hide();
    },

    // -----------------------------------------------------------------------
    // Panel animation
    // -----------------------------------------------------------------------

    InitializePanelAnimation: function() {
      var that = this;
      var panel = document.getElementById('right-panel');
      var toggle = document.getElementById('right-panel-toggle');
      var isResizing = false;

      // Ensure the initial chevron direction matches the real offcanvas state.
      that.panelOpen = panel.classList.contains('show');

      function ResizingLoop() {
        // If the offcanvas is hidden (e.g. before workspace load), its left edge can be 0,
        // which would incorrectly move the toggle off-screen. Clamp to [0, panelWidth].
        var panelRect = panel.getBoundingClientRect();
        var panelWidth = Math.max(0, panelRect.width || 0);
        var right = window.innerWidth - panelRect.left;

        if (!isFinite(right)) {
          right = 0;
        }

        right = Math.max(0, Math.min(right, panelWidth));
        toggle.style.right = right + 'px';

        if (isResizing) {
          requestAnimationFrame(ResizingLoop);
        }
      }

      function StartResizing() {
        isResizing = true;
        requestAnimationFrame(ResizingLoop);
      }

      function StopResizing() {
        isResizing = false;
        ResizingLoop();
      }

      ResizingLoop();

      panel.addEventListener('hide.bs.offcanvas', function() {
        that.panelOpen = false;
        StartResizing();
      });
      panel.addEventListener('hidden.bs.offcanvas', StopResizing);

      panel.addEventListener('show.bs.offcanvas', function() {
        that.panelOpen = true;
        StartResizing();
      });
      panel.addEventListener('shown.bs.offcanvas', StopResizing);

      // Keep the toggle aligned if viewport size changes.
      window.addEventListener('resize', StopResizing);
    },

    // -----------------------------------------------------------------------
    // Pyramid loading and map initialization
    // -----------------------------------------------------------------------

    LoadPyramid: function() {
      var that = this;

      if (this.level == 'series')
      {
        axios.get('../pyramids/' + this.resourceId)
          .then(function(response) {
            that.InitializePyramid(response.data, '../tiles/' + that.resourceId + '/');
          })
          .catch(function(error) {
            alert('Error - Cannot get the pyramid structure of series: ' + that.resourceId);
          });
      }
      else if (this.level == 'instance')
      {
        axios.get('../frames-pyramids/' + this.resourceId + '/' + this.frameNumber)
          .then(function(response) {
            that.InitializePyramid(response.data, '../frames-tiles/' + that.resourceId + '/' + that.frameNumber + '/');
          })
          .catch(function(error) {
            alert('Error - Cannot get the pyramid structure of frame ' + that.frameNumber + ' of instance: ' + that.resourceId);
          });
      }
    },

    InitializePyramid: function(pyramid, tilesBaseUrl) {
      this.mapBackground = pyramid['BackgroundColor'];  // New in WSI 2.1

      var width = pyramid['TotalWidth'];
      var height = pyramid['TotalHeight'];
      var countLevels = pyramid['Resolutions'].length;

      var metersPerUnit = null;
      if (this.scaling) {
        var imagedVolumeWidth = pyramid['ImagedVolumeWidth'];  // In millimeters
        var imagedVolumeHeight = pyramid['ImagedVolumeHeight'];
        if (imagedVolumeWidth !== undefined &&
            imagedVolumeHeight !== undefined) {
          var metersPerUnitX = parseFloat(imagedVolumeWidth) / (1000.0 * parseFloat(width));
          var metersPerUnitY = parseFloat(imagedVolumeHeight) / (1000.0 * parseFloat(height));
          if (IsNear(metersPerUnitX / metersPerUnitY, 1)) {
            metersPerUnit = metersPerUnitX;
          } else {
            // Backward compatibility with OrthancWSIDicomizer <= 3.2, where X/Y were swapped
            metersPerUnitX = parseFloat(imagedVolumeWidth) / (1000.0 * parseFloat(height));
            metersPerUnitY = parseFloat(imagedVolumeHeight) / (1000.0 * parseFloat(width));
            if (IsNear(metersPerUnitX / metersPerUnitY, 1)) {
              metersPerUnit = metersPerUnitX;
            } else {
              console.error('Anisotropic pixel spacing (may result from an inconsistency ' +
                            'in the imaged volume size), not showing the scale');
            }
          }
        }
      }

      if (metersPerUnit) {
        this.showMagnificationButtons = true;
      }

      // Maps always need a projection, but Zoomify layers are not geo-referenced, and
      // are only measured in pixels.  So, we create a fake projection that the map
      // can use to properly display the layer.
      var proj = new ol.proj.Projection({
        code: 'pixel',
        units: 'pixel',
        metersPerUnit: metersPerUnit,
        extent: [0, 0, width, height]
      });

      var extent = [0, -height, width, 0];

      var rotateControl = new ol.control.Rotate({
        target: 'toolbar-left',
        autoHide: false,  // Show the button even if rotation is 0
        resetNorth: function() {  // Disable the default action
        }
      });

      new bootstrap.Popover(rotateControl.element, {
        placement: 'right',
        container: 'body',
        html: true,
        content: document.getElementById('popover-rotate')
      });

      new bootstrap.Popover(document.getElementById('button-adjustments'), {
        placement: 'right',
        container: 'body',
        html: true,
        content: document.getElementById('popover-adjustments')
      });

      // Disable the rotation of the map, and inertia while panning
      // http://stackoverflow.com/a/25682186
      var interactions = ol.interaction.defaults.defaults({
        //pinchRotate : false,
        dragPan: false  // disable kinetics
        //shiftDragZoom: false  // disable zoom box
      }).extend([
        new ol.interaction.DragPan(),
        new ol.interaction.DragRotate({
          //condition: ol.events.condition.shiftKeyOnly  // Rotate only when Shift key is pressed
        })
      ]);

      var controls = ol.control.defaults.defaults({
        attribution: false,
        rotate: false        // remove the default rotate
      }).extend([
        rotateControl,

        /*new ol.control.ScaleLine({
          minWidth: 100
          })*/
        new MicroscopeScaleLine({
          minWidth: 100,
          referenceMagnification: this.referenceMagnification
        })

      ]);

      if (this.imageDescription !== null) {
        controls.extend([
          new ol.control.Attribution({
            attributions: this.imageDescription,
            collapsible: false
          })
        ]);
      }

      var tileLayer = new ol.layer.Tile({
        extent: extent,
        source: new ol.source.TileImage({
          projection: proj,
          tileUrlFunction: function(tileCoord, pixelRatio, projection) {
            return (tilesBaseUrl + (countLevels - 1 - tileCoord[0]) + '/' + tileCoord[1] + '/' + tileCoord[2]);
          },
          tileGrid: new ol.tilegrid.TileGrid({
            extent: extent,
            resolutions: pyramid['Resolutions'].reverse(),
            tileSizes: pyramid['TilesSizes'].reverse()
          })
        }),
        wrapX: false,
        projection: proj
      });

      var that = this;
      tileLayer.on('prerender', (event) => {
        const context = event.context;

        if (context) {
          context.save();
          var brightness = Math.pow(4, that.brightness / 100.0);  // Ranges between 0.25 and 4
          var contrast = Math.pow(4, that.contrast / 100.0);      // Ranges between 0.25 and 4
          var saturation = Math.pow(4, that.saturation / 100.0);  // Ranges between 0.25 and 4
          context.filter =
            'brightness(' + brightness.toFixed(4) + ') ' +
            'contrast(' + contrast.toFixed(4) + ')' +
            'saturate(' + saturation.toFixed(4) + ')';
          // 'hue-rotate(' + that.hue + 'deg)';
        }
      });

      tileLayer.on('postrender', (event) => {
        const context = event.context;

        if (context) {
          context.restore();
        }
      });

      this.map = new ol.Map({
        target: 'map',
        layers: [ tileLayer ],
        view: new ol.View({
          projection: proj,
          center: [width / 2, -height / 2],
          zoom: 0,
          minResolution: 0.1   // "1" means "do not interpelate over pixels"
        }),
        interactions: interactions,
        controls: controls
      });

      // Prevent toolbar pointer events from reaching OL interactions (e.g. Select)
      [ 'toolbar-left', 'toolbar-top' ].forEach(function(id) {
        var el = document.getElementById(id);
        [ 'pointerdown', 'pointerup', 'pointermove', 'click' ].forEach(function(type) {
          el.addEventListener(type, function(e) {
            e.stopPropagation();
          });
        });
      });

      // Re-append toolbars inside the map viewport so they inherit OL's scaling
      var viewport = this.map.getViewport();
      viewport.appendChild(document.getElementById('toolbar-left'));
      viewport.appendChild(document.getElementById('toolbar-top'));

      this.map.once('postrender', function() {
        // Match Bootstrap button size to OL button size
        /*var olBtnSize = document.querySelector('.ol-zoom button').offsetWidth + 'px';
          document.querySelectorAll('.icon-btn').forEach(function(el) {
          el.style.width = olBtnSize;
          el.style.height = olBtnSize;
          });*/

        // Move the top toolbar directly right to the zoom control, regardless of scaling
        var zoomEl = document.querySelector('.ol-zoom');
        document.getElementById('toolbar-top').style.left = (zoomEl.offsetLeft + zoomEl.offsetWidth) + 'px';
        document.getElementById('toolbar-top').style.top = zoomEl.offsetTop + 'px';

        // Move the left toolbar directly below the zoom control, regardless of scaling
        document.getElementById('toolbar-left').style.left = zoomEl.offsetLeft + 'px';
        document.getElementById('toolbar-left').style.top = (zoomEl.offsetTop + zoomEl.offsetHeight) + 'px';

        // Move the vertical buttons below the rotate control, regardless of scaling
        var rotateEl = document.querySelector('.ol-rotate');
        document.getElementById('toolbar-left-content').style.top = (rotateEl.offsetTop + rotateEl.offsetHeight) + 'px';
      });

      this.map.getView().fit(extent, this.map.getSize());

      this.toolbarsVisible = true;
      this.InitializeAnnotations();
    },

    ResetAdjustments: function() {
      this.brightness = 0;
      this.contrast = 0;
      this.saturation = 0;
      this.map.render();
    },

    // -----------------------------------------------------------------------
    // Drawing annotations
    // -----------------------------------------------------------------------

    InitializeAnnotations: function() {
      function GetLayerById(id) {
        for (var i = 0; i < app.userLayers.length; i++) {
          if (app.userLayers[i].id == id) {
            return app.userLayers[i];
          }
        }
        return null;
      }

      function GetImportedLayerById(id) {
        for (var i = 0; i < app.importedLayers.length; i++) {
          if (app.importedLayers[i].id == id) {
            return app.importedLayers[i];
          }
        }
        return null;
      }

      function GetLayerOfFeature(feature) {
        var layerId = feature.get('layer-id');
        console.assert(layerId !== null);
        var layer = GetLayerById(layerId);
        console.assert(layer !== null);
        return layer;
      }

      function IsFeatureVisible(feature) {
        var layerId = feature.get('layer-id');
        var userLayer = GetLayerById(layerId);
        if (userLayer !== null) {
          return userLayer.visible;
        }

        var importedLayer = GetImportedLayerById(layerId);
        return importedLayer !== null && importedLayer.visible;
      }

      // Single vector source holding all features from all layers
      this.drawSource = new ol.source.Vector();

      this.drawLayer = new ol.layer.Vector({
        source: this.drawSource,
        style: function(feature, resolution) {
          var layer = GetLayerOfFeature(feature);
          if (layer.visible) {
            return CreateFeatureStyle(feature, resolution, layer.color);
          } else {
            return null;
          }
        }
      });

      this.map.addLayer(this.drawLayer);

      // Shared annotations: a separate read-only source for all imported layers
      this.drawImportedSource = new ol.source.Vector();
      this.drawImportedLayer = new ol.layer.Vector({
        source: this.drawImportedSource,
        style: function(feature, resolution) {
          var entry = GetImportedLayerById(feature.get('layer-id'));
          if (!entry || !entry.visible) {
            return null;
          }

          return CreateFeatureStyle(feature, resolution, entry.color);
        }
      });
      this.map.addLayer(this.drawImportedLayer);

      // Draw interactions (inactive until toggled)
      this.drawLine = new ol.interaction.Draw({
        source: this.drawSource,
        type: 'LineString',
        maxPoints: 2
      });
      this.drawPoint = new ol.interaction.Draw({ source: this.drawSource, type: 'Point' });
      this.drawCircle = new ol.interaction.Draw({ source: this.drawSource, type: 'Circle' });
      this.drawRectangle = new ol.interaction.Draw({
        source: this.drawSource,
        type: 'Circle',
        geometryFunction: ol.interaction.Draw.createBox()
      });
      this.drawClosedPolygon = new ol.interaction.Draw({ source: this.drawSource, type: 'Polygon' });
      this.drawFreehand = new ol.interaction.Draw({ source: this.drawSource, type: 'Polygon', freehand: true });
      this.drawFreehandLine = new ol.interaction.Draw({ source: this.drawSource, type: 'LineString', freehand: true });
      this.drawArrow = new ol.interaction.Draw({
        source: this.drawSource,
        type: 'LineString',
        maxPoints: 2
      });

      this.moveFeature = new ol.interaction.Translate({
        source: this.drawSource,
        filter: function(feature) {
          return feature.get('layer-id') === app.activeUserLayerId;
        }
      });
      this.modifyFeatureCollection = new ol.Collection();
      this.modifyFeature = new ol.interaction.Modify({ features: this.modifyFeatureCollection });

      // Select interaction (inactive until toggled).
      // The condition restricts user-click selection to the dedicated select tool only,
      // preventing spurious selection events when starting a draw near an existing feature.
      this.selectAnnotation = new ol.interaction.Select({
        layers: [ this.drawLayer, this.drawImportedLayer ],
        filter: function(feature) {
          return IsFeatureVisible(feature);
        },
        condition: function(e) {
          return ol.events.condition.singleClick(e) && app.activeDrawTool === 'select';
        },
        hitTolerance: 5,  /* pixels around the feature that count as a hit */
        style: function(feature, resolution) {
          return CreateFeatureStyle(feature, resolution, '#0000ff');  /* selected annotations are in blue */
        }
      });


      var that = this;

      function preventDoubleClickZoom() {
        that.map.getInteractions().forEach(function(interaction) {
          if (interaction instanceof ol.interaction.DoubleClickZoom) {
            interaction.setActive(false);
            setTimeout(function() { interaction.setActive(true); }, 50);
          }
        });
      }

      function onDrawEnd(e, callPreventDoubleClickZoom) {
        e.feature.set('layer-id', app.activeUserLayerId);
        e.feature.set('creation-datetime', new Date());
        if (callPreventDoubleClickZoom) {
          preventDoubleClickZoom();
        }
        that.selectAnnotation.getFeatures().clear();
        that.selectAnnotation.getFeatures().push(e.feature);
        that.selectAnnotation.dispatchEvent({ type: 'select', selected: [e.feature], deselected: [] });
      }

      this.drawLine.on('drawend', function(e) { onDrawEnd(e, true); });
      this.drawPoint.on('drawend', function(e) { onDrawEnd(e, true); });
      this.drawCircle.on('drawend', function(e) { onDrawEnd(e, true); });
      this.drawRectangle.on('drawend', function(e) { onDrawEnd(e, true); });
      this.drawClosedPolygon.on('drawend', function(e) { onDrawEnd(e, true); });
      this.drawFreehand.on('drawend', function(e) { onDrawEnd(e, false); });
      this.drawFreehandLine.on('drawend', function(e) { onDrawEnd(e, false); });
      this.drawArrow.on('drawend', function(e) {
        e.feature.set('type', 'arrow');
        onDrawEnd(e, false);
      });

      this.moveFeature.on('translateend', function(e) { that.SaveUserFeatures(); });
      this.modifyFeature.on('modifyend', function(e) { that.SaveUserFeatures(); });

      this.selectAnnotation.on('select', function(e) {
        if (e.selected.length === 1) {
          that.annotationProperties = [];

          var feature = e.selected[0];
          that.selectedFeature = feature;

          var date = feature.get('creation-datetime');
          if (date === undefined) {
            that.AddReadOnlyProperty('Date', '');
          } else {
            that.AddReadOnlyProperty('Date', date.toLocaleString());
          }

          var geometry = feature.getGeometry();

          if (geometry.getType() === 'LineString') {
            // Line, freehand
            that.AddReadOnlyProperty('Length', FormatLength(geometry.getLength(), that.map.getView().getProjection()));
          } else if (geometry.getType() === 'Circle') {
            // geometry.getArea() is not available on circles
            var radius = geometry.getRadius();
            var area = Math.PI * radius * radius;
            that.AddReadOnlyProperty('Area', FormatArea(area, that.map.getView().getProjection()));
          } else if (geometry.getType() === 'Polygon') {
            // Rectangle, closed polygon, freehand polygon
            that.AddReadOnlyProperty('Area', FormatArea(geometry.getArea(), that.map.getView().getProjection()));
          }

          if (that.drawSource !== null &&
              that.drawSource.hasFeature(feature)) {
            that.AddTextAreaProperty('Label', feature.get('label') || '', 'label');
          } else {
            that.AddReadOnlyTextAreaProperty('Label', feature.get('label') || '');
          }

          /*
          // TODO
          that.AddDropdownProperty('Category', [
          { value: 'tumor',    label: 'Tumor' },
          { value: 'stroma',   label: 'Stroma' },
          { value: 'necrosis', label: 'Necrosis' }
          ], feature.get('category') || '', 'category');
          */

          bootstrap.Offcanvas.getOrCreateInstance(document.getElementById('right-panel')).show();
        } else {
          that.ClearAnnotationSelection(false);
        }
      });

      this.LoadAnnotationsInfo();
    },

    // -----------------------------------------------------------------------
    // Imported layers
    // -----------------------------------------------------------------------

    ShowShareUserLayerModal: function(layer) {
      this.shareLayerTarget = layer;
      this.shareLayerPublic = layer.public;
      this.shareLayerUsers = layer.shared_with;
      this.shareLayerSearchQuery = '';
      this.shareLayerSearchResults = [];
      this.modalShareUserLayer.show();
    },

    ShareLayerIsUserSelected: function(user) {
      for (var i = 0; i < this.shareLayerUsers.length; i++) {
        if (this.shareLayerUsers[i].type == user.type &&
            this.shareLayerUsers[i].name == user.name) {
          return true;
        }
      }

      return false;
    },

    ShareLayerAddUser: function(user) {
      if (!this.ShareLayerIsUserSelected(user)) {
        this.shareLayerUsers.push(user);
      }

      this.shareLayerSearchQuery = '';
      this.shareLayerSearchResults = [];
    },

    ShareLayerAddStandardUser: function(name) {
      var that = this;
      axios.post('../api/create-standard-user',
                 this.CreatePostPayload({ 'name': name }))
        .then(function(response) {
          that.ShareLayerAddUser(response.data);
        })
        .catch(function() {
          console.error('Cannot create standard user');
        });

      this.shareLayerSearchQuery = '';
      this.shareLayerSearchResults = [];
    },

    ShareLayerSearchUsers: function(newQuery) {
      if (newQuery !== undefined) {
        this.shareLayerSearchQuery = newQuery;
      }
      var query = this.shareLayerSearchQuery.trim();
      if (!query) {
        this.shareLayerSearchResults = [];
      } else {
        var that = this;
        axios.post('../api/search-active-users',
                   this.CreatePostPayload({ 'query': query }))
          .then(function(response) {
            that.shareLayerSearchResults = [];
            for (var i = 0; i < response.data.length; i++) {
              if (!that.ShareLayerIsUserSelected(response.data[i])) {
                that.shareLayerSearchResults.push(response.data[i]);
              }
            }
          })
          .catch(function() {
            that.shareLayerSearchResults = [];
          });
      }
    },

    ShareLayerSave: function() {
      this.shareLayerTarget.public = this.shareLayerPublic;
      this.shareLayerTarget.shared_with = this.shareLayerUsers;
      this.SaveUserLayer(this.shareLayerTarget);
      this.modalShareUserLayer.hide();
    },



    ShowImportLayerModal: function() {
      this.importUserSearchQuery = '';
      this.importUserSearchResults = [];
      this.importSelectedUser = '';
      this.importSelectedLayer = '';
      this.importAvailableUsers = [];
      this.importAvailableLayers = [];
      this.modalImportLayer.show();

      var that = this;
      axios.post('../api/list-sharing-users',
                 this.CreatePostPayload({}))
        .then(function(response) {
          that.importAvailableUsers = response.data;
          that.ImportUserSearchChanged();
        })
        .catch(function() {
          console.error('Cannot load users sharing layers');
        });
    },

    ImportUserSearchChanged: function(newQuery) {
      if (newQuery !== undefined) {
        this.importUserSearchQuery = newQuery;
      }

      this.importSelectedUser = '';
      this.importSelectedLayer = '';
      this.importAvailableLayers = [];
      this.importUserSearchResults = [];

      var query = this.importUserSearchQuery.trim().toLowerCase();

      if (query !== '') {
        for (var i = 0; i < this.importAvailableUsers.length; i++) {
          if (this.importAvailableUsers[i].name.toLowerCase().indexOf(query) !== -1) {
            this.importUserSearchResults.push(this.importAvailableUsers[i]);
          }
        }
      }
    },

    ImportUserSelect: function(userId) {
      this.importUserSearchQuery = '';
      this.importUserSearchResults = [];
      this.importSelectedUser = userId;
      this.ImportUserChanged();
    },

    ImportUserChanged: function() {
      this.importSelectedLayer = '';
      this.importAvailableLayers = [];

      var that = this;
      axios.post('../api/list-shared-layers',
                 this.CreatePostPayload({ 'author': this.importSelectedUser }))
        .then(function(response) {
          that.importAvailableLayers = response.data;
        })
        .catch(function() {
          console.error('Cannot load imported layers');
        });
    },

    ImportLayerConfirmed: function() {
      this.modalImportLayer.hide();
      var userId = this.importSelectedUser;
      var layerId = this.importSelectedLayer;

      var that = this;
      axios.post('../api/import-layer',
                 this.CreatePostPayload({
                   'author': userId,
                   'layer': layerId
                 }))
        .then(function(response) {
          that.LoadLayers();
        })
        .catch(function() {
          console.error('Cannot import layer');
        });
    },


    DeleteImportedLayer: function(layerId) {
      this.pendingDelete = layerId;
      this.modalDeleteImportedLayer.show();
    },

    ImportedLayerDeleteConfirmed: function() {
      this.modalDeleteImportedLayer.hide();
      var layerId = this.pendingDelete;

      var that = this;
      axios.post('../api/remove-imported-layer',
                 this.CreatePostPayload({
                   'layer': layerId
                 }))
        .then(function(response) {
          that.LoadLayers();
        })
        .catch(function() {
          console.error('Cannot remove imported layer');
        });
    },

    SaveImportedLayer: function(layer) {
      var that = this;
      axios.post('../api/save-imported-layer',
                 this.CreatePostPayload({
                   'layer': layer
                 }))
        .catch(function() {
          console.error('Cannot save imported layer');
        });
    },


    ReloadImportedFeatures: function() {
      // Reset current selection when refreshing imported content.
      this.ClearAnnotationSelection(true);

      console.assert(this.drawImportedSource !== null);  // InitializeAnnotations() must have been invoked
      console.assert(this.workspaceInfo.enabled !== undefined);  // LoadLayers() must have been invoked

      if (!this.workspaceInfo.sharing) {
        return;
      }

      var that = this;
      axios.post('../api/load-imported-features',
                 this.CreatePostPayload({}))
        .then(function(response) {
          that.drawImportedSource.clear();

          for (let i = 0; i < response.data.features.length; i++) {
            UnserializeFeatureOntoMap(that.drawImportedSource, response.data.features[i]);
          }
        })
        .catch(function() {
          console.error('Cannot load imported features');
        });
    }
  }
});


function IsNear(a, b)
{
  return Math.abs(a - b) <= 0.01;
}


function FormatUnit(value, units)
{
  // Order by unit size ascending (factor descending)
  for (var i = 0; i < units.length - 1; i++) {
    var nextScaled = value * units[i + 1].factor;

    // Stop when the next larger unit would produce a value below 1
    if (Math.abs(nextScaled) < 1) {
      var scaled = value * units[i].factor;
      return scaled.toFixed(2) + ' ' + units[i].label;
    }
  }

  // Use the largest unit available
  var largest = units[units.length - 1];
  var scaled = value * largest.factor;
  return scaled.toFixed(2) + ' ' + largest.label;
}


function FormatLength(lengthPx, projection)
{
  var metersPerUnit = projection.getMetersPerUnit();
  if (metersPerUnit) {
    var meters = lengthPx * metersPerUnit;

    return FormatUnit(meters, [
      { label: 'μm', factor: 1e6 },
      { label: 'mm', factor: 1e3 },
      { label: 'cm', factor: 1e2 },
      { label: 'm',  factor: 1 },
      { label: 'km', factor: 1e-3 }
    ]);

  } else {
    return lengthPx.toFixed(0) + ' px';
  }
}


function FormatArea(areaPx, projection)
{
  var metersPerUnit = projection.getMetersPerUnit();
  if (metersPerUnit) {
    var sqMeters = areaPx * metersPerUnit * metersPerUnit;

    return FormatUnit(sqMeters, [
      { label: 'μm²', factor: 1e12 },
      { label: 'mm²', factor: 1e6 },
      { label: 'cm²', factor: 1e4 },
      { label: 'm²',  factor: 1 },
      { label: 'km²', factor: 1e-6 }
    ]);

  } else {
    return areaPx.toFixed(0) + ' px²';
  }
}


function CreateLayerStyle(color)
{
  function HexToRGBA(hex, alpha)
  {
    var r = parseInt(hex.slice(1, 3), 16);
    var g = parseInt(hex.slice(3, 5), 16);
    var b = parseInt(hex.slice(5, 7), 16);
    return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
  }

  return new ol.style.Style({
    stroke: new ol.style.Stroke({ color: color, width: 2 }),
    fill: new ol.style.Fill({ color: HexToRGBA(color, 0.2) }),
    // The "image" style is used by point annotations
    image: new ol.style.Circle({
      radius: 5,
      fill: new ol.style.Fill({ color: color })
    })
  });
}


function CreateArrowStyle(feature, resolution, color)
{
  const coordinates = feature.getGeometry().getCoordinates();

  if (coordinates.length == 2) {
    const start = coordinates[0];
    const end = coordinates[1];

    const angle = Math.atan2(
      end[1] - start[1],
      end[0] - start[0]
    );

    const headLength = 10 * resolution;
    const headAngle = Math.PI / 6;

    const p1 = [
      end[0] - headLength * Math.cos(angle - headAngle),
      end[1] - headLength * Math.sin(angle - headAngle)
    ];

    const p2 = [
      end[0] - headLength * Math.cos(angle + headAngle),
      end[1] - headLength * Math.sin(angle + headAngle)
    ];

    return [
      // shaft
      new ol.style.Style({
        stroke: new ol.style.Stroke({
          color: color,
          width: 2
        })
      }),

      // arrow head
      new ol.style.Style({
        geometry: new ol.geom.LineString([
          p1, end, p2
        ]),
        stroke: new ol.style.Stroke({
          color: color,
          width: 2
        })
      })
    ];
  }
}


function IsArrowFeature(feature)
{
  return feature.get('type') === 'arrow';
}


function CreateFeatureStyle(feature, resolution, color)
{
  if (IsArrowFeature(feature)) {
    return CreateArrowStyle(feature, resolution, color);
  } else {
    return CreateLayerStyle(color);
  }
}


function SerializeFeature(feature)
{
  var type = feature.getGeometry().getType();

  if (type === 'LineString') {
    var s;
    if (IsArrowFeature(feature)) {
      s = 'arrow';
    } else {
      s = 'polyline';
    }

    return {
      'type' : s,
      'coordinates' : feature.getGeometry().getCoordinates()
    };
  } else if (type === 'Point') {
    return {
      'type' : 'point',
      'coordinates' : feature.getGeometry().getCoordinates()
    };
  } else if (type === 'Circle') {
    return {
      'type' : 'circle',
      'center' : feature.getGeometry().getCenter(),
      'radius' : feature.getGeometry().getRadius()
    };
  } else if (type === 'Polygon') {
    return {
      'type' : 'polygon',
      'coordinates' : feature.getGeometry().getCoordinates()
    };
  } else {
    console.error('Not implemented: ' + type);
    return null;
  }
}


function UnserializeGeometry(json)
{
  if (json.type === 'polyline') {
    return new ol.geom.LineString(json['coordinates']);
  } else if (json.type === 'arrow') {
    var feature = new ol.geom.LineString(json['coordinates']);
    feature.set('type', 'arrow');
    return feature;
  } else if (json.type === 'point') {
    return new ol.geom.Point(json['coordinates']);
  } else if (json.type === 'circle') {
    return new ol.geom.Circle(json['center'], json['radius']);
  } else if (json.type === 'polygon') {
    return new ol.geom.Polygon(json['coordinates']);
  } else {
    console.error('Not implemented: ' + json.type);
    return null;
  }
}


function UnserializeFeatureOntoMap(mapSource, serialized)
{
  var geometry = UnserializeGeometry(serialized);

  if (geometry !== null) {
    var feature = new ol.Feature(geometry);

    var layerId = serialized['layer-id'];
    console.assert(layerId !== undefined);
    feature.set('layer-id', layerId);

    var type = serialized['type'];
    if (type !== undefined) {
      feature.set('type', type);
    }

    var label = serialized['label'];
    if (label !== undefined) {
      feature.set('label', label);
    }

    var date = serialized['creation-datetime'];  // This is a numerical timestamp
    if (date !== undefined) {
      feature.set('creation-datetime', new Date(date));
    }

    mapSource.addFeature(feature);
  }
}


function BeforeUnloadHandler(event)
{
  event.preventDefault();

  // Included for legacy support, e.g. Chrome/Edge < 119
  event.returnValue = true;
};




function SetMagnification(map, referenceMagnification, magnification)
{
  var view = map.getView();
  var projection = view.getProjection();

  if (projection.getMetersPerUnit()) {  // Ensure that "metersPerUnit" is not null
    var resolution = referenceMagnification / magnification;

    view.animate({
      resolution: view.getConstrainedResolution(resolution),
      duration: 250
    });
  }
}


function GetMagnification(map, referenceMagnification)
{
  var view = map.getView();
  var projection = view.getProjection();

  if (projection.getMetersPerUnit() !== undefined) {  // Ensure that "metersPerUnit" is not null
    var resolution = view.getResolution();

    return referenceMagnification / resolution;
  }
}


/**
 * A ScaleLine control that also displays the equivalent microscope
 * objective magnification (4x, 10x, 40x,...) for the current zoom level.
 */
class MicroscopeScaleLine extends ol.control.ScaleLine {
  constructor(options = {}) {
    super(options);

    this.referenceMagnification_ = options.referenceMagnification;
    console.assert(this.referenceMagnification_ !== undefined);

    this.magnificationElement_ = document.createElement('div');
    this.magnificationElement_.className = 'ol-scale-magnification';
    this.element.appendChild(this.magnificationElement_);
  }

  updateElement_() {
    super.updateElement_();

    var map = this.getMap();
    if (map && this.magnificationElement_) {
      var magnification = GetMagnification(map, this.referenceMagnification_);
      if (magnification) {
        this.magnificationElement_.innerText = magnification.toFixed(2) + 'x';
      }
    }
  }
}