├── .gitignore ├── CHANGES.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── circle.yml ├── docs └── source │ ├── conf.py │ ├── index.rst │ ├── requirements.txt │ └── walkthrough.ipynb ├── examples ├── __init__.py ├── reuters.py └── server.py ├── pynorama-js ├── .babelrc ├── .eslintrc.js ├── .flowconfig ├── __mocks__ │ ├── fileMock.js │ └── styleMock.js ├── build.sh ├── debug.sh ├── package.json ├── src │ ├── common │ │ ├── components.jsx │ │ ├── css │ │ │ └── components.css │ │ └── redux_ext.js │ ├── css │ │ ├── layout.css │ │ └── xml.css │ ├── datatable │ │ ├── default_options.jsx │ │ ├── dtypes.js │ │ ├── index.jsx │ │ ├── presenters │ │ │ ├── cells │ │ │ │ ├── band_cell.jsx │ │ │ │ ├── boolean_cell.jsx │ │ │ │ ├── default_cell.jsx │ │ │ │ ├── index.js │ │ │ │ ├── templated_cell.jsx │ │ │ │ └── yes_no.jsx │ │ │ ├── css │ │ │ │ └── datatable.css │ │ │ ├── default_footer.jsx │ │ │ ├── default_header.jsx │ │ │ ├── default_layout.jsx │ │ │ ├── default_row.jsx │ │ │ ├── table.jsx │ │ │ ├── transforms.jsx │ │ │ ├── transforms │ │ │ │ ├── nans.jsx │ │ │ │ ├── quantile_range.jsx │ │ │ │ ├── sample.jsx │ │ │ │ ├── search.jsx │ │ │ │ └── sort.jsx │ │ │ └── util.jsx │ │ └── state.js │ ├── doctable_panel.jsx │ ├── layout.jsx │ ├── pipeline.jsx │ ├── pipeline_panel.jsx │ ├── sessions_panel.jsx │ ├── tree │ │ ├── collapsible_tree.jsx │ │ ├── css │ │ │ └── treeview.css │ │ ├── state.js │ │ ├── tagged_tree.jsx │ │ ├── toolbar.jsx │ │ └── tree_operations.jsx │ ├── view.jsx │ ├── view_state.js │ ├── viewer_panel.jsx │ └── viewers │ │ ├── doctree.jsx │ │ ├── html.jsx │ │ ├── json.jsx │ │ ├── pdf.jsx │ │ ├── raw.jsx │ │ └── xml.jsx ├── test │ ├── common │ │ └── components_test.js │ ├── datatable │ │ ├── dtypes_test.js │ │ └── presenters │ │ │ ├── cells │ │ │ ├── boolean_cell_test.js │ │ │ └── yes_no_test.js │ │ │ ├── default_row_test.js │ │ │ └── util_test.js │ └── viewers │ │ ├── raw_test.js │ │ └── xml_test.js ├── webpack.config.babel.js └── webpack.legacy.config.babel.js ├── pynorama.png ├── pynorama ├── __init__.py ├── exceptions.py ├── logging.py ├── make_config.py ├── make_server.py ├── sessions │ ├── __init__.py │ ├── base_store.py │ ├── json_file.py │ ├── memory.py │ └── mongo.py ├── table │ ├── __init__.py │ ├── base_table.py │ ├── mongo_table.py │ └── pandas_table.py ├── templates │ ├── index.html │ └── pynorama_view.html └── view.py ├── pytest.ini ├── setup.cfg ├── setup.py └── tests ├── __init__.py └── unit ├── __init__.py ├── fixtures └── sessions.json ├── sessions ├── __init__.py ├── test_base_store.py ├── test_json_file.py ├── test_memory.py └── test_mongo.py ├── table ├── __init__.py └── test_pandas_table.py ├── test_make_config.py ├── test_pynorama.py └── test_view.py /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .pydevproject 3 | .project 4 | .idea/ 5 | .ipynb_checkpoints/ 6 | .venv 7 | .pyc 8 | bin/ 9 | */static/*.js 10 | */static/*.js.map 11 | build/ 12 | dist/ 13 | *.egg-info/ 14 | docs/source/pynorama*.rst 15 | docs/source/CHANGES.rst 16 | docs/source/README.rst 17 | docs/source/modules.rst 18 | .eggs/ 19 | .cache/ 20 | __pycache__/ 21 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | 3 | ### 1.0.0 4 | 5 | * Initial public release 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include pynorama/templates/*.html 2 | recursive-include pynorama/static *.js 3 | include README.md 4 | include CHANGES.md 5 | include LICENSE 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Pynorama 2 | Pynorama is a tool for visualizing intricate datasets for which a simple table format is not suitable. It was created with Natural Language Processing applications in mind. 3 | 4 | ![pynorama example screenshot](pynorama.png) 5 | 6 | Pynorama lets you define *views* in **Python** that are rendered as interactive web applications, letting you browse, analyse and understand your data. 7 | 8 | Pynorama is **scalable and extensible.** 9 | Pynorama has a clean and simple architecture. 10 | It makes little assumptions about your data source or data format. 11 | Read in the [documentation](https://github.com/manahl/pynorama/blob/master/docs/source/walkthrough.ipynb) about developing extensions. 12 | 13 | ## Quickstart 14 | 15 | ### Install Pynorama 16 | 17 | For a minimal install run: 18 | ``` 19 | pip install pynorama 20 | ``` 21 | 22 | ### Using Pynorama 23 | 24 | To create a *view*: 25 | * define a table describing your data records. Currently supported sources are pandas.DataFrame and MongoDB queries. 26 | * define different stages of your data pipeline. 27 | * return a particular records for a given stage. 28 | * configure the UI 29 | 30 | In Python this would look similar to this: 31 | ```python 32 | from pynorama import View 33 | from pynorama.table import PandasTable 34 | 35 | class ExampleView(View): 36 | def __init__(self, name, description=''): 37 | super(ExampleView, self).__init__(name, description) 38 | setup_data() 39 | 40 | def get_pipeline(self): 41 | return { 42 | 'raw_stage': {'viewer': 'raw'}, 43 | 'tokenized': {'viewer': 'json', 'parents': ['raw_stage']} 44 | } 45 | 46 | def get_record(self, key, stage): 47 | if stage == 'raw_stage': 48 | return get_html(key) 49 | else: 50 | return get_processed_data(key) 51 | 52 | def get_table(self): 53 | return PandasTable(get_dataframe()) 54 | ``` 55 | 56 | Next, register the view with pynorama: 57 | ```python 58 | from pynorama import register_view 59 | 60 | register_view(ExampleView('example')) 61 | ``` 62 | 63 | Finally, let Pynorama set up a *Flask* server for you and start it: 64 | ```python 65 | from pynorama import make_server 66 | 67 | app = make_server() 68 | app.run(host='localhost', port='5000') 69 | ``` 70 | 71 | Now just run your Python script! The view should be accessible at *http://localhost:5000/view/example*. 72 | 73 | For more information check the [examples](examples) and the [documentation](https://github.com/manahl/pynorama/blob/master/docs/source/walkthrough.ipynb)! 74 | 75 | ## Acknowledgements 76 | 77 | Pynorama was developed at [Man AHL](http://www.ahl.com/). 78 | 79 | Original concept and implementation: [Alexander Wettig](https://github.com/CodeCreator) 80 | 81 | Contributors from AHL Tech team: 82 | 83 | * [Slavi Marinov](https://github.com/slavi) 84 | * [Nikolai Matiushev](https://github.com/egao1980) 85 | 86 | Contributions welcome! 87 | 88 | ## License 89 | 90 | Pynorama is licensed under the GNU LGPL v2.1. A copy of which is included in [LICENSE](LICENSE) 91 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | pre: 3 | - sudo apt-get update 4 | - wget https://github.com/jgm/pandoc/releases/download/2.0.5/pandoc-2.0.5-1-amd64.deb 5 | - sudo dpkg -i pandoc-2.0.5-1-amd64.deb 6 | 7 | environment: 8 | COVERALLS_REPO_TOKEN: TODO 9 | 10 | dependencies: 11 | override: 12 | - pip install -e .[docs] 13 | 14 | test: 15 | override: 16 | - python setup.py test 17 | 18 | post: 19 | - python setup.py build_sphinx 20 | - cp result.xml $CIRCLE_TEST_REPORTS 21 | - cp coverage.xml $CIRCLE_TEST_REPORTS 22 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Pynorama documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Dec 11 13:33:59 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | # import os 20 | # import sys 21 | # sys.path.insert(0, os.path.abspath('.')) 22 | 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | # 28 | # needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = ['sphinx.ext.autodoc', 34 | 'sphinx.ext.coverage', 35 | 'sphinx.ext.viewcode', 36 | 'sphinxcontrib.napoleon', 37 | 'nbsphinx'] 38 | 39 | # Add any paths that contain templates here, relative to this directory. 40 | templates_path = ['_templates'] 41 | 42 | # The suffix(es) of source filenames. 43 | # You can specify multiple suffix as a list of string: 44 | # 45 | # source_suffix = ['.rst', '.md'] 46 | source_suffix = '.rst' 47 | 48 | # The master toctree document. 49 | master_doc = 'index' 50 | 51 | # General information about the project. 52 | project = u'Pynorama' 53 | copyright = u'2017, Man AHL Technology' 54 | author = u'Man AHL Technology' 55 | 56 | # The version info for the project you're documenting, acts as replacement for 57 | # |version| and |release|, also used in various other places throughout the 58 | # built documents. 59 | # 60 | # The short X.Y version. 61 | version = u'0.1.0' 62 | # The full version, including alpha/beta/rc tags. 63 | release = u'0.1.0' 64 | 65 | # The language for content autogenerated by Sphinx. Refer to documentation 66 | # for a list of supported languages. 67 | # 68 | # This is also used if you do content translation via gettext catalogs. 69 | # Usually you set "language" from the command line for these cases. 70 | language = None 71 | 72 | # List of patterns, relative to source directory, that match files and 73 | # directories to ignore when looking for source files. 74 | # This patterns also effect to html_static_path and html_extra_path 75 | exclude_patterns = ['docs'] 76 | 77 | # The name of the Pygments (syntax highlighting) style to use. 78 | pygments_style = 'sphinx' 79 | 80 | # If true, `todo` and `todoList` produce output, else they produce nothing. 81 | todo_include_todos = False 82 | 83 | 84 | # -- Options for HTML output ---------------------------------------------- 85 | 86 | # The theme to use for HTML and HTML Help pages. See the documentation for 87 | # a list of builtin themes. 88 | # 89 | html_theme = 'nature' 90 | 91 | # Theme options are theme-specific and customize the look and feel of a theme 92 | # further. For a list of options available for each theme, see the 93 | # documentation. 94 | # 95 | # html_theme_options = {} 96 | 97 | # Add any paths that contain custom static files (such as style sheets) here, 98 | # relative to this directory. They are copied after the builtin static files, 99 | # so a file named "default.css" will overwrite the builtin "default.css". 100 | html_static_path = ['_static'] 101 | 102 | # Custom sidebar templates, must be a dictionary that maps document names 103 | # to template names. 104 | # 105 | # This is required for the alabaster theme 106 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 107 | html_sidebars = { 108 | '**': [ 109 | 'relations.html', # needs 'show_related': True theme option to display 110 | 'searchbox.html', 111 | ] 112 | } 113 | 114 | 115 | # -- Options for HTMLHelp output ------------------------------------------ 116 | 117 | # Output file base name for HTML help builder. 118 | htmlhelp_basename = 'Pynoramadoc' 119 | 120 | 121 | # -- Options for LaTeX output --------------------------------------------- 122 | 123 | latex_elements = { 124 | # The paper size ('letterpaper' or 'a4paper'). 125 | # 126 | # 'papersize': 'letterpaper', 127 | 128 | # The font size ('10pt', '11pt' or '12pt'). 129 | # 130 | # 'pointsize': '10pt', 131 | 132 | # Additional stuff for the LaTeX preamble. 133 | # 134 | # 'preamble': '', 135 | 136 | # Latex figure (float) alignment 137 | # 138 | # 'figure_align': 'htbp', 139 | } 140 | 141 | # Grouping the document tree into LaTeX files. List of tuples 142 | # (source start file, target name, title, 143 | # author, documentclass [howto, manual, or own class]). 144 | latex_documents = [ 145 | (master_doc, 'Pynorama.tex', u'Pynorama Documentation', 146 | u'Man AHL Technology', 'manual'), 147 | ] 148 | 149 | 150 | # -- Options for manual page output --------------------------------------- 151 | 152 | # One entry per manual page. List of tuples 153 | # (source start file, name, description, authors, manual section). 154 | man_pages = [ 155 | (master_doc, 'pynorama', u'Pynorama Documentation', 156 | [author], 1) 157 | ] 158 | 159 | 160 | # -- Options for Texinfo output ------------------------------------------- 161 | 162 | # Grouping the document tree into Texinfo files. List of tuples 163 | # (source start file, target name, title, author, 164 | # dir menu entry, description, category) 165 | texinfo_documents = [ 166 | (master_doc, 'Pynorama', u'Pynorama Documentation', 167 | author, 'Pynorama', 'One line description of project.', 168 | 'Miscellaneous'), 169 | ] 170 | 171 | import sys 172 | import os 173 | sys.path.insert(0, os.path.abspath('../..')) 174 | 175 | import pypandoc 176 | long_description = pypandoc.convert('../../README.md', 'rst') 177 | changelog = pypandoc.convert('../../CHANGES.md', 'rst') 178 | 179 | with open("README.rst", "w") as text_file: 180 | text_file.write(long_description) 181 | 182 | with open("CHANGES.rst", "w") as text_file: 183 | text_file.write(changelog) 184 | 185 | # Create module documentation from docstrings 186 | import sphinx.apidoc as autodoc 187 | autodoc.main(['', '-o', '../../docs/source', '../../pynorama']) 188 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to Pynorama's documentation! 2 | ==================================== 3 | 4 | .. include:: README.rst 5 | 6 | Walkthrough 7 | ----------- 8 | 9 | .. toctree:: 10 | :maxdepth: 3 11 | 12 | walkthrough.ipynb 13 | 14 | Changelog 15 | --------- 16 | 17 | .. include:: CHANGELOG.rst 18 | :literal: 19 | 20 | Indices and tables 21 | ================== 22 | 23 | * :ref:`genindex` 24 | * :ref:`modindex` 25 | * :ref:`search` 26 | -------------------------------------------------------------------------------- /docs/source/requirements.txt: -------------------------------------------------------------------------------- 1 | nbsphinx 2 | nbsphinx 3 | sphinxcontrib-napoleon 4 | -------------------------------------------------------------------------------- /docs/source/walkthrough.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "## Using Pynorama" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "### Defining a view" 15 | ] 16 | }, 17 | { 18 | "cell_type": "markdown", 19 | "metadata": {}, 20 | "source": [ 21 | "To create a new pynorama view, we first have to derive from the View base class.\n", 22 | "Let's have a look at an example from the examples folder:" 23 | ] 24 | }, 25 | { 26 | "cell_type": "code", 27 | "execution_count": 1, 28 | "metadata": {}, 29 | "outputs": [], 30 | "source": [ 31 | "import pandas as pd\n", 32 | "from nltk.corpus import reuters\n", 33 | "from nltk import sent_tokenize, word_tokenize\n", 34 | "\n", 35 | "from pynorama import View, make_config\n", 36 | "from pynorama.table import PandasTable\n", 37 | "from pynorama.logging import logger\n", 38 | "from pynorama.exceptions import RecordNotFound\n", 39 | "\n", 40 | "class ReutersView(View):\n", 41 | " def __init__(self):\n", 42 | " super(ReutersView, self).__init__(\n", 43 | " name='reuters',\n", 44 | " description='nltk\\'s reuters corpus')\n", 45 | "\n", 46 | " def load(self):\n", 47 | " logger.info('Starting processing reuters dataset.')\n", 48 | " self.df = pd.DataFrame([{\n", 49 | " 'id': id,\n", 50 | " 'abspath': str(reuters.abspath(id)),\n", 51 | " 'categories': [c+' ' for c in reuters.categories(id)],\n", 52 | " 'headline': reuters.raw(id).split('\\n', 1)[0],\n", 53 | " 'length': len(reuters.raw(id))\n", 54 | " } for id in reuters.fileids()])\n", 55 | " logger.info('Finishing processing reuters dataset.')\n", 56 | "\n", 57 | " def get_table(self):\n", 58 | " return PandasTable(self.df)\n", 59 | "\n", 60 | " def get_pipeline(self):\n", 61 | " return {\n", 62 | " 'raw': { 'viewer': 'raw'},\n", 63 | " 'doctree': {'parents': ['raw'],\n", 64 | " 'viewer': 'doctree'}\n", 65 | " }\n", 66 | "\n", 67 | " def get_record(self, key, stage):\n", 68 | " rawdoc = reuters.raw(key)\n", 69 | " if stage == 'raw':\n", 70 | " return rawdoc\n", 71 | " if stage == 'doctree':\n", 72 | " return [word_tokenize(sent) for sent in sent_tokenize(rawdoc)]\n", 73 | " raise RecordNotFound(key, stage);\n", 74 | "\n", 75 | "\n", 76 | " def get_config(self):\n", 77 | " return make_config('id',\n", 78 | " available_transforms=[\"nans\", \"search\", \"quantile_range\"],\n", 79 | " initial_visible_columns=[\"id\"])\n" 80 | ] 81 | }, 82 | { 83 | "cell_type": "markdown", 84 | "metadata": {}, 85 | "source": [ 86 | "Let's step through each of the methods. [TODO: with screenshots]\n", 87 | "\n", 88 | "* `__init__` is called when you initialise your dataset and can be used for once-only initializations and assignments.\n", 89 | "\n", 90 | "* `load` should be used to load resources. It is called once upon registration and every time the *reload* button in the top left corner is clicked.\n", 91 | " \n", 92 | "* `get_table` is responsible for the contents of the table. PandasTable is a subclass of `pynorama.table.Table` and provides functionality to transform (i.e. filter) the table based on user actions. Pynorama comes with out-of-the-box for pandas DataFrames and Mongo DB collections as table. A different set of filters is available for both. The table is requested every time a user edited a filter or changed a page in the table.\n", 93 | " \n", 94 | "* `get_pipeline` defines the different stages of your pipeline, which are later rendered as a graph. You return a dictionary of stages and their coniguration, see available options [in this section](#Pipeline-definition). This function is called upon loading of the HTML and upon reload of the view.\n", 95 | "\n", 96 | "* `get_record` returns the content that will be displayed by the chosen viewer for the selected stage. Viewers expect data to be in a certain format. This function is called when a user has selected a document and a stage.\n", 97 | "\n", 98 | "* `get_config` is useful adapting the user interface in some cases without having to write Javascript. The function has to return a nested dict that is then converted to JSON and given to the user interface. `make_config` is a util function that creates this unhandy nested dictionary for some parameters. See [below](#Configuring-the-user-interface) for more information about the config. This function is called upon loading the HTML of this view. " 99 | ] 100 | }, 101 | { 102 | "cell_type": "markdown", 103 | "metadata": {}, 104 | "source": [ 105 | "### Pipeline definition" 106 | ] 107 | }, 108 | { 109 | "cell_type": "markdown", 110 | "metadata": {}, 111 | "source": [ 112 | "`get_pipeline` expects a dictionary defining the different stages of your pipeline, which are later rendered as a graph. For each stage name as key, the value is another dictionary with configuration options for that stage. The following options are available:\n", 113 | "* parents: an array of the stages that acted as input to the current stage. This will create visual connections in the graph.\n", 114 | "* viewer: the front-end viewer that should be used to display a record of this stage. See [below](#Viewers) for more information about viewers.\n", 115 | "* parameters for the selected viewer depending on the type of viewer chosen\n", 116 | "* color: the background color for the node in the pipeline graph\n", 117 | "* TODO: more options\n", 118 | " " 119 | ] 120 | }, 121 | { 122 | "cell_type": "markdown", 123 | "metadata": {}, 124 | "source": [ 125 | "### Viewers" 126 | ] 127 | }, 128 | { 129 | "cell_type": "markdown", 130 | "metadata": {}, 131 | "source": [ 132 | "Pynorama comes with the following viewers out-of-the-box, each expecting a certain input format and some requiring additional parameters: [TODO: screenshot]\n", 133 | "\n", 134 | "* `json`: A json object inspector of the JSON-serialized record returned by `get_record`. If no viewer was given `json` is assumed.\n", 135 | "* `pdf`: TODO\n", 136 | "* `doctree`: Renders a nested tree of words.\n", 137 | "* `xml`: Renders an interactive tree of an xml document that was returned by `get_record` as a string.\n", 138 | "* `raw`: Renders a string representation of the record, while preserving whitespace and line-breaks. " 139 | ] 140 | }, 141 | { 142 | "cell_type": "markdown", 143 | "metadata": {}, 144 | "source": [ 145 | "### Configuring the user interface" 146 | ] 147 | }, 148 | { 149 | "cell_type": "markdown", 150 | "metadata": {}, 151 | "source": [ 152 | "### Defining a session store" 153 | ] 154 | }, 155 | { 156 | "cell_type": "markdown", 157 | "metadata": {}, 158 | "source": [ 159 | "TODO: screenshot\n", 160 | "\n", 161 | "Users can store the state of the user interface in a view at any given point in sessions. Storing these is the responsibility of the session store. Pyonrama comes with out-of-the-box support for:\n", 162 | "\n", 163 | "* Transient storage (`InMemorySessionStore`)\n", 164 | "* JSON files (`JsonFileSessionStore`)\n", 165 | "* Mongo DB Collection (`MongoSessionStore`)\n", 166 | "\n", 167 | "By default, Pynorama uses the `InMemorySessionStore`, which requires no configuration. Hence, **sessions are lost** after the server is stopped. **To store sessions permanently**, supply a session store as the first argument to `make_server`.\n", 168 | "\n", 169 | "To define your own sessions store, inherit SessionStore and override `save_sessions` and `load_sessions`. You don't have worry about caching sessions in memory, as that is the SessionStore's responsibility. Have a look at the source code of the other stores to see how they work." 170 | ] 171 | }, 172 | { 173 | "cell_type": "markdown", 174 | "metadata": {}, 175 | "source": [ 176 | "### Deployment" 177 | ] 178 | }, 179 | { 180 | "cell_type": "markdown", 181 | "metadata": {}, 182 | "source": [ 183 | "For development, you can simply execute the python files or alternatively use the `flask run` command ([see here](http://flask.pocoo.org/docs/latest/quickstart/)). Since `make_server` just returns the flask application object, you can use all of flask's deployment options ([see here](http://flask.pocoo.org/docs/latest/deploying/))." 184 | ] 185 | }, 186 | { 187 | "cell_type": "markdown", 188 | "metadata": {}, 189 | "source": [ 190 | "### Examples" 191 | ] 192 | }, 193 | { 194 | "cell_type": "markdown", 195 | "metadata": {}, 196 | "source": [ 197 | "The examples folder is a great way to explore the possibilities of pynorama.\n", 198 | "Note that the examples have extra dependencies on:\n", 199 | "\n", 200 | "* nltk (in particular the *reuters* corpus has to be installed using `nltk.download()`)\n", 201 | "\n", 202 | "The entry point for the examples application is *server.py*. The following views are available:\n", 203 | "\n", 204 | "* reuters: example demonstrating visualization of a corpus of news data." 205 | ] 206 | }, 207 | { 208 | "cell_type": "markdown", 209 | "metadata": {}, 210 | "source": [ 211 | "## Extending Pynorama" 212 | ] 213 | }, 214 | { 215 | "cell_type": "markdown", 216 | "metadata": {}, 217 | "source": [ 218 | "### Setting up the Javascript development environment\n", 219 | "\n", 220 | "The Pynorama front-end is a [React](https://reactjs.org/) project and uses [Webpack](https://webpack.js.org/) to transpile and bundle the javascript. When you install python using pip only bundles and no source files are included. To develop the front-end code, you have to:\n", 221 | "\n", 222 | "* clone the git repo using `git clone https://github.com/manahl/pynorama`.\n", 223 | "* go to the pynorama root folder using `cd pynorama`.\n", 224 | "* execute `pip install -e .` to install pynorama in developer mode\n", 225 | "* go to the pynorama-js folder using `cd pynorama-js`.\n", 226 | "* execute `npm install` ([node_js](https://nodejs.org/en/) is required) to install depedencies.\n", 227 | "\n", 228 | "Now you can develop the front-end code.\n", 229 | "\n", 230 | "To debug, run `debug.sh [PORT]` in `pynorama-js`, which starts a local webpack-dev-server at the given port, transpiling the webpack bundles as you change the source files. Start your Pynorama server the usual way, but when you open your view in the browser, add the parameter webpack_dev_port=[PORT] to your url, (e.g. http://localhost:5000/view/example/?webpack_dev_port=5001). To build the bundles, use `build.sh` in `pynorama-js`. For non-unix systems or special requirements, have a look inside `debug.sh` and `build.sh`." 231 | ] 232 | }, 233 | { 234 | "cell_type": "markdown", 235 | "metadata": {}, 236 | "source": [ 237 | "### Front-end project structure" 238 | ] 239 | }, 240 | { 241 | "cell_type": "markdown", 242 | "metadata": {}, 243 | "source": [ 244 | "TODO" 245 | ] 246 | }, 247 | { 248 | "cell_type": "markdown", 249 | "metadata": {}, 250 | "source": [ 251 | "### Table" 252 | ] 253 | }, 254 | { 255 | "cell_type": "markdown", 256 | "metadata": {}, 257 | "source": [ 258 | "TODO" 259 | ] 260 | }, 261 | { 262 | "cell_type": "code", 263 | "execution_count": null, 264 | "metadata": {}, 265 | "outputs": [], 266 | "source": [] 267 | } 268 | ], 269 | "metadata": { 270 | "kernelspec": { 271 | "display_name": "Python 2", 272 | "language": "python", 273 | "name": "python2" 274 | }, 275 | "language_info": { 276 | "codemirror_mode": { 277 | "name": "ipython", 278 | "version": 2 279 | }, 280 | "file_extension": ".py", 281 | "mimetype": "text/x-python", 282 | "name": "python", 283 | "nbconvert_exporter": "python", 284 | "pygments_lexer": "ipython2", 285 | "version": "2.7.11" 286 | } 287 | }, 288 | "nbformat": 4, 289 | "nbformat_minor": 2 290 | } 291 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/pynorama/4781a1277ba8f059113f8d178efc0993e3cd7454/examples/__init__.py -------------------------------------------------------------------------------- /examples/reuters.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from nltk.corpus import reuters 3 | from nltk import sent_tokenize, word_tokenize 4 | try: 5 | import spacy 6 | from spacy import displacy 7 | SPACY = True 8 | except ImportError: 9 | SPACY = False 10 | 11 | from pynorama import View, make_config 12 | from pynorama.table import PandasTable 13 | from pynorama.logging import logger 14 | from pynorama.exceptions import RecordNotFound 15 | 16 | 17 | class ReutersView(View): 18 | """ 19 | Example demonstrating visualization of a set of news data. 20 | 21 | Note: you have to have nltk and the nltk reuters corpus installed. 22 | """ 23 | def __init__(self): 24 | super(ReutersView, self).__init__( 25 | name='reuters', 26 | description='nltk\'s reuters corpus') 27 | 28 | def load(self): 29 | logger.info('Starting processing reuters dataset.') 30 | self.df = pd.DataFrame([{ 31 | 'doc_id': doc_id, 32 | 'abspath': str(reuters.abspath(doc_id)), 33 | 'categories': [c + ' ' for c in reuters.categories(doc_id)], 34 | 'headline': reuters.raw(doc_id).split('\n', 1)[0], 35 | 'length': len(reuters.raw(doc_id)) 36 | } for doc_id in reuters.fileids()]) 37 | logger.info('Finishing processing reuters dataset.') 38 | if SPACY: 39 | self.nlp = spacy.load('en_core_web_sm') 40 | 41 | def get_table(self): 42 | return PandasTable(self.df) 43 | 44 | def get_pipeline(self): 45 | pipeline = { 46 | 'raw': {'viewer': 'raw'}, 47 | 'doctree': {'parents': ['raw'], 48 | 'viewer': 'doctree'} 49 | } 50 | if SPACY: 51 | pipeline['displacy']= {'parents': ['raw'], 52 | 'viewer': 'html'} 53 | return pipeline 54 | 55 | def get_record(self, key, stage): 56 | rawdoc = reuters.raw(key) 57 | if stage == 'raw': 58 | return rawdoc 59 | if stage == 'doctree': 60 | return [word_tokenize(sent) for sent in sent_tokenize(rawdoc)] 61 | if stage == 'displacy' and SPACY: 62 | doc = self.nlp(rawdoc) 63 | html = displacy.render(doc, style='ent', minify=True) 64 | return html 65 | raise RecordNotFound(key, stage) 66 | 67 | def get_config(self): 68 | return make_config('doc_id', 69 | available_transforms=['nans', 'search', 'quantile_range'], 70 | initial_visible_columns=['doc_id']) 71 | -------------------------------------------------------------------------------- /examples/server.py: -------------------------------------------------------------------------------- 1 | from pynorama import register_view, make_server 2 | from reuters import ReutersView 3 | 4 | 5 | if __name__ == '__main__': 6 | app = make_server() 7 | register_view(ReutersView()) 8 | app.run(host='0.0.0.0', port=5051, threaded=True) 9 | -------------------------------------------------------------------------------- /pynorama-js/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react", "flow", "stage-3"] 3 | } 4 | -------------------------------------------------------------------------------- /pynorama-js/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": [ 3 | "google", 4 | "plugin:flowtype/recommended", 5 | "plugin:react/recommended", 6 | "prettier", 7 | "prettier/flowtype", 8 | "prettier/react" 9 | ], 10 | "plugins": [ 11 | "flowtype", 12 | "react", 13 | "prettier" 14 | ], 15 | "parserOptions": { 16 | "sourceType": "module", 17 | "ecmaFeatures": { 18 | "jsx": true 19 | } 20 | }, 21 | "env": { 22 | "es6": true, 23 | "node": true 24 | }, 25 | "rules": { 26 | "prettier/prettier": "error", 27 | "max-len": ["error", {"code": 119, "ignoreUrls": true}], 28 | "no-confusing-arrow": "error", 29 | "no-tabs": "error", 30 | "curly": ["error", "all"] 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /pynorama-js/.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | 7 | [lints] 8 | 9 | [options] 10 | -------------------------------------------------------------------------------- /pynorama-js/__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /pynorama-js/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /pynorama-js/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd $(dirname $0) 4 | node_modules/webpack/bin/webpack.js --config webpack.legacy.config.babel.js 5 | node_modules/webpack/bin/webpack.js --config webpack.config.babel.js 6 | 7 | cd .. 8 | cp pynorama-js/bin/. pynorama/static/ -a -v 9 | -------------------------------------------------------------------------------- /pynorama-js/debug.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd $(dirname $0) 3 | 4 | if [ -z $1 ]; then 5 | echo "Please provide port of the webpack-dev-server as argument." 6 | echo "Usage: ./debug.sh [PORT]" 7 | exit 1 8 | fi 9 | 10 | #start webpack first to generate files for workbox 11 | node_modules/webpack/bin/webpack.js --config webpack.config.babel.js 12 | #start webpack dev 13 | node_modules/webpack-dev-server/bin/webpack-dev-server.js \ 14 | --host 0.0.0.0 --public --disable-host-check --port $1 \ 15 | --config webpack.config.babel.js 16 | -------------------------------------------------------------------------------- /pynorama-js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pynorama-js", 3 | "version": "1.0.0", 4 | "description": "Natural Language Processing Visualization Tool", 5 | "license": "LGPL-2.1", 6 | "dependencies": { 7 | "bootstrap": "^3.3.7", 8 | "classnames": "^2.2.5", 9 | "dom-helpers": "^3.3.1", 10 | "lodash": "^4.17.4", 11 | "messageformat": "^1.1.0", 12 | "pdfobject": "^2.0.201604172", 13 | "rc-slider": "^8.6.1", 14 | "react": "^15.6.1", 15 | "react-bootstrap": "^0.31.2", 16 | "react-dom": "^15.6.1", 17 | "react-graph-vis": "^0.1.2", 18 | "react-object-inspector": "^0.2.1", 19 | "react-redux": "^5.0.5", 20 | "react-resize-aware": "^2.5.0", 21 | "react-select": "^1.0.0-rc.5", 22 | "redux": "^3.7.2", 23 | "sanitize-html": "^1.15.0", 24 | "url-search-params": "^0.9.0", 25 | "uuid": "^3.2.1", 26 | "vis": "^4.21.0", 27 | "warning": "^3.0.0" 28 | }, 29 | "devDependencies": { 30 | "babel-core": "^6.25.0", 31 | "babel-eslint": "^8.0.1", 32 | "babel-jest": "^22.4.3", 33 | "babel-loader": "^7.1.1", 34 | "babel-plugin-syntax-dynamic-import": "^6.18.0", 35 | "babel-plugin-transform-es2015-parameters": "^6.24.1", 36 | "babel-plugin-transform-imports": "^1.4.1", 37 | "babel-plugin-transform-object-rest-spread": "^6.23.0", 38 | "babel-preset-env": "^1.6.0", 39 | "babel-preset-es2015": "^6.24.1", 40 | "babel-preset-es2016": "^6.24.1", 41 | "babel-preset-flow": "^6.23.0", 42 | "babel-preset-react": "^6.24.1", 43 | "babel-preset-stage-3": "^6.24.1", 44 | "babel-register": "^6.26.0", 45 | "css-loader": "^0.28.4", 46 | "enzyme": "^3.3.0", 47 | "enzyme-adapter-react-15": "^1.0.5", 48 | "eslint": "^4.5.0", 49 | "eslint-config-airbnb": "^15.1.0", 50 | "eslint-config-google": "^0.9.1", 51 | "eslint-config-prettier": "^2.6.0", 52 | "eslint-config-react-tools": "^1.0.10", 53 | "eslint-plugin-flowtype": "^2.39.1", 54 | "eslint-plugin-import": "^2.7.0", 55 | "eslint-plugin-jsx-a11y": "^5.1.1", 56 | "eslint-plugin-prettier": "^2.3.1", 57 | "eslint-plugin-react": "^7.3.0", 58 | "file-loader": "^0.11.2", 59 | "flow": "^0.2.3", 60 | "jest": "^22.4.3", 61 | "name-all-modules-plugin": "^1.0.1", 62 | "prettier": "^1.6.1", 63 | "prettier-eslint": "^8.2.1", 64 | "react-test-renderer": "^15.6.1", 65 | "style-loader": "^0.18.2", 66 | "uglify-es": "^3.2.0", 67 | "uglifyjs-webpack-plugin": "^1.1.1", 68 | "url-loader": "^0.5.9", 69 | "webpack": "^3.0.0", 70 | "webpack-dev-server": "^2.9.3", 71 | "workbox-webpack-plugin": "^2.1.2" 72 | }, 73 | "browserslist": [ 74 | "> 1%", 75 | "last 2 versions", 76 | "Firefox ESR", 77 | "IE 10" 78 | ], 79 | "scripts": { 80 | "flow start": "flow start", 81 | "flow stop": "flow stop", 82 | "flow status": "flow status", 83 | "flow coverage": "flow coverage", 84 | "test": "jest", 85 | "test:watch": "npm test -- --watch" 86 | }, 87 | "jest": { 88 | "moduleDirectories": [ 89 | "node_modules", 90 | "src" 91 | ], 92 | "testMatch": [ 93 | "**/test/**/*_test.js?(x)" 94 | ], 95 | "moduleNameMapper": { 96 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": 97 | "/__mocks__/fileMock.js", 98 | "\\.(css|less)$": "/__mocks__/styleMock.js" 99 | } 100 | 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /pynorama-js/src/common/components.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Glyphicon } from "react-bootstrap"; 3 | import { findDOMNode } from "react-dom"; 4 | 5 | import "./css/components.css"; 6 | 7 | export const CloseableHeader = ({ 8 | children, 9 | onClose, 10 | moveButtons = null, 11 | onMoveNegative, 12 | onMovePositive 13 | }) => { 14 | return ( 15 |
16 |
{children}
17 |
18 | {moveButtons && ( 19 | 27 | )} 28 | 33 | {moveButtons && ( 34 | 42 | )} 43 |
44 |
45 | ); 46 | }; 47 | 48 | export class HorizontalResizer extends React.Component { 49 | constructor(props) { 50 | super(props); 51 | } 52 | 53 | render() { 54 | const { initialWidth, minWidth, maxWidth } = this.props; 55 | return ( 56 |
57 |
(this.contentRef = ref)} 65 | > 66 | {this.props.children} 67 |
68 |
(this.splitterRef = ref)} 71 | onMouseDown={e => this.mousedown(e)} 72 | /> 73 |
74 | ); 75 | } 76 | 77 | componentDidMount() { 78 | // Set fixed of width after content has taken up required size; allow some time for that. 79 | /* 80 | this.initialWidth || window.setTimeout(()=>{ 81 | if (this.contentRef) { 82 | let startWidth = 83 | parseInt(window.getComputedStyle(this.contentRef).width); 84 | this.contentRef.style.width = startWidth + 'px'; 85 | } 86 | }, 1000) 87 | */ 88 | } 89 | 90 | mousedown(e) { 91 | let startX = e.clientX; 92 | let sizeRef = this.contentRef; 93 | let startWidth = parseInt(window.getComputedStyle(sizeRef).width); 94 | let mouseup = function(e) { 95 | document.body.removeEventListener("mouseup", mouseup); 96 | document.body.removeEventListener("mousemove", mousemove); 97 | }; 98 | let mousemove = function(e) { 99 | sizeRef.style.width = startWidth + e.clientX - startX + "px"; 100 | }; 101 | 102 | document.body.addEventListener("mousemove", mousemove); 103 | document.body.addEventListener("mouseup", mouseup); 104 | } 105 | } 106 | 107 | export const VerticalScrollArea = function VerticalScrollArea({ children }) { 108 | return
{children}
; 109 | }; 110 | 111 | export const HorizontalStack = function HorizontalStack({ children }) { 112 | const wrappedChildren = React.Children.map( 113 | children, 114 | child => 115 | child.type == HorizontalStack.Item ? ( 116 | child 117 | ) : ( 118 | {child} 119 | ) 120 | ); 121 | return
{wrappedChildren}
; 122 | }; 123 | 124 | HorizontalStack.Item = ({ children, grow = false, shrink = false }) => { 125 | const style = { flexGrow: grow ? 1 : 0, flexShrink: shrink ? 1 : 0 }; 126 | return ( 127 |
128 | {children} 129 |
130 | ); 131 | }; 132 | 133 | export const HorizontalPanel = function HorizontalPanel({ children, header }) { 134 | return ( 135 |
136 | {header &&
{header}
} 137 |
{children}
138 |
139 | ); 140 | }; 141 | 142 | export const VisibilityToggle = class VisibilityToggle extends React.Component { 143 | constructor(props) { 144 | super(props); 145 | this.prevDisplay = ""; 146 | } 147 | 148 | render() { 149 | return this.props.children; 150 | } 151 | 152 | componentDidMount() { 153 | if (this.props.isVisible === false) { 154 | let e = findDOMNode(this); 155 | this.prevDisplay = e.style.display; 156 | e.style.display = "none"; 157 | } 158 | } 159 | 160 | componentDidUpdate() { 161 | let e = findDOMNode(this); 162 | if (this.props.isVisible === false) { 163 | this.prevDisplay = e.style.display; 164 | e.style.display = "none"; 165 | } else { 166 | e.style.display = this.prevDisplay; 167 | } 168 | } 169 | }; 170 | 171 | 172 | export const DivDummy = function DivDummy({children}) { 173 | return
{children}
; 174 | } 175 | -------------------------------------------------------------------------------- /pynorama-js/src/common/css/components.css: -------------------------------------------------------------------------------- 1 | .closeableheader-container { 2 | width: 100%; 3 | height: auto; 4 | 5 | display: flex; 6 | flex-direction: row; 7 | flex-wrap: nowrap; 8 | justify-content: flex-start; 9 | align-items: center; 10 | align-content: flex-start; 11 | } 12 | 13 | .closeableheader-content { 14 | width: 100%; 15 | overflow: hidden; 16 | } 17 | 18 | .closeableheader-buttons { 19 | flex-shrink: 0; 20 | } 21 | 22 | .closeableheader-button { 23 | cursor: pointer; 24 | margin: 3px; 25 | } 26 | 27 | .hresizer-container { 28 | height: 100%; 29 | display: flex; 30 | 31 | vertical-align: top; 32 | flex-direction: row; 33 | flex-wrap: nowrap; 34 | justify-content: flex-start; 35 | align-items: stretch; 36 | align-content: flex-start; 37 | } 38 | 39 | .hresizer-content { 40 | width: auto; 41 | height: 100%; 42 | } 43 | 44 | .hresizer-splitter { 45 | width: 3px; 46 | height: 100%; 47 | cursor: col-resize; 48 | background: lightgray; 49 | } 50 | 51 | .hresizer-splitter:hover { 52 | background: gray; 53 | } 54 | 55 | .hpanel-container { 56 | width: auto; 57 | height: 100%; 58 | display: flex; 59 | flex-direction: column; 60 | flex-wrap: nowrap; 61 | justify-content: flex-start; 62 | align-items: stretch; 63 | align-content: flex-start; 64 | } 65 | 66 | .hpanel-content { 67 | width: 100%; 68 | height: 100%; 69 | overflow-y: hidden; 70 | } 71 | 72 | .hpanel-header { 73 | width: 100%; 74 | overflow: hidden; 75 | flex-shrink: 0; 76 | } 77 | 78 | .hstack { 79 | height: 100%; 80 | min-height: 100%; 81 | display: flex; 82 | flex-direction: row; 83 | flex-wrap: nowrap; 84 | justify-content: flex-start; 85 | align-items: stretch; 86 | align-content: flex-start; 87 | } 88 | 89 | .hstack-item { 90 | 91 | } 92 | 93 | .vertical-scroll-area { 94 | overflow-y: auto; 95 | height: 100%; 96 | } 97 | 98 | .vertical-scroll-area > * { 99 | height: auto; 100 | } 101 | -------------------------------------------------------------------------------- /pynorama-js/src/common/redux_ext.js: -------------------------------------------------------------------------------- 1 | /* 2 | redux_ext is a layer of abstraction on top of the redux library, 3 | that allows for a nicer definition of reducers, actions and getters 4 | and thus providers better separation of state logic and presentational components. 5 | 6 | All classes defined in redux_ext are immutable and return new instances, 7 | if properties are altered with methods. 8 | */ 9 | import { 10 | createStore as reduxCreateStore, 11 | applyMiddleware, 12 | compose 13 | } from "redux"; 14 | import { connect as reduxConnect } from "react-redux"; 15 | import _ from "lodash"; 16 | 17 | /* 18 | global objects that maps action types to reducer functions 19 | */ 20 | const reducers = {}; 21 | 22 | /* 23 | not re-instantiating objects and arrays for immutable objects 24 | saves some performance 25 | */ 26 | export const EMPTY_ARR = []; 27 | export const EMPTY_OBJ = {}; 28 | 29 | /* 30 | This function replaces react-redux's connect. 31 | Instead of taking two functions mapState2Props and mapDispatch2Props, 32 | it accepts a single mapping, which should be a mapping of getter and setter objects. 33 | It will then create the mapState2Props and mapDispatch2Props and will return the 34 | configured redux connect function. 35 | 36 | The mapping can be a 37 | - mapping of 38 | - getters or setters or 39 | - functions returning a getter or a setter 40 | - or a function returning such a mapping 41 | */ 42 | export const connect = function(mapping) { 43 | const state2props = (state, ownProps) => { 44 | let localMapping = mapping; 45 | if (typeof localMapping == "function") { 46 | localMapping = localMapping(ownProps); 47 | } 48 | let res = {}; 49 | for (let key in localMapping) { 50 | let localRes = localMapping[key]; 51 | if (typeof localRes == "function") { 52 | localRes = localRes(ownProps); 53 | } 54 | if (localRes instanceof Getter) { 55 | res[key] = localRes.get(state); 56 | } 57 | } 58 | return res; 59 | }; 60 | const dispatch2props = (dispatch, ownProps) => { 61 | let localMapping = mapping; 62 | if (typeof localMapping == "function") { 63 | localMapping = localMapping(ownProps); 64 | } 65 | let res = {}; 66 | for (let key in localMapping) { 67 | let localRes = localMapping[key]; 68 | if (typeof localRes == "function") { 69 | localRes = localRes(ownProps); 70 | } 71 | if (localRes instanceof Action) { 72 | let action = localRes; 73 | res[key] = (...args) => dispatch(action.get(action.mapping(...args))); 74 | } 75 | } 76 | return res; 77 | }; 78 | return reduxConnect(state2props, dispatch2props); 79 | }; 80 | 81 | /* 82 | Will create getters out of an object, which can be either 83 | - an object of named functions 84 | - a list of named functions 85 | - a ReduxExt Mapping or Getter or Setter 86 | - a named function 87 | - a anonymous function when the second parameter that names the type of the getter 88 | 89 | The returned object has the same structure as the given object, 90 | i.e. will return an object for an object, 91 | an array for an array, 92 | a single getter for a single getter. 93 | */ 94 | export function makeGetters(mapping, type) { 95 | if (_.isFunction(mapping)) { 96 | return new Getter(mapping, type); 97 | } else if ( 98 | mapping instanceof ReduxExtImmutable || 99 | mapping instanceof Mapping 100 | ) { 101 | return mapping; 102 | } else if (_.isArray(mapping)) { 103 | return new _.map(mapping, value => makeGetters(value)); 104 | } else if (_.isPlainObject(mapping)) { 105 | return new Mapping( 106 | _.mapValues(mapping, (value, key) => makeGetters(value, key)) 107 | ); 108 | } 109 | throw new Error("Unexpected type. Can't create getters."); 110 | } 111 | 112 | /* 113 | Similar to makeGetters, 114 | only that it creates actions out of a mapping of reducer functions, 115 | rather than getters out of getter functions. 116 | */ 117 | export function makeActions(mapping, type) { 118 | if (_.isFunction(mapping)) { 119 | return new Action(mapping, type); 120 | } else if ( 121 | mapping instanceof ReduxExtImmutable || 122 | mapping instanceof Mapping 123 | ) { 124 | return mapping; 125 | } else if (_.isArray(mapping)) { 126 | return new _.map(mapping, value => makeActions(value)); 127 | } else if (_.isPlainObject(mapping)) { 128 | return new Mapping( 129 | _.mapValues(mapping, (value, key) => makeActions(value, key)) 130 | ); 131 | } 132 | throw new Error("Unexpected type. Can't create getters."); 133 | } 134 | 135 | /* 136 | Base class of getters and setters 137 | that established the immutable nature of the Getter and Setter classes. 138 | Both share that they can be specialised with options and a scope. 139 | */ 140 | class ReduxExtImmutable { 141 | constructor() { 142 | this.options = EMPTY_OBJ; 143 | this.scope = EMPTY_ARR; 144 | } 145 | 146 | clone() { 147 | return _.clone(this); 148 | } 149 | 150 | withOptions(options) { 151 | const clone = this.clone(); 152 | clone.options = { ...this.options, ...options }; 153 | return clone; 154 | } 155 | 156 | withScope(scope, how = "replace") { 157 | const clone = this.clone(); 158 | clone.scope = extendScope(this.scope, scope, how); 159 | return clone; 160 | } 161 | } 162 | 163 | /* 164 | very useful function for altering scope or options of shallow object 165 | of getters&setters2props mapping (the first parameter of ReduxExt's connect), 166 | as subsequently withScope or withOptions can be called on the whole mapping. 167 | 168 | */ 169 | export function all(mapping) { 170 | return new Mapping(mapping); 171 | } 172 | 173 | /* 174 | provide functionality that withScope and withOptions can be applied to 175 | each element of an object (i.e. mapping) 176 | */ 177 | class Mapping { 178 | constructor(mapping) { 179 | _.forEach(mapping, (val, key) => { 180 | if (!(val instanceof ReduxExtImmutable || val instanceof Mapping)) { 181 | throw new Error(`ReduxExt property ${key} is invalid`); 182 | } 183 | }); 184 | Object.assign(this, mapping); 185 | } 186 | 187 | clone() { 188 | return _.clone(this); 189 | } 190 | 191 | withMappedProperties(func) { 192 | const clone = this.clone(); 193 | return Object.assign(clone, _.mapValues(this, func)); 194 | } 195 | 196 | withScope(scope, how = "replace") { 197 | return this.withMappedProperties(element => element.withScope(scope, how)); 198 | } 199 | 200 | withOptions(options) { 201 | return this.withMappedProperties(element => element.withOptions(options)); 202 | } 203 | } 204 | 205 | /* 206 | getters have to provide a get function, 207 | a pure function which maps (state, options, get) => property. 208 | 209 | The parameter 'state' can be scoped by the action using 'withScope' 210 | (i.e. the get function is not given the global state but only a part of the state). 211 | and 'options' can be configured by the action using 'withOptions'. 212 | */ 213 | export class Getter extends ReduxExtImmutable { 214 | constructor(getterFunction, type = null) { 215 | super(); 216 | 217 | this.type = type || getterFunction.name; 218 | 219 | if (!this.type) { 220 | throw Error("Please provide a named function for the getter."); 221 | } 222 | 223 | this.getterFunction = getterFunction; 224 | } 225 | 226 | get(state) { 227 | return this.getterFunction( 228 | applyScope(state, this.scope), 229 | this.options, 230 | getter => getter.get(state), 231 | this.scope 232 | ); 233 | } 234 | } 235 | 236 | const DEFAULT_MAPPING_FUNCTION = obj => obj; 237 | 238 | /* 239 | actions have to provide a reducer, 240 | a pure function which maps (state, options, get) => nextState or another action, 241 | whose reducer is then executed. 242 | 243 | The parameter 'state' can be scoped by the action using 'withScope' 244 | (i.e. the reducer is not given the global state but only a part of the state). 245 | and 'options' can be configured by the action using 'withOptions'. 246 | */ 247 | export class Action extends ReduxExtImmutable { 248 | constructor(reducer = null, type = null) { 249 | super(); 250 | 251 | if (reducer) { 252 | this.type = type || reducer.name; 253 | if (!this.type) { 254 | throw Error("Please provide a named function for the reducer."); 255 | } 256 | this.reducer(reducer); 257 | } else { 258 | this.type = type; 259 | if (!this.type) { 260 | throw Error("Please provide a type name for your action."); 261 | } 262 | } 263 | 264 | this.mapping = DEFAULT_MAPPING_FUNCTION; 265 | } 266 | 267 | get(options) { 268 | return { ...this.options, ...options, type: this.type, scope: this.scope }; 269 | } 270 | 271 | withMapping(mapping) { 272 | const clone = this.clone(); 273 | clone.mapping = mapping; 274 | return clone; 275 | } 276 | 277 | reducer(reducer) { 278 | reducers[this.type] = (state, action) => { 279 | let { type, scope, ...actionOptions } = action; 280 | 281 | let result = reducer( 282 | applyScope(state, scope), 283 | actionOptions, 284 | getter => getter.get(state), 285 | scope 286 | ); 287 | 288 | if (result instanceof Action) { 289 | return topLevelReducer(state, result.get()); 290 | } else { 291 | return reduceScope(state, result, scope); 292 | } 293 | }; 294 | } 295 | } 296 | 297 | /* 298 | scopes can be given as simple strings as parameters, 299 | but they should always be internally converted to arrays, 300 | so their format is consistent and they can be concatenated. 301 | */ 302 | function _toScopeArray(scope) { 303 | if (scope instanceof Array) { 304 | return scope; 305 | } 306 | if (!scope) { 307 | return EMPTY_ARR; 308 | } 309 | return [scope]; 310 | } 311 | 312 | /* 313 | merges scopes that can either be arrays of strings or a single string. 314 | 315 | examples: 316 | base | extension | how | result 317 | ["a"] | ["b","c"] | "push_front" | ["b", "c", "a"] 318 | "a" | ["b","c"] | "push_back" | ["a", "b", "c"] 319 | ["a"] | "b" | "replace" | ["b"] 320 | */ 321 | function extendScope(base, extension, how) { 322 | if (how !== "push_front" && how !== "push_back" && how !== "replace") { 323 | throw Error(`Parameter 'how' has to be either 'push_front', 'push_back' or 'replace' 324 | when extending scopes`); 325 | } 326 | 327 | if (how === "replace") { 328 | return _toScopeArray(extension); 329 | } 330 | const baseArr = _toScopeArray(how === "push_front" ? base : extension); 331 | const extensionArr = _toScopeArray(how === "push_back" ? base : extension); 332 | return [...baseArr, ...extensionArr]; 333 | } 334 | 335 | /* 336 | example: 337 | const a={ 338 | b: {d: 3}, 339 | c: {d: 4} 340 | } 341 | const x = applyScope(a, "b") 342 | -> x would be {d: 3} 343 | */ 344 | export function applyScope(state = {}, scope) { 345 | for (let i = 0; i < scope.length; ++i) { 346 | state = state[scope[i]] || EMPTY_OBJ; 347 | } 348 | return state; 349 | } 350 | 351 | /* 352 | example: 353 | const a={ 354 | b: {d: 3}, 355 | c: {d: 4} 356 | } 357 | const x = applyScope(a, {d: 5}, "b") 358 | -> x would be { 359 | b: {d: 5}. 360 | c: {d: 4} 361 | } 362 | */ 363 | export function reduceScope(state, result, scope) { 364 | if (scope.length > 0) { 365 | return { 366 | ...state, 367 | [scope[0]]: reduceScope( 368 | state[scope[0]] || EMPTY_OBJ, 369 | result, 370 | scope.slice(1) 371 | ) 372 | }; 373 | } else { 374 | return result; 375 | } 376 | } 377 | 378 | /* 379 | if user dispatches actions directly, e.g. 380 | store.dispatch(myAction), they will be properly executed 381 | */ 382 | function actionMiddleware({ dispatch, getState }) { 383 | return next => action => { 384 | if (action instanceof Action) { 385 | return dispatch(action.get()); 386 | } 387 | return next(action); 388 | }; 389 | } 390 | 391 | /* 392 | the overall reducer that looks for the appropriate reducer for that 393 | action type. 394 | */ 395 | function topLevelReducer(state, action) { 396 | if (reducers[action.type]) { 397 | return reducers[action.type](state, action); 398 | } 399 | // TODO: log this 400 | return state; 401 | } 402 | 403 | /* 404 | creates a store that with an initial state, 405 | that can work with ReduxExt's Actions. 406 | The store is by default visible in the Redux debug tools. 407 | */ 408 | export function createStore(initialState = {}) { 409 | // TODO: parameterize this 410 | const composeEnhancers = 411 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 412 | let store = reduxCreateStore( 413 | topLevelReducer, 414 | initialState, 415 | composeEnhancers(applyMiddleware(actionMiddleware)) 416 | ); 417 | 418 | store.get = function(getter) { 419 | return getter.get(store.getState()); 420 | }; 421 | return store; 422 | } 423 | -------------------------------------------------------------------------------- /pynorama-js/src/css/layout.css: -------------------------------------------------------------------------------- 1 | div, 2 | span { 3 | font-size: 13px; 4 | } 5 | 6 | html, 7 | body, 8 | #main-div { 9 | height: 100%; 10 | } 11 | 12 | body::-webkit-scrollbar, 13 | table::-webkit-scrollbar, 14 | div::-webkit-scrollbar { 15 | width: initial; 16 | height: initial; 17 | } 18 | 19 | body::-webkit-scrollbar-track, 20 | table::-webkit-scrollbar-track, 21 | div::-webkit-scrollbar-track { 22 | background-color: white; 23 | } 24 | 25 | body::-webkit-scrollbar-thumb, 26 | table::-webkit-scrollbar-thumb, 27 | div::-webkit-scrollbar-thumb { 28 | background-color: lightgray; 29 | border: white 5px solid; 30 | } 31 | 32 | 33 | .taskcolumn { 34 | width: 35px; 35 | } 36 | 37 | .whitespacecolumn { 38 | width: 100px; 39 | padding-right: 100px; 40 | } 41 | 42 | .fixedcolumn { 43 | position: fixed; 44 | z-index: 999; 45 | background-color: white; 46 | width: inherit; 47 | height: inherit; 48 | } 49 | 50 | .reload-spinning { 51 | animation: spin 4s linear infinite; 52 | } 53 | 54 | @keyframes spin { 55 | 100% { 56 | transform: rotate(360deg); 57 | } 58 | } 59 | 60 | .turnaround { 61 | width: 102vh; 62 | transform: rotate(-90deg) translate(-50vh, -51vh); 63 | transform-origin: top; 64 | } 65 | 66 | .panelheader { 67 | background: linear-gradient(#5bc0de, #6bc0de); 68 | font-weight: bold; 69 | color: white; 70 | padding: 5px; 71 | 72 | margin-bottom: 3px; 73 | box-shadow: 1px 1px 3px black; 74 | } 75 | 76 | .example-enter { 77 | opacity: 0.01; 78 | } 79 | 80 | .example-enter.example-enter-active { 81 | opacity: 1; 82 | transition: opacity 1500ms ease-in; 83 | } 84 | 85 | .example-leave { 86 | opacity: 1; 87 | } 88 | 89 | .example-leave.example-leave-active { 90 | opacity: 0.01; 91 | transition: opacity 1300ms ease-in; 92 | } 93 | 94 | .header-nowrap { 95 | white-space: nowrap; 96 | } 97 | -------------------------------------------------------------------------------- /pynorama-js/src/css/xml.css: -------------------------------------------------------------------------------- 1 | .tagName { 2 | color: rgb(200, 0, 133); 3 | } 4 | 5 | .tag { 6 | white-space: nowrap; 7 | } 8 | 9 | .attribute { 10 | margin-left: 3px; 11 | margin-right: 3px; 12 | } 13 | 14 | .attributeName { 15 | color: rgb(200, 30, 30); 16 | } 17 | 18 | .attributeEquals { 19 | color: rgb(20, 20, 20); 20 | } 21 | 22 | .attributeValue { 23 | color: rgb(50, 30, 150); 24 | } 25 | -------------------------------------------------------------------------------- /pynorama-js/src/datatable/default_options.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import DefaultHeader from "./presenters/default_header"; 4 | import DefaultRow from "./presenters/default_row"; 5 | import DefaultLayout from "./presenters/default_layout"; 6 | import DefaultFooter from "./presenters/default_footer"; 7 | import {default as resolve_renderer} from "./presenters/cells" 8 | 9 | // These are a guide to how the final options should look. 10 | // Note: not all of these are implemented at the moment, 11 | // especially the options for cells and headers. 12 | 13 | 14 | export default { 15 | layout: { 16 | component: DefaultLayout 17 | }, 18 | queryUrl: null, 19 | state: { 20 | preTransforms: [ 21 | // transforms that are fixed and can't be moved 22 | ], 23 | postTransforms: [ 24 | // transforms that are fixed and can't be moved 25 | ], 26 | initialPageLength: 15, 27 | initialVisibleColumns: [] 28 | }, 29 | transforms: { 30 | toolbox: { 31 | clearButton: true, 32 | available: [] 33 | } 34 | }, 35 | table: { 36 | showIndex: true, 37 | rows: { 38 | component: DefaultRow, 39 | hoverHighlight: true, 40 | clickableCursor: true, 41 | onClick: null 42 | }, 43 | headers: { 44 | renderers: [ 45 | (props, options, getNextRenderer) => ( 46 | 47 | ) 48 | ], 49 | all: { 50 | trim: 40 // TODO 51 | }, 52 | index: { 53 | // override 'all' 54 | displayName: "index" 55 | } 56 | }, 57 | cells: { 58 | renderers: [ 59 | resolve_renderer 60 | ], 61 | all: { 62 | roundFloats: 2, // TODO 63 | dateFormat: null // TODO 64 | } 65 | }, 66 | footer: { 67 | component: DefaultFooter, 68 | pageLengths: [15, 20, 50, 100, 150] // TODO 69 | } 70 | } 71 | }; 72 | -------------------------------------------------------------------------------- /pynorama-js/src/datatable/dtypes.js: -------------------------------------------------------------------------------- 1 | export function isNumber(type) { 2 | return type && (type.name.startsWith("float") || type.name.startsWith("int")); 3 | } 4 | 5 | export function isDateTime(type) { 6 | return type && type.name.startsWith("datetime64"); 7 | } 8 | 9 | export function npDateTime64ToISO(val) { 10 | return new Date(val).toISOString(); 11 | } 12 | -------------------------------------------------------------------------------- /pynorama-js/src/datatable/index.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | index contians the DataTableProvider, the core component, 3 | which given the transforms, offset and pageLength queries the server to retrieve the new dataTable 4 | that is subsequently displayed in the table 5 | */ 6 | 7 | import React from "react"; 8 | 9 | import _ from "lodash"; 10 | 11 | import { connect } from "common/redux_ext"; 12 | 13 | import { getters, actions } from "./state"; 14 | import Table from "./presenters/table"; 15 | import Transforms from "./presenters/transforms"; 16 | 17 | import DEFAULT_OPTIONS from "./default_options"; 18 | 19 | import "./presenters/css/datatable.css"; 20 | 21 | export default connect({ 22 | pageLength: getters.pageLength, 23 | offset: getters.offset, 24 | transforms: getters.transforms, 25 | 26 | setPageLength: actions.setPageLength, 27 | setPostTransforms: actions.setPostTransforms, 28 | setPreTransforms: actions.setPreTransforms, 29 | addVisibleColumns: actions.addVisibleColumns, 30 | setIsLoading: actions.setIsLoading.withMapping(value => ({ value })) 31 | }) 32 | ( 33 | class DataTableProvider extends React.Component { 34 | constructor(props) { 35 | super(props); 36 | // use loadingCounter to keep track of how many HTTP requests are open. 37 | // increment for every opened reqeust, decrement for every HTTP response. 38 | this.loadingCounter = 0; 39 | this.state = {}; 40 | this.updateOptions(); 41 | } 42 | 43 | componentDidMount() { 44 | // can only set state once component mounted 45 | this.updateData(this.props); 46 | } 47 | 48 | componentWillReceiveProps(nextProps) { 49 | if (!nextProps.isReloading) { 50 | this.updateData(nextProps); 51 | } 52 | if (nextProps.options !== this.props.options) { 53 | this.updateOptions(); 54 | } 55 | } 56 | 57 | shouldComponentUpdate(nextProps, nextState) { 58 | // to prevent unneccesary re-renders only update the entire table 59 | // if new data from the server has arrived, 60 | // otherwise child component will update by connecting to the redux store 61 | return this.state !== nextState; 62 | } 63 | 64 | updateOptions() { 65 | this.options = _.merge({}, DEFAULT_OPTIONS, this.props.options); 66 | 67 | // set the default state options in redux store on every app reload 68 | const stateOptions = this.options.state; 69 | this.props.setPageLength({ pageLength: stateOptions.initialPageLength }); 70 | this.props.setPreTransforms({ transforms: stateOptions.preTransforms }); 71 | this.props.setPostTransforms({ transforms: stateOptions.postTransforms }); 72 | this.props.addVisibleColumns({ 73 | columns: stateOptions.initialVisibleColumns 74 | }); 75 | } 76 | 77 | updateData({ transforms, offset, pageLength, setIsLoading }) { 78 | ++this.loadingCounter; 79 | setIsLoading(true); 80 | return fetch(this.options.queryUrl, { 81 | headers: { 82 | Accept: "application/json", 83 | "Content-Type": "application/json" 84 | }, 85 | method: "POST", 86 | body: JSON.stringify({ 87 | length: pageLength, 88 | offset, 89 | transforms 90 | }) 91 | }) 92 | .then(response => { 93 | if (!response.ok) { 94 | throw Error(response.statusText); 95 | } 96 | return response.json(); 97 | }) 98 | .then(data => { 99 | --this.loadingCounter; 100 | if (this.loadingCounter === 0) { 101 | setIsLoading(false); 102 | this.setState({ 103 | ...data 104 | }); 105 | } 106 | }) 107 | .catch(e => { 108 | --this.loadingCounter; 109 | if (this.loadingCounter === 0) { 110 | setIsLoading(false); 111 | this.setState({ 112 | error: e.toString() 113 | }); 114 | } 115 | throw e; 116 | }); 117 | } 118 | 119 | render() { 120 | if (_.isEmpty(this.state)) 121 | return null; 122 | 123 | const { component: Layout, ...layoutOptions } = this.options.layout; 124 | 125 | const table = ( 126 | 133 | ); 134 | const transforms = ( 135 | 141 | ); 142 | 143 | return ( 144 | 145 | ); 146 | } 147 | } 148 | ) 149 | -------------------------------------------------------------------------------- /pynorama-js/src/datatable/presenters/cells/band_cell.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { isNumber, isDateTime, npDateTime64ToISO } from "../../dtypes"; 4 | import _ from "lodash"; 5 | 6 | export default function BandCell({ column, dtype, value, options }) { 7 | let style = { 8 | textAlign: 9 | column === "index" || isNumber(dtype) || isDateTime(dtype) 10 | ? "right" 11 | : "left" 12 | }; 13 | 14 | let { bands } = options; 15 | if (bands) { 16 | let styles = bands.map(({ left, right, style }) => { 17 | if ( 18 | (_.isNil(left) || left < value) && 19 | (_.isNil(right) || value < right) 20 | ) { 21 | return style; 22 | } else { 23 | return {}; 24 | } 25 | }); 26 | style = _.merge(style, ...styles); 27 | } 28 | 29 | const renderedValue = isDateTime(dtype) ? npDateTime64ToISO(value) : value; 30 | 31 | return ( 32 |
33 | {renderedValue != null && renderedValue.toString()} 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /pynorama-js/src/datatable/presenters/cells/boolean_cell.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function BooleanCell({ column, dtype, value, options }) { 4 | const style = { 5 | textAlign: "right" 6 | }; 7 | 8 | let renderedValue = value ? value.toString().toLowerCase() : "no"; 9 | 10 | if (renderedValue === "no" || renderedValue === "false") { 11 | renderedValue = "False"; 12 | } else { 13 | renderedValue = "True"; 14 | } 15 | 16 | return ( 17 |
18 | {renderedValue} 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /pynorama-js/src/datatable/presenters/cells/default_cell.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import sanitizeHtml from "sanitize-html"; 3 | 4 | import { isNumber, isDateTime, npDateTime64ToISO } from "../../dtypes"; 5 | 6 | export default function DefaultCell({ column, dtype, value, options }) { 7 | const style = { 8 | textAlign: 9 | column === "index" || isNumber(dtype) || isDateTime(dtype) 10 | ? "right" 11 | : "left" 12 | }; 13 | 14 | const renderedValue = isDateTime(dtype) ? npDateTime64ToISO(value) : value; 15 | if ( 16 | renderedValue && 17 | (typeof renderedValue === "string" || renderedValue instanceof String) && 18 | renderedValue.startsWith("<") 19 | ) { 20 | let clean = sanitizeHtml(renderedValue, { 21 | allowedAttributes: Object.assign( 22 | {}, 23 | sanitizeHtml.defaults.allowedAttributes, 24 | { a: ["href", "rel", "target", "name"] } 25 | ) 26 | }); 27 | return ( 28 |
33 | ); 34 | } 35 | 36 | return ( 37 |
38 | {renderedValue != null && renderedValue.toString()} 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /pynorama-js/src/datatable/presenters/cells/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import DefaultCell from "./default_cell"; 3 | import BandCell from "./band_cell"; 4 | import YesNoCell from "./yes_no"; 5 | import BooleanCell from "./boolean_cell"; 6 | import TemplatedCell from "./templated_cell"; 7 | 8 | const cellRendererTypes = { 9 | yes_no: (props, options) => , 10 | boolean: (props, options) => , 11 | band: (props, options) => , 12 | template: (props, options) => , 13 | default: (props, options) => 14 | }; 15 | 16 | export default (props, options, getNextRenderer) => { 17 | const renderer_name = _.get(options, "renderer", "default"); 18 | const renderer = _.get(cellRendererTypes, renderer_name, cellRendererTypes["default"]); 19 | return renderer(props, options); 20 | }; 21 | -------------------------------------------------------------------------------- /pynorama-js/src/datatable/presenters/cells/templated_cell.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import MessageFormat from "messageformat"; 3 | import moment from "moment"; 4 | import sanitizeHtml from "sanitize-html"; 5 | 6 | const FORMATTERS = { 7 | upcase: (v) => v.toUpperCase(), 8 | locale: (v, lc) => lc, 9 | prop: (v, lc, p) => v[p], 10 | relative: (v, lc, now = []) => { 11 | let timeNow = moment(now); 12 | let time = moment(v); 13 | return time.from(timeNow); 14 | }, 15 | timedelta: (v) => { 16 | let timeNow = moment(0); 17 | let time = moment(v); 18 | return time.from(timeNow, true); 19 | }, 20 | decimal: (v, lc, precision) => v.toFixed(precision), 21 | replace: (v, lc, [pattern, replacement, flags = ""]) => { 22 | const strip = /^"(.*(?="$))"$/; 23 | if (v) { 24 | return v.replace( 25 | new RegExp( 26 | pattern.replace(strip, '$1'), 27 | flags.replace(strip, '$1') 28 | ), 29 | replacement.replace(strip, '$1')); 30 | } 31 | return v; 32 | } 33 | }; 34 | 35 | import { isDateTime, isNumber } from "../../dtypes"; 36 | 37 | const format = (value, style, message, formatters = {}) => { 38 | let mf = new MessageFormat("en-GB").setIntlSupport(true); 39 | mf.addFormatters(formatters); 40 | return mf.compile(message)({value}); 41 | }; 42 | 43 | export default function TemplatedCell({ column, dtype, value, options }) { 44 | const style = { 45 | textAlign: 46 | column === "index" || isNumber(dtype) || isDateTime(dtype) 47 | ? "right" 48 | : "left" 49 | }; 50 | 51 | if ((isNumber(dtype) || isDateTime(dtype)) && value == null) { 52 | return
; 53 | } 54 | 55 | let message = options.message ? options.message : "{value}"; 56 | let formattedValue = format(value, style, message, FORMATTERS); 57 | if (options.html) { 58 | formattedValue = sanitizeHtml(formattedValue, { 59 | allowedAttributes: Object.assign( 60 | {}, 61 | sanitizeHtml.defaults.allowedAttributes, 62 | {a: ["href", "rel", "target", "name"]} 63 | ) 64 | }); 65 | 66 | return ( 67 |
70 |
71 | ); 72 | } 73 | return ( 74 |
75 | {formattedValue} 76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /pynorama-js/src/datatable/presenters/cells/yes_no.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function YesNoCell({ column, dtype, value, options }) { 4 | const style = { 5 | textAlign: "right" 6 | }; 7 | 8 | let renderedValue = value ? value.toString().toLowerCase() : "no"; 9 | 10 | if (renderedValue === "no" || renderedValue === "false") { 11 | renderedValue = "no"; 12 | } else { 13 | renderedValue = "yes"; 14 | } 15 | 16 | return ( 17 |
18 | {renderedValue} 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /pynorama-js/src/datatable/presenters/css/datatable.css: -------------------------------------------------------------------------------- 1 | .datatable { 2 | height: 100%; 3 | width: 100%; 4 | border-collapse: collapse; 5 | border-spacing: 0; 6 | padding-bottom: 1px; 7 | } 8 | 9 | .datatable th, .datatable td { 10 | border: lightgray 1px solid; 11 | padding: 3px; 12 | } 13 | 14 | .default-header-cell { 15 | display: flex; 16 | width: 100%; 17 | flex-wrap: nowrap; 18 | cursor: pointer; 19 | 20 | background-color: #e9e9e9; 21 | padding: 3px; 22 | border-width: 0; 23 | } 24 | 25 | .default-footer { 26 | text-align: center; 27 | } 28 | 29 | .default-header-cell-text { 30 | flex-grow: 1; 31 | } 32 | 33 | .datatable .datatable-hover-highlight:hover { 34 | background-color: rgba(150, 150, 150, 0.27); 35 | } 36 | 37 | .datatable .datatable-clickable-cursor { 38 | cursor: pointer; 39 | } 40 | 41 | .datatable .cell-stretch { 42 | width: 100%; 43 | height: 100%; 44 | } 45 | 46 | .datatable-transform-stack { 47 | display: flex; 48 | flex-direction: column; 49 | flex-wrap: nowrap; 50 | justify-content: flex-start; 51 | align-items: stretch; 52 | align-content: flex-start; 53 | 54 | height: 100%; 55 | width: 100%; 56 | } 57 | 58 | .transforms-space-below { 59 | margin-bottom: 10px; 60 | } 61 | -------------------------------------------------------------------------------- /pynorama-js/src/datatable/presenters/default_footer.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "common/redux_ext"; 3 | 4 | import { Pagination, Label } from "react-bootstrap"; 5 | import ToggleButton from "react-bootstrap/lib/ToggleButton"; 6 | import ToggleButtonGroup from "react-bootstrap/lib/ToggleButtonGroup"; 7 | 8 | import { getters, actions } from "../state"; 9 | 10 | export default connect({ 11 | page: getters.page, 12 | pageLength: getters.pageLength, 13 | changePage: actions.changePage, 14 | setPageLength: actions.setPageLength, 15 | isLoading: getters.isLoading 16 | })(function DefaultFooter({ 17 | dataCount, 18 | page, 19 | changePage, 20 | isLoading, 21 | error, 22 | pageLength, 23 | setPageLength 24 | }) { 25 | const pageCount = Math.ceil(dataCount / pageLength); 26 | const pageLengths = [15, 20, 50, 100, 150]; 27 | const buttons = pageLengths.map(l => ( 28 | 29 | {l} 30 | 31 | )); 32 | return ( 33 |
34 | 39 |
40 | changePage({ page: selected })} 50 | /> 51 |
52 | setPageLength({ pageLength: value })} 57 | > 58 | {buttons} 59 | 60 |
61 | ); 62 | }); 63 | -------------------------------------------------------------------------------- /pynorama-js/src/datatable/presenters/default_header.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "common/redux_ext"; 3 | 4 | import { Glyphicon } from "react-bootstrap"; 5 | 6 | import { getters, actions } from "../state"; 7 | 8 | export default connect(props => ({ 9 | isSorted: getters.isSorted.withOptions({ column: props.column }), 10 | toggleSort: actions.toggleSort.withOptions({ column: props.column }) 11 | }))(function DefaultHeader({ column, type, isSorted, toggleSort, options }) { 12 | const direction = isSorted === "ascending" ? "bottom" : "top"; 13 | const icon = 14 | isSorted != null ? ( 15 | 16 | ) : ( 17 | 18 | ); 19 | const renderedName = 20 | column === "index" ? options.displayName || column : column; 21 | return ( 22 | 26 | ); 27 | }); 28 | -------------------------------------------------------------------------------- /pynorama-js/src/datatable/presenters/default_layout.jsx: -------------------------------------------------------------------------------- 1 | export default function DefaultLayout({ table, transforms, options }) { 2 | return table; 3 | } 4 | -------------------------------------------------------------------------------- /pynorama-js/src/datatable/presenters/default_row.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function DefaultRow({ children, options, row }) { 4 | const classNames = []; 5 | if (options.className != null) { 6 | classNames.push(options.className); 7 | } 8 | if (options.hoverHighlight) { 9 | classNames.push("datatable-hover-highlight"); 10 | } 11 | if (options.clickableCursor) { 12 | classNames.push("datatable-clickable-cursor"); 13 | } 14 | return ( 15 |
20 | {children} 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /pynorama-js/src/datatable/presenters/table.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { connect } from "common/redux_ext"; 4 | import { getters, actions } from "../state"; 5 | 6 | import _ from "lodash"; 7 | 8 | function render(renderers, props, options) { 9 | const localRenderers = [...renderers]; 10 | const getNextRenderer = () => localRenderers.pop(); 11 | return getNextRenderer()(props, options, getNextRenderer); 12 | } 13 | 14 | export default connect({ 15 | visibleColumns: getters.visibleColumns 16 | })(function Table({ data, dtypes, dataCount, visibleColumns, error, options }) { 17 | const columns = []; 18 | const allColumns = ["index", ...data.columns]; 19 | if (options.showIndex) { 20 | columns.push("index"); 21 | } 22 | columns.push(..._.intersection(visibleColumns, data.columns)); 23 | 24 | let columnIndices = {}; 25 | for (let column of allColumns) { 26 | columnIndices[column] = data.columns.indexOf(column); 27 | } 28 | 29 | let renderedHeaderCells = columns.map(column => { 30 | let renderedCell = render( 31 | options.headers.renderers, 32 | { column, dtype: dtypes[column] }, 33 | _.merge({}, options.headers.all, options.headers[column]) 34 | ); 35 | return ; 36 | }); 37 | 38 | let { component: Row, ...rowOptions } = options.rows; 39 | let renderedRows = []; 40 | for (let i in data.data) { 41 | const row = {}; 42 | for (let column of allColumns) { 43 | row[column] = 44 | column === "index" 45 | ? data.index[i] 46 | : data.data[i][columnIndices[column]]; 47 | } 48 | 49 | const renderedCells = []; 50 | for (let column of columns) { 51 | const renderedCell = render( 52 | options.cells.renderers, 53 | { 54 | column, 55 | dtype: dtypes[column], 56 | value: row[column] 57 | }, 58 | _.merge({}, options.cells.all, options.cells[column]) 59 | ); 60 | 61 | renderedCells.push(); 62 | } 63 | renderedRows.push( 64 | 65 | {renderedCells} 66 | 67 | ); 68 | } 69 | 70 | const { component: Footer, ...footerOptions } = options.footer; 71 | return ( 72 |
{renderedCell}{renderedCell}
73 | 74 | {renderedHeaderCells} 75 | 76 | {renderedRows} 77 | {Footer && ( 78 | 79 | 80 | 87 | 88 | 89 | )} 90 |
81 |
86 |
91 | ); 92 | }); 93 | -------------------------------------------------------------------------------- /pynorama-js/src/datatable/presenters/transforms.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect, all } from "common/redux_ext"; 3 | import { getters, actions } from "../state"; 4 | import { Panel, Glyphicon, Button, ButtonGroup } from "react-bootstrap"; 5 | import { CloseableHeader } from "common/components"; 6 | 7 | import ToggleButton from "react-bootstrap/lib/ToggleButton"; 8 | import ToggleButtonGroup from "react-bootstrap/lib/ToggleButtonGroup"; 9 | import _ from "lodash"; 10 | 11 | import Select from "react-select"; 12 | import "react-select/dist/react-select.css"; 13 | 14 | function getPresentation(transformType) { 15 | try { 16 | return require(`./transforms/${transformType}.jsx`).default || {}; 17 | } catch (e) { 18 | return {}; 19 | } 20 | } 21 | 22 | const TransformContainer = connect(props => 23 | all({ 24 | removeTransform: actions.removeTransform, 25 | updateTransform: actions.updateTransform, 26 | moveTransformUp: actions.moveTransformUp, 27 | moveTransformDown: actions.moveTransformDown 28 | }).withOptions({ index: props.index }) 29 | )(function TransformContainer({ 30 | index, 31 | transform, 32 | error, 33 | dtypes, 34 | removeTransform, 35 | updateTransform, 36 | moveTransformUp, 37 | moveTransformDown, 38 | sideResult 39 | }) { 40 | const presentation = getPresentation(transform.type); 41 | 42 | const header = transform.fixed ? ( 43 | presentation.heading 44 | ) : ( 45 | moveTransformDown()} 48 | onMoveNegative={() => moveTransformUp()} 49 | onClose={removeTransform} 50 | > 51 | {presentation.heading} 52 | 53 | ); 54 | 55 | const footer = error && {error}; 56 | 57 | return ( 58 | 63 | {presentation.Presenter && ( 64 | 68 | updateTransform({ transform: { ...options } })} 69 | transform={transform} 70 | dtypes={dtypes} 71 | /> 72 | )} 73 | 74 | ); 75 | }); 76 | 77 | const Toolbox = connect({ 78 | removeAllTransforms: actions.removeAllTransforms, 79 | addTransform: actions.addTransform 80 | })(function Toolbox({ addTransform, removeAllTransforms, dtypes, options }) { 81 | // TODO: React Dev Tool catch exceptions. Bad for debugging. 82 | const buttons = options.available.map(type => { 83 | const presentation = getPresentation(type); 84 | 85 | const transform = presentation.getDefaultTransform(dtypes); 86 | 87 | if (!presentation.heading) 88 | return null; 89 | 90 | return ( 91 | 97 | ); 98 | }); 99 | return ( 100 |
101 | 102 | {buttons} 103 | 104 | {options.clearButton && ( 105 | 108 | )} 109 |
110 | ); 111 | }); 112 | 113 | const ColumnsSelect = connect({ 114 | visibleColumns: getters.visibleColumns, 115 | setVisibleColumns: actions.setVisibleColumns 116 | })(function ColumnsSelect({ dtypes, visibleColumns, setVisibleColumns }) { 117 | const columns = _.keys(dtypes); 118 | const options = columns.map(e => ({ 119 | value: e, 120 | label: e 121 | })); 122 | return ( 123 |
124 | onChange && onChange(val.value)} 31 | name={name} 32 | value={selected} 33 | /> 34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /pynorama-js/src/datatable/state.js: -------------------------------------------------------------------------------- 1 | /* 2 | Defines the state of the DataTable. 3 | 4 | Given that DataTables have no id or name property and share the same scope, 5 | only one DataTable per redux store is possible at the moment. 6 | */ 7 | import { makeActions, makeGetters } from "common/redux_ext"; 8 | 9 | import _ from "lodash"; 10 | 11 | const DATA_TABLE_SCOPE = "datatable"; 12 | const DEFAULT_PAGE_LENGTH = 15; 13 | const DEFAULT_TRANSFORMS = []; 14 | 15 | const DEFAULT_VISIBLE_COLUMNS = []; 16 | 17 | export const getters = makeGetters({ 18 | page(state) { 19 | return state.page || 1; 20 | }, 21 | offset(state, {}, get) { 22 | return Math.floor((get(getters.page) - 1) * get(getters.pageLength)); 23 | }, 24 | visibleColumns(state) { 25 | return state.visibleColumns || DEFAULT_VISIBLE_COLUMNS; 26 | }, 27 | pageLength(state) { 28 | return state.pageLength || DEFAULT_PAGE_LENGTH; 29 | }, 30 | transforms(state) { 31 | return state.transforms || DEFAULT_TRANSFORMS; 32 | }, 33 | isSorted(state, { column }, get) { 34 | let transforms = get(getters.transforms); 35 | let sort = _.findLast(transforms, t => t.type === "sort"); 36 | if (sort && sort.column == column) { 37 | return sort.ascending ? "ascending" : "descending"; 38 | } 39 | return null; 40 | }, 41 | isLoading(state) { 42 | return state.isLoading || false; 43 | } 44 | }).withScope(DATA_TABLE_SCOPE); 45 | 46 | const moveTransform = makeActions(function moveTransform( 47 | state, 48 | { index, offset }, 49 | get 50 | ) { 51 | const transforms = get(getters.transforms); 52 | const { pre = [], post = [], middle = [] } = _.groupBy( 53 | transforms, 54 | t => t.position || "middle" 55 | ); 56 | 57 | const indexInNormal = index - pre.length; 58 | if (indexInNormal > -1 && indexInNormal < middle.length) { 59 | const transform = middle[indexInNormal]; 60 | // positive modulus division 61 | const newIndex = 62 | ((index + offset) % middle.length + middle.length) % middle.length; 63 | 64 | middle.splice(index, 1); 65 | middle.splice(newIndex, 0, transform); 66 | return setTransforms.withOptions({ 67 | transforms: middle, 68 | position: "middle" 69 | }); 70 | } 71 | return state; 72 | }); 73 | 74 | const setTransforms = makeActions(function setTransforms( 75 | state, 76 | { transforms, position }, 77 | get 78 | ) { 79 | const prevTransforms = get(getters.transforms); 80 | const { pre = [], post = [], middle = [] } = _.groupBy( 81 | prevTransforms, 82 | t => t.position || "middle" 83 | ); 84 | 85 | switch (position) { 86 | case "pre": { 87 | const fixedTransforms = transforms.map(transform => ({ 88 | ...transform, 89 | position: "pre" 90 | })); 91 | return { ...state, transforms: [...fixedTransforms, ...middle, ...post] }; 92 | } 93 | case "post": { 94 | const fixedTransforms = transforms.map(transform => ({ 95 | ...transform, 96 | position: "post" 97 | })); 98 | return { ...state, transforms: [...pre, ...middle, ...fixedTransforms] }; 99 | } 100 | case "middle": { 101 | return { ...state, transforms: [...pre, ...transforms, ...post] }; 102 | } 103 | default: { 104 | return state; 105 | } 106 | } 107 | }).withScope(DATA_TABLE_SCOPE); 108 | 109 | export const actions = makeActions({ 110 | changePage(state, { page }) { 111 | return { ...state, page }; 112 | }, 113 | toggleSort(state, { column }, get) { 114 | const transforms = get(getters.transforms); 115 | const index = _.findLastIndex(transforms, t => t.type === "sort"); 116 | if (index < 0) { 117 | return actions.addTransform.withOptions({ 118 | transform: { 119 | type: "sort", 120 | column, 121 | ascending: true 122 | } 123 | }); 124 | } else { 125 | const sort = transforms[index]; 126 | const updatedSort = 127 | sort.column === column 128 | ? { ascending: !sort.ascending } 129 | : { column, ascending: true }; 130 | return actions.updateTransform.withOptions({ 131 | transform: updatedSort, 132 | index 133 | }); 134 | } 135 | }, 136 | removeTransform(state, { index }, get) { 137 | const transforms = [...get(getters.transforms)]; 138 | transforms.splice(index, 1); 139 | return { ...state, transforms }; 140 | }, 141 | removeAllTransforms(state) { 142 | return setTransforms.withOptions({ 143 | position: "normal", 144 | transforms: DEFAULT_TRANSFORMS 145 | }); 146 | }, 147 | setPreTransforms: setTransforms.withOptions({ position: "pre" }), 148 | setPostTransforms: setTransforms.withOptions({ position: "post" }), 149 | addTransform(state, { transform }, get) { 150 | const transforms = get(getters.transforms); 151 | const { pre = [], post = [], middle = [] } = _.groupBy( 152 | transforms, 153 | t => t.position || "middle" 154 | ); 155 | 156 | return { 157 | ...state, 158 | transforms: [...pre, ...middle, transform, ...post] 159 | }; 160 | }, 161 | updateTransform(state, { transform, index }, get) { 162 | const transforms = [...get(getters.transforms)]; 163 | transforms[index] = { ...transforms[index], ...transform }; 164 | return { ...state, transforms }; 165 | }, 166 | setPageLength(state, { pageLength }) { 167 | return { ...state, pageLength }; 168 | }, 169 | setVisibleColumns(state, { columns }) { 170 | return { ...state, visibleColumns: columns }; 171 | }, 172 | addVisibleColumns(state, { columns }, get) { 173 | return { 174 | ...state, 175 | visibleColumns: _.uniq([...get(getters.visibleColumns), ...columns]) 176 | }; 177 | }, 178 | moveTransformUp: moveTransform.withOptions({ offset: -1 }), 179 | moveTransformDown: moveTransform.withOptions({ offset: 1 }), 180 | setIsLoading(state, { value }) { 181 | return { ...state, isLoading: value }; 182 | } 183 | }).withScope(DATA_TABLE_SCOPE); 184 | -------------------------------------------------------------------------------- /pynorama-js/src/doctable_panel.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "common/redux_ext"; 3 | 4 | import { 5 | HorizontalResizer, 6 | HorizontalPanel, 7 | HorizontalStack, 8 | CloseableHeader, 9 | VisibilityToggle, 10 | VerticalScrollArea 11 | } from "common/components"; 12 | import { getters, actions } from "view_state"; 13 | 14 | import DataTable from "datatable"; 15 | 16 | import DefaultRow from "datatable/presenters/default_row"; 17 | import { isNumber, isDateTime, npDateTime64ToISO } from "datatable/dtypes"; 18 | 19 | const minWidth = "250px"; 20 | const maxWidth = null; 21 | const initialWidth = "80vw"; 22 | 23 | import _ from "lodash"; 24 | 25 | const CustomRow = connect({ 26 | documentKey: getters.documentKey, 27 | setDocument: actions.setDocument 28 | })(function CustomRow({ 29 | documentKey, 30 | setDocument, 31 | options, 32 | children, 33 | row, 34 | ...other 35 | }) { 36 | const keyField = options.keyField; 37 | const selected = row[keyField] === documentKey; 38 | const className = selected ? options.selectedClassName : undefined; 39 | const style = selected ? options.selectedStyle : undefined; 40 | const modifiedOptions = _.merge({}, options, { 41 | style, 42 | className, 43 | onClick: () => setDocument({ documentKey: row[keyField] }) 44 | }); 45 | return ( 46 | 47 | {children} 48 | 49 | ); 50 | }); 51 | 52 | const DocTableLayout = function DocTableLayout({ transforms, table, options }) { 53 | return ( 54 | 55 | 56 | {transforms} 57 | 58 | 59 | {table} 60 | 61 | 62 | ); 63 | }; 64 | 65 | const options = _.merge( 66 | {}, 67 | { 68 | queryUrl: "table", 69 | layout: { 70 | component: DocTableLayout 71 | }, 72 | table: { 73 | rows: { 74 | component: CustomRow, 75 | keyField: "index", 76 | selectedStyle: { color: "#90a0cc", backgroundColor: "#dddde3" } 77 | } 78 | } 79 | }, 80 | window.options.datatable 81 | ); 82 | 83 | export const DocTablePanel = connect({ 84 | isVisible: getters.isDataTableVisible, 85 | closePanel: actions.closeDataTable, 86 | isReloading: getters.isReloading 87 | })(function DocTablePanel({ isVisible, closePanel, isReloading }) { 88 | const header = ( 89 |
90 | closePanel()}> 91 | Document Table - {window.viewName} 92 | 93 |
94 | ); 95 | return ( 96 | 97 | 102 | 103 | 104 | 105 | 106 | 107 | ); 108 | }); 109 | -------------------------------------------------------------------------------- /pynorama-js/src/layout.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "common/redux_ext"; 3 | import { 4 | getters, 5 | actions, 6 | PIPELINE_PANEL_ID, 7 | DATA_TABLE_PANEL_ID, 8 | SESSIONS_PANEL_ID 9 | } from "view_state"; 10 | import ToggleButton from "react-bootstrap/lib/ToggleButton"; 11 | import ToggleButtonGroup from "react-bootstrap/lib/ToggleButtonGroup"; 12 | import { Glyphicon } from "react-bootstrap"; 13 | import { HorizontalStack, VerticalScrollArea } from "common/components"; 14 | import "css/layout.css"; 15 | 16 | import { ViewerPanel } from "viewer_panel"; 17 | import { DocTablePanel } from "doctable_panel"; 18 | import { PipelinePanel } from "pipeline_panel"; 19 | import { SessionsPanel } from "sessions_panel"; 20 | 21 | export const Workspace = () => { 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 | ); 32 | }; 33 | 34 | export const ViewersArea = connect({ 35 | versions: getters.versions 36 | })(({ versions }) => { 37 | const viewers = versions.map(version => ( 38 | 43 | )); 44 | return ( 45 | 46 | {viewers} 47 | 48 | ); 49 | }); 50 | 51 | const panels = [ 52 | { 53 | id: SESSIONS_PANEL_ID, 54 | label: "Sessions", 55 | glyph: "glyphicon glyphicon-floppy-disk" 56 | }, 57 | { 58 | id: DATA_TABLE_PANEL_ID, 59 | label: "Table", 60 | glyph: "glyphicon glyphicon-th-list" 61 | }, 62 | { 63 | id: PIPELINE_PANEL_ID, 64 | label: "Pipeline", 65 | glyph: "glyphicon glyphicon-globe" 66 | } 67 | ]; 68 | 69 | const RELOADING_ID = "RELOADING"; 70 | 71 | function reload(setIsReloading) { 72 | setIsReloading(true); 73 | fetch("reload", { method: "POST" }).then(e => setIsReloading(false)); 74 | } 75 | 76 | const TaskColumn = connect({ 77 | visiblePanels: getters.visiblePanels, 78 | setVisiblePanels: actions.setVisiblePanels, 79 | setIsReloading: actions.setIsReloading.withMapping(value => ({ value })), 80 | isReloading: getters.isReloading 81 | })(({ visiblePanels, setVisiblePanels, setIsReloading, isReloading }) => { 82 | const reloadSize = 10; 83 | 84 | // pretty dirty implementation. 85 | // Unfortunately ToggleButtonGroup doesn't use a flexbox 86 | const buttonStyle = { width: (100 - reloadSize) / panels.length + "%" }; 87 | const refreshButtonStyle = { 88 | width: reloadSize + "%" 89 | }; 90 | 91 | const renderedButtons = panels.map(panel => ( 92 | 98 | {panel.label} 99 | 100 | )); 101 | 102 | if (isReloading) { 103 | visiblePanels = [...visiblePanels, RELOADING_ID]; 104 | } 105 | 106 | const onValuesChanged = vals => { 107 | if (vals.includes(RELOADING_ID)) { 108 | reload(setIsReloading); 109 | vals = _.without(vals, RELOADING_ID); 110 | } 111 | setVisiblePanels({ visiblePanels: vals }); 112 | }; 113 | 114 | return ( 115 |
116 |
117 |
118 | 126 | {renderedButtons} 127 | 132 | 136 | 137 | 138 |
139 |
140 |
141 | ); 142 | }); 143 | -------------------------------------------------------------------------------- /pynorama-js/src/pipeline.jsx: -------------------------------------------------------------------------------- 1 | import Graph from "react-graph-vis"; 2 | import React from "react"; 3 | 4 | import { connect } from "common/redux_ext"; 5 | import { actions, getters } from "view_state"; 6 | 7 | import _ from "lodash"; 8 | 9 | import URLSearchParams from "url-search-params"; 10 | 11 | let params = new URLSearchParams(window.location.search); 12 | let node = params.get("node"); 13 | 14 | const defaultOptions = { 15 | nodes: { 16 | shape: "box", 17 | widthConstraint: { 18 | maximum: 100 19 | }, 20 | borderWidth: 1, 21 | borderWidthSelected: 5, 22 | margin: 10, 23 | color: { 24 | background: "#D2E5FF", 25 | border: "rgba(0,0,0,0)", 26 | highlight: { 27 | background: "#D2E5FF", 28 | border: "#333333" 29 | } 30 | } 31 | }, 32 | layout: { 33 | hierarchical: { 34 | enabled: true, 35 | sortMethod: "directed", 36 | nodeSpacing: 200, 37 | direction: "LR" 38 | } 39 | }, 40 | interaction: { selectable: true, multiselect: true }, 41 | physics: { 42 | enabled: true, 43 | hierarchicalRepulsion: { 44 | nodeDistance: 60, 45 | springLength: 100, 46 | damping: 0.9 47 | } 48 | }, 49 | edges: { 50 | smooth: true, 51 | arrows: { to: true }, 52 | color: { 53 | color: "#000000" 54 | } 55 | }, 56 | autoResize: true 57 | }; 58 | 59 | let PipelinePresenter = class extends React.Component { 60 | selectionChanged(e) { 61 | this.props.setVersions({ 62 | versions: e.nodes.map(node => ({ 63 | name: node, 64 | viewer: this.props.data.nodeMapping[node].viewer 65 | })) 66 | }); 67 | } 68 | 69 | render() { 70 | let { data, versions } = this.props; 71 | if (!data) { 72 | return null; 73 | } 74 | 75 | const nodes = _.values(data.nodeMapping).map(node => ({ 76 | id: node.id, 77 | label: node.label || node.id, 78 | color: { background: node.color, highlight: { background: node.color } } 79 | })); 80 | const graph = { nodes, edges: data.edges }; 81 | 82 | const events = { 83 | selectNode: e => this.selectionChanged(e), 84 | deselectNode: e => this.selectionChanged(e), 85 | dragStart: e => this.selectionChanged(e) 86 | }; 87 | return ( 88 | { 94 | this.network = network; 95 | this.componentWillReceiveProps(this.props); 96 | }} 97 | /> 98 | ); 99 | } 100 | 101 | componentWillReceiveProps(nextProps) { 102 | this.network && 103 | this.network.setSelection({ 104 | nodes: nextProps.versions.map(node => node.name) 105 | }); 106 | } 107 | 108 | shouldComponentUpdate(nextProps) { 109 | return this.props.data == null; 110 | } 111 | }; 112 | 113 | PipelinePresenter = connect({ 114 | versions: getters.versions, 115 | setVersions: actions.setVersions 116 | })(PipelinePresenter); 117 | 118 | class PipelineDataProvider extends React.Component { 119 | constructor(props) { 120 | super(props); 121 | this.state = {}; 122 | this.updateData(); 123 | } 124 | 125 | updateData() { 126 | // TODO: deal with errors 127 | fetch(`pipeline`) 128 | .then(response => { 129 | if (!response.ok) { 130 | throw Error(response.statusText); 131 | } 132 | return response.json(); 133 | }) 134 | .then(data => { 135 | if (data.error) { 136 | throw Error(data.error); 137 | } 138 | this.setState({ data }); 139 | }); 140 | } 141 | 142 | componentWillReceiveProps(nextProps) { 143 | if (!nextProps.isReloading) { 144 | this.updateData(); 145 | } 146 | } 147 | 148 | render() { 149 | return ; 150 | } 151 | } 152 | 153 | export const Pipeline = connect({ 154 | isReloading: getters.isReloading 155 | })(PipelineDataProvider); 156 | -------------------------------------------------------------------------------- /pynorama-js/src/pipeline_panel.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "common/redux_ext"; 3 | 4 | import { 5 | HorizontalPanel, 6 | CloseableHeader, 7 | HorizontalResizer, 8 | VisibilityToggle 9 | } from "common/components"; 10 | import { getters, actions } from "view_state"; 11 | import { Pipeline } from "./pipeline"; 12 | 13 | const minWidth = "200px"; 14 | const maxWidth = null; 15 | const initialWidth = "600px"; 16 | 17 | export const PipelinePanel = connect({ 18 | isVisible: getters.isPipelineVisible, 19 | closePanel: actions.closePipeline 20 | })(({ isVisible, closePanel }) => { 21 | const header = ( 22 |
23 | closePanel()}> 24 | Pipeline - {window.viewName} 25 | 26 |
27 | ); 28 | return ( 29 | 30 | 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | }); 42 | -------------------------------------------------------------------------------- /pynorama-js/src/sessions_panel.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "common/redux_ext"; 3 | 4 | import { 5 | HorizontalResizer, 6 | HorizontalPanel, 7 | HorizontalStack, 8 | CloseableHeader, 9 | VisibilityToggle 10 | } from "common/components"; 11 | import { getters, actions } from "view_state"; 12 | 13 | import { 14 | ButtonGroup, 15 | Button, 16 | InputGroup, 17 | FormControl, 18 | ListGroup, 19 | ListGroupItem, 20 | Glyphicon 21 | } from "react-bootstrap"; 22 | 23 | import _ from "lodash"; 24 | 25 | const minWidth = "200px"; 26 | const maxWidth = null; 27 | const initialWidth = "300px"; 28 | 29 | const SESSION_PREFIX = "session_"; 30 | 31 | const SessionsPresenter = ({ 32 | applySession, 33 | removeSession, 34 | addSession, 35 | sessions 36 | }) => { 37 | if (!sessions) { 38 | sessions = []; 39 | } 40 | const list = sessions.map(item => { 41 | return ( 42 | 52 | ); 53 | }); 54 | const onFormSubmit = e => { 55 | e.preventDefault(); 56 | addSession(e.target.session.value); 57 | e.target.session.value = ""; 58 | }; 59 | 60 | const marginStyle = { margin: "5px" }; 61 | return ( 62 |
63 |
64 | 65 | {list} 66 | 67 |
68 |
69 |
70 | 71 | 76 | 77 | 78 | 79 | 80 |
81 |
82 |
83 | ); 84 | }; 85 | 86 | const SessionsDataProvider = connect({ 87 | isReloading: getters.isReloading, 88 | globalState: getters.globalState, 89 | setGlobalState: actions.setGlobalState 90 | })( 91 | class SessionsDataProvider extends React.Component { 92 | constructor(props) { 93 | super(props); 94 | this.state = {}; 95 | 96 | this.applySession = this.applySession.bind(this); 97 | this.removeSession = this.removeSession.bind(this); 98 | this.addSession = this.addSession.bind(this); 99 | } 100 | 101 | componentDidMount() { 102 | this.updateData(); 103 | } 104 | 105 | updateData() { 106 | // TODO: deal with errors 107 | fetch("get_sessions") 108 | .then(response => { 109 | if (!response.ok) { 110 | throw Error(response.statusText); 111 | } 112 | return response.json(); 113 | }) 114 | .then(data => { 115 | if (data.error) { 116 | throw Error(data.error); 117 | } 118 | this.setState({ sessions: data }); 119 | }); 120 | } 121 | 122 | addSession(session) { 123 | fetch("add_session", { 124 | method: "POST", 125 | headers: { 126 | Accept: "application/json", 127 | "Content-Type": "application/json" 128 | }, 129 | body: JSON.stringify({ 130 | session, 131 | state: JSON.stringify(this.props.globalState) 132 | }) 133 | }).then(response => { 134 | this.updateData(); 135 | }); 136 | } 137 | 138 | removeSession(session) { 139 | fetch("remove_session", { 140 | method: "POST", 141 | headers: { 142 | "Content-Type": "application/json" 143 | }, 144 | body: JSON.stringify({ session }) 145 | }).then(response => { 146 | this.updateData(); 147 | }); 148 | } 149 | 150 | applySession(session) { 151 | fetch(`get_state?session=${encodeURI(session)}`, { 152 | method: "GET", 153 | headers: { 154 | Accept: "application/json" 155 | } 156 | }) 157 | .then(response => { 158 | if (!response.ok) { 159 | throw Error(response.statusText); 160 | } 161 | return response.json(); 162 | }) 163 | .then(state => { 164 | this.props.setGlobalState({ state }); 165 | }); 166 | } 167 | 168 | componentWillReceiveProps(nextProps) { 169 | if ( 170 | nextProps.isReloading != this.props.isReloading && 171 | !nextProps.isReloading 172 | ) { 173 | this.updateData(); 174 | } 175 | } 176 | 177 | shouldComponentUpdate(nextProps, nextState) { 178 | return ( 179 | nextProps.isReloading != this.props.isReloading || 180 | nextState.sessions != this.state.sessions 181 | ); 182 | } 183 | 184 | render() { 185 | return ( 186 | 192 | ); 193 | } 194 | } 195 | ); 196 | 197 | export const SessionsPanel = connect({ 198 | isVisible: getters.isSessionsPanelVisible, 199 | closePanel: actions.closeSessionsPanel 200 | })(({ isVisible, closePanel }) => { 201 | const header = ( 202 |
203 | 204 | Sessions - {window.viewName} 205 | 206 |
207 | ); 208 | return ( 209 | 210 | 215 | 216 | 217 | 218 | 219 | 220 | ); 221 | }); 222 | -------------------------------------------------------------------------------- /pynorama-js/src/tree/collapsible_tree.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Glyphicon } from "react-bootstrap"; 3 | import _ from "lodash"; 4 | 5 | import { connect, all } from "common/redux_ext"; 6 | import { DivDummy } from 'common/components' 7 | 8 | import { getters, actions, DEFAULT_DEEP_INDEX, DEFAULT_DEEP_LENGTHS} from "./state"; 9 | 10 | import "./css/treeview.css"; 11 | 12 | export default function createCollapsibleComponent( 13 | getChildren, 14 | LeafNodePresenter, 15 | CollapsedNodePresenter, 16 | ExpandedNodePresenter, 17 | ExpandedNodeContainer = DivDummy, 18 | showControls = true 19 | ) { 20 | const CollapsibleComponent = connect(props => all({ 21 | highlighted: getters.highlighted, 22 | hidden: getters.hidden, 23 | collapsed: getters.collapsed, 24 | toggleCollapsed: actions.toggleCollapsed, 25 | setHighlight: actions.setHighlight.withMapping(value => ({value})), 26 | }).withOptions({ 27 | levelLengths: props.levelLengths, 28 | deepLengths: props.deepLengths, 29 | deepIndex: props.deepIndex, 30 | group: props.group 31 | }))( 32 | function UnconnectedCollapsibleComponent({ 33 | node, 34 | deepIndex=DEFAULT_DEEP_INDEX, 35 | deepLenghts=DEFAULT_DEEP_LENGTHS, 36 | highlighted, 37 | hidden, 38 | collapsed, 39 | toggleCollapsed, 40 | setHighlight, 41 | ...other }) { 42 | 43 | if (hidden) { 44 | return null; 45 | } 46 | 47 | const withHighlight = (component) => ( 48 |
setHighlight(false)} 50 | onMouseEnter={() => setHighlight(true)} 51 | className={highlighted && "tree-highlighted" || ""} 52 | > 53 | {component} 54 |
55 | ); 56 | 57 | if (!getChildren(node)) { 58 | return withHighlight( 59 | 64 | ); 65 | } 66 | 67 | let controls = null; 68 | if (showControls) { 69 | controls = ( 70 |
toggleCollapsed()} 73 | > 74 | 80 |
81 | ); 82 | } 83 | 84 | if (collapsed) { 85 | return ( 86 |
87 | {controls} 88 |
89 | { 90 | withHighlight( 91 | 94 | ) 95 | } 96 |
97 |
98 | ); 99 | } else { 100 | const childPresenters = _.map( 101 | getChildren(node), 102 | (child,index) => ( 103 | 110 | ) 111 | ); 112 | 113 | return ( 114 |
115 | {controls} 116 |
117 | 118 | { 119 | withHighlight() 124 | } 125 | {childPresenters} 126 | 127 |
128 |
129 | ); 130 | } 131 | } 132 | ); 133 | return CollapsibleComponent; 134 | } 135 | -------------------------------------------------------------------------------- /pynorama-js/src/tree/css/treeview.css: -------------------------------------------------------------------------------- 1 | .tree-indent-area { 2 | width: 25px; 3 | display: table-cell; 4 | vertical-align: top; 5 | } 6 | 7 | .tree-indented { 8 | display: table-cell; 9 | } 10 | 11 | .tree-clickable-area { 12 | cursor: pointer; 13 | } 14 | 15 | .tree-clickable-area:hover { 16 | background-color: lightgray; 17 | } 18 | 19 | .tree-node { 20 | display: table; 21 | table-layout: fixed; 22 | width: 100%; 23 | } 24 | 25 | .tree-button { 26 | display: inline-block; 27 | background: no-repeat left top; 28 | vertical-align: middle; 29 | padding: 4px; 30 | cursor: pointer; 31 | height: 100%; 32 | } 33 | 34 | .tree-highlighted { 35 | background-color: rgba(150, 150, 150, 0.27); 36 | } 37 | 38 | .tree-line { 39 | display: flex; 40 | flex-direction: row; 41 | flex-wrap: nowrap; 42 | justify-content: flex-start; 43 | align-content: flex-start; 44 | min-height: 23px; 45 | } 46 | 47 | .tree-toolbar { 48 | margin-bottom: 5px; 49 | } 50 | 51 | .tree-text-ellipsis { 52 | text-overflow: ellipsis; 53 | white-space: nowrap; 54 | overflow: hidden; 55 | display: block; 56 | width: inherit; 57 | } 58 | 59 | .tree-text-wrap { 60 | white-space: normal; 61 | } 62 | 63 | .tree-label { 64 | margin-right: 5px; 65 | } 66 | -------------------------------------------------------------------------------- /pynorama-js/src/tree/state.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | import { makeActions, makeGetters, applyScope, reduceScope } from "common/redux_ext"; 4 | 5 | const TREE_SCOPE = "tree" 6 | const EMPTY_OBJ = {}; 7 | const EMPTY_ARR = []; 8 | 9 | export const DEFAULT_DEEP_INDEX = []; 10 | export const DEFAULT_DEEP_LENGTHS = [] 11 | 12 | const DEFAULT_GROUP = "default_group" 13 | 14 | export const getters = makeGetters({ 15 | highlighted(state, {deepIndex=DEFAULT_DEEP_INDEX, group=DEFAULT_GROUP}) { 16 | const value = ((state[group] || EMPTY_OBJ).highlighted || EMPTY_OBJ)[deepIndex]; 17 | if (value==null) 18 | return false; 19 | return value; 20 | }, 21 | hidden(state, {deepIndex=DEFAULT_DEEP_INDEX, group=DEFAULT_GROUP}) { 22 | const value = ((state[group] || EMPTY_OBJ).hidden || EMPTY_OBJ)[deepIndex]; 23 | if (value==null) 24 | return false; 25 | return value; 26 | }, 27 | collapsed(state, {deepIndex=DEFAULT_DEEP_INDEX, group=DEFAULT_GROUP}) { 28 | const groupState = state[group] || EMPTY_OBJ; 29 | const expanded = groupState.expanded || EMPTY_OBJ; 30 | const expandedLevels = groupState.expandedLevels || EMPTY_ARR; 31 | 32 | const level = deepIndex.length; 33 | return ((expanded[level] || EMPTY_OBJ)[deepIndex] || false) ^ (expandedLevels[level] || false) == 0; 34 | } 35 | }).withScope(TREE_SCOPE); 36 | 37 | export const actions = makeActions({ 38 | setHighlight(state, {deepIndex=DEFAULT_DEEP_INDEX, group=DEFAULT_GROUP, value}) { 39 | return reduceScope(state, value, [group, "highlighted", deepIndex]) 40 | }, 41 | toggleCollapsed(state, {deepIndex=DEFAULT_DEEP_INDEX, group=DEFAULT_GROUP, levelLengths}) { 42 | let groupState = state[group] || EMPTY_OBJ; 43 | const expanded = groupState.expanded || EMPTY_OBJ; 44 | 45 | const level = deepIndex.length; 46 | 47 | const expandedLevel = { 48 | ...expanded[level], 49 | [deepIndex]: expanded[level] ? !(expanded[level][deepIndex] || false) : true 50 | }; 51 | 52 | if (_(expandedLevel).values().sum() == levelLengths[level]) { 53 | const expandedLevels = (groupState.expandedLevels || EMPTY_ARR).slice(); 54 | expandedLevels[level] = !(expandedLevels[level] || false); 55 | 56 | const expandedNew = {...expanded, [level]: EMPTY_OBJ}; 57 | return reduceScope(state, {...groupState, expanded: expandedNew, expandedLevels}, [group]); 58 | } else { 59 | const expandedNew = {...expanded, [level]: expandedLevel}; 60 | return reduceScope(state, {...groupState, expanded: expandedNew}, [group]); 61 | } 62 | }, 63 | expandLevel(state, {group=DEFAULT_GROUP, levelLengths}) { 64 | const groupState = state[group] || EMPTY_OBJ; 65 | const expanded = groupState.expanded || EMPTY_OBJ; 66 | const expandedLevels = (groupState.expandedLevels || EMPTY_ARR).slice(); 67 | 68 | for (let i = 0; i < Math.max(expandedLevels.length, levelLengths.length); ++i) { 69 | if (!expandedLevels[i]) { 70 | expandedLevels[i] = true; 71 | const expandedNew = {...expanded, [i]: EMPTY_OBJ}; 72 | return reduceScope(state, {...groupState, expanded: expandedNew, expandedLevels}, [group]); 73 | } 74 | } 75 | return state; 76 | }, 77 | collapseLevel(state, {group=DEFAULT_GROUP, levelLengths}) { 78 | const groupState = state[group] || EMPTY_OBJ; 79 | const expanded = groupState.expanded || EMPTY_OBJ; 80 | const expandedLevels = (groupState.expandedLevels || EMPTY_ARR).slice(); 81 | 82 | for (let i = 0; i < Math.max(expandedLevels.length, levelLengths.length); ++i) { 83 | if (_(expanded[i+1] || EMPTY_OBJ).values().sum() == 0 && !expandedLevels[i+1]) { 84 | expandedLevels[i] = false; 85 | const expandedNew = {...expanded, [i]: EMPTY_OBJ}; 86 | return reduceScope(state, {...groupState, expanded: expandedNew, expandedLevels}, [group]); 87 | } 88 | } 89 | return state; 90 | } 91 | }).withScope(TREE_SCOPE); 92 | -------------------------------------------------------------------------------- /pynorama-js/src/tree/tagged_tree.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Label, Glyphicon } from "react-bootstrap"; 3 | 4 | import "./css/treeview.css"; 5 | 6 | function AttributePresenter({ showTags, node }) { 7 | let tagPresenters = []; 8 | let count = 0; 9 | if (showTags) { 10 | for (let tag of node.getTags()) { 11 | tagPresenters.push( 12 | 15 | ); 16 | } 17 | } 18 | return {tagPresenters}; 19 | }; 20 | 21 | export function createTextPresenter(textVisible) { 22 | class TextPresenter extends React.Component { 23 | constructor(props) { 24 | super(props); 25 | this.state = { wrap: false }; 26 | } 27 | 28 | toggleWrap() { 29 | this.setState({ wrap: !this.state.wrap }); 30 | } 31 | 32 | render() { 33 | const text = textVisible && this.props.renderText(this.props.node) || ""; 34 | return ( 35 |
36 | 37 | {textVisible ? ( 38 | this.toggleWrap()} 41 | glyph={`glyphicon glyphicon-eye-${this.state.wrap 42 | ? "open" : "close"}`} 43 | /> 44 | ) : null} 45 | 46 | {text} 47 | 48 |
49 | ); 50 | } 51 | } 52 | return TextPresenter; 53 | } 54 | -------------------------------------------------------------------------------- /pynorama-js/src/tree/toolbar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | ButtonToolbar, 4 | ButtonGroup, 5 | Button, 6 | Glyphicon, 7 | FormControl, 8 | InputGroup, 9 | Form 10 | } from "react-bootstrap"; 11 | import ToggleButton from "react-bootstrap/lib/ToggleButton"; 12 | import ToggleButtonGroup from "react-bootstrap/lib/ToggleButtonGroup"; 13 | 14 | import { connect, all } from 'common/redux_ext' 15 | import { getters, actions } from './state' 16 | 17 | export const ExpansionLevelButtonGroup = connect(props => all({ 18 | expandLevel: actions.expandLevel, 19 | collapseLevel: actions.collapseLevel, 20 | }).withOptions({levelLengths: props.levelLengths})) 21 | ( 22 | function _ExpansionLevelButtonGroup({ 23 | expandLevel, collapseLevel 24 | }) { 25 | return ( 26 | 27 | 30 | 33 | 34 | ); 35 | } 36 | ); 37 | 38 | export const Toolbar = function Toolbar({children}) { 39 | return ( 40 |
41 | 42 | {children} 43 | 44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /pynorama-js/src/tree/tree_operations.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export function calculateLevelLengths(node, getChildren) { 4 | let levelLengths = []; 5 | let levelNodes = [node]; 6 | 7 | let counter = 0; 8 | let nextLevelNodes = []; 9 | 10 | while (levelNodes.length > 0) { 11 | levelLengths[counter] = levelNodes.length; 12 | 13 | nextLevelNodes = []; 14 | for (let levelNode of levelNodes) { 15 | if (getChildren(levelNode)) 16 | nextLevelNodes.push(...getChildren(levelNode)); 17 | } 18 | levelNodes = nextLevelNodes; 19 | ++counter; 20 | } 21 | 22 | return levelLengths; 23 | } 24 | -------------------------------------------------------------------------------- /pynorama-js/src/view.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "react-dom"; 3 | 4 | import { createStore } from "common/redux_ext"; 5 | import { Provider } from "react-redux"; 6 | 7 | import { Workspace } from "layout"; 8 | 9 | import "bootstrap/dist/css/bootstrap.min.css"; 10 | 11 | let store = createStore(window.initialState || {}); 12 | 13 | render( 14 | 15 | 16 | , 17 | document.getElementById("main-div") 18 | ); 19 | -------------------------------------------------------------------------------- /pynorama-js/src/view_state.js: -------------------------------------------------------------------------------- 1 | import { makeActions, makeGetters } from "common/redux_ext"; 2 | import _ from "lodash"; 3 | 4 | const VIEWERS_SCOPE = "viewers"; 5 | const PANELS_SCOPE = "panels"; 6 | 7 | const DEFAULT_VERSIONS = []; 8 | 9 | export const DATA_TABLE_PANEL_ID = "_DATA_TABLE"; 10 | export const PIPELINE_PANEL_ID = "_PIPELINE"; 11 | export const SESSIONS_PANEL_ID = "_SESSIONS_PANEL"; 12 | 13 | const DEFAULT_VISIBLE_PANELS = [DATA_TABLE_PANEL_ID]; 14 | 15 | export const viewersGetters = makeGetters({ 16 | documentKey(state) { 17 | return state.documentKey || null; 18 | }, 19 | versions(state) { 20 | return state.versions || DEFAULT_VERSIONS; 21 | } 22 | }).withScope(VIEWERS_SCOPE); 23 | 24 | const isPanelVisible = makeGetters(function isPanelVisible( 25 | state, 26 | { panel }, 27 | get 28 | ) { 29 | return get(getters.visiblePanels).includes(panel); 30 | }); 31 | 32 | export const panelsGetters = makeGetters({ 33 | visiblePanels(state) { 34 | return state.visiblePanels || DEFAULT_VISIBLE_PANELS; 35 | }, 36 | isPanelVisible, 37 | isDataTableVisible: isPanelVisible.withOptions({ 38 | panel: DATA_TABLE_PANEL_ID 39 | }), 40 | isPipelineVisible: isPanelVisible.withOptions({ 41 | panel: PIPELINE_PANEL_ID 42 | }), 43 | isSessionsPanelVisible: isPanelVisible.withOptions({ 44 | panel: SESSIONS_PANEL_ID 45 | }) 46 | }).withScope(PANELS_SCOPE); 47 | 48 | export const getters = { 49 | ...viewersGetters, 50 | ...panelsGetters, 51 | ...makeGetters({ 52 | globalState(state) { 53 | return state; 54 | }, 55 | isReloading(state) { 56 | return state.isReloading || false; 57 | } 58 | }) 59 | }; 60 | 61 | const closePanel = makeActions(function closePanel(state, { panel }, get) { 62 | return { 63 | ...state, 64 | visiblePanels: _.without(get(getters.visiblePanels), panel) 65 | }; 66 | }); 67 | 68 | const moveVersion = makeActions(function moveVersion( 69 | state, 70 | { version: versionName, offset }, 71 | get 72 | ) { 73 | let versions = get(getters.versions); 74 | let index = -1; 75 | let version = null; 76 | for (let i = 0; i < versions.length; ++i) { 77 | if (versions[i].name == versionName) { 78 | index = i; 79 | version = versions[i]; 80 | break; 81 | } 82 | } 83 | if (index > -1) { 84 | let newIndex = (index + offset) % versions.length; 85 | if (newIndex < 0) { 86 | newIndex += versions.length; 87 | } 88 | versions = versions.slice(); 89 | versions.splice(index, 1); 90 | versions.splice(newIndex, 0, version); 91 | return { ...state, versions }; 92 | } else { 93 | return state; 94 | } 95 | }); 96 | 97 | const viewersActions = makeActions({ 98 | setDocument(state, { documentKey }) { 99 | return { ...state, documentKey }; 100 | }, 101 | setVersions(state, { versions }) { 102 | return { ...state, versions }; 103 | }, 104 | addVersion(state, { version }, get) { 105 | return { ...state, versions: [...get(getters.versions), version] }; 106 | }, 107 | closeVersion(state, { version }, get) { 108 | let versions = get(getters.versions); 109 | versions = versions.filter(v => v.name != version); 110 | return { ...state, versions: versions }; 111 | }, 112 | moveVersionLeft: moveVersion.withOptions({ offset: -1 }), 113 | moveVersionRight: moveVersion.withOptions({ offset: 1 }) 114 | }).withScope(VIEWERS_SCOPE); 115 | 116 | const panelsActions = makeActions({ 117 | setVisiblePanels(state, { visiblePanels }) { 118 | return { ...state, visiblePanels }; 119 | }, 120 | closeDataTable: closePanel.withOptions({ 121 | panel: DATA_TABLE_PANEL_ID 122 | }), 123 | closePipeline: closePanel.withOptions({ 124 | panel: PIPELINE_PANEL_ID 125 | }), 126 | closeSessionsPanel: closePanel.withOptions({ 127 | panel: SESSIONS_PANEL_ID 128 | }) 129 | }).withScope(PANELS_SCOPE); 130 | 131 | export const actions = { 132 | ...makeActions({ 133 | setIsReloading(state, { value }) { 134 | return { ...state, isReloading: value }; 135 | }, 136 | setGlobalState(currState, { state }) { 137 | return { ...state, isReloading: false }; 138 | } 139 | }), 140 | ...panelsActions, 141 | ...viewersActions 142 | }; 143 | -------------------------------------------------------------------------------- /pynorama-js/src/viewer_panel.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "common/redux_ext"; 3 | import { Label } from "react-bootstrap"; 4 | import { 5 | CloseableHeader, 6 | HorizontalPanel, 7 | HorizontalResizer 8 | } from "common/components"; 9 | 10 | import { getters, actions } from "view_state"; 11 | 12 | const DEFAULT_VIEWER = "json" 13 | const DEFAULT_INITIAL_WIDTH = "500px" 14 | 15 | const getViewerModule = viewer => { 16 | try { 17 | return require(`viewers/${viewer.toLowerCase()}.jsx`); 18 | } catch (e) { 19 | console.log(`Error in finding the custom viewer for ${viewer}:`); 20 | console.log(e); 21 | return require(`viewers/${DEFAULT_VIEWER}.jsx`); 22 | } 23 | }; 24 | 25 | const ViewerPresenter = connect(props => ({ 26 | closeVersion: actions.closeVersion.withOptions({ version: props.version }), 27 | moveVersionLeft: actions.moveVersionLeft.withOptions({ 28 | version: props.version 29 | }), 30 | moveVersionRight: actions.moveVersionRight.withOptions({ 31 | version: props.version 32 | }) 33 | }))( 34 | function UnconnectedViewerPresenter({ 35 | version, 36 | error, 37 | data, 38 | viewer, 39 | documentKey, 40 | isLoading, 41 | closeVersion, 42 | moveVersionLeft, 43 | moveVersionRight 44 | }){ 45 | let message = null; 46 | if (!documentKey) { 47 | message = "Select a document!"; 48 | } else if (isLoading || !data) { 49 | message = "Loading..."; 50 | } 51 | if (error) { 52 | message = "Error: " + error; 53 | } 54 | 55 | const header = ( 56 |
57 | closeVersion()} 60 | onMovePositive={() => moveVersionRight()} 61 | onMoveNegative={() => moveVersionLeft()} 62 | > 63 | 64 | Document:{" "} 65 | 70 | 71 |
72 | 73 | Version: 74 | 75 |
76 |
77 | ); 78 | 79 | const module = getViewerModule(viewer); 80 | const Viewer = module.Viewer || module.default; 81 | return ( 82 | 87 | 88 | {data && } 89 | 90 | 91 | ); 92 | } 93 | ); 94 | 95 | class ViewerDataProvider extends React.Component { 96 | constructor(props) { 97 | super(props); 98 | this.state = { loadingCounter: 0 }; 99 | } 100 | 101 | componentDidMount() { 102 | this.updateData(this.props); 103 | } 104 | 105 | updateData({ documentKey, version, viewer }) { 106 | if (documentKey) { 107 | this.setState({ loadingCounter: this.state.loadingCounter + 1 }); 108 | 109 | fetch(`record?key=${escape(documentKey)}&stage=${escape(version)}`) 110 | .then(response => { 111 | if (!response.ok) { 112 | throw Error(response.statusText); 113 | } 114 | if (response.headers.get("content-type") == "application/json") { 115 | return response 116 | .json() 117 | .then(data => { 118 | if (data && data.error) { 119 | throw Error(data.error); 120 | } 121 | this.setState({ 122 | error: null, 123 | data, 124 | loadingCounter: this.state.loadingCounter - 1 125 | }); 126 | }) 127 | .catch(e => { 128 | throw e; 129 | }); 130 | } else { 131 | return response 132 | .blob() 133 | .then(data => { 134 | this.setState({ 135 | error: null, 136 | data: data, 137 | loadingCounter: this.state.loadingCounter - 1 138 | }); 139 | }) 140 | .catch(e => { 141 | throw e; 142 | }); 143 | } 144 | }) 145 | .catch(e => { 146 | this.setState({ 147 | error: e.message, 148 | data: null, 149 | blob: null, 150 | loadingCounter: this.state.loadingCounter - 1 151 | }); 152 | throw e; 153 | }); 154 | } 155 | } 156 | 157 | componentWillReceiveProps(nextProps) { 158 | let { documentKey, version, isReloading } = nextProps; 159 | if ( 160 | !( 161 | documentKey == this.props.documentKey && version == this.props.version 162 | ) && 163 | !isReloading 164 | ) { 165 | this.updateData(nextProps); 166 | } 167 | } 168 | 169 | render() { 170 | return ( 171 | 179 | ); 180 | } 181 | } 182 | 183 | export const ViewerPanel = connect({ 184 | documentKey: getters.documentKey, 185 | isReloading: getters.isReloading 186 | })(ViewerDataProvider); 187 | -------------------------------------------------------------------------------- /pynorama-js/src/viewers/doctree.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import _ from 'lodash' 3 | 4 | import { calculateLevelLengths } from 'tree/tree_operations' 5 | import createCollapsibleComponent from "tree/collapsible_tree"; 6 | import { createTextPresenter } from "tree/tagged_tree"; 7 | import { Toolbar, ExpansionLevelButtonGroup } from 'tree/toolbar' 8 | 9 | function getChildren(node) { 10 | return _.isString(node) ? null : node; 11 | } 12 | 13 | function renderText(node) { 14 | const children = getChildren(node); 15 | if (children) { 16 | let text = ""; 17 | for (let child of children) { 18 | text += renderText(child) + " "; 19 | } 20 | return text; 21 | } 22 | return node.toString(); 23 | } 24 | 25 | const TaggedTreeComponent = createCollapsibleComponent( 26 | getChildren, 27 | createTextPresenter(true), 28 | createTextPresenter(true), 29 | createTextPresenter(false) 30 | ); 31 | 32 | export default function DocTreeViewer({data}) { 33 | const levelLengths = calculateLevelLengths(data, getChildren); 34 | 35 | return ( 36 |
37 | 38 | 40 | 41 | 46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /pynorama-js/src/viewers/html.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import _ from 'lodash' 3 | 4 | export default function HtmlViewer({ data, info }) { 5 | return ( 6 | 8 | ); 9 | }; 10 | -------------------------------------------------------------------------------- /pynorama-js/src/viewers/json.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ObjectInspector from "react-object-inspector"; 3 | 4 | export default function JSONViewer({ data }) { 5 | return ; 6 | }; 7 | -------------------------------------------------------------------------------- /pynorama-js/src/viewers/pdf.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PDFObject from "pdfobject"; 3 | 4 | export default class PdfViewer extends React.Component { 5 | componentWillUpdate(nextProps, nextState) { 6 | let oldBlob = this.props.data; 7 | let newBlob = nextProps.data; 8 | if (oldBlob != newBlob) { 9 | const stage = nextProps.stage; 10 | 11 | PDFObject.embed(newBlob, this.pdfElement); 12 | } 13 | } 14 | 15 | componentDidMount() { 16 | PDFObject.embed(this.props.data, this.pdfElement); 17 | } 18 | 19 | render() { 20 | const { width, height, containerId } = this.props; 21 | const stage = this.props.stage; 22 | 23 | return
this.pdfElement = el} />; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pynorama-js/src/viewers/raw.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import _ from 'lodash' 3 | 4 | export default function RawViewer({ data, info }) { 5 | return ( 6 |
 7 |       {_.isString(data) ? data : JSON.stringify(data)}
 8 |     
9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /pynorama-js/src/viewers/xml.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { calculateLevelLengths } from 'tree/tree_operations' 4 | import createCollapsibleComponent from "tree/collapsible_tree"; 5 | import { Toolbar, ExpansionLevelButtonGroup } from 'tree/toolbar' 6 | 7 | import "css/xml.css"; 8 | 9 | class ExpandedNodePresenter extends React.Component { 10 | render() { 11 | return ( 12 | 13 | ); 14 | } 15 | } 16 | 17 | class ExpandedNodeContainer extends React.Component { 18 | render() { 19 | return ( 20 |
21 | {this.props.children} 22 | 23 |
24 | ); 25 | } 26 | } 27 | 28 | class CollapsedNodePresenter extends React.Component { 29 | render() { 30 | return ( 31 |
32 | 33 | ... 34 | 35 |
36 | ); 37 | } 38 | } 39 | 40 | export class LeafNodePresenter extends React.Component { 41 | render() { 42 | if (this.props.node.nodeType == Node.ELEMENT_NODE) { 43 | return ( 44 |
45 | 46 |
47 | ); 48 | } else { 49 | return {this.props.node.nodeValue}; 50 | } 51 | } 52 | } 53 | export class TagFrontPresenter extends React.Component { 54 | render() { 55 | let attributes = []; 56 | for (let attr of this.props.node.attributes) { 57 | attributes.push(); 58 | } 59 | return ( 60 | 61 | {"<" + this.props.node.nodeName} 62 | {attributes} 63 | {this.props.selfClosing ? "/>" : ">"} 64 | 65 | ); 66 | } 67 | } 68 | 69 | export class AttributePresenter extends React.Component { 70 | render() { 71 | return ( 72 | 73 | {this.props.node.nodeName} 74 | = 75 | 76 | {'"' + this.props.node.nodeValue + '"'} 77 | 78 | 79 | ); 80 | } 81 | } 82 | 83 | class TagEndPresenter extends React.Component { 84 | render() { 85 | return ( 86 | 87 | {""} 88 | 89 | ); 90 | } 91 | } 92 | 93 | export function getChildren(node) { 94 | if (node.nodeType == Node.ELEMENT_NODE) { 95 | let children = []; 96 | for (let child of node.childNodes) { 97 | if (child.nodeType == Node.ELEMENT_NODE) { 98 | children.push(child); 99 | } 100 | if ( 101 | child.nodeType == Node.TEXT_NODE && 102 | child.nodeValue.trim() != "" 103 | ) { 104 | children.push(child); 105 | } 106 | } 107 | return children; 108 | } else { 109 | return null; 110 | } 111 | } 112 | 113 | const Presenter = createCollapsibleComponent( 114 | getChildren, 115 | LeafNodePresenter, 116 | CollapsedNodePresenter, 117 | ExpandedNodePresenter, 118 | ExpandedNodeContainer 119 | ); 120 | 121 | export default function XmlViewer({data}) { 122 | const xmlString = data; 123 | const parser = new DOMParser(); 124 | const xmlDoc = parser.parseFromString(xmlString, "text/xml"); 125 | const root = xmlDoc.children[0]; 126 | 127 | const levelLengths = calculateLevelLengths(root, getChildren); 128 | 129 | return ( 130 |
131 | 132 | 134 | 135 | 139 |
140 | ); 141 | } 142 | -------------------------------------------------------------------------------- /pynorama-js/test/common/components_test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { configure, shallow, mount } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-15'; 4 | import {CloseableHeader} from '../../src/common/components'; 5 | 6 | configure({ adapter: new Adapter() }); 7 | 8 | describe('CloseableHeader', () => { 9 | it('should move negative', () => { 10 | const mockNegative = jest.fn(); 11 | const wrapper = mount(CloseableHeader({children: 'test', 12 | moveButtons: 'horizontal', 13 | onMoveNegative: mockNegative})) 14 | wrapper.find('Glyphicon').at(0).simulate('click'); 15 | expect(mockNegative).toBeCalled(); 16 | }); 17 | 18 | it('should close', () => { 19 | const mockClose = jest.fn(); 20 | const wrapper = mount(CloseableHeader({children: 'test', 21 | moveButtons: 'horizontal', 22 | onClose: mockClose})) 23 | wrapper.find('Glyphicon').at(1).simulate('click'); 24 | expect(mockClose).toBeCalled(); 25 | }); 26 | 27 | it('should move positive', () => { 28 | const mockPositive = jest.fn(); 29 | const wrapper = mount(CloseableHeader({children: 'test', 30 | moveButtons: 'horizontal', 31 | onMovePositive: mockPositive})) 32 | wrapper.find('Glyphicon').at(2).simulate('click'); 33 | expect(mockPositive).toBeCalled(); 34 | }); 35 | 36 | }) -------------------------------------------------------------------------------- /pynorama-js/test/datatable/dtypes_test.js: -------------------------------------------------------------------------------- 1 | import { isNumber, isDateTime } from '../../src/datatable/dtypes'; 2 | 3 | describe('dtypes isNumber', () => { 4 | it('should recognise ints', () => { 5 | const result = isNumber({ name: 'int64', str: ' { 10 | const result = isNumber({ name: 'float32', str: ' { 15 | const result = isNumber({ name: 'object', str: '|O8' }) 16 | expect(result).toBe(false); 17 | }); 18 | }) 19 | 20 | describe('dtypes isDateTime', () => { 21 | it('should recognise datetimes', () => { 22 | const result = isDateTime({ name: 'datetime64[ns]', str: ' { 27 | const result = isDateTime({ name: 'int64', str: ' { 10 | it('should convert no to False', () => { 11 | const wrapper = shallow() 12 | expect(wrapper.type()).toBe('div'); 13 | expect(wrapper.text()).toBe('False'); 14 | expect(wrapper.hasClass('cell-stretch')).toBe(true); 15 | }); 16 | 17 | it('should render boolean false as False', () => { 18 | const wrapper = shallow() 19 | expect(wrapper.text()).toBe('False'); 20 | }); 21 | 22 | it('should convert everything non-negative to True', () => { 23 | const wrapper = shallow() 24 | expect(wrapper.text()).toBe('True'); 25 | }); 26 | 27 | }) -------------------------------------------------------------------------------- /pynorama-js/test/datatable/presenters/cells/yes_no_test.js: -------------------------------------------------------------------------------- 1 | //import ShallowRenderer from 'react-test-renderer/shallow'; 2 | import React from 'react'; 3 | import { configure, shallow } from 'enzyme'; 4 | import Adapter from 'enzyme-adapter-react-15'; 5 | import YesNoCell from '../../../../src/datatable/presenters/cells/yes_no'; 6 | 7 | configure({ adapter: new Adapter() }); 8 | 9 | describe('YesNoCell', () => { 10 | it('should convert false to no', () => { 11 | const wrapper = shallow() 12 | expect(wrapper.type()).toBe('div'); 13 | expect(wrapper.text()).toBe('no'); 14 | expect(wrapper.hasClass('cell-stretch')).toBe(true); 15 | }); 16 | 17 | it('should preserve no', () => { 18 | const wrapper = shallow() 19 | expect(wrapper.text()).toBe('no'); 20 | }); 21 | 22 | it('should convert everything non-negative to yes', () => { 23 | const wrapper = shallow() 24 | expect(wrapper.text()).toBe('yes'); 25 | }); 26 | 27 | }) -------------------------------------------------------------------------------- /pynorama-js/test/datatable/presenters/default_row_test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { configure, shallow, mount } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-15'; 4 | import DefaultRow from '../../../src/datatable/presenters/default_row'; 5 | 6 | configure({ adapter: new Adapter() }); 7 | 8 | describe('DefaultRow', () => { 9 | it('should have clickable cursor and hover highlight', () => { 10 | const options = { className: 'classy', 11 | hoverHighlight: true, 12 | clickableCursor: true} 13 | const wrapper = shallow(DefaultRow({children: 'child', options: options, row: 'row'})) 14 | expect(wrapper.type()).toBe('tr'); 15 | expect(wrapper.text()).toBe('child'); 16 | expect(wrapper.hasClass('classy')).toBe(true); 17 | expect(wrapper.hasClass('datatable-hover-highlight')).toBe(true); 18 | expect(wrapper.hasClass('datatable-clickable-cursor')).toBe(true); 19 | }); 20 | 21 | it('should respond to click', () => { 22 | const mockClick = jest.fn(); 23 | const wrapper = mount( 24 | {DefaultRow({options: { onClick: mockClick}, row: 'row'})} 25 |
) 26 | 27 | wrapper.find('tr').simulate('click'); 28 | expect(mockClick).toBeCalled(); 29 | }); 30 | }) -------------------------------------------------------------------------------- /pynorama-js/test/datatable/presenters/util_test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { configure, shallow } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-15'; 4 | import { ColumnSelectFormGroup } from '../../../src/datatable/presenters/util'; 5 | 6 | configure({ adapter: new Adapter() }); 7 | 8 | describe('ColumnSelectFormGroup', () => { 9 | it('should render with the right options', () => { 10 | const dtypes = { a: { name: 'float32', str: ' { 9 | it('should render strings directly', () => { 10 | const wrapper = shallow() 11 | 12 | expect(wrapper.type()).toBe('pre'); 13 | }); 14 | 15 | it('should JSONify objects', () => { 16 | const document = {title: 'Oh my', 17 | topics: 'world peace'} 18 | const wrapper = shallow() 19 | expect(wrapper.text()).toBe('{"title":"Oh my","topics":"world peace"}'); 20 | }); 21 | }) 22 | -------------------------------------------------------------------------------- /pynorama-js/test/viewers/xml_test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { configure, mount } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-15'; 4 | import { AttributePresenter, TagFrontPresenter } from '../../src/viewers/xml'; 5 | 6 | configure({ adapter: new Adapter() }); 7 | 8 | describe('AttributePresenter', () => { 9 | it('should render attribute', () => { 10 | const att = document.createAttribute("pydata"); 11 | att.value = "london"; 12 | 13 | const wrapper = mount() 14 | 15 | expect(wrapper.find('.attributeName').text().toLowerCase()).toBe('pydata'); 16 | expect(wrapper.find('.attributeValue').text().toLowerCase()).toBe('"london"'); 17 | }); 18 | }) 19 | 20 | describe('TagFrontPresenter', () => { 21 | it('should render many attributes', () => { 22 | const node = document.createElement("span"); 23 | node.setAttribute("pydata","london"); 24 | node.setAttribute("man","AHL"); 25 | 26 | const wrapper = mount() 27 | // expect(wrapper.find('AttributePresenter').children()).to.have.length(2); 28 | 29 | expect(wrapper.find('AttributePresenter').at(0).text()).toEqual('pydata="london"') 30 | expect(wrapper.find('AttributePresenter').at(1).text()).toEqual('man="AHL"') 31 | 32 | }); 33 | }) 34 | -------------------------------------------------------------------------------- /pynorama-js/webpack.config.babel.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import webpack from 'webpack'; 3 | import path from 'path'; 4 | import WorkboxBuildWebpackPlugin from 'workbox-webpack-plugin'; 5 | 6 | const BUILD_DIR = path.resolve(__dirname, 'bin'); 7 | const SRC_DIR = path.resolve(__dirname, 'src'); 8 | const NODE_DIR = path.resolve(__dirname, 'node_modules'); 9 | 10 | const generateBabelEnvLoader = (browserlist) => { 11 | return { 12 | loader: 'babel-loader', 13 | options: { 14 | babelrc: false, 15 | presets: [ 16 | [ 17 | 'env', 18 | { 19 | modules: false, 20 | useBuiltIns: true, 21 | targets: { 22 | browsers: browserlist, 23 | }, 24 | }, 25 | ], 26 | 'react', 27 | ], 28 | plugins: [ 29 | 'transform-imports', 30 | 'syntax-dynamic-import', 31 | 'transform-object-rest-spread', 32 | 'transform-es2015-destructuring'], 33 | }, 34 | }; 35 | }; 36 | 37 | const config = { 38 | entry: { 39 | vendor: [ 40 | 'lodash', 41 | 'vis', 42 | 'react', 43 | 'react-bootstrap', 44 | 'react-dom', 45 | 'react-graph-vis', 46 | 'rc-slider', 47 | 'react-redux', 48 | 'react-resize-aware', 49 | 'react-select', 50 | 'redux', 51 | 'pdfobject'], 52 | view: path.resolve(SRC_DIR, 'view.jsx'), 53 | }, 54 | output: { 55 | path: BUILD_DIR, 56 | filename: '[name].bundle.js', 57 | }, 58 | module: { 59 | rules: [ 60 | { 61 | test: /\.jsx?$/, 62 | use: generateBabelEnvLoader([ 63 | // The last two versions of each browser, excluding versions 64 | // that don't support 43 | 44 | 45 |
46 | 47 |
48 | {% if webpack_dev_port %} 49 | 50 | 51 | {% else %} 52 | 53 | 54 | 55 | {% endif %} 56 | 57 | 58 | -------------------------------------------------------------------------------- /pynorama/view.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from .exceptions import ViewNotFound 3 | from .logging import logger 4 | 5 | 6 | views = OrderedDict() 7 | 8 | 9 | def register_views(*args): 10 | views.update({view.get_name(): view for view in args}) 11 | for view in args: 12 | view.load() 13 | 14 | 15 | def register_view(view): 16 | views[view.get_name()] = view 17 | try: 18 | view.load() 19 | except Exception as e: 20 | logger.error('Error loading {}: {}'.format(view.get_name(), e)) 21 | 22 | 23 | def get_view(name): 24 | if name not in views: 25 | raise ViewNotFound(name) 26 | return views[name] 27 | 28 | 29 | def list_views(): 30 | return [view for _, view in views.items()] 31 | 32 | 33 | class View(object): 34 | """Inherit this class to define the data and config of a new pynorama view.""" 35 | def __init__(self, name, description=''): 36 | self.description = description 37 | self.name = name 38 | 39 | def get_pipeline(self): 40 | return {} 41 | 42 | def get_record(self, key, stage): 43 | return None 44 | 45 | def get_table(self): 46 | raise NotImplementedError( 47 | 'Please implement get_table in your View derived class.') 48 | 49 | def load(self): 50 | pass 51 | 52 | def get_config(self): 53 | return {} 54 | 55 | def get_description(self): 56 | return self.description 57 | 58 | def get_name(self): 59 | return self.name 60 | 61 | def get_metadata(self): 62 | return {} 63 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | mongodb_fixture_dir = tests/unit/fixtures 3 | addopts = --cov=pynorama --cov-report=xml --junitxml=result.xml -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | # Convert Markdown to RST for PyPI 4 | # http://stackoverflow.com/a/26737672 5 | try: 6 | import pypandoc 7 | long_description = pypandoc.convert('README.md', 'rst') 8 | changelog = pypandoc.convert('CHANGES.md', 'rst') 9 | except (IOError, ImportError, OSError): 10 | long_description = open('README.md').read() 11 | changelog = open('CHANGES.md').read() 12 | 13 | setup( 14 | name='pynorama', 15 | version='0.4.3', 16 | author="Man AHL Technology", 17 | author_email="ManAHLTech@ahl.com", 18 | description='Natural Language Processing Visualization Tool', 19 | long_description='\n'.join((long_description, changelog)), 20 | keywords=['ahl', 'visualization', 'NLP', 'data discovery'], 21 | url='https://github.com/manahl/pynorama', 22 | install_requires=[ 23 | 'flask', 24 | 'numpy', 25 | 'pandas' 26 | ], 27 | extras_require={ 28 | 'docs': [ 29 | 'pypandoc', 30 | 'sphinx', 31 | 'nbsphinx', 32 | 'sphinxcontrib-napoleon' 33 | ], 34 | 'mongo': [ 35 | 'pymongo' 36 | ], 37 | 'examples': [ 38 | 'nltk', 39 | 'spacy' 40 | ] 41 | }, 42 | setup_requires=[ 43 | 'pytest-runner' 44 | ], 45 | tests_require=[ 46 | 'mock', 47 | 'pytest', 48 | 'pytest-cov', 49 | 'pytest-shutil', 50 | 'pytest-mongodb', 51 | 'numexpr' 52 | ], 53 | classifiers=[ 54 | 'Development Status :: 3 - Alpha', 55 | 'Topic :: Utilities', 56 | 'Framework :: Flask', 57 | 'Topic :: Scientific/Engineering', 58 | 'Topic :: Scientific/Engineering :: Visualization', 59 | 'Programming Language :: Python', 60 | 'Programming Language :: Python :: 2.7' 61 | ], 62 | packages=find_packages( 63 | where='.', 64 | exclude=['*.tests', '*.tests.*', 'tests.*', 'tests', 'example'] 65 | ), 66 | include_package_data=True, 67 | license='LGPL-2.1', 68 | ) 69 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/pynorama/4781a1277ba8f059113f8d178efc0993e3cd7454/tests/__init__.py -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/pynorama/4781a1277ba8f059113f8d178efc0993e3cd7454/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/fixtures/sessions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "view_name": "test", 4 | "sessions": { 5 | "foo": "bar", 6 | "xyz": 1, 7 | "someday": "2017-12-19T13:18:44.745Z" 8 | } 9 | }, 10 | { 11 | "view_name": "other_test", 12 | "sessions": { 13 | "foo": "BAR", 14 | "xyz": 2, 15 | "someday": "1917-12-19T13:18:44.745Z" 16 | } 17 | } 18 | ] -------------------------------------------------------------------------------- /tests/unit/sessions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/pynorama/4781a1277ba8f059113f8d178efc0993e3cd7454/tests/unit/sessions/__init__.py -------------------------------------------------------------------------------- /tests/unit/sessions/test_base_store.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from mock import sentinel 3 | from pynorama.sessions.base_store import SessionStore 4 | 5 | 6 | class MockSessionStore(SessionStore): 7 | def save_sessions(self, view_name, sessions): 8 | pass 9 | 10 | def load_sessions(self, view_name): 11 | pass 12 | 13 | 14 | def test_base_session_store_save_sessions(): 15 | store = SessionStore() 16 | with pytest.raises(NotImplementedError) as e: 17 | store.save_sessions(sentinel.view_name, sentinel.sessions) 18 | assert 'Please implement save_sessions' in str(e) 19 | 20 | 21 | def test_base_session_store_load_sessions(): 22 | store = SessionStore() 23 | with pytest.raises(NotImplementedError) as e: 24 | store.load_sessions(sentinel.view_name) 25 | assert 'Please implement load_sessions' in str(e) 26 | 27 | 28 | def test_base_session_store_get_and_set_sessions(): 29 | store = MockSessionStore() 30 | store.set_sessions(sentinel.view_name, sentinel.sessions) 31 | assert sentinel.sessions == store.get_sessions(sentinel.view_name) 32 | -------------------------------------------------------------------------------- /tests/unit/sessions/test_json_file.py: -------------------------------------------------------------------------------- 1 | pytest_plugins = ['pytest_shutil'] 2 | 3 | import os 4 | 5 | from pynorama.sessions import JsonFileSessionStore 6 | from json import load 7 | 8 | 9 | def test_load_sessions(workspace): 10 | raw_session = """ 11 | {"foo": "bar", "xyz": 1, "someday": "2017-12-19T13:18:44.745Z"} 12 | """ 13 | 14 | with open(os.path.join(workspace.workspace, 'test.json'), 'w') as f: 15 | f.write(raw_session) 16 | 17 | store = JsonFileSessionStore(workspace.workspace) 18 | session_data = store.load_sessions('test') 19 | assert {"foo": "bar", "xyz": 1, "someday": "2017-12-19T13:18:44.745Z"} == session_data 20 | 21 | 22 | def test_save_sessions(workspace): 23 | store = JsonFileSessionStore(workspace.workspace) 24 | expected = {"foo": "bar", "xyz": 1, "someday": "2017-12-19T13:18:44.745Z"} 25 | store.save_sessions('test', expected) 26 | 27 | with open(os.path.join(workspace.workspace, 'test.json'), 'r') as f: 28 | actual = load(f) 29 | 30 | assert actual == expected 31 | -------------------------------------------------------------------------------- /tests/unit/sessions/test_memory.py: -------------------------------------------------------------------------------- 1 | from mock import sentinel 2 | from pynorama.sessions.memory import InMemorySessionStore 3 | 4 | 5 | def test_memory_session_store_get_and_set_sessions(): 6 | store = InMemorySessionStore() 7 | store.set_sessions(sentinel.view_name, sentinel.sessions) 8 | assert sentinel.sessions == store.get_sessions(sentinel.view_name) 9 | 10 | 11 | def test_memory_session_store_get_sessions_nonexistent(): 12 | store = InMemorySessionStore() 13 | assert {} == store.get_sessions(sentinel.view_name) 14 | -------------------------------------------------------------------------------- /tests/unit/sessions/test_mongo.py: -------------------------------------------------------------------------------- 1 | from pynorama.sessions import MongoSessionStore 2 | from pytest_mongodb.plugin import mongodb 3 | from mongomock.mongo_client import MongoClient 4 | 5 | 6 | def test_load_sessions(mongodb): 7 | assert 'sessions' in mongodb.collection_names() 8 | sesstion_data = MongoSessionStore(mongodb.sessions).load_sessions('test') 9 | assert {"foo": "bar", "xyz": 1, "someday": "2017-12-19T13:18:44.745Z"} == sesstion_data 10 | 11 | 12 | def test_save_sessions(): 13 | collection = MongoClient().db.collection 14 | store = MongoSessionStore(collection) 15 | store.save_sessions('test', { 16 | "foo": "bar", 17 | "xyz": 1, 18 | "someday": "2017-12-19T13:18:44.745Z" 19 | }) 20 | 21 | assert collection.count() == 1 22 | views_data = collection.find_one() 23 | assert 'view_name' in views_data 24 | assert views_data.get('view_name') == 'test' 25 | assert {"foo": "bar", "xyz": 1, "someday": "2017-12-19T13:18:44.745Z"} == views_data.get('sessions') 26 | -------------------------------------------------------------------------------- /tests/unit/table/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/man-group/pynorama/4781a1277ba8f059113f8d178efc0993e3cd7454/tests/unit/table/__init__.py -------------------------------------------------------------------------------- /tests/unit/table/test_pandas_table.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | from pynorama.table.pandas_table import (sample_transform, sort_transform, quantile_range_transform, 4 | nans_transform, search_transform, query_transform, 5 | histogram_transform, PandasTable) 6 | 7 | 8 | def test_sample_transform(): 9 | original = pd.DataFrame({'distance': [20, 10, 30]}, index=['a', 'b', 'c']) 10 | transformed = sample_transform(PandasTable(original), {'fraction': 1.0}) 11 | expected = pd.DataFrame({'distance': [20, 10, 30]}, index=['a', 'b', 'c']) 12 | assert expected.equals(transformed.to_pandas()) 13 | 14 | 15 | def test_sort_transform(): 16 | original = pd.DataFrame({'distance': [20, 10, 30]}, index=['a', 'b', 'c']) 17 | transformed = sort_transform(PandasTable(original), {'column': 'distance', 'ascending': False}) 18 | expected = pd.DataFrame({'distance': [30, 20, 10]}, index=['c', 'a', 'b']) 19 | assert expected.equals(transformed.to_pandas()) 20 | 21 | 22 | def test_quantile_range_transform_lower(): 23 | original = pd.DataFrame({'distance': [20, 10, 30, 40]}, 24 | index=['a', 'b', 'c', 'd']) 25 | transformed = quantile_range_transform(PandasTable(original), 26 | {'column': 'distance', 27 | 'lower': 0, 28 | 'upper': 0.5}) 29 | expected = pd.DataFrame({'distance': [20, 10]}, 30 | index=['a', 'b']) 31 | assert expected.equals(transformed.to_pandas()) 32 | 33 | 34 | def test_quantile_range_transform_upper(): 35 | original = pd.DataFrame({'distance': [20, 10, 30, 40]}, 36 | index=['a', 'b', 'c', 'd']) 37 | transformed = quantile_range_transform(PandasTable(original), 38 | {'column': 'distance', 39 | 'lower': 0.5, 40 | 'upper': 1}) 41 | expected = pd.DataFrame({'distance': [30, 40]}, 42 | index=['c', 'd']) 43 | assert expected.equals(transformed.to_pandas()) 44 | 45 | 46 | def test_histogram_transform_numeric(): 47 | original = pd.DataFrame({'distance': [20, 10, 30, 40]}, 48 | index=['a', 'b', 'c', 'd']) 49 | transformed = histogram_transform(PandasTable(original), 50 | {'column': 'distance', 51 | 'bins': 2}) 52 | expected_side_result = {'x': [17.5, 32.5], 53 | 'y': [2, 2], 54 | 'width': [15., 15.]} 55 | assert original.equals(transformed.to_pandas()) 56 | assert np.allclose(expected_side_result['x'], transformed.side_result['x']) 57 | assert np.allclose(expected_side_result['y'], transformed.side_result['y']) 58 | assert np.allclose(expected_side_result['width'], transformed.side_result['width']) 59 | 60 | 61 | def test_histogram_transform_categorical(): 62 | original = pd.DataFrame({'token': ['t1', 't1', 't2', 't2']}, 63 | index=['a', 'b', 'c', 'd']) 64 | transformed = histogram_transform(PandasTable(original), 65 | {'column': 'token', 66 | 'bins': 2}) 67 | expected_side_result = {'x': ['t1', 't2', 'Other'], 68 | 'y': [2, 2, 0], 69 | 'width': None} 70 | assert original.equals(transformed.to_pandas()) 71 | assert expected_side_result.keys() == transformed.side_result.keys() 72 | assert expected_side_result['width'] == transformed.side_result['width'] 73 | assert set(expected_side_result['x']) == set(transformed.side_result['x']) # 't1', 't2' may be flipped 74 | assert expected_side_result['y'] == transformed.side_result['y'] 75 | 76 | 77 | def test_nans_transform_hide(): 78 | original = pd.DataFrame({'distance': [20, 10, None, 40], 79 | 'speed': [None, 300, 200, 100]}, 80 | index=['a', 'b', 'c', 'd']) 81 | transformed = nans_transform(PandasTable(original), 82 | {'filter': 'hide'}) 83 | expected = pd.DataFrame({'distance': [10, 40], 84 | 'speed': [300, 100]}, 85 | index=['b', 'd'], 86 | dtype=('float64', 'float64')) 87 | assert expected.equals(transformed.to_pandas()) 88 | 89 | 90 | def test_nans_transform_show(): 91 | original = pd.DataFrame({'distance': [20, 10, None, 40], 92 | 'speed': [None, 300, 200, 100]}, 93 | index=['a', 'b', 'c', 'd']) 94 | transformed = nans_transform(PandasTable(original), 95 | {'filter': 'show'}) 96 | expected = pd.DataFrame({'distance': [20, None], 97 | 'speed': [None, 200]}, 98 | index=['a', 'c']) 99 | assert expected.equals(transformed.to_pandas()) 100 | 101 | 102 | def test_search_transform_int(): 103 | original = pd.DataFrame({'distance': [20, 10, 30, 40], 104 | 'speed': [100, 300, 200, 100]}, 105 | index=['a', 'b', 'c', 'd']) 106 | transformed = search_transform(PandasTable(original), 107 | {'column': 'distance', 108 | 'searchterm': '10'}) 109 | expected = pd.DataFrame({'distance': [10], 110 | 'speed': [300]}, 111 | index=['b']) 112 | assert expected.equals(transformed.to_pandas()) 113 | 114 | 115 | def test_search_transform_string(): 116 | original = pd.DataFrame({'distance': [20, 10, 30, 40], 117 | 'token': ['t1', 't1', 't2', 't2']}, 118 | index=['a', 'b', 'c', 'd']) 119 | transformed = search_transform(PandasTable(original), 120 | {'column': 'token', 121 | 'searchterm': 't2'}) 122 | expected = pd.DataFrame({'distance': [30, 40], 123 | 'token': ['t2', 't2']}, 124 | index=['c', 'd']) 125 | assert expected.equals(transformed.to_pandas()) 126 | 127 | 128 | def test_query_transform(): 129 | original = pd.DataFrame({'distance': [20, 10, 30, 40], 130 | 'token': ['t1', 't1', 't2', 't2']}, 131 | index=['a', 'b', 'c', 'd']) 132 | transformed = query_transform(PandasTable(original), 133 | {'query': 'distance > 30'}) 134 | expected = pd.DataFrame({'distance': [40], 135 | 'token': ['t2']}, 136 | index=['d']) 137 | assert expected.equals(transformed.to_pandas()) 138 | 139 | 140 | def test_query_transform_mean(): 141 | original = pd.DataFrame({'distance': [20, 10, 30, 40], 142 | 'token': ['t1', 't1', 't2', 't2']}, 143 | index=['a', 'b', 'c', 'd']) 144 | transformed = query_transform(PandasTable(original), 145 | {'query': 'distance < mean[distance]'}) 146 | expected = pd.DataFrame({'distance': [20, 10], 147 | 'token': ['t1', 't1']}, 148 | index=['a', 'b']) 149 | assert expected.equals(transformed.to_pandas()) 150 | 151 | 152 | def test_query_transform_quantile(): 153 | original = pd.DataFrame({'distance': [20, 10, 30, 40], 154 | 'token': ['t1', 't1', 't2', 't2']}, 155 | index=['a', 'b', 'c', 'd']) 156 | transformed = query_transform(PandasTable(original), 157 | {'query': 'distance < quantile[distance,0.3]'}) 158 | expected = pd.DataFrame({'distance': [10], 159 | 'token': ['t1']}, 160 | index=['b']) 161 | assert expected.equals(transformed.to_pandas()) 162 | 163 | 164 | def test_pandas_table_len(): 165 | original = pd.DataFrame({'distance': [20, 10, 30, 40]}, 166 | index=['a', 'b', 'c', 'd']) 167 | assert 4 == len(PandasTable(original)) 168 | 169 | 170 | def test_pandas_table_to_pandas(): 171 | original = pd.DataFrame({'distance': [20, 10, 30, 40]}, 172 | index=['a', 'b', 'c', 'd']) 173 | assert original.equals(PandasTable(original).to_pandas()) 174 | 175 | 176 | def test_pandas_table_apply_bounds(): 177 | original = pd.DataFrame({'distance': [20, 10, 30, 40]}, 178 | index=['a', 'b', 'c', 'd']) 179 | expected = pd.DataFrame({'distance': [10, 30]}, 180 | index=['b', 'c']) 181 | assert expected.equals(PandasTable(original).apply_bounds(1, 2).to_pandas()) 182 | 183 | 184 | def test_pandas_table_process_transforms(): 185 | original = pd.DataFrame({'distance': [20, 10, 30, 40]}, 186 | index=['a', 'b', 'c', 'd']) 187 | transforms = [{'type': 'quantile_range', 188 | 'column': 'distance', 189 | 'lower': 0, 190 | 'upper': 0.5}, 191 | {'type': 'sort', 192 | 'column': 'distance', 193 | 'ascending': True}] 194 | transformed, errors, sides = PandasTable(original).process_transforms(transforms) 195 | expected = pd.DataFrame({'distance': [10, 20]}, 196 | index=['b', 'a']) 197 | assert expected.equals(transformed.to_pandas()) 198 | assert errors == dict() 199 | assert sides == dict() 200 | -------------------------------------------------------------------------------- /tests/unit/test_make_config.py: -------------------------------------------------------------------------------- 1 | from pynorama.make_config import make_config 2 | 3 | 4 | def test_make_config(): 5 | config = make_config('a', 6 | show_index=False, 7 | available_transforms=['sort', 'search'], 8 | initial_visible_columns=['a', 'c']) 9 | expected = { 10 | 'datatable': { 11 | 'state': { 12 | 'initialVisibleColumns': ['a', 'c'] 13 | }, 14 | 'transforms': { 15 | 'toolbox': { 16 | 'available': ['sort', 'search'] 17 | } 18 | }, 19 | 'table': { 20 | 'show_index': False, 21 | 'rows': { 22 | 'keyField': 'a' 23 | } 24 | } 25 | } 26 | } 27 | assert expected == config 28 | -------------------------------------------------------------------------------- /tests/unit/test_pynorama.py: -------------------------------------------------------------------------------- 1 | def test_imports(): 2 | from pynorama import register_view, register_views, View, make_server, make_config 3 | from pynorama.table import PandasTable, MongoTable, Table 4 | from pynorama.sessions import JsonFileSessionStore, InMemorySessionStore, MongoSessionStore, SessionStore 5 | -------------------------------------------------------------------------------- /tests/unit/test_view.py: -------------------------------------------------------------------------------- 1 | from pynorama.view import register_view, list_views, View 2 | 3 | 4 | def test_view_order_matches(): 5 | register_view(View(name='view_5', description='View 5 description')) 6 | register_view(View(name='view_3', description='View 3 description')) 7 | register_view(View(name='view_2', description='View 2 description')) 8 | register_view(View(name='view_4', description='View 4 description')) 9 | register_view(View(name='view_1', description='View 1 description')) 10 | assert ['view_5', 'view_3', 'view_2', 'view_4', 'view_1'] == [v.get_name() for v in list_views()] 11 | --------------------------------------------------------------------------------