├── .babelrc ├── .circleci └── config.yml ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── .prettierrc ├── .pylintrc ├── CHANGELOG.md ├── MANIFEST.in ├── README.md ├── dash-sunburst.png ├── dash_sunburst ├── Sunburst.py ├── __init__.py ├── _imports_.py ├── bundle.js ├── metadata.json └── package.json ├── extract-meta ├── index.html ├── package-lock.json ├── package.json ├── readme_usage_py.png ├── setup.py ├── src ├── demo │ ├── App.js │ └── index.js └── lib │ ├── components │ └── Sunburst.react.js │ ├── d3 │ └── sunburst.js │ └── index.js ├── tests ├── IntegrationTests.py ├── __init__.py ├── requirements.txt └── test_render.py ├── usage.py ├── usage_backend_update_via_controls.py ├── usage_backend_update_via_selections.py ├── webpack.config.js └── webpack.serve.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | jobs: 4 | "node": 5 | docker: 6 | - image: circleci/node:8.11.3 7 | 8 | steps: 9 | - checkout 10 | 11 | - restore_cache: 12 | key: deps1-{{ .Branch }}-{{ checksum "package.json" }} 13 | 14 | - run: 15 | name: Install package.json 16 | command: npm i 17 | 18 | - save_cache: 19 | key: deps1-{{ .Branch }}-{{ checksum "package.json" }} 20 | paths: 21 | - node_modules 22 | 23 | - run: 24 | name: Run eslint 25 | command: ./node_modules/.bin/eslint src 26 | when: always 27 | 28 | 29 | "python-3.6": 30 | docker: 31 | - image: circleci/python:3.6-stretch-browsers 32 | 33 | environment: 34 | PERCY_ENABLED: False 35 | 36 | steps: 37 | - checkout 38 | 39 | - restore_cache: 40 | key: deps1-{{ .Branch }}-{{ checksum "tests/requirements.txt" }} 41 | 42 | - run: 43 | name: Create virtualenv 44 | command: | 45 | python3 -m venv venv 46 | 47 | - run: 48 | name: Install requirements 49 | command: | 50 | . venv/bin/activate 51 | pip install -r tests/requirements.txt --quiet 52 | 53 | - save_cache: 54 | key: deps1-{{ .Branch }}-{{ checksum "tests/requirements.txt" }} 55 | paths: 56 | - "venv" 57 | 58 | - run: 59 | name: Run pylint 60 | command: | 61 | . venv/bin/activate 62 | pylint usage.py tests 63 | when: always 64 | 65 | - run: 66 | name: Run flake8 67 | command: | 68 | . venv/bin/activate 69 | flake8 usage.py tests 70 | when: always 71 | 72 | - run: 73 | name: Integration Tests 74 | command: | 75 | . venv/bin/activate 76 | python -m unittest tests.test_render 77 | when: always 78 | 79 | 80 | workflows: 81 | version: 2 82 | build: 83 | jobs: 84 | - "python-3.6" 85 | - "node" 86 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.css 2 | registerServiceWorker.js -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "prettier"], 3 | "parser": "babel-eslint", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module", 7 | "ecmaFeatures": { 8 | "arrowFunctions": true, 9 | "blockBindings": true, 10 | "classes": true, 11 | "defaultParams": true, 12 | "destructuring": true, 13 | "forOf": true, 14 | "generators": true, 15 | "modules": true, 16 | "templateStrings": true, 17 | "jsx": true 18 | } 19 | }, 20 | "env": { 21 | "browser": true, 22 | "es6": true, 23 | "jasmine": true, 24 | "jest": true, 25 | "node": true 26 | }, 27 | "globals": { 28 | "jest": true 29 | }, 30 | "plugins": [ 31 | "react", 32 | "import" 33 | ], 34 | "rules": { 35 | "accessor-pairs": ["error"], 36 | "block-scoped-var": ["error"], 37 | "consistent-return": ["error"], 38 | "curly": ["error", "all"], 39 | "default-case": ["error"], 40 | "dot-location": ["off"], 41 | "dot-notation": ["error"], 42 | "eqeqeq": ["error"], 43 | "guard-for-in": ["off"], 44 | "import/named": ["off"], 45 | "import/no-duplicates": ["error"], 46 | "import/no-named-as-default": ["error"], 47 | "new-cap": ["error"], 48 | "no-alert": [1], 49 | "no-caller": ["error"], 50 | "no-case-declarations": ["error"], 51 | "no-console": ["off"], 52 | "no-div-regex": ["error"], 53 | "no-dupe-keys": ["error"], 54 | "no-else-return": ["error"], 55 | "no-empty-pattern": ["error"], 56 | "no-eq-null": ["error"], 57 | "no-eval": ["error"], 58 | "no-extend-native": ["error"], 59 | "no-extra-bind": ["error"], 60 | "no-extra-boolean-cast": ["error"], 61 | "no-inline-comments": ["error"], 62 | "no-implicit-coercion": ["error"], 63 | "no-implied-eval": ["error"], 64 | "no-inner-declarations": ["off"], 65 | "no-invalid-this": ["error"], 66 | "no-iterator": ["error"], 67 | "no-labels": ["error"], 68 | "no-lone-blocks": ["error"], 69 | "no-loop-func": ["error"], 70 | "no-multi-str": ["error"], 71 | "no-native-reassign": ["error"], 72 | "no-new": ["error"], 73 | "no-new-func": ["error"], 74 | "no-new-wrappers": ["error"], 75 | "no-param-reassign": ["error"], 76 | "no-process-env": ["warn"], 77 | "no-proto": ["error"], 78 | "no-redeclare": ["error"], 79 | "no-return-assign": ["error"], 80 | "no-script-url": ["error"], 81 | "no-self-compare": ["error"], 82 | "no-sequences": ["error"], 83 | "no-shadow": ["off"], 84 | "no-throw-literal": ["error"], 85 | "no-undefined": ["error"], 86 | "no-unused-expressions": ["error"], 87 | "no-use-before-define": ["error", "nofunc"], 88 | "no-useless-call": ["error"], 89 | "no-useless-concat": ["error"], 90 | "no-with": ["error"], 91 | "prefer-const": ["error"], 92 | "radix": ["error"], 93 | "react/jsx-no-duplicate-props": ["error"], 94 | "react/jsx-no-undef": ["error"], 95 | "react/jsx-uses-react": ["error"], 96 | "react/jsx-uses-vars": ["error"], 97 | "react/no-did-update-set-state": ["error"], 98 | "react/no-direct-mutation-state": ["error"], 99 | "react/no-is-mounted": ["error"], 100 | "react/no-unknown-property": ["error"], 101 | "react/prefer-es6-class": ["error", "always"], 102 | "react/prop-types": "error", 103 | "valid-jsdoc": ["off"], 104 | "yoda": ["error"], 105 | "spaced-comment": ["error", "always", { 106 | "block": { 107 | exceptions: ["*"] 108 | } 109 | }], 110 | "no-unused-vars": ["error", { 111 | "args": "after-used", 112 | "argsIgnorePattern": "^_", 113 | "caughtErrorsIgnorePattern": "^e$" 114 | }], 115 | "no-magic-numbers": ["error", { 116 | "ignoreArrayIndexes": true, 117 | "ignore": [-1, 0, 1, 2, 3, 100, 10, 0.5, 90, 180, 360] 118 | }], 119 | "no-underscore-dangle": ["off"] 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # production 7 | /build 8 | /demo 9 | 10 | # testing 11 | /coverage 12 | 13 | # misc 14 | .DS_Store 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | # virtualenv 25 | vv 26 | venv 27 | 28 | # python 29 | *.pyc 30 | 31 | # builds 32 | my_dash_component.egg-info 33 | dist 34 | *__pycache__* 35 | __pycache__/ 36 | 37 | *.pyc 38 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # testing 5 | /coverage 6 | 7 | # misc 8 | .DS_Store 9 | .env.local 10 | .env.development.local 11 | .env.test.local 12 | .env.production.local 13 | 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | 18 | # Development folders and files 19 | public 20 | src 21 | scripts 22 | config 23 | .travis.yml 24 | CHANGELOG.md 25 | README.md 26 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "singleQuote": true, 4 | "bracketSpacing": false, 5 | "trailingComma": "es5" 6 | } 7 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code 6 | extension-pkg-whitelist= 7 | 8 | # Add files or directories to the blacklist. They should be base names, not 9 | # paths. 10 | ignore=CVS 11 | 12 | # Add files or directories matching the regex patterns to the blacklist. The 13 | # regex matches against base names, not paths. 14 | ignore-patterns= 15 | 16 | # Python code to execute, usually for sys.path manipulation such as 17 | # pygtk.require(). 18 | #init-hook= 19 | 20 | # Use multiple processes to speed up Pylint. 21 | jobs=1 22 | 23 | # List of plugins (as comma separated values of python modules names) to load, 24 | # usually to register additional checkers. 25 | load-plugins= 26 | 27 | # Pickle collected data for later comparisons. 28 | persistent=yes 29 | 30 | # Specify a configuration file. 31 | #rcfile= 32 | 33 | # When enabled, pylint would attempt to guess common misconfiguration and emit 34 | # user-friendly hints instead of false-positive error messages 35 | suggestion-mode=yes 36 | 37 | # Allow loading of arbitrary C extensions. Extensions are imported into the 38 | # active Python interpreter and may run arbitrary code. 39 | unsafe-load-any-extension=no 40 | 41 | 42 | [MESSAGES CONTROL] 43 | 44 | # Only show warnings with the listed confidence levels. Leave empty to show 45 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 46 | confidence= 47 | 48 | # Disable the message, report, category or checker with the given id(s). You 49 | # can either give multiple identifiers separated by comma (,) or put this 50 | # option multiple times (only on the command line, not in the configuration 51 | # file where it should appear only once).You can also use "--disable=all" to 52 | # disable everything first and then reenable specific checks. For example, if 53 | # you want to run only the similarities checker, you can use "--disable=all 54 | # --enable=similarities". If you want to run only the classes checker, but have 55 | # no Warning level messages displayed, use"--disable=all --enable=classes 56 | # --disable=W" 57 | disable=print-statement, 58 | parameter-unpacking, 59 | unpacking-in-except, 60 | old-raise-syntax, 61 | backtick, 62 | long-suffix, 63 | old-ne-operator, 64 | old-octal-literal, 65 | import-star-module-level, 66 | non-ascii-bytes-literal, 67 | raw-checker-failed, 68 | bad-inline-option, 69 | locally-disabled, 70 | locally-enabled, 71 | file-ignored, 72 | suppressed-message, 73 | useless-suppression, 74 | deprecated-pragma, 75 | apply-builtin, 76 | basestring-builtin, 77 | buffer-builtin, 78 | cmp-builtin, 79 | coerce-builtin, 80 | execfile-builtin, 81 | file-builtin, 82 | long-builtin, 83 | raw_input-builtin, 84 | reduce-builtin, 85 | standarderror-builtin, 86 | unicode-builtin, 87 | xrange-builtin, 88 | coerce-method, 89 | delslice-method, 90 | getslice-method, 91 | setslice-method, 92 | no-absolute-import, 93 | old-division, 94 | dict-iter-method, 95 | dict-view-method, 96 | next-method-called, 97 | metaclass-assignment, 98 | indexing-exception, 99 | raising-string, 100 | reload-builtin, 101 | oct-method, 102 | hex-method, 103 | nonzero-method, 104 | cmp-method, 105 | input-builtin, 106 | round-builtin, 107 | intern-builtin, 108 | unichr-builtin, 109 | map-builtin-not-iterating, 110 | zip-builtin-not-iterating, 111 | range-builtin-not-iterating, 112 | filter-builtin-not-iterating, 113 | using-cmp-argument, 114 | eq-without-hash, 115 | div-method, 116 | idiv-method, 117 | rdiv-method, 118 | exception-message-attribute, 119 | invalid-str-codec, 120 | sys-max-int, 121 | bad-python3-import, 122 | deprecated-string-function, 123 | deprecated-str-translate-call, 124 | deprecated-itertools-function, 125 | deprecated-types-field, 126 | next-method-defined, 127 | dict-items-not-iterating, 128 | dict-keys-not-iterating, 129 | dict-values-not-iterating, 130 | no-member, 131 | missing-docstring, 132 | invalid-name, 133 | redefined-builtin, 134 | wrong-import-order, 135 | too-many-arguments, 136 | too-many-locals, 137 | consider-using-enumerate, 138 | len-as-condition, 139 | too-many-branches, 140 | too-many-statements, 141 | blacklisted-name, 142 | line-too-long, 143 | bare-except, 144 | duplicate-code, 145 | too-many-function-args, 146 | attribute-defined-outside-init, 147 | broad-except 148 | 149 | # Enable the message, report, category or checker with the given id(s). You can 150 | # either give multiple identifier separated by comma (,) or put this option 151 | # multiple time (only on the command line, not in the configuration file where 152 | # it should appear only once). See also the "--disable" option for examples. 153 | enable=c-extension-no-member 154 | 155 | 156 | [REPORTS] 157 | 158 | # Python expression which should return a note less than 10 (10 is the highest 159 | # note). You have access to the variables errors warning, statement which 160 | # respectively contain the number of errors / warnings messages and the total 161 | # number of statements analyzed. This is used by the global evaluation report 162 | # (RP0004). 163 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 164 | 165 | # Template used to display messages. This is a python new-style format string 166 | # used to format the message information. See doc for all details 167 | #msg-template= 168 | 169 | # Set the output format. Available formats are text, parseable, colorized, json 170 | # and msvs (visual studio).You can also give a reporter class, eg 171 | # mypackage.mymodule.MyReporterClass. 172 | output-format=text 173 | 174 | # Tells whether to display a full report or only the messages 175 | reports=no 176 | 177 | # Activate the evaluation score. 178 | score=yes 179 | 180 | 181 | [REFACTORING] 182 | 183 | # Maximum number of nested blocks for function / method body 184 | max-nested-blocks=5 185 | 186 | # Complete name of functions that never returns. When checking for 187 | # inconsistent-return-statements if a never returning function is called then 188 | # it will be considered as an explicit return statement and no message will be 189 | # printed. 190 | never-returning-functions=optparse.Values,sys.exit 191 | 192 | 193 | [BASIC] 194 | 195 | # Naming style matching correct argument names 196 | argument-naming-style=snake_case 197 | 198 | # Regular expression matching correct argument names. Overrides argument- 199 | # naming-style 200 | #argument-rgx= 201 | 202 | # Naming style matching correct attribute names 203 | attr-naming-style=snake_case 204 | 205 | # Regular expression matching correct attribute names. Overrides attr-naming- 206 | # style 207 | #attr-rgx= 208 | 209 | # Bad variable names which should always be refused, separated by a comma 210 | bad-names=foo, 211 | bar, 212 | baz, 213 | toto, 214 | tutu, 215 | tata 216 | 217 | # Naming style matching correct class attribute names 218 | class-attribute-naming-style=any 219 | 220 | # Regular expression matching correct class attribute names. Overrides class- 221 | # attribute-naming-style 222 | #class-attribute-rgx= 223 | 224 | # Naming style matching correct class names 225 | class-naming-style=PascalCase 226 | 227 | # Regular expression matching correct class names. Overrides class-naming-style 228 | #class-rgx= 229 | 230 | # Naming style matching correct constant names 231 | const-naming-style=UPPER_CASE 232 | 233 | # Regular expression matching correct constant names. Overrides const-naming- 234 | # style 235 | #const-rgx= 236 | 237 | # Minimum line length for functions/classes that require docstrings, shorter 238 | # ones are exempt. 239 | docstring-min-length=-1 240 | 241 | # Naming style matching correct function names 242 | function-naming-style=snake_case 243 | 244 | # Regular expression matching correct function names. Overrides function- 245 | # naming-style 246 | #function-rgx= 247 | 248 | # Good variable names which should always be accepted, separated by a comma 249 | good-names=i, 250 | j, 251 | k, 252 | ex, 253 | Run, 254 | _ 255 | 256 | # Include a hint for the correct naming format with invalid-name 257 | include-naming-hint=no 258 | 259 | # Naming style matching correct inline iteration names 260 | inlinevar-naming-style=any 261 | 262 | # Regular expression matching correct inline iteration names. Overrides 263 | # inlinevar-naming-style 264 | #inlinevar-rgx= 265 | 266 | # Naming style matching correct method names 267 | method-naming-style=snake_case 268 | 269 | # Regular expression matching correct method names. Overrides method-naming- 270 | # style 271 | #method-rgx= 272 | 273 | # Naming style matching correct module names 274 | module-naming-style=snake_case 275 | 276 | # Regular expression matching correct module names. Overrides module-naming- 277 | # style 278 | #module-rgx= 279 | 280 | # Colon-delimited sets of names that determine each other's naming style when 281 | # the name regexes allow several styles. 282 | name-group= 283 | 284 | # Regular expression which should only match function or class names that do 285 | # not require a docstring. 286 | no-docstring-rgx=^_ 287 | 288 | # List of decorators that produce properties, such as abc.abstractproperty. Add 289 | # to this list to register other decorators that produce valid properties. 290 | property-classes=abc.abstractproperty 291 | 292 | # Naming style matching correct variable names 293 | variable-naming-style=snake_case 294 | 295 | # Regular expression matching correct variable names. Overrides variable- 296 | # naming-style 297 | #variable-rgx= 298 | 299 | 300 | [FORMAT] 301 | 302 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 303 | expected-line-ending-format= 304 | 305 | # Regexp for a line that is allowed to be longer than the limit. 306 | ignore-long-lines=^\s*(# )??$ 307 | 308 | # Number of spaces of indent required inside a hanging or continued line. 309 | indent-after-paren=4 310 | 311 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 312 | # tab). 313 | indent-string=' ' 314 | 315 | # Maximum number of characters on a single line. 316 | max-line-length=100 317 | 318 | # Maximum number of lines in a module 319 | max-module-lines=1000 320 | 321 | # List of optional constructs for which whitespace checking is disabled. `dict- 322 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 323 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 324 | # `empty-line` allows space-only lines. 325 | no-space-check=trailing-comma, 326 | dict-separator 327 | 328 | # Allow the body of a class to be on the same line as the declaration if body 329 | # contains single statement. 330 | single-line-class-stmt=no 331 | 332 | # Allow the body of an if to be on the same line as the test if there is no 333 | # else. 334 | single-line-if-stmt=no 335 | 336 | 337 | [LOGGING] 338 | 339 | # Logging modules to check that the string format arguments are in logging 340 | # function parameter format 341 | logging-modules=logging 342 | 343 | 344 | [MISCELLANEOUS] 345 | 346 | # List of note tags to take in consideration, separated by a comma. 347 | notes=FIXME, 348 | XXX, 349 | 350 | 351 | [SIMILARITIES] 352 | 353 | # Ignore comments when computing similarities. 354 | ignore-comments=yes 355 | 356 | # Ignore docstrings when computing similarities. 357 | ignore-docstrings=yes 358 | 359 | # Ignore imports when computing similarities. 360 | ignore-imports=no 361 | 362 | # Minimum lines number of a similarity. 363 | min-similarity-lines=4 364 | 365 | 366 | [SPELLING] 367 | 368 | # Limits count of emitted suggestions for spelling mistakes 369 | max-spelling-suggestions=4 370 | 371 | # Spelling dictionary name. Available dictionaries: none. To make it working 372 | # install python-enchant package. 373 | spelling-dict= 374 | 375 | # List of comma separated words that should not be checked. 376 | spelling-ignore-words= 377 | 378 | # A path to a file that contains private dictionary; one word per line. 379 | spelling-private-dict-file= 380 | 381 | # Tells whether to store unknown words to indicated private dictionary in 382 | # --spelling-private-dict-file option instead of raising a message. 383 | spelling-store-unknown-words=no 384 | 385 | 386 | [TYPECHECK] 387 | 388 | # List of decorators that produce context managers, such as 389 | # contextlib.contextmanager. Add to this list to register other decorators that 390 | # produce valid context managers. 391 | contextmanager-decorators=contextlib.contextmanager 392 | 393 | # List of members which are set dynamically and missed by pylint inference 394 | # system, and so shouldn't trigger E1101 when accessed. Python regular 395 | # expressions are accepted. 396 | generated-members= 397 | 398 | # Tells whether missing members accessed in mixin class should be ignored. A 399 | # mixin class is detected if its name ends with "mixin" (case insensitive). 400 | ignore-mixin-members=yes 401 | 402 | # This flag controls whether pylint should warn about no-member and similar 403 | # checks whenever an opaque object is returned when inferring. The inference 404 | # can return multiple potential results while evaluating a Python object, but 405 | # some branches might not be evaluated, which results in partial inference. In 406 | # that case, it might be useful to still emit no-member and other checks for 407 | # the rest of the inferred objects. 408 | ignore-on-opaque-inference=yes 409 | 410 | # List of class names for which member attributes should not be checked (useful 411 | # for classes with dynamically set attributes). This supports the use of 412 | # qualified names. 413 | ignored-classes=optparse.Values,thread._local,_thread._local 414 | 415 | # List of module names for which member attributes should not be checked 416 | # (useful for modules/projects where namespaces are manipulated during runtime 417 | # and thus existing member attributes cannot be deduced by static analysis. It 418 | # supports qualified module names, as well as Unix pattern matching. 419 | ignored-modules= 420 | 421 | # Show a hint with possible names when a member name was not found. The aspect 422 | # of finding the hint is based on edit distance. 423 | missing-member-hint=yes 424 | 425 | # The minimum edit distance a name should have in order to be considered a 426 | # similar match for a missing member name. 427 | missing-member-hint-distance=1 428 | 429 | # The total number of similar names that should be taken in consideration when 430 | # showing a hint for a missing member. 431 | missing-member-max-choices=1 432 | 433 | 434 | [VARIABLES] 435 | 436 | # List of additional names supposed to be defined in builtins. Remember that 437 | # you should avoid to define new builtins when possible. 438 | additional-builtins= 439 | 440 | # Tells whether unused global variables should be treated as a violation. 441 | allow-global-unused-variables=yes 442 | 443 | # List of strings which can identify a callback function by name. A callback 444 | # name must start or end with one of those strings. 445 | callbacks=cb_, 446 | _cb 447 | 448 | # A regular expression matching the name of dummy variables (i.e. expectedly 449 | # not used). 450 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 451 | 452 | # Argument names that match this expression will be ignored. Default to name 453 | # with leading underscore 454 | ignored-argument-names=_.*|^ignored_|^unused_ 455 | 456 | # Tells whether we should check for unused import in __init__ files. 457 | init-import=no 458 | 459 | # List of qualified module names which can have objects that can redefine 460 | # builtins. 461 | redefining-builtins-modules=six.moves,past.builtins,future.builtins 462 | 463 | 464 | [CLASSES] 465 | 466 | # List of method names used to declare (i.e. assign) instance attributes. 467 | defining-attr-methods=__init__, 468 | __new__, 469 | setUp 470 | 471 | # List of member names, which should be excluded from the protected access 472 | # warning. 473 | exclude-protected=_asdict, 474 | _fields, 475 | _replace, 476 | _source, 477 | _make 478 | 479 | # List of valid names for the first argument in a class method. 480 | valid-classmethod-first-arg=cls 481 | 482 | # List of valid names for the first argument in a metaclass class method. 483 | valid-metaclass-classmethod-first-arg=mcs 484 | 485 | 486 | [DESIGN] 487 | 488 | # Maximum number of arguments for function / method 489 | max-args=5 490 | 491 | # Maximum number of attributes for a class (see R0902). 492 | max-attributes=7 493 | 494 | # Maximum number of boolean expressions in a if statement 495 | max-bool-expr=5 496 | 497 | # Maximum number of branch for function / method body 498 | max-branches=12 499 | 500 | # Maximum number of locals for function / method body 501 | max-locals=15 502 | 503 | # Maximum number of parents for a class (see R0901). 504 | max-parents=7 505 | 506 | # Maximum number of public methods for a class (see R0904). 507 | max-public-methods=20 508 | 509 | # Maximum number of return / yield for function / method body 510 | max-returns=6 511 | 512 | # Maximum number of statements in function / method body 513 | max-statements=50 514 | 515 | # Minimum number of public methods for a class (see R0903). 516 | min-public-methods=2 517 | 518 | 519 | [IMPORTS] 520 | 521 | # Allow wildcard imports from modules that define __all__. 522 | allow-wildcard-with-all=no 523 | 524 | # Analyse import fallback blocks. This can be used to support both Python 2 and 525 | # 3 compatible code, which means that the block might have code that exists 526 | # only in one or another interpreter, leading to false positives when analysed. 527 | analyse-fallback-blocks=no 528 | 529 | # Deprecated modules which should not be used, separated by a comma 530 | deprecated-modules=optparse,tkinter.tix 531 | 532 | # Create a graph of external dependencies in the given file (report RP0402 must 533 | # not be disabled) 534 | ext-import-graph= 535 | 536 | # Create a graph of every (i.e. internal and external) dependencies in the 537 | # given file (report RP0402 must not be disabled) 538 | import-graph= 539 | 540 | # Create a graph of internal dependencies in the given file (report RP0402 must 541 | # not be disabled) 542 | int-import-graph= 543 | 544 | # Force import order to recognize a module as part of the standard 545 | # compatibility libraries. 546 | known-standard-library= 547 | 548 | # Force import order to recognize a module as part of a third party library. 549 | known-third-party=enchant 550 | 551 | 552 | [EXCEPTIONS] 553 | 554 | # Exceptions that will emit a warning when being caught. Defaults to 555 | # "Exception" 556 | overgeneral-exceptions=Exception 557 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include dash_sunburst/bundle.js 2 | include dash_sunburst/metadata.json 3 | include dash_sunburst/package.json 4 | include README.md 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | _Please note: this package is intended as a tutorial. It is not recommended for actual use in your apps - for that purpose we have the sunburst trace type in plotly.js / dcc.Graph https://plotly.com/python/sunburst-charts/_ 2 | 3 | # Dash Sunburst 4 | 5 | ![sunburst chart](dash-sunburst.png) 6 | 7 | This repository demonstrates the principles of combining D3 with React, using a Sunburst chart as an example, and was created from the [`dash-component-boilerplate` template](https://github.com/plotly/dash-component-boilerplate). The Demo uses this `Sunburst` component to show the contents of a house, with items being added, removed, and resized over time, and letting you zoom in and out of the rooms and items both from within the component itself and from another control. 8 | 9 | This component was created primarily as a _D3.JS + Dash_ tutorial. You can use this component in your projects but we are not maintaining it. In fact, we built a first-class Sunburst chart as part of plotly.js and we recommend using this sunburst chart instead: https://plot.ly/python/sunburst-charts/ 10 | 11 | To run the Dash demo: 12 | 1. Clone this repo 13 | 2. Run the demo app 14 | ``` 15 | python usage.py 16 | ``` 17 | 3. Open your web browser to http://localhost:8050 18 | ![sunburst chart in Python](readme_usage_py.png) 19 | 20 | # Code walkthrough - JavaScript side 21 | 22 | Following the structure laid out in the [D3 + React tutorial](https://gist.github.com/alexcjohnson/a4b714eee8afd2123ee00cb5b3278a5f) we make two files: [`d3/sunburst.js`](src/lib/d3/sunburst.js) for the D3 component and [`components/Sunburst.react.js`](src/lib/components/Sunburst.react.js) for its React/Dash wrapper. Following the `dash-component-boilerplate` example, this component is then exported using [`index.js`](src/lib/index.js) which is imported by the main component in [`App.js`](src/demo/App.js). 23 | 24 | ## Sunburst.react.js 25 | 26 | This wrapper simply connects the React component API to the similar structures we create in the D3 component. Excerpting from this file out of order, we see: 27 | 28 | ```js 29 | Sunburst.propTypes = { 30 | /** 31 | * id and setProps are standard for Dash components 32 | */ 33 | id: PropTypes.string, 34 | setProps: PropTypes.func, 35 | 36 | /** 37 | * All the rest are the state of the figure. See the full source for details 38 | */ 39 | width: PropTypes.number, 40 | height: PropTypes.number, 41 | padding: PropTypes.number, 42 | innerRadius: PropTypes.number, 43 | transitionDuration: PropTypes.number, 44 | data: PropTypes.object.isRequired, 45 | dataVersion: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 46 | selectedPath: PropTypes.arrayOf(PropTypes.string), 47 | interactive: PropTypes.bool 48 | }; 49 | ``` 50 | 51 | In addition to the standard `id` and `setProps` props, we insert all the state needed by the D3 component as props of the React wrapper. This gives us type validation - for the most part anyway; this example doesn't validate the structure of `data`, nor put limits on the numeric fields, but a more production-ready version may want to do this. Note in particular the `dataVersion` prop. We will use this to avoid having to copy - and diff - the entire `data` object, which may be large and tedious. Also `selectedPath`, which is connected to the state of the user interaction with the sunburst, as different parts of the subtree are selected. `interactive` lets you disable click-to-select nodes, when you want that managed elsewhere. 52 | 53 | ```js 54 | render() { 55 | return
{this.el = el}} />; 56 | } 57 | ``` 58 | 59 | In `render` we just create an empty `
` and store a reference to it in `this.el`. 60 | 61 | ```js 62 | componentDidMount() { 63 | this.sunburst = new SunburstD3(this.el, this.props, figure => { 64 | const {setProps} = this.props; 65 | const {selectedPath} = figure; 66 | 67 | if (setProps) { setProps({selectedPath}); } 68 | else { this.setState({selectedPath}); } 69 | }); 70 | } 71 | ``` 72 | 73 | `componentDidMount` instantiates our D3 component, giving it the element to render into, the props for initial render (it will ignore the Dash-specific ones), and a callback to respond to changes from inside that component. A more complex component might emit a variety of events depending on different user interactions, but in the end all that really matters is the current state of the component, not what specifically changed with this event. Here we know the only thing that changed is `selectedPath` but it would be just as well to call `setProps(figure)` no matter what event was emitted. 74 | 75 | ```js 76 | componentDidUpdate() { 77 | this.sunburst.update(this.props); 78 | } 79 | ``` 80 | 81 | Whenever the React component gets new props, it simply forwards them on to the D3 component. 82 | 83 | ## App.js 84 | 85 | We don't need to know anything about the D3 component in order to use the `Sunburst` React component in our app - just what's encapsulated in the `Sunburst` component itself: 86 | 87 | ```js 88 | constructor() { 89 | super(); 90 | this.state = { 91 | transitionDuration: 1000, 92 | selectedPath: ['living room'], 93 | dataVersion: 1, 94 | data: { 95 | ... 96 | } 97 | } 98 | this.setProps = this.setProps.bind(this); 99 | this.mutateData = this.mutateData.bind(this); 100 | 101 | this.period = 3; 102 | this.updateInterval = setInterval(this.mutateData, 1000 * this.period); 103 | } 104 | ``` 105 | 106 | In the `App` constructor we start with a seed state for the `Sunburst` - because this is a simple app where everything is related to that `Sunburst`, its state is stored in the top level of `App.state`, but a more complex app would nest it. We also initialize the interval that will periodically edit the data. We don't need to be concerned with the `mutateData` method, except to know that all it does is call `this.setState({data: updatedData})` 107 | 108 | ```js 109 | render() { 110 | const {data, selectedPath} = this.state; 111 | const selectedPathStr = selectedPath.join(','); 112 | const paths = getPathStrs(data, ''); 113 | const options = paths.map(path => ( 114 | 117 | )); 118 | const selectChange = e => { 119 | this.setState({selectedPath: e.target.value.split(',')}) 120 | }; 121 | 122 | return ( 123 |
124 |

Sunburst Demo

125 |

Click a node, or select it in the dropdown, to select a subtree.

126 |

Every {this.period} seconds a node will be added, removed, resized, or renamed

127 | 131 | 134 |
135 | ) 136 | } 137 | ``` 138 | 139 | `App` renders some introductory notes, our `Sunburst` component, and a dropdown menu that pulls the complete list of paths out of the same state that's used by the `Sunburst`. You'll notice that whether we select an item by clicking on it directly or via this dropdown, both the dropdown and the `Sunburst` update. 140 | 141 | ## sunburst.js 142 | 143 | Finally, here's the D3 code, all contained in a class we export as `SunburstD3`. 144 | 145 | ```js 146 | constructor(el, figure, onChange) { 147 | const self = this; 148 | self.update = self.update.bind(self); 149 | self._update = self._update.bind(self); 150 | 151 | self.svg = d3.select(el).append('svg'); 152 | self.pathGroup = self.svg.append('g'); 153 | self.textGroup = self.svg.append('g') 154 | .style('pointer-events', 'none'); 155 | 156 | self.angularScale = d3.scale.linear().range([0, Tau]); 157 | self.radialScale = d3.scale.sqrt(); 158 | self.colorScale = d3.scale.category20(); 159 | self.partition = d3.layout.partition() 160 | .value(d => !d.children && d.size) 161 | .sort((a, b) => a.i - b.i); 162 | 163 | self.arc = d3.svg.arc() 164 | .startAngle(d => constrain(self.angularScale(d.x), 0, Tau)) 165 | .endAngle(d => constrain(self.angularScale(d.x + d.dx), 0, Tau)) 166 | .innerRadius(d => Math.max(0, self.radialScale(d.y))) 167 | .outerRadius(d => Math.max(0, self.radialScale(d.y + d.dy))); 168 | 169 | self.figure = {}; 170 | 171 | self.onChange = onChange; 172 | 173 | self.initialized = false; 174 | 175 | self._promise = Promise.resolve(); 176 | 177 | self.update(figure); 178 | } 179 | ``` 180 | 181 | Our constructor does 3 things: 182 | 1. Creates the container elements that we'll need no matter what specific diagram we render inside: `self.svg` is the `` element, `self.pathGroup` will contain the sunburst arcs, and `self.textGroup` will hold text, added as a separate group so the text will always be in front of the arcs. 183 | 2. Pre-calculates d3 helpers that won't change later (`self.angularScale` through `self.arc`) 184 | 3. Sends the initial figure to `self.update`. 185 | There's also a bit of complication around updating potentially during animations. `self._promise` is a chain that's added on to whenever a new animation is scheduled, and `self.update` is an async wrapper around the synchronous `self._update`, ensuring a new figure is applied only after that chain is complete. 186 | 187 | `self._update` is the meat, so we'll tackle it in pieces: 188 | 189 | ### Figure setup 190 | ```js 191 | const oldFigure = self.figure; 192 | 193 | // fill defaults in the new figure 194 | const width = figure.width || dflts.width; 195 | const height = figure.height || dflts.height; 196 | // interactive: undefined defaults to true 197 | const interactive = figure.interactive !== false; 198 | const padding = figure.padding || dflts.padding; 199 | const innerRadius = figure.innerRadius || dflts.innerRadius; 200 | const transitionDuration = figure.transitionDuration || dflts.transitionDuration; 201 | const {data, dataVersion} = figure; 202 | const selectedPath = figure.selectedPath || []; 203 | 204 | const newFigure = self.figure = { 205 | width, 206 | height, 207 | interactive, 208 | padding, 209 | innerRadius, 210 | transitionDuration, 211 | data, 212 | dataVersion, 213 | selectedPath 214 | }; 215 | ``` 216 | 217 | Here we stash the previous figure as `oldFigure` and create a new one, inserting default values where values were not provided. 218 | 219 | Next comes functions containing our standard D3 code (which was inspired by https://bl.ocks.org/mbostock/4348373 but has been heavily modified, as you can see. Notice that I'm using D3V3 here so some things will change if you're using V4 or V5), but we've broken up the activity by purpose, `transitionToNode`, `updatePaths`, and `setSize`. We'll use these depending on the observed changes. The only items I want to call out within this block are 220 | 1. `transitionToNode` is used in the `click` callback for our nodes (wrapped up with animation management code). 221 | 2. At the end of `transitionToNode` is the block: 222 | ```js 223 | if(self.onChange) { 224 | self.figure.selectedPath = getPath(node); 225 | self.onChange(self.figure); 226 | } 227 | ``` 228 | So when this is called on a click, it updates the `figure` and we pass it back up the React chain of command. But it's also called during drawing, in which case the figure we pass back up will be the same one we just received. Which makes the next section extremely important... 229 | 230 | ### Diffing 231 | ```js 232 | const change = diff(oldFigure, newFigure); 233 | if(!change) { return; } 234 | 235 | const sizeChange = change.width || change.height || change.padding; 236 | const dataChange = change.data; 237 | ``` 238 | 239 | We compare the old and new figures to determine what changed. Here we're concerned with three things: 240 | 1) Are there any changes at all? If not, we can bail out now, without running any DOM manipulations. This will happen regularly due to `transitionToNode` as described above. 241 | 2) Did the size of the figure change? If so there are more extensive things we need to do, that will require updating the size and position of all our paths and text elements. 242 | 3) Did the data change? Inside `diff` we look for `dataVersion`, and if we find it we skip comparing `data` itself between the old and new figures, instead reporting changes in `dataVersion` as `change.data`. 243 | 244 | There can be other changes that lead to a truthy `change` without setting either `sizeChange` or `dataChange` - such as `innerRadius` and `selectedPath`, and in general if we added styling properties (colors, line widths, font sizes...) they would fall into this category too. Those can follow the minimal update pathway below. 245 | 246 | ### Drawing 247 | ```js 248 | if(sizeChange) { setSize(); } 249 | 250 | let paths = self.pathGroup.selectAll('path'); 251 | let texts = self.textGroup.selectAll('text'); 252 | 253 | if(dataChange) { 254 | // clone data before partitioning, since this mutates the data 255 | self.nodes = self.partition.nodes(addIndices(JSON.parse(JSON.stringify(data)))); 256 | paths = paths.data(self.nodes, getPathStr); 257 | texts = texts.data(self.nodes, getPathStr); 258 | 259 | // exit paths at the beginning of the transition 260 | // enters will happen at the end 261 | paths.exit().remove(); 262 | texts.exit().remove(); 263 | } 264 | 265 | const selectedNode = getNode(self.nodes[0], selectedPath); 266 | // no node: path is wrong, probably because we received a new selectedPath 267 | // before the data it belongs with 268 | if(!selectedNode) { return retVal; } 269 | 270 | // immediate redraw rather than transition if: 271 | const shouldAnimate = 272 | // first draw 273 | self.initialized && 274 | // new root node 275 | (newRootName === oldRootName) && 276 | // not a pure up/down transition 277 | sameHead(oldSelectedPath, newSelectedPath) && 278 | // the previous data didn't contain the new selected node 279 | // this can happen if we transition selectedPath first, then data 280 | (!dataChange || getNode(oldFigure.data, newSelectedPath)); 281 | 282 | console.log(shouldAnimate, oldSelectedPath, newSelectedPath); 283 | 284 | if(shouldAnimate) { 285 | retVal = new Promise(resolve => { 286 | transitionToNode(selectedNode) 287 | .each('end', () => { 288 | updatePaths(paths, texts, dataChange); 289 | self.transitioning = false; 290 | resolve(); 291 | }); 292 | }); 293 | } 294 | else { 295 | // first draw has no animation, and initializes the scales 296 | self.angularScale.domain(selectedX(selectedNode)); 297 | self.radialScale.domain(selectedY(selectedNode)) 298 | self.radialScale.range(selectedRadius(selectedNode)); 299 | 300 | updatePaths(paths, texts, dataChange); 301 | 302 | self.initialized = true; 303 | } 304 | ``` 305 | 306 | If the size and data did not change, all we do is select the paths and texts, find the selected node, transition to it, and, upon finishing that transition, update the paths - and `updatePaths` knows about `dataChange` so it can skip the `enter()` steps. 307 | 308 | The logic for whether the state transition is amenable to animation or not is handled here, in `shouldAnimate`. This is important for Dash - and for React integration in general - because it means this is the *only* place we need to worry about edge detection. Dash apps are stateless, so it's particularly tricky to determine this on the Python side, and React apps are best written the same way as far down the tree as possible. D3 to a certain extent *can* work similarly, but for finer control we explicitly calculate what kind of change has been made and tell D3 whether to animate. 309 | 310 | Now lets open the JavaScript demo environment: 311 | ``` 312 | npm run start 313 | ``` 314 | 315 | Lo and behold, we have a zoomable sunburst chart, connected to changing data and sibling UI controls, drawn with D3 and React :tada: There are of course bits of polish to be added if this component were to be used in production - shrinking or removing text that's too big for its arc, and creating style props, for example, and nicer tooltips than the built-in `` elements. But the principles are the same. 316 | 317 | # Code Walkthrough - Python side 318 | 319 | `dash-component-boilerplate` makes it super easy to connect the React component we just made to Python. As in its [README](https://github.com/plotly/dash-component-boilerplate), run: 320 | ``` 321 | npm run build:js-dev 322 | npm run build:py 323 | ``` 324 | For these build steps to run without warnings, the `lib/components` directory should contain *only* React components, which is why we moved the D3 code into its own directory, `lib/d3`. Now we can use the component in our Dash app [`usage.py`](usage.py): 325 | ```py 326 | from dash_sunburst import Sunburst 327 | ``` 328 | 329 | We'll make a simple app using this component: Feeding some static data to the component, we'll display the selected path elsewhere, and create a plotly.js graph that calculates some statistics based on the displayed data and selected path. First the static data and the app layout: 330 | 331 | ```py 332 | sunburst_data = { ... } 333 | 334 | app.layout = html.Div([ 335 | html.Div( 336 | [Sunburst(id='sun', data=sunburst_data)], 337 | style={'width': '49%', 'display': 'inline-block', 'float': 'left'}), 338 | dcc.Graph( 339 | id='graph', 340 | style={'width': '49%', 'display': 'inline-block', 'float': 'left'}), 341 | html.Div(id='output', style={'clear': 'both'}) 342 | ]) 343 | ``` 344 | Our `Sunburst` component doesn't support `style`, so we wrap it in an `html.Div`. The `Graph` and `Div#output` are initially blank, but our callbacks will fill them in on load. The content of these callbacks is straightforward Python - check out `usage.py` for the complete code - the key is simply to identify the dependencies of each one using the `@app.callback` decorator: 345 | ```py 346 | @app.callback(Output('output', 'children'), [Input('sun', 'selectedPath')]) 347 | def display_selected(selected_path): 348 | # format the selected path for display as text 349 | ... 350 | 351 | @app.callback(Output('graph', 'figure'), [Input('sun', 'data'), Input('sun', 'selectedPath')]) 352 | def display_graph(data, selected_path): 353 | # crawl the sunburst data, along with its selected path, 354 | # to create the related plotly.js graph 355 | ... 356 | ``` 357 | 358 | And that's it! `python usage.py` gives us our D3 sunburst diagram, connected through Dash to whatever else we choose. 359 | 360 | ![usage.py running](readme_usage_py.png) 361 | 362 | Further examples expanding on server-side updates can be found in [`usage_backend_update_via_controls.py`](usage_backend_update_via_controls.py) and [`usage_backend_update_via_selections.py`](usage_backend_update_via_selections.py) 363 | 364 | # More Resources 365 | - Learn more about Dash: https://dash.plot.ly 366 | - View the original component boilerplate: https://github.com/plotly/dash-component-boilerplate 367 | -------------------------------------------------------------------------------- /dash-sunburst.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/dash-sunburst/db27a153dca638b2e513e0a9c913dacca81860af/dash-sunburst.png -------------------------------------------------------------------------------- /dash_sunburst/Sunburst.py: -------------------------------------------------------------------------------- 1 | # AUTO GENERATED FILE - DO NOT EDIT 2 | 3 | from dash.development.base_component import Component, _explicitize_args 4 | 5 | 6 | class Sunburst(Component): 7 | """A Sunburst component. 8 | 9 | 10 | Keyword arguments: 11 | - id (string; optional): The ID used to identify this component in Dash callbacks 12 | - width (number; optional): Width of the figure to draw, in pixels 13 | - height (number; optional): Height of the figure to draw, in pixels 14 | - padding (number; optional): Pixels to leave blank around the edges 15 | - innerRadius (number; optional): Radius, in pixels, for the inner circle when you're zoomed in, 16 | that you click on to zoom back out 17 | - transitionDuration (number; optional): Animation duration when you click around selecting subtrees 18 | - data (dict; required): The sunburst data. Should have the form: 19 | 20 | `{name: '...', children: [c0, c1, c2]}` 21 | 22 | and children `c` can have the same form to arbitrary nesting, 23 | or for leaf nodes the form is: 24 | 25 | `{name: '...', size: ###}` 26 | 27 | any node can also have a `color` property, set to any CSS color string, 28 | to use instead of the default coloring. Nodes with no children will 29 | inherit their parent's color if not specified. Otherwise colors are pulled 30 | from d3.scale.category20 in the order nodes are encountered. 31 | - dataVersion (string | number; optional): Optional version id for data, to avoid having to diff a large object 32 | - selectedPath (list; optional): The currently selected path within the sunburst 33 | as an array of child names 34 | - interactive (boolean; optional): Sets whether you can click a node to select that path 35 | 36 | Available events: """ 37 | @_explicitize_args 38 | def __init__(self, id=Component.UNDEFINED, width=Component.UNDEFINED, height=Component.UNDEFINED, padding=Component.UNDEFINED, innerRadius=Component.UNDEFINED, transitionDuration=Component.UNDEFINED, data=Component.REQUIRED, dataVersion=Component.UNDEFINED, selectedPath=Component.UNDEFINED, interactive=Component.UNDEFINED, **kwargs): 39 | self._prop_names = ['id', 'width', 'height', 'padding', 'innerRadius', 'transitionDuration', 'data', 'dataVersion', 'selectedPath', 'interactive'] 40 | self._type = 'Sunburst' 41 | self._namespace = 'dash_sunburst' 42 | self._valid_wildcard_attributes = [] 43 | self.available_events = [] 44 | self.available_properties = ['id', 'width', 'height', 'padding', 'innerRadius', 'transitionDuration', 'data', 'dataVersion', 'selectedPath', 'interactive'] 45 | self.available_wildcard_properties = [] 46 | 47 | _explicit_args = kwargs.pop('_explicit_args') 48 | _locals = locals() 49 | _locals.update(kwargs) # For wildcard attrs 50 | args = {k: _locals[k] for k in _explicit_args if k != 'children'} 51 | 52 | for k in [u'data']: 53 | if k not in args: 54 | raise TypeError( 55 | 'Required argument `' + k + '` was not specified.') 56 | super(Sunburst, self).__init__(**args) 57 | 58 | def __repr__(self): 59 | if(any(getattr(self, c, None) is not None 60 | for c in self._prop_names 61 | if c is not self._prop_names[0]) 62 | or any(getattr(self, c, None) is not None 63 | for c in self.__dict__.keys() 64 | if any(c.startswith(wc_attr) 65 | for wc_attr in self._valid_wildcard_attributes))): 66 | props_string = ', '.join([c+'='+repr(getattr(self, c, None)) 67 | for c in self._prop_names 68 | if getattr(self, c, None) is not None]) 69 | wilds_string = ', '.join([c+'='+repr(getattr(self, c, None)) 70 | for c in self.__dict__.keys() 71 | if any([c.startswith(wc_attr) 72 | for wc_attr in 73 | self._valid_wildcard_attributes])]) 74 | return ('Sunburst(' + props_string + 75 | (', ' + wilds_string if wilds_string != '' else '') + ')') 76 | else: 77 | return ( 78 | 'Sunburst(' + 79 | repr(getattr(self, self._prop_names[0], None)) + ')') 80 | -------------------------------------------------------------------------------- /dash_sunburst/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function as _ 2 | 3 | import os as _os 4 | import sys as _sys 5 | import json 6 | 7 | import dash as _dash 8 | 9 | if not hasattr(_dash, 'development'): 10 | print('Dash was not successfully imported. ' 11 | 'Make sure you don\'t have a file ' 12 | 'named \n"dash.py" in your current directory.', file=_sys.stderr) 13 | _sys.exit(1) 14 | 15 | _basepath = _os.path.dirname(__file__) 16 | _filepath = _os.path.abspath(_os.path.join(_basepath, 'package.json')) 17 | with open(_filepath) as f: 18 | package = json.load(f) 19 | 20 | package_name = package['name'].replace(' ', '_').replace('-', '_') 21 | __version__ = package['version'] 22 | 23 | _current_path = _os.path.dirname(_os.path.abspath(__file__)) 24 | _components = _dash.development.component_loader.load_components( 25 | _os.path.join(_current_path, 'metadata.json'), 26 | package_name 27 | ) 28 | 29 | _this_module = _sys.modules[__name__] 30 | 31 | 32 | _js_dist = [ 33 | { 34 | 'relative_package_path': 'bundle.js', 35 | 'external_url': ( 36 | 'https://unpkg.com/dash_sunburst' 37 | '/' + package_name + '/bundle.js' 38 | ).format(__version__), 39 | 'namespace': package_name 40 | } 41 | ] 42 | 43 | _css_dist = [] 44 | 45 | 46 | for _component in _components: 47 | setattr(_this_module, _component.__name__, _component) 48 | setattr(_component, '_js_dist', _js_dist) 49 | setattr(_component, '_css_dist', _css_dist) 50 | -------------------------------------------------------------------------------- /dash_sunburst/_imports_.py: -------------------------------------------------------------------------------- 1 | from .Sunburst import Sunburst 2 | 3 | 4 | __all__ = [ 5 | "Sunburst", 6 | ] 7 | -------------------------------------------------------------------------------- /dash_sunburst/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "src/lib/components/Sunburst.react.js": { 3 | "description": "", 4 | "displayName": "Sunburst", 5 | "methods": [], 6 | "props": { 7 | "id": { 8 | "type": { 9 | "name": "string" 10 | }, 11 | "required": false, 12 | "description": "The ID used to identify this component in Dash callbacks" 13 | }, 14 | "setProps": { 15 | "type": { 16 | "name": "func" 17 | }, 18 | "required": false, 19 | "description": "Dash-assigned callback that should be called whenever any of the\nproperties change" 20 | }, 21 | "width": { 22 | "type": { 23 | "name": "number" 24 | }, 25 | "required": false, 26 | "description": "Width of the figure to draw, in pixels" 27 | }, 28 | "height": { 29 | "type": { 30 | "name": "number" 31 | }, 32 | "required": false, 33 | "description": "Height of the figure to draw, in pixels" 34 | }, 35 | "padding": { 36 | "type": { 37 | "name": "number" 38 | }, 39 | "required": false, 40 | "description": "Pixels to leave blank around the edges" 41 | }, 42 | "innerRadius": { 43 | "type": { 44 | "name": "number" 45 | }, 46 | "required": false, 47 | "description": "Radius, in pixels, for the inner circle when you're zoomed in,\nthat you click on to zoom back out" 48 | }, 49 | "transitionDuration": { 50 | "type": { 51 | "name": "number" 52 | }, 53 | "required": false, 54 | "description": "Animation duration when you click around selecting subtrees" 55 | }, 56 | "data": { 57 | "type": { 58 | "name": "object" 59 | }, 60 | "required": true, 61 | "description": "The sunburst data. Should have the form:\n\n `{name: '...', children: [c0, c1, c2]}`\n\nand children `c` can have the same form to arbitrary nesting,\nor for leaf nodes the form is:\n\n `{name: '...', size: ###}`\n\nany node can also have a `color` property, set to any CSS color string,\nto use instead of the default coloring. Nodes with no children will\ninherit their parent's color if not specified. Otherwise colors are pulled\nfrom d3.scale.category20 in the order nodes are encountered." 62 | }, 63 | "dataVersion": { 64 | "type": { 65 | "name": "union", 66 | "value": [ 67 | { 68 | "name": "string" 69 | }, 70 | { 71 | "name": "number" 72 | } 73 | ] 74 | }, 75 | "required": false, 76 | "description": "Optional version id for data, to avoid having to diff a large object" 77 | }, 78 | "selectedPath": { 79 | "type": { 80 | "name": "arrayOf", 81 | "value": { 82 | "name": "string" 83 | } 84 | }, 85 | "required": false, 86 | "description": "The currently selected path within the sunburst\nas an array of child names" 87 | }, 88 | "interactive": { 89 | "type": { 90 | "name": "bool" 91 | }, 92 | "required": false, 93 | "description": "Sets whether you can click a node to select that path", 94 | "defaultValue": { 95 | "value": "true", 96 | "computed": false 97 | } 98 | } 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /dash_sunburst/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dash-sunburst", 3 | "version": "0.0.1", 4 | "description": "dash-sunburst", 5 | "main": "build/index.js", 6 | "scripts": { 7 | "start": "webpack-serve ./webpack.serve.config.js --open", 8 | "build:js-dev": "webpack --mode development", 9 | "build:js": "webpack --mode production", 10 | "build:py": "node ./extract-meta src/lib/components > dash_sunburst/metadata.json && copyfiles package.json dash_sunburst && python -c \"import dash; dash.development.component_loader.generate_classes('dash_sunburst', 'dash_sunburst/metadata.json')\"", 11 | "build:all": "npm run build:js & npm run build:py", 12 | "build:all-dev": "npm run build:js-dev & npm run build:py" 13 | }, 14 | "author": "Alex Johnson alex@plot.ly", 15 | "license": "MIT", 16 | "dependencies": { 17 | "d3": "^3.5.12", 18 | "ramda": "^0.25.0", 19 | "react": "15.4.2", 20 | "react-dom": "15.4.2" 21 | }, 22 | "devDependencies": { 23 | "babel-core": "^6.26.3", 24 | "babel-eslint": "^8.2.3", 25 | "babel-loader": "^7.1.4", 26 | "copyfiles": "^2.0.0", 27 | "babel-preset-env": "^1.7.0", 28 | "babel-preset-react": "^6.24.1", 29 | "css-loader": "^0.28.11", 30 | "eslint": "^4.19.1", 31 | "eslint-config-prettier": "^2.9.0", 32 | "eslint-plugin-import": "^2.12.0", 33 | "eslint-plugin-react": "^7.9.1", 34 | "npm": "^6.1.0", 35 | "react-docgen": "^2.20.1", 36 | "style-loader": "^0.21.0", 37 | "webpack": "^4.20.2", 38 | "webpack-cli": "^3.1.1", 39 | "webpack-serve": "^1.0.2" 40 | }, 41 | "peerDependencies": { 42 | "react": ">=0.14", 43 | "react-dom": ">=0.14" 44 | }, 45 | "engines": { 46 | "node": ">=8.11.0", 47 | "npm": ">=6.1.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /extract-meta: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const reactDocs = require('react-docgen'); 6 | 7 | const componentPaths = process.argv.slice(2); 8 | if (!componentPaths.length) { 9 | help(); 10 | process.exit(1); 11 | } 12 | 13 | const metadata = Object.create(null); 14 | componentPaths.forEach(componentPath => 15 | collectMetadataRecursively(componentPath) 16 | ); 17 | writeOut(metadata); 18 | 19 | function help() { 20 | console.error('usage: '); 21 | console.error( 22 | 'extract-meta path/to/component(s) ' + 23 | ' [path/to/more/component(s), ...] > metadata.json' 24 | ); 25 | } 26 | 27 | function writeError(msg, filePath) { 28 | if (filePath) { 29 | process.stderr.write(`Error with path ${filePath}`); 30 | } 31 | 32 | process.stderr.write(msg + '\n'); 33 | if (msg instanceof Error) { 34 | process.stderr.write(msg.stack + '\n'); 35 | } 36 | } 37 | 38 | function parseFile(filepath) { 39 | const urlpath = filepath.split(path.sep).join('/'); 40 | let src; 41 | 42 | if (!['.jsx', '.js'].includes(path.extname(filepath))) { 43 | return; 44 | } 45 | 46 | try { 47 | src = fs.readFileSync(filepath); 48 | metadata[urlpath] = reactDocs.parse(src); 49 | } catch (error) { 50 | writeError(error, filepath); 51 | } 52 | } 53 | 54 | function collectMetadataRecursively(componentPath) { 55 | if (fs.lstatSync(componentPath).isDirectory()) { 56 | let dirs; 57 | try { 58 | dirs = fs.readdirSync(componentPath); 59 | } catch (error) { 60 | writeError(error, componentPath); 61 | } 62 | dirs.forEach(filename => { 63 | const filepath = path.join(componentPath, filename); 64 | if (fs.lstatSync(filepath).isDirectory()) { 65 | collectMetadataRecursively(filepath); 66 | } else { 67 | parseFile(filepath); 68 | } 69 | }); 70 | } else { 71 | parseFile(componentPath); 72 | } 73 | } 74 | 75 | function writeOut(result) { 76 | console.log(JSON.stringify(result, '\t', 2)); 77 | } 78 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dash-sunburst 5 | 6 | 7 |
8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dash-sunburst", 3 | "version": "0.0.1", 4 | "description": "dash-sunburst", 5 | "main": "build/index.js", 6 | "scripts": { 7 | "start": "webpack-serve ./webpack.serve.config.js --open", 8 | "build:js-dev": "webpack --mode development", 9 | "build:js": "webpack --mode production", 10 | "build:py": "node ./extract-meta src/lib/components > dash_sunburst/metadata.json && copyfiles package.json dash_sunburst && python -c \"import dash; dash.development.component_loader.generate_classes('dash_sunburst', 'dash_sunburst/metadata.json')\"", 11 | "build:all": "npm run build:js & npm run build:py", 12 | "build:all-dev": "npm run build:js-dev & npm run build:py" 13 | }, 14 | "author": "Alex Johnson alex@plot.ly", 15 | "license": "MIT", 16 | "dependencies": { 17 | "d3": "^3.5.12", 18 | "ramda": "^0.25.0", 19 | "react": "15.4.2", 20 | "react-dom": "15.4.2" 21 | }, 22 | "devDependencies": { 23 | "babel-core": "^6.26.3", 24 | "babel-eslint": "^8.2.3", 25 | "babel-loader": "^7.1.4", 26 | "copyfiles": "^2.0.0", 27 | "babel-preset-env": "^1.7.0", 28 | "babel-preset-react": "^6.24.1", 29 | "css-loader": "^0.28.11", 30 | "eslint": "^4.19.1", 31 | "eslint-config-prettier": "^2.9.0", 32 | "eslint-plugin-import": "^2.12.0", 33 | "eslint-plugin-react": "^7.9.1", 34 | "npm": "^6.1.0", 35 | "react-docgen": "^2.20.1", 36 | "style-loader": "^0.21.0", 37 | "webpack": "^4.20.2", 38 | "webpack-cli": "^3.1.1", 39 | "webpack-serve": "^1.0.2" 40 | }, 41 | "peerDependencies": { 42 | "react": ">=0.14", 43 | "react-dom": ">=0.14" 44 | }, 45 | "engines": { 46 | "node": ">=8.11.0", 47 | "npm": ">=6.1.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /readme_usage_py.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/dash-sunburst/db27a153dca638b2e513e0a9c913dacca81860af/readme_usage_py.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from setuptools import setup 4 | 5 | 6 | with open(os.path.join('dash_sunburst', 'package.json')) as f: 7 | package = json.load(f) 8 | 9 | package_name = package["name"].replace(" ", "_").replace("-", "_") 10 | 11 | setup( 12 | name=package_name, 13 | version=package["version"], 14 | author=package['author'], 15 | packages=[package_name], 16 | include_package_data=True, 17 | license=package['license'], 18 | description=package['description'] if 'description' in package else package_name, 19 | install_requires=[] 20 | ) 21 | -------------------------------------------------------------------------------- /src/demo/App.js: -------------------------------------------------------------------------------- 1 | /* eslint no-magic-numbers: 0 */ 2 | import React, {Component} from 'react'; 3 | 4 | import {Sunburst} from '../lib'; 5 | 6 | class App extends Component { 7 | 8 | constructor() { 9 | super(); 10 | this.state = { 11 | // width: 500, 12 | // height: 500, 13 | // padding: 10, 14 | // innerRadius: 20, 15 | transitionDuration: 1000, 16 | selectedPath: ['living room'], 17 | dataVersion: 1, 18 | data: { 19 | name: 'house', 20 | children: [ 21 | { 22 | name: 'living room', 23 | children: [ 24 | {name: 'couch', size: 5}, 25 | {name: 'tv', size: 3}, 26 | {name: 'desk', size: 4}, 27 | {name: 'chair', size: 1}, 28 | {name: 'table', size: 4}, 29 | {name: 'piano', size: 2} 30 | ] 31 | }, 32 | { 33 | name: 'kitchen', 34 | color: '#006', 35 | children: [ 36 | {name: 'fridge', size: 3, color: '#600'}, 37 | {name: 'dishwasher', size: 2, color: '#060'}, 38 | {name: 'sink', size: 1}, 39 | {name: 'cabinets', size: 7}, 40 | {name: 'oven', size: 2} 41 | ] 42 | }, 43 | {name: 'coat closet', size: 4}, 44 | {name: 'storage closet', size: 10}, 45 | {name: 'bathroom', size: 6}, 46 | { 47 | name: 'master bedroom', 48 | children: [ 49 | {name: 'bed', size: 8}, 50 | {name: 'recliner', size: 3}, 51 | {name: 'dresser', size: 4}, 52 | {name: 'master bath', size: 6}, 53 | {name: 'closet', size: 5} 54 | ] 55 | }, 56 | { 57 | name: 'bedroom', 58 | children: [ 59 | {name: 'bed', size: 5}, 60 | {name: 'desk', size: 3}, 61 | {name: 'dresser', size: 4}, 62 | {name: 'closet', size: 5} 63 | ] 64 | }, 65 | {name: 'hall', size: 10} 66 | ] 67 | } 68 | } 69 | this.setProps = this.setProps.bind(this); 70 | this.mutateData = this.mutateData.bind(this); 71 | 72 | this.period = 3; 73 | this.updateInterval = setInterval(this.mutateData, 1000 * this.period); 74 | } 75 | 76 | setProps(newProps) { 77 | this.setState(newProps); 78 | } 79 | 80 | render() { 81 | const {data, selectedPath} = this.state; 82 | const selectedPathStr = selectedPath.join(','); 83 | const paths = getPathStrs(data, ''); 84 | const options = paths.map(path => ( 85 | 88 | )); 89 | const selectChange = e => { 90 | this.setState({selectedPath: e.target.value.split(',')}) 91 | }; 92 | 93 | return ( 94 |
95 |

Sunburst Demo

96 |

Click a node, or select it in the dropdown, to select a subtree.

97 |

Every {this.period} seconds a node will be added, removed, resized, or renamed

98 | 102 | 105 |
106 | ) 107 | } 108 | 109 | mutateData() { 110 | const {data, dataVersion} = this.state; 111 | const newSize = Math.round(Math.random() * 200) / 10; 112 | 113 | // Pick a random node 114 | const nodes = getPathStrs(data, '').map(getNode(data)); 115 | const {node, parent} = nodes[Math.floor(Math.random() * nodes.length)]; 116 | 117 | // Pick a random operation to execute on this node 118 | const operations = [addChild, resizeNode, removeNode, renameNode]; 119 | const operation = operations[Math.floor(Math.random() * operations.length)]; 120 | 121 | operation(); 122 | this.setState({dataVersion: dataVersion + 1, data: data}); 123 | 124 | function addChild() { 125 | const newName = 'box ' + dataVersion; 126 | const newChild = {name: newName, size: newSize}; 127 | if(node.children) { 128 | node.children.push(newChild); 129 | } 130 | else { 131 | node.children = [newChild]; 132 | delete node.size; 133 | } 134 | } 135 | 136 | function resizeNode() { 137 | // only valid on leaf nodes 138 | if(node.size) { node.size = newSize; } 139 | } 140 | 141 | function removeNode() { 142 | // only remove leaf nodes, otherwise we'd be removing too much! 143 | if(!node.children) { 144 | parent.children.splice(parent.children.indexOf(node),1); 145 | if(!parent.children.length) { 146 | delete parent.children; 147 | parent.size = newSize; 148 | } 149 | } 150 | } 151 | 152 | function renameNode() { 153 | // Alternate name! Eventually most of the house will be cheese! 154 | // 155 | // Note that because we're using the node path (of names) as the 156 | // data binding key, renaming a node causes it (and all its children) 157 | // to disappear for transitionDuration time, then it reappears with 158 | // its new name. The only way I see to avoid this missing period 159 | // would be to key off some other id than name - otherwise how are 160 | // we to tell the difference between a rename and actually removing 161 | // one node and adding another with a different name? 162 | node.name = 'cheese ' + dataVersion; 163 | } 164 | } 165 | } 166 | 167 | function getPathStrs(data, head) { 168 | let out = [head]; 169 | for(let i = 0; i < (data.children || []).length; i++) { 170 | const childi = data.children[i]; 171 | out = out.concat(getPathStrs(childi, addPath(head, childi.name))); 172 | } 173 | return out; 174 | } 175 | 176 | function addPath(head, name) { 177 | return head ? (head + ',' + name) : name; 178 | } 179 | 180 | function getNode(root) { 181 | return function(pathStr) { 182 | const path = (pathStr || '').split(','); 183 | let node = root; 184 | const lineage = [node]; 185 | for(let i = 0; i < path.length; i++) { 186 | const part = path[i]; 187 | for(let j = 0; j < node.children.length; j++) { 188 | const childj = node.children[j]; 189 | if(childj.name === part) { 190 | node = childj; 191 | lineage.push(node); 192 | break; 193 | } 194 | } 195 | } 196 | return {node: node, parent: lineage[lineage.length - 2]}; 197 | } 198 | } 199 | 200 | export default App; 201 | -------------------------------------------------------------------------------- /src/demo/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | ReactDOM.render(, document.getElementById('root')); 6 | -------------------------------------------------------------------------------- /src/lib/components/Sunburst.react.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import SunburstD3 from '../d3/sunburst'; 4 | 5 | export default class Sunburst extends Component { 6 | componentDidMount() { 7 | this.sunburst = new SunburstD3(this.el, this.props, figure => { 8 | const {setProps} = this.props; 9 | const {selectedPath} = figure; 10 | 11 | if (setProps) { setProps({selectedPath}); } 12 | else { this.setState({selectedPath}); } 13 | }); 14 | } 15 | 16 | componentDidUpdate() { 17 | this.sunburst.update(this.props); 18 | } 19 | 20 | render() { 21 | return
{this.el = el}} />; 22 | } 23 | } 24 | 25 | Sunburst.defaultProps = { 26 | interactive: true 27 | }; 28 | 29 | Sunburst.propTypes = { 30 | /** 31 | * The ID used to identify this component in Dash callbacks 32 | */ 33 | id: PropTypes.string, 34 | 35 | /** 36 | * Dash-assigned callback that should be called whenever any of the 37 | * properties change 38 | */ 39 | setProps: PropTypes.func, 40 | 41 | /** 42 | * Width of the figure to draw, in pixels 43 | */ 44 | width: PropTypes.number, 45 | 46 | /** 47 | * Height of the figure to draw, in pixels 48 | */ 49 | height: PropTypes.number, 50 | 51 | /** 52 | * Pixels to leave blank around the edges 53 | */ 54 | padding: PropTypes.number, 55 | 56 | /** 57 | * Radius, in pixels, for the inner circle when you're zoomed in, 58 | * that you click on to zoom back out 59 | */ 60 | innerRadius: PropTypes.number, 61 | 62 | /** 63 | * Animation duration when you click around selecting subtrees 64 | */ 65 | transitionDuration: PropTypes.number, 66 | 67 | /** 68 | * The sunburst data. Should have the form: 69 | * 70 | * `{name: '...', children: [c0, c1, c2]}` 71 | * 72 | * and children `c` can have the same form to arbitrary nesting, 73 | * or for leaf nodes the form is: 74 | * 75 | * `{name: '...', size: ###}` 76 | * 77 | * any node can also have a `color` property, set to any CSS color string, 78 | * to use instead of the default coloring. Nodes with no children will 79 | * inherit their parent's color if not specified. Otherwise colors are pulled 80 | * from d3.scale.category20 in the order nodes are encountered. 81 | */ 82 | data: PropTypes.object.isRequired, 83 | 84 | /** 85 | * Optional version id for data, to avoid having to diff a large object 86 | */ 87 | dataVersion: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 88 | 89 | /** 90 | * The currently selected path within the sunburst 91 | * as an array of child names 92 | */ 93 | selectedPath: PropTypes.arrayOf(PropTypes.string), 94 | 95 | /** 96 | * Sets whether you can click a node to select that path 97 | */ 98 | interactive: PropTypes.bool 99 | }; 100 | -------------------------------------------------------------------------------- /src/lib/d3/sunburst.js: -------------------------------------------------------------------------------- 1 | import d3 from 'd3'; 2 | 3 | const dflts = { 4 | width: 600, 5 | height: 500, 6 | padding: 10, 7 | innerRadius: 20, 8 | transitionDuration: 750 9 | }; 10 | 11 | const numFormat = d3.format(',.3g'); 12 | 13 | const Tau = 2 * Math.PI; 14 | 15 | function constrain(v, vMin, vMax) { 16 | return Math.max(vMin, Math.min(vMax, v)); 17 | } 18 | 19 | const textStyle = { 20 | fill: '#444', 21 | 'text-anchor': 'middle', 22 | 'font-size': '10px', 23 | 'font-family': 'Arial', 24 | 'text-shadow': 'white -1px 0px 0.5px, white 0px -1px 0.5px, white 0px 1px 0.5px, white 1px 0px 0.5px' 25 | }; 26 | 27 | export default class SunburstD3 { 28 | constructor(el, figure, onChange) { 29 | const self = this; 30 | self.update = self.update.bind(self); 31 | self._update = self._update.bind(self); 32 | 33 | self.svg = d3.select(el).append('svg'); 34 | self.pathGroup = self.svg.append('g'); 35 | self.textGroup = self.svg.append('g') 36 | .style('pointer-events', 'none'); 37 | 38 | self.angularScale = d3.scale.linear().range([0, Tau]); 39 | self.radialScale = d3.scale.sqrt(); 40 | self.colorScale = d3.scale.category20(); 41 | self.partition = d3.layout.partition() 42 | .value(d => !d.children && d.size) 43 | .sort((a, b) => a.i - b.i); 44 | 45 | self.arc = d3.svg.arc() 46 | .startAngle(d => constrain(self.angularScale(d.x), 0, Tau)) 47 | .endAngle(d => constrain(self.angularScale(d.x + d.dx), 0, Tau)) 48 | .innerRadius(d => Math.max(0, self.radialScale(d.y))) 49 | .outerRadius(d => Math.max(0, self.radialScale(d.y + d.dy))); 50 | 51 | self.figure = {}; 52 | 53 | self.onChange = onChange; 54 | 55 | self.initialized = false; 56 | 57 | self._promise = Promise.resolve(); 58 | 59 | self.update(figure); 60 | } 61 | 62 | update(figure) { 63 | const self = this; 64 | // ensure any previous transition is complete before we start 65 | self._promise = self._promise.then(() => self._update(figure)); 66 | } 67 | 68 | _update(figure) { 69 | const self = this; 70 | const oldFigure = self.figure; 71 | 72 | // fill defaults in the new figure 73 | const width = figure.width || dflts.width; 74 | const height = figure.height || dflts.height; 75 | // interactive: undefined defaults to true 76 | const interactive = figure.interactive !== false; 77 | const padding = figure.padding || dflts.padding; 78 | const innerRadius = figure.innerRadius || dflts.innerRadius; 79 | const transitionDuration = figure.transitionDuration || dflts.transitionDuration; 80 | const {data, dataVersion} = figure; 81 | const selectedPath = figure.selectedPath || []; 82 | 83 | const newFigure = self.figure = { 84 | width, 85 | height, 86 | interactive, 87 | padding, 88 | innerRadius, 89 | transitionDuration, 90 | data, 91 | dataVersion, 92 | selectedPath 93 | }; 94 | 95 | /* 96 | * Definitions 97 | */ 98 | 99 | const selectedX = node => [node.x, node.x + node.dx]; 100 | const selectedY = node => [node.y, 1]; 101 | const selectedRadius = node => [node.y ? self.figure.innerRadius : 0, self.radius]; 102 | 103 | const rCenter = node => self.radialScale(node.y + node.dy / 2); 104 | const angleCenter = node => self.angularScale(node.x + node.dx / 2); 105 | const xCenter = node => rCenter(node) * Math.sin(angleCenter(node)); 106 | const yCenter = node => -rCenter(node) * Math.cos(angleCenter(node)); 107 | 108 | const skinny = node => { 109 | const dtheta = self.angularScale(node.x + node.dx) - self.angularScale(node.x); 110 | const r0 = self.radialScale(node.y); 111 | const dr = self.radialScale(node.y + node.dy) / r0 - 1; 112 | return r0 && (dr / dtheta > 1); 113 | }; 114 | 115 | const textTrans = node => { 116 | const rot = (angleCenter(node) * 360 / Tau + (skinny(node) ? 0 : 90)) % 180 - 90; 117 | return 'rotate(' + rot + ',' + xCenter(node) + ',' + yCenter(node) + ')'; 118 | }; 119 | 120 | const hideText = node => { 121 | return ( 122 | angleCenter(node) > 0 && angleCenter(node) < Tau && 123 | rCenter(node) > 0 && rCenter(node) < self.radius 124 | ) ? 1 : 0; 125 | } 126 | 127 | const posOnly = (d) => { 128 | const {x, dx, y, dy} = d; 129 | return {x, dx, y, dy}; 130 | } 131 | 132 | function wrap(accessor) { 133 | return d => { 134 | return t => { 135 | const d0 = self.oldDataMap[getPathStr(d)]; 136 | if(d0 && d0 !== d) { 137 | const interpolator = d3.interpolateObject(posOnly(d0), posOnly(d)); 138 | return accessor(interpolator(t)); 139 | } 140 | return accessor(d); 141 | } 142 | }; 143 | } 144 | 145 | const transitionToNode = node => { 146 | // simultaneous transitions can cause infinite loops in some cases 147 | // mostly self._promise takes care of this, we want to avoid clicks 148 | // during transitions. 149 | self.transitioning = true; 150 | const transition = self.svg.transition() 151 | .duration(self.figure.transitionDuration) 152 | .tween('scale', () => { 153 | const angularDomain = d3.interpolate( 154 | self.angularScale.domain(), 155 | selectedX(node) 156 | ); 157 | const radialDomain = d3.interpolate( 158 | self.radialScale.domain(), 159 | selectedY(node) 160 | ); 161 | const radialRange = d3.interpolate( 162 | self.radialScale.range(), 163 | selectedRadius(node) 164 | ); 165 | return function(t) { 166 | self.angularScale 167 | .domain(angularDomain(t)); 168 | self.radialScale 169 | .domain(radialDomain(t)) 170 | .range(radialRange(t)); 171 | }; 172 | }); 173 | transition.selectAll('path') 174 | .attrTween('d', wrap(self.arc)); 175 | transition.selectAll('text') 176 | .attrTween('x', wrap(xCenter)) 177 | .attrTween('y', wrap(yCenter)) 178 | .attrTween('transform', wrap(textTrans)) 179 | .attrTween('opacity', wrap(hideText)); 180 | 181 | if(self.onChange) { 182 | self.figure.selectedPath = getPath(node); 183 | self.onChange(self.figure); 184 | } 185 | return transition; 186 | }; 187 | 188 | const updatePaths = (_paths, _texts, _dataChange) => { 189 | if(_dataChange) { 190 | const enteringPaths = _paths.enter().append('path') 191 | .style({stroke: '#fff', strokeWidth: 1}) 192 | .on('click', node => { 193 | if(self.transitioning) { return; } 194 | self._promise = self._promise.then(() => { 195 | return new Promise(resolve => { 196 | if(self.figure.interactive) { 197 | transitionToNode(node) 198 | .each('end', () => { 199 | self.transitioning = false; 200 | resolve(); 201 | }); 202 | } 203 | else { 204 | resolve(); 205 | } 206 | }); 207 | }); 208 | }); 209 | enteringPaths.append('title'); 210 | 211 | _texts.enter().append('text') 212 | .style(textStyle) 213 | .text(d => d.name); 214 | } 215 | 216 | /* 217 | * Updates to attributes, that we need to do regardless of what changed 218 | */ 219 | 220 | _paths 221 | .attr('d', self.arc) 222 | // coloring this way will be history-dependent: if you insert a 223 | // new item in the middle it will get the next color, and existing 224 | // items will keep their colors. But if you later redraw this 225 | // component straight from the final data you'll get different colors 226 | .style('fill', d => ( 227 | // first look for an explicit color (or explicit parent color) 228 | d.color || 229 | (!d.children && d.parent.color) || 230 | self.colorScale(getPathStr(d.children ? d : d.parent)) 231 | )); 232 | 233 | // title is a cheap solution for tooltips; better is to call 234 | // `d3.on('mouse(over|out)')` and draw a tooltip. 235 | // That requires a bit of extra logic if you want these tooltips 236 | // to work correctly across state updates 237 | _paths.select('title').text(d => d.name + '\n' + numFormat(d.value)); 238 | 239 | _texts 240 | .attr('x', xCenter) 241 | .attr('y', yCenter) 242 | .attr('transform', textTrans) 243 | .attr('opacity', hideText); 244 | 245 | const dataMap = self.oldDataMap = {}; 246 | _paths.each(d => { 247 | dataMap[getPathStr(d)] = posOnly(d); 248 | }); 249 | }; 250 | 251 | const setSize = () => { 252 | self.radius = (Math.min(height, width) / 2) - padding; 253 | 254 | self.svg.attr({width, height}); 255 | const centered = 'translate(' + (width / 2) + ',' + (height / 2) + ')'; 256 | self.pathGroup.attr('transform', centered); 257 | self.textGroup.attr('transform', centered); 258 | }; 259 | 260 | /* 261 | * Diffing 262 | */ 263 | 264 | let retVal = Promise.resolve(); 265 | 266 | const change = diff(oldFigure, newFigure); 267 | if(!change) { return retVal; } 268 | 269 | const sizeChange = change.width || change.height || change.padding; 270 | const dataChange = change.data; 271 | 272 | const oldRootName = self.rootName; 273 | const newRootName = self.rootName = data.name; 274 | 275 | const oldSelectedPath = self.selectedPath; 276 | const newSelectedPath = self.selectedPath = selectedPath.slice(); 277 | 278 | /* 279 | * Drawing 280 | */ 281 | 282 | if(sizeChange) { setSize(); } 283 | 284 | let paths = self.pathGroup.selectAll('path'); 285 | let texts = self.textGroup.selectAll('text'); 286 | 287 | if(dataChange) { 288 | // clone data before partitioning, since this mutates the data 289 | self.nodes = self.partition.nodes(addIndices(JSON.parse(JSON.stringify(data)))); 290 | paths = paths.data(self.nodes, getPathStr); 291 | texts = texts.data(self.nodes, getPathStr); 292 | 293 | // exit paths at the beginning of the transition 294 | // enters will happen at the end 295 | paths.exit().remove(); 296 | texts.exit().remove(); 297 | } 298 | 299 | const selectedNode = getNode(self.nodes[0], selectedPath); 300 | // no node: path is wrong, probably because we received a new selectedPath 301 | // before the data it belongs with 302 | if(!selectedNode) { return retVal; } 303 | 304 | // immediate redraw rather than transition if: 305 | const shouldAnimate = 306 | // first draw 307 | self.initialized && 308 | // new root node 309 | (newRootName === oldRootName) && 310 | // not a pure up/down transition 311 | sameHead(oldSelectedPath, newSelectedPath) && 312 | // the previous data didn't contain the new selected node 313 | // this can happen if we transition selectedPath first, then data 314 | (!dataChange || getNode(oldFigure.data, newSelectedPath)); 315 | 316 | console.log(shouldAnimate, oldSelectedPath, newSelectedPath); 317 | 318 | if(shouldAnimate) { 319 | retVal = new Promise(resolve => { 320 | transitionToNode(selectedNode) 321 | .each('end', () => { 322 | updatePaths(paths, texts, dataChange); 323 | self.transitioning = false; 324 | resolve(); 325 | }); 326 | }); 327 | } 328 | else { 329 | // first draw has no animation, and initializes the scales 330 | self.angularScale.domain(selectedX(selectedNode)); 331 | self.radialScale.domain(selectedY(selectedNode)) 332 | self.radialScale.range(selectedRadius(selectedNode)); 333 | 334 | updatePaths(paths, texts, dataChange); 335 | 336 | self.initialized = true; 337 | } 338 | return retVal; 339 | } 340 | }; 341 | 342 | function sameHead(array1, array2) { 343 | const len = Math.min(array1.length, array2.length); 344 | for(let i = 0; i < len; i++) { 345 | if(array1[i] !== array2[i]) { return false; } 346 | } 347 | return true; 348 | } 349 | 350 | // so we can sort by index, not by size as partition does by default. 351 | function addIndices(node) { 352 | if(node.children) { 353 | node.children.forEach((child, i) => { 354 | child.i = i; 355 | addIndices(child); 356 | }); 357 | } 358 | return node; 359 | } 360 | 361 | function getPath(d) { 362 | return d.parent ? getPath(d.parent).concat([d.name]) : []; 363 | } 364 | 365 | function getPathStr(d) { 366 | return getPath(d).join(',') || d.name; 367 | } 368 | 369 | function getNode(node, path) { 370 | if(!path.length) { return node; } 371 | if(!node.children) { return false; } 372 | 373 | let childi; 374 | for(var i = 0; i < node.children.length; i++) { 375 | childi = node.children[i]; 376 | if(childi.name === path[0]) { 377 | return getNode(childi, path.slice(1)); 378 | } 379 | } 380 | return false; 381 | } 382 | 383 | /** 384 | * Very simple diff - assumes newObj is flat and has all the possible keys from oldObj 385 | * uses a "dataVersion" key to avoid diffing the full data object. 386 | * In fact, this way we can avoid copying data (ie treating it immutably), 387 | * and just use dataVersion to track mutations. 388 | */ 389 | function diff(oldObj, newObj) { 390 | const V = 'Version'; 391 | const out = {}; 392 | let hasChange = false; 393 | for(const key in newObj) { 394 | if(key.substr(key.length - V.length) === V) { continue; } 395 | 396 | if(typeof newObj[key] === 'object') { 397 | if(newObj[key + V]) { 398 | if(newObj[key + V] !== oldObj[key + V]) { 399 | out[key] = 1; 400 | hasChange = true; 401 | } 402 | } 403 | else if(JSON.stringify(oldObj[key]) !== JSON.stringify(newObj[key])) { 404 | out[key] = 1; 405 | hasChange = true; 406 | } 407 | } 408 | else if(oldObj[key] !== newObj[key]) { 409 | out[key] = 1; 410 | hasChange = true; 411 | } 412 | } 413 | return hasChange && out; 414 | } 415 | -------------------------------------------------------------------------------- /src/lib/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | import Sunburst from './components/Sunburst.react'; 3 | 4 | export { 5 | Sunburst 6 | }; 7 | -------------------------------------------------------------------------------- /tests/IntegrationTests.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import logging 4 | import os 5 | import multiprocessing 6 | import sys 7 | import time 8 | import unittest 9 | import percy 10 | import threading 11 | import platform 12 | import flask 13 | import requests 14 | 15 | from selenium import webdriver 16 | from selenium.webdriver.chrome.options import Options 17 | 18 | 19 | class IntegrationTests(unittest.TestCase): 20 | def percy_snapshot(self, name=''): 21 | if os.environ.get('PERCY_ENABLED', False): 22 | snapshot_name = '{} - {}'.format(name, sys.version_info) 23 | self.percy_runner.snapshot( 24 | name=snapshot_name 25 | ) 26 | 27 | @classmethod 28 | def setUpClass(cls): 29 | super(IntegrationTests, cls).setUpClass() 30 | 31 | options = Options() 32 | if 'DASH_TEST_CHROMEPATH' in os.environ: 33 | options.binary_location = os.environ['DASH_TEST_CHROMEPATH'] 34 | 35 | cls.driver = webdriver.Chrome(chrome_options=options) 36 | 37 | if os.environ.get('PERCY_ENABLED', False): 38 | loader = percy.ResourceLoader( 39 | webdriver=cls.driver 40 | ) 41 | cls.percy_runner = percy.Runner(loader=loader) 42 | cls.percy_runner.initialize_build() 43 | 44 | @classmethod 45 | def tearDownClass(cls): 46 | super(IntegrationTests, cls).tearDownClass() 47 | 48 | cls.driver.quit() 49 | if os.environ.get('PERCY_ENABLED', False): 50 | cls.percy_runner.finalize_build() 51 | 52 | def setUp(self): 53 | pass 54 | 55 | def tearDown(self): 56 | time.sleep(3) 57 | if platform.system() == 'Windows': 58 | requests.get('http://localhost:8050/stop') 59 | else: 60 | self.server_process.terminate() 61 | time.sleep(3) 62 | 63 | def startServer(self, app): 64 | if 'DASH_TEST_PROCESSES' in os.environ: 65 | processes = int(os.environ['DASH_TEST_PROCESSES']) 66 | else: 67 | processes = 4 68 | 69 | def run(): 70 | app.scripts.config.serve_locally = True 71 | app.css.config.serve_locally = True 72 | app.run_server( 73 | port=8050, 74 | debug=False, 75 | processes=processes 76 | ) 77 | 78 | def run_windows(): 79 | app.scripts.config.serve_locally = True 80 | app.css.config.serve_locally = True 81 | 82 | @app.server.route('/stop') 83 | def _stop_server_windows(): 84 | stopper = flask.request.environ['werkzeug.server.shutdown'] 85 | stopper() 86 | return 'stop' 87 | 88 | app.run_server( 89 | port=8050, 90 | debug=False, 91 | threaded=True 92 | ) 93 | 94 | # Run on a separate process so that it doesn't block 95 | 96 | system = platform.system() 97 | if system == 'Windows': 98 | self.server_thread = threading.Thread(target=run_windows) 99 | self.server_thread.start() 100 | else: 101 | self.server_process = multiprocessing.Process(target=run) 102 | self.server_process.start() 103 | logging.getLogger('werkzeug').setLevel(logging.ERROR) 104 | time.sleep(5) 105 | 106 | # Visit the dash page 107 | self.driver.get('http://localhost:8050') 108 | time.sleep(0.5) 109 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/dash-sunburst/db27a153dca638b2e513e0a9c913dacca81860af/tests/__init__.py -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | # Switch into a virtual environment 2 | # pip install -r requirements.txt 3 | 4 | chromedriver-binary 5 | dash 6 | dash-core-components 7 | dash-html-components 8 | dash-renderer 9 | ipdb 10 | percy 11 | selenium 12 | flake8 13 | pylint 14 | -------------------------------------------------------------------------------- /tests/test_render.py: -------------------------------------------------------------------------------- 1 | from .IntegrationTests import IntegrationTests 2 | import dash 3 | import dash_html_components as html 4 | from selenium.webdriver.common.by import By 5 | from selenium.webdriver.support.ui import WebDriverWait 6 | from selenium.webdriver.support import expected_conditions as EC 7 | 8 | from dash_sunburst import Sunburst # pylint: disable=no-name-in-module 9 | 10 | 11 | class Tests(IntegrationTests): 12 | def test_render_component(self): 13 | app = dash.Dash(__name__) 14 | app.layout = html.Div([ 15 | html.Div(id='waitfor'), 16 | Sunburst(data={ 17 | 'name': 'apples', 18 | 'children': [ 19 | {'name': 'bananas', 'size': 1}, 20 | {'name': 'carrots', 'size': 2}, 21 | { 22 | 'name': 'durians', 23 | 'children': [ 24 | {'name': 'elderberries', 'size': 3}, 25 | {'name': 'figs', 'size': 4} 26 | ] 27 | } 28 | ] 29 | }) 30 | ]) 31 | 32 | self.startServer(app) 33 | 34 | WebDriverWait(self.driver, 10).until( 35 | EC.presence_of_element_located((By.ID, "waitfor")) 36 | ) 37 | 38 | self.percy_snapshot('Simple Render') 39 | -------------------------------------------------------------------------------- /usage.py: -------------------------------------------------------------------------------- 1 | import dash 2 | from dash.dependencies import Input, Output 3 | import dash_core_components as dcc 4 | import dash_html_components as html 5 | 6 | from dash_sunburst import Sunburst 7 | 8 | app = dash.Dash('') 9 | 10 | app.scripts.config.serve_locally = True 11 | app.css.config.serve_locally = True 12 | 13 | sunburst_data = { 14 | 'name': 'house', 15 | 'children': [ 16 | { 17 | 'name': 'living room', 18 | 'children': [ 19 | {'name': 'couch', 'size': 6}, 20 | {'name': 'tv', 'size': 3}, 21 | {'name': 'desk', 'size': 4}, 22 | {'name': 'chair', 'size': 1}, 23 | {'name': 'table', 'size': 5}, 24 | {'name': 'piano', 'size': 2} 25 | ] 26 | }, 27 | { 28 | 'name': 'kitchen', 29 | 'children': [ 30 | {'name': 'fridge', 'size': 3.5}, 31 | {'name': 'dishwasher', 'size': 2.5}, 32 | {'name': 'sink', 'size': 1.5}, 33 | {'name': 'cabinets', 'size': 8}, 34 | {'name': 'oven', 'size': 1.7} 35 | ] 36 | }, 37 | {'name': 'coat closet', 'size': 4.5}, 38 | {'name': 'storage closet', 'size': 10}, 39 | {'name': 'bathroom', 'size': 7.5}, 40 | { 41 | 'name': 'master bedroom', 42 | 'children': [ 43 | {'name': 'bed', 'size': 9}, 44 | {'name': 'recliner', 'size': 3.2}, 45 | {'name': 'dresser', 'size': 4.7}, 46 | {'name': 'master bath', 'size': 7}, 47 | {'name': 'closet', 'size': 5.5} 48 | ] 49 | }, 50 | { 51 | 'name': 'bedroom', 52 | 'children': [ 53 | {'name': 'bed', 'size': 5.7}, 54 | {'name': 'desk', 'size': 3.8}, 55 | {'name': 'dresser', 'size': 4.7}, 56 | {'name': 'closet', 'size': 5.3} 57 | ] 58 | }, 59 | {'name': 'hall', 'size': 11} 60 | ] 61 | } 62 | 63 | app.layout = html.Div([ 64 | html.Div( 65 | [Sunburst(id='sun', data=sunburst_data)], 66 | style={'width': '49%', 'display': 'inline-block', 'float': 'left'}), 67 | dcc.Graph( 68 | id='graph', 69 | style={'width': '49%', 'display': 'inline-block', 'float': 'left'}), 70 | html.Div(id='output', style={'clear': 'both'}) 71 | ]) 72 | 73 | @app.callback(Output('output', 'children'), [Input('sun', 'selectedPath')]) 74 | def display_selected(selected_path): 75 | return 'You have selected path: {}'.format('->'.join(selected_path or []) or 'root') 76 | 77 | @app.callback(Output('graph', 'figure'), [Input('sun', 'data'), Input('sun', 'selectedPath')]) 78 | def display_graph(data, selected_path): 79 | x = [] 80 | y = [] 81 | text = [] 82 | color = [] 83 | joined_selected = '->'.join(selected_path or []) 84 | 85 | SELECTED_COLOR = '#03c' 86 | SELECTED_CHILDREN_COLOR = '#8cf' 87 | SELECTED_PARENTS_COLOR = '#f80' 88 | DESELECTED_COLOR = '#ccc' 89 | 90 | def node_color(node_path): 91 | joined_node = '->'.join(node_path) 92 | if joined_node == joined_selected: 93 | return SELECTED_COLOR 94 | if joined_node.startswith(joined_selected): 95 | return SELECTED_CHILDREN_COLOR 96 | if joined_selected.startswith(joined_node): 97 | return SELECTED_PARENTS_COLOR 98 | return DESELECTED_COLOR 99 | 100 | def append_point(child_count, size, node, node_path): 101 | x.append(child_count) 102 | y.append(size) 103 | text.append(node['name']) 104 | color.append(node_color(node_path)) 105 | 106 | def crawl(node, node_path): 107 | if 'size' in node: 108 | append_point(1, node['size'], node, node_path) 109 | return (1, node['size']) 110 | else: 111 | node_count, node_size = 1, 0 112 | for child in node['children']: 113 | this_count, this_size = crawl(child, node_path + [child['name']]) 114 | node_count += this_count 115 | node_size += this_size 116 | append_point(node_count, node_size, node, node_path) 117 | return (node_count, node_size) 118 | 119 | crawl(data, []) 120 | 121 | layout = { 122 | 'width': 500, 123 | 'height': 500, 124 | 'xaxis': {'title': 'Total Nodes', 'type': 'log'}, 125 | 'yaxis': {'title': 'Total Size', 'type': 'log'}, 126 | 'hovermode': 'closest' 127 | } 128 | 129 | return { 130 | 'data': [{ 131 | 'x': x, 132 | 'y': y, 133 | 'text': text, 134 | 'textposition': 'middle right', 135 | 'marker': { 136 | 'color': color, 137 | 'size': [(v*v + 100)**0.5 for v in y], 138 | 'opacity': 0.5 139 | }, 140 | 'mode': 'markers+text', 141 | 'cliponaxis': False 142 | }], 143 | 'layout': layout 144 | } 145 | 146 | if __name__ == '__main__': 147 | app.run_server(debug=True) 148 | -------------------------------------------------------------------------------- /usage_backend_update_via_controls.py: -------------------------------------------------------------------------------- 1 | import dash 2 | from dash.dependencies import Input, Output 3 | import dash_sunburst 4 | import dash_core_components as dcc 5 | import dash_html_components as html 6 | 7 | 8 | N_CHILDREN = 3 9 | 10 | def node_name(level_index, node_index): 11 | return 'Level {}, Node {}'.format(level_index, node_index) 12 | 13 | def create_data(start_level_index, node_index): 14 | data = { 15 | 'name': node_name(start_level_index, node_index), 16 | 'children': [{ 17 | 'name': node_name(start_level_index + 1, i + 1), 18 | 'size': 1, 19 | 'children': [{ 20 | 'name': node_name(start_level_index + 2, j + 1), 21 | 'size': 1 22 | } for j in range(N_CHILDREN)] 23 | } for i in range(N_CHILDREN)] 24 | } 25 | 26 | # wrap back down to level 1 27 | for i in range(start_level_index - 1, 0, -1): 28 | data = { 29 | 'name': node_name(i, 1), 30 | 'children': [data] 31 | } 32 | return data 33 | 34 | def extract_level_and_node_from_name(name): 35 | level = int(name.split(', ')[0].replace('Level ', '')) 36 | node = int(name.split(', ')[1].replace('Node ', '')) 37 | return (level, node) 38 | 39 | 40 | app = dash.Dash(__name__) 41 | app.scripts.config.serve_locally = True 42 | app.css.config.serve_locally = True 43 | 44 | app.layout = html.Div([ 45 | html.Label('Display Level: '), 46 | dcc.Dropdown( 47 | id='level', 48 | options=[{ 49 | 'label': 'Level {}'.format(i), 50 | 'value': i 51 | } for i in range(1, 10)], 52 | value=1 53 | ), 54 | html.Label('Display node within level'), 55 | dcc.Dropdown(id='node'), 56 | 57 | dash_sunburst.Sunburst( 58 | id='sun', 59 | data=create_data(1, 1), 60 | interactive=False # disable clicking nodes 61 | ) 62 | ]) 63 | 64 | 65 | @app.callback(Output('node', 'options'), [Input('level', 'value')]) 66 | def update_node_options(level): 67 | # first level only has one node 68 | level_nodes = N_CHILDREN if level > 1 else 1 69 | return [{ 70 | 'label': 'Level {}, Node {}'.format(level, i), 71 | 'value': i 72 | } for i in range(1, level_nodes + 1)] 73 | 74 | 75 | @app.callback(Output('node', 'value'), [Input('node', 'options')]) 76 | def update_node_value(options): 77 | return options[0]['value'] 78 | 79 | # Originally I had both sun.selectedPath and sun.data depend on 80 | # level.value and node.value. But the two callbacks would fire separately and 81 | # in random order, which confused the receiving component. 82 | # 83 | # In order to ensure a consistent order of operations, I have selectedPath 84 | # depend on level and node, and then data depends on selectedPath. 85 | @app.callback(Output('sun', 'selectedPath'), [ 86 | Input('level', 'value'), 87 | Input('node', 'value')]) 88 | def update_selected_path(level, node): 89 | path = [node_name(i, node if i == level else 1) for i in range(2, level + 1)] 90 | return path 91 | 92 | 93 | @app.callback(Output('sun', 'data'), [Input('sun', 'selectedPath')]) 94 | def update_sun(selectedPath): 95 | if(len(selectedPath)): 96 | (level, node) = extract_level_and_node_from_name(selectedPath[-1]) 97 | else: 98 | (level, node) = (1, 1) 99 | return create_data(level, node) 100 | 101 | 102 | if __name__ == '__main__': 103 | app.run_server(debug=True) 104 | -------------------------------------------------------------------------------- /usage_backend_update_via_selections.py: -------------------------------------------------------------------------------- 1 | import dash 2 | from dash.dependencies import Input, Output 3 | import dash_sunburst 4 | import dash_html_components as html 5 | 6 | 7 | N_CHILDREN = 3 8 | 9 | 10 | def node_name(level_index, node_index): 11 | return 'Level {}, Node {}'.format(level_index, node_index) 12 | 13 | 14 | def create_data_from_path(path): 15 | start_level_index = len(path) + 1 16 | node_index = extract_level_and_node_from_name(path[-1])[1] if len(path) else 1 17 | data = { 18 | 'name': node_name(start_level_index, node_index), 19 | 'children': [{ 20 | 'name': node_name(start_level_index + 1, i + 1), 21 | 'size': 1, 22 | 'children': [{ 23 | 'name': node_name(start_level_index + 2, j + 1), 24 | 'size': 1 25 | } for j in range(N_CHILDREN)] 26 | } for i in range(N_CHILDREN)] 27 | } 28 | 29 | # wrap back down to level 1 30 | for name in reversed(path[:-1]): 31 | data = { 32 | 'name': name, 33 | 'children': [data] 34 | } 35 | if len(path): 36 | data = { 37 | 'name': node_name(1, 1), 38 | 'children': [data] 39 | } 40 | return data 41 | 42 | 43 | def extract_level_and_node_from_name(name): 44 | level = int(name.split(', ')[0].replace('Level ', '')) 45 | node = int(name.split(', ')[1].replace('Node ', '')) 46 | return (level, node) 47 | 48 | 49 | app = dash.Dash(__name__) 50 | app.scripts.config.serve_locally = True 51 | app.css.config.serve_locally = True 52 | 53 | app.layout = html.Div([ 54 | dash_sunburst.Sunburst( 55 | id='sun', 56 | data=create_data_from_path([]), 57 | selectedPath=[] 58 | ), 59 | ]) 60 | 61 | 62 | @app.callback(Output('sun', 'data'), 63 | [Input('sun', 'selectedPath')]) 64 | def display_sun(selectedPath): 65 | print('->'.join(selectedPath)) 66 | return create_data_from_path(selectedPath) 67 | 68 | 69 | if __name__ == '__main__': 70 | app.run_server(debug=True) 71 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const packagejson = require('./package.json'); 3 | 4 | const dashLibraryName = packagejson.name.replace(/-/g, '_'); 5 | 6 | module.exports = { 7 | entry: {main: './src/lib/index.js'}, 8 | output: { 9 | path: path.resolve(__dirname, dashLibraryName), 10 | filename: 'bundle.js', 11 | library: dashLibraryName, 12 | libraryTarget: 'window', 13 | }, 14 | externals: { 15 | react: 'React', 16 | 'react-dom': 'ReactDOM', 17 | 'plotly.js': 'Plotly', 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.js$/, 23 | exclude: /node_modules/, 24 | use: { 25 | loader: 'babel-loader', 26 | }, 27 | }, 28 | { 29 | test: /\.css$/, 30 | use: [ 31 | { 32 | loader: 'style-loader', 33 | }, 34 | { 35 | loader: 'css-loader', 36 | }, 37 | ], 38 | }, 39 | ], 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /webpack.serve.config.js: -------------------------------------------------------------------------------- 1 | const config = require('./webpack.config.js'); 2 | 3 | config.entry = {main: './src/demo/index.js'}; 4 | config.output = {filename: 'output.js'}; 5 | config.mode = 'development'; 6 | config.externals = undefined; // eslint-disable-line 7 | config.devtool = 'inline-source-map'; 8 | module.exports = config; 9 | --------------------------------------------------------------------------------