text: #!/usr/bin/env python
  #
  # sampleline.py - The SampleLineAction
  #
  # Author: Paul McCarthy <pauldmccarthy@gmail.com>
  #
  """This module provides the :class:`SampleLineAction` class, which allows
  the user to open a :class:`SampleLinePanel`. The ``SampleLinePanel`` is
  a FSLeyes control which allows the user to draw a line on the canvases of
  an :class:`.OrthoPanel`, and plot the data along that line from the currently
  selected :class:`.Image` overlay.
  """
  
  import                  os
  import                  copy
  
  import numpy         as np
  import scipy.ndimage as ndimage
  import                  wx
  
  import fsl.data.image                             as fslimage
  import fsl.utils.settings                         as fslsettings
  import fsl.transform.affine                       as affine
  import fsleyes_widgets.widgetlist                 as widgetlist
  import fsleyes_widgets.utils.status               as status
  import fsleyes_props                              as props
  import fsleyes.strings                            as strings
  import fsleyes.tooltips                           as tooltips
  import fsleyes.icons                              as fslicons
  import fsleyes.actions                            as actions
  import fsleyes.actions.screenshot                 as screenshot
  import fsleyes.views.orthopanel                   as orthopanel
  import fsleyes.controls.controlpanel              as ctrlpanel
  import fsleyes.plotting                           as plotting
  import fsleyes.plotting.plotcanvas                as plotcanvas
  import fsleyes.plugins.profiles.samplelineprofile as samplelineprofile
  
  
  def sampleAlongLine(data, start, end, resolution, order):
      """Samples from ``data``, along a line between ``start`` and ``end``.
  
      :arg data:       3D array
      :arg start:      Start coordinate
      :arg end:        End coordinate
      :arg resolution: Number of points to sample
      :arg order:      Interpolation  (see ``scipy.ndimage.map_coordinates``)
      :returns:        Tuple containing:
                         - 1D Numpy array containing the sampled values
                         - ``(3, N)`` numpy array containing the coordinates for
                           each sample
      """
  
      start  = list(start)
      end    = list(end)
      shape  = data.shape
      coords = np.linspace(start, end, resolution).T
  
      # map_coordinates doesn't take
      # kindly to dims of length 1
      data = data.squeeze()
      if any(s == 1 for s in shape):
          drop      = [i for i, s in enumerate(shape) if s == 1]
          mapcoords = np.delete(coords, drop, axis=0)
      else:
          mapcoords = coords
  
      # multi-channel data?
      if len(data.dtype) > 1:
          channels = []
          for chan in data.dtype.fields.keys():
              channels.append(data[chan])
          data = channels
      else:
          data = [data]
  
      ys = []
      for arr in data:
          ys.append(ndimage.map_coordinates(arr,
                                            mapcoords,
                                            order=order,
                                            output=np.float64))
  
      # For multi channel data, we currently
      # just take the mean across all
      # channels, but this might change in
      # the future (if there is any need).
      if len(ys) > 1: y = np.mean(ys, axis=0)
      else:           y = ys[0]
  
      return y, coords
  
  
  class SampleLineAction(actions.ToggleControlPanelAction):
      """The ``SampleLineAction`` simply shows/hides a :class:`SampleLinePanel`.
      """
  
  
      @staticmethod
      def supportedViews():
          """The ``SampleLineAction`` is restricted for use with
          :class:`.OrthoPanel` views.
          """
          return [orthopanel.OrthoPanel]
  
  
      def __init__(self, overlayList, displayCtx, ortho):
          """Create a  ``SampleLineAction``. """
          super().__init__(overlayList, displayCtx, ortho, SampleLinePanel)
  
          self.__ortho = ortho
          self.__name  = '{}_{}'.format(type(self).__name__, id(self))
  
          displayCtx.addListener('selectedOverlay', self.__name,
                                 self.__selectedOverlayChanged)
  
  
      def destroy(self):
          """Called when the :class:`.OrthoPanel` that owns this action is
          closed. Clears references, removes listeners, and calls the base
          class ``destroy`` method.
          """
          if self.destroyed:
              return
  
          self.__ortho = None
          self.displayCtx.removeListener('selectedOverlay', self.__name)
          super().destroy()
  
  
      def __selectedOverlayChanged(self, *a):
          """Called when the selected overlay changes. Enables/disables this
          action (and hence the bound Tools menu item) depending on whether the
          overlay is an image.
          """
          ovl = self.displayCtx.getSelectedOverlay()
          self.enabled = isinstance(ovl, fslimage.Image)
  
  
      def __run(self):
          """Open/close a :class:`SampleLinePanel`. """
          self.viewPanel.togglePanel(SampleLinePanel)
  
  
  class SampleLineDataSeries(plotting.DataSeries):
      """The ``SampleLineDataSeries`` represents data that is sampled along
      a straight line through a 3D volume from an :class:`.Image` overlay.
  
      ``SampleLineDataSeries`` objects are created by the
      :class:`SampleLinePanel`.
      """
  
  
      interp = props.Choice((0, 1, 2, 3), default=0)
      """How to interpolate the sampled data when it is plotted. The value is
      used directly as the ``order`` parameter to the
      ``scipy.ndimage.map_coordinates`` function.
      """
  
  
      resolution = props.Int(minval=2, maxval=200, default=100, clamped=False)
      """The number of points (uniformly spaced) to sample along the line. """
  
  
      normalise = props.Choice(('none', 'y', 'x', 'xy'))
      """Whether to normalise all plotted data along the x, or y, or both, axes.
      """
  
  
      def __init__(self,
                   overlay,
                   overlayList,
                   displayCtx,
                   plotCanvas,
                   index,
                   start,
                   end):
          """Create a ``SampleLineDataSeries``.
  
          :arg overlay:     The :class:`.Image` overlay to sample from
          :arg overlayList: The :class:`.OverlayList`
          :arg displayCtx:  A :class:`.DisplayContext` instance
          :arg plotCanvas:  The :class:`.PlotCanvas` that is plotting this
                            ``DataSeries``.
          :arg index:       Volume index, for images with more than 3
                            dimensions (see :meth:`.NiftiOpts.index`)
          :arg start:       Start of sampling line, in voxel coordinates
          :arg end:         End of sampling line, in voxel coordinates
          """
  
          plotting.DataSeries.__init__(
              self, overlay, overlayList, displayCtx, plotCanvas)
  
          self.__index  = index
          self.__start  = start
          self.__end    = end
          self.__coords = None
  
          self.addListener('resolution', self.name, self.__refreshData)
          self.addListener('interp',     self.name, self.__refreshData)
          self.addListener('normalise',  self.name, self.__refreshData)
  
          self.label = ('{}: [{:.2f} {:.2f} {:.2f}] -> '
                        '[{:.2f} {:.2f} {:.2f}]'.format(
                            overlay.name, *start, *end))
  
  
      @property
      def coords(self):
          """Return a ``(3, n)`` array containing the voxel coordinates of
          each sampled point for the most recently generated data, or ``None``
          if no data has been sampled yet.
          """
          return self.__coords
  
  
      def __refreshData(self, *a):
          """Called when :attr:`resolution`, :attr:`interp` or :attr:`normalise`
          change. Re-samples the data from the image.
          """
  
          overlay      = self.overlay
          opts         = self.displayCtx.getOpts(overlay)
          data         = overlay[self.__index]
          resolution   = self.resolution
          order        = self.interp
          normalisex   = 'x' in self.normalise
          normalisey   = 'y' in self.normalise
          start        = self.__start
          end          = self.__end
  
          y, coords = sampleAlongLine(data, start, end, resolution, order)
  
          if normalisey:
              y = (y - y.min()) / (y.max() - y.min())
  
          if normalisex:
              xmax = 1
          else:
              wstart = opts.transformCoords(start, 'voxel', 'world')
              wend   = opts.transformCoords(end,   'voxel', 'world')
              xmax   = affine.veclength(wstart - wend)[0]
  
          x = np.linspace(0, xmax, resolution)
  
          self.__coords = coords
          self.setData(x, y)
  
  
  class SampleLinePanel(ctrlpanel.ControlPanel):
      """The ``SampleLinePanel`` is a FSLeyes control which can be used in
      conjunction with an :class:`.OrthoPanel` view. It allows the user to draw
      a line on an ``OrthoPanel`` canvas, and plot the voxel intensities, along
      that line, from the currently selected :class:`.Image` overlay.
  
      The :class:`.SampleLineProfile` class implements user interaction, and the
      :class:`.PlotCanvas` class is used for plotting.
      """
  
  
      # Used to synchronise between GUI widgets
      # and SampleLineDataSeries property values
      # The GUI widgets are bound to the most
      # recently added SampleLineDataSeries
      # instance
      interp     = copy.copy(SampleLineDataSeries.interp)
      resolution = copy.copy(SampleLineDataSeries.resolution)
      normalise  = copy.copy(SampleLineDataSeries.normalise)
      colour     = copy.copy(plotting.DataSeries.colour)
      lineWidth  = copy.copy(plotting.DataSeries.lineWidth)
      lineStyle  = copy.copy(plotting.DataSeries.lineStyle)
  
  
      @staticmethod
      def supportedViews():
          """Overrides :meth:`.ControlMixin.supportedViews`. The
          ``SampleLinePanel`` is only intended to be added to
          :class:`.OrthoPanel` views.
          """
          return [orthopanel.OrthoPanel]
  
  
      @staticmethod
      def ignoreControl():
          """Tells FSLeyes not to add the ``SampleLinePanel`` as an option to
          the Settings menu. Instead, the :class:`SampleLineAction` is added as
          an option to the Tools menu.
          """
          return True
  
  
      @staticmethod
      def profileCls():
          """Returns the :class:`.SampleLineProfile` class, which needs to be
          activated in conjunction with the ``SampleLinePanel``.
          """
          return samplelineprofile.SampleLineProfile
  
  
      @staticmethod
      def defaultLayout():
          """Returns a dictionary containing layout settings to be passed to
          :class:`.ViewPanel.togglePanel`.
          """
          return {'floatPane' : True,
                  'floatOnly' : True}
  
  
      def __init__(self, parent, overlayList, displayCtx, ortho):
          """Create a ``SampleLinePanel``.
  
          :arg parent:      The :mod:`wx` parent object.
          :arg overlayList: The :class:`.OverlayList` instance.
          :arg displayCtx:  The :class:`.DisplayContext` instance.
          :arg ortho:       The :class:`.OrthoPanel` instance.
          """
          ctrlpanel.ControlPanel.__init__(
              self, parent, overlayList, displayCtx, ortho)
  
          profile = ortho.currentProfile
  
          # plot which displays the sampled data
          canvas = plotcanvas.PlotCanvas(self, drawFunc=self.__draw)
          canvas.canvas.SetMinSize((-1, 150))
  
          self.__ortho   = ortho
          self.__profile = profile
          self.__canvas  = canvas
          self.__current = None
  
          # initial settings
          self.colour    = '#000050'
          self.lineWidth = 2
          canvas.legend  = False
  
          # Controls which allow the user to select
          # interpolation/resolution, line colour, etc
          widgets = widgetlist.WidgetList(self)
          legend = props.makeWidget(
              widgets, canvas, 'legend',
              labels=strings.properties['PlotCanvas.legend'])
  
          interp = props.makeWidget(
              widgets, self, 'interp', labels=strings.choices[self, 'interp'])
          resolution = props.makeWidget(
              widgets, self, 'resolution', slider=True, spin=True,
              showLimits=False)
          normalise = props.makeWidget(
              widgets, self, 'normalise',
              labels=strings.choices[self, 'normalise'])
  
          colour    = props.makeWidget(widgets, self, 'colour')
          lineWidth = props.makeWidget(widgets, self, 'lineWidth')
          lineStyle = props.makeWidget(
              widgets, self, 'lineStyle',
              labels=strings.choices['DataSeries.lineStyle'])
  
          widgets.AddWidget(legend,     strings.labels[self, 'legend'])
          widgets.AddWidget(interp,     strings.labels[self, 'interp'])
          widgets.AddWidget(resolution, strings.labels[self, 'resolution'])
          widgets.AddWidget(normalise,  strings.labels[self, 'normalise'])
          widgets.AddWidget(colour,     strings.labels[self, 'colour'])
          widgets.AddWidget(lineWidth,  strings.labels[self, 'lineWidth'])
          widgets.AddWidget(lineStyle,  strings.labels[self, 'lineStyle'])
  
          # Controls allowing the user to add/remove
          # sample lines from the plot to save the
          # data to a file, and to save a screenshot
          # of the plot
          ctrlSizer  = wx.BoxSizer(wx.HORIZONTAL)
          screenshot = actions.ActionButton(
              'screenshot',
              icon=fslicons.findImageFile('camera24'),
              tooltip=tooltips.actions['PlotPanel.screenshot'])
          export = actions.ActionButton(
              'export',
              icon=fslicons.findImageFile('exportDataSeries24'),
              tooltip=tooltips.actions['PlotPanel.exportDataSeries'])
          add = actions.ActionButton(
              'addDataSeries',
              icon=fslicons.findImageFile('add24'),
              tooltip=tooltips.actions[self, 'addDataSeries'])
          remove = actions.ActionButton(
              'removeDataSeries',
              icon=fslicons.findImageFile('remove24'),
              tooltip=tooltips.actions[self, 'removeDataSeries'])
  
          screenshot = props.buildGUI(self, self, screenshot)
          export     = props.buildGUI(self, self, export)
          add        = props.buildGUI(self, self, add)
          remove     = props.buildGUI(self, self, remove)
  
          ctrlSizer.Add(screenshot, flag=wx.EXPAND)
          ctrlSizer.Add(export,     flag=wx.EXPAND)
          ctrlSizer.Add(add,        flag=wx.EXPAND)
          ctrlSizer.Add(remove,     flag=wx.EXPAND)
  
          # Labels which show the start and end
          # coordinates of the sample line in voxel
          # ("v") and world ("w") coordinates, and
          # the line length in [units] (probably mm)
          vfromlbl = wx.StaticText(self)
          vtolbl   = wx.StaticText(self)
          wfromlbl = wx.StaticText(self)
          wtolbl   = wx.StaticText(self)
          vfromval = wx.StaticText(self)
          vtoval   = wx.StaticText(self)
          wfromval = wx.StaticText(self)
          wtoval   = wx.StaticText(self)
          lenlbl   = wx.StaticText(self)
          lenval   = wx.StaticText(self)
  
          self.__vfromval = vfromval
          self.__vtoval   = vtoval
          self.__wfromval = wfromval
          self.__wtoval   = wtoval
          self.__lenval   = lenval
  
          vfromlbl.SetLabel(strings.labels[self, 'voxelfrom'])
          vtolbl  .SetLabel(strings.labels[self, 'voxelto'])
          wfromlbl.SetLabel(strings.labels[self, 'worldfrom'])
          wtolbl  .SetLabel(strings.labels[self, 'worldto'])
          lenlbl  .SetLabel(strings.labels[self, 'length'])
  
          infoSizer = wx.FlexGridSizer(3, 4, 5, 5)
          infoSizer.AddGrowableCol(1)
          infoSizer.AddGrowableCol(3)
          infoSizer.Add(vfromlbl)
          infoSizer.Add(vfromval)
          infoSizer.Add(vtolbl)
          infoSizer.Add(vtoval)
          infoSizer.Add(wfromlbl)
          infoSizer.Add(wfromval)
          infoSizer.Add(wtolbl)
          infoSizer.Add(wtoval)
          infoSizer.Add(lenlbl)
          infoSizer.Add(lenval)
          infoSizer.Add((1, 1))
          infoSizer.Add((1, 1))
  
          mainSizer = wx.BoxSizer(wx.VERTICAL)
          mainSizer.Add(ctrlSizer,     flag=wx.EXPAND)
          mainSizer.Add(widgets,       flag=wx.EXPAND)
          mainSizer.Add(infoSizer,     flag=wx.EXPAND)
          mainSizer.Add(canvas.canvas, flag=wx.EXPAND, proportion=1)
  
          self.SetSizer(mainSizer)
          self.Layout()
  
          profile.registerHandler('LeftMouseDown', self.name, self.__onMouseDown)
          profile.registerHandler('LeftMouseDrag', self.name, self.__onMouseDrag)
          profile.registerHandler('LeftMouseUp',   self.name, self.__onMouseUp)
  
          self.addListener('interp',     self.name, canvas.asyncDraw)
          self.addListener('resolution', self.name, canvas.asyncDraw)
          self.addListener('normalise',  self.name, canvas.asyncDraw)
          self.addListener('colour',     self.name, canvas.asyncDraw)
          self.addListener('lineWidth',  self.name, canvas.asyncDraw)
          self.addListener('lineStyle',  self.name, canvas.asyncDraw)
  
  
      def destroy(self):
          """Called when this ``SampleLinePanel`` is no longer needed. Clears
          references, and calls :meth:`.ControlPanel.destroy`.
          """
          super().destroy()
          self.__canvas.destroy()
          self.__ortho   = None
          self.__profile = None
          self.__canvas  = None
          self.__current = None
  
  
      @property
      def canvas(self):
          """Return a reference to the :class:`.PlotCanvas` that is used to
          plot sampled data from lines that the user has drawn.
          """
          return self.__canvas
  
  
      @actions.action
      def addDataSeries(self):
          """Holds/persists the most recently sampled line to the plot. """
          if self.__current is not None:
              self.__canvas.dataSeries.append(self.__current)
              self.__bindToDataSeries(self.__current, False)
              self.__current = None
  
  
      @actions.action
      def removeDataSeries(self):
          """Removes the most recently held/persisted line from the plot. """
          canvas = self.__canvas
          if len(canvas.dataSeries) > 0:
              canvas.dataSeries.pop()
  
  
      @actions.action
      def export(self):
          """Prompts the user to save the sampled data to a file. """
  
          # only one series can be saved - the
          # user is asked to select which one
          if self.__current is None: series = []
          else:                      series = [self.__current]
  
          series.extend(reversed(self.__canvas.dataSeries))
  
          if len(series) == 0:
              return
  
          # ask what series, and whether they
          # want coordinates of sample points
          parent = self.GetParent()
          dlg    = ExportSampledDataDialog(parent, series)
          if dlg.ShowModal() != wx.ID_OK:
              return
  
          series     = dlg.GetSeries()
          saveCoords = dlg.GetCoordinates()
  
          # ask where to save the file
          fromDir = fslsettings.read('loadSaveOverlayDir', os.getcwd())
          msg     = strings.titles[self, 'savefile']
          dlg     = wx.FileDialog(parent,
                                  message=msg,
                                  defaultDir=fromDir,
                                  defaultFile='sample.txt',
                                  style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT)
  
          if dlg.ShowModal() != wx.ID_OK:
              return
  
          filename = dlg.GetPath()
  
          # prepare the data
          if saveCoords == 'none':
              data = series.getData()[1]
          else:
              image      = series.overlay
              opts       = self.displayCtx.getOpts(image)
              samples    = series.getData()[1]
              coords     = series.coords.T
              data       = np.zeros((len(samples), 4))
              data[:, 0] = samples
  
              if saveCoords == 'voxel':
                  data[:, 1:] = coords
              else:
                  data[:, 1:] = opts.transformCoords(coords, 'voxel', 'world')
  
          # save the file
          errTitle = strings.titles[  self, 'exportError']
          errMsg   = strings.messages[self, 'exportError']
          with status.reportIfError(errTitle, errMsg):
              np.savetxt(filename, data, fmt='%0.8f')
  
  
      @actions.action
      def screenshot(self):
          """Creates and runs a :class:`.ScreenshotAction`, which propmts the
          user to save the plot to a file.
          """
          screenshot.ScreenshotAction(self.overlayList,
                                      self.displayCtx,
                                      self.__canvas)()
  
  
      def __bindToDataSeries(self, ds, bind=True):
          """Binds/unbinds the GUI widgets (:attr:`colour`, :attr:`resolution`),
          etc to/from the given :class:`SampleLineDataSeries` instance.
          """
          ds.bindProps('colour',     self, unbind=not bind)
          ds.bindProps('lineWidth',  self, unbind=not bind)
          ds.bindProps('lineStyle',  self, unbind=not bind)
          ds.bindProps('resolution', self, unbind=not bind)
          ds.bindProps('interp',     self, unbind=not bind)
          ds.bindProps('normalise',  self, unbind=not bind)
  
  
      def __updateInfo(self):
          """Called while the mouse is being dragged. Updates the information
          about the sampling line (e.g. start/end voxel coordinates) that is
          displayed at the top of the ``SampleLinePanel``.
          """
          start = self.__profile.sampleStart
          end   = self.__profile.sampleEnd
          if start is None or end is None:
              self.__vfromval.SetLabel('')
              self.__vtoval  .SetLabel('')
              self.__wfromval.SetLabel('')
              self.__wtoval  .SetLabel('')
              self.__lenval  .SetLabel('')
              return
  
          overlay = self.displayCtx.getSelectedOverlay()
          opts    = self.displayCtx.getOpts(overlay)
          image   = opts.referenceImage
  
          # display world and voxel coordinates
          if image is not None:
              opts   = self.displayCtx.getOpts(image)
              units  = image.xyzUnits
              units  = strings.nifti.get(('xyz_unit', units), '(unknown units)')
              ws     = opts.transformCoords(start, 'display', 'world')
              we     = opts.transformCoords(end,   'display', 'world')
              vs     = opts.transformCoords(start, 'display', 'voxel')
              ve     = opts.transformCoords(end,   'display', 'voxel')
              length = affine.veclength(ws - we)[0]
  
              vs     = '[{:.2f}, {:.2f}, {:.2f}]'.format(*vs)
              ve     = '[{:.2f}, {:.2f}, {:.2f}]'.format(*ve)
              ws     = '[{:.2f}, {:.2f}, {:.2f}]'.format(*ws)
              we     = '[{:.2f}, {:.2f}, {:.2f}]'.format(*we)
              length = f'{length:.2f} {units}'
  
              self.__vfromval.SetLabel(vs)
              self.__vtoval  .SetLabel(ve)
              self.__wfromval.SetLabel(ws)
              self.__wtoval  .SetLabel(we)
              self.__lenval  .SetLabel(length)
  
  
      def __onMouseDown(self, *a):
          """Called on mouse down events on an :class:`.OrthoPanel` canvas.
          Calls :meth:`__updateInfo`.
          """
          self.__updateInfo()
  
  
      def __onMouseDrag(self, *a):
          """Called on mouse drag events on an :class:`.OrthoPanel` canvas.
          Calls :meth:`__updateInfo`.
          """
          self.__updateInfo()
  
  
      def __onMouseUp(self, *a):
          """Called on mouse up events on an :class:`.OrthoPanel` canvas.
          Samples and plots data from the currently selected overlay along
          the drawn sample line.
          """
  
          start  = self.__profile.sampleStart
          end    = self.__profile.sampleEnd
          image  = self.displayCtx.getSelectedOverlay()
  
          if start is None or end is None:
              return
  
          if image is None or not isinstance(image, fslimage.Image):
              return
  
          if self.__current is not None:
              self.__bindToDataSeries(self.__current, False)
  
          # Round to avoid floating point imprecision
          opts   = self.displayCtx.getOpts(image)
          vstart = opts.transformCoords(start, 'display', 'voxel').round(6)
          vend   = opts.transformCoords(end,   'display', 'voxel').round(6)
  
          series = SampleLineDataSeries(image,
                                        self.overlayList,
                                        self.displayCtx,
                                        self.__canvas,
                                        opts.index(),
                                        vstart,
                                        vend)
  
          self.__bindToDataSeries(series)
          self.__current = series
          self.__canvas.asyncDraw()
  
  
      def __draw(self):
          """Passed as the ``drawFunc`` to the :class:`.PlotCanvas`. Calls
          :meth:`.PlotCanvas.drawDataSeries`.
          """
          if self.__current is None: extras = None
          else:                      extras = [self.__current]
          self.__canvas.drawDataSeries(extraSeries=extras, refresh=True)
  
  
  class ExportSampledDataDialog(wx.Dialog):
      """The ``ExportSampledDataDialog`` is used by the
      :meth:`SampleLinePanel.export` method to ask the user which sample data
      they want to save, and whether they want to save the sample point
      coordinates to file as well as the samples themselves.
      """
  
  
      def __init__(self, parent, series):
          """Create an ``ExportSampledDataDialog``.
  
          :arg parent: ``wx`` parent object
          :arg series: Sequence of ``SampleLineDataSeries`` instances. Must
                       contain at least one series. If there is more than one
                       series, the user is asked to choose one.
          """
          title        = strings.titles[self]
          coords       = strings.choices[self, 'saveCoordinates']
          seriesLabels = [s.label for s in series]
          coordsLabels = list(coords.values())
          coords       = list(coords.keys())
  
          wx.Dialog.__init__(self,
                             parent,
                             title=title,
                             style=wx.DEFAULT_DIALOG_STYLE)
  
          self.__series       = series
          self.__coords       = coords
          self.__coordsChoice = wx.Choice(self, choices=coordsLabels)
          self.__coordsChoice.SetSelection(0)
  
          coordsLabel = wx.StaticText(self)
          ok          = wx.Button(self, id=wx.ID_OK)
          cancel      = wx.Button(self, id=wx.ID_CANCEL)
  
          coordsLabel.SetLabel(strings.labels[self, 'coords'])
          ok         .SetLabel(strings.labels[self, 'ok'])
          cancel     .SetLabel(strings.labels[self, 'cancel'])
  
          if len(series) > 1:
              self.__seriesChoice = wx.Choice(self, choices=seriesLabels)
              self.__seriesChoice.SetSelection(0)
              seriesLabel = wx.StaticText(self)
              seriesLabel.SetLabel(strings.labels[self, 'series'])
  
          ok.SetDefault()
          ok    .Bind(wx.EVT_BUTTON, self.__onOk)
          cancel.Bind(wx.EVT_BUTTON, self.__onCancel)
  
          buttonSizer = wx.BoxSizer(wx.HORIZONTAL)
          mainSizer   = wx.BoxSizer(wx.VERTICAL)
  
          buttonSizer.Add((10, 10), proportion=1)
          buttonSizer.Add(ok)
          buttonSizer.Add((10, 10))
          buttonSizer.Add(cancel)
          buttonSizer.Add((10, 10), proportion=1)
  
          mainSizer.Add((10, 10), proportion=1)
  
          if len(series) > 1:
              mainSizer.Add(seriesLabel,
                            flag=wx.ALIGN_CENTRE | wx.ALL, border=10)
              mainSizer.Add((10, 10))
              mainSizer.Add(self.__seriesChoice,
                            flag=wx.ALIGN_CENTRE | wx.ALL, border=10)
              mainSizer.Add((10, 10))
  
          mainSizer.Add(coordsLabel, flag=wx.ALIGN_CENTRE | wx.ALL, border=10)
          mainSizer.Add((10, 10))
          mainSizer.Add(self.__coordsChoice,
                        flag=wx.ALIGN_CENTRE | wx.ALL, border=10)
          mainSizer.Add((10, 10), proportion=1)
          mainSizer.Add(buttonSizer, flag=wx.ALIGN_CENTRE | wx.ALL, border=10)
          mainSizer.Add((10, 10))
  
          self.SetSizer(mainSizer)
          self.Layout()
          self.Fit()
          self.CentreOnParent()
  
  
      def __onOk(self, ev):
          """Called when the ok button is pushed. Closes the dialog. """
          self.EndModal(wx.ID_OK)
  
  
      def __onCancel(self, ev):
          """Called when the cancel button is pushed. Closes the dialog. """
          self.EndModal(wx.ID_CANCEL)
  
  
      def GetCoordinates(self):
          """Return one of ``'none'``, ``'voxel'``, or ``'world'``, denoting
          the user's preference for saving coordinates to the file.
          """
          idx = self.__coordsChoice.GetSelection()
          return self.__coords[idx]
  
  
      def GetSeries(self):
          """Return the :class:`.SampleLineDataSeries` that was selected."""
          if len(self.__series) == 1:
              return self.__series[0]
          idx = self.__seriesChoice.GetSelection()
          return self.__series[idx]
  