├── ipyscales ├── tests │ ├── __init__.py │ ├── test_continuous.py │ ├── test_nbextension_path.py │ ├── test_colorbar.py │ ├── test_values.py │ ├── test_datawidgets.py │ ├── test_selectors.py │ ├── conftest.py │ ├── test_colorarray.py │ └── test_color.py ├── _version.py ├── _frontend.py ├── nbextension │ ├── __init__.py │ └── static │ │ └── extension.js ├── _example_helper.py ├── __init__.py ├── value.py ├── traittypes.py ├── datawidgets.py ├── continuous.py ├── colorbar.py ├── colorarray.py ├── selectors.py ├── scale.py └── color.py ├── .coveragerc ├── docs ├── source │ ├── examples │ │ ├── colorbar.nblink │ │ ├── colormaps.nblink │ │ ├── scaled-data.nblink │ │ ├── introduction.nblink │ │ └── index.rst │ ├── _static │ │ └── helper.js │ ├── index.rst │ ├── develop-install.rst │ ├── installing.rst │ └── conf.py ├── environment.yml ├── Makefile └── make.bat ├── js ├── src │ ├── colormap │ │ ├── index.ts │ │ ├── editor.ts │ │ ├── colorbar.ts │ │ └── scales.ts │ ├── index.ts │ ├── version.ts │ ├── extension.ts │ ├── widgets.ts │ ├── plugin.ts │ ├── utils.ts │ ├── continuous.ts │ ├── value.ts │ ├── selectors.ts │ ├── datawidgets.ts │ └── scale.ts ├── styles │ └── plugin.css ├── .npmignore ├── tests │ ├── tsconfig.json │ ├── src │ │ ├── utils.spec.ts │ │ ├── helpers.spec.ts │ │ ├── colorbar.spec.ts │ │ ├── value.spec.ts │ │ ├── scale.spec.ts │ │ ├── selectors.spec.ts │ │ ├── continuous.spec.ts │ │ └── colormap.spec.ts │ └── karma.conf.js ├── tsconfig.json ├── webpack.config.js └── package.json ├── jupyter-config └── nbconfig │ └── notebook.d │ └── jupyter-scales.json ├── pytest.ini ├── pyproject.toml ├── codecov.yml ├── readthedocs.yml ├── setup.cfg ├── MANIFEST.in ├── README.md ├── .github └── workflows │ ├── js.yml │ └── python.yml ├── examples ├── introduction.ipynb └── colorbar.ipynb ├── LICENSE.txt ├── .gitignore └── setup.py /ipyscales/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = ipyscales/tests/* 3 | 4 | [report] 5 | omit = ipyscales/tests/* 6 | -------------------------------------------------------------------------------- /docs/source/examples/colorbar.nblink: -------------------------------------------------------------------------------- 1 | { 2 | "path": "../../../examples/colorbar.ipynb" 3 | } 4 | -------------------------------------------------------------------------------- /docs/source/examples/colormaps.nblink: -------------------------------------------------------------------------------- 1 | { 2 | "path": "../../../examples/colormaps.ipynb" 3 | } 4 | -------------------------------------------------------------------------------- /docs/source/examples/scaled-data.nblink: -------------------------------------------------------------------------------- 1 | { 2 | "path": "../../../examples/scaled-data.ipynb" 3 | } 4 | -------------------------------------------------------------------------------- /docs/source/examples/introduction.nblink: -------------------------------------------------------------------------------- 1 | { 2 | "path": "../../../examples/introduction.ipynb" 3 | } 4 | -------------------------------------------------------------------------------- /js/src/colormap/index.ts: -------------------------------------------------------------------------------- 1 | 2 | export * from './colorbar'; 3 | export * from './editor'; 4 | export * from './scales'; 5 | -------------------------------------------------------------------------------- /jupyter-config/nbconfig/notebook.d/jupyter-scales.json: -------------------------------------------------------------------------------- 1 | { 2 | "load_extensions": { 3 | "jupyter-scales/extension": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /js/styles/plugin.css: -------------------------------------------------------------------------------- 1 | 2 | /* Ensure strokes use the foreground color */ 3 | svg.jupyterColorbar { 4 | color: var(--jp-content-font-color1); 5 | } 6 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = ipyscales/tests examples 3 | norecursedirs = node_modules .ipynb_checkpoints 4 | addopts = --nbval --current-env 5 | -------------------------------------------------------------------------------- /docs/source/_static/helper.js: -------------------------------------------------------------------------------- 1 | var cache_require = window.require; 2 | 3 | window.addEventListener('load', function() { 4 | window.require = cache_require; 5 | }); 6 | -------------------------------------------------------------------------------- /js/.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | tests/ 4 | .jshintrc 5 | # Ignore any build output from python: 6 | dist/*.tar.gz 7 | dist/*.wheel 8 | 9 | # Ignore dev noise 10 | coverage/ 11 | lab-dist/ 12 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | # Minimum requirements for the build system to execute. 3 | requires = ["jupyterlab==3.*","setuptools", "wheel", "jupyter-packaging"] 4 | build-backend = "setuptools.build_meta" 5 | -------------------------------------------------------------------------------- /ipyscales/_version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | version_info = (0, 7, 1, "dev") 8 | __version__ = ".".join(map(str, version_info)) 9 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: off 2 | # show coverage in CI status, but never consider it a failure 3 | coverage: 4 | status: 5 | project: 6 | default: 7 | target: 0% 8 | patch: 9 | default: 10 | target: 0% 11 | ignore: 12 | - "ipyscales/tests" 13 | -------------------------------------------------------------------------------- /docs/environment.yml: -------------------------------------------------------------------------------- 1 | 2 | name: ipyscales_docs 3 | channels: 4 | - conda-forge 5 | - nodefaults 6 | dependencies: 7 | - nodejs 8 | - numpy 9 | - sphinx 10 | - pip 11 | - ipywidgets 12 | - pypandoc 13 | - ipydatawidgets 14 | - pip: 15 | - git+https://github.com/spatialaudio/nbsphinx.git@master#egg=nbsphinx 16 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | configuration: docs/source/conf.py 5 | 6 | formats: 7 | - epub 8 | 9 | python: 10 | version: 3.7 11 | install: 12 | - method: pip 13 | path: . 14 | extra_requirements: 15 | - examples 16 | - docs 17 | conda: 18 | environment: docs/environment.yml 19 | -------------------------------------------------------------------------------- /ipyscales/_frontend.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | """ 8 | Information about the frontend package of the widgets. 9 | """ 10 | 11 | module_name = "jupyter-scales" 12 | module_version = "^3.0.0" 13 | -------------------------------------------------------------------------------- /js/src/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | export * from './scale'; 5 | export * from './continuous'; 6 | export * from './colormap'; 7 | export * from './datawidgets'; 8 | export * from './selectors'; 9 | export * from './value'; 10 | 11 | export * from './version'; 12 | -------------------------------------------------------------------------------- /js/tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "noImplicitAny": true, 5 | "lib": ["dom", "es6"], 6 | "noEmitOnError": true, 7 | "strictNullChecks": true, 8 | "module": "commonjs", 9 | "moduleResolution": "node", 10 | "target": "ES6", 11 | "outDir": "build", 12 | "skipLibCheck": true, 13 | "sourceMap": true 14 | }, 15 | "include": [ 16 | "src/*.ts", 17 | "../src/**/*.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /ipyscales/nbextension/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | 8 | def _jupyter_nbextension_paths(): 9 | return [ 10 | { 11 | "section": "notebook", 12 | "src": "nbextension/static", 13 | "dest": "jupyter-scales", 14 | "require": "jupyter-scales/extension", 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /ipyscales/_example_helper.py: -------------------------------------------------------------------------------- 1 | def example_id_gen(max_n=1000): 2 | for i in range(1, max_n): 3 | yield "ipyscales_example_model_%03d" % (i,) 4 | 5 | 6 | def use_example_model_ids(): 7 | from ipywidgets import Widget 8 | 9 | old_init = Widget.__init__ 10 | id_gen = example_id_gen() 11 | 12 | def new_init(self, *args, **kwargs): 13 | kwargs["model_id"] = next(id_gen) 14 | old_init(self, *args, **kwargs) 15 | 16 | Widget.__init__ = new_init 17 | -------------------------------------------------------------------------------- /ipyscales/tests/test_continuous.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | import pytest 8 | 9 | from ..continuous import LinearScale, LogScale, PowScale 10 | 11 | 12 | def test_linearscale_creation_blank(): 13 | LinearScale() 14 | 15 | 16 | def test_logscale_creation_blank(): 17 | w = LogScale() 18 | 19 | 20 | def test_powscale_creation_blank(): 21 | w = PowScale() 22 | -------------------------------------------------------------------------------- /ipyscales/nbextension/static/extension.js: -------------------------------------------------------------------------------- 1 | // Entry point for the notebook bundle containing custom model definitions. 2 | // 3 | define(function() { 4 | "use strict"; 5 | 6 | window['requirejs'].config({ 7 | map: { 8 | '*': { 9 | 'jupyter-scales': 'nbextensions/jupyter-scales/index', 10 | }, 11 | } 12 | }); 13 | // Export the required load_ipython_extension function 14 | return { 15 | load_ipython_extension : function() {} 16 | }; 17 | }); 18 | -------------------------------------------------------------------------------- /js/src/version.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | const data = require('../package.json'); 5 | 6 | /** 7 | * The _model_module_version/_view_module_version this package implements. 8 | * 9 | * The html widget manager assumes that this is the same as the npm package 10 | * version number. 11 | */ 12 | export const MODULE_VERSION = data.version; 13 | 14 | /* 15 | * The current package name. 16 | */ 17 | export const MODULE_NAME = data.name; 18 | -------------------------------------------------------------------------------- /docs/source/examples/index.rst: -------------------------------------------------------------------------------- 1 | 2 | Examples 3 | ======== 4 | 5 | This section contains several examples generated from Jupyter notebooks. 6 | The widgets have been embedded into the page for demonstrative purposes. 7 | 8 | .. todo:: 9 | 10 | Add links to notebooks in examples folder similar to the initial 11 | one. This is a manual step to ensure only those examples that 12 | are suited for inclusion are used. 13 | 14 | 15 | .. toctree:: 16 | :glob: 17 | 18 | introduction 19 | colormaps 20 | colorbar 21 | scaled-data 22 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [metadata] 5 | description-file = README.md 6 | license_file = LICENSE.txt 7 | 8 | [manifix] 9 | known-excludes = 10 | .git* 11 | .git/**/* 12 | .coveragerc 13 | .travis.yml 14 | .vscode/**/* 15 | appveyor.yml 16 | codecov.yml 17 | lerna-debug.log 18 | readthedocs.yml 19 | **/node_modules/**/* 20 | **/__py_cache__/**/* 21 | **/*.pyc 22 | js/coverage/**/* 23 | js/lib/**/* 24 | js/test/build/**/* 25 | js/package-lock.json 26 | js/yarn.lock 27 | *.egg-info 28 | -------------------------------------------------------------------------------- /ipyscales/tests/test_nbextension_path.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | 8 | def test_nbextension_path(): 9 | # Check that magic function can be imported from package root: 10 | from ipyscales import _jupyter_nbextension_paths 11 | 12 | # Ensure that it can be called without incident: 13 | path = _jupyter_nbextension_paths() 14 | # Some sanity checks: 15 | assert len(path) == 1 16 | assert isinstance(path[0], dict) 17 | -------------------------------------------------------------------------------- /js/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "esModuleInterop":true, 5 | "lib": ["es2015", "dom"], 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "noEmitOnError": true, 9 | "noUnusedLocals": true, 10 | "outDir": "lib", 11 | "resolveJsonModule": true, 12 | "rootDir": "src", 13 | "skipLibCheck": true, 14 | "sourceMap": true, 15 | "strict": true, 16 | "strictPropertyInitialization": false, 17 | "target": "es2015" 18 | }, 19 | "include": [ 20 | "src/**/*.ts", 21 | "src/**/*.tsx" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /js/src/extension.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | // Entry point for the notebook bundle containing custom model definitions. 5 | // 6 | // Setup notebook base URL 7 | // 8 | // Some static assets may be required by the custom widget javascript. The base 9 | // url for the notebook is not known at build time and is therefore computed 10 | // dynamically. 11 | (window as any).__webpack_public_path__ = document.querySelector('body')!.getAttribute('data-base-url') + 'nbextensions/jupyter-scales'; 12 | 13 | export * from './index'; 14 | -------------------------------------------------------------------------------- /ipyscales/tests/test_colorbar.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Vidar Tonaas Fauske. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | import pytest 8 | 9 | from traitlets import TraitError 10 | 11 | from ..color import LinearColorScale 12 | from ..colorbar import ColorBar 13 | 14 | 15 | def test_colorbar_creation_blank(): 16 | with pytest.raises(TraitError): 17 | ColorBar() 18 | 19 | 20 | def test_colorbar_creation(): 21 | colormap = LinearColorScale(range=("red", "blue")) 22 | w = ColorBar(colormap=colormap) 23 | assert w.colormap is colormap 24 | -------------------------------------------------------------------------------- /ipyscales/tests/test_values.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | import pytest 8 | 9 | from traitlets import TraitError, Undefined 10 | 11 | from ..continuous import LinearScale 12 | from ..value import ScaledValue 13 | 14 | 15 | def test_scaled_creation_blank(): 16 | with pytest.raises(TraitError): 17 | w = ScaledValue() 18 | 19 | 20 | def test_scaled_creation(): 21 | scale = LinearScale() 22 | w = ScaledValue(input=5, scale=scale) 23 | assert w.input is 5 24 | assert w.scale is scale 25 | -------------------------------------------------------------------------------- /ipyscales/tests/test_datawidgets.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | import pytest 8 | 9 | import numpy as np 10 | from traitlets import TraitError, Undefined 11 | 12 | from ..continuous import LinearScale 13 | from ..datawidgets import ScaledArray 14 | 15 | 16 | def test_scaled_creation_blank(): 17 | with pytest.raises(TraitError): 18 | w = ScaledArray() 19 | 20 | 21 | def test_scaled_creation(): 22 | data = np.zeros((2, 4)) 23 | scale = LinearScale() 24 | w = ScaledArray(data, scale) 25 | assert w.data is data 26 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include README.md 3 | include pyproject.toml 4 | include MANIFEST.in 5 | 6 | include pytest.ini 7 | include .coveragerc 8 | 9 | # Documentation 10 | graft docs 11 | exclude docs/\#* 12 | prune docs/build 13 | prune docs/gh-pages 14 | prune docs/dist 15 | 16 | # Examples 17 | graft examples 18 | 19 | # Javascript files 20 | graft ipyscales/nbextension 21 | graft js 22 | prune **/node_modules 23 | prune js/coverage 24 | prune js/lib 25 | 26 | # Patterns to exclude from any directory 27 | global-exclude *~ 28 | global-exclude *.pyc 29 | global-exclude *.pyo 30 | global-exclude .git 31 | global-exclude .ipynb_checkpoints 32 | global-exclude *.tsbuildinfo 33 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = ipyscales 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | 2 | ipyscales 3 | ===================================== 4 | 5 | Version: |release| 6 | 7 | A Jupyter widgets library for scales. 8 | 9 | 10 | Quickstart 11 | ---------- 12 | 13 | To get started with ipyscales, install with pip:: 14 | 15 | pip install ipyscales 16 | 17 | or with conda:: 18 | 19 | conda install ipyscales 20 | 21 | 22 | Contents 23 | -------- 24 | 25 | .. toctree:: 26 | :maxdepth: 2 27 | :caption: Installation and usage 28 | 29 | installing 30 | 31 | .. toctree:: 32 | :maxdepth: 2 33 | 34 | examples/index 35 | 36 | 37 | .. toctree:: 38 | :maxdepth: 2 39 | :caption: Development 40 | 41 | develop-install 42 | 43 | 44 | .. links 45 | 46 | .. _`Jupyter widgets`: https://jupyter.org/widgets.html 47 | 48 | .. _`notebook`: https://jupyter-notebook.readthedocs.io/en/latest/ 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # ipyscales 3 | 4 | [![codecov](https://codecov.io/gh/vidartf/ipyscales/branch/master/graph/badge.svg)](https://codecov.io/gh/vidartf/ipyscales) 5 | [![Documentation Status](https://readthedocs.org/projects/ipyscales/badge/?version=latest)](http://ipyscales.readthedocs.io/en/latest/?badge=latest) 6 | 7 | 8 | A Jupyter widgets library for scales. 9 | 10 | ## Installation 11 | 12 | You can install using `pip`: 13 | 14 | ```bash 15 | pip install ipyscales 16 | ``` 17 | 18 | If you are using Jupyter Notebook 5.2 or earlier, you may also need to enable 19 | the nbextension: 20 | 21 | ```bash 22 | jupyter nbextension enable --py [--sys-prefix|--user|--system] ipyscales 23 | ``` 24 | 25 | ## Acknowledgements 26 | 27 | ipyscales is developed with financial support from: 28 | 29 | - OpenDreamKit Horizon 2020 European Research Infrastructures project (#676541), https://opendreamkit.org 30 | -------------------------------------------------------------------------------- /js/src/widgets.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | export { 5 | LinearScaleModel, 6 | LogScaleModel, 7 | PowScaleModel 8 | } from './continuous'; 9 | 10 | export { 11 | ArrayColorScaleModel, 12 | ColorBarModel, 13 | ColorBarView, 14 | ColorMapEditorModel, 15 | ColorMapEditorView, 16 | LinearColorScaleModel, 17 | LogColorScaleModel, 18 | NamedDivergingColorMap, 19 | NamedOrdinalColorMap, 20 | NamedSequentialColorMap, 21 | } from './colormap'; 22 | 23 | export { ScaledArrayModel } from './datawidgets'; 24 | 25 | export { 26 | QuantizeScaleModel, 27 | QuantileScaleModel, 28 | TresholdScaleModel, 29 | OrdinalScaleModel, 30 | } from './scale'; 31 | 32 | export { 33 | DropdownView, 34 | StringDropdownModel, 35 | WidgetDropdownModel 36 | } from './selectors'; 37 | 38 | export * from './value'; 39 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | set SPHINXPROJ=ipyscales 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/source/develop-install.rst: -------------------------------------------------------------------------------- 1 | 2 | Developer install 3 | ================= 4 | 5 | 6 | To install a developer version of ipyscales, you will first need to clone 7 | the repository:: 8 | 9 | git clone https://github.com/vidartf/ipyscales 10 | cd ipyscales 11 | 12 | Next, install it with a develop install using pip:: 13 | 14 | pip install -e . 15 | 16 | 17 | If you are planning on working on the JS/frontend code, you should also do 18 | a link installation of the extension:: 19 | 20 | jupyter nbextension install [--sys-prefix / --user / --system] --symlink --py ipyscales 21 | 22 | jupyter nbextension enable [--sys-prefix / --user / --system] --py ipyscales 23 | 24 | with the `appropriate flag`_. Or, if you are using Jupyterlab:: 25 | 26 | jupyter labextension install . 27 | 28 | 29 | .. links 30 | 31 | .. _`appropriate flag`: https://jupyter-notebook.readthedocs.io/en/stable/extending/frontend_extensions.html#installing-and-enabling-extensions 32 | -------------------------------------------------------------------------------- /ipyscales/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | 8 | from ._version import __version__, version_info 9 | 10 | from .scale import ( 11 | Scale, 12 | SequentialScale, 13 | DivergingScale, 14 | QuantizeScale, 15 | QuantileScale, 16 | TresholdScale, 17 | OrdinalScale, 18 | ) 19 | from .continuous import ContinuousScale, LinearScale, LogScale, PowScale 20 | from .color import ( 21 | ColorScale, 22 | LinearColorScale, 23 | LogColorScale, 24 | NamedSequentialColorMap, 25 | NamedDivergingColorMap, 26 | NamedOrdinalColorMap, 27 | ) 28 | from .colorbar import ColorBar, ColorMapEditor 29 | from .value import ScaledValue 30 | 31 | # do not import data widgets, to ensure optional dep. on ipydatawidget 32 | 33 | from .nbextension import _jupyter_nbextension_paths 34 | 35 | 36 | # deprecated: 37 | LinearScaleWidget = LinearScale 38 | ScaleWidget = Scale 39 | -------------------------------------------------------------------------------- /.github/workflows/js.yml: -------------------------------------------------------------------------------- 1 | name: JS Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | run: 13 | runs-on: ${{ matrix.os }} 14 | 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | os: [ubuntu-latest, windows-latest] 19 | 20 | defaults: 21 | run: 22 | shell: bash -l {0} 23 | 24 | steps: 25 | - uses: actions/checkout@v2 26 | with: 27 | fetch-depth: 50 28 | 29 | - name: Setup mamba 30 | uses: conda-incubator/setup-miniconda@v2 31 | with: 32 | mamba-version: "*" 33 | channels: conda-forge 34 | 35 | - name: Create the conda environment 36 | run: mamba install -q python=3.9 pip>17 37 | 38 | - name: Install dependencies 39 | run: | 40 | python --version 41 | node --version 42 | 43 | python -m pip install --upgrade -e ".[test]" 44 | 45 | - name: Run tests 46 | run: | 47 | cd js 48 | npm run test:ci 49 | 50 | - name: Upload coverage to Codecov 51 | uses: codecov/codecov-action@v1 52 | -------------------------------------------------------------------------------- /js/src/plugin.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { 5 | Application, IPlugin 6 | } from '@lumino/application'; 7 | 8 | import { 9 | Widget 10 | } from '@lumino/widgets'; 11 | 12 | import { 13 | IJupyterWidgetRegistry 14 | } from '@jupyter-widgets/base'; 15 | 16 | import * as scales from './widgets'; 17 | 18 | import { 19 | MODULE_NAME, MODULE_VERSION 20 | } from './version'; 21 | 22 | import '../styles/plugin.css'; 23 | 24 | const EXTENSION_ID = 'jupyter-scales:plugin'; 25 | 26 | /** 27 | * The example plugin. 28 | */ 29 | const plugin: IPlugin, void> = { 30 | id: EXTENSION_ID, 31 | requires: [IJupyterWidgetRegistry], 32 | activate: activateWidgetExtension, 33 | autoStart: true 34 | }; 35 | 36 | export default plugin; 37 | 38 | 39 | /** 40 | * Activate the widget extension. 41 | */ 42 | function activateWidgetExtension(app: Application, registry: IJupyterWidgetRegistry): void { 43 | registry.registerWidget({ 44 | name: MODULE_NAME, 45 | version: MODULE_VERSION, 46 | exports: scales, 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /docs/source/installing.rst: -------------------------------------------------------------------------------- 1 | 2 | .. _installation: 3 | 4 | Installation 5 | ============ 6 | 7 | 8 | The simplest way to install ipyscales is via pip:: 9 | 10 | pip install ipyscales 11 | 12 | or via conda:: 13 | 14 | conda install ipyscales 15 | 16 | 17 | If you installed via pip, and notebook version < 5.3, you will also have to 18 | install / configure the front-end extension as well. If you are using classic 19 | notebook (as opposed to Jupyterlab), run:: 20 | 21 | jupyter nbextension install [--sys-prefix / --user / --system] --py ipyscales 22 | 23 | jupyter nbextension enable [--sys-prefix / --user / --system] --py ipyscales 24 | 25 | with the `appropriate flag`_. If you are using Jupyterlab, install the extension 26 | with:: 27 | 28 | jupyter labextension install jupyter-scales 29 | 30 | If you are installing using conda, these commands should be unnecessary, but If 31 | you need to run them the commands should be the same (just make sure you choose the 32 | `--sys-prefix` flag). 33 | 34 | 35 | .. links 36 | 37 | .. _`appropriate flag`: https://jupyter-notebook.readthedocs.io/en/stable/extending/frontend_extensions.html#installing-and-enabling-extensions 38 | -------------------------------------------------------------------------------- /ipyscales/value.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | """ 8 | Defines a widget for getting a scaled value client-side. 9 | """ 10 | 11 | from ipywidgets import Widget, register, widget_serialization 12 | from traitlets import Unicode, Instance, Union, Any, Undefined 13 | 14 | from ._frontend import module_name, module_version 15 | from .scale import Scale 16 | 17 | 18 | @register 19 | class ScaledValue(Widget): 20 | _model_module = Unicode(module_name).tag(sync=True) 21 | _model_module_version = Unicode(module_version).tag(sync=True) 22 | _model_name = Unicode("ScaledValueModel").tag(sync=True) 23 | 24 | scale = Instance(Scale).tag(sync=True, **widget_serialization) 25 | 26 | input = Union( 27 | [Instance("ipyscales.ScaledValue"), Any()], 28 | allow_none=True, 29 | help="The input to be scaled. If set to another ScaledValue, it will use its output as the input.", 30 | ).tag(sync=True, **widget_serialization) 31 | 32 | output = Any( 33 | None, 34 | allow_none=True, 35 | read_only=True, 36 | help="Placeholder trait for linking with ipywidgets.jslink(). Not synced.", 37 | ).tag( 38 | sync=True 39 | ) # Not actually synced, even if sync=True 40 | -------------------------------------------------------------------------------- /ipyscales/traittypes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | """ 8 | Defines some trait types used by ipsycales 9 | """ 10 | 11 | import re 12 | 13 | from ipywidgets import Color 14 | from traitlets import TraitError, List 15 | 16 | 17 | class VarlenTuple(List): 18 | klass = tuple 19 | _cast_types = (list,) 20 | 21 | 22 | _color_hexa_re = re.compile(r"^#[a-fA-F0-9]{4}(?:[a-fA-F0-9]{4})?$") 23 | 24 | _color_frac_percent = r"\s*(\d+(\.\d*)?|\.\d+)?%?\s*" 25 | _color_int_percent = r"\s*\d+%?\s*" 26 | 27 | _color_rgb = r"rgb\({ip},{ip},{ip}\)" 28 | _color_rgba = r"rgba\({ip},{ip},{ip},{fp}\)" 29 | _color_hsl = r"hsl\({fp},{fp},{fp}\)" 30 | _color_hsla = r"hsla\({fp},{fp},{fp},{fp}\)" 31 | 32 | _color_rgbhsl_re = re.compile( 33 | "({0})|({1})|({2})|({3})".format( 34 | _color_rgb, _color_rgba, _color_hsl, _color_hsla 35 | ).format(ip=_color_int_percent, fp=_color_frac_percent) 36 | ) 37 | 38 | 39 | class FullColor(Color): 40 | def validate(self, obj, value): 41 | if isinstance(value, str): 42 | try: 43 | return super(FullColor, self).validate(obj, value) 44 | except TraitError: 45 | if _color_hexa_re.match(value) or _color_rgbhsl_re.match(value): 46 | return value 47 | raise 48 | self.error(obj, value) 49 | -------------------------------------------------------------------------------- /examples/introduction.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Introduction" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": 1, 13 | "metadata": {}, 14 | "outputs": [], 15 | "source": [ 16 | "import ipyscales" 17 | ] 18 | }, 19 | { 20 | "cell_type": "code", 21 | "execution_count": 9, 22 | "metadata": {}, 23 | "outputs": [ 24 | { 25 | "name": "stdout", 26 | "output_type": "stream", 27 | "text": [ 28 | "clamp: False, domain: (0.0, 1.0), interpolator: interpolate, range: (0.0, 1.0)\n" 29 | ] 30 | } 31 | ], 32 | "source": [ 33 | "# Make a default scale, and list its trait values:\n", 34 | "scale = ipyscales.LinearScale()\n", 35 | "print(', '.join('%s: %s' % (key, getattr(scale, key)) for key in sorted(scale.keys) if not key.startswith('_')))" 36 | ] 37 | } 38 | ], 39 | "metadata": { 40 | "kernelspec": { 41 | "display_name": "Python 3", 42 | "language": "python", 43 | "name": "python3" 44 | }, 45 | "language_info": { 46 | "codemirror_mode": { 47 | "name": "ipython", 48 | "version": 3 49 | }, 50 | "file_extension": ".py", 51 | "mimetype": "text/x-python", 52 | "name": "python", 53 | "nbconvert_exporter": "python", 54 | "pygments_lexer": "ipython3", 55 | "version": "3.5.4" 56 | } 57 | }, 58 | "nbformat": 4, 59 | "nbformat_minor": 2 60 | } 61 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Jupyter Development Team 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /ipyscales/tests/test_selectors.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Vidar Tonaas Fauske. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | from collections import OrderedDict 8 | 9 | import pytest 10 | 11 | from ipywidgets import Widget 12 | from traitlets import TraitError 13 | 14 | from ..selectors import StringDropdown, WidgetDropdown 15 | 16 | 17 | def test_stringsel_creation_blank(): 18 | with pytest.raises(TypeError): 19 | StringDropdown() 20 | 21 | 22 | def test_stringsel_creation(): 23 | w = StringDropdown(("A", "B", "C")) 24 | assert w.value == "A" 25 | 26 | 27 | def test_stringsel_valid_change(): 28 | w = StringDropdown(("A", "B", "C")) 29 | w.value = "B" 30 | assert w.value == "B" 31 | 32 | 33 | def test_stringsel_invalid_change(): 34 | w = StringDropdown(("A", "B", "C")) 35 | with pytest.raises(TraitError): 36 | w.value = "D" 37 | 38 | 39 | def test_widgetsel_creation_blank(): 40 | with pytest.raises(TypeError): 41 | WidgetDropdown() 42 | 43 | 44 | A = Widget() 45 | B = Widget() 46 | C = Widget() 47 | 48 | 49 | def test_widgetsel_creation(): 50 | # Works after python 3.7: 51 | # w = WidgetDropdown(dict(A=A, B=B, C=C)) 52 | w = WidgetDropdown(OrderedDict([("A", A), ("B", B), ("C", C)])) 53 | assert w.value == A 54 | 55 | 56 | def test_widgetsel_valid_change(): 57 | w = WidgetDropdown(dict(A=A, B=B, C=C)) 58 | w.value = B 59 | assert w.value == B 60 | 61 | 62 | def test_widgetsel_invalid_change(): 63 | w = WidgetDropdown(dict(A=A, B=B, C=C)) 64 | with pytest.raises(TraitError): 65 | w.value = Widget() 66 | -------------------------------------------------------------------------------- /ipyscales/tests/conftest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | import pytest 8 | 9 | from ipykernel.comm import Comm 10 | from ipywidgets import Widget 11 | 12 | 13 | class MockComm(Comm): 14 | """A mock Comm object. 15 | 16 | Can be used to inspect calls to Comm's open/send/close methods. 17 | """ 18 | 19 | comm_id = "a-b-c-d" 20 | kernel = "Truthy" 21 | 22 | def __init__(self, *args, **kwargs): 23 | self.log_open = [] 24 | self.log_send = [] 25 | self.log_close = [] 26 | super(MockComm, self).__init__(*args, **kwargs) 27 | 28 | def open(self, *args, **kwargs): 29 | self.log_open.append((args, kwargs)) 30 | 31 | def send(self, *args, **kwargs): 32 | self.log_send.append((args, kwargs)) 33 | 34 | def close(self, *args, **kwargs): 35 | self.log_close.append((args, kwargs)) 36 | 37 | 38 | _widget_attrs = {} 39 | undefined = object() 40 | 41 | 42 | @pytest.fixture 43 | def mock_comm(): 44 | _widget_attrs["_comm_default"] = getattr(Widget, "_comm_default", undefined) 45 | Widget._comm_default = lambda self: MockComm() 46 | _widget_attrs["_repr_mimebundle_"] = Widget._repr_mimebundle_ 47 | 48 | def raise_not_implemented(*args, **kwargs): 49 | raise NotImplementedError() 50 | 51 | Widget._repr_mimebundle_ = raise_not_implemented 52 | 53 | yield MockComm() 54 | 55 | for attr, value in _widget_attrs.items(): 56 | if value is undefined: 57 | delattr(Widget, attr) 58 | else: 59 | setattr(Widget, attr, value) 60 | -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | name: Python Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | run: 13 | runs-on: ${{ matrix.os }} 14 | 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | os: [ubuntu-latest, macos-latest, windows-latest] 19 | python_version: [3.7, 3.8, 3.9] 20 | 21 | defaults: 22 | run: 23 | shell: pwsh 24 | 25 | steps: 26 | - uses: actions/checkout@v2 27 | with: 28 | fetch-depth: 50 29 | 30 | - name: Setup mamba 31 | uses: conda-incubator/setup-miniconda@v2 32 | with: 33 | mamba-version: "*" 34 | channels: conda-forge 35 | 36 | - name: Create the conda environment 37 | run: mamba install -q python=${{ matrix.python_version }} pip>17 pytest-cov numpy 38 | 39 | - name: Windows binary dep upgrade 40 | if: matrix.os == 'windows-latest' 41 | run: mamba install -q pywin32 42 | 43 | - name: Install dependencies 44 | run: | 45 | python --version 46 | node --version 47 | python -m pip --version 48 | 49 | python -m pip install codecov 50 | 51 | python -m pip install --upgrade ".[test]" 52 | 53 | - name: Run tests 54 | run: | 55 | cd ${{ runner.temp }} 56 | py.test -l --cov-report xml:coverage.xml --cov=ipyscales --pyargs ipyscales 57 | py.test -l --cov-report xml:coverage-nbval.xml --cov=ipyscales ${{ github.workspace }}/examples 58 | cd ${{ github.workspace }} 59 | 60 | - name: Upload coverage to Codecov 61 | uses: codecov/codecov-action@v1 62 | with: 63 | files: ${{ runner.temp }}/coverage.xml,${{ runner.temp }}/coverage-nbval.xml 64 | -------------------------------------------------------------------------------- /js/src/utils.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { 5 | WidgetModel 6 | } from '@jupyter-widgets/base'; 7 | 8 | 9 | export function arrayEquals(a: unknown[], b: unknown[]): boolean { 10 | if (a.length !== b.length) return false; 11 | const al = a.length; 12 | for (let i=0; i < al; ++i) { 13 | if (a[i] !== b[i]) return false; 14 | } 15 | return true; 16 | } 17 | 18 | 19 | /** 20 | * Parse a CSS color string to an RGBA number array. 21 | * 22 | * Handles the formats: 23 | * - #ffffff 24 | * - rgb(255, 255, 255) 25 | * - rgba(255, 255, 255, 1.0) 26 | */ 27 | export function parseCssColor(color: string): [number, number, number, number] { 28 | let m = color.match(/^#([0-9a-f]{6})$/i); 29 | if (m) { 30 | return [ 31 | parseInt(m[1].substr(0, 2), 16), 32 | parseInt(m[1].substr(2, 2), 16), 33 | parseInt(m[1].substr(4, 2), 16), 34 | 255 35 | ]; 36 | } 37 | m = color.match(/^rgb\((\s*(\d+)\s*),(\s*(\d+)\s*),(\s*(\d+)\s*)\)$/i); 38 | if (m) { 39 | return [ 40 | parseInt(m[2], 10), 41 | parseInt(m[4], 10), 42 | parseInt(m[6], 10), 43 | 255 44 | ]; 45 | } 46 | m = color.match(/^rgba\((\s*(\d+)\s*),(\s*(\d+)\s*),(\s*(\d+)\s*),(\s*(\d\.?|\d*\.\d+)\s*)\)$/i); 47 | if (m) { 48 | return [ 49 | parseInt(m[2], 10), 50 | parseInt(m[4], 10), 51 | parseInt(m[6], 10), 52 | Math.round(255 * Math.max(0, Math.min(1, parseFloat(m[8])))), 53 | ]; 54 | } 55 | throw new Error(`Invalid CSS color: "${color}"`); 56 | } 57 | 58 | /** 59 | * Serializer that prevents syncing to kernel 60 | */ 61 | export function undefSerializer(obj: any, widget?: WidgetModel): undefined { 62 | return undefined; 63 | } 64 | -------------------------------------------------------------------------------- /ipyscales/tests/test_colorarray.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | import pytest 8 | 9 | from traitlets import TraitError 10 | import numpy as np 11 | 12 | from ..colorarray import ArrayColorScale 13 | 14 | 15 | def test_arraycolorscale_creation_blank(): 16 | ArrayColorScale() 17 | 18 | 19 | def test_arraycolorscale_accepts_2x3_list(): 20 | ArrayColorScale(colors=[[0, 0, 0], [1, 1, 1]]) 21 | 22 | 23 | def test_arraycolorscale_accepts_2x4_list(): 24 | ArrayColorScale(colors=[[0, 0, 0, 0.3], [1, 1, 1, 1.0]]) 25 | 26 | 27 | def test_arraycolorscale_fails_1x3_list(): 28 | with pytest.raises(TraitError): 29 | ArrayColorScale(colors=[[0, 0, 0]]) 30 | 31 | 32 | def test_arraycolorscale_fails_1D_list(): 33 | with pytest.raises(TraitError): 34 | ArrayColorScale(colors=[0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1]) 35 | 36 | 37 | def test_arraycolorscale_fails_3D_list(): 38 | with pytest.raises(TraitError): 39 | ArrayColorScale(colors=[[[0, 0, 0], [0, 0, 0]], [[1, 1, 1], [1, 1, 1]]]) 40 | 41 | 42 | def test_arraycolorscale_fails_2x2_list(): 43 | with pytest.raises(TraitError): 44 | ArrayColorScale(colors=[[0, 0], [1, 1]]) 45 | 46 | 47 | def test_arraycolorscale_fails_2x5_list(): 48 | with pytest.raises(TraitError): 49 | ArrayColorScale(colors=[[0, 0, 0, 0, 0], [1, 1, 1, 1, 1]]) 50 | 51 | 52 | def test_arraycolorscale_accepts_2x3_array(): 53 | ArrayColorScale(colors=np.array([[0, 0, 0], [1, 1, 1]], dtype=np.float)) 54 | 55 | 56 | def test_arraycolorscale_accepts_2x4_array(): 57 | ArrayColorScale(colors=np.array([[0, 0, 0, 0.3], [1, 1, 1, 1.0]])) 58 | 59 | 60 | def test_arraycolorscale_accepts_hsl(): 61 | ArrayColorScale(space="hsl") 62 | -------------------------------------------------------------------------------- /js/tests/src/utils.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import expect = require('expect.js'); 5 | 6 | import { 7 | parseCssColor 8 | } from '../../src/utils'; 9 | 10 | 11 | describe('parseCssColor', () => { 12 | 13 | it('should parse 6-digit hexes', async () => { 14 | expect(parseCssColor('#ffffff')).to.eql([255, 255, 255, 255]); 15 | expect(parseCssColor('#000000')).to.eql([0, 0, 0, 255]); 16 | expect(parseCssColor('#011000')).to.eql([1, 16, 0, 255]); 17 | }); 18 | 19 | it('should parse rgb strings', async () => { 20 | expect(parseCssColor('rgb( 255,255, 255 )')).to.eql([255, 255, 255, 255]); 21 | expect(parseCssColor('rgb(0,255,0)')).to.eql([0, 255, 0, 255]); 22 | expect(parseCssColor('rgb(010, 255,0)')).to.eql([10, 255, 0, 255]); 23 | }); 24 | 25 | it('should parse rgba strings', async () => { 26 | expect(parseCssColor('rgba( 255,255, 255 , 1.0 )')).to.eql([255, 255, 255, 255]); 27 | expect(parseCssColor('rgba(0,255,0, .1)')).to.eql([0, 255, 0, 26]); 28 | expect(parseCssColor('rgba(0,0,0,1. )')).to.eql([0, 0, 0, 255]); 29 | expect(parseCssColor('rgba(0,0,0, 1)')).to.eql([0, 0, 0, 255]); 30 | expect(parseCssColor('rgba(0,0,0, 0.1 )')).to.eql([0, 0, 0, 26]); 31 | }); 32 | 33 | it('should throw for invalid string', async () => { 34 | const args = [ 35 | 'rgba(0,255,0, .1', 36 | 'rgba(0,25.5,0, .1)', 37 | 'rgba(0,255,0)', 38 | 'rgb(0,255,0, 0.2)', 39 | 'ffffff', 40 | '#ffff0', 41 | ]; 42 | for (let s of args) { 43 | expect(parseCssColor).withArgs(s).to.throwError(/Invalid CSS color:/); 44 | } 45 | }); 46 | 47 | }); 48 | -------------------------------------------------------------------------------- /js/tests/karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function (config) { 2 | config.set({ 3 | basePath: '..', 4 | frameworks: ['mocha', 'karma-typescript'], 5 | reporters: ['mocha', 'karma-typescript'], 6 | client: { 7 | mocha: { 8 | timeout : 10000, // 10 seconds - upped from 2 seconds 9 | retries: 3 // Allow for slow server on CI. 10 | } 11 | }, 12 | files: [ 13 | { pattern: "tests/src/**/*.ts" }, 14 | { pattern: "src/**/*.ts" }, 15 | ], 16 | exclude: [ 17 | "src/plugin.ts", 18 | "src/extension.ts", 19 | ], 20 | preprocessors: { 21 | '**/*.ts': ['karma-typescript'] 22 | }, 23 | browserNoActivityTimeout: 31000, // 31 seconds - upped from 10 seconds 24 | port: 9876, 25 | colors: true, 26 | singleRun: !config.debug, 27 | logLevel: config.LOG_INFO, 28 | 29 | // you can define custom flags 30 | customLaunchers: { 31 | ChromeCI: { 32 | base: 'ChromeHeadless', 33 | flags: ['--no-sandbox'] 34 | } 35 | }, 36 | 37 | 38 | karmaTypescriptConfig: { 39 | tsconfig: 'tests/tsconfig.json', 40 | coverageOptions: { 41 | instrumentation: !config.debug 42 | }, 43 | reports: { 44 | "text-summary": "", 45 | "html": "coverage", 46 | "lcovonly": { 47 | "directory": "coverage", 48 | "filename": "coverage.lcov" 49 | } 50 | }, 51 | bundlerOptions: { 52 | sourceMap: false, // Disabled due to error/bug 53 | transforms: [ 54 | require("karma-typescript-es6-transform")({ 55 | presets: [ 56 | ["@babel/preset-env", { 57 | targets: { 58 | browsers: ["last 2 Chrome versions"] 59 | }, 60 | }] 61 | ] 62 | }) 63 | ] 64 | } 65 | } 66 | }); 67 | }; 68 | -------------------------------------------------------------------------------- /ipyscales/datawidgets.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | """ 8 | Scaled data widget. 9 | """ 10 | 11 | from ipywidgets import register, widget_serialization 12 | from traitlets import Instance, Unicode, Undefined, Union 13 | from ipydatawidgets import ( 14 | DataUnion, 15 | data_union_serialization, 16 | NDArraySource, 17 | NDArrayBase, 18 | ) 19 | 20 | from .scale import Scale 21 | from .color import ColorScale 22 | from ._frontend import module_name, module_version 23 | 24 | 25 | @register 26 | class ScaledArray(NDArraySource): 27 | """A widget that provides a scaled version of the array. 28 | 29 | The widget will compute the scaled version of the array on the 30 | frontend side in order to avoid re-transmission of data when 31 | only the scale changes. 32 | """ 33 | 34 | _model_name = Unicode("ScaledArrayModel").tag(sync=True) 35 | _model_module = Unicode(module_name).tag(sync=True) 36 | _model_module_version = Unicode(module_version).tag(sync=True) 37 | 38 | data = DataUnion(help="The data to scale.").tag( 39 | sync=True, **data_union_serialization 40 | ) 41 | 42 | scale = Instance(Scale).tag(sync=True, **widget_serialization) 43 | 44 | # TODO: Use Enum instead of free-text: 45 | output_dtype = Unicode("inherit").tag(sync=True) 46 | 47 | def __init__(self, data=Undefined, scale=Undefined, **kwargs): 48 | super(ScaledArray, self).__init__(data=data, scale=scale, **kwargs) 49 | 50 | def _get_dtype(self): 51 | if self.output_dtype == "inherit": 52 | return self.data.dtype 53 | return self.output_dtype 54 | 55 | def _get_shape(self): 56 | if isinstance(self.scale, ColorScale): 57 | return self.data.shape + (4,) 58 | return self.data.shape 59 | -------------------------------------------------------------------------------- /ipyscales/continuous.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | """ 8 | Defines linear scale widget, and any supporting functions 9 | """ 10 | 11 | from traitlets import Float, CFloat, Unicode, List, Union, Bool, Any 12 | from ipywidgets import Color, register 13 | 14 | from .scale import Scale 15 | from .traittypes import VarlenTuple 16 | 17 | 18 | class ContinuousScale(Scale): 19 | """A continuous scale widget. 20 | 21 | This should be treated as an abstract class, and should 22 | not be directly instantiated. 23 | """ 24 | 25 | domain = VarlenTuple(trait=CFloat(), default_value=(0.0, 1.0), minlen=2).tag( 26 | sync=True 27 | ) 28 | 29 | range = VarlenTuple(trait=Any(), default_value=(0.0, 1.0), minlen=2).tag(sync=True) 30 | 31 | interpolator = Unicode("interpolate").tag(sync=True) 32 | clamp = Bool(False).tag(sync=True) 33 | 34 | 35 | @register 36 | class LinearScale(ContinuousScale): 37 | """A linear scale widget. 38 | 39 | See the documentation for d3-scale's linear for 40 | further details. 41 | """ 42 | 43 | _model_name = Unicode("LinearScaleModel").tag(sync=True) 44 | 45 | 46 | @register 47 | class LogScale(ContinuousScale): 48 | """A logarithmic scale widget. 49 | 50 | See the documentation for d3-scale's scaleLog for 51 | further details. 52 | """ 53 | 54 | _model_name = Unicode("LogScaleModel").tag(sync=True) 55 | 56 | domain = VarlenTuple(trait=CFloat(), default_value=(1.0, 10.0), minlen=2).tag( 57 | sync=True 58 | ) 59 | 60 | base = Float(10).tag(sync=True) 61 | 62 | 63 | @register 64 | class PowScale(ContinuousScale): 65 | """A power scale widget. 66 | 67 | See the documentation for d3-scale's scaleLog for 68 | further details. 69 | """ 70 | 71 | _model_name = Unicode("PowScaleModel").tag(sync=True) 72 | 73 | exponent = Float(1).tag(sync=True) 74 | -------------------------------------------------------------------------------- /ipyscales/colorbar.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Vidar Tonaas Fauske. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | """ 8 | TODO: Add module docstring 9 | """ 10 | 11 | from ipywidgets import DOMWidget, widget_serialization, register 12 | from traitlets import Unicode, Instance, Enum, Float, Int 13 | from .color import ColorScale 14 | from ._frontend import module_name, module_version 15 | 16 | 17 | class Base(DOMWidget): 18 | """A color bar widget, representing a color map""" 19 | 20 | _model_module = Unicode(module_name).tag(sync=True) 21 | _model_module_version = Unicode(module_version).tag(sync=True) 22 | _view_module = Unicode(module_name).tag(sync=True) 23 | _view_module_version = Unicode(module_version).tag(sync=True) 24 | 25 | colormap = Instance(ColorScale, allow_none=False).tag( 26 | sync=True, **widget_serialization 27 | ) 28 | 29 | breadth = Int(30, min=1).tag(sync=True) 30 | border_thickness = Float(1.0).tag(sync=True) 31 | padding = Int(5).tag(sync=True) 32 | 33 | 34 | @register 35 | class ColorBar(Base): 36 | """A color bar widget, representing a color map""" 37 | 38 | _model_name = Unicode("ColorBarModel").tag(sync=True) 39 | _view_name = Unicode("ColorBarView").tag(sync=True) 40 | 41 | orientation = Enum(("vertical", "horizontal"), "vertical").tag(sync=True) 42 | side = Enum(("bottomright", "topleft"), "bottomright").tag(sync=True) 43 | 44 | length = Int(100, min=2).tag(sync=True) 45 | title = Unicode(None, allow_none=True).tag(sync=True) 46 | title_padding = Int(0).tag(sync=True) 47 | axis_padding = Int(0).tag(sync=True) 48 | 49 | 50 | @register 51 | class ColorMapEditor(Base): 52 | """A color map editor widget""" 53 | 54 | _model_name = Unicode("ColorMapEditorModel").tag(sync=True) 55 | _view_name = Unicode("ColorMapEditorView").tag(sync=True) 56 | 57 | orientation = Enum(("vertical", "horizontal"), "horizontal").tag(sync=True) 58 | length = Int(300, min=2).tag(sync=True) 59 | -------------------------------------------------------------------------------- /js/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const version = require('./package.json').version; 3 | 4 | // Custom webpack rules 5 | const rules = [ 6 | { test: /\.ts$/, loader: 'ts-loader' }, 7 | { test: /\.js$/, loader: 'source-map-loader' }, 8 | { test: /\.css$/, use: ['style-loader', 'css-loader']} 9 | ]; 10 | 11 | // Packages that shouldn't be bundled but loaded at runtime 12 | const externals = ['@jupyter-widgets/base', 'jupyter-datawidgets']; 13 | 14 | const resolve = { 15 | // Add '.ts' and '.tsx' as resolvable extensions. 16 | extensions: [".webpack.js", ".web.js", ".ts", ".js"] 17 | }; 18 | 19 | module.exports = [ 20 | /** 21 | * Notebook extension 22 | * 23 | * This bundle only contains the part of the JavaScript that is run on load of 24 | * the notebook. 25 | */ 26 | { 27 | entry: './src/extension.ts', 28 | output: { 29 | filename: 'index.js', 30 | path: path.resolve(__dirname, '..', 'ipyscales', 'nbextension', 'static'), 31 | libraryTarget: 'amd' 32 | }, 33 | module: { 34 | rules: rules 35 | }, 36 | devtool: 'source-map', 37 | externals, 38 | resolve, 39 | }, 40 | 41 | /** 42 | * Embeddable jupyter-scales bundle 43 | * 44 | * This bundle is almost identical to the notebook extension bundle. The only 45 | * difference is in the configuration of the webpack public path for the 46 | * static assets. 47 | * 48 | * The target bundle is always `dist/index.js`, which is the path required by 49 | * the custom widget embedder. 50 | */ 51 | { 52 | entry: './src/index.ts', 53 | output: { 54 | filename: 'index.js', 55 | path: path.resolve(__dirname, 'dist'), 56 | libraryTarget: 'amd', 57 | library: "jupyter-scales", 58 | publicPath: 'https://unpkg.com/jupyter-scales@' + version + '/dist/' 59 | }, 60 | devtool: 'source-map', 61 | module: { 62 | rules: rules 63 | }, 64 | externals, 65 | resolve, 66 | }, 67 | 68 | 69 | /** 70 | * Documentation widget bundle 71 | * 72 | * This bundle is used to embed widgets in the package documentation. 73 | */ 74 | { 75 | entry: './src/index.ts', 76 | output: { 77 | filename: 'embed-bundle.js', 78 | path: path.resolve(__dirname, '..', 'docs', 'source', '_static'), 79 | library: "jupyter-scales", 80 | libraryTarget: 'amd' 81 | }, 82 | module: { 83 | rules: rules 84 | }, 85 | devtool: 'source-map', 86 | externals, 87 | resolve, 88 | } 89 | 90 | ]; 91 | -------------------------------------------------------------------------------- /ipyscales/colorarray.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | """ 8 | Defines array color scale widget, and any supporting functions 9 | """ 10 | 11 | from traitlets import Unicode, TraitError, Undefined, Enum, CFloat 12 | from ipywidgets import register 13 | from ipydatawidgets import DataUnion, data_union_serialization 14 | 15 | from .color import ColorScale 16 | from .scale import SequentialScale 17 | 18 | 19 | def minlen_validator(minlen): 20 | def validator(trait, value): 21 | if value.shape[0] < minlen: 22 | raise TraitError( 23 | "%s needs have a minimum length of %d, got %d" 24 | % (trait.name, minlen, len(value)) 25 | ) 26 | 27 | return validator 28 | 29 | 30 | def color_array_shape_validator(trait, value): 31 | if value is None or value is Undefined: 32 | return value 33 | if len(value.shape) != 2: 34 | raise TraitError( 35 | "%s shape expected to have 2 components, but got shape %s" 36 | % (trait.name, value.shape) 37 | ) 38 | if not (3 <= value.shape[-1] <= 4): 39 | raise TraitError( 40 | "Expected %s to have 3 or 4 elements for the last dimension of its shape, but got %d" 41 | % (trait.name, value.shape[-1]) 42 | ) 43 | return value 44 | 45 | 46 | def color_array_minlen_validator(minlen): 47 | len_val = minlen_validator(minlen) 48 | 49 | def validator(trait, value): 50 | value = color_array_shape_validator(trait, value) 51 | return len_val(trait, value) 52 | 53 | return validator 54 | 55 | 56 | @register 57 | class ArrayColorScale(SequentialScale, ColorScale): 58 | """A sequential color scale with array domain/range. 59 | """ 60 | 61 | _model_name = Unicode("ArrayColorScaleModel").tag(sync=True) 62 | 63 | def __init__(self, colors=Undefined, space="rgb", gamma=1.0, **kwargs): 64 | if colors is not Undefined: 65 | kwargs["colors"] = colors 66 | super(ArrayColorScale, self).__init__(space=space, gamma=gamma, **kwargs) 67 | 68 | colors = DataUnion( 69 | [[0, 0, 0], [1, 1, 1]], # [black, white] 70 | shape_constraint=color_array_minlen_validator(2), 71 | help="An array of RGB(A) or HSL(A) values, normalized between 0 and 1.", 72 | ).tag(sync=True, **data_union_serialization) 73 | 74 | space = Enum(["rgb", "hsl"], "rgb", help="The color space of the range.").tag( 75 | sync=True 76 | ) 77 | 78 | gamma = CFloat(1.0, help="Gamma to use if interpolating in RGB space.").tag( 79 | sync=True 80 | ) 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask instance folder 58 | instance/ 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | docs/source/_static/embed-bundle.js 66 | docs/source/_static/embed-bundle.js.LICENSE.txt 67 | docs/source/_static/embed-bundle.js.map 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # IPython Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # dotenv 82 | .env 83 | 84 | # virtualenv 85 | venv/ 86 | ENV/ 87 | 88 | # Spyder project settings 89 | .spyderproject 90 | 91 | # Rope project settings 92 | .ropeproject 93 | 94 | # ========================= 95 | # Operating System Files 96 | # ========================= 97 | 98 | # OSX 99 | # ========================= 100 | 101 | .DS_Store 102 | .AppleDouble 103 | .LSOverride 104 | 105 | # Thumbnails 106 | ._* 107 | 108 | # Files that might appear in the root of a volume 109 | .DocumentRevisions-V100 110 | .fseventsd 111 | .Spotlight-V100 112 | .TemporaryItems 113 | .Trashes 114 | .VolumeIcon.icns 115 | 116 | # Directories potentially created on remote AFP share 117 | .AppleDB 118 | .AppleDesktop 119 | Network Trash Folder 120 | Temporary Items 121 | .apdisk 122 | 123 | # Windows 124 | # ========================= 125 | 126 | # Windows image file caches 127 | Thumbs.db 128 | ehthumbs.db 129 | 130 | # Folder config file 131 | Desktop.ini 132 | 133 | # Recycle Bin used on file shares 134 | $RECYCLE.BIN/ 135 | 136 | # Windows Installer files 137 | *.cab 138 | *.msi 139 | *.msm 140 | *.msp 141 | 142 | # Windows shortcuts 143 | *.lnk 144 | 145 | 146 | # NPM 147 | # ---- 148 | 149 | **/node_modules/ 150 | ipyscales/nbextension/static/index.* 151 | js/lab-dist/**/* 152 | js/package-lock.json 153 | js/yarn.lock 154 | 155 | # Coverage data 156 | # ------------- 157 | **/coverage/ 158 | 159 | # Test data 160 | .pytest_cache 161 | -------------------------------------------------------------------------------- /ipyscales/selectors.py: -------------------------------------------------------------------------------- 1 | from ipywidgets import Widget, DOMWidget, widget_serialization 2 | from traitlets import Dict, Instance, Unicode, Undefined, validate, TraitError 3 | 4 | from ._frontend import module_name, module_version 5 | from .traittypes import VarlenTuple 6 | 7 | 8 | def findvalue(array, value, compare=lambda x, y: x == y): 9 | "A function that uses the compare function to return a value from the list." 10 | try: 11 | return next(x for x in array if compare(x, value)) 12 | except StopIteration: 13 | raise ValueError("%r not in array" % value) 14 | 15 | 16 | class _SelectorBase(DOMWidget): 17 | _model_module = Unicode(module_name).tag(sync=True) 18 | _model_module_version = Unicode(module_version).tag(sync=True) 19 | _view_module = Unicode(module_name).tag(sync=True) 20 | _view_module_version = Unicode(module_version).tag(sync=True) 21 | 22 | 23 | class StringDropdown(_SelectorBase): 24 | """Select a string from a list of options. 25 | 26 | Here, the value and options have no serializers. 27 | """ 28 | 29 | _model_name = Unicode("StringDropdownModel").tag(sync=True) 30 | _view_name = Unicode("DropdownView").tag(sync=True) 31 | 32 | value = Unicode(None, help="Selected value", allow_none=True).tag(sync=True) 33 | 34 | options = VarlenTuple(Unicode()).tag(sync=True) 35 | 36 | def __init__(self, options, value=Undefined, **kwargs): 37 | # Select the first item by default, if we can 38 | if value == Undefined: 39 | value = options[0] if options else None 40 | super(StringDropdown, self).__init__(options=options, value=value, **kwargs) 41 | 42 | @validate("value") 43 | def _validate_value(self, proposal): 44 | value = proposal.value 45 | try: 46 | return findvalue(self.options, value) if value is not None else None 47 | except ValueError: 48 | raise TraitError("Invalid selection: value not found") 49 | 50 | 51 | class WidgetDropdown(_SelectorBase): 52 | """Select a widget reference from a list of options. 53 | 54 | Here, the value and options have widget serializers. 55 | """ 56 | 57 | _model_name = Unicode("WidgetDropdownModel").tag(sync=True) 58 | _view_name = Unicode("DropdownView").tag(sync=True) 59 | 60 | value = Instance(Widget, help="Selected value", allow_none=True).tag( 61 | sync=True, **widget_serialization 62 | ) 63 | 64 | options = Dict(Instance(Widget)).tag(sync=True, **widget_serialization) 65 | 66 | def __init__(self, options, value=Undefined, **kwargs): 67 | # Select the first item by default, if we can 68 | if value is Undefined and isinstance(options, dict): 69 | value = tuple(options.values())[0] if options else None 70 | super(WidgetDropdown, self).__init__(options=options, value=value, **kwargs) 71 | 72 | @validate("value") 73 | def _validate_value(self, proposal): 74 | value = proposal.value 75 | try: 76 | return ( 77 | findvalue(self.options.values(), value) if value is not None else None 78 | ) 79 | except ValueError: 80 | raise TraitError("Invalid selection: value not found") 81 | -------------------------------------------------------------------------------- /ipyscales/scale.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | """ 8 | Defines a scale widget base class, and any supporting functions 9 | """ 10 | 11 | from ipywidgets import Widget, register 12 | from traitlets import Unicode, CFloat, Bool, Tuple, Any, Undefined 13 | 14 | from ._frontend import module_name, module_version 15 | 16 | from .traittypes import VarlenTuple 17 | 18 | 19 | # TODO: Add and use an interpolator trait (Enum tested against d3) 20 | 21 | 22 | class Scale(Widget): 23 | """A scale widget. 24 | 25 | This should be treated as an abstract class, and should 26 | not be directly instantiated. 27 | """ 28 | 29 | _model_module = Unicode(module_name).tag(sync=True) 30 | _model_module_version = Unicode(module_version).tag(sync=True) 31 | 32 | 33 | class SequentialScale(Scale): 34 | """A sequential scale widget. 35 | """ 36 | 37 | domain = Tuple(CFloat(), CFloat(), default_value=(0.0, 1.0)).tag(sync=True) 38 | 39 | clamp = Bool(False).tag(sync=True) 40 | 41 | 42 | class DivergingScale(Scale): 43 | """A diverging scale widget. 44 | """ 45 | 46 | domain = Tuple(CFloat(), CFloat(), CFloat(), default_value=(0.0, 0.5, 1.0)).tag( 47 | sync=True 48 | ) 49 | 50 | clamp = Bool(False).tag(sync=True) 51 | 52 | 53 | @register 54 | class QuantizeScale(Scale): 55 | """A quantized scale widget. 56 | """ 57 | 58 | _model_name = Unicode("QuantizeScaleModel").tag(sync=True) 59 | 60 | domain = Tuple(CFloat(), CFloat(), default_value=(0.0, 1.0)).tag(sync=True) 61 | 62 | range = VarlenTuple(trait=Any(), default_value=(0.0, 1.0), minlen=2).tag(sync=True) 63 | 64 | 65 | @register 66 | class QuantileScale(Scale): 67 | """A quantile scale widget. 68 | """ 69 | 70 | _model_name = Unicode("QuantileScaleModel").tag(sync=True) 71 | 72 | domain = VarlenTuple(trait=CFloat(), default_value=(0,), minlen=1).tag(sync=True) 73 | 74 | range = VarlenTuple(trait=Any(), default_value=(0,), minlen=1).tag(sync=True) 75 | 76 | 77 | @register 78 | class TresholdScale(Scale): 79 | """A treshold scale widget. 80 | """ 81 | 82 | _model_name = Unicode("TresholdScaleModel").tag(sync=True) 83 | 84 | domain = VarlenTuple(trait=Any(), default_value=(), minlen=0).tag(sync=True) 85 | 86 | range = VarlenTuple(trait=Any(), default_value=(0,), minlen=1).tag(sync=True) 87 | 88 | 89 | def serialize_unkown(value, widget): 90 | if value is scaleImplicit: 91 | return "__implicit" 92 | return value 93 | 94 | 95 | def deserialize_unkown(value, widget): 96 | if value == "__implicit": 97 | return scaleImplicit 98 | return value 99 | 100 | 101 | unknown_serializers = {"to_json": serialize_unkown, "from_json": deserialize_unkown} 102 | 103 | 104 | scaleImplicit = object() 105 | 106 | 107 | @register 108 | class OrdinalScale(Scale): 109 | """An ordinal scale widget. 110 | """ 111 | 112 | _model_name = Unicode("OrdinalScaleModel").tag(sync=True) 113 | 114 | domain = VarlenTuple( 115 | trait=Any(), default_value=None, minlen=0, allow_none=True 116 | ).tag(sync=True) 117 | 118 | range = VarlenTuple(trait=Any(), default_value=(), minlen=0).tag(sync=True) 119 | 120 | unknown = Any(scaleImplicit, allow_none=True).tag(sync=True, **unknown_serializers) 121 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | from glob import glob 8 | from pathlib import Path 9 | import os.path 10 | from os.path import join as pjoin 11 | 12 | 13 | from jupyter_packaging import ( 14 | create_cmdclass, 15 | install_npm, 16 | ensure_targets, 17 | combine_commands, 18 | get_version, 19 | ) 20 | 21 | from setuptools import setup, find_packages 22 | 23 | 24 | HERE = Path(__file__).absolute().parent 25 | 26 | # The name of the project 27 | name = "ipyscales" 28 | # Get our version 29 | version = get_version(HERE.joinpath(name, "_version.py")) 30 | 31 | nb_path = HERE.joinpath(name, "nbextension", "static") 32 | js_path = HERE.joinpath("js") 33 | lab_path = HERE.joinpath("js", "lab-dist") 34 | 35 | # Representative files that should exist after a successful build 36 | jstargets = [ 37 | nb_path / "index.js", 38 | js_path / "lib" / "plugin.js" 39 | ] 40 | 41 | package_data_spec = {name: ["nbextension/static/*.*js*"]} 42 | 43 | data_files_spec = [ 44 | ("share/jupyter/nbextensions/jupyter-scales", nb_path, "*.js*"), 45 | ("share/jupyter/lab/extensions", lab_path, "*.tgz"), 46 | ('share/jupyter/labextensions/jupyter-scales', lab_path / 'jupyter-scales', '**/*.*'), 47 | ("etc/jupyter", HERE / "jupyter-config", "**/*.json"), 48 | ] 49 | 50 | cmdclass = create_cmdclass( 51 | "jsdeps", package_data_spec=package_data_spec, data_files_spec=data_files_spec 52 | ) 53 | cmdclass["jsdeps"] = combine_commands( 54 | install_npm(js_path, build_cmd="build:all"), ensure_targets(jstargets) 55 | ) 56 | 57 | 58 | setup_args = dict( 59 | name=name, 60 | description="A widget library for scales", 61 | long_description=(HERE / "README.md").read_text(encoding="utf-8"), 62 | long_description_content_type='text/markdown', 63 | version=version, 64 | scripts=glob(pjoin("scripts", "*")), 65 | cmdclass=cmdclass, 66 | packages=find_packages(str(HERE)), 67 | author="Vidar T Fauske", 68 | author_email="vidartf@gmail.com", 69 | url="https://github.com/vidartf/ipyscales", 70 | license="BSD-3-Clause", 71 | platforms="Linux, Mac OS X, Windows", 72 | keywords=["Jupyter", "Widgets", "IPython"], 73 | classifiers=[ 74 | "Intended Audience :: Developers", 75 | "Intended Audience :: Science/Research", 76 | "License :: OSI Approved :: BSD License", 77 | "Programming Language :: Python", 78 | "Programming Language :: Python :: 3", 79 | "Programming Language :: Python :: 3.6", 80 | "Programming Language :: Python :: 3.7", 81 | 'Programming Language :: Python :: 3.8', 82 | 'Programming Language :: Python :: 3.9', 83 | "Framework :: Jupyter", 84 | ], 85 | include_package_data=True, 86 | python_requires=">=3.7", 87 | install_requires=["ipywidgets>=7.0.0"], 88 | extras_require={ 89 | "test": [ 90 | "ipydatawidgets>=4.2", 91 | "ipywidgets>=7.6", 92 | "nbval", 93 | "pytest>=4.6", 94 | "pytest-cov", 95 | "pytest_check_links", 96 | ], 97 | "examples": ["ipydatawidgets>=4.2"], 98 | "docs": [ 99 | "sphinx>=1.5", 100 | "recommonmark", 101 | "sphinx_rtd_theme", 102 | "nbsphinx>=0.2.13", 103 | "nbsphinx-link", 104 | "pypandoc", 105 | ], 106 | }, 107 | entry_points={}, 108 | ) 109 | 110 | if __name__ == "__main__": 111 | setup(**setup_args) 112 | -------------------------------------------------------------------------------- /js/src/continuous.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { 5 | scaleLinear, scaleLog, scalePow, InterpolatorFactory, ScaleLinear 6 | } from 'd3-scale'; 7 | 8 | import * as d3Interpolate from 'd3-interpolate'; 9 | 10 | import { 11 | ScaleModel 12 | } from './scale'; 13 | 14 | 15 | /** 16 | * Find the name of the d3-interpolate function 17 | */ 18 | function interpolatorName(fun: Function) { 19 | for (let key of Object.keys(d3Interpolate)) { 20 | if ((d3Interpolate as any)[key] === fun) { 21 | return key; 22 | } 23 | } 24 | throw ReferenceError(`Cannot find name of interpolator ${fun}`); 25 | } 26 | 27 | /** 28 | * A widget model of a continuous scale 29 | */ 30 | export abstract class ContinuousScaleModel extends ScaleModel { 31 | defaults() { 32 | return {...super.defaults(), 33 | domain: [0, 1], 34 | range: [0, 1], 35 | 36 | interpolator: 'interpolate', 37 | clamp: false, 38 | }; 39 | } 40 | 41 | createPropertiesArrays() { 42 | super.createPropertiesArrays(); 43 | this.simpleProperties.push( 44 | 'domain', 45 | 'range', 46 | 'clamp', 47 | ); 48 | } 49 | 50 | /** 51 | * Create the wrapped d3-scale scaleLinear object 52 | */ 53 | abstract constructObject(): any; 54 | 55 | /** 56 | * Sync the model properties to the d3 object. 57 | */ 58 | syncToObject() { 59 | super.syncToObject(); 60 | let interpolatorName = this.get('interpolator') || 'interpolate'; 61 | let interpolator = (d3Interpolate as any)[interpolatorName] as InterpolatorFactory; 62 | this.obj.interpolate(interpolator); 63 | } 64 | 65 | /** 66 | * Synt the d3 object properties to the model. 67 | */ 68 | syncToModel(toSet: Backbone.ObjectHash) { 69 | let interpolator = this.obj.interpolate() as InterpolatorFactory; 70 | toSet['interpolator'] = interpolatorName(interpolator); 71 | super.syncToModel(toSet); 72 | } 73 | 74 | static serializers = { 75 | ...ScaleModel.serializers, 76 | } 77 | } 78 | 79 | 80 | /** 81 | * A widget model of a linear scale 82 | */ 83 | export class LinearScaleModel extends ContinuousScaleModel { 84 | 85 | /** 86 | * Create the wrapped d3-scale scaleLinear object 87 | */ 88 | constructObject(): any { 89 | return scaleLinear(); 90 | } 91 | 92 | obj: ScaleLinear; 93 | 94 | static serializers = { 95 | ...ContinuousScaleModel.serializers, 96 | } 97 | 98 | static model_name = 'LinearScaleModel'; 99 | } 100 | 101 | 102 | /** 103 | * A widget model of a linear scale 104 | */ 105 | export class LogScaleModel extends ContinuousScaleModel { 106 | defaults() { 107 | return {...super.defaults(), 108 | base: 10, 109 | domain: [1, 10], 110 | }; 111 | } 112 | 113 | createPropertiesArrays() { 114 | super.createPropertiesArrays(); 115 | this.simpleProperties.push( 116 | 'base', 117 | ); 118 | } 119 | 120 | /** 121 | * Create the wrapped d3-scale scaleLinear object 122 | */ 123 | constructObject(): any { 124 | return scaleLog(); 125 | } 126 | 127 | static serializers = { 128 | ...ContinuousScaleModel.serializers, 129 | } 130 | 131 | static model_name = 'LogScaleModel'; 132 | } 133 | 134 | 135 | /** 136 | * A widget model of a linear scale 137 | */ 138 | export class PowScaleModel extends ContinuousScaleModel { 139 | defaults() { 140 | this.constructor 141 | return {...super.defaults(), 142 | exponent: 1, 143 | }; 144 | } 145 | 146 | createPropertiesArrays() { 147 | super.createPropertiesArrays(); 148 | this.simpleProperties.push( 149 | 'exponent', 150 | ); 151 | } 152 | 153 | /** 154 | * Create the wrapped d3-scale scaleLinear object 155 | */ 156 | constructObject(): any { 157 | return scalePow(); 158 | } 159 | 160 | static serializers = { 161 | ...ContinuousScaleModel.serializers, 162 | } 163 | 164 | static model_name = 'PowScaleModel'; 165 | } 166 | -------------------------------------------------------------------------------- /js/src/colormap/editor.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Vidar Tonaas Fauske. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { 5 | DOMWidgetModel, DOMWidgetView, ISerializers, IWidgetManager, WidgetModel, unpack_models 6 | } from '@jupyter-widgets/base'; 7 | 8 | import { 9 | chromaEditor, ChromaEditor 10 | } from 'chromabar'; 11 | 12 | 13 | import { select } from 'd3-selection'; 14 | 15 | import { 16 | MODULE_NAME, MODULE_VERSION 17 | } from '../version'; 18 | 19 | import { 20 | ScaleModel 21 | } from '../scale' 22 | 23 | 24 | // Override typing 25 | declare module "@jupyter-widgets/base" { 26 | function unpack_models(value?: any, manager?: IWidgetManager): Promise; 27 | } 28 | 29 | export class ColorMapEditorModel extends DOMWidgetModel { 30 | defaults() { 31 | return {...super.defaults(), 32 | _model_name: ColorMapEditorModel.model_name, 33 | _model_module: ColorMapEditorModel.model_module, 34 | _model_module_version: ColorMapEditorModel.model_module_version, 35 | _view_name: ColorMapEditorModel.view_name, 36 | _view_module: ColorMapEditorModel.view_module, 37 | _view_module_version: ColorMapEditorModel.view_module_version, 38 | colormap: null, 39 | 40 | orientation: 'horizontal', 41 | length: 300, 42 | breadth: 30, 43 | padding: 5, 44 | border_thickness: 1, 45 | }; 46 | } 47 | 48 | initialize(attributes: any, options: any) { 49 | super.initialize(attributes, options); 50 | this.setupListeners(); 51 | } 52 | 53 | setupListeners() { 54 | // register listener for current child value 55 | const childAttrName = 'colormap'; 56 | var curValue = this.get(childAttrName); 57 | if (curValue) { 58 | this.listenTo(curValue, 'change', this.onChildChanged.bind(this)); 59 | this.listenTo(curValue, 'childchange', this.onChildChanged.bind(this)); 60 | } 61 | 62 | // make sure to (un)hook listeners when child points to new object 63 | this.on(`change:${childAttrName}`, (model: this, value: WidgetModel) => { 64 | var prevModel = this.previous(childAttrName); 65 | var currModel = value; 66 | if (prevModel) { 67 | this.stopListening(prevModel); 68 | } 69 | if (currModel) { 70 | this.listenTo(currModel, 'change', this.onChildChanged.bind(this)); 71 | this.listenTo(currModel, 'childchange', this.onChildChanged.bind(this)); 72 | } 73 | }, this); 74 | 75 | } 76 | 77 | onChildChanged(model: WidgetModel) { 78 | // Propagate up hierarchy: 79 | this.trigger('childchange', this); 80 | } 81 | 82 | static serializers: ISerializers = { 83 | ...DOMWidgetModel.serializers, 84 | colormap: { deserialize: unpack_models } 85 | } 86 | 87 | static model_name = 'ColorMapEditorModel'; 88 | static model_module = MODULE_NAME; 89 | static model_module_version = MODULE_VERSION; 90 | static view_name = 'ColorMapEditorView'; 91 | static view_module = MODULE_NAME; 92 | static view_module_version = MODULE_VERSION; 93 | } 94 | 95 | 96 | export class ColorMapEditorView extends DOMWidgetView { 97 | render() { 98 | const cmModel = this.model.get('colormap') as ScaleModel; 99 | this.editorFn = chromaEditor(cmModel.obj) 100 | .onUpdate((save: boolean) => { 101 | // Sync back all changes to both server and here 102 | cmModel.syncToModel({}); 103 | if (save) { 104 | cmModel.save_changes(); 105 | } 106 | }); 107 | 108 | this.onChange(); 109 | this.model.on('change', this.tick, this); 110 | this.model.on('childchange', this.tick, this); 111 | } 112 | 113 | tick() { 114 | requestAnimationFrame(this.onChange.bind(this)); 115 | } 116 | 117 | onChange() { 118 | this.editorFn 119 | .orientation(this.model.get('orientation')) 120 | .barLength(this.model.get('length')) 121 | .breadth(this.model.get('breadth')) 122 | .padding(this.model.get('padding')) 123 | .borderThickness(this.model.get('border_thickness')); 124 | let svg = select(this.el) 125 | .selectAll('svg.jupyterColorbar').data([null]); 126 | svg = svg.merge(svg.enter().append('svg') 127 | .attr('class', 'jupyterColorbar')); 128 | svg 129 | .call(this.editorFn); 130 | } 131 | 132 | editorFn: ChromaEditor; 133 | } 134 | -------------------------------------------------------------------------------- /js/tests/src/helpers.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import * as widgets from '@jupyter-widgets/base'; 5 | import { ManagerBase } from '@jupyter-widgets/base-manager'; 6 | import * as services from '@jupyterlab/services'; 7 | 8 | let numComms = 0; 9 | 10 | 11 | export class MockComm implements widgets.IClassicComm { 12 | constructor() { 13 | this.comm_id = `mock-comm-id-${numComms}`; 14 | numComms += 1; 15 | } 16 | 17 | on_close(fn: Function | null) { 18 | this._on_close = fn; 19 | } 20 | 21 | on_msg(fn: Function | null) { 22 | this._on_msg = fn; 23 | } 24 | 25 | _process_msg(msg: services.KernelMessage.ICommMsgMsg) { 26 | if (this._on_msg) { 27 | return this._on_msg(msg); 28 | } else { 29 | return Promise.resolve(); 30 | } 31 | } 32 | 33 | open(data?: any, metadata?: any, buffers?: ArrayBuffer[] | ArrayBufferView[]): string { 34 | if (this._on_open) { 35 | this._on_open(); 36 | } 37 | return ''; 38 | } 39 | 40 | close(data?: any, metadata?: any, buffers?: ArrayBuffer[] | ArrayBufferView[]): string { 41 | if (this._on_close) { 42 | this._on_close(); 43 | } 44 | return ''; 45 | } 46 | 47 | send(data?: any, metadata?: any, buffers?: ArrayBuffer[] | ArrayBufferView[]): string { 48 | return ''; 49 | } 50 | 51 | comm_id: string; 52 | target_name: string; 53 | _on_msg: Function | null = null; 54 | _on_close: Function | null = null; 55 | _on_open: Function | null = null; 56 | } 57 | 58 | export class DummyManager extends ManagerBase { 59 | constructor() { 60 | super(); 61 | this.el = window.document.createElement('div'); 62 | } 63 | 64 | display_view(msg: services.KernelMessage.IMessage, view: widgets.DOMWidgetView, options: any) { 65 | // TODO: make this a spy 66 | // TODO: return an html element 67 | return Promise.resolve(view).then(view => { 68 | this.el.appendChild(view.el); 69 | view.on('remove', () => console.log('view removed', view)); 70 | return view.el; 71 | }); 72 | } 73 | 74 | protected loadClass(className: string, moduleName: string, moduleVersion: string): Promise { 75 | if (moduleName === '@jupyter-widgets/base') { 76 | if ((widgets as any)[className]) { 77 | return Promise.resolve((widgets as any)[className]); 78 | } else { 79 | return Promise.reject(`Cannot find class ${className}`) 80 | } 81 | } else if (moduleName === 'jupyter-scales') { 82 | if (this.testClasses[className]) { 83 | return Promise.resolve(this.testClasses[className]); 84 | } else { 85 | return Promise.reject(`Cannot find class ${className}`) 86 | } 87 | } else { 88 | return Promise.reject(`Cannot find module ${moduleName}`); 89 | } 90 | } 91 | 92 | _get_comm_info() { 93 | return Promise.resolve({}); 94 | } 95 | 96 | _create_comm(comm_target_name: string, model_id: string, data?: any, metadata?: any, buffers?: ArrayBuffer[] | ArrayBufferView[]): Promise { 97 | return Promise.resolve(new MockComm()); 98 | } 99 | 100 | el: HTMLElement; 101 | testClasses: { [key: string]: any } = {}; 102 | } 103 | 104 | 105 | export interface Constructor { 106 | new (attributes?: any, options?: any): T; 107 | } 108 | 109 | 110 | export function createTestModel( 111 | constructor: Constructor, 112 | attributes?: any, 113 | widget_manager?: widgets.WidgetModel['widget_manager'], 114 | ): T { 115 | 116 | let id = widgets.uuid(); 117 | let modelOptions = { 118 | widget_manager: widget_manager || new DummyManager(), 119 | model_id: id, 120 | } 121 | 122 | return new constructor(attributes, modelOptions); 123 | 124 | } 125 | 126 | 127 | export async function createTestModelFromSerialized( 128 | constructor: Constructor, 129 | state?: any, 130 | widget_manager?: widgets.WidgetModel['widget_manager'], 131 | ): Promise { 132 | widget_manager = widget_manager || new DummyManager(); 133 | let attributes = await (constructor as any)._deserialize_state(state, widget_manager); 134 | 135 | return createTestModel(constructor, attributes, widget_manager); 136 | } 137 | 138 | 139 | export function createTestView( 140 | model: widgets.WidgetModel, 141 | viewCtor: Constructor 142 | ): Promise { 143 | 144 | let mgr = model.widget_manager as DummyManager; 145 | mgr.testClasses[model.get('_view_name')] = viewCtor; 146 | return model.widget_manager.create_view(model, undefined) as any; 147 | 148 | } 149 | -------------------------------------------------------------------------------- /ipyscales/tests/test_color.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | import pytest 8 | 9 | from traitlets import TraitError 10 | 11 | from ..color import ( 12 | LinearColorScale, 13 | LogColorScale, 14 | NamedSequentialColorMap, 15 | NamedDivergingColorMap, 16 | NamedOrdinalColorMap, 17 | ) 18 | from ..colorbar import ColorMapEditor 19 | 20 | 21 | def test_lincolorscale_creation_blank(): 22 | LinearColorScale() 23 | 24 | 25 | def test_lincolorscale_accepts_hex(): 26 | # Should accept rgb, rrggbb, rgba, rrggbbaa 27 | LinearColorScale(range=["#aaa", "#ffffff", "#aaaa", "#ffffffff"]) 28 | 29 | 30 | def test_lincolorscale_accepts_rgba(): 31 | LinearColorScale( 32 | range=[ 33 | "rgb(0, 0, 0)", # rgb 34 | "rgb( 20,70,50 )", # rgb with spaces 35 | "rgba(10,10,10, 0.5)", # rgba with float 36 | "rgba(255, 255, 255, 255)", 37 | ] 38 | ) # alpha will be clamped to 1 39 | 40 | 41 | def test_lincolorscale_rejects_invalid_strings(): 42 | with pytest.raises(TraitError): 43 | LinearColorScale(range=["foo", "#a5312"]) 44 | 45 | 46 | def test_lincolorscale_rejects_floats(): 47 | with pytest.raises(TraitError): 48 | LinearColorScale(range=[1.2, 2.78]) 49 | 50 | 51 | def test_lincolorscale_rejects_ints(): 52 | with pytest.raises(TraitError): 53 | LinearColorScale(range=[1, 2]) 54 | 55 | 56 | def test_lincolorscale_edit(): 57 | w = LinearColorScale() 58 | editor = w.edit() 59 | assert isinstance(editor, ColorMapEditor) 60 | assert editor.colormap == w 61 | 62 | 63 | def test_logcolorscale_creation_blank(): 64 | LogColorScale() 65 | 66 | 67 | def test_logcolorscale_edit(): 68 | w = LogColorScale() 69 | editor = w.edit() 70 | assert isinstance(editor, ColorMapEditor) 71 | assert editor.colormap == w 72 | 73 | 74 | def test_named_sequential_colorscale_creation_blank(): 75 | NamedSequentialColorMap() 76 | 77 | 78 | def test_named_sequential_colorscale_creation_valid(): 79 | w = NamedSequentialColorMap("Rainbow") 80 | assert w.name == "Rainbow" 81 | 82 | 83 | def test_named_sequential_colorscale_creation_invalid(): 84 | with pytest.raises(TraitError): 85 | NamedSequentialColorMap("Spectral") 86 | 87 | 88 | def test_named_sequential_colorscale_edit(): 89 | w = NamedSequentialColorMap() 90 | editor = w.edit() 91 | # just check that no exceptions are raised 92 | 93 | 94 | def test_named_diverging_colorscale_creation_blank(): 95 | NamedDivergingColorMap() 96 | 97 | 98 | def test_named_diverging_colorscale_creation_valid(): 99 | w = NamedDivergingColorMap("Spectral") 100 | assert w.name == "Spectral" 101 | 102 | 103 | def test_named_diverging_colorscale_creation_invalid(): 104 | with pytest.raises(TraitError): 105 | NamedDivergingColorMap("Rainbow") 106 | 107 | 108 | def test_named_diverging_colorscale_edit(): 109 | w = NamedDivergingColorMap() 110 | editor = w.edit() 111 | # just check that no exceptions are raised 112 | 113 | 114 | def test_named_ordinal_colorscale_creation_blank(): 115 | w = NamedOrdinalColorMap() 116 | assert w.name == "Category10" 117 | assert w.cardinality == 10 118 | 119 | 120 | def test_named_ordinal_colorscale_creation_valid_fixed(): 121 | w = NamedOrdinalColorMap("Accent") 122 | assert w.name == "Accent" 123 | assert w.cardinality == 8 124 | 125 | 126 | def test_named_ordinal_colorscale_creation_valid_free(): 127 | for i in range(3, 12): 128 | w = NamedOrdinalColorMap("RdBu", i) 129 | assert w.name == "RdBu" 130 | assert w.cardinality == i 131 | 132 | 133 | def test_named_ordinal_colorscale_creation_invalid_free(): 134 | with pytest.raises(TraitError): 135 | NamedOrdinalColorMap("RdBu", 2) 136 | with pytest.raises(TraitError): 137 | NamedOrdinalColorMap("RdBu", 20) 138 | 139 | 140 | def test_named_ordinal_colorscale_creation_fixed_ignores(): 141 | w = NamedOrdinalColorMap("Accent", 25) 142 | assert w.cardinality == 8 143 | 144 | 145 | def test_named_ordinal_colorscale_cardinality_changes(): 146 | w = NamedOrdinalColorMap("Accent", 25) 147 | assert w.cardinality == 8 148 | w.name = "Category10" 149 | assert w.cardinality == 10 150 | 151 | 152 | def test_named_ordinal_colorscale_cardinality_untouched(): 153 | w = NamedOrdinalColorMap("Accent", 25) 154 | assert w.cardinality == 8 155 | w.name = "RdBu" 156 | assert w.cardinality == 8 157 | 158 | 159 | def test_named_ordinal_colorscale_creation_invalid_name(): 160 | with pytest.raises(TraitError): 161 | NamedOrdinalColorMap("Foobar") 162 | with pytest.raises(TraitError): 163 | NamedOrdinalColorMap("Viridis") 164 | -------------------------------------------------------------------------------- /js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jupyter-scales", 3 | "version": "3.3.0", 4 | "description": "A widget library for scales", 5 | "keywords": [ 6 | "jupyter", 7 | "jupyterlab", 8 | "jupyterlab-extension", 9 | "widgets" 10 | ], 11 | "homepage": "https://github.com/vidartf/ipyscales", 12 | "bugs": { 13 | "url": "https://github.com/vidartf/ipyscales/issues" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/vidartf/ipyscales" 18 | }, 19 | "license": "BSD-3-Clause", 20 | "author": { 21 | "name": "Jupyter Development Team", 22 | "email": "jupyter@googlegroups.com" 23 | }, 24 | "main": "lib/index.js", 25 | "types": "./lib/index.d.ts", 26 | "files": [ 27 | "lib/**/*.js", 28 | "lib/**/*.js.map", 29 | "lib/**/*.d.ts", 30 | "dist/*.js", 31 | "dist/*.js.map", 32 | "dist/*.d.ts", 33 | "src/**/*", 34 | "styles/**/*" 35 | ], 36 | "scripts": { 37 | "build": "npm run build:lib && npm run build:nbextension", 38 | "build:all": "npm run build:labextension && npm run build:nbextension && npm run build:prebuilt", 39 | "build:labextension": "npm run clean:labextension && mkdirp lab-dist && cd lab-dist && npm pack ..", 40 | "build:lib": "tsc", 41 | "build:nbextension": "webpack --mode=production", 42 | "build:prebuilt": "jupyter labextension build .", 43 | "clean": "npm run clean:lib && npm run clean:nbextension", 44 | "clean:labextension": "rimraf lab-dist", 45 | "clean:lib": "rimraf lib", 46 | "clean:nbextension": "rimraf ../ipyscales/nbextension/static/index.js", 47 | "prepack": "npm run build:lib", 48 | "prepublishOnly": "npm run clean && npm run build", 49 | "test": "npm run test:chrome", 50 | "test:chrome": "karma start --browsers=Chrome tests/karma.conf.js", 51 | "test:ci": "karma start --browsers=ChromeCI tests/karma.conf.js", 52 | "test:debug": "karma start --browsers=Chrome --debug=true tests/karma.conf.js", 53 | "test:dev": "karma start --browsers=Chrome --singleRun=false tests/karma.conf.js", 54 | "test:firefox": "karma start --browsers=Firefox tests/karma.conf.js", 55 | "update:all": "update-dependency --minimal --regex .*", 56 | "watch": "npm-run-all -p watch:*", 57 | "watch:lib": "tsc -w", 58 | "watch:nbextension": "webpack --watch --mode=development" 59 | }, 60 | "dependencies": { 61 | "@jupyter-widgets/base": "2.0.1 || ^3 || ^4 || ^5 || ^6", 62 | "@lumino/coreutils": "^1.3.0", 63 | "chromabar": "^0.7.0", 64 | "d3-interpolate": "^2.0.0", 65 | "d3-scale": "^3.1.0", 66 | "d3-scale-chromatic": "^2.0.0", 67 | "d3-selection": "^1.4.0", 68 | "jupyter-dataserializers": "^2.3.0 || ^3.0.1", 69 | "jupyter-datawidgets": "^5.4.0", 70 | "ndarray": "^1.0.18" 71 | }, 72 | "devDependencies": { 73 | "@jupyter-widgets/base-manager": "^1.0.0", 74 | "@jupyterlab/builder": "^3.0.0", 75 | "@jupyterlab/buildutils": "^3.0.0", 76 | "@lumino/application": "^1.6.0", 77 | "@lumino/messaging": "^1.6.0", 78 | "@lumino/widgets": "^1.6.0", 79 | "@types/d3-interpolate": "^2.0.0", 80 | "@types/d3-scale": "^2.1.1", 81 | "@types/d3-scale-chromatic": "^2.0.0", 82 | "@types/d3-selection": "^1.4.1", 83 | "@types/expect.js": "^0.3.29", 84 | "@types/mocha": "^9.1.1", 85 | "@types/ndarray": "^1.0.6", 86 | "@types/node": "^18.7.13", 87 | "@types/webpack-env": "^1.13.6", 88 | "expect.js": "^0.3.1", 89 | "json-loader": "^0.5.7", 90 | "karma": "^6.4.0", 91 | "karma-chrome-launcher": "^3.0.0", 92 | "karma-firefox-launcher": "^2.1.0", 93 | "karma-mocha": "^2.0.1", 94 | "karma-mocha-reporter": "^2.2.5", 95 | "karma-typescript": "^5.2.0", 96 | "karma-typescript-es6-transform": "^5.2.0", 97 | "mkdirp": "^1.0.4", 98 | "mocha": "^10.0.0", 99 | "npm-run-all": "^4.1.3", 100 | "rimraf": "^3.0.2", 101 | "source-map-loader": "^4.0.0", 102 | "ts-loader": "^9.3.1", 103 | "typescript": "^4.1.3", 104 | "webpack": "^5.11.1", 105 | "webpack-cli": "^4.3.1" 106 | }, 107 | "jupyterlab": { 108 | "extension": "lib/plugin", 109 | "outputDir": "lab-dist/jupyter-scales", 110 | "sharedPackages": { 111 | "@jupyter-widgets/base": { 112 | "bundled": false, 113 | "singleton": true 114 | }, 115 | "jupyter-datawidgets": { 116 | "bundled": true, 117 | "singleton": true 118 | }, 119 | "jupyter-dataserializers": { 120 | "bundled": true, 121 | "singleton": true 122 | } 123 | }, 124 | "discovery": { 125 | "kernel": [ 126 | { 127 | "kernel_spec": { 128 | "language": "^python" 129 | }, 130 | "base": { 131 | "name": "ipyscales" 132 | }, 133 | "managers": [ 134 | "pip", 135 | "conda" 136 | ] 137 | } 138 | ] 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /js/src/colormap/colorbar.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Vidar Tonaas Fauske. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { 5 | DOMWidgetModel, DOMWidgetView, ISerializers, IWidgetManager, WidgetModel, unpack_models 6 | } from '@jupyter-widgets/base'; 7 | 8 | import { Message } from '@lumino/messaging'; 9 | 10 | import { 11 | chromabar, ChromaBar 12 | } from 'chromabar'; 13 | 14 | import { select } from 'd3-selection'; 15 | 16 | import { 17 | MODULE_NAME, MODULE_VERSION 18 | } from '../version'; 19 | 20 | 21 | // Override typing 22 | declare module "@jupyter-widgets/base" { 23 | function unpack_models(value?: any, manager?: IWidgetManager): Promise; 24 | } 25 | 26 | 27 | export class ColorBarModel extends DOMWidgetModel { 28 | defaults() { 29 | return {...super.defaults(), 30 | _model_name: ColorBarModel.model_name, 31 | _model_module: ColorBarModel.model_module, 32 | _model_module_version: ColorBarModel.model_module_version, 33 | _view_name: ColorBarModel.view_name, 34 | _view_module: ColorBarModel.view_module, 35 | _view_module_version: ColorBarModel.view_module_version, 36 | colormap: null, 37 | 38 | orientation: 'vertical', 39 | side: 'bottomright', 40 | length: 100, 41 | breadth: 30, 42 | border_thickness: 1, 43 | title: null, 44 | padding: 5, 45 | title_padding: 0, 46 | axis_padding: 0, 47 | }; 48 | } 49 | 50 | initialize(attributes: any, options: any) { 51 | super.initialize(attributes, options); 52 | this.setupListeners(); 53 | } 54 | 55 | setupListeners() { 56 | // register listener for current child value 57 | const childAttrName = 'colormap'; 58 | var curValue = this.get(childAttrName); 59 | if (curValue) { 60 | this.listenTo(curValue, 'change', this.onChildChanged.bind(this)); 61 | this.listenTo(curValue, 'childchange', this.onChildChanged.bind(this)); 62 | } 63 | 64 | // make sure to (un)hook listeners when child points to new object 65 | this.on(`change:${childAttrName}`, (model: this, value: WidgetModel) => { 66 | var prevModel = this.previous(childAttrName); 67 | var currModel = value; 68 | if (prevModel) { 69 | this.stopListening(prevModel); 70 | } 71 | if (currModel) { 72 | this.listenTo(currModel, 'change', this.onChildChanged.bind(this)); 73 | this.listenTo(currModel, 'childchange', this.onChildChanged.bind(this)); 74 | } 75 | }, this); 76 | 77 | } 78 | 79 | onChildChanged(model: Backbone.Model) { 80 | // Propagate up hierarchy: 81 | this.trigger('childchange', this); 82 | } 83 | 84 | static serializers: ISerializers = { 85 | ...DOMWidgetModel.serializers, 86 | colormap: { deserialize: unpack_models } 87 | } 88 | 89 | static model_name = 'ColorBarModel'; 90 | static model_module = MODULE_NAME; 91 | static model_module_version = MODULE_VERSION; 92 | static view_name = 'ColorBarView'; 93 | static view_module = MODULE_NAME; 94 | static view_module_version = MODULE_VERSION; 95 | } 96 | 97 | 98 | export class ColorBarView extends DOMWidgetView { 99 | render() { 100 | const cmModel = this.model.get('colormap'); 101 | this.barFunc = chromabar(cmModel.obj); 102 | 103 | this.onChange(); 104 | this.model.on('change', this.onChange, this); 105 | this.model.on('childchange', this.onChange, this); 106 | } 107 | 108 | _processLuminoMessage(msg: Message, _super: (msg: Message) => void): void { 109 | _super.call(this, msg); 110 | switch (msg.type) { 111 | case 'after-attach': 112 | // Auto-sizing should be updated when attached to DOM: 113 | this.onChange(); 114 | break; 115 | } 116 | } 117 | 118 | processPhosphorMessage(msg: Message): void { 119 | this._processLuminoMessage(msg, (DOMWidgetView as any).processPhosphorMessage); 120 | } 121 | 122 | processLuminoMessage(msg: Message): void { 123 | this._processLuminoMessage(msg, (DOMWidgetView as any).processLuminoMessage); 124 | } 125 | 126 | onChange() { 127 | // Sync config: 128 | this.barFunc 129 | .orientation(this.model.get('orientation')) 130 | .side(this.model.get('side')) 131 | .barLength(this.model.get('length')) 132 | .breadth(this.model.get('breadth')) 133 | .borderThickness(this.model.get('border_thickness')) 134 | .title(this.model.get('title')) 135 | .padding(this.model.get('padding')) 136 | .titlePadding(this.model.get('title_padding')) 137 | .axisPadding(this.model.get('axis_padding')); 138 | 139 | // Update DOM: 140 | let svg = select(this.el) 141 | .selectAll('svg.jupyterColorbar').data([null]); 142 | svg = svg.merge(svg.enter().append('svg') 143 | .attr('class', 'jupyterColorbar')) 144 | .call(this.barFunc); 145 | } 146 | 147 | barFunc: ChromaBar; 148 | } 149 | -------------------------------------------------------------------------------- /js/src/value.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { 5 | WidgetModel, unpack_models, IWidgetManager 6 | } from '@jupyter-widgets/base'; 7 | 8 | import { 9 | ObjectHash 10 | } from 'backbone'; 11 | 12 | import { 13 | ISerializers, listenToUnion 14 | } from 'jupyter-dataserializers'; 15 | 16 | import { 17 | ScaleModel 18 | } from './scale'; 19 | 20 | import { 21 | MODULE_NAME, MODULE_VERSION 22 | } from './version'; 23 | 24 | import { 25 | undefSerializer 26 | } from './utils'; 27 | 28 | 29 | // Override typing 30 | declare module "@jupyter-widgets/base" { 31 | function unpack_models(value?: any, manager?: IWidgetManager): Promise; 32 | } 33 | 34 | 35 | /** 36 | * Scaled value model. 37 | * 38 | * This model provides a scaled value, that is automatically recomputed when 39 | * either the input value or the scale changes. 40 | */ 41 | export class ScaledValueModel extends WidgetModel { 42 | defaults() { 43 | const ctor = this.constructor as any; 44 | return {...super.defaults(), ...{ 45 | _model_name: ctor.model_name, 46 | _model_module: ctor.model_module, 47 | _model_module_version: ctor.model_module_version, 48 | _view_name: ctor.view_name, 49 | _view_module: ctor.view_module, 50 | _view_module_version: ctor.view_module_version, 51 | input: null, 52 | scale: null, 53 | output: null, 54 | }} as any; 55 | } 56 | 57 | /** 58 | * (Re-)compute the output. 59 | * 60 | * @returns {void} 61 | * @memberof ScaledArrayModel 62 | */ 63 | computeScaledValue(options?: any): void { 64 | options = typeof options === 'object' 65 | ? {...options, setOutputOf: this} 66 | : {setOutput: true}; 67 | let scale = this.get('scale') as ScaleModel | null; 68 | let input = this.get('input') as unknown; 69 | 70 | // If input is another ScaledValueModel, use its output as our input: 71 | if (input instanceof ScaledValueModel) { 72 | input = input.get('output') as unknown; 73 | } 74 | 75 | // Handle null case immediately: 76 | if (input === null || scale === null) { 77 | this.set('output', null, options); 78 | return; 79 | } 80 | this.set('output', scale.obj(input), options); 81 | } 82 | 83 | /** 84 | * Initialize the model 85 | * 86 | * @param {Backbone.ObjectHash} attributes 87 | * @param {{model_id: string; comm?: any; widget_manager: any; }} options 88 | * @memberof ScaledArrayModel 89 | */ 90 | initialize(attributes: ObjectHash, options: {model_id: string; comm?: any; widget_manager: any; }): void { 91 | super.initialize(attributes, options); 92 | const scale = (this.get('scale') as ScaleModel | null) || undefined; 93 | // Await scale object for init: 94 | this.initPromise = Promise.resolve(scale && scale.initPromise).then(() => { 95 | this.computeScaledValue(); 96 | this.setupListeners(); 97 | }); 98 | } 99 | 100 | /** 101 | * Sets up any relevant event listeners after the object has been initialized, 102 | * but before the initPromise is resolved. 103 | * 104 | * @memberof ScaledArrayModel 105 | */ 106 | setupListeners(): void { 107 | // Listen to changes on scale model: 108 | this.listenTo(this.get('scale'), 'change', this.onChange); 109 | // make sure to (un)hook listeners when child points to new object 110 | this.on('change:scale', (model: this, value: ScaleModel, options: any) => { 111 | const prevModel = this.previous('scale') as ScaleModel; 112 | const currModel = value; 113 | if (prevModel) { 114 | this.stopListening(prevModel); 115 | } 116 | if (currModel) { 117 | this.listenTo(currModel, 'change', this.onChange.bind(this)); 118 | } 119 | this.onChange(this); 120 | }, this); 121 | 122 | // Listen to changes on input union: 123 | listenToUnion(this, 'input', this.onChange.bind(this), true); 124 | } 125 | 126 | /** 127 | * Callback for when the source input changes. 128 | * 129 | * @param {WidgetModel} model 130 | * @memberof ScaledArrayModel 131 | */ 132 | protected onChange(model: WidgetModel, options?: any): void { 133 | if (!options || options.setOutputOf !== this) { 134 | this.computeScaledValue(options); 135 | } 136 | } 137 | 138 | /** 139 | * A promise that resolves once the model has finished its initialization. 140 | * 141 | * @type {Promise} 142 | * @memberof ScaledArrayModel 143 | */ 144 | initPromise: Promise; 145 | 146 | static serializers: ISerializers = { 147 | input: { deserialize: unpack_models }, 148 | scale: { deserialize: unpack_models }, 149 | output: { serialize: undefSerializer }, 150 | }; 151 | 152 | static model_name = 'ScaledValueModel'; 153 | static model_module = MODULE_NAME; 154 | static model_module_version = MODULE_VERSION; 155 | static view_name = null; 156 | static view_module = null; 157 | static view_module_version = MODULE_VERSION; 158 | } 159 | -------------------------------------------------------------------------------- /js/tests/src/colorbar.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import expect = require('expect.js'); 5 | 6 | import { 7 | createTestModel, createTestView 8 | } from './helpers.spec'; 9 | 10 | import { 11 | ColorBarModel, ColorBarView, 12 | ColorMapEditorModel, ColorMapEditorView, 13 | LinearColorScaleModel 14 | } from '../../src/' 15 | 16 | 17 | describe('ColorBar', () => { 18 | 19 | describe('ColorBarModel', () => { 20 | 21 | it('should be createable', () => { 22 | const model = createTestModel(ColorBarModel); 23 | expect(model).to.be.an(ColorBarModel); 24 | expect(model.get('colormap')).to.be(null); 25 | }); 26 | 27 | it('should be createable non-default values', () => { 28 | const cm = createTestModel(LinearColorScaleModel); 29 | const state = { 30 | colormap: cm 31 | }; 32 | const model = createTestModel(ColorBarModel, state); 33 | expect(model).to.be.an(ColorBarModel); 34 | expect(model.get('colormap')).to.be(cm); 35 | }); 36 | 37 | it('should trigger event when colormap is modified', async () => { 38 | const map = createTestModel(LinearColorScaleModel); 39 | const state = { 40 | colormap: map, 41 | }; 42 | const model = createTestModel(ColorBarModel, state); 43 | let triggered = 0; 44 | model.on('childchange', () => { triggered += 1; }) 45 | map.set('range', ['red', 'blue']); 46 | expect(triggered).to.be(1); 47 | }); 48 | 49 | it('should update bindings when colormap changes', async () => { 50 | const mapA = createTestModel(LinearColorScaleModel); 51 | const mapB = createTestModel(LinearColorScaleModel) 52 | const state = { 53 | colormap: mapA, 54 | }; 55 | const model = createTestModel(ColorBarModel, state); 56 | let changeTriggered = 0; 57 | let childChangeTriggered = 0; 58 | model.on('change', () => { changeTriggered += 1; }) 59 | model.on('childchange', () => { childChangeTriggered += 1; }) 60 | 61 | model.set('colormap', mapB); 62 | expect(changeTriggered).to.be(1); 63 | expect(childChangeTriggered).to.be(0); 64 | 65 | mapA.set('range', ['red', 'blue']); 66 | expect(childChangeTriggered).to.be(0); 67 | 68 | mapB.set('range', ['red', 'blue']); 69 | expect(childChangeTriggered).to.be(1); 70 | }); 71 | 72 | }); 73 | 74 | describe('ColorBarView', () => { 75 | 76 | it('should be createable', async () => { 77 | const state = { 78 | colormap: createTestModel(LinearColorScaleModel), 79 | }; 80 | const model = createTestModel(ColorBarModel, state); 81 | const view = await createTestView(model, ColorBarView); 82 | expect(view).to.be.an(ColorBarView); 83 | expect(view.model).to.be(model); 84 | }); 85 | 86 | }); 87 | 88 | describe('ColorMapEditorModel', () => { 89 | 90 | it('should be createable', () => { 91 | let model = createTestModel(ColorMapEditorModel); 92 | expect(model).to.be.an(ColorMapEditorModel); 93 | expect(model.get('colormap')).to.be(null); 94 | }); 95 | 96 | it('should trigger event when colormap is modified', async () => { 97 | const map = createTestModel(LinearColorScaleModel); 98 | const state = { 99 | colormap: map, 100 | }; 101 | const model = createTestModel(ColorMapEditorModel, state); 102 | let triggered = 0; 103 | model.on('childchange', () => { triggered += 1; }) 104 | map.set('range', ['red', 'blue']); 105 | expect(triggered).to.be(1); 106 | }); 107 | 108 | it('should update bindings when colormap changes', async () => { 109 | const mapA = createTestModel(LinearColorScaleModel); 110 | const mapB = createTestModel(LinearColorScaleModel) 111 | const state = { 112 | colormap: mapA, 113 | }; 114 | const model = createTestModel(ColorMapEditorModel, state); 115 | let changeTriggered = 0; 116 | let childChangeTriggered = 0; 117 | model.on('change', () => { changeTriggered += 1; }) 118 | model.on('childchange', () => { childChangeTriggered += 1; }) 119 | 120 | model.set('colormap', mapB); 121 | expect(changeTriggered).to.be(1); 122 | expect(childChangeTriggered).to.be(0); 123 | 124 | mapA.set('range', ['red', 'blue']); 125 | expect(childChangeTriggered).to.be(0); 126 | 127 | mapB.set('range', ['red', 'blue']); 128 | expect(childChangeTriggered).to.be(1); 129 | }); 130 | 131 | }); 132 | 133 | describe('ColorMapEditorView', () => { 134 | 135 | it('should be createable', async () => { 136 | const state = { 137 | colormap: createTestModel(LinearColorScaleModel), 138 | }; 139 | const model = createTestModel(ColorMapEditorModel, state); 140 | const view = await createTestView(model, ColorMapEditorView); 141 | expect(view).to.be.an(ColorMapEditorView); 142 | expect(view.model).to.be(model); 143 | }); 144 | 145 | }); 146 | 147 | }); 148 | -------------------------------------------------------------------------------- /js/tests/src/value.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import expect = require('expect.js'); 5 | 6 | import { 7 | uuid, WidgetModel 8 | } from '@jupyter-widgets/base'; 9 | 10 | import { 11 | ManagerBase 12 | } from '@jupyter-widgets/base-manager'; 13 | 14 | import { 15 | LinearScaleModel 16 | } from '../../src/continuous'; 17 | 18 | import { 19 | LinearColorScaleModel 20 | } from '../../src/colormap'; 21 | 22 | import { 23 | ScaledValueModel 24 | } from '../../src/value'; 25 | 26 | import { 27 | DummyManager, createTestModel 28 | } from './helpers.spec'; 29 | 30 | 31 | async function createWidgetModel(widget_manager?: WidgetModel['widget_manager']): Promise { 32 | widget_manager = widget_manager || new DummyManager(); 33 | 34 | let scale = createTestModel(LinearScaleModel, { 35 | domain: [0, 10], 36 | range: [-10, -5], 37 | }, widget_manager); 38 | widget_manager.register_model(scale.model_id, Promise.resolve(scale)); 39 | 40 | let attributes = { 41 | scale, 42 | input: 5, 43 | }; 44 | const model = createTestModel(ScaledValueModel, attributes, widget_manager); 45 | widget_manager.register_model(model.model_id, Promise.resolve(model)); 46 | await model.initPromise; 47 | return model; 48 | } 49 | 50 | describe('ScaledValueModel', () => { 51 | 52 | it('should be creatable', async () => { 53 | let widget_manager = new DummyManager(); 54 | let modelOptions = { 55 | widget_manager: widget_manager, 56 | model_id: uuid(), 57 | } 58 | let serializedState = {}; 59 | let model = new ScaledValueModel(serializedState, modelOptions as any); 60 | await model.initPromise; 61 | 62 | expect(model).to.be.an(ScaledValueModel); 63 | expect(model.get('output')).to.be(null); 64 | }); 65 | 66 | it('should not include output in serialization', async () => { 67 | const model = await createWidgetModel(); 68 | const state = await (model.widget_manager as ManagerBase).get_state(); 69 | const models = Object.keys(state.state).map(k => state.state[k].state); 70 | expect(models.length).to.be(2); 71 | expect(models[1]._model_name).to.be('ScaledValueModel'); 72 | expect(models[1].output).to.be(undefined); 73 | }); 74 | 75 | it('should compute scaled input when initialized with scale and input', async () => { 76 | let model = await createWidgetModel(); 77 | expect(model).to.be.an(ScaledValueModel); 78 | expect(model.get('output')).to.eql(-7.5); 79 | }); 80 | 81 | it('should trigger change when setting scale to null', async () => { 82 | let model = await createWidgetModel(); 83 | 84 | let triggered = false; 85 | model.on('change:output', (model: WidgetModel, value: number | null, options: any) => { 86 | triggered = true; 87 | }); 88 | model.set('scale', null); 89 | expect(model).to.be.an(ScaledValueModel); 90 | expect(triggered).to.be(true); 91 | expect(model.get('output')).to.be(null); 92 | }); 93 | 94 | it('should trigger change when changing from null', async () => { 95 | let model = await createWidgetModel(); 96 | 97 | let input = model.get('input') as number; 98 | model.set('input', null); 99 | let triggered = false; 100 | model.on('change:output', (model: WidgetModel, value: number | null, options: any) => { 101 | triggered = true; 102 | }); 103 | model.set('input', input); 104 | expect(model).to.be.an(ScaledValueModel); 105 | expect(model.get('output')).to.eql(-7.5); 106 | expect(triggered).to.be(true); 107 | }); 108 | 109 | it('should not trigger change when still incomplete', async () => { 110 | let model = await createWidgetModel(); 111 | 112 | let input = model.get('input') as number; 113 | model.set({input: null, scale: null}); 114 | let triggered = false; 115 | model.on('change:output', (model: WidgetModel, value: number | null, options: any) => { 116 | triggered = true; 117 | }); 118 | model.set('input', input); 119 | expect(model).to.be.an(ScaledValueModel); 120 | expect(triggered).to.be(false); 121 | }); 122 | 123 | it('should map to rgba for color scale', async () => { 124 | let model = await createWidgetModel(); 125 | 126 | let scale = createTestModel(LinearColorScaleModel, { 127 | domain: [0, 10], 128 | range: ['red', 'blue'], 129 | }, model.widget_manager as DummyManager); 130 | await scale.initPromise; 131 | model.set({ 132 | scale, 133 | }); 134 | expect(model.get('output')).to.be('rgb(128, 0, 128)'); 135 | }); 136 | 137 | it('should accept another scale as input', async () => { 138 | let modelA = await createWidgetModel(); 139 | let modelB = await createWidgetModel(modelA.widget_manager as DummyManager); 140 | 141 | let scale = createTestModel(LinearColorScaleModel, { 142 | domain: modelA.get('scale').get('range'), 143 | range: ['red', 'blue'], 144 | }, modelA.widget_manager); 145 | await scale.initPromise; 146 | modelB.set({ 147 | input: modelA, 148 | scale, 149 | }); 150 | expect(modelB.get('output')).to.be('rgb(128, 0, 128)'); 151 | }); 152 | 153 | }); 154 | -------------------------------------------------------------------------------- /js/src/selectors.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { 5 | DOMWidgetModel, DOMWidgetView, unpack_models, WidgetModel 6 | } from '@jupyter-widgets/base'; 7 | 8 | import { 9 | MODULE_NAME, MODULE_VERSION 10 | } from './version'; 11 | 12 | import { 13 | arrayEquals 14 | } from './utils'; 15 | 16 | 17 | /** 18 | * Base model for scales 19 | */ 20 | export abstract class SelectorBaseModel extends DOMWidgetModel { 21 | 22 | defaults() { 23 | const ctor = this.constructor as any; 24 | return {...super.defaults(), 25 | _model_name: ctor.model_name, 26 | _model_module: ctor.model_module, 27 | _model_module_version: ctor.model_module_version, 28 | _view_name: ctor.view_name, 29 | _view_module: ctor.view_module, 30 | _view_module_version: ctor.view_module_version, 31 | }; 32 | } 33 | 34 | abstract getLabels(): string[]; 35 | 36 | abstract get selectedLabel(): string | null; 37 | abstract set selectedLabel(value: string | null); 38 | 39 | static serializers = { 40 | ...DOMWidgetModel.serializers, 41 | } 42 | 43 | static model_name: string; // Base model should not be instantiated directly 44 | static model_module = MODULE_NAME; 45 | static model_module_version = MODULE_VERSION; 46 | static view_name = null; 47 | static view_module = MODULE_NAME; 48 | static view_module_version = MODULE_VERSION; 49 | } 50 | 51 | 52 | /** 53 | * A widget model of a linear scale 54 | */ 55 | export class StringDropdownModel extends SelectorBaseModel { 56 | 57 | defaults() { 58 | return { 59 | ...super.defaults(), 60 | value: null, 61 | options: [], 62 | }; 63 | } 64 | 65 | getLabels(): string[] { 66 | return this.get('options') || []; 67 | }; 68 | 69 | get selectedLabel(): string | null { 70 | return this.get('value'); 71 | } 72 | 73 | set selectedLabel(value: string | null) { 74 | this.set({value}, 'fromView'); 75 | this.save_changes(); 76 | } 77 | 78 | 79 | static serializers = { 80 | ...SelectorBaseModel.serializers, 81 | } 82 | 83 | static model_name = 'StringDropdownModel'; 84 | } 85 | 86 | 87 | type WidgetMap = {[key: string]: WidgetModel}; 88 | 89 | 90 | /** 91 | * A widget model of a linear scale 92 | */ 93 | export class WidgetDropdownModel extends SelectorBaseModel { 94 | 95 | defaults() { 96 | return { 97 | ...super.defaults(), 98 | value: null, 99 | options: {}, 100 | }; 101 | } 102 | 103 | initialize(attributes: any, options: any): void { 104 | super.initialize(attributes, options); 105 | this.setupListeners(); 106 | this.updateReverse(this, this.get('options') || []); 107 | } 108 | 109 | setupListeners(): void { 110 | this.on('change:options', this.updateReverse, this); 111 | } 112 | 113 | updateReverse(model: WidgetModel, value: WidgetMap, options?: any) { 114 | this.reverseMap = {}; 115 | for (let key of Object.keys(value)) { 116 | const v = value[key]; 117 | if (v && (v as any).toJSON) { 118 | this.reverseMap[value[key].toJSON({})] = key; 119 | } 120 | } 121 | } 122 | 123 | getLabels(): string[] { 124 | const options: WidgetMap = this.get('options') || {}; 125 | return Object.keys(options); 126 | }; 127 | 128 | get selectedLabel(): string | null { 129 | const value = this.get('value') as WidgetModel | null; 130 | return value && this.reverseMap[value.toJSON(undefined)]; 131 | } 132 | 133 | set selectedLabel(value: string | null) { 134 | const options: WidgetMap = this.get('options') || {}; 135 | this.set({value: value ? options[value] || null : null}, 'fromView'); 136 | this.save_changes(); 137 | } 138 | 139 | protected reverseMap: {[key: string]: string}; 140 | 141 | static serializers = { 142 | ...SelectorBaseModel.serializers, 143 | value: { deserialize: unpack_models }, 144 | options: { deserialize: unpack_models }, 145 | } 146 | 147 | static model_name = 'StringDropdownModel'; 148 | } 149 | 150 | 151 | export class DropdownView extends DOMWidgetView { 152 | /** 153 | * Public constructor. 154 | */ 155 | initialize(parameters: any) { 156 | super.initialize(parameters); 157 | this.listenTo(this.model, 'change', this.onModelChange.bind(this)); 158 | } 159 | 160 | onModelChange(model: SelectorBaseModel, options?: any) { 161 | if (options === 'fromView') { 162 | return; 163 | } 164 | this.update(); 165 | } 166 | 167 | /** 168 | * Called when view is rendered. 169 | */ 170 | render() { 171 | super.render(); 172 | 173 | this.el.classList.add('jupyter-widgets'); 174 | this.el.classList.add('widget-inline-hbox'); 175 | this.el.classList.add('widget-dropdown'); 176 | 177 | this.listbox = document.createElement('select'); 178 | this.el.appendChild(this.listbox); 179 | this.update(); 180 | } 181 | 182 | /** 183 | * Update the contents of this view 184 | */ 185 | update() { 186 | const labels = this.model.getLabels(); 187 | if (!arrayEquals(labels, this._current_options)) { 188 | this._updateOptions(labels); 189 | } 190 | 191 | // Select the correct element 192 | const sel = this.model.selectedLabel; 193 | if (sel === null) { 194 | this.listbox.selectedIndex = -1; 195 | } else { 196 | this.listbox.value = sel; 197 | } 198 | return super.update(); 199 | } 200 | 201 | protected _updateOptions(labels: string[]) { 202 | this.listbox.textContent = ''; 203 | for (let label of labels) { 204 | let option = document.createElement('option'); 205 | option.textContent = label.replace(/ /g, '\xa0'); // space ->   206 | option.value = label; 207 | this.listbox.appendChild(option); 208 | } 209 | this._current_options = labels.slice(); 210 | } 211 | 212 | events(): {[e: string]: string} { 213 | return { 214 | 'change select': '_handle_change' 215 | }; 216 | } 217 | 218 | /** 219 | * Handle when a new value is selected. 220 | */ 221 | _handle_change() { 222 | this.model.selectedLabel = this.listbox.selectedIndex === -1 223 | ? null 224 | : this.listbox.value; 225 | } 226 | 227 | listbox: HTMLSelectElement; 228 | 229 | model: SelectorBaseModel; 230 | 231 | _current_options: string[] = []; 232 | } 233 | 234 | -------------------------------------------------------------------------------- /ipyscales/color.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | """ 8 | Defines color scale widget, and any supporting functions 9 | """ 10 | 11 | from traitlets import ( 12 | Float, 13 | Unicode, 14 | Bool, 15 | CaselessStrEnum, 16 | Undefined, 17 | Int, 18 | TraitError, 19 | observe, 20 | validate, 21 | ) 22 | from ipywidgets import register, jslink, VBox 23 | 24 | from .scale import Scale, SequentialScale, DivergingScale, OrdinalScale 25 | from .continuous import LinearScale, LogScale 26 | from .selectors import StringDropdown 27 | from .traittypes import FullColor, VarlenTuple 28 | 29 | 30 | class ColorScale(Scale): 31 | """A common base class for color scales""" 32 | 33 | pass 34 | 35 | 36 | @register 37 | class LinearColorScale(LinearScale, ColorScale): 38 | """A color scale widget. 39 | 40 | The same as a LinearScale, but validates range as color. 41 | 42 | See d3-interpolate for a list of interpolator names 43 | to use. Default uses 'interpolateRgb' for colors. 44 | """ 45 | 46 | _model_name = Unicode("LinearColorScaleModel").tag(sync=True) 47 | 48 | range = VarlenTuple( 49 | trait=FullColor(), default_value=("black", "white"), minlen=2 50 | ).tag(sync=True) 51 | 52 | def edit(self): 53 | from .colorbar import ColorMapEditor 54 | 55 | return ColorMapEditor(colormap=self) 56 | 57 | 58 | @register 59 | class LogColorScale(LogScale, ColorScale): 60 | """A logarithmic color scale widget. 61 | 62 | The same as a LogScale, but validates range as color. 63 | 64 | See d3-interpolate for a list of interpolator names 65 | to use. Default uses 'interpolateRgb' for colors. 66 | """ 67 | 68 | _model_name = Unicode("LogColorScaleModel").tag(sync=True) 69 | 70 | range = VarlenTuple( 71 | trait=FullColor(), default_value=("black", "white"), minlen=2 72 | ).tag(sync=True) 73 | 74 | def edit(self): 75 | from .colorbar import ColorMapEditor 76 | 77 | return ColorMapEditor(colormap=self) 78 | 79 | 80 | # List of valid colormap names 81 | # TODO: Write unit test that validates this vs the actual values in d3 82 | seq_colormap_names = ( 83 | "Viridis", 84 | "Inferno", 85 | "Magma", 86 | "Plasma", 87 | "Warm", 88 | "Cool", 89 | "CubehelixDefault", 90 | "Rainbow", 91 | "Sinebow", 92 | "Blues", 93 | "Greens", 94 | "Greys", 95 | "Oranges", 96 | "Purples", 97 | "Reds", 98 | "BuGn", 99 | "BuPu", 100 | "GnBu", 101 | "OrRd", 102 | "PuBuGn", 103 | "PuBu", 104 | "PuRd", 105 | "RdPu", 106 | "YlGnBu", 107 | "YlGn", 108 | "YlOrBr", 109 | "YlOrRd", 110 | ) 111 | 112 | div_colormap_names = ( 113 | "BrBG", 114 | "PRGn", 115 | "PiYG", 116 | "PuOr", 117 | "RdBu", 118 | "RdGy", 119 | "RdYlBu", 120 | "RdYlGn", 121 | "Spectral", 122 | ) 123 | 124 | scheme_only_colormaps = { 125 | # Name: fixed cardinality 126 | "Category10": 10, 127 | "Accent": 8, 128 | "Dark2": 8, 129 | "Paired": 12, 130 | "Pastel1": 9, 131 | "Pastel2": 8, 132 | "Set1": 9, 133 | "Set2": 8, 134 | "Set3": 12, 135 | } 136 | 137 | # These sequential scales do not have a discreet variant: 138 | non_scheme_sequential = ( 139 | "CubehelixDefault", 140 | "Rainbow", 141 | "Warm", 142 | "Cool", 143 | "Sinebow", 144 | "Viridis", 145 | "Magma", 146 | "Inferno", 147 | "Plasma", 148 | ) 149 | 150 | 151 | @register 152 | class NamedSequentialColorMap(SequentialScale, ColorScale): 153 | """A linear scale widget for colors, initialized from a named color map. 154 | """ 155 | 156 | _model_name = Unicode("NamedSequentialColorMap").tag(sync=True) 157 | 158 | name = CaselessStrEnum(seq_colormap_names, "Viridis").tag(sync=True) 159 | 160 | def __init__(self, name="Viridis", **kwargs): 161 | super(NamedSequentialColorMap, self).__init__(name=name, **kwargs) 162 | 163 | def edit(self): 164 | "Create linked widgets for this data." 165 | children = [] 166 | 167 | w = StringDropdown( 168 | value=self.name, options=seq_colormap_names 169 | ) 170 | jslink((self, "name"), (w, "value")) 171 | children.append(w) 172 | 173 | return VBox(children=children) 174 | 175 | 176 | @register 177 | class NamedDivergingColorMap(DivergingScale, ColorScale): 178 | """A linear scale widget for colors, initialized from a named color map. 179 | """ 180 | 181 | _model_name = Unicode("NamedDivergingColorMap").tag(sync=True) 182 | 183 | name = CaselessStrEnum(div_colormap_names, "BrBG").tag(sync=True) 184 | 185 | def __init__(self, name="BrBG", **kwargs): 186 | super(NamedDivergingColorMap, self).__init__(name=name, **kwargs) 187 | 188 | def edit(self): 189 | "Create linked widgets for this data." 190 | children = [] 191 | 192 | w = StringDropdown( 193 | value=self.name, options=div_colormap_names 194 | ) 195 | jslink((self, "name"), (w, "value")) 196 | children.append(w) 197 | 198 | return VBox(children=children) 199 | 200 | 201 | @register 202 | class NamedOrdinalColorMap(OrdinalScale, ColorScale): 203 | """An ordinal scale widget for colors, initialized from a named color map. 204 | """ 205 | 206 | _model_name = Unicode("NamedOrdinalColorMap").tag(sync=True) 207 | 208 | name = CaselessStrEnum( 209 | sorted( 210 | set(scheme_only_colormaps.keys()) 211 | | (set(seq_colormap_names) - set(non_scheme_sequential)) 212 | | set(div_colormap_names) 213 | ), 214 | "Category10", 215 | ).tag(sync=True) 216 | 217 | cardinality = Int(10, min=3, max=12).tag(sync=True) 218 | 219 | def __init__(self, name="Category10", cardinality=Undefined, **kwargs): 220 | # Ensure correct N if fixed length scheme is used: 221 | try: 222 | cardinality = scheme_only_colormaps[name] 223 | except KeyError: 224 | pass 225 | super(NamedOrdinalColorMap, self).__init__( 226 | name=name, cardinality=cardinality, **kwargs 227 | ) 228 | 229 | @observe("name", type="change") 230 | def _on_name_change(self, change): 231 | # Ensure that N gets updated if fixed length scheme is used: 232 | try: 233 | self.cardinality = scheme_only_colormaps[change["new"]] 234 | except KeyError: 235 | pass 236 | 237 | # Range is fixed by colormap name: 238 | range = None 239 | 240 | def edit(self): 241 | "Create linked widgets for this data." 242 | children = [] 243 | 244 | w = StringDropdown( 245 | value=self.name, 246 | options=NamedOrdinalColorMap.name.values, 247 | ) 248 | jslink((self, "name"), (w, "value")) 249 | children.append(w) 250 | 251 | return VBox(children=children) 252 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # ipyscales documentation build configuration file 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 | 16 | # -- General configuration ------------------------------------------------ 17 | 18 | # If your documentation needs a minimal Sphinx version, state it here. 19 | # 20 | # needs_sphinx = '1.0' 21 | 22 | # Add any Sphinx extension module names here, as strings. They can be 23 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 24 | # ones. 25 | extensions = [ 26 | "sphinx.ext.autodoc", 27 | "sphinx.ext.viewcode", 28 | "sphinx.ext.intersphinx", 29 | "sphinx.ext.napoleon", 30 | "sphinx.ext.todo", 31 | "nbsphinx", 32 | "nbsphinx_link", 33 | ] 34 | 35 | # Ensure our extension is available: 36 | import sys 37 | from os.path import dirname, join as pjoin 38 | 39 | docs = dirname(dirname(__file__)) 40 | root = dirname(docs) 41 | sys.path.insert(0, root) 42 | sys.path.insert(0, pjoin(docs, "sphinxext")) 43 | 44 | # Add any paths that contain templates here, relative to this directory. 45 | templates_path = ["_templates"] 46 | 47 | # The suffix(es) of source filenames. 48 | # You can specify multiple suffix as a list of string: 49 | # 50 | # source_suffix = ['.rst', '.md'] 51 | source_suffix = ".rst" 52 | 53 | # The master toctree document. 54 | master_doc = "index" 55 | 56 | # General information about the project. 57 | project = "ipyscales" 58 | copyright = "2018, Vidar Tonaas Fauske" 59 | author = "Vidar Tonaas Fauske" 60 | 61 | # The version info for the project you're documenting, acts as replacement for 62 | # |version| and |release|, also used in various other places throughout the 63 | # built documents. 64 | # 65 | # The short X.Y version. 66 | 67 | 68 | # get version from python package: 69 | import os 70 | 71 | here = os.path.dirname(__file__) 72 | repo = os.path.join(here, "..", "..") 73 | _version_py = os.path.join(repo, "ipyscales", "_version.py") 74 | version_ns = {} 75 | with open(_version_py) as f: 76 | exec(f.read(), version_ns) 77 | 78 | # The short X.Y version. 79 | version = "%i.%i" % version_ns["version_info"][:2] 80 | # The full version, including alpha/beta/rc tags. 81 | release = version_ns["__version__"] 82 | 83 | # The language for content autogenerated by Sphinx. Refer to documentation 84 | # for a list of supported languages. 85 | # 86 | # This is also used if you do content translation via gettext catalogs. 87 | # Usually you set "language" from the command line for these cases. 88 | language = None 89 | 90 | # List of patterns, relative to source directory, that match files and 91 | # directories to ignore when looking for source files. 92 | # This patterns also effect to html_static_path and html_extra_path 93 | exclude_patterns = ["**.ipynb_checkpoints"] 94 | 95 | # The name of the Pygments (syntax highlighting) style to use. 96 | pygments_style = "sphinx" 97 | 98 | # If true, `todo` and `todoList` produce output, else they produce nothing. 99 | todo_include_todos = False 100 | 101 | 102 | # -- Options for HTML output ---------------------------------------------- 103 | 104 | 105 | # Theme options are theme-specific and customize the look and feel of a theme 106 | # further. For a list of options available for each theme, see the 107 | # documentation. 108 | # 109 | # html_theme_options = {} 110 | 111 | # Add any paths that contain custom static files (such as style sheets) here, 112 | # relative to this directory. They are copied after the builtin static files, 113 | # so a file named "default.css" will overwrite the builtin "default.css". 114 | html_static_path = ["_static"] 115 | 116 | 117 | # -- Options for HTMLHelp output ------------------------------------------ 118 | 119 | # Output file base name for HTML help builder. 120 | htmlhelp_basename = "ipyscalesdoc" 121 | 122 | 123 | # -- Options for LaTeX output --------------------------------------------- 124 | 125 | latex_elements = { 126 | # The paper size ('letterpaper' or 'a4paper'). 127 | # 128 | # 'papersize': 'letterpaper', 129 | # The font size ('10pt', '11pt' or '12pt'). 130 | # 131 | # 'pointsize': '10pt', 132 | # Additional stuff for the LaTeX preamble. 133 | # 134 | # 'preamble': '', 135 | # Latex figure (float) alignment 136 | # 137 | # 'figure_align': 'htbp', 138 | } 139 | 140 | # Grouping the document tree into LaTeX files. List of tuples 141 | # (source start file, target name, title, 142 | # author, documentclass [howto, manual, or own class]). 143 | latex_documents = [ 144 | ( 145 | master_doc, 146 | "ipyscales.tex", 147 | "ipyscales Documentation", 148 | "Vidar Tonaas Fauske", 149 | "manual", 150 | ) 151 | ] 152 | 153 | 154 | # -- Options for manual page output --------------------------------------- 155 | 156 | # One entry per manual page. List of tuples 157 | # (source start file, name, description, authors, manual section). 158 | man_pages = [(master_doc, "ipyscales", "ipyscales Documentation", [author], 1)] 159 | 160 | 161 | # -- Options for Texinfo output ------------------------------------------- 162 | 163 | # Grouping the document tree into Texinfo files. List of tuples 164 | # (source start file, target name, title, author, 165 | # dir menu entry, description, category) 166 | texinfo_documents = [ 167 | ( 168 | master_doc, 169 | "ipyscales", 170 | "ipyscales Documentation", 171 | author, 172 | "ipyscales", 173 | "A Jupyter widget for scales", 174 | "Miscellaneous", 175 | ) 176 | ] 177 | 178 | 179 | # Example configuration for intersphinx: refer to the Python standard library. 180 | intersphinx_mapping = {"https://docs.python.org/3/": None} 181 | 182 | # Read The Docs 183 | # on_rtd is whether we are on readthedocs.org, this line of code grabbed from 184 | # docs.readthedocs.org 185 | on_rtd = os.environ.get("READTHEDOCS", None) == "True" 186 | 187 | if not on_rtd: # only import and set the theme if we're building docs locally 188 | import sphinx_rtd_theme 189 | 190 | html_theme = "sphinx_rtd_theme" 191 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 192 | 193 | # otherwise, readthedocs.org uses their theme by default, so no need to specify it 194 | 195 | 196 | # Uncomment this line if you have know exceptions in your included notebooks 197 | # that nbsphinx complains about: 198 | # 199 | nbsphinx_allow_errors = True # exception ipstruct.py ipython_genutils 200 | 201 | import ipywidgets 202 | has_embed = False 203 | 204 | try: 205 | import ipywidgets.embed 206 | has_embed = True 207 | except ImportError: 208 | pass 209 | 210 | def setup(app): 211 | 212 | if on_rtd and not os.path.exists( 213 | os.path.join(here, '_static', 'embed-bundle.js' 214 | )): 215 | # We don't have a develop install on RTD, ensure we get build output: 216 | from subprocess import check_call 217 | cwd = os.path.join(here, '..', '..', 'js') 218 | check_call(['npm', 'install'], cwd=cwd) 219 | check_call(['npm', 'run', 'build'], cwd=cwd) 220 | 221 | def add_scripts(app): 222 | from sphinx.util import logging 223 | logger = logging.getLogger(__name__) 224 | 225 | if has_embed: 226 | app.add_js_file('https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.4/require.min.js') 227 | app.add_js_file(ipywidgets.embed.DEFAULT_EMBED_REQUIREJS_URL) 228 | else: 229 | app.add_js_file('https://unpkg.com/jupyter-js-widgets@^2.0.13/dist/embed.js') 230 | 231 | for fname in ["helper.js", "embed-bundle.js"]: 232 | if not os.path.exists(os.path.join(here, "_static", fname)): 233 | 234 | logger.warn("missing javascript file: %s" % fname) 235 | app.add_js_file(fname) 236 | 237 | app.connect("builder-inited", add_scripts) 238 | -------------------------------------------------------------------------------- /js/tests/src/scale.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import expect = require('expect.js'); 5 | 6 | import { scaleImplicit } from 'd3-scale'; 7 | 8 | import { 9 | createTestModel, DummyManager 10 | } from './helpers.spec'; 11 | 12 | import { 13 | ScaleModel, QuantizeScaleModel, QuantileScaleModel, TresholdScaleModel, 14 | OrdinalScaleModel 15 | } from '../../src/' 16 | 17 | 18 | class TestModel extends ScaleModel { 19 | constructObject(): any | Promise { 20 | return {}; 21 | } 22 | 23 | syncToObject(): void { 24 | this.syncCalled++; 25 | } 26 | 27 | onCustomMessage(content: any, buffers: any) { 28 | super.onCustomMessage.call(this, arguments); // Get that coverage! 29 | this.customMessages++; 30 | } 31 | 32 | syncCalled = 0; 33 | customMessages = 0; 34 | } 35 | 36 | 37 | class BrokenModel extends ScaleModel { 38 | constructObject(): any | Promise { 39 | return {}; 40 | } 41 | 42 | syncToObject(): void { 43 | this.syncCalled++; 44 | } 45 | 46 | syncCalled = 0; 47 | } 48 | 49 | delete (BrokenModel as any).prototype.constructObject; 50 | 51 | 52 | describe('BaseModel', () => { 53 | 54 | describe('constrution', () => { 55 | 56 | it('should be createable', () => { 57 | let model = createTestModel(TestModel); 58 | expect(model).to.be.an(TestModel); 59 | return model.initPromise.then(() => { 60 | expect(typeof model.obj).to.be('object'); 61 | }); 62 | }); 63 | 64 | it('should fail if not getting model_id', () => { 65 | let widget_manager = new DummyManager(); 66 | let modelOptions = { 67 | widget_manager: widget_manager, 68 | model_id: undefined, 69 | } 70 | 71 | let p = Promise.resolve(new TestModel({}, modelOptions as any)).then(model => { 72 | return model.initPromise; 73 | }) 74 | 75 | p.then(() => { expect().fail('Promise should be rejected!'); }) 76 | return p.catch(reason => { 77 | expect(reason).to.match(/Model missing ID/); 78 | }); 79 | }); 80 | 81 | it('should be createable with a value', () => { 82 | let state = { value: 'Foo Bar!' } 83 | let model = createTestModel(TestModel, state); 84 | expect(model).to.be.an(TestModel); 85 | }); 86 | 87 | }); 88 | 89 | describe('syncToModel', () => { 90 | 91 | it('should do nothing if object falsy', () => { 92 | let model = createTestModel(TestModel); 93 | return model.initPromise.then(() => { 94 | model.on('change', expect().fail); 95 | model.syncToModel({}); 96 | model.syncToModel(null!); 97 | }); 98 | }); 99 | 100 | it('should set properties if passed', () => { 101 | let model = createTestModel(TestModel); 102 | return model.initPromise.then(() => { 103 | let numCalled = 0; 104 | model.on('change', () => { ++numCalled; }); 105 | model.syncToModel({testAttrib: 5}); 106 | expect(numCalled).to.be(1); 107 | }); 108 | }); 109 | 110 | it('should not trigger a sync back to object', () => { 111 | let model = createTestModel(TestModel); 112 | return model.initPromise.then(() => { 113 | let old = model.syncCalled; 114 | model.syncToModel({testAttrib: 5}); 115 | expect(model.syncCalled).to.be(old); 116 | }); 117 | }); 118 | 119 | }); 120 | 121 | describe('syncToObject', () => { 122 | 123 | it('should get called during creation', () => { 124 | let model = createTestModel(TestModel); 125 | return model.initPromise.then(() => { 126 | expect(model.syncCalled).to.be(1); 127 | }); 128 | }); 129 | 130 | it('should trigger when attributes change', () => { 131 | let model = createTestModel(TestModel); 132 | return model.initPromise.then(() => { 133 | model.set({testAttrib: 5}); 134 | expect(model.syncCalled).to.be(2); 135 | }); 136 | }); 137 | 138 | it('should not trigger when attributes change with flag set', () => { 139 | let model = createTestModel(TestModel); 140 | return model.initPromise.then(() => { 141 | model.set({testAttrib: 5}, 'pushFromObject'); 142 | expect(model.syncCalled).to.be(1); 143 | }); 144 | }); 145 | 146 | }); 147 | 148 | it('should call custom message handler', () => { 149 | let model = createTestModel(TestModel); 150 | return model.initPromise.then(() => { 151 | model.trigger('msg:custom', {}, []); 152 | expect(model.customMessages).to.be(1); 153 | }); 154 | }); 155 | 156 | }); 157 | 158 | 159 | describe('QuantizeScaleModel', () => { 160 | 161 | it('should be createable', () => { 162 | let model = createTestModel(QuantizeScaleModel); 163 | expect(model).to.be.an(QuantizeScaleModel); 164 | return model.initPromise.then(() => { 165 | expect(typeof model.obj).to.be('function'); 166 | }); 167 | }); 168 | 169 | it('should have expected default values in model', () => { 170 | let model = createTestModel(QuantizeScaleModel); 171 | expect(model).to.be.an(QuantizeScaleModel); 172 | return model.initPromise.then(() => { 173 | expect(model.get('range')).to.eql([0, 1]); 174 | expect(model.get('domain')).to.eql([0, 1]); 175 | }); 176 | }); 177 | 178 | it('should have expected default values in object', () => { 179 | let model = createTestModel(QuantizeScaleModel); 180 | expect(model).to.be.an(QuantizeScaleModel); 181 | return model.initPromise.then(() => { 182 | expect(model.obj.range()).to.eql([0, 1]); 183 | expect(model.obj.domain()).to.eql([0, 1]); 184 | }); 185 | }); 186 | 187 | }); 188 | 189 | 190 | describe('QuantileScaleModel', () => { 191 | 192 | it('should be createable', () => { 193 | let model = createTestModel(QuantileScaleModel); 194 | expect(model).to.be.an(QuantileScaleModel); 195 | return model.initPromise.then(() => { 196 | expect(typeof model.obj).to.be('function'); 197 | }); 198 | }); 199 | 200 | it('should have expected default values in model', () => { 201 | let model = createTestModel(QuantileScaleModel); 202 | expect(model).to.be.an(QuantileScaleModel); 203 | return model.initPromise.then(() => { 204 | expect(model.get('range')).to.eql([0]); 205 | expect(model.get('domain')).to.eql([0]); 206 | }); 207 | }); 208 | 209 | it('should have expected default values in object', () => { 210 | let model = createTestModel(QuantileScaleModel); 211 | expect(model).to.be.an(QuantileScaleModel); 212 | return model.initPromise.then(() => { 213 | expect(model.obj.range()).to.eql([0]); 214 | expect(model.obj.domain()).to.eql([0]); 215 | }); 216 | }); 217 | 218 | }); 219 | 220 | 221 | describe('TresholdScaleModel', () => { 222 | 223 | it('should be createable', () => { 224 | let model = createTestModel(TresholdScaleModel); 225 | expect(model).to.be.an(TresholdScaleModel); 226 | return model.initPromise.then(() => { 227 | expect(typeof model.obj).to.be('function'); 228 | }); 229 | }); 230 | 231 | it('should have expected default values in model', () => { 232 | let model = createTestModel(TresholdScaleModel); 233 | expect(model).to.be.an(TresholdScaleModel); 234 | return model.initPromise.then(() => { 235 | expect(model.get('range')).to.eql([0]); 236 | expect(model.get('domain')).to.eql([]); 237 | }); 238 | }); 239 | 240 | it('should have expected default values in object', () => { 241 | let model = createTestModel(TresholdScaleModel); 242 | expect(model).to.be.an(TresholdScaleModel); 243 | return model.initPromise.then(() => { 244 | expect(model.obj.range()).to.eql([0]); 245 | expect(model.obj.domain()).to.eql([]); 246 | }); 247 | }); 248 | 249 | }); 250 | 251 | 252 | describe('OrdinalScaleModel', () => { 253 | 254 | it('should be createable', () => { 255 | let model = createTestModel(OrdinalScaleModel); 256 | expect(model).to.be.an(OrdinalScaleModel); 257 | return model.initPromise.then(() => { 258 | expect(typeof model.obj).to.be('function'); 259 | }); 260 | }); 261 | 262 | it('should have expected default values in model', () => { 263 | let model = createTestModel(OrdinalScaleModel); 264 | expect(model).to.be.an(OrdinalScaleModel); 265 | return model.initPromise.then(() => { 266 | expect(model.get('range')).to.eql([]); 267 | expect(model.get('domain')).to.eql([]); 268 | expect(model.get('unknown')).to.be(scaleImplicit); 269 | }); 270 | }); 271 | 272 | it('should have expected default values in object', () => { 273 | let model = createTestModel(OrdinalScaleModel); 274 | expect(model).to.be.an(OrdinalScaleModel); 275 | return model.initPromise.then(() => { 276 | expect(model.obj.range()).to.eql([]); 277 | expect(model.obj.domain()).to.eql([]); 278 | expect(model.obj.unknown()).to.eql(scaleImplicit); 279 | }); 280 | }); 281 | 282 | it('should be createable with non-default values', () => { 283 | let state = { 284 | range: [0.01, 2.35], 285 | domain: [-1e7, 1e5], 286 | unknown: 100, 287 | }; 288 | let model = createTestModel(OrdinalScaleModel, state); 289 | return model.initPromise.then(() => { 290 | expect(model.obj.range()).to.eql([0.01, 2.35]); 291 | expect(model.obj.domain()).to.eql([-1e7, 1e5]); 292 | expect(model.obj.unknown()).to.be(100); 293 | }); 294 | }); 295 | 296 | }); 297 | -------------------------------------------------------------------------------- /js/tests/src/selectors.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import expect = require('expect.js'); 5 | 6 | import { 7 | WidgetModel 8 | } from '@jupyter-widgets/base'; 9 | 10 | import { 11 | StringDropdownModel, WidgetDropdownModel, DropdownView 12 | } from '../../src/selectors' 13 | 14 | import { 15 | DummyManager, createTestModel 16 | } from './helpers.spec'; 17 | 18 | import ndarray = require('ndarray'); 19 | 20 | 21 | describe('StringDropdown', () => { 22 | 23 | describe('StringDropdownModel', () => { 24 | 25 | it('should be creatable', () => { 26 | const model = createTestModel(StringDropdownModel, { 27 | options: ['A', 'B', 'C'], 28 | value: 'A', 29 | }); 30 | 31 | expect(model).to.be.an(StringDropdownModel); 32 | expect(model.getLabels()).to.eql(['A', 'B', 'C']); 33 | expect(model.selectedLabel).to.be('A'); 34 | }); 35 | 36 | }); 37 | 38 | describe('View', () => { 39 | 40 | it('should be creatable', () => { 41 | const model = createTestModel(StringDropdownModel, { 42 | options: ['A', 'B', 'C'], 43 | value: 'A', 44 | }); 45 | 46 | let view = new DropdownView({model}); 47 | 48 | expect(view).to.be.an(DropdownView); 49 | view.render(); 50 | const viewEl = view.el as HTMLElement; 51 | expect(viewEl.tagName.toLowerCase()).to.be('div'); 52 | expect(viewEl.children.length).to.be(1); 53 | const selEl = viewEl.children[0] as HTMLSelectElement; 54 | expect(selEl.tagName.toLowerCase()).to.be('select'); 55 | const optEls = selEl.children; 56 | expect(optEls.length).to.be(3); 57 | expect(selEl.selectedIndex).to.be(0); 58 | view.remove(); 59 | }); 60 | 61 | it('should update when model selection changes', () => { 62 | const model = createTestModel(StringDropdownModel, { 63 | options: ['A', 'B', 'C'], 64 | value: 'A', 65 | }); 66 | 67 | let view = new DropdownView({model}); 68 | view.render(); 69 | model.set('value', 'B'); 70 | const viewEl = view.el as HTMLElement; 71 | const selEl = viewEl.children[0] as HTMLSelectElement; 72 | expect(selEl.selectedIndex).to.be(1); 73 | view.remove(); 74 | }); 75 | 76 | it('should update when model options changes', () => { 77 | const model = createTestModel(StringDropdownModel, { 78 | options: ['A', 'B', 'C'], 79 | value: 'A', 80 | }); 81 | 82 | let view = new DropdownView({model}); 83 | view.render(); 84 | model.set('options', ['B', 'C', 'D', 'A'],); 85 | const viewEl = view.el as HTMLElement; 86 | const selEl = viewEl.children[0] as HTMLSelectElement; 87 | expect(selEl.selectedIndex).to.be(3); 88 | view.remove(); 89 | }); 90 | 91 | it('should select index -1 when value is null', () => { 92 | const model = createTestModel(StringDropdownModel, { 93 | options: ['A', 'B', 'C'], 94 | value: null, 95 | }); 96 | 97 | let view = new DropdownView({model}); 98 | view.render(); 99 | const viewEl = view.el as HTMLElement; 100 | const selEl = viewEl.children[0] as HTMLSelectElement; 101 | expect(selEl.selectedIndex).to.be(-1); 102 | view.remove(); 103 | }); 104 | 105 | it('should select index -1 when value is not in options', () => { 106 | const model = createTestModel(StringDropdownModel, { 107 | options: ['A', 'B', 'C'], 108 | value: 'D', 109 | }); 110 | 111 | let view = new DropdownView({model}); 112 | 113 | view.render(); 114 | const viewEl = view.el as HTMLElement; 115 | const selEl = viewEl.children[0] as HTMLSelectElement; 116 | expect(selEl.selectedIndex).to.be(-1); 117 | view.remove(); 118 | }); 119 | 120 | it('should update model user selects an option', () => { 121 | const model = createTestModel(StringDropdownModel, { 122 | options: ['A', 'B', 'C'], 123 | value: 'A', 124 | }); 125 | 126 | let view = new DropdownView({model}); 127 | view.render(); 128 | const viewEl = view.el as HTMLElement; 129 | const selEl = viewEl.children[0] as HTMLSelectElement; 130 | 131 | selEl.selectedIndex = 2; 132 | selEl.dispatchEvent(new Event('change', { 133 | bubbles: true, 134 | cancelable: false, 135 | })); 136 | expect(model.get('value')).to.be('C'); 137 | 138 | selEl.selectedIndex = -1; 139 | selEl.dispatchEvent(new Event('change', { 140 | bubbles: true, 141 | cancelable: false, 142 | })); 143 | expect(model.get('value')).to.be(null); 144 | 145 | view.remove(); 146 | }); 147 | 148 | }); 149 | 150 | }); 151 | 152 | 153 | describe('WidgetDropdown', () => { 154 | 155 | describe('WidgetDropdownModel', () => { 156 | 157 | it('should be creatable', () => { 158 | const mgr = new DummyManager(); 159 | const A = createTestModel(WidgetModel, {}, mgr); 160 | const B = createTestModel(WidgetModel, {}, mgr); 161 | const C = createTestModel(WidgetModel, {}, mgr); 162 | const model = createTestModel(WidgetDropdownModel, { 163 | options: {A, B, C}, 164 | value: A, 165 | }, mgr); 166 | 167 | expect(model).to.be.an(WidgetDropdownModel); 168 | expect(model.getLabels()).to.eql(['A', 'B', 'C']); 169 | expect(model.selectedLabel).to.be('A'); 170 | }); 171 | 172 | }); 173 | 174 | describe('View', () => { 175 | 176 | let mgr: DummyManager; 177 | let A: WidgetModel; 178 | let B: WidgetModel; 179 | let C: WidgetModel; 180 | let D: WidgetModel; 181 | 182 | beforeEach(() => { 183 | mgr = new DummyManager(); 184 | A = createTestModel(WidgetModel, {}, mgr); 185 | B = createTestModel(WidgetModel, {}, mgr); 186 | C = createTestModel(WidgetModel, {}, mgr); 187 | D = createTestModel(WidgetModel, {}, mgr); 188 | }) 189 | 190 | it('should be creatable', () => { 191 | const model = createTestModel(WidgetDropdownModel, { 192 | options: {A, B, C}, 193 | value: A, 194 | }, mgr); 195 | 196 | let view = new DropdownView({model}); 197 | 198 | expect(view).to.be.an(DropdownView); 199 | view.render(); 200 | const viewEl = view.el as HTMLElement; 201 | expect(viewEl.tagName.toLowerCase()).to.be('div'); 202 | expect(viewEl.children.length).to.be(1); 203 | const selEl = viewEl.children[0] as HTMLSelectElement; 204 | expect(selEl.tagName.toLowerCase()).to.be('select'); 205 | const optEls = selEl.children; 206 | expect(optEls.length).to.be(3); 207 | expect(selEl.selectedIndex).to.be(0); 208 | view.remove(); 209 | }); 210 | 211 | it('should update when model selection changes', () => { 212 | const model = createTestModel(WidgetDropdownModel, { 213 | options: {A, B, C}, 214 | value: A, 215 | }, mgr); 216 | 217 | let view = new DropdownView({model}); 218 | view.render(); 219 | model.set('value', B); 220 | const viewEl = view.el as HTMLElement; 221 | const selEl = viewEl.children[0] as HTMLSelectElement; 222 | expect(selEl.selectedIndex).to.be(1); 223 | view.remove(); 224 | }); 225 | 226 | it('should update when model options changes', () => { 227 | const model = createTestModel(WidgetDropdownModel, { 228 | options: {A, B, C}, 229 | value: A, 230 | }, mgr); 231 | 232 | let view = new DropdownView({model}); 233 | view.render(); 234 | model.set('options', {B, D, A}); 235 | const viewEl = view.el as HTMLElement; 236 | const selEl = viewEl.children[0] as HTMLSelectElement; 237 | expect(selEl.selectedIndex).to.be(2); 238 | view.remove(); 239 | }); 240 | 241 | it('should select index -1 when value is null', () => { 242 | const model = createTestModel(WidgetDropdownModel, { 243 | options: {A, B, C}, 244 | value: null, 245 | }, mgr); 246 | 247 | let view = new DropdownView({model}); 248 | view.render(); 249 | const viewEl = view.el as HTMLElement; 250 | const selEl = viewEl.children[0] as HTMLSelectElement; 251 | expect(selEl.selectedIndex).to.be(-1); 252 | view.remove(); 253 | }); 254 | 255 | it('should select index -1 when value is not in options', () => { 256 | const model = createTestModel(WidgetDropdownModel, { 257 | options: {A, B, C}, 258 | value: D, 259 | }, mgr); 260 | 261 | let view = new DropdownView({model}); 262 | 263 | view.render(); 264 | const viewEl = view.el as HTMLElement; 265 | const selEl = viewEl.children[0] as HTMLSelectElement; 266 | expect(selEl.selectedIndex).to.be(-1); 267 | view.remove(); 268 | }); 269 | 270 | it('should update model user selects an option', () => { 271 | const model = createTestModel(WidgetDropdownModel, { 272 | options: {A, B, C}, 273 | value: A, 274 | }, mgr); 275 | 276 | let view = new DropdownView({model}); 277 | view.render(); 278 | const viewEl = view.el as HTMLElement; 279 | const selEl = viewEl.children[0] as HTMLSelectElement; 280 | 281 | selEl.selectedIndex = 2; 282 | selEl.dispatchEvent(new Event('change', { 283 | bubbles: true, 284 | cancelable: false, 285 | })); 286 | expect(model.get('value')).to.be(C); 287 | 288 | selEl.selectedIndex = -1; 289 | selEl.dispatchEvent(new Event('change', { 290 | bubbles: true, 291 | cancelable: false, 292 | })); 293 | expect(model.get('value')).to.be(null); 294 | 295 | view.remove(); 296 | }); 297 | 298 | }); 299 | 300 | }); 301 | -------------------------------------------------------------------------------- /js/tests/src/continuous.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import expect = require('expect.js'); 5 | 6 | import { 7 | interpolate, interpolateRound 8 | } from 'd3-interpolate'; 9 | 10 | import { 11 | createTestModel 12 | } from './helpers.spec'; 13 | 14 | import { 15 | LinearScaleModel, LogScaleModel, PowScaleModel 16 | } from '../../src/' 17 | 18 | 19 | describe('LinearScaleModel', () => { 20 | 21 | it('should be createable', () => { 22 | let model = createTestModel(LinearScaleModel); 23 | expect(model).to.be.an(LinearScaleModel); 24 | return model.initPromise.then(() => { 25 | expect(typeof model.obj).to.be('function'); 26 | }); 27 | }); 28 | 29 | it('should have expected default values in model', () => { 30 | let model = createTestModel(LinearScaleModel); 31 | expect(model).to.be.an(LinearScaleModel); 32 | return model.initPromise.then(() => { 33 | expect(model.get('range')).to.eql([0, 1]); 34 | expect(model.get('domain')).to.eql([0, 1]); 35 | expect(model.get('clamp')).to.be(false); 36 | expect(model.get('interpolator')).to.be('interpolate'); 37 | }); 38 | }); 39 | 40 | it('should have expected default values in object', () => { 41 | let model = createTestModel(LinearScaleModel); 42 | expect(model).to.be.an(LinearScaleModel); 43 | return model.initPromise.then(() => { 44 | expect(model.obj.range()).to.eql([0, 1]); 45 | expect(model.obj.domain()).to.eql([0, 1]); 46 | expect(model.obj.clamp()).to.be(false); 47 | expect(model.obj.interpolate()).to.be(interpolate); 48 | }); 49 | }); 50 | 51 | it('should be createable with non-default values', () => { 52 | let state = { 53 | range: [0.01, 2.35], 54 | domain: [-1e7, 1e5], 55 | clamp: true, 56 | interpolator: 'interpolateRound' 57 | }; 58 | let model = createTestModel(LinearScaleModel, state); 59 | return model.initPromise.then(() => { 60 | expect(model.obj.range()).to.eql([0.01, 2.35]); 61 | expect(model.obj.domain()).to.eql([-1e7, 1e5]); 62 | expect(model.obj.clamp()).to.be(true); 63 | expect(model.obj.interpolate()).to.be(interpolateRound); 64 | }); 65 | }); 66 | 67 | it('should throw an error for an invalid interpolator', () => { 68 | let state = { 69 | interpolator: 'interpolateFunctionThatDoesNotExist' 70 | }; 71 | let model = createTestModel(LinearScaleModel, state); 72 | expect(model).to.be.an(LinearScaleModel); 73 | return model.initPromise.catch(reason => { 74 | expect(reason).to.match(/.*: Cannot find name of interpolator.*/); 75 | }); 76 | }); 77 | 78 | it('should sync to the default interpolator for null', () => { 79 | let state = { 80 | interpolator: null 81 | }; 82 | let model = createTestModel(LinearScaleModel, state); 83 | expect(model).to.be.an(LinearScaleModel); 84 | return model.initPromise.then(() => { 85 | expect(model.get('interpolator')).to.be('interpolate'); 86 | expect(model.obj.interpolate()).to.be(interpolate); 87 | }); 88 | }); 89 | 90 | it('should sync to the default interpolator for undefined', () => { 91 | let state = { 92 | interpolator: undefined 93 | }; 94 | let model = createTestModel(LinearScaleModel, state); 95 | expect(model).to.be.an(LinearScaleModel); 96 | return model.initPromise.then(() => { 97 | expect(model.get('interpolator')).to.be('interpolate'); 98 | expect(model.obj.interpolate()).to.be(interpolate); 99 | }); 100 | }); 101 | 102 | }); 103 | 104 | 105 | describe('LogScaleModel', () => { 106 | 107 | it('should be createable', () => { 108 | let model = createTestModel(LogScaleModel); 109 | expect(model).to.be.an(LogScaleModel); 110 | return model.initPromise.then(() => { 111 | expect(typeof model.obj).to.be('function'); 112 | }); 113 | }); 114 | 115 | it('should have expected default values in model', () => { 116 | let model = createTestModel(LogScaleModel); 117 | expect(model).to.be.an(LogScaleModel); 118 | return model.initPromise.then(() => { 119 | expect(model.get('range')).to.eql([0, 1]); 120 | expect(model.get('domain')).to.eql([1, 10]); 121 | expect(model.get('clamp')).to.be(false); 122 | expect(model.get('interpolator')).to.be('interpolate'); 123 | expect(model.get('base')).to.be(10); 124 | }); 125 | }); 126 | 127 | it('should have expected default values in object', () => { 128 | let model = createTestModel(LogScaleModel); 129 | expect(model).to.be.an(LogScaleModel); 130 | return model.initPromise.then(() => { 131 | expect(model.obj.range()).to.eql([0, 1]); 132 | expect(model.get('domain')).to.eql([1, 10]); 133 | expect(model.obj.clamp()).to.be(false); 134 | expect(model.obj.interpolate()).to.be(interpolate); 135 | expect(model.obj.base()).to.be(10); 136 | }); 137 | }); 138 | 139 | it('should be createable with non-default values', () => { 140 | let state = { 141 | range: [0.01, 2.35], 142 | domain: [-1e7, 1e5], 143 | clamp: true, 144 | interpolator: 'interpolateRound', 145 | base: 2.78, 146 | }; 147 | let model = createTestModel(LogScaleModel, state); 148 | return model.initPromise.then(() => { 149 | expect(model.obj.range()).to.eql([0.01, 2.35]); 150 | expect(model.obj.domain()).to.eql([-1e7, 1e5]); 151 | expect(model.obj.clamp()).to.be(true); 152 | expect(model.obj.interpolate()).to.be(interpolateRound); 153 | expect(model.obj.base()).to.be(2.78); 154 | }); 155 | }); 156 | 157 | it('should throw an error for an invalid interpolator', () => { 158 | let state = { 159 | interpolator: 'interpolateFunctionThatDoesNotExist' 160 | }; 161 | let model = createTestModel(LogScaleModel, state); 162 | expect(model).to.be.an(LogScaleModel); 163 | return model.initPromise.catch(reason => { 164 | expect(reason).to.match(/.*: Cannot find name of interpolator.*/); 165 | }); 166 | }); 167 | 168 | it('should sync to the default interpolator for null', () => { 169 | let state = { 170 | interpolator: null 171 | }; 172 | let model = createTestModel(LogScaleModel, state); 173 | expect(model).to.be.an(LogScaleModel); 174 | return model.initPromise.then(() => { 175 | expect(model.get('interpolator')).to.be('interpolate'); 176 | expect(model.obj.interpolate()).to.be(interpolate); 177 | }); 178 | }); 179 | 180 | it('should sync to the default interpolator for undefined', () => { 181 | let state = { 182 | interpolator: undefined 183 | }; 184 | let model = createTestModel(LogScaleModel, state); 185 | expect(model).to.be.an(LogScaleModel); 186 | return model.initPromise.then(() => { 187 | expect(model.get('interpolator')).to.be('interpolate'); 188 | expect(model.obj.interpolate()).to.be(interpolate); 189 | }); 190 | }); 191 | 192 | }); 193 | 194 | 195 | describe('PowScaleModel', () => { 196 | 197 | it('should be createable', () => { 198 | let model = createTestModel(PowScaleModel); 199 | expect(model).to.be.an(PowScaleModel); 200 | return model.initPromise.then(() => { 201 | expect(typeof model.obj).to.be('function'); 202 | }); 203 | }); 204 | 205 | it('should have expected default values in model', () => { 206 | let model = createTestModel(PowScaleModel); 207 | expect(model).to.be.an(PowScaleModel); 208 | return model.initPromise.then(() => { 209 | expect(model.get('range')).to.eql([0, 1]); 210 | expect(model.get('domain')).to.eql([0, 1]); 211 | expect(model.get('clamp')).to.be(false); 212 | expect(model.get('interpolator')).to.be('interpolate'); 213 | expect(model.get('exponent')).to.be(1); 214 | }); 215 | }); 216 | 217 | it('should have expected default values in object', () => { 218 | let model = createTestModel(PowScaleModel); 219 | expect(model).to.be.an(PowScaleModel); 220 | return model.initPromise.then(() => { 221 | expect(model.obj.range()).to.eql([0, 1]); 222 | expect(model.obj.domain()).to.eql([0, 1]); 223 | expect(model.obj.clamp()).to.be(false); 224 | expect(model.obj.interpolate()).to.be(interpolate); 225 | expect(model.obj.exponent()).to.be(1); 226 | }); 227 | }); 228 | 229 | it('should be createable with non-default values', () => { 230 | let state = { 231 | range: [0.01, 2.35], 232 | domain: [-1e7, 1e5], 233 | clamp: true, 234 | interpolator: 'interpolateRound', 235 | exponent: 2.78 236 | }; 237 | let model = createTestModel(PowScaleModel, state); 238 | return model.initPromise.then(() => { 239 | expect(model.obj.range()).to.eql([0.01, 2.35]); 240 | expect(model.obj.domain()).to.eql([-1e7, 1e5]); 241 | expect(model.obj.clamp()).to.be(true); 242 | expect(model.obj.interpolate()).to.be(interpolateRound); 243 | expect(model.obj.exponent()).to.be(2.78); 244 | }); 245 | }); 246 | 247 | it('should throw an error for an invalid interpolator', () => { 248 | let state = { 249 | interpolator: 'interpolateFunctionThatDoesNotExist' 250 | }; 251 | let model = createTestModel(PowScaleModel, state); 252 | expect(model).to.be.an(PowScaleModel); 253 | return model.initPromise.catch(reason => { 254 | expect(reason).to.match(/.*: Cannot find name of interpolator.*/); 255 | }); 256 | }); 257 | 258 | it('should sync to the default interpolator for null', () => { 259 | let state = { 260 | interpolator: null 261 | }; 262 | let model = createTestModel(PowScaleModel, state); 263 | expect(model).to.be.an(PowScaleModel); 264 | return model.initPromise.then(() => { 265 | expect(model.get('interpolator')).to.be('interpolate'); 266 | expect(model.obj.interpolate()).to.be(interpolate); 267 | }); 268 | }); 269 | 270 | it('should sync to the default interpolator for undefined', () => { 271 | let state = { 272 | interpolator: undefined 273 | }; 274 | let model = createTestModel(PowScaleModel, state); 275 | expect(model).to.be.an(PowScaleModel); 276 | return model.initPromise.then(() => { 277 | expect(model.get('interpolator')).to.be('interpolate'); 278 | expect(model.obj.interpolate()).to.be(interpolate); 279 | }); 280 | }); 281 | 282 | }); 283 | -------------------------------------------------------------------------------- /js/src/datawidgets.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { 5 | IWidgetManager, unpack_models, WidgetModel 6 | } from '@jupyter-widgets/base'; 7 | 8 | import { 9 | ObjectHash 10 | } from 'backbone'; 11 | 12 | import { 13 | data_union_serialization, listenToUnion, 14 | TypedArray, typesToArray, 15 | ISerializers, getArray, IDataWriteBack, setArray 16 | } from 'jupyter-dataserializers'; 17 | 18 | import { 19 | DataModel 20 | } from 'jupyter-datawidgets/lib/base'; 21 | 22 | import { 23 | isColorMapModel 24 | } from './colormap'; 25 | 26 | import { 27 | LinearScaleModel 28 | } from './continuous'; 29 | 30 | import { 31 | MODULE_NAME, MODULE_VERSION 32 | } from './version'; 33 | 34 | import { 35 | parseCssColor, undefSerializer 36 | } from './utils'; 37 | 38 | 39 | import ndarray = require('ndarray'); 40 | 41 | 42 | // Override typing 43 | declare module "@jupyter-widgets/base" { 44 | function unpack_models(value?: any, manager?: IWidgetManager): Promise; 45 | } 46 | 47 | 48 | /** 49 | * Create new ndarray, with default attributes taken from another 50 | * 51 | * @param {ndarray.NDArray} array 52 | * @returns {ndarray.NDArray} 53 | */ 54 | export function arrayFrom( 55 | array: ndarray.NdArray, 56 | dtype?: ndarray.DataType | null, 57 | shape?: number[] | null 58 | ): ndarray.NdArray { 59 | 60 | dtype = dtype || array.dtype; 61 | shape = shape || array.shape; 62 | if ((dtype as string) === 'buffer' || dtype === 'generic' || dtype === 'array' || dtype === 'bigint64' || dtype === 'biguint64') { 63 | throw new Error(`Cannot create ndarray of dtype "${dtype}".`); 64 | } 65 | return ndarray( 66 | new typesToArray[dtype](shape.reduce((ac, v) => { 67 | ac *= v; 68 | return ac; 69 | }, 1)), 70 | shape, 71 | array.stride, 72 | array.offset, 73 | ); 74 | } 75 | 76 | 77 | /** 78 | * Whether two ndarrays differ in shape. 79 | */ 80 | function shapesDiffer(a: number[] | null, b: number[] | null) { 81 | if (a === null && b === null) { 82 | return false; 83 | } 84 | return a === null || b === null || 85 | JSON.stringify(a) !== JSON.stringify(b); 86 | } 87 | 88 | 89 | /** 90 | * Scaled array model. 91 | * 92 | * This model provides a scaled version of an array, that is 93 | * automatically recomputed when either the array or the scale 94 | * changes. 95 | * 96 | * @export 97 | * @class ScaledArrayModel 98 | * @extends {DataModel} 99 | */ 100 | export class ScaledArrayModel extends DataModel implements IDataWriteBack { 101 | defaults() { 102 | const ctor = this.constructor as any; 103 | return {...super.defaults(), ...{ 104 | _model_name: ctor.model_name, 105 | _model_module: ctor.model_module, 106 | _model_module_version: ctor.model_module_version, 107 | _view_name: ctor.view_name, 108 | _view_module: ctor.view_module, 109 | _view_module_version: ctor.view_module_version, 110 | data: ndarray([]), 111 | scale: null, 112 | scaledData: null, 113 | output_dtype: 'inherit', 114 | }} as any; 115 | } 116 | 117 | /** 118 | * (Re-)compute the scaledData data. 119 | * 120 | * @returns {void} 121 | * @memberof ScaledArrayModel 122 | */ 123 | computeScaledData(options?: any): void { 124 | options = typeof options === 'object' 125 | ? {...options, setScaled: true} 126 | : {setScaled: true}; 127 | let array = getArray(this.get('data')); 128 | let scale = this.get('scale') as LinearScaleModel | null; 129 | // Handle null case immediately: 130 | if (array === null || scale === null) { 131 | this.set('scaledData', null, options); 132 | return; 133 | } 134 | let resized = this.arrayMismatch(); 135 | let scaledData = this.get('scaledData') as ndarray.NdArray; 136 | if (resized) { 137 | // Allocate new array 138 | scaledData = arrayFrom(array, this.scaledDtype(), this.scaledShape()); 139 | } else { 140 | // Reuse data, but wrap in new ndarray object to trigger change 141 | const version = (scaledData as any)._version + 1 || 0; 142 | scaledData = ndarray( 143 | scaledData.data, 144 | scaledData.shape, 145 | scaledData.stride, 146 | scaledData.offset 147 | ); 148 | // Tag on a version# to differntiate it: 149 | (scaledData as any)._version = version; 150 | } 151 | let data = array.data as TypedArray; 152 | let target = scaledData!.data as TypedArray; 153 | 154 | // Set values: 155 | if (isColorMapModel(scale)) { 156 | for (let i = 0; i < data.length; ++i) { 157 | const c = parseCssColor(scale!.obj(data[i])) 158 | target[i*4+0] = c[0]; 159 | target[i*4+1] = c[1]; 160 | target[i*4+2] = c[2]; 161 | target[i*4+3] = c[3]; 162 | } 163 | } else { 164 | for (let i = 0; i < data.length; ++i) { 165 | target[i] = scale.obj(data[i]); 166 | } 167 | } 168 | 169 | this.set('scaledData', scaledData, options); 170 | } 171 | 172 | /** 173 | * Initialize the model 174 | * 175 | * @param {Backbone.ObjectHash} attributes 176 | * @param {{model_id: string; comm?: any; widget_manager: any; }} options 177 | * @memberof ScaledArrayModel 178 | */ 179 | initialize(attributes: ObjectHash, options: {model_id: string; comm?: any; widget_manager: any; }): void { 180 | super.initialize(attributes, options); 181 | const scale = (this.get('scale') as LinearScaleModel | null) || undefined; 182 | // Await scale object for init: 183 | this.initPromise = Promise.resolve(scale && scale.initPromise).then(() => { 184 | this.computeScaledData(); 185 | this.setupListeners(); 186 | }); 187 | } 188 | 189 | /** 190 | * Sets up any relevant event listeners after the object has been initialized, 191 | * but before the initPromise is resolved. 192 | * 193 | * @memberof ScaledArrayModel 194 | */ 195 | setupListeners(): void { 196 | // Listen to direct changes on our model: 197 | this.on('change:scale', this.onChange, this); 198 | 199 | // Listen to changes within array and scale models: 200 | listenToUnion(this, 'data', this.onChange.bind(this), true); 201 | 202 | this.listenTo(this.get('scale'), 'change', this.onChange); 203 | // make sure to (un)hook listeners when child points to new object 204 | this.on('change:scale', (model: this, value: LinearScaleModel, options: any) => { 205 | const prevModel = this.previous('scale') as LinearScaleModel; 206 | const currModel = value; 207 | if (prevModel) { 208 | this.stopListening(prevModel); 209 | } 210 | if (currModel) { 211 | this.listenTo(currModel, 'change', this.onChange.bind(this)); 212 | } 213 | }, this); 214 | } 215 | 216 | getNDArray(key='scaledData'): ndarray.NdArray | null { 217 | if (key === 'scaledData') { 218 | if (this.get('scaledData') === null) { 219 | this.computeScaledData(); 220 | } 221 | return this.get('scaledData'); 222 | } else { 223 | return this.get(key); 224 | } 225 | } 226 | 227 | canWriteBack(key='scaledData'): boolean { 228 | if (key === 'data') { 229 | return true; 230 | } 231 | if (key !== 'scaledData') { 232 | return false; 233 | } 234 | const scale = this.get('scale') as LinearScaleModel | null; 235 | if (isColorMapModel(scale)) { 236 | return false; 237 | } 238 | return !!scale && typeof scale.obj.invert === 'function'; 239 | } 240 | 241 | setNDArray(array: ndarray.NdArray | null, key='scaledData', options?: any): void { 242 | if (key === 'scaledData') { 243 | // Writing back, we need to feed the data through scale.invert() 244 | 245 | const current = getArray(this.get('data')); 246 | const scale = this.get('scale') as LinearScaleModel | null; 247 | // Handle null case immediately: 248 | if (array === null || scale === null) { 249 | setArray(this, 'data', null, options); 250 | return; 251 | } 252 | // Allocate new array 253 | const dtype = current ? current.dtype : array.dtype; 254 | // Special case colors, as we allow them to transform the shape 255 | const shape = array.shape; 256 | const newArray = arrayFrom(array, dtype, shape); 257 | 258 | let data = array.data as TypedArray; 259 | let target = newArray.data as TypedArray; 260 | 261 | // Set values: 262 | for (let i = 0; i < data.length; ++i) { 263 | target[i] = scale.obj.invert(data[i]) 264 | } 265 | 266 | setArray(this, 'data', newArray, options); 267 | 268 | } else { 269 | setArray(this, key, array, options); 270 | } 271 | } 272 | 273 | /** 274 | * Callback for when the source data changes. 275 | * 276 | * @param {WidgetModel} model 277 | * @memberof ScaledArrayModel 278 | */ 279 | protected onChange(model: WidgetModel, options?: any): void { 280 | if (!options || options.setScaled !== true) { 281 | this.computeScaledData(options); 282 | } 283 | } 284 | 285 | /** 286 | * Whether scaledData has the incorrect shape or type. 287 | * 288 | * @protected 289 | * @returns {boolean} 290 | * @memberof ScaledArrayModel 291 | */ 292 | protected arrayMismatch(): boolean { 293 | const current = this.get('scaledData') as ndarray.NdArray | null; 294 | if (current && current.dtype !== this.scaledDtype()) { 295 | return true; 296 | } 297 | return shapesDiffer(current && current.shape, this.scaledShape()); 298 | } 299 | 300 | /** 301 | * Get what the dtype of the scaled data *should* be 302 | */ 303 | protected scaledDtype(): ndarray.DataType | null { 304 | let output_dtype = this.get('output_dtype') as ndarray.DataType | 'inherit'; 305 | if (output_dtype !== 'inherit') { 306 | return output_dtype; 307 | } 308 | let array = getArray(this.get('data')); 309 | if (array === null) { 310 | return null; 311 | } 312 | return array.dtype; 313 | } 314 | 315 | /** 316 | * Get what the shape of the scaled data *should* be 317 | */ 318 | protected scaledShape(): number[] | null { 319 | const scale = this.get('scale'); 320 | const array = getArray(this.get('data')); 321 | // Special case colors, as we allow them to transform the shape 322 | if (isColorMapModel(scale)) { 323 | return array && array.shape.concat(4); 324 | } 325 | return array && array.shape; 326 | } 327 | 328 | /** 329 | * A promise that resolves once the model has finished its initialization. 330 | * 331 | * @type {Promise} 332 | * @memberof ScaledArrayModel 333 | */ 334 | initPromise: Promise; 335 | 336 | static serializers: ISerializers = { 337 | ...DataModel.serializers, 338 | data: data_union_serialization, 339 | scale: { deserialize: unpack_models }, 340 | scaledData: { serialize: undefSerializer }, 341 | }; 342 | 343 | static model_name = 'ScaledArrayModel'; 344 | static model_module = MODULE_NAME; 345 | static model_module_version = MODULE_VERSION; 346 | } 347 | -------------------------------------------------------------------------------- /examples/colorbar.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Color bars" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "ipyscales includes widgets for visualising color maps as color bars, and for manually editing color maps.\n", 15 | "\n", 16 | "Let's define two simple color maps first:" 17 | ] 18 | }, 19 | { 20 | "cell_type": "code", 21 | "execution_count": 1, 22 | "metadata": {}, 23 | "outputs": [], 24 | "source": [ 25 | "import ipyscales" 26 | ] 27 | }, 28 | { 29 | "cell_type": "code", 30 | "execution_count": 2, 31 | "metadata": {}, 32 | "outputs": [], 33 | "source": [ 34 | "from ipyscales._example_helper import use_example_model_ids\n", 35 | "use_example_model_ids()" 36 | ] 37 | }, 38 | { 39 | "cell_type": "code", 40 | "execution_count": 3, 41 | "metadata": {}, 42 | "outputs": [], 43 | "source": [ 44 | "cm = ipyscales.LinearColorScale(\n", 45 | " range=('blue', 'green'),\n", 46 | " domain=(1000, 5000)\n", 47 | ")\n", 48 | "cm_div = ipyscales.LinearColorScale(\n", 49 | " range=('blue', 'white', 'red'),\n", 50 | " domain=(-1, 0, 1)\n", 51 | ")" 52 | ] 53 | }, 54 | { 55 | "cell_type": "markdown", 56 | "metadata": {}, 57 | "source": [ 58 | "Next, let us define a color bar for the first color map:" 59 | ] 60 | }, 61 | { 62 | "cell_type": "code", 63 | "execution_count": 4, 64 | "metadata": {}, 65 | "outputs": [], 66 | "source": [ 67 | "colorbar = ipyscales.ColorBar(colormap=cm, length=300)\n", 68 | "assert colorbar.colormap is cm" 69 | ] 70 | }, 71 | { 72 | "cell_type": "code", 73 | "execution_count": 5, 74 | "metadata": {}, 75 | "outputs": [ 76 | { 77 | "data": { 78 | "application/vnd.jupyter.widget-view+json": { 79 | "model_id": "ipyscales_example_model_003", 80 | "version_major": 2, 81 | "version_minor": 0 82 | }, 83 | "text/plain": [ 84 | "ColorBar(colormap=LinearColorScale(domain=(1000.0, 5000.0), range=('blue', 'green')), length=300)" 85 | ] 86 | }, 87 | "metadata": {}, 88 | "output_type": "display_data" 89 | } 90 | ], 91 | "source": [ 92 | "colorbar" 93 | ] 94 | }, 95 | { 96 | "cell_type": "markdown", 97 | "metadata": {}, 98 | "source": [ 99 | "The orientation of the color bar can be made horizontal as well:" 100 | ] 101 | }, 102 | { 103 | "cell_type": "code", 104 | "execution_count": 6, 105 | "metadata": {}, 106 | "outputs": [ 107 | { 108 | "data": { 109 | "application/vnd.jupyter.widget-view+json": { 110 | "model_id": "ipyscales_example_model_005", 111 | "version_major": 2, 112 | "version_minor": 0 113 | }, 114 | "text/plain": [ 115 | "ColorBar(colormap=LinearColorScale(domain=(-1.0, 0.0, 1.0), range=('blue', 'white', 'red')), length=300, orien…" 116 | ] 117 | }, 118 | "metadata": {}, 119 | "output_type": "display_data" 120 | } 121 | ], 122 | "source": [ 123 | "ipyscales.ColorBar(colormap=cm_div, length=300, orientation='horizontal')" 124 | ] 125 | }, 126 | { 127 | "cell_type": "markdown", 128 | "metadata": {}, 129 | "source": [ 130 | "The color map can include transparent colors, and the color bar also supports this:" 131 | ] 132 | }, 133 | { 134 | "cell_type": "code", 135 | "execution_count": 7, 136 | "metadata": {}, 137 | "outputs": [], 138 | "source": [ 139 | "cm_div_transp = ipyscales.LinearColorScale(\n", 140 | " range=('rgba(0, 0, 255, 0.5)', 'white', 'rgba(255, 0, 0, 0.5)'),\n", 141 | " domain=(-1, 0, 1)\n", 142 | ")" 143 | ] 144 | }, 145 | { 146 | "cell_type": "code", 147 | "execution_count": 8, 148 | "metadata": {}, 149 | "outputs": [ 150 | { 151 | "data": { 152 | "application/vnd.jupyter.widget-view+json": { 153 | "model_id": "ipyscales_example_model_008", 154 | "version_major": 2, 155 | "version_minor": 0 156 | }, 157 | "text/plain": [ 158 | "ColorBar(colormap=LinearColorScale(domain=(-1.0, 0.0, 1.0), range=('rgba(0, 0, 255, 0.5)', 'white', 'rgba(255,…" 159 | ] 160 | }, 161 | "metadata": {}, 162 | "output_type": "display_data" 163 | } 164 | ], 165 | "source": [ 166 | "ipyscales.ColorBar(colormap=cm_div_transp, length=200)" 167 | ] 168 | }, 169 | { 170 | "cell_type": "markdown", 171 | "metadata": {}, 172 | "source": [ 173 | "The editor `ColorMapEditor` can be used to manually edit the color map. The actions you can do are:\n", 174 | "- Change the color of a stop by double-clicking the corresponding handle.\n", 175 | "- Change the location of stops by dragging the handles." 176 | ] 177 | }, 178 | { 179 | "cell_type": "code", 180 | "execution_count": 9, 181 | "metadata": {}, 182 | "outputs": [ 183 | { 184 | "data": { 185 | "application/vnd.jupyter.widget-view+json": { 186 | "model_id": "ipyscales_example_model_010", 187 | "version_major": 2, 188 | "version_minor": 0 189 | }, 190 | "text/plain": [ 191 | "ColorMapEditor(colormap=LinearColorScale(domain=(-1.0, 0.0, 1.0), range=('rgba(0, 0, 255, 0.5)', 'white', 'rgb…" 192 | ] 193 | }, 194 | "metadata": {}, 195 | "output_type": "display_data" 196 | } 197 | ], 198 | "source": [ 199 | "ipyscales.ColorMapEditor(\n", 200 | " colormap=cm_div_transp, length=400, orientation='horizontal')" 201 | ] 202 | }, 203 | { 204 | "cell_type": "markdown", 205 | "metadata": {}, 206 | "source": [ 207 | "Future features planned for the editor include:\n", 208 | "- Adding new stops.\n", 209 | "- Removing stops.\n", 210 | "- Modifying the stop location by numeric input.\n", 211 | "- UI for changing opacity of a color stop." 212 | ] 213 | } 214 | ], 215 | "metadata": { 216 | "kernelspec": { 217 | "display_name": "Python 3", 218 | "language": "python", 219 | "name": "python3" 220 | }, 221 | "language_info": { 222 | "codemirror_mode": { 223 | "name": "ipython", 224 | "version": 3 225 | }, 226 | "file_extension": ".py", 227 | "mimetype": "text/x-python", 228 | "name": "python", 229 | "nbconvert_exporter": "python", 230 | "pygments_lexer": "ipython3", 231 | "version": "3.6.6" 232 | }, 233 | "widgets": { 234 | "application/vnd.jupyter.widget-state+json": { 235 | "state": { 236 | "ipyscales_example_model_001": { 237 | "model_module": "jupyter-scales", 238 | "model_module_version": "^3.0.0", 239 | "model_name": "LinearColorScaleModel", 240 | "state": { 241 | "_model_module_version": "^3.0.0", 242 | "_model_name": "LinearColorScaleModel", 243 | "_view_module_version": "", 244 | "domain": [ 245 | 1000, 246 | 5000 247 | ], 248 | "range": [ 249 | "blue", 250 | "green" 251 | ] 252 | } 253 | }, 254 | "ipyscales_example_model_002": { 255 | "model_module": "jupyter-scales", 256 | "model_module_version": "^3.0.0", 257 | "model_name": "LinearColorScaleModel", 258 | "state": { 259 | "_model_module_version": "^3.0.0", 260 | "_model_name": "LinearColorScaleModel", 261 | "_view_module_version": "", 262 | "domain": [ 263 | -1, 264 | 0, 265 | 1 266 | ], 267 | "range": [ 268 | "blue", 269 | "white", 270 | "red" 271 | ] 272 | } 273 | }, 274 | "ipyscales_example_model_003": { 275 | "model_module": "jupyter-scales", 276 | "model_module_version": "^3.0.0", 277 | "model_name": "ColorBarModel", 278 | "state": { 279 | "_model_module_version": "^3.0.0", 280 | "_view_module_version": "^3.0.0", 281 | "colormap": "IPY_MODEL_ipyscales_example_model_001", 282 | "layout": "IPY_MODEL_ipyscales_example_model_004", 283 | "length": 300 284 | } 285 | }, 286 | "ipyscales_example_model_004": { 287 | "model_module": "@jupyter-widgets/base", 288 | "model_module_version": "1.1.0", 289 | "model_name": "LayoutModel", 290 | "state": {} 291 | }, 292 | "ipyscales_example_model_005": { 293 | "model_module": "jupyter-scales", 294 | "model_module_version": "^3.0.0", 295 | "model_name": "ColorBarModel", 296 | "state": { 297 | "_model_module_version": "^3.0.0", 298 | "_view_module_version": "^3.0.0", 299 | "colormap": "IPY_MODEL_ipyscales_example_model_002", 300 | "layout": "IPY_MODEL_ipyscales_example_model_006", 301 | "length": 300, 302 | "orientation": "horizontal" 303 | } 304 | }, 305 | "ipyscales_example_model_006": { 306 | "model_module": "@jupyter-widgets/base", 307 | "model_module_version": "1.1.0", 308 | "model_name": "LayoutModel", 309 | "state": {} 310 | }, 311 | "ipyscales_example_model_007": { 312 | "model_module": "jupyter-scales", 313 | "model_module_version": "^3.0.0", 314 | "model_name": "LinearColorScaleModel", 315 | "state": { 316 | "_model_module_version": "^3.0.0", 317 | "_model_name": "LinearColorScaleModel", 318 | "_view_module_version": "", 319 | "domain": [ 320 | -1, 321 | 0, 322 | 1 323 | ], 324 | "range": [ 325 | "rgba(0, 0, 255, 0.5)", 326 | "white", 327 | "rgba(255, 0, 0, 0.5)" 328 | ] 329 | } 330 | }, 331 | "ipyscales_example_model_008": { 332 | "model_module": "jupyter-scales", 333 | "model_module_version": "^3.0.0", 334 | "model_name": "ColorBarModel", 335 | "state": { 336 | "_model_module_version": "^3.0.0", 337 | "_view_module_version": "^3.0.0", 338 | "colormap": "IPY_MODEL_ipyscales_example_model_007", 339 | "layout": "IPY_MODEL_ipyscales_example_model_009", 340 | "length": 200 341 | } 342 | }, 343 | "ipyscales_example_model_009": { 344 | "model_module": "@jupyter-widgets/base", 345 | "model_module_version": "1.1.0", 346 | "model_name": "LayoutModel", 347 | "state": {} 348 | }, 349 | "ipyscales_example_model_010": { 350 | "model_module": "jupyter-scales", 351 | "model_module_version": "^3.0.0", 352 | "model_name": "ColorMapEditorModel", 353 | "state": { 354 | "_model_module_version": "^3.0.0", 355 | "_view_module_version": "^3.0.0", 356 | "colormap": "IPY_MODEL_ipyscales_example_model_007", 357 | "layout": "IPY_MODEL_ipyscales_example_model_011", 358 | "length": 400 359 | } 360 | }, 361 | "ipyscales_example_model_011": { 362 | "model_module": "@jupyter-widgets/base", 363 | "model_module_version": "1.1.0", 364 | "model_name": "LayoutModel", 365 | "state": {} 366 | } 367 | }, 368 | "version_major": 2, 369 | "version_minor": 0 370 | } 371 | } 372 | }, 373 | "nbformat": 4, 374 | "nbformat_minor": 2 375 | } 376 | -------------------------------------------------------------------------------- /js/src/scale.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { 5 | WidgetModel, IWidgetManager 6 | } from '@jupyter-widgets/base'; 7 | 8 | import { 9 | ScaleSequential, ScaleQuantize, scaleQuantize, ScaleQuantile, scaleQuantile, 10 | ScaleOrdinal, scaleOrdinal, scaleImplicit 11 | } from 'd3-scale'; 12 | 13 | import { 14 | listenToUnion, 15 | } from 'jupyter-dataserializers'; 16 | 17 | import { 18 | MODULE_NAME, MODULE_VERSION 19 | } from './version'; 20 | 21 | 22 | export 23 | interface ISerializerMap { 24 | [key: string]: { 25 | deserialize?: (value?: any, manager?: IWidgetManager) => any; 26 | serialize?: (value?: any, widget?: WidgetModel) => any; 27 | }; 28 | } 29 | 30 | export 31 | interface IInitializeOptions { 32 | model_id: string; 33 | comm?: any; 34 | widget_manager: IWidgetManager; 35 | } 36 | 37 | 38 | /** 39 | * Base model for scales 40 | */ 41 | export 42 | abstract class ScaleModel extends WidgetModel { 43 | 44 | /** 45 | * Returns default values for the model attributes. 46 | */ 47 | defaults() { 48 | const ctor = this.constructor as any; 49 | return {...super.defaults(), 50 | _model_name: ctor.model_name, 51 | _model_module: ctor.model_module, 52 | _model_module_version: ctor.model_module_version, 53 | _view_name: ctor.view_name, 54 | _view_module: ctor.view_module, 55 | _view_module_version: ctor.view_module_version, 56 | }; 57 | } 58 | 59 | /** 60 | * Backbone initialize function. 61 | */ 62 | initialize(attributes: Backbone.ObjectHash, options: IInitializeOptions) { 63 | super.initialize(attributes, options); 64 | this.createPropertiesArrays(); 65 | 66 | // Instantiate scale object 67 | this.initPromise = this.createObject().then(() => { 68 | 69 | // sync the properties from the server to the model 70 | this.syncToObject(); 71 | 72 | // sync any properties that might have mutated back 73 | this.syncToModel({}); 74 | 75 | // setup msg, model, and children change listeners 76 | this.setupListeners(); 77 | 78 | }); 79 | } 80 | 81 | createPropertiesArrays() { 82 | this.datawidgetProperties = []; 83 | this.childModelProperties = []; 84 | this.simpleProperties = []; 85 | } 86 | 87 | /** 88 | * Update the model attributes from the objects properties. 89 | * 90 | * The base method calls `this.set(toSet, 'pushFromObject');`. 91 | * Overriding methods should add any properties not in 92 | * simpleProperties to the hash before calling the super 93 | * method. 94 | */ 95 | syncToModel(toSet: Backbone.ObjectHash): void { 96 | for (let name of this.simpleProperties) { 97 | toSet[name] = this.obj[name](); 98 | } 99 | // Apply all direct changes at once 100 | this.set(toSet, 'pushFromObject'); 101 | } 102 | 103 | /** 104 | * Update the model attributes from the objects properties. 105 | */ 106 | syncToObject(): void { 107 | // Sync the simple properties: 108 | for (let name of this.simpleProperties) { 109 | this.obj[name](this.get(name)); 110 | } 111 | }; 112 | 113 | /** 114 | * Create or return the underlying object this model represents. 115 | */ 116 | protected createObject(): Promise { 117 | // call constructor method overridden by every class 118 | let objPromise = Promise.resolve(this.constructObject()); 119 | 120 | 121 | return objPromise.then(this.processNewObj.bind(this)); 122 | 123 | } 124 | 125 | /** 126 | * Construct and return the underlying object this model represents. 127 | * 128 | * Override this in inherting classes. 129 | */ 130 | protected abstract constructObject(): any | Promise; 131 | 132 | /** 133 | * Process a new underlying object to represent this model. 134 | * 135 | * The base implementation sets up mapping between the model, 136 | * the cache, and the widget manager. 137 | */ 138 | protected processNewObj(obj: any): any | Promise { 139 | obj.ipymodelId = this.model_id; // brand that sucker 140 | obj.ipymodel = this; 141 | 142 | this.obj = obj; 143 | return obj; 144 | } 145 | 146 | /** 147 | * Set up any event listeners. 148 | * 149 | * Called after object initialization is complete. 150 | */ 151 | setupListeners() { 152 | // Handle changes in child model instance props 153 | for (let propName of this.childModelProperties) { 154 | // register listener for current child value 155 | var curValue = this.get(propName) as ScaleModel; 156 | if (curValue) { 157 | this.listenTo(curValue, 'change', this.onChildChanged.bind(this)); 158 | this.listenTo(curValue, 'childchange', this.onChildChanged.bind(this)); 159 | } 160 | 161 | // make sure to (un)hook listeners when child points to new object 162 | this.on('change:' + propName, (model: ScaleModel, value: ScaleModel, options: any) => { 163 | const prevModel = this.previous(propName) as ScaleModel; 164 | const currModel = value; 165 | if (prevModel) { 166 | this.stopListening(prevModel); 167 | } 168 | if (currModel) { 169 | this.listenTo(currModel, 'change', this.onChildChanged.bind(this)); 170 | this.listenTo(currModel, 'childchange', this.onChildChanged.bind(this)); 171 | } 172 | }, this); 173 | }; 174 | 175 | // Handle changes in data widgets/union properties 176 | for (let propName of this.datawidgetProperties) { 177 | listenToUnion(this, propName, this.onChildChanged.bind(this), false); 178 | }; 179 | this.on('change', this.onChange, this); 180 | this.on('msg:custom', this.onCustomMessage, this); 181 | } 182 | 183 | onChange(model: WidgetModel, options: any) { 184 | if (options !== 'pushFromObject') { 185 | this.syncToObject(); 186 | } 187 | } 188 | 189 | onChildChanged(model: WidgetModel, options: any) { 190 | // Propagate up hierarchy: 191 | this.trigger('childchange', this); 192 | } 193 | 194 | onCustomMessage(content: any, buffers: any) { 195 | } 196 | 197 | static serializers: ISerializerMap = WidgetModel.serializers; 198 | 199 | static model_name: string; // Base model should not be instantiated directly 200 | static model_module = MODULE_NAME; 201 | static model_module_version = MODULE_VERSION; 202 | static view_name = null; 203 | static view_module = null; 204 | static view_module_version = MODULE_VERSION; 205 | 206 | /** 207 | * The underlying object this model represents. 208 | */ 209 | obj: any; 210 | 211 | /** 212 | * Promise that resolves when initialization is complete. 213 | */ 214 | initPromise: Promise; 215 | 216 | datawidgetProperties: string[]; 217 | childModelProperties: string[]; 218 | simpleProperties: string[]; 219 | } 220 | 221 | 222 | /** 223 | * A widget model of a sequential scale 224 | */ 225 | export abstract class SequentialScaleModel extends ScaleModel { 226 | defaults() { 227 | return {...super.defaults(), 228 | domain: [0, 1], 229 | clamp: false, 230 | }; 231 | } 232 | 233 | createPropertiesArrays() { 234 | super.createPropertiesArrays(); 235 | this.simpleProperties.push( 236 | 'domain', 237 | 'clamp', 238 | ); 239 | } 240 | 241 | obj: ScaleSequential; 242 | 243 | static serializers = { 244 | ...ScaleModel.serializers, 245 | } 246 | } 247 | 248 | 249 | /** 250 | * A widget model of a quantize scale 251 | */ 252 | export class QuantizeScaleModel extends ScaleModel { 253 | defaults() { 254 | return {...super.defaults(), 255 | domain: [0, 1], 256 | range: [0, 1], 257 | }; 258 | } 259 | 260 | createPropertiesArrays() { 261 | super.createPropertiesArrays(); 262 | this.simpleProperties.push( 263 | 'domain', 264 | 'range', 265 | ); 266 | } 267 | 268 | constructObject() { 269 | return scaleQuantize(); 270 | } 271 | 272 | obj: ScaleQuantize; 273 | 274 | static serializers = { 275 | ...ScaleModel.serializers, 276 | } 277 | 278 | static model_name = 'QuantizeScaleModel'; 279 | } 280 | 281 | 282 | /** 283 | * A widget model of a quantile scale 284 | */ 285 | export class QuantileScaleModel extends ScaleModel { 286 | defaults() { 287 | return {...super.defaults(), 288 | domain: [0], 289 | range: [0], 290 | }; 291 | } 292 | 293 | createPropertiesArrays() { 294 | super.createPropertiesArrays(); 295 | this.simpleProperties.push( 296 | 'domain', 297 | 'range', 298 | ); 299 | } 300 | 301 | constructObject() { 302 | return scaleQuantile(); 303 | } 304 | 305 | obj: ScaleQuantile; 306 | 307 | static serializers = { 308 | ...ScaleModel.serializers, 309 | } 310 | 311 | static model_name = 'QuantileScaleModel'; 312 | } 313 | 314 | 315 | /** 316 | * A widget model of a teshold scale 317 | */ 318 | export class TresholdScaleModel extends ScaleModel { 319 | defaults() { 320 | return {...super.defaults(), 321 | domain: [], 322 | range: [0], 323 | }; 324 | } 325 | 326 | createPropertiesArrays() { 327 | super.createPropertiesArrays(); 328 | this.simpleProperties.push( 329 | 'domain', 330 | 'range', 331 | ); 332 | } 333 | 334 | constructObject() { 335 | return scaleQuantile(); 336 | } 337 | 338 | obj: ScaleQuantile; 339 | 340 | static serializers = { 341 | ...ScaleModel.serializers, 342 | } 343 | 344 | static model_name = 'TresholdScaleModel'; 345 | } 346 | 347 | 348 | 349 | /** 350 | * A widget model of an ordinal scale 351 | */ 352 | export class OrdinalScaleModel extends ScaleModel { 353 | defaults() { 354 | return {...super.defaults(), 355 | domain: [], 356 | range: [], 357 | unknown: scaleImplicit, 358 | }; 359 | } 360 | 361 | createPropertiesArrays() { 362 | super.createPropertiesArrays(); 363 | this.simpleProperties.push( 364 | 'range', 365 | 'unknown', 366 | ); 367 | } 368 | 369 | constructObject() { 370 | return scaleOrdinal(); 371 | } 372 | 373 | /** 374 | * Update the model attributes from the objects properties. 375 | */ 376 | syncToModel(toSet: Backbone.ObjectHash): void { 377 | toSet['domain'] = this.obj.domain(); 378 | super.syncToModel(toSet); 379 | } 380 | 381 | /** 382 | * Update the model attributes from the objects properties. 383 | */ 384 | syncToObject(): void { 385 | super.syncToObject(); 386 | this.obj.domain(this.get('domain') ?? []); 387 | }; 388 | 389 | obj: ScaleOrdinal; 390 | 391 | static serializers = { 392 | ...ScaleModel.serializers, 393 | unknown: { 394 | deserialize: (value?: any, manager?: IWidgetManager) => { 395 | return value === '__implicit' 396 | ? scaleImplicit 397 | : value === null 398 | ? undefined 399 | : value; 400 | }, 401 | serialize: (value?: any, widget?: WidgetModel) => { 402 | return value === scaleImplicit 403 | ? '__implicit' 404 | : value === undefined 405 | ? null 406 | : value; 407 | }, 408 | } 409 | } 410 | 411 | static model_name = 'OrdinalScaleModel'; 412 | } 413 | -------------------------------------------------------------------------------- /js/src/colormap/scales.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { 5 | rgb, hsl 6 | } from 'd3-color'; 7 | 8 | import { 9 | interpolateRgb, interpolateHsl, piecewise 10 | } from 'd3-interpolate'; 11 | 12 | import { 13 | scaleSequential, scaleDiverging, scaleOrdinal, 14 | } from 'd3-scale'; 15 | 16 | // Polyfill missing typing for diverging scale: 17 | declare module "d3-scale" { 18 | function scaleDiverging(interpolator: ((t: number) => Output)): ScaleSequential; 19 | } 20 | 21 | import * as d3Chromatic from 'd3-scale-chromatic'; 22 | 23 | import { 24 | data_union_array_serialization, TypedArray 25 | } from 'jupyter-dataserializers'; 26 | 27 | import ndarray = require('ndarray') 28 | 29 | import { 30 | LinearScaleModel, LogScaleModel 31 | } from '../continuous'; 32 | 33 | import { SequentialScaleModel, OrdinalScaleModel } from '../scale'; 34 | 35 | import { arrayEquals } from '../utils'; 36 | 37 | 38 | /** 39 | * Contiguous color map. 40 | */ 41 | export class LinearColorScaleModel extends LinearScaleModel { 42 | defaults(): any { 43 | return {...super.defaults(), 44 | range: ['black', 'white'], 45 | }; 46 | } 47 | 48 | isColorScale = true; 49 | 50 | } 51 | 52 | /** 53 | * Contiguous color map. 54 | */ 55 | export class LogColorScaleModel extends LogScaleModel { 56 | defaults(): any { 57 | return {...super.defaults(), 58 | range: ['black', 'white'], 59 | }; 60 | } 61 | 62 | isColorScale = true; 63 | 64 | static model_name = 'LogColorScaleModel'; 65 | } 66 | 67 | 68 | export class ArrayColorScaleModel extends SequentialScaleModel { 69 | 70 | isColorScale = true; 71 | 72 | defaults(): any { 73 | return {...super.defaults(), 74 | colors: ndarray(new Float32Array([0, 0, 0, 1, 1, 1]), [2, 3]), 75 | space: 'rgb', 76 | gamma: 1.0, 77 | }; 78 | } 79 | 80 | createInterpolator(): (t: number) => any { 81 | const space = this.get('space') as string; 82 | const colors = this.get('colors') as ndarray.NdArray; 83 | const factory = space === 'hsl' ? hsl : rgb; 84 | const spaceColors = []; 85 | const alpha = colors.shape[1] > 3; 86 | if (space === 'hsl') { 87 | for (let i = 0; i < colors.shape[0]; ++i) { 88 | spaceColors.push(factory( 89 | 360 * colors.get(i, 0), 90 | colors.get(i, 1), 91 | colors.get(i, 2), 92 | alpha ? colors.get(i, 3) : 1.0 93 | )); 94 | } 95 | return piecewise(interpolateHsl, spaceColors); 96 | } 97 | 98 | for (let i = 0; i < colors.shape[0]; ++i) { 99 | spaceColors.push(factory( 100 | 255 * colors.get(i, 0), 101 | 255 * colors.get(i, 1), 102 | 255 * colors.get(i, 2), 103 | alpha ? colors.get(i, 3) : 1.0 104 | )); 105 | } 106 | let gamma = this.get('gamma'); 107 | if (gamma === undefined || gamma === null) { 108 | gamma = 1.0; 109 | } 110 | return piecewise(interpolateRgb.gamma(gamma), spaceColors); 111 | } 112 | 113 | syncToObject() { 114 | super.syncToObject(); 115 | const interpProps = ['colors', 'space', 'gamma']; 116 | const interpChange = interpProps.some(prop => this.hasChanged(prop)); 117 | if (interpChange) { 118 | this.obj.interpolator(this.createInterpolator()); 119 | } 120 | } 121 | 122 | /** 123 | * Create the wrapped d3-scale scaleLinear object 124 | */ 125 | constructObject(): any { 126 | return scaleSequential(this.createInterpolator()); 127 | } 128 | 129 | protected colorInterp: ((t: number) => string) | null = null; 130 | 131 | static model_name = 'ArrayColorScaleModel'; 132 | 133 | static serializers = { 134 | ...SequentialScaleModel.serializers, 135 | colors: data_union_array_serialization, 136 | } 137 | } 138 | 139 | 140 | export type ColorInterpolator = (t: number) => string; 141 | 142 | const chromaticInterpLut: {[key: string]: ColorInterpolator} = {}; 143 | for (let key of Object.keys(d3Chromatic)) { 144 | if (key.indexOf('interpolate') === 0) { 145 | const lowKey = key.slice('interpolate'.length).toLowerCase(); 146 | chromaticInterpLut[lowKey] = (d3Chromatic as any)[key]; 147 | } 148 | } 149 | 150 | const chromaticSchemeLut: {[key: string]: string[] | string[][]} = {}; 151 | for (let key of Object.keys(d3Chromatic)) { 152 | if (key.indexOf('scheme') === 0) { 153 | const lowKey = key.slice('scheme'.length).toLowerCase(); 154 | chromaticSchemeLut[lowKey] = (d3Chromatic as any)[key]; 155 | } 156 | } 157 | 158 | function isFixedScheme(candidate: string[] | string[][]): candidate is string[] { 159 | return candidate.length < 4 || !Array.isArray(candidate[3]); 160 | } 161 | 162 | 163 | /** 164 | * A contiguous color map created from a named color map. 165 | */ 166 | class NamedSequentialColorMapBase extends SequentialScaleModel { 167 | 168 | getInterpolatorFactory(): ColorInterpolator { 169 | let name = this.get('name') as string; 170 | return chromaticInterpLut[name.toLowerCase()]; 171 | } 172 | 173 | getInterpolatorFactoryName(): string | null { 174 | const interp = this.obj.interpolator(); 175 | // Do a reverse lookup in d3Chromatic 176 | const lut = d3Chromatic as any; 177 | for (let key of Object.keys(lut)) { 178 | if (interp === lut[key]) { 179 | const name = key.replace(/^interpolate/, ''); 180 | return name; 181 | } 182 | } 183 | throw new Error(`Unknown color interpolator name of function: ${interp}`); 184 | } 185 | 186 | constructObject() { 187 | const interpolator = this.getInterpolatorFactory(); 188 | return scaleSequential(interpolator); 189 | } 190 | 191 | /** 192 | * Sync the model properties to the d3 object. 193 | */ 194 | syncToObject() { 195 | super.syncToObject(); 196 | const interpolator = this.getInterpolatorFactory(); 197 | this.obj 198 | .interpolator(interpolator); 199 | } 200 | 201 | syncToModel(toSet: Backbone.ObjectHash) { 202 | toSet['name'] = this.getInterpolatorFactoryName(); 203 | super.syncToModel(toSet); 204 | } 205 | 206 | isColorScale = true; 207 | } 208 | 209 | /** 210 | * A contiguous color map created from a named color map. 211 | */ 212 | export class NamedSequentialColorMap extends NamedSequentialColorMapBase { 213 | defaults(): any { 214 | return {...super.defaults(), 215 | name: 'Viridis' 216 | }; 217 | } 218 | 219 | static model_name = 'NamedSequentialColorMap'; 220 | } 221 | 222 | /** 223 | * A contiguous color map created from a named color map. 224 | */ 225 | export class NamedDivergingColorMap extends NamedSequentialColorMapBase { 226 | defaults(): any { 227 | return {...super.defaults(), 228 | name: 'BrBG', 229 | domain: [0, 0.5, 1], 230 | }; 231 | } 232 | 233 | constructObject() { 234 | const interpolator = this.getInterpolatorFactory(); 235 | return scaleDiverging(interpolator); 236 | } 237 | 238 | static model_name = 'NamedDivergingColorMap'; 239 | } 240 | 241 | 242 | /** 243 | * A contiguous color map created from a named color map. 244 | */ 245 | export class NamedOrdinalColorMap extends OrdinalScaleModel { 246 | defaults(): any { 247 | const def = {...super.defaults(), 248 | name: 'Category10', 249 | cardinality: 10, 250 | } as any; 251 | delete def.range; 252 | return def; 253 | } 254 | 255 | createPropertiesArrays() { 256 | super.createPropertiesArrays(); 257 | this.simpleProperties.splice( 258 | this.simpleProperties.indexOf('range'), 1 259 | ); 260 | } 261 | 262 | getScheme(): string[] { 263 | const name = this.get('name') as string; 264 | const scheme = chromaticSchemeLut[name.toLowerCase()]; 265 | if (!scheme) { 266 | throw new Error(`Unknown scheme name: ${name}`); 267 | } 268 | if (isFixedScheme(scheme)) { 269 | return scheme; 270 | } 271 | const cardinality = this.get('cardinality') as number; 272 | return scheme[cardinality]; 273 | } 274 | 275 | getSchemeName(): string | null { 276 | const scheme = this.obj.range() as string[]; 277 | // Do a reverse lookup in d3Chromatic 278 | const lut = d3Chromatic as any; 279 | for (let key of Object.keys(lut)) { 280 | let candidate = lut[key]; 281 | if (!candidate || !Array.isArray(candidate)) { 282 | continue; 283 | } 284 | if (!isFixedScheme(candidate)) { 285 | candidate = candidate[scheme.length]; 286 | if (!candidate) { 287 | continue; 288 | } 289 | } 290 | if (arrayEquals(scheme, candidate)) { 291 | const name = key.replace(/^scheme/, ''); 292 | return name; 293 | } 294 | } 295 | throw new Error(`Unknown color scheme name for range: ${scheme}`); 296 | } 297 | 298 | constructObject() { 299 | const scheme = this.getScheme(); 300 | return scaleOrdinal(scheme); 301 | } 302 | 303 | /** 304 | * Sync the model properties to the d3 object. 305 | */ 306 | syncToObject() { 307 | super.syncToObject(); 308 | const scheme = this.getScheme(); 309 | this.obj 310 | .range(scheme); 311 | } 312 | 313 | syncToModel(toSet: Backbone.ObjectHash) { 314 | toSet['name'] = this.getSchemeName(); 315 | super.syncToModel(toSet); 316 | } 317 | 318 | isColorScale = true; 319 | 320 | static model_name = 'NamedOrdinalColorMap'; 321 | } 322 | 323 | 324 | export interface ColorScale { 325 | copy(): this; 326 | domain(domain: number[]): this; 327 | (value: number | { valueOf(): number }): string; 328 | } 329 | 330 | export interface ColorMapModel { 331 | obj: ColorScale; 332 | isColorScale: true; 333 | } 334 | 335 | export function isColorMapModel(candidate: any): candidate is ColorMapModel { 336 | return ( 337 | candidate !== null && candidate !== undefined && 338 | candidate.isColorScale === true); 339 | } 340 | 341 | 342 | export function colormapAsRGBArray(mapModel: ColorMapModel, size: number): Uint8ClampedArray; 343 | export function colormapAsRGBArray(mapModel: ColorMapModel, array: T): T; 344 | export function colormapAsRGBArray(mapModel: ColorMapModel, data: number | TypedArray): TypedArray { 345 | let n; 346 | if (typeof data === 'number') { 347 | n = data; 348 | data = new Uint8ClampedArray(n * 3); 349 | } else { 350 | n = data.length / 3; 351 | } 352 | const scale = mapModel.obj.copy().domain([0, n]); 353 | for (let i=0; i(mapModel: ColorMapModel, array: T): T; 366 | export function colormapAsRGBAArray(mapModel: ColorMapModel, data: number | TypedArray): TypedArray { 367 | let n; 368 | if (typeof data === 'number') { 369 | n = data; 370 | data = new Uint8ClampedArray(n * 4); 371 | } else { 372 | n = data.length / 4; 373 | } 374 | let scale; 375 | 376 | let values = Array.from(new Array(n), (x,i) => i); // range(n) 377 | if (mapModel instanceof NamedDivergingColorMap) { 378 | scale = mapModel.obj.copy().domain([0, n/2, n]); 379 | } else if (mapModel instanceof OrdinalScaleModel) { 380 | scale = mapModel.obj; 381 | values = scale.domain(); 382 | } else { 383 | scale = mapModel.obj.copy().domain([0, n - 1]); 384 | } 385 | for (let i of values) { 386 | const color = rgb(scale(i)); 387 | data[i * 4 + 0] = color.r; 388 | data[i * 4 + 1] = color.g; 389 | data[i * 4 + 2] = color.b; 390 | data[i * 4 + 3] = 255 * color.opacity; 391 | } 392 | 393 | return data; 394 | } 395 | -------------------------------------------------------------------------------- /js/tests/src/colormap.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import expect = require('expect.js'); 5 | 6 | import { 7 | interpolate, interpolateHsl 8 | } from 'd3-interpolate'; 9 | 10 | import { 11 | createTestModel 12 | } from './helpers.spec'; 13 | 14 | import { 15 | LinearColorScaleModel, LogColorScaleModel, 16 | NamedDivergingColorMap, NamedSequentialColorMap, 17 | colormapAsRGBArray, colormapAsRGBAArray, 18 | NamedOrdinalColorMap, ArrayColorScaleModel, isColorMapModel 19 | } from '../../src/' 20 | import ndarray = require('ndarray'); 21 | 22 | 23 | describe('ColorScales', () => { 24 | 25 | describe('LinearColorScaleModel', () => { 26 | 27 | it('should be createable', () => { 28 | let model = createTestModel(LinearColorScaleModel); 29 | expect(model).to.be.an(LinearColorScaleModel); 30 | return model.initPromise.then(() => { 31 | expect(typeof model.obj).to.be('function'); 32 | }); 33 | }); 34 | 35 | it('should have expected default values in model', () => { 36 | let model = createTestModel(LinearColorScaleModel); 37 | expect(model).to.be.an(LinearColorScaleModel); 38 | return model.initPromise.then(() => { 39 | expect(model.get('range')).to.eql(['black', 'white']); 40 | expect(model.get('domain')).to.eql([0, 1]); 41 | expect(model.get('clamp')).to.be(false); 42 | expect(model.get('interpolator')).to.be('interpolate'); 43 | }); 44 | }); 45 | 46 | it('should have expected default values in object', () => { 47 | let model = createTestModel(LinearColorScaleModel); 48 | expect(model).to.be.an(LinearColorScaleModel); 49 | return model.initPromise.then(() => { 50 | expect(model.obj.range()).to.eql(['black', 'white']); 51 | expect(model.obj.domain()).to.eql([0, 1]); 52 | expect(model.obj.clamp()).to.be(false); 53 | expect(model.obj.interpolate()).to.be(interpolate); 54 | }); 55 | }); 56 | 57 | it('should be createable with non-default values', () => { 58 | let state = { 59 | range: ['#f00', 'rgba(0, 0, 255, 0.5)'], 60 | domain: [-1e7, 1e5], 61 | clamp: true, 62 | interpolator: 'interpolateHsl', 63 | }; 64 | let model = createTestModel(LinearColorScaleModel, state); 65 | return model.initPromise.then(() => { 66 | expect(model.obj.range()).to.eql(['#f00', 'rgba(0, 0, 255, 0.5)']); 67 | expect(model.obj.domain()).to.eql([-1e7, 1e5]); 68 | expect(model.obj.clamp()).to.be(true); 69 | expect(model.obj.interpolate()).to.be(interpolateHsl); 70 | }); 71 | }); 72 | 73 | }); 74 | 75 | describe('LogColorScaleModel', () => { 76 | it('should be createable', () => { 77 | let model = createTestModel(LogColorScaleModel); 78 | expect(model).to.be.an(LogColorScaleModel); 79 | return model.initPromise.then(() => { 80 | expect(typeof model.obj).to.be('function'); 81 | }); 82 | }); 83 | 84 | it('should have expected default values in model', () => { 85 | let model = createTestModel(LogColorScaleModel); 86 | expect(model).to.be.an(LogColorScaleModel); 87 | return model.initPromise.then(() => { 88 | expect(model.get('range')).to.eql(['black', 'white']); 89 | expect(model.get('domain')).to.eql([1, 10]); 90 | expect(model.get('clamp')).to.be(false); 91 | expect(model.get('interpolator')).to.be('interpolate'); 92 | }); 93 | }); 94 | 95 | it('should have expected default values in object', () => { 96 | let model = createTestModel(LogColorScaleModel); 97 | expect(model).to.be.an(LogColorScaleModel); 98 | return model.initPromise.then(() => { 99 | expect(model.obj.range()).to.eql(['black', 'white']); 100 | expect(model.get('domain')).to.eql([1, 10]); 101 | expect(model.obj.clamp()).to.be(false); 102 | expect(model.obj.interpolate()).to.be(interpolate); 103 | }); 104 | }); 105 | 106 | it('should be createable with non-default values', () => { 107 | let state = { 108 | range: ['#f00', 'rgba(0, 0, 255, 0.5)'], 109 | domain: [-1e7, 1e5], 110 | clamp: true, 111 | interpolator: 'interpolateHsl', 112 | }; 113 | let model = createTestModel(LogColorScaleModel, state); 114 | return model.initPromise.then(() => { 115 | expect(model.obj.range()).to.eql(['#f00', 'rgba(0, 0, 255, 0.5)']); 116 | expect(model.obj.domain()).to.eql([-1e7, 1e5]); 117 | expect(model.obj.clamp()).to.be(true); 118 | expect(model.obj.interpolate()).to.be(interpolateHsl); 119 | }); 120 | }); 121 | 122 | }); 123 | 124 | describe('NamedSequentialColorMap', () => { 125 | 126 | it('should be createable', () => { 127 | let model = createTestModel(NamedSequentialColorMap); 128 | expect(model).to.be.an(NamedSequentialColorMap); 129 | return model.initPromise.then(() => { 130 | expect(typeof model.obj).to.be('function'); 131 | }); 132 | }); 133 | 134 | it('should have expected default values in model', () => { 135 | let model = createTestModel(NamedSequentialColorMap); 136 | expect(model).to.be.an(NamedSequentialColorMap); 137 | return model.initPromise.then(() => { 138 | expect(model.get('name')).to.be('Viridis'); 139 | expect(model.get('domain')).to.eql([0, 1]); 140 | expect(model.get('clamp')).to.be(false); 141 | }); 142 | }); 143 | 144 | it('should have expected default values in object', () => { 145 | let model = createTestModel(NamedSequentialColorMap); 146 | expect(model).to.be.an(NamedSequentialColorMap); 147 | return model.initPromise.then(() => { 148 | expect(model.obj.domain()).to.eql([0, 1]); 149 | expect(model.obj.clamp()).to.be(false); 150 | }); 151 | }); 152 | 153 | it('should be createable with non-default values', () => { 154 | let state = { 155 | name: 'PuBuGn', 156 | domain: [-1e7, 1e5], 157 | clamp: true, 158 | }; 159 | let model = createTestModel(NamedSequentialColorMap, state); 160 | return model.initPromise.then(() => { 161 | expect(model.obj.domain()).to.eql([-1e7, 1e5]); 162 | expect(model.obj.clamp()).to.be(true); 163 | expect(colormapAsRGBArray(model as any, 10)).to.eql([ 164 | 255, 247, 251, 165 | 239, 231, 242, 166 | 219, 216, 234, 167 | 190, 201, 226, 168 | 152, 185, 217, 169 | 105, 168, 207, 170 | 64, 150, 192, 171 | 25, 135, 159, 172 | 3, 120, 119, 173 | 1, 99, 83 ]); 174 | }); 175 | }); 176 | 177 | }); 178 | 179 | describe('NamedDivergingColorMap', () => { 180 | 181 | it('should be createable', () => { 182 | let model = createTestModel(NamedDivergingColorMap); 183 | expect(model).to.be.an(NamedDivergingColorMap); 184 | return model.initPromise.then(() => { 185 | expect(typeof model.obj).to.be('function'); 186 | }); 187 | }); 188 | 189 | it('should have expected default values in model', () => { 190 | let model = createTestModel(NamedDivergingColorMap); 191 | expect(model).to.be.an(NamedDivergingColorMap); 192 | return model.initPromise.then(() => { 193 | expect(model.get('name')).to.be('BrBG'); 194 | expect(model.get('domain')).to.eql([0, 0.5, 1]); 195 | expect(model.get('clamp')).to.be(false); 196 | }); 197 | }); 198 | 199 | it('should have expected default values in object', () => { 200 | let model = createTestModel(NamedDivergingColorMap); 201 | expect(model).to.be.an(NamedDivergingColorMap); 202 | return model.initPromise.then(() => { 203 | expect(model.obj.domain()).to.eql([0, 0.5, 1]); 204 | expect(model.obj.clamp()).to.be(false); 205 | }); 206 | }); 207 | 208 | it('should be createable with non-default values', () => { 209 | let state = { 210 | name: 'PiYG', 211 | domain: [-1e7, 0, 1e5], 212 | clamp: true, 213 | }; 214 | let model = createTestModel(NamedDivergingColorMap, state); 215 | return model.initPromise.then(() => { 216 | expect(model.obj.domain()).to.eql([-1e7, 0, 1e5]); 217 | expect(model.obj.clamp()).to.be(true); 218 | expect(colormapAsRGBAArray(model as any, 10)).to.eql([ 219 | 142, 1, 82, 255, 220 | 192, 38, 126, 255, 221 | 221, 114, 173, 255, 222 | 240, 179, 214, 255, 223 | 250, 221, 237, 255, 224 | 245, 243, 239, 255, 225 | 225, 242, 202, 255, 226 | 182, 222, 135, 255, 227 | 128, 187, 71, 255, 228 | 79, 145, 37, 255, 229 | ]); 230 | }); 231 | }); 232 | 233 | }); 234 | 235 | describe('NamedOrdinalColorMap', () => { 236 | 237 | it('should be createable', () => { 238 | let model = createTestModel(NamedOrdinalColorMap); 239 | expect(model).to.be.an(NamedOrdinalColorMap); 240 | return model.initPromise.then(() => { 241 | expect(typeof model.obj).to.be('function'); 242 | }); 243 | }); 244 | 245 | it('should have expected default values in model', () => { 246 | let model = createTestModel(NamedOrdinalColorMap); 247 | expect(model).to.be.an(NamedOrdinalColorMap); 248 | return model.initPromise.then(() => { 249 | expect(model.get('name')).to.be('Category10'); 250 | expect(model.get('cardinality')).to.be(10); 251 | expect(model.get('domain')).to.eql([]); 252 | }); 253 | }); 254 | 255 | it('should have expected default values in object', () => { 256 | let model = createTestModel(NamedOrdinalColorMap); 257 | expect(model).to.be.an(NamedOrdinalColorMap); 258 | return model.initPromise.then(() => { 259 | expect(model.obj.domain()).to.eql([]); 260 | expect(model.obj.range().length).to.eql(10); 261 | }); 262 | }); 263 | 264 | it('should be createable with non-default values', () => { 265 | let state = { 266 | name: 'PuBuGn', 267 | domain: [-1e7, 1e5], 268 | cardinality: 3, 269 | }; 270 | let model = createTestModel(NamedOrdinalColorMap, state); 271 | return model.initPromise.then(() => { 272 | expect(model.obj.domain()).to.eql([-1e7, 1e5]); 273 | expect(model.obj.range()).to.eql(['#ece2f0', '#a6bddb', '#1c9099']); 274 | }); 275 | }); 276 | 277 | it('should throw an error for invalid name', () => { 278 | let state = { 279 | name: 'FooBar', 280 | }; 281 | expect(createTestModel).withArgs(NamedOrdinalColorMap, state) 282 | .to.throwError(/^Unknown scheme name: FooBar/); 283 | }); 284 | 285 | it('should throw an error for unkown range', () => { 286 | let model = createTestModel(NamedOrdinalColorMap); 287 | return model.initPromise.then(() => { 288 | model.obj.range(['#ffffff', '#000000']); 289 | expect(model.syncToModel.bind(model)).withArgs({}).to.throwError( 290 | /^Unknown color scheme name /); 291 | }); 292 | }); 293 | 294 | }); 295 | 296 | describe('ArrayColorScaleModel', () => { 297 | 298 | it('should be createable', () => { 299 | let model = createTestModel(ArrayColorScaleModel); 300 | expect(model).to.be.an(ArrayColorScaleModel); 301 | return model.initPromise.then(() => { 302 | expect(typeof model.obj).to.be('function'); 303 | }); 304 | }); 305 | 306 | it('should have expected default values in model', () => { 307 | let model = createTestModel(ArrayColorScaleModel); 308 | expect(model).to.be.an(ArrayColorScaleModel); 309 | return model.initPromise.then(() => { 310 | const colors = model.get('colors'); 311 | expect(colors.shape).to.eql([2, 3]); 312 | expect(colors.data).to.eql([0, 0, 0, 1, 1, 1]); 313 | expect(model.get('space')).to.be('rgb'); 314 | expect(model.get('gamma')).to.be(1.0); 315 | expect(model.get('domain')).to.eql([0, 1]); 316 | expect(model.get('clamp')).to.be(false); 317 | expect(model.isColorScale).to.be(true); 318 | }); 319 | }); 320 | 321 | it('should have expected default values in object', () => { 322 | let model = createTestModel(ArrayColorScaleModel); 323 | expect(model).to.be.an(ArrayColorScaleModel); 324 | return model.initPromise.then(() => { 325 | expect(model.obj.domain()).to.eql([0, 1]); 326 | expect(model.obj.clamp()).to.be(false); 327 | }); 328 | }); 329 | 330 | it('should update in object on change', () => { 331 | let model = createTestModel(ArrayColorScaleModel); 332 | expect(model).to.be.an(ArrayColorScaleModel); 333 | return model.initPromise.then(() => { 334 | model.set('colors', ndarray(new Float32Array([1, 1, 1, 0, 0, 0]), [2, 3])); 335 | expect(model.obj.interpolator()(1)).to.be('rgb(0, 0, 0)'); 336 | model.set('space', 'hsl'); 337 | expect(model.obj.interpolator()(0)).to.be('rgb(255, 255, 255)'); 338 | expect(model.obj.interpolator()(1)).to.be('rgb(0, 0, 0)'); 339 | }); 340 | }); 341 | 342 | it('should be createable with non-default values', () => { 343 | let state = { 344 | colors: ndarray(new Float32Array([ 345 | 0.5, 0.5, 0.5, 0.5, 346 | 1.0, 1.0, 0.0, 1.0, 347 | 0.0, 0.0, 0.0, 0.0, 348 | ]), [3, 4]), 349 | domain: [-1e7, 1e5], 350 | clamp: true, 351 | gamma: 2.2 352 | }; 353 | let model = createTestModel(ArrayColorScaleModel, state); 354 | return model.initPromise.then(() => { 355 | expect(model.obj.domain()).to.eql([-1e7, 1e5]); 356 | expect(model.obj.clamp()).to.be(true); 357 | expect(colormapAsRGBAArray(model as any, 7)).to.eql([ 358 | 128, 128, 128, 128, 359 | 182, 182, 106, 170, 360 | 222, 222, 77, 212, 361 | 255, 255, 0, 255, 362 | 212, 212, 0, 170, 363 | 155, 155, 0, 85, 364 | 0, 0, 0, 0, 365 | ]); 366 | }); 367 | }); 368 | 369 | }); 370 | 371 | }); 372 | --------------------------------------------------------------------------------