diff --git a/.circleci/config.yml b/.circleci/config.yml index 5a04826947..a817962452 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -54,7 +54,7 @@ jobs: pip install -e .[ci,dev,testing,celery,diskcache] --progress-bar off pip list | grep dash npm i - npm run build + npm run build.sequential python setup.py sdist mkdir dash-package && cp dist/*.tar.gz dash-package/dash-package.tar.gz ls -la dash-package @@ -120,7 +120,7 @@ jobs: set -eo pipefail pip install -e . --progress-bar off && pip list | grep dash npm install npm run initialize - npm run build + npm run build.sequential npm run lint - run: name: 🐍 Python Unit Tests & ☕ JS Unit Tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a5fa0e200..8a6454a302 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,26 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added +- [#1949](https://github.com/plotly/dash/pull/1915) Add built-in MathJax support to both `dcc.Markdown` and `dcc.Graph`. A new boolean prop `mathjax` was added to these two components, defaulting to `False`. Set `mathjax=True` to enable math rendering. This work uses MathJax v3, although `dcc.Graph` and Plotly.js can also be used with MathJax v2. + - In `dcc.Markdown` this has two flavors: inline math is any content between single dollar signs, for example `"$E=mc^2$"`, and "display" math (on its own line, potentially multi-line) is delimited by double dollar signs. + - In `dcc.Graph`, most text fields (graph and axis titles, trace names, scatter and bar text) can use math, and it's enabled with single dollar sign delimiters. A limitation here is that currently a given piece of text can only be one or the other: if math is found, everything outside the delimiters is ignored. See https://plotly.com/python/LaTeX/ for details. + - For an intro to LaTeX math, see https://en.wikibooks.org/wiki/LaTeX/Mathematics. + - Big thanks to [Equinor](https://www.equinor.com/) for sponsoring this development, including the related work in Plotly.js! + +### Updated +- [#1949](https://github.com/plotly/dash/pull/1915) Upgrade Plotly.js to v2.11.0 (from v2.9.0) + - [Feature release 2.10.0](https://github.com/plotly/plotly.js/releases/tag/v2.10.0): + - Support for MathJax v3 + - `fillpattern` for `scatter` traces with filled area + - [Feature release 2.11.0](https://github.com/plotly/plotly.js/releases/tag/v2.11.0): + - Every trace type can now be rendered in a stricter CSP environment, specifically avoiding `unsafe-eval`. Please note: the `regl`-based traces (`scattergl`, `scatterpolargl`, `parcoords`, and `splom`) are only strict in the `strict` bundle, which is NOT served by default in Dash. To use this bundle with Dash, you must either download it and put it in your `assets/` folder, or include it as an `external_script` from the CDN: https://cdn.plot.ly/plotly-strict-2.11.0.min.js. All other trace types are strict in the normal bundle. + - Patch release [2.10.1](https://github.com/plotly/plotly.js/releases/tag/v2.5.1) containing a bugfix for `mesh3d` traces. + + ### Fixed - [#1915](https://github.com/plotly/dash/pull/1915) Fix bug [#1474](https://github.com/plotly/dash/issues/1474) when both dcc.Graph and go.Figure have animation, and when the second animation in Figure is executed, the Frames from the first animation are played instead of the second one. -### Fixed - [#1953](https://github.com/plotly/dash/pull/1953) Fix bug [#1783](https://github.com/plotly/dash/issues/1783) in which a failed hot reloader blocks the UI with alerts. ## [2.2.0] - 2022-02-18 diff --git a/components/dash-core-components/.gitignore b/components/dash-core-components/.gitignore index 5900796195..3f950d5be9 100644 --- a/components/dash-core-components/.gitignore +++ b/components/dash-core-components/.gitignore @@ -19,6 +19,7 @@ venv/ /build /dash_core_components dash_core_components_base/plotly.min.js +dash_core_components_base/mathjax.js /deps /inst /man diff --git a/components/dash-core-components/dash_core_components_base/__init__.py b/components/dash-core-components/dash_core_components_base/__init__.py index 427d110325..32d28f866e 100644 --- a/components/dash-core-components/dash_core_components_base/__init__.py +++ b/components/dash-core-components/dash_core_components_base/__init__.py @@ -48,6 +48,7 @@ "graph", "highlight", "markdown", + "mathjax", "slider", "upload", ] diff --git a/components/dash-core-components/package-lock.json b/components/dash-core-components/package-lock.json index f143d8880e..b215cea5f9 100644 --- a/components/dash-core-components/package-lock.json +++ b/components/dash-core-components/package-lock.json @@ -19,9 +19,10 @@ "fast-isnumeric": "^1.1.4", "file-saver": "^2.0.5", "highlight.js": "^11.4.0", + "mathjax": "^3.2.0", "moment": "^2.29.1", "node-polyfill-webpack-plugin": "^1.1.4", - "plotly.js-dist-min": "2.9.0", + "plotly.js-dist-min": "2.11.0", "prop-types": "^15.7.2", "ramda": "^0.27.1", "rc-slider": "^9.7.5", @@ -33,6 +34,7 @@ "react-resize-detector": "^6.7.6", "react-select-fast-filter-options": "^0.2.3", "react-virtualized-select": "^3.1.3", + "remark-math": "^3.0.1", "uniqid": "^5.4.0" }, "devDependencies": { @@ -6289,6 +6291,11 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/mathjax": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mathjax/-/mathjax-3.2.0.tgz", + "integrity": "sha512-PL+rdYRK4Wxif+SQ94zP/L0sv6/oW/1WdQiIx0Jvn9FZaU5W9E6nlIv8liYAXBNPL2Fw/i+o/mZ1212eSzn0Cw==" + }, "node_modules/md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -7156,9 +7163,9 @@ } }, "node_modules/plotly.js-dist-min": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/plotly.js-dist-min/-/plotly.js-dist-min-2.9.0.tgz", - "integrity": "sha512-qQCf8XpYcMpbG5nH6PPc0/gXVQdKUkKQ7mjux3DlXARsOzRf9EbRqwFMSzLz2aElJyrXHunYkxtXW+xswzXz3A==" + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/plotly.js-dist-min/-/plotly.js-dist-min-2.11.0.tgz", + "integrity": "sha512-erNjtDdRvQE0aJRbbfzJ4PilwHZ1/94ucjZON4h7qOBt1DeTR4kM48mv3JjWmgu8g6uyocAnw+ooX82d0Od0bA==" }, "node_modules/postcss": { "version": "8.4.6", @@ -8018,6 +8025,15 @@ "jsesc": "bin/jsesc" } }, + "node_modules/remark-math": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-3.0.1.tgz", + "integrity": "sha512-epT77R/HK0x7NqrWHdSV75uNLwn8g9qTyMqCRCDujL0vj/6T6+yhdrR7mjELWtkse+Fw02kijAaBuVcHBor1+Q==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-5.0.0.tgz", @@ -14486,6 +14502,11 @@ "resolved": "https://registry.npmjs.org/markdown-escapes/-/markdown-escapes-1.0.4.tgz", "integrity": "sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg==" }, + "mathjax": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mathjax/-/mathjax-3.2.0.tgz", + "integrity": "sha512-PL+rdYRK4Wxif+SQ94zP/L0sv6/oW/1WdQiIx0Jvn9FZaU5W9E6nlIv8liYAXBNPL2Fw/i+o/mZ1212eSzn0Cw==" + }, "md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -15159,9 +15180,9 @@ } }, "plotly.js-dist-min": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/plotly.js-dist-min/-/plotly.js-dist-min-2.9.0.tgz", - "integrity": "sha512-qQCf8XpYcMpbG5nH6PPc0/gXVQdKUkKQ7mjux3DlXARsOzRf9EbRqwFMSzLz2aElJyrXHunYkxtXW+xswzXz3A==" + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/plotly.js-dist-min/-/plotly.js-dist-min-2.11.0.tgz", + "integrity": "sha512-erNjtDdRvQE0aJRbbfzJ4PilwHZ1/94ucjZON4h7qOBt1DeTR4kM48mv3JjWmgu8g6uyocAnw+ooX82d0Od0bA==" }, "postcss": { "version": "8.4.6", @@ -15815,6 +15836,11 @@ } } }, + "remark-math": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-3.0.1.tgz", + "integrity": "sha512-epT77R/HK0x7NqrWHdSV75uNLwn8g9qTyMqCRCDujL0vj/6T6+yhdrR7mjELWtkse+Fw02kijAaBuVcHBor1+Q==" + }, "remark-parse": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-5.0.0.tgz", diff --git a/components/dash-core-components/package.json b/components/dash-core-components/package.json index eb8b8b06c6..40dcf01850 100644 --- a/components/dash-core-components/package.json +++ b/components/dash-core-components/package.json @@ -23,7 +23,7 @@ "test": "run-s -c lint test:intg test:pyimport", "test:intg": "pytest --nopercyfinalize --headless tests/integration", "test:pyimport": "python -m unittest tests/test_dash_import.py", - "prebuild:js": "cp node_modules/plotly.js-dist-min/plotly.min.js dash_core_components_base/plotly.min.js", + "prebuild:js": "cp node_modules/plotly.js-dist-min/plotly.min.js dash_core_components_base/plotly.min.js && cp node_modules/mathjax/es5/tex-svg.js dash_core_components_base/mathjax.js", "build:js": "webpack --mode production", "build:backends": "dash-generate-components ./src/components dash_core_components -p package-info.json && cp dash_core_components_base/** dash_core_components/ && dash-generate-components ./src/components dash_core_components -p package-info.json -k RangeSlider,Slider,Dropdown,RadioItems,Checklist,DatePickerSingle,DatePickerRange,Input,Link --r-prefix 'dcc' --r-suggests 'dash,dashHtmlComponents,jsonlite,plotly' --jl-prefix 'dcc' && black dash_core_components", "build": "run-s prepublishOnly build:js build:backends", @@ -46,9 +46,10 @@ "fast-isnumeric": "^1.1.4", "file-saver": "^2.0.5", "highlight.js": "^11.4.0", + "mathjax": "^3.2.0", "moment": "^2.29.1", "node-polyfill-webpack-plugin": "^1.1.4", - "plotly.js-dist-min": "2.9.0", + "plotly.js-dist-min": "2.11.0", "prop-types": "^15.7.2", "ramda": "^0.27.1", "rc-slider": "^9.7.5", @@ -60,6 +61,7 @@ "react-resize-detector": "^6.7.6", "react-select-fast-filter-options": "^0.2.3", "react-virtualized-select": "^3.1.3", + "remark-math": "^3.0.1", "uniqid": "^5.4.0" }, "devDependencies": { diff --git a/components/dash-core-components/src/components/Graph.react.js b/components/dash-core-components/src/components/Graph.react.js index 2b4aaf17fc..fa1814d611 100644 --- a/components/dash-core-components/src/components/Graph.react.js +++ b/components/dash-core-components/src/components/Graph.react.js @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import {asyncDecorator} from '@plotly/dash-component-plugins'; import graph from '../utils/LazyLoader/graph'; import plotly from '../utils/LazyLoader/plotly'; +import lazyLoadMathJax from '../utils/LazyLoader/mathjax'; import { privatePropTypes, privateDefaultProps, @@ -21,6 +22,10 @@ class PlotlyGraph extends Component { constructor(props) { super(props); + if (props.mathjax) { + PlotlyGraph._loadMathjax = true; + } + this.state = { prependData: [], extendData: [], @@ -120,7 +125,11 @@ class PlotlyGraph extends Component { } const RealPlotlyGraph = asyncDecorator(PlotlyGraph, () => - Promise.all([plotly(), graph()]).then(([, graph]) => graph) + Promise.all([ + graph(), + plotly(), + PlotlyGraph._loadMathjax ? lazyLoadMathJax() : undefined, + ]).then(([graph]) => graph) ); const ControlledPlotlyGraph = memo(props => { @@ -266,6 +275,11 @@ PlotlyGraph.propTypes = { */ className: PropTypes.string, + /** + * If true, loads mathjax v3 (tex-svg) into the page and use it in the graph + */ + mathjax: PropTypes.bool, + /** * Beta: If true, animate between updates using * plotly.js's `animate` function @@ -574,6 +588,7 @@ PlotlyGraph.defaultProps = { frames: [], }, responsive: 'auto', + mathjax: false, animate: false, animation_options: { frame: { diff --git a/components/dash-core-components/src/components/Markdown.react.js b/components/dash-core-components/src/components/Markdown.react.js index 0512b67ac5..9f98405158 100644 --- a/components/dash-core-components/src/components/Markdown.react.js +++ b/components/dash-core-components/src/components/Markdown.react.js @@ -1,8 +1,8 @@ +import {asyncDecorator} from '@plotly/dash-component-plugins'; import PropTypes from 'prop-types'; -import React, {Component, lazy, Suspense} from 'react'; +import React, {Component, Suspense} from 'react'; import markdown from '../utils/LazyLoader/markdown'; - -const RealDashMarkdown = lazy(markdown); +import lazyLoadMathJax from '../utils/LazyLoader/mathjax'; // eslint-disable-next-line valid-jsdoc /** @@ -11,6 +11,14 @@ const RealDashMarkdown = lazy(markdown); * [react-markdown](https://rexxars.github.io/react-markdown/) under the hood. */ export default class DashMarkdown extends Component { + constructor(props) { + super(props); + + if (props.mathjax) { + DashMarkdown._loadMathjax = true; + } + } + render() { return ( @@ -32,6 +40,11 @@ DashMarkdown.propTypes = { */ className: PropTypes.string, + /** + * If true, loads mathjax v3 (tex-svg) into the page and use it in the markdown + */ + mathjax: PropTypes.bool, + /** * A boolean to control raw HTML escaping. * Setting HTML from code is risky because it's easy to @@ -91,10 +104,18 @@ DashMarkdown.propTypes = { }; DashMarkdown.defaultProps = { + mathjax: false, dangerously_allow_html: false, highlight_config: {}, dedent: true, }; +const RealDashMarkdown = asyncDecorator(DashMarkdown, () => + Promise.all([ + markdown(), + DashMarkdown._loadMathjax ? lazyLoadMathJax() : undefined, + ]).then(([md]) => md) +); + export const propTypes = DashMarkdown.propTypes; export const defaultProps = DashMarkdown.defaultProps; diff --git a/components/dash-core-components/src/fragments/Graph.react.js b/components/dash-core-components/src/fragments/Graph.react.js index 431e69f3e5..f249c23f32 100644 --- a/components/dash-core-components/src/fragments/Graph.react.js +++ b/components/dash-core-components/src/fragments/Graph.react.js @@ -1,3 +1,4 @@ +import lazyLoadMathJax from '../utils/LazyLoader/mathjax'; import React, {Component} from 'react'; // /build/withPolyfill for IE11 support - https://github.com/maslianok/react-resize-detector/issues/144 import ResizeDetector from 'react-resize-detector/build/withPolyfill'; @@ -144,17 +145,21 @@ class PlotlyGraph extends Component { plot(props) { let {figure, config} = props; - const {animate, animation_options, responsive} = props; + const {animate, animation_options, responsive, mathjax} = props; const gd = this.gd.current; figure = props._dashprivate_transformFigure(figure, gd); config = props._dashprivate_transformConfig(config, gd); + const configClone = this.getConfig(config, responsive); + // add typesetMath | not exposed to the dash API + configClone.typesetMath = mathjax; + const figureClone = { data: figure.data, layout: this.getLayout(figure.layout, responsive), frames: figure.frames, - config: this.getConfig(config, responsive), + config: configClone, }; if ( @@ -176,38 +181,45 @@ class PlotlyGraph extends Component { gd.classList.add('dash-graph--pending'); - return Plotly.react(gd, figureClone).then(() => { - const gd = this.gd.current; - - // double-check gd hasn't been unmounted - if (!gd) { - return; - } + return lazyLoadMathJax(mathjax) + .then(() => { + const gd = this.gd.current; + return gd && Plotly.react(gd, figureClone); + }) + .then(() => { + const gd = this.gd.current; + + // double-check gd hasn't been unmounted + if (!gd) { + return; + } - gd.classList.remove('dash-graph--pending'); + gd.classList.remove('dash-graph--pending'); - // in case we've made a new DOM element, transfer events - if (this._hasPlotted && gd !== this._prevGd) { - if (this._prevGd && this._prevGd.removeAllListeners) { - this._prevGd.removeAllListeners(); - Plotly.purge(this._prevGd); + // in case we've made a new DOM element, transfer events + if (this._hasPlotted && gd !== this._prevGd) { + if (this._prevGd && this._prevGd.removeAllListeners) { + this._prevGd.removeAllListeners(); + Plotly.purge(this._prevGd); + } + this._hasPlotted = false; } - this._hasPlotted = false; - } - if (!this._hasPlotted) { - this.bindEvents(); - this.graphResize(true); - this._hasPlotted = true; - this._prevGd = gd; - } - }); + if (!this._hasPlotted) { + this.bindEvents(); + this.graphResize(true); + this._hasPlotted = true; + this._prevGd = gd; + } + }); } mergeTraces(props, dataKey, plotlyFnKey) { const clearState = props.clearState; const dataArray = props[dataKey]; + let p = Promise.resolve(); + dataArray.forEach(data => { let updateData, traceIndices, maxPoints; if (Array.isArray(data) && typeof data[0] === 'object') { @@ -227,11 +239,16 @@ class PlotlyGraph extends Component { traceIndices = generateIndices(updateData); } - const gd = this.gd.current; - return Plotly[plotlyFnKey](gd, updateData, traceIndices, maxPoints); + p = p.then(() => { + const gd = this.gd.current; + return ( + gd && + Plotly[plotlyFnKey](gd, updateData, traceIndices, maxPoints) + ); + }); }); - clearState(dataKey); + p.then(() => clearState(dataKey)); } getConfig(config, responsive) { @@ -383,16 +400,22 @@ class PlotlyGraph extends Component { } componentDidMount() { - this.plot(this.props); + let p = this.plot(this.props); if (this.props.prependData) { - this.mergeTraces(this.props, 'prependData', 'prependTraces'); + p = p.then(() => + this.mergeTraces(this.props, 'prependData', 'prependTraces') + ); } if (this.props.extendData) { - this.mergeTraces(this.props, 'extendData', 'extendTraces'); + p = p.then(() => + this.mergeTraces(this.props, 'extendData', 'extendTraces') + ); } if (this.props.prependData?.length || this.props.extendData?.length) { - this.props._dashprivate_onFigureModified(this.props.figure); + p.then(() => + this.props._dashprivate_onFigureModified(this.props.figure) + ); } } @@ -425,31 +448,43 @@ class PlotlyGraph extends Component { */ return; } + + let p = Promise.resolve(); if ( + this.props.mathjax !== nextProps.mathjax || this.props.figure !== nextProps.figure || this.props._dashprivate_transformConfig !== nextProps._dashprivate_transformConfig || this.props._dashprivate_transformFigure !== nextProps._dashprivate_transformFigure ) { - this.plot(nextProps); + p = this.plot(nextProps); } if (this.props.prependData !== nextProps.prependData) { - this.mergeTraces(nextProps, 'prependData', 'prependTraces'); + p = p.then(() => + this.mergeTraces(nextProps, 'prependData', 'prependTraces') + ); } if (this.props.extendData !== nextProps.extendData) { - this.mergeTraces(nextProps, 'extendData', 'extendTraces'); + p = p.then(() => + this.mergeTraces(nextProps, 'extendData', 'extendTraces') + ); } if (this.props.prependData?.length || this.props.extendData?.length) { - this.props._dashprivate_onFigureModified(this.props.figure); + p.then(() => + this.props._dashprivate_onFigureModified(this.props.figure) + ); } } componentDidUpdate(prevProps) { - if (prevProps.id !== this.props.id) { + if ( + prevProps.id !== this.props.id || + prevProps.mathjax !== this.props.mathjax + ) { this.plot(this.props); } } diff --git a/components/dash-core-components/src/fragments/Markdown.react.js b/components/dash-core-components/src/fragments/Markdown.react.js index ebb000d0e3..eff440aa1f 100644 --- a/components/dash-core-components/src/fragments/Markdown.react.js +++ b/components/dash-core-components/src/fragments/Markdown.react.js @@ -2,7 +2,9 @@ import React, {Component} from 'react'; import {mergeDeepRight, pick, type} from 'ramda'; import JsxParser from 'react-jsx-parser'; import Markdown from 'react-markdown'; +import RemarkMath from 'remark-math'; +import Math from './Math.react'; import MarkdownHighlighter from '../utils/MarkdownHighlighter'; import {propTypes, defaultProps} from '../components/Markdown.react'; @@ -85,6 +87,7 @@ export default class DashMarkdown extends Component { highlight_config, loading_state, dangerously_allow_html, + mathjax, children, dedent, } = this.props; @@ -131,7 +134,16 @@ export default class DashMarkdown extends Component { ( + + ), + + inlineMath: props => ( + + ), + html: props => props.escapeHtml ? ( props.value diff --git a/components/dash-core-components/src/fragments/Math.react.js b/components/dash-core-components/src/fragments/Math.react.js new file mode 100644 index 0000000000..989cb78e12 --- /dev/null +++ b/components/dash-core-components/src/fragments/Math.react.js @@ -0,0 +1,54 @@ +import PropTypes from 'prop-types'; +import React, {Component} from 'react'; + +import lazyLoadMathJax from '../utils/LazyLoader/mathjax'; + +export default class DashMath extends Component { + constructor(props) { + super(props); + this.span_element = React.createRef(); + } + + componentDidMount() { + this.renderMath(); + } + + componentDidUpdate(prevProps) { + if ( + prevProps.tex !== this.props.tex || + prevProps.inline !== this.props.inline + ) { + this.renderMath(); + } + } + + renderMath() { + const current = this.span_element.current; + lazyLoadMathJax().then(function () { + window.MathJax.typeset([current]); + }); + } + + render() { + return ( + + {this.props.inline ? '\\(' : '\\['} + {this.props.tex} + {this.props.inline ? '\\)' : '\\]'} + + ); + } +} + +DashMath.propTypes = { + tex: PropTypes.string, + inline: PropTypes.bool, +}; + +DashMath.defaultProps = { + tex: '', + inline: true, +}; + +export const propTypes = DashMath.propTypes; +export const defaultProps = DashMath.defaultProps; diff --git a/components/dash-core-components/src/utils/LazyLoader/mathjax.js b/components/dash-core-components/src/utils/LazyLoader/mathjax.js new file mode 100644 index 0000000000..f0160df51a --- /dev/null +++ b/components/dash-core-components/src/utils/LazyLoader/mathjax.js @@ -0,0 +1,5 @@ +export default (mathjax) => Promise.resolve(window.MathJax || ( + mathjax === false ? + undefined : + import(/* webpackChunkName: "mathjax" */ '../mathjax').then(() => window.MathJax) +)); diff --git a/components/dash-core-components/src/utils/mathjax.js b/components/dash-core-components/src/utils/mathjax.js new file mode 100644 index 0000000000..347a34513c --- /dev/null +++ b/components/dash-core-components/src/utils/mathjax.js @@ -0,0 +1,3 @@ +import 'mathjax/es5/tex-svg'; + +window.MathJax.config.startup.typeset = false; diff --git a/components/dash-core-components/tests/integration/graph/test_graph_varia.py b/components/dash-core-components/tests/integration/graph/test_graph_varia.py index 83e975f7bf..885635632f 100644 --- a/components/dash-core-components/tests/integration/graph/test_graph_varia.py +++ b/components/dash-core-components/tests/integration/graph/test_graph_varia.py @@ -9,18 +9,6 @@ from selenium.webdriver.support import expected_conditions as EC -def findSyncPlotlyJs(scripts): - for script in scripts: - if "dash_core_components/plotly-" in script.get_attribute("src"): - return script - - -def findAsyncPlotlyJs(scripts): - for script in scripts: - if "dash_core_components/async-plotlyjs" in script.get_attribute("src"): - return script - - @pytest.mark.parametrize("is_eager", [True, False]) def test_grva001_candlestick(dash_dcc, is_eager): app = Dash(__name__, eager_loading=is_eager) @@ -87,7 +75,7 @@ def test_grva002_graphs_with_different_figures(dash_dcc, is_eager): "x": [1, 2, 3], "y": [2, 4, 5], "type": "bar", - "name": u"Montréal", + "name": "Montréal", }, ], "layout": {"title": "Dash Data Visualization"}, @@ -107,7 +95,7 @@ def test_grva002_graphs_with_different_figures(dash_dcc, is_eager): "x": [11, 22, 33], "y": [22, 44, 55], "type": "bar", - "name": u"Montréal", + "name": "Montréal", }, ], "layout": {"title": "Dash Data Visualization"}, @@ -567,7 +555,7 @@ def test_grva006_unmounted_graph_resize(dash_dcc, is_eager): "x": [1, 2, 3], "y": [2, 4, 5], "type": "scattergl", - "name": u"Montréal", + "name": "Montréal", }, ] }, @@ -594,7 +582,7 @@ def test_grva006_unmounted_graph_resize(dash_dcc, is_eager): "x": [1, 2, 3], "y": [1, 2, 3], "type": "scattergl", - "name": u"Montréal", + "name": "Montréal", }, ] }, @@ -637,11 +625,14 @@ def test_grva006_unmounted_graph_resize(dash_dcc, is_eager): assert dash_dcc.get_logs() == [] -def test_grva007_external_plotlyjs_prevents_lazy(dash_dcc): +@pytest.mark.parametrize("is_eager", [False, True]) +def test_grva007_external_plotlyjs_prevents_lazy(is_eager, dash_dcc): + # specific plotly.js version that's older than the built-in version + v = "2.8.1" app = Dash( __name__, - eager_loading=False, - external_scripts=["https://unpkg.com/plotly.js-dist-min/plotly.min.js"], + eager_loading=is_eager, + external_scripts=[f"https://unpkg.com/plotly.js-dist-min@{v}/plotly.min.js"], ) app.layout = html.Div(id="div", children=[html.Button(id="btn")]) @@ -664,18 +655,22 @@ def load_chart(n_clicks): # Give time for the async dependency to be requested (if any) time.sleep(2) - scripts = dash_dcc.driver.find_elements(By.CSS_SELECTOR, "script") - assert findSyncPlotlyJs(scripts) is None - assert findAsyncPlotlyJs(scripts) is None + v_loaded = dash_dcc.driver.execute_script("return Plotly.version") + + # TODO: in eager mode, built-in plotly.js wins!! I don't think this is what we want. + # But need to look into why we use the bare plotly.js bundle in eager mode, rather + # than simply preloading the regular async chunk. + if not is_eager: + # as loaded in external_scripts + assert v_loaded == v dash_dcc.find_element("#btn").click() # Give time for the async dependency to be requested (if any) time.sleep(2) - scripts = dash_dcc.driver.find_elements(By.CSS_SELECTOR, "script") - assert findSyncPlotlyJs(scripts) is None - assert findAsyncPlotlyJs(scripts) is None + # Check that the originally-loaded version is still the one we have + assert dash_dcc.driver.execute_script("return Plotly.version") == v_loaded assert dash_dcc.get_logs() == [] @@ -847,3 +842,203 @@ def graph_dims(): assert graph_dims() == responsive_size assert dash_dcc.get_logs() == [] + + +def test_grva010_external_mathjax_prevents_lazy(dash_dcc): + # specific MathJax version that's older than the built-in version + v = "3.1.4" + app = Dash( + __name__, + eager_loading=False, + external_scripts=[f"https://cdn.jsdelivr.net/npm/mathjax@{v}/es5/tex-svg.js"], + ) + + app.layout = html.Div(id="div", children=[html.Button(id="btn")]) + + @app.callback(Output("div", "children"), [Input("btn", "n_clicks")]) + def load_chart(n_clicks): + if n_clicks is None: + raise PreventUpdate + + return dcc.Graph( + mathjax=True, + id="output", + figure={ + "data": [{"y": [3, 1, 2]}], + "layout": {"title": {"text": "$E=mc^2$"}}, + }, + ) + + dash_dcc.start_server( + app, + debug=True, + use_reloader=False, + use_debugger=True, + dev_tools_hot_reload=False, + ) + + # Give time for the async dependency to be requested (if any) + dash_dcc.wait_for_element("button#btn") + + # even in eager mode (when the async bundle is preloaded) we keep the + # external version, which seems to be a pleasant effect of how + # webpack bundles these chunks! + assert dash_dcc.driver.execute_script("return MathJax.version") == v + + dash_dcc.find_element("#btn").click() + dash_dcc.wait_for_element(".gtitle-math") + + # We still have the external version, not the built-in one + assert dash_dcc.driver.execute_script("return MathJax.version") == v + + assert dash_dcc.get_logs() == [] + + +@pytest.mark.parametrize("is_eager", [True, False]) +def test_grva011_without_mathjax(dash_dcc, is_eager): + app = Dash(__name__, eager_loading=is_eager, assets_folder="../../assets") + + app.layout = html.Div( + [ + dcc.Graph( + id="output", + figure={ + "data": [{"y": [3, 1, 2]}], + "layout": {"title": {"text": "Apple: $2, Orange: $3"}}, + }, + ) + ] + ) + + dash_dcc.start_server(app) + assert dash_dcc.wait_for_element(".gtitle").text == "Apple: $2, Orange: $3" + + assert not dash_dcc.driver.execute_script("return !!window.MathJax") + + assert dash_dcc.get_logs() == [] + + +@pytest.mark.parametrize("is_eager", [True, False]) +def test_grva012_with_mathjax(dash_dcc, is_eager): + app = Dash(__name__, eager_loading=is_eager, assets_folder="../../assets") + + app.layout = html.Div( + [ + dcc.Graph( + mathjax=True, + id="output", + figure={ + "data": [{"y": [3, 1, 2]}], + "layout": {"title": {"text": "Equation: $E=mc^2$"}}, + }, + ) + ] + ) + + dash_dcc.start_server(app) + dash_dcc.wait_for_element(".gtitle-math") + + assert dash_dcc.driver.execute_script("return !!window.MathJax") + + assert dash_dcc.get_logs() == [] + + +@pytest.mark.parametrize("is_eager", [True, False]) +def test_grva013_toggle_mathjax(dash_dcc, is_eager): + app = Dash(__name__, eager_loading=is_eager) + + gravity = "$F=\\frac{Gm_1m_2}{r^2}$" + + app.layout = html.Div( + [ + html.Button("Toggle MathJax", id="btn"), + dcc.Graph( + id="gd", + figure={ + "data": [{"y": [3, 1, 2]}], + "layout": {"title": {"text": gravity}}, + }, + ), + ] + ) + + @app.callback( + Output("gd", "mathjax"), Input("btn", "n_clicks"), prevent_initial_call=True + ) + def toggle(n): + return (n or 0) % 2 != 0 + + dash_dcc.start_server(app) + + # Initial state: no MathJax loaded or rendered, unformatted text is shown + dash_dcc.wait_for_contains_text(".gtitle", gravity) + + # Note: in eager mode, the async-mathjax bundle IS loaded, but it seems like + # it isn't executed until we ask for MathJax with import() + assert not dash_dcc.driver.execute_script("return !!window.MathJax") + + btn = dash_dcc.find_element("#btn") + btn.click() + + # One click: MathJax is rendered, unformatted text is gone + + dash_dcc.wait_for_element(".gtitle-math") + assert dash_dcc.driver.execute_script("return !!window.MathJax") + + btn.click() + + # Second click: Back to initial state except that MathJax library is still loaded + dash_dcc.wait_for_contains_text(".gtitle", gravity) + dash_dcc.wait_for_no_elements(".gtitle-math") + assert dash_dcc.driver.execute_script("return !!window.MathJax") + + +@pytest.mark.parametrize("is_eager", [True, False]) +def test_grva014_load_mathjax(dash_dcc, is_eager): + app = Dash(__name__, eager_loading=is_eager) + + gravity = "$F=\\frac{Gm_1m_2}{r^2}$" + + app.layout = html.Div( + [ + html.Button("Add Second MathJax", id="btn"), + dcc.Graph( + mathjax=False, + id="gd", + figure={ + "data": [{"y": [3, 1, 2]}], + "layout": {"title": {"text": gravity}}, + }, + ), + html.Div("initial", id="out"), + ] + ) + + @app.callback( + Output("out", "children"), Input("btn", "n_clicks"), prevent_initial_call=True + ) + def add_math(n): + return dcc.Graph( + mathjax=True, + id="gd2", + figure={ + "data": [{"y": [3, 1, 2]}], + "layout": {"title": {"text": gravity}}, + }, + ) + + dash_dcc.start_server(app) + + # Initial state: no MathJax loaded or rendered, unformatted text is shown + dash_dcc.wait_for_contains_text("#gd .gtitle", gravity) + dash_dcc.wait_for_no_elements("#gd .gtitle-math") + assert not dash_dcc.driver.execute_script("return !!window.MathJax") + + btn = dash_dcc.find_element("#btn") + btn.click() + + # One click: MathJax is loaded and rendered on the second, unformatted text is gone + + dash_dcc.wait_for_element("#gd2 .gtitle-math") + assert gravity not in dash_dcc._get_element("#gd2 .gtitle").text + assert dash_dcc.driver.execute_script("return !!window.MathJax") diff --git a/components/dash-core-components/tests/integration/markdown/test_markdown.py b/components/dash-core-components/tests/integration/markdown/test_markdown.py index 0f33ab38a2..a0cbf61c2a 100644 --- a/components/dash-core-components/tests/integration/markdown/test_markdown.py +++ b/components/dash-core-components/tests/integration/markdown/test_markdown.py @@ -1,4 +1,5 @@ -from dash import Dash, dcc, html +import pytest +from dash import Dash, dcc, html, Input, Output def test_mkdw001_img(dash_dcc): @@ -90,6 +91,286 @@ def test_mkdw002_dcclink(dash_dcc): ) dash_dcc.start_server(app) - dash_dcc.percy_snapshot("mkdw002 - markdowns display") + assert dash_dcc.get_logs() == [] + + +@pytest.mark.parametrize("is_eager", [True, False]) +def test_mkdw003_without_mathjax(dash_dcc, is_eager): + app = Dash(__name__, eager_loading=is_eager) + + app.layout = html.Div( + [ + dcc.Markdown("# No MathJax: Apple: $2, Orange: $3"), + ] + ) + + dash_dcc.start_server(app) + dash_dcc.wait_for_text_to_equal("h1", "No MathJax: Apple: $2, Orange: $3") + assert not dash_dcc.driver.execute_script("return !!window.MathJax") + assert dash_dcc.get_logs() == [] + + +@pytest.mark.parametrize("is_eager", [True, False]) +def test_mkdw004_inline_mathjax(dash_dcc, is_eager): + app = Dash(__name__, eager_loading=is_eager, assets_folder="../../assets") + + app.layout = html.Div( + [ + dcc.Markdown("# h1 tag with inline MathJax: $E=mc^2$", mathjax=True), + ] + ) + + dash_dcc.start_server(app) + dash_dcc.wait_for_element("h1 svg") + assert dash_dcc.get_logs() == [] + + +@pytest.mark.parametrize("is_eager", [True, False]) +def test_mkdw005_block_mathjax(dash_dcc, is_eager): + app = Dash(__name__, eager_loading=is_eager, assets_folder="../../assets") + + app.layout = html.Div( + [ + dcc.Markdown( + """ + ## h2 tag with MathJax block: + $$ + \\frac{1}{(\\sqrt{\\phi \\sqrt{5}}-\\phi) e^{\\frac25 \\pi}} = + 1+\\frac{e^{-2\\pi}} {1+\\frac{e^{-4\\pi}} {1+\\frac{e^{-6\\pi}} + {1+\\frac{e^{-8\\pi}} {1+\\ldots} } } } + $$ + ## Next line. + """, + mathjax=True, + id="md", + ), + ] + ) + + dash_dcc.start_server(app) + dash_dcc.wait_for_element("#md svg") + assert dash_dcc.get_logs() == [] + + +@pytest.mark.parametrize("is_eager", [True, False]) +def test_mkdw006_toggle_mathjax(dash_dcc, is_eager): + app = Dash(__name__, eager_loading=is_eager) + + gravity = "$F=\\frac{Gm_1m_2}{r^2}$" + + app.layout = html.Div( + [ + html.Button("Toggle MathJax", id="btn"), + dcc.Markdown( + f""" + # Test MathJax Toggling {gravity} + """, + id="md", + ), + ] + ) + + @app.callback( + Output("md", "mathjax"), Input("btn", "n_clicks"), prevent_initial_call=True + ) + def toggle(n): + return (n or 0) % 2 != 0 + + dash_dcc.start_server(app) + + # Initial state: no MathJax loaded or rendered, unformatted text is shown + dash_dcc.wait_for_contains_text("#md", gravity) + dash_dcc.wait_for_no_elements("#md svg") + assert not dash_dcc.driver.execute_script("return !!window.MathJax") + + btn = dash_dcc.find_element("#btn") + btn.click() + + # One click: MathJax is rendered, unformatted text is gone + + dash_dcc.wait_for_element("#md svg") + assert gravity not in dash_dcc._get_element("#md").text + assert dash_dcc.driver.execute_script("return !!window.MathJax") + + btn.click() + + # Second click: Back to initial state except that MathJax library is still loaded + dash_dcc.wait_for_contains_text("#md", gravity) + dash_dcc.wait_for_no_elements("#md svg") + assert dash_dcc.driver.execute_script("return !!window.MathJax") + + +@pytest.mark.parametrize("is_eager", [True, False]) +def test_mkdw007_load_mathjax(dash_dcc, is_eager): + app = Dash(__name__, eager_loading=is_eager) + + gravity = "$F=\\frac{Gm_1m_2}{r^2}$" + + app.layout = html.Div( + [ + html.Button("Add Second MathJax", id="btn"), + dcc.Markdown( + f""" + # No Math Rendering Here! {gravity} + """, + id="md", + mathjax=False, + ), + html.Div("initial", id="out"), + ] + ) + + @app.callback( + Output("out", "children"), Input("btn", "n_clicks"), prevent_initial_call=True + ) + def add_math(n): + return dcc.Markdown(f"# Math!\n{gravity}", id="md2", mathjax=True) + + dash_dcc.start_server(app) + + # Initial state: no MathJax loaded or rendered, unformatted text is shown + dash_dcc.wait_for_contains_text("#md", gravity) + dash_dcc.wait_for_no_elements("#md svg") + assert not dash_dcc.driver.execute_script("return !!window.MathJax") + + btn = dash_dcc.find_element("#btn") + btn.click() + + # One click: MathJax is loaded and rendered on the second, unformatted text is gone + + dash_dcc.wait_for_element("#md2 svg") + assert gravity not in dash_dcc._get_element("#md2").text + assert dash_dcc.driver.execute_script("return !!window.MathJax") + + +def test_mkdw008_mathjax_visual(dash_dcc): + app = Dash(__name__, assets_folder="../../assets") + + false = False + + # json + fig = { + "data": [ + {"x": [0, 1], "y": [0, 1.414], "name": "$E^2=m^2c^4+p^2c^2$"}, + { + "x": [0, 1], + "y": [1.4, 0.1], + "type": "bar", + "name": "$x=\\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}$", + }, + { + "type": "pie", + "values": [1, 9], + "labels": ["$\\frac{1}{10}=10\\%$", "$\\frac{9}{10}=90\\%$"], + "domain": {"x": [0.3, 0.75], "y": [0.55, 1]}, + }, + { + "type": "heatmap", + "z": [[1, 2], [3, 4]], + "xaxis": "x2", + "yaxis": "y2", + "colorbar": {"y": 0.225, "len": 0.45}, + }, + ], + "layout": { + "yaxis": {"domain": [0, 0.45], "title": {"text": "$y=\\sin{2 \\theta}$"}}, + "xaxis": { + "domain": [0, 0.45], + "title": {"text": "$x=\\int_0^a a^2+1$"}, + "tickvals": [0, 1], + "ticktext": ["$\\frac{0}{100}$", "$\\frac{100}{100}$"], + }, + "xaxis2": {"domain": [0.85, 1], "anchor": "y2"}, + "yaxis2": { + "domain": [0, 0.45], + "anchor": "x2", + "title": {"text": "$(||01\\rangle+|10\\rangle)/\\sqrt2$"}, + }, + "height": 500, + "width": 800, + "margin": {"r": 250}, + "title": { + "text": "$i\\hbar\\frac{d\\Psi}{dt}=-[V-\\frac{-\\hbar^2}{2m}\\nabla^2]\\Psi$" + }, + "annotations": [ + { + "text": "$(top,left)$", + "showarrow": false, + "xref": "paper", + "yref": "paper", + "xanchor": "left", + "yanchor": "top", + "x": 0, + "y": 1, + "textangle": 10, + "bordercolor": "#0c0", + "borderpad": 3, + "bgcolor": "#dfd", + }, + { + "text": "$(right,bottom)$", + "xref": "paper", + "yref": "paper", + "xanchor": "right", + "yanchor": "bottom", + "x": 0.2, + "y": 0.7, + "ax": -20, + "ay": -20, + "textangle": -30, + "bordercolor": "#0c0", + "borderpad": 3, + "bgcolor": "#dfd", + "opacity": 0.5, + }, + {"text": "$not-visible$", "visible": false}, + { + "text": "$^{29}Si$", + "x": 0.7, + "y": 0.7, + "showarrow": false, + "xanchor": "right", + "yanchor": "top", + }, + { + "text": "$^{17}O$", + "x": 0.7, + "y": 0.7, + "ax": 15, + "ay": -15, + "xanchor": "left", + "yanchor": "bottom", + }, + ], + }, + } + + app.layout = html.Div( + children=[ + dcc.Markdown("# h1 tag with inline MathJax: $E=mc^2$", mathjax=True), + dcc.Markdown( + """ + ## h2 tag with MathJax block: + $$ + \\frac{1}{(\\sqrt{\\phi \\sqrt{5}}-\\phi) e^{\\frac25 \\pi}} = + 1+\\frac{e^{-2\\pi}} {1+\\frac{e^{-4\\pi}} {1+\\frac{e^{-6\\pi}} + {1+\\frac{e^{-8\\pi}} {1+\\ldots} } } } + $$ + ## Next line. + """, + mathjax=True, + ), + dcc.Graph(mathjax=True, id="graph-with-math", figure=fig), + dcc.Markdown("### No MathJax: Apple: $2, Orange: $3"), + dcc.Graph(id="graph-without-math", figure=fig), + ] + ) + + dash_dcc.start_server(app) + dash_dcc.find_element("h1 svg") + dash_dcc.find_element("#graph-with-math svg") + assert dash_dcc.driver.execute_script("return !!window.MathJax") + + dash_dcc.percy_snapshot("mkdw008 - markdown and graph with/without mathjax") assert dash_dcc.get_logs() == [] diff --git a/components/dash-core-components/tests/integration/tab/test_tabs_with_graphs.py b/components/dash-core-components/tests/integration/tab/test_tabs_with_graphs.py index 36e91d353d..ffe456fb4d 100644 --- a/components/dash-core-components/tests/integration/tab/test_tabs_with_graphs.py +++ b/components/dash-core-components/tests/integration/tab/test_tabs_with_graphs.py @@ -211,6 +211,7 @@ def check_graph_config_shape(dash_dcc): "globalTransforms", "notifyOnLogging", "role", + "typesetMath", ] def crawl(schema, props): diff --git a/components/dash-core-components/webpack.config.js b/components/dash-core-components/webpack.config.js index 8fb84601d3..0f3b0c69fe 100644 --- a/components/dash-core-components/webpack.config.js +++ b/components/dash-core-components/webpack.config.js @@ -65,7 +65,7 @@ module.exports = (env, argv) => { }, { test: /\.jsx?$/, - include: /node_modules[\\\/](react-jsx-parser|highlight[.]js|react-markdown|is-plain-obj|color)[\\\/]/, + include: /node_modules[\\\/](react-jsx-parser|highlight[.]js|react-markdown|remark-math|is-plain-obj|color)[\\\/]/, use: { loader: 'babel-loader', options: { @@ -134,7 +134,7 @@ module.exports = (env, argv) => { new WebpackDashDynamicImport(), new webpack.SourceMapDevToolPlugin({ filename: '[file].map', - exclude: ['async-plotlyjs'] + exclude: ['async-plotlyjs', 'async-mathjax'] }), new NodePolyfillPlugin() ] diff --git a/dash/development/update_components.py b/dash/development/update_components.py index 26c0170c76..0235040c64 100644 --- a/dash/development/update_components.py +++ b/dash/development/update_components.py @@ -20,7 +20,7 @@ class _CombinedFormatter( ) -def booststrap_components(components_source): +def bootstrap_components(components_source, concurrency): is_windows = sys.platform == "win32" @@ -30,10 +30,9 @@ def booststrap_components(components_source): else "dash-core-components|dash-html-components|dash-table" ) - cmd = shlex.split( - "npx lerna exec --scope *@({})* -- npm i".format(source_glob), - posix=not is_windows, - ) + cmdstr = f"npx lerna exec --concurrency {concurrency} --scope *@({source_glob})* -- npm i" + cmd = shlex.split(cmdstr, posix=not is_windows) + print(cmdstr) with subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=is_windows @@ -57,7 +56,7 @@ def booststrap_components(components_source): ) -def build_components(components_source): +def build_components(components_source, concurrency): is_windows = sys.platform == "win32" @@ -67,10 +66,9 @@ def build_components(components_source): else "dash-core-components|dash-html-components|dash-table" ) - cmd = shlex.split( - "npx lerna exec --scope *@({})* -- npm run build".format(source_glob), - posix=not is_windows, - ) + cmdstr = f"npx lerna exec --concurrency {concurrency} --scope *@({source_glob})* -- npm run build" + cmd = shlex.split(cmdstr, posix=not is_windows) + print(cmdstr) with subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=is_windows @@ -140,11 +138,17 @@ def cli(): " The default argument is 'all'.", default="all", ) + parser.add_argument( + "--concurrency", + type=int, + default=3, + help="Maximum concurrent steps, up to 3 (ie all components in parallel)", + ) args = parser.parse_args() - booststrap_components(args.components_source) - build_components(args.components_source) + bootstrap_components(args.components_source, args.concurrency) + build_components(args.components_source, args.concurrency) if __name__ == "__main__": diff --git a/package.json b/package.json index 8e3ef4875e..705585d1b6 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "private::test.integration-dash": "TESTFILES=$(circleci tests glob \"tests/integration/**/test_*.py\" | circleci tests split --split-by=timings) && pytest --headless --nopercyfinalize --junitxml=test-reports/junit_intg.xml ${TESTFILES}", "private::test.integration-dash-import": "cd tests/integration/dash && python dash_import_test.py", "build": "run-s private::build.*", + "build.sequential": "npm run private::build.renderer && npm run private::build.components -- --concurrency 1", "format": "run-s private::format.*", "initialize": "run-s private::initialize.*", "prepare": "husky install",