source: sasview/src/sas/qtgui/Plotting/Plotter.py @ eae226b

ESS_GUIESS_GUI_DocsESS_GUI_batch_fittingESS_GUI_bumps_abstractionESS_GUI_iss1116ESS_GUI_iss879ESS_GUI_iss959ESS_GUI_openclESS_GUI_orderingESS_GUI_sync_sascalc
Last change on this file since eae226b was 8f83719f, checked in by Piotr Rozyczko <rozyczko@…>, 7 years ago

More inversion work on details in validation, UI design and such

  • Property mode set to 100644
File size: 24.4 KB
Line 
1from PyQt5 import QtCore
2from PyQt5 import QtGui
3from PyQt5 import QtWidgets
4
5import functools
6import copy
7import matplotlib.pyplot as plt
8from matplotlib.font_manager import FontProperties
9from sas.qtgui.Plotting.PlotterData import Data1D
10from sas.qtgui.Plotting.PlotterBase import PlotterBase
11from sas.qtgui.Plotting.AddText import AddText
12from sas.qtgui.Plotting.SetGraphRange import SetGraphRange
13from sas.qtgui.Plotting.LinearFit import LinearFit
14from sas.qtgui.Plotting.PlotProperties import PlotProperties
15
16import sas.qtgui.Utilities.GuiUtils as GuiUtils
17import sas.qtgui.Plotting.PlotUtilities as PlotUtilities
18
19class PlotterWidget(PlotterBase):
20    """
21    1D Plot widget for use with a QDialog
22    """
23    def __init__(self, parent=None, manager=None, quickplot=False):
24        super(PlotterWidget, self).__init__(parent, manager=manager, quickplot=quickplot)
25
26        self.parent = parent
27
28        # Dictionary of {plot_id:Data1d}
29        self.plot_dict = {}
30
31        # Window for text add
32        self.addText = AddText(self)
33
34        # Log-ness of the axes
35        self.xLogLabel = "log10(x)"
36        self.yLogLabel = "log10(y)"
37
38        # Data container for the linear fit
39        self.fit_result = Data1D(x=[], y=[], dy=None)
40        self.fit_result.symbol = 13
41        self.fit_result.name = "Fit"
42
43    @property
44    def data(self):
45        return self._data
46
47    @data.setter
48    def data(self, value):
49        """ data setter """
50        self._data = value
51        if value._xunit:
52            self.xLabel = "%s(%s)"%(value._xaxis, value._xunit)
53        else:
54            self.xLabel = "%s"%(value._xaxis)
55        if value._yunit:
56            self.yLabel = "%s(%s)"%(value._yaxis, value._yunit)
57        else:
58            self.yLabel = "%s"%(value._yaxis)
59
60        if value.scale == 'linear' or value.isSesans:
61            self.xscale = 'linear'
62            self.yscale = 'linear'
63        self.title(title=value.name)
64
65    def plot(self, data=None, color=None, marker=None, hide_error=False):
66        """
67        Add a new plot of self._data to the chart.
68        """
69        # Data1D
70        if isinstance(data, Data1D):
71            self.data = data
72        assert(self._data)
73
74        is_fit = (self.data.id=="fit")
75
76        # Transform data if required.
77        # TODO: it properly!
78        #if data.xtransform is not None or data.ytransform is not None:
79        #    a, b, c, d = GuiUtils.xyTransform(self.data, self.data.xtransform, self.data.ytransform)
80
81        # Shortcuts
82        ax = self.ax
83        x = self._data.view.x
84        y = self._data.view.y
85
86        # Marker symbol. Passed marker is one of matplotlib.markers characters
87        # Alternatively, picked up from Data1D as an int index of PlotUtilities.SHAPES dict
88        if marker is None:
89            marker = self.data.symbol
90            # Try name first
91            try:
92                marker = dict(PlotUtilities.SHAPES)[marker]
93            except KeyError:
94                marker = list(PlotUtilities.SHAPES.values())[marker]
95
96        assert marker is not None
97        # Plot name
98        if self.data.title:
99            self.title(title=self.data.title)
100        else:
101            self.title(title=self.data.name)
102
103        # Error marker toggle
104        if hide_error is None:
105            hide_error = self.data.hide_error
106
107        # Plot color
108        if color is None:
109            color = self.data.custom_color
110
111        color = PlotUtilities.getValidColor(color)
112
113        markersize = self._data.markersize
114
115        # Draw non-standard markers
116        l_width = markersize * 0.4
117        if marker == '-' or marker == '--':
118            line = self.ax.plot(x, y, color=color, lw=l_width, marker='',
119                             linestyle=marker, label=self._title, zorder=10)[0]
120
121        elif marker == 'vline':
122            y_min = min(y)*9.0/10.0 if min(y) < 0 else 0.0
123            line = self.ax.vlines(x=x, ymin=y_min, ymax=y, color=color,
124                            linestyle='-', label=self._title, lw=l_width, zorder=1)
125
126        elif marker == 'step':
127            line = self.ax.step(x, y, color=color, marker='', linestyle='-',
128                                label=self._title, lw=l_width, zorder=1)[0]
129
130        else:
131            # plot data with/without errorbars
132            if hide_error:
133                line = ax.plot(x, y, marker=marker, color=color, markersize=markersize,
134                        linestyle='', label=self._title, picker=True)
135            else:
136                line = ax.errorbar(x, y,
137                            yerr=self._data.view.dy, xerr=None,
138                            capsize=2, linestyle='',
139                            barsabove=False,
140                            color=color,
141                            marker=marker,
142                            markersize=markersize,
143                            lolims=False, uplims=False,
144                            xlolims=False, xuplims=False,
145                            label=self._title,
146                            picker=True)
147
148        # Update the list of data sets (plots) in chart
149        self.plot_dict[self._data.id] = self.data
150
151        # Now add the legend with some customizations.
152
153        self.legend = ax.legend(loc='upper right', shadow=True)
154        if self.legend:
155            self.legend.set_picker(True)
156
157        # Current labels for axes
158        if self.y_label and not is_fit:
159            ax.set_ylabel(self.y_label)
160        if self.x_label and not is_fit:
161            ax.set_xlabel(self.x_label)
162
163        # Include scaling (log vs. linear)
164        ax.set_xscale(self.xscale)
165        ax.set_yscale(self.yscale)
166
167        # define the ranges
168        self.setRange = SetGraphRange(parent=self,
169            x_range=self.ax.get_xlim(), y_range=self.ax.get_ylim())
170
171        # refresh canvas
172        self.canvas.draw_idle()
173
174    def createContextMenu(self):
175        """
176        Define common context menu and associated actions for the MPL widget
177        """
178        self.defaultContextMenu()
179
180        # Separate plots
181        self.addPlotsToContextMenu()
182
183        # Additional menu items
184        self.contextMenu.addSeparator()
185        self.actionAddText = self.contextMenu.addAction("Add Text")
186        self.actionRemoveText = self.contextMenu.addAction("Remove Text")
187        self.contextMenu.addSeparator()
188        self.actionChangeScale = self.contextMenu.addAction("Change Scale")
189        self.contextMenu.addSeparator()
190        self.actionSetGraphRange = self.contextMenu.addAction("Set Graph Range")
191        self.actionResetGraphRange =\
192            self.contextMenu.addAction("Reset Graph Range")
193        # Add the title change for dialogs
194        #if self.parent:
195        self.contextMenu.addSeparator()
196        self.actionWindowTitle = self.contextMenu.addAction("Window Title")
197
198        # Define the callbacks
199        self.actionAddText.triggered.connect(self.onAddText)
200        self.actionRemoveText.triggered.connect(self.onRemoveText)
201        self.actionChangeScale.triggered.connect(self.onScaleChange)
202        self.actionSetGraphRange.triggered.connect(self.onSetGraphRange)
203        self.actionResetGraphRange.triggered.connect(self.onResetGraphRange)
204        self.actionWindowTitle.triggered.connect(self.onWindowsTitle)
205
206    def addPlotsToContextMenu(self):
207        """
208        Adds operations on all plotted sets of data to the context menu
209        """
210        for id in list(self.plot_dict.keys()):
211            plot = self.plot_dict[id]
212
213            name = plot.name if plot.name else plot.title
214            plot_menu = self.contextMenu.addMenu('&%s' % name)
215
216            self.actionDataInfo = plot_menu.addAction("&DataInfo")
217            self.actionDataInfo.triggered.connect(
218                                functools.partial(self.onDataInfo, plot))
219
220            self.actionSavePointsAsFile = plot_menu.addAction("&Save Points as a File")
221            self.actionSavePointsAsFile.triggered.connect(
222                                functools.partial(self.onSavePoints, plot))
223            plot_menu.addSeparator()
224
225            if plot.id != 'fit':
226                self.actionLinearFit = plot_menu.addAction('&Linear Fit')
227                self.actionLinearFit.triggered.connect(
228                                functools.partial(self.onLinearFit, id))
229                plot_menu.addSeparator()
230
231            self.actionRemovePlot = plot_menu.addAction("Remove")
232            self.actionRemovePlot.triggered.connect(
233                                functools.partial(self.onRemovePlot, id))
234
235            if not plot.is_data:
236                self.actionFreeze = plot_menu.addAction('&Freeze')
237                self.actionFreeze.triggered.connect(
238                                functools.partial(self.onFreeze, id))
239            plot_menu.addSeparator()
240
241            if plot.is_data:
242                self.actionHideError = plot_menu.addAction("Hide Error Bar")
243                if plot.dy is not None and plot.dy != []:
244                    if plot.hide_error:
245                        self.actionHideError.setText('Show Error Bar')
246                else:
247                    self.actionHideError.setEnabled(False)
248                self.actionHideError.triggered.connect(
249                                functools.partial(self.onToggleHideError, id))
250                plot_menu.addSeparator()
251
252            self.actionModifyPlot = plot_menu.addAction('&Modify Plot Property')
253            self.actionModifyPlot.triggered.connect(
254                                functools.partial(self.onModifyPlot, id))
255
256    def createContextMenuQuick(self):
257        """
258        Define context menu and associated actions for the quickplot MPL widget
259        """
260        # Default actions
261        self.defaultContextMenu()
262
263        # Additional actions
264        self.actionToggleGrid = self.contextMenu.addAction("Toggle Grid On/Off")
265        self.contextMenu.addSeparator()
266        self.actionChangeScale = self.contextMenu.addAction("Change Scale")
267
268        # Define the callbacks
269        self.actionToggleGrid.triggered.connect(self.onGridToggle)
270        self.actionChangeScale.triggered.connect(self.onScaleChange)
271
272    def onScaleChange(self):
273        """
274        Show a dialog allowing axes rescaling
275        """
276        if self.properties.exec_() == QtWidgets.QDialog.Accepted:
277            self.xLogLabel, self.yLogLabel = self.properties.getValues()
278            self.xyTransform(self.xLogLabel, self.yLogLabel)
279
280    def onAddText(self):
281        """
282        Show a dialog allowing adding custom text to the chart
283        """
284        if self.addText.exec_() == QtWidgets.QDialog.Accepted:
285            # Retrieve the new text, its font and color
286            extra_text = self.addText.text()
287            extra_font = self.addText.font()
288            extra_color = self.addText.color()
289
290            # Place the text on the screen at (0,0)
291            pos_x = self.x_click
292            pos_y = self.y_click
293
294            # Map QFont onto MPL font
295            mpl_font = FontProperties()
296            mpl_font.set_size(int(extra_font.pointSize()))
297            mpl_font.set_family(str(extra_font.family()))
298            mpl_font.set_weight(int(extra_font.weight()))
299            # MPL style names
300            styles = ['normal', 'italic', 'oblique']
301            # QFont::Style maps directly onto the above
302            try:
303                mpl_font.set_style(styles[extra_font.style()])
304            except:
305                pass
306
307            if len(extra_text) > 0:
308                new_text = self.ax.text(str(pos_x),
309                                        str(pos_y),
310                                        extra_text,
311                                        color=extra_color,
312                                        fontproperties=mpl_font)
313                # Update the list of annotations
314                self.textList.append(new_text)
315                self.canvas.draw_idle()
316                pass
317
318    def onRemoveText(self):
319        """
320        Remove the most recently added text
321        """
322        num_text = len(self.textList)
323        if num_text < 1:
324            return
325        txt = self.textList[num_text - 1]
326        text_remove = txt.get_text()
327        txt.remove()
328        self.textList.remove(txt)
329
330        self.canvas.draw_idle()
331
332    def onSetGraphRange(self):
333        """
334        Show a dialog allowing setting the chart ranges
335        """
336        # min and max of data
337        if self.setRange.exec_() == QtWidgets.QDialog.Accepted:
338            x_range = self.setRange.xrange()
339            y_range = self.setRange.yrange()
340            if x_range is not None and y_range is not None:
341                self.ax.set_xlim(x_range)
342                self.ax.set_ylim(y_range)
343                self.canvas.draw_idle()
344
345    def onResetGraphRange(self):
346        """
347        Resets the chart X and Y ranges to their original values
348        """
349        x_range = (self.data.x.min(), self.data.x.max())
350        y_range = (self.data.y.min(), self.data.y.max())
351        if x_range is not None and y_range is not None:
352            self.ax.set_xlim(x_range)
353            self.ax.set_ylim(y_range)
354            self.canvas.draw_idle()
355
356    def onLinearFit(self, id):
357        """
358        Creates and displays a simple linear fit for the selected plot
359        """
360        selected_plot = self.plot_dict[id]
361
362        maxrange = (min(selected_plot.x), max(selected_plot.x))
363        fitrange = self.ax.get_xlim()
364
365        fit_dialog = LinearFit(parent=self,
366                    data=selected_plot,
367                    max_range=maxrange,
368                    fit_range=fitrange,
369                    xlabel=self.xLogLabel,
370                    ylabel=self.yLogLabel)
371        fit_dialog.updatePlot.connect(self.onFitDisplay)
372        if fit_dialog.exec_() == QtWidgets.QDialog.Accepted:
373            return
374
375    def replacePlot(self, id, new_plot):
376        """
377        Remove plot 'id' and add 'new_plot' to the chart.
378        This effectlvely refreshes the chart with changes to one of its plots
379        """
380        self.removePlot(id)
381        self.plot(data=new_plot)
382
383    def onRemovePlot(self, id):
384        """
385        Responds to the plot delete action
386        """
387        self.removePlot(id)
388
389        if len(self.plot_dict) == 0:
390            # last plot: graph is empty must be the panel must be destroyed
391                self.parent.close()
392
393    def removePlot(self, id):
394        """
395        Deletes the selected plot from the chart
396        """
397        if id not in list(self.plot_dict.keys()):
398            return
399
400        selected_plot = self.plot_dict[id]
401
402        plot_dict = copy.deepcopy(self.plot_dict)
403
404        # Labels might have been changed
405        xl = self.ax.xaxis.label.get_text()
406        yl = self.ax.yaxis.label.get_text()
407
408        self.plot_dict = {}
409
410        plt.cla()
411        self.ax.cla()
412
413        for ids in plot_dict:
414            if ids != id:
415                self.plot(data=plot_dict[ids], hide_error=plot_dict[ids].hide_error)
416
417        # Reset the labels
418        self.ax.set_xlabel(xl)
419        self.ax.set_ylabel(yl)
420        self.canvas.draw()
421
422    def onFreeze(self, id):
423        """
424        Freezes the selected plot to a separate chart
425        """
426        plot = self.plot_dict[id]
427        self.manager.add_data(data_list=[plot])
428
429    def onModifyPlot(self, id):
430        """
431        Allows for MPL modifications to the selected plot
432        """
433        selected_plot = self.plot_dict[id]
434
435        # Old style color - single integer for enum color
436        # New style color - #hhhhhh
437        color = selected_plot.custom_color
438        # marker symbol and size
439        marker = selected_plot.symbol
440        marker_size = selected_plot.markersize
441        # plot name
442        legend = selected_plot.title
443
444        plotPropertiesWidget = PlotProperties(self,
445                                color=color,
446                                marker=marker,
447                                marker_size=marker_size,
448                                legend=legend)
449        if plotPropertiesWidget.exec_() == QtWidgets.QDialog.Accepted:
450            # Update Data1d
451            selected_plot.markersize = plotPropertiesWidget.markersize()
452            selected_plot.custom_color = plotPropertiesWidget.color()
453            selected_plot.symbol = plotPropertiesWidget.marker()
454            selected_plot.title = plotPropertiesWidget.legend()
455
456            # Redraw the plot
457            self.replacePlot(id, selected_plot)
458
459    def onToggleHideError(self, id):
460        """
461        Toggles hide error/show error menu item
462        """
463        selected_plot = self.plot_dict[id]
464        current = selected_plot.hide_error
465
466        # Flip the flag
467        selected_plot.hide_error = not current
468
469        plot_dict = copy.deepcopy(self.plot_dict)
470        self.plot_dict = {}
471
472        # Clean the canvas
473        plt.cla()
474        self.ax.cla()
475
476        # Recreate the plots but reverse the error flag for the current
477        for ids in plot_dict:
478            if ids == id:
479                self.plot(data=plot_dict[ids], hide_error=(not current))
480            else:
481                self.plot(data=plot_dict[ids], hide_error=plot_dict[ids].hide_error)               
482
483    def xyTransform(self, xLabel="", yLabel=""):
484        """
485        Transforms x and y in View and set the scale
486        """
487        # Transform all the plots on the chart
488        for id in list(self.plot_dict.keys()):
489            current_plot = self.plot_dict[id]
490            if current_plot.id == "fit":
491                self.removePlot(id)
492                continue
493
494            new_xlabel, new_ylabel, xscale, yscale =\
495                GuiUtils.xyTransform(current_plot, xLabel, yLabel)
496            self.xscale = xscale
497            self.yscale = yscale
498
499            # Plot the updated chart
500            self.removePlot(id)
501
502            # This assignment will wrap the label in Latex "$"
503            self.xLabel = new_xlabel
504            self.yLabel = new_ylabel
505            # Directly overwrite the data to avoid label reassignment
506            self._data = current_plot
507            self.plot()
508
509        pass # debug hook
510
511    def onFitDisplay(self, fit_data):
512        """
513        Add a linear fitting line to the chart
514        """
515        # Create new data structure with fitting result
516        tempx = fit_data[0]
517        tempy = fit_data[1]
518        self.fit_result.x = []
519        self.fit_result.y = []
520        self.fit_result.x = tempx
521        self.fit_result.y = tempy
522        self.fit_result.dx = None
523        self.fit_result.dy = None
524
525        #Remove another Fit, if exists
526        self.removePlot("fit")
527
528        self.fit_result.reset_view()
529        #self.offset_graph()
530
531        # Set plot properties
532        self.fit_result.id = 'fit'
533        self.fit_result.title = 'Fit'
534        self.fit_result.name = 'Fit'
535
536        # Plot the line
537        self.plot(data=self.fit_result, marker='-', hide_error=True)
538
539    def onMplMouseDown(self, event):
540        """
541        Left button down and ready to drag
542        """
543        # Check that the LEFT button was pressed
544        if event.button != 1:
545            return
546
547        self.leftdown = True
548        for text in self.textList:
549            if text.contains(event)[0]: # If user has clicked on text
550                self.selectedText = text
551                return
552        if event.inaxes is None:
553            return
554        try:
555            self.x_click = float(event.xdata)  # / size_x
556            self.y_click = float(event.ydata)  # / size_y
557        except:
558            self.position = None
559
560    def onMplMouseUp(self, event):
561        """
562        Set the data coordinates of the click
563        """
564        self.x_click = event.xdata
565        self.y_click = event.ydata
566
567        # Check that the LEFT button was released
568        if event.button == 1:
569            self.leftdown = False
570            self.selectedText = None
571
572        #release the legend
573        if self.gotLegend == 1:
574            self.gotLegend = 0
575
576    def onMplMouseMotion(self, event):
577        """
578        Check if the left button is press and the mouse in moving.
579        Compute delta for x and y coordinates and then perform the drag
580        """
581        if self.gotLegend == 1 and self.leftdown:
582            self.onLegendMotion(event)
583            return
584
585        #if self.leftdown and self.selectedText is not None:
586        if not self.leftdown or self.selectedText is None:
587            return
588        # User has clicked on text and is dragging
589        if event.inaxes is None:
590            # User has dragged outside of axes
591            self.selectedText = None
592        else:
593            # Only move text if mouse is within axes
594            self.selectedText.set_position((event.xdata, event.ydata))
595            self.canvas.draw_idle()
596        return
597
598    def onMplPick(self, event):
599        """
600        On pick legend
601        """
602        legend = self.legend
603        if event.artist != legend:
604            return
605        # Get the box of the legend.
606        bbox = self.legend.get_window_extent()
607        # Get mouse coordinates at time of pick.
608        self.mouse_x = event.mouseevent.x
609        self.mouse_y = event.mouseevent.y
610        # Get legend coordinates at time of pick.
611        self.legend_x = bbox.xmin
612        self.legend_y = bbox.ymin
613        # Indicate we picked up the legend.
614        self.gotLegend = 1
615
616        #self.legend.legendPatch.set_alpha(0.5)
617
618    def onLegendMotion(self, event):
619        """
620        On legend in motion
621        """
622        ax = event.inaxes
623        if ax is None:
624            return
625        # Event occurred inside a plotting area
626        lo_x, hi_x = ax.get_xlim()
627        lo_y, hi_y = ax.get_ylim()
628        # How much the mouse moved.
629        x = mouse_diff_x = self.mouse_x - event.x
630        y = mouse_diff_y = self.mouse_y - event.y
631        # Put back inside
632        if x < lo_x:
633            x = lo_x
634        if x > hi_x:
635            x = hi_x
636        if y < lo_y:
637            y = lo_y
638        if y > hi_y:
639            y = hi_y
640        # Move the legend from its previous location by that same amount
641        loc_in_canvas = self.legend_x - mouse_diff_x, \
642                        self.legend_y - mouse_diff_y
643        # Transform into legend coordinate system
644        trans_axes = self.legend.parent.transAxes.inverted()
645        loc_in_norm_axes = trans_axes.transform_point(loc_in_canvas)
646        self.legend_pos_loc = tuple(loc_in_norm_axes)
647        self.legend._loc = self.legend_pos_loc
648        # self.canvas.draw()
649        self.canvas.draw_idle()
650
651    def onMplWheel(self, event):
652        """
653        Process mouse wheel as zoom events
654        """
655        ax = event.inaxes
656        step = event.step
657
658        if ax is not None:
659            # Event occurred inside a plotting area
660            lo, hi = ax.get_xlim()
661            lo, hi = PlotUtilities.rescale(lo, hi, step,
662                              pt=event.xdata, scale=ax.get_xscale())
663            if not self.xscale == 'log' or lo > 0:
664                self._scale_xlo = lo
665                self._scale_xhi = hi
666                ax.set_xlim((lo, hi))
667
668            lo, hi = ax.get_ylim()
669            lo, hi = PlotUtilities.rescale(lo, hi, step, pt=event.ydata,
670                              scale=ax.get_yscale())
671            if not self.yscale == 'log' or lo > 0:
672                self._scale_ylo = lo
673                self._scale_yhi = hi
674                ax.set_ylim((lo, hi))
675        else:
676            # Check if zoom happens in the axes
677            xdata, ydata = None, None
678            x, y = event.x, event.y
679
680            for ax in self.axes:
681                insidex, _ = ax.xaxis.contains(event)
682                if insidex:
683                    xdata, _ = ax.transAxes.inverted().transform_point((x, y))
684                insidey, _ = ax.yaxis.contains(event)
685                if insidey:
686                    _, ydata = ax.transAxes.inverted().transform_point((x, y))
687            if xdata is not None:
688                lo, hi = ax.get_xlim()
689                lo, hi = PlotUtilities.rescale(lo, hi, step,
690                                  bal=xdata, scale=ax.get_xscale())
691                if not self.xscale == 'log' or lo > 0:
692                    self._scale_xlo = lo
693                    self._scale_xhi = hi
694                    ax.set_xlim((lo, hi))
695            if ydata is not None:
696                lo, hi = ax.get_ylim()
697                lo, hi = PlotUtilities.rescale(lo, hi, step, bal=ydata,
698                                  scale=ax.get_yscale())
699                if not self.yscale == 'log' or lo > 0:
700                    self._scale_ylo = lo
701                    self._scale_yhi = hi
702                    ax.set_ylim((lo, hi))
703        self.canvas.draw_idle()
704
705
706class Plotter(QtWidgets.QDialog, PlotterWidget):
707    def __init__(self, parent=None, quickplot=False):
708
709        QtWidgets.QDialog.__init__(self)
710        PlotterWidget.__init__(self, parent=self, manager=parent, quickplot=quickplot)
711        icon = QtGui.QIcon()
712        icon.addPixmap(QtGui.QPixmap(":/res/ball.ico"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
713        self.setWindowIcon(icon)
714
715
Note: See TracBrowser for help on using the repository browser.